Skip to content

Commit 1150789

Browse files
authored
feat: add Amplitude error logging for CLI commands (#892)
1 parent 5110356 commit 1150789

File tree

3 files changed

+164
-72
lines changed

3 files changed

+164
-72
lines changed

packages/snap/src/cli.ts

Lines changed: 88 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { program } from 'commander'
44
import './cloud'
55
import { handler } from './cloud/config-utils'
6+
import { wrapAction } from './utils/analytics'
67
import { version } from './version'
78

89
const defaultPort = 3000
@@ -58,20 +59,24 @@ program
5859
program
5960
.command('generate-types')
6061
.description('Generate types.d.ts file for your project')
61-
.action(async () => {
62-
const { generateTypes } = require('./generate-types')
63-
await generateTypes(process.cwd())
64-
process.exit(0)
65-
})
62+
.action(
63+
wrapAction(async () => {
64+
const { generateTypes } = require('./generate-types')
65+
await generateTypes(process.cwd())
66+
process.exit(0)
67+
}),
68+
)
6669

6770
program
6871
.command('install')
6972
.description('Sets up Python virtual environment and install dependencies')
7073
.option('-v, --verbose', 'Enable verbose logging')
71-
.action(async (options) => {
72-
const { install } = require('./install')
73-
await install({ isVerbose: options.verbose })
74-
})
74+
.action(
75+
wrapAction(async (options) => {
76+
const { install } = require('./install')
77+
await install({ isVerbose: options.verbose })
78+
}),
79+
)
7580

7681
program
7782
.command('dev')
@@ -82,17 +87,19 @@ program
8287
.option('-d, --debug', 'Enable debug logging')
8388
.option('-m, --mermaid', 'Enable mermaid diagram generation')
8489
.option('--motia-dir <path>', 'Path where .motia folder will be created')
85-
.action(async (arg) => {
86-
if (arg.debug) {
87-
console.log('🔍 Debug logging enabled')
88-
process.env.LOG_LEVEL = 'debug'
89-
}
90-
91-
const port = arg.port ? parseInt(arg.port) : defaultPort
92-
const host = arg.host ? arg.host : defaultHost
93-
const { dev } = require('./dev')
94-
await dev(port, host, arg.disableVerbose, arg.mermaid, arg.motiaDir)
95-
})
90+
.action(
91+
wrapAction(async (arg) => {
92+
if (arg.debug) {
93+
console.log('🔍 Debug logging enabled')
94+
process.env.LOG_LEVEL = 'debug'
95+
}
96+
97+
const port = arg.port ? parseInt(arg.port) : defaultPort
98+
const host = arg.host ? arg.host : defaultHost
99+
const { dev } = require('./dev')
100+
await dev(port, host, arg.disableVerbose, arg.mermaid, arg.motiaDir)
101+
}),
102+
)
96103

97104
program
98105
.command('start')
@@ -102,29 +109,31 @@ program
102109
.option('-v, --disable-verbose', 'Disable verbose logging')
103110
.option('-d, --debug', 'Enable debug logging')
104111
.option('--motia-dir <path>', 'Path where .motia folder will be created')
105-
.action(async (arg) => {
106-
if (arg.debug) {
107-
console.log('🔍 Debug logging enabled')
108-
process.env.LOG_LEVEL = 'debug'
109-
}
110-
111-
const port = arg.port ? parseInt(arg.port) : defaultPort
112-
const host = arg.host ? arg.host : defaultHost
113-
const { start } = require('./start')
114-
await start(port, host, arg.disableVerbose, arg.motiaDir)
115-
})
112+
.action(
113+
wrapAction(async (arg) => {
114+
if (arg.debug) {
115+
console.log('🔍 Debug logging enabled')
116+
process.env.LOG_LEVEL = 'debug'
117+
}
118+
119+
const port = arg.port ? parseInt(arg.port) : defaultPort
120+
const host = arg.host ? arg.host : defaultHost
121+
const { start } = require('./start')
122+
await start(port, host, arg.disableVerbose, arg.motiaDir)
123+
}),
124+
)
116125

117126
program
118127
.command('emit')
119128
.description('Emit an event to the Motia server')
120129
.requiredOption('--topic <topic>', 'Event topic/type to emit')
121130
.requiredOption('--message <message>', 'Event payload as JSON string')
122131
.option('-p, --port <number>', 'Port number (default: 3000)')
123-
.action(async (options) => {
124-
const port = options.port || 3000
125-
const url = `http://localhost:${port}/emit`
132+
.action(
133+
wrapAction(async (options) => {
134+
const port = options.port || 3000
135+
const url = `http://localhost:${port}/emit`
126136

127-
try {
128137
const response = await fetch(url, {
129138
method: 'POST',
130139
headers: { 'Content-Type': 'application/json' },
@@ -140,74 +149,83 @@ program
140149

141150
const result = await response.json()
142151
console.log('Event emitted successfully:', result)
143-
} catch (error) {
144-
console.error('Error:', error instanceof Error ? error.message : 'Unknown error')
145-
process.exit(1)
146-
}
147-
})
152+
}),
153+
)
148154

149155
const generate = program.command('generate').description('Generate motia resources')
150156

151157
generate
152158
.command('step')
153159
.description('Create a new step with interactive prompts')
154160
.option('-d, --dir <step file path>', 'The path relative to the steps directory, used to create the step file')
155-
.action(async (arg) => {
156-
const { createStep } = require('./create-step')
157-
await createStep({
158-
stepFilePath: arg.dir,
159-
})
160-
})
161+
.action(
162+
wrapAction(async (arg) => {
163+
const { createStep } = require('./create-step')
164+
await createStep({
165+
stepFilePath: arg.dir,
166+
})
167+
}),
168+
)
161169

162170
generate
163171
.command('openapi')
164172
.description('Generate OpenAPI spec for your project')
165173
.option('-t, --title <title>', 'Title for the OpenAPI document. Defaults to project name')
166174
.option('-v, --version <version>', 'Version for the OpenAPI document. Defaults to 1.0.0', '1.0.0')
167175
.option('-o, --output <output>', 'Output file for the OpenAPI document. Defaults to openapi.json', 'openapi.json')
168-
.action(async (options) => {
169-
const { generateLockedData } = require('./generate-locked-data')
170-
const { generateOpenApi } = require('./openapi/generate')
176+
.action(
177+
wrapAction(async (options) => {
178+
const { generateLockedData } = require('./generate-locked-data')
179+
const { generateOpenApi } = require('./openapi/generate')
171180

172-
const lockedData = await generateLockedData({ projectDir: process.cwd() })
173-
const apiSteps = lockedData.apiSteps()
181+
const lockedData = await generateLockedData({ projectDir: process.cwd() })
182+
const apiSteps = lockedData.apiSteps()
174183

175-
generateOpenApi(process.cwd(), apiSteps, options.title, options.version, options.output)
176-
process.exit(0)
177-
})
184+
generateOpenApi(process.cwd(), apiSteps, options.title, options.version, options.output)
185+
process.exit(0)
186+
}),
187+
)
178188

179189
const docker = program.command('docker').description('Motia docker commands')
180190

181191
docker
182192
.command('setup')
183193
.description('Setup a motia-docker for your project')
184-
.action(async () => {
185-
const { setup } = require('./docker/setup')
186-
await setup()
187-
process.exit(0)
188-
})
194+
.action(
195+
wrapAction(async () => {
196+
const { setup } = require('./docker/setup')
197+
await setup()
198+
process.exit(0)
199+
}),
200+
)
189201

190202
docker
191203
.command('run')
192204
.description('Build and run your project in a docker container')
193205
.option('-p, --port <port>', 'The port to run the server on', `${defaultPort}`)
194206
.option('-n, --project-name <project name>', 'The name for your project')
195207
.option('-s, --skip-build', 'Skip docker build')
196-
.action(async (arg) => {
197-
const { run } = require('./docker/run')
198-
await run(arg.port, arg.projectName, arg.skipBuild)
199-
process.exit(0)
200-
})
208+
.action(
209+
wrapAction(async (arg) => {
210+
const { run } = require('./docker/run')
211+
await run(arg.port, arg.projectName, arg.skipBuild)
212+
process.exit(0)
213+
}),
214+
)
201215

202216
docker
203217
.command('build')
204218
.description('Build your project in a docker container')
205219
.option('-n, --project-name <project name>', 'The name for your project')
206-
.action(async (arg) => {
207-
const { build } = require('./docker/build')
208-
await build(arg.projectName)
209-
process.exit(0)
210-
})
220+
.action(
221+
wrapAction(async (arg) => {
222+
const { build } = require('./docker/build')
223+
await build(arg.projectName)
224+
process.exit(0)
225+
}),
226+
)
211227

212228
program.version(version, '-V, --version', 'Output the current version')
213-
program.parse(process.argv)
229+
program.parseAsync(process.argv).catch(() => {
230+
process.exit(1)
231+
})

packages/snap/src/cloud/config-utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
1+
import { flush } from '@amplitude/analytics-node'
2+
import { logCliError } from '../utils/analytics'
13
import { CLIOutputManager, type Message } from './cli-output-manager'
24

5+
const getCommandName = (): string => {
6+
const args = process.argv.slice(2)
7+
const commandParts: string[] = []
8+
9+
for (let i = 0; i < args.length && i < 3; i++) {
10+
const arg = args[i]
11+
if (!arg.startsWith('-') && !arg.startsWith('--')) {
12+
commandParts.push(arg)
13+
} else {
14+
break
15+
}
16+
}
17+
18+
return commandParts.join(' ') || 'unknown'
19+
}
20+
321
export class CliContext {
422
private readonly output = new CLIOutputManager()
523

@@ -28,10 +46,15 @@ export type CliHandler = <TArgs extends Record<string, any>>(args: TArgs, contex
2846
export function handler(handler: CliHandler): (args: Record<string, any>) => Promise<void> {
2947
return async (args: Record<string, unknown>) => {
3048
const context = new CliContext()
49+
const commandName = getCommandName()
3150

3251
try {
3352
await handler(args, context)
3453
} catch (error: any) {
54+
logCliError(commandName, error)
55+
await flush().promise.catch(() => {
56+
// Silently fail
57+
})
3558
if (error instanceof Error) {
3659
context.log('error', (message) => message.tag('failed').append(error.message))
3760
context.exit(1)

packages/snap/src/utils/analytics.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { add, Identify, identify, init, setOptOut, Types } from '@amplitude/analytics-node'
2-
import { getUserIdentifier, isAnalyticsEnabled } from '@motiadev/core'
1+
import { add, flush, Identify, identify, init, setOptOut, Types } from '@amplitude/analytics-node'
2+
import { getUserIdentifier, isAnalyticsEnabled, trackEvent } from '@motiadev/core'
33
import { getProjectName } from '@motiadev/core/dist/src/analytics/utils'
44
import { version } from '../version'
55
import { MotiaEnrichmentPlugin } from './amplitude/enrichment-plugin'
@@ -38,3 +38,54 @@ export const identifyUser = () => {
3838
// Silently fail
3939
}
4040
}
41+
42+
export const logCliError = (command: string, error: unknown) => {
43+
try {
44+
const errorMessage = error instanceof Error ? error.message : String(error)
45+
const errorType = error instanceof Error ? error.constructor.name : 'Unknown'
46+
const errorStack =
47+
error instanceof Error && error.stack ? error.stack.split('\n').slice(0, 10).join('\n') : undefined
48+
49+
const truncatedMessage = errorMessage.length > 500 ? `${errorMessage.substring(0, 500)}...` : errorMessage
50+
51+
trackEvent('cli_command_error', {
52+
command,
53+
error_message: truncatedMessage,
54+
error_type: errorType,
55+
...(errorStack && { error_stack: errorStack }),
56+
})
57+
} catch (logError) {
58+
// Silently fail to not disrupt CLI operations
59+
}
60+
}
61+
62+
const getCommandNameFromArgs = (): string => {
63+
const args = process.argv.slice(2)
64+
const commandParts: string[] = []
65+
66+
for (let i = 0; i < args.length && i < 3; i++) {
67+
const arg = args[i]
68+
if (!arg.startsWith('-') && !arg.startsWith('--')) {
69+
commandParts.push(arg)
70+
} else {
71+
break
72+
}
73+
}
74+
75+
return commandParts.join(' ') || 'unknown'
76+
}
77+
78+
export const wrapAction = <T extends (...args: any[]) => Promise<any>>(action: T): T => {
79+
return (async (...args: any[]) => {
80+
try {
81+
return await action(...args)
82+
} catch (error) {
83+
const commandName = getCommandNameFromArgs()
84+
logCliError(commandName, error)
85+
await flush().promise.catch(() => {
86+
// Silently fail
87+
})
88+
throw error
89+
}
90+
}) as T
91+
}

0 commit comments

Comments
 (0)