diff --git a/integ-tests/dev-server.test.ts b/integ-tests/dev-server.test.ts index b1608f6c0..df07b7a42 100644 --- a/integ-tests/dev-server.test.ts +++ b/integ-tests/dev-server.test.ts @@ -88,7 +88,7 @@ describe('integration: dev server', () => { }); it.skipIf(!hasNpm || !hasGit || !hasUv)( - 'starts dev server and responds to health check', + 'starts dev server, responds to health check, and emits telemetry', async () => { expect(projectPath, 'Project should have been created').toBeTruthy(); @@ -98,12 +98,21 @@ describe('integration: dev server', () => { devProcess = spawn('node', [cliPath, 'dev', '--port', String(port), '--logs'], { cwd: projectPath, stdio: 'pipe', - env: { ...process.env, INIT_CWD: undefined }, + env: { ...process.env, INIT_CWD: undefined, ...telemetry.env }, }); const serverReady = await waitForServer(port, 20000); expect(serverReady, 'Dev server should respond to ping within 20s').toBeTruthy(); + // Verify telemetry was emitted for the server startup (before blocking) + telemetry.assertMetricEmitted({ + command: 'dev', + dev_action: 'server', + ui_mode: 'terminal', + exit_reason: 'success', + }); + telemetry.clearEntries(); + // Invoke the running server and verify telemetry const invokeResult = await runCLI(['dev', 'hello', '--port', String(port)], projectPath, { env: telemetry.env, @@ -135,4 +144,27 @@ describe('integration: dev server', () => { }, 30000 ); + + it.skipIf(!hasNpm || !hasGit || !hasUv)( + 'exits with error when runtime not found and emits failure telemetry', + async () => { + expect(projectPath, 'Project should have been created').toBeTruthy(); + + telemetry.clearEntries(); + const result = await runCLI(['dev', '--logs', '--runtime', 'nonexistent-agent'], projectPath, { + env: telemetry.env, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('nonexistent-agent'); + expect(result.stderr).toContain('not found'); + + telemetry.assertMetricEmitted({ + command: 'dev', + dev_action: 'server', + exit_reason: 'failure', + }); + }, + 15000 + ); }); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 0f67615fc..49411646d 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -1,7 +1,7 @@ import { ConnectionError, + NoProjectError, ResourceNotFoundError, - type Result, ValidationError, findConfigRoot, getWorkingDirectory, @@ -26,12 +26,10 @@ import { } from '../../operations/dev'; import { OtelCollector, startOtelCollector } from '../../operations/dev/otel'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { TelemetryClientAccessor } from '../../telemetry/client-accessor.js'; import { AgentProtocol, standardize } from '../../telemetry/schemas/common-shapes.js'; -import { FatalError } from '../../tui/components'; import { LayoutProvider } from '../../tui/context'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject, requireTTY } from '../../tui/guards'; +import { requireTTY } from '../../tui/guards'; import { parseHeaderFlags } from '../shared/header-utils'; import { runBrowserMode } from './browser-mode'; import type { Command } from '@commander-js/extra-typings'; @@ -197,74 +195,77 @@ export const registerDev = (program: Command) => { // Exec mode: run shell command in the dev container if (opts.exec) { - if (!positionalPrompt) { - console.error('A command is required with --exec. Usage: agentcore dev --exec "whoami"'); - process.exit(1); - } - const workingDir = getWorkingDirectory(); - const project = await loadProjectConfig(workingDir); - const agentName = opts.runtime ?? project?.runtimes[0]?.name ?? 'unknown'; - const targetAgent = project?.runtimes.find(a => a.name === agentName); - if (targetAgent?.build !== 'Container') { - console.error('Error: --exec is only supported for Container build agents.'); - console.error('For CodeZip agents, use your terminal to run commands directly.'); - process.exit(1); - } - const containerName = `agentcore-dev-${agentName}`.toLowerCase(); const execResult = await withCommandRunTelemetry( 'dev', { dev_action: 'exec' as const, ui_mode: 'terminal' as const, has_stream: false, - agent_protocol: standardize(AgentProtocol, (targetAgent?.protocol ?? 'http').toLowerCase()), + agent_protocol: standardize(AgentProtocol, 'unknown'), invoke_count: 0, }, - async (): Promise => { + async recorder => { + if (!positionalPrompt) { + throw new ValidationError('A command is required with --exec. Usage: agentcore dev --exec "whoami"'); + } + const workingDir = getWorkingDirectory(); + const project = await loadProjectConfig(workingDir); + const agentName = opts.runtime ?? project?.runtimes[0]?.name ?? 'unknown'; + const targetAgent = project?.runtimes.find(a => a.name === agentName); + if (targetAgent?.build !== 'Container') { + throw new ValidationError( + '--exec is only supported for Container build agents. For CodeZip agents, use your terminal to run commands directly.' + ); + } + recorder.set({ + agent_protocol: standardize(AgentProtocol, (targetAgent?.protocol ?? 'http').toLowerCase()), + }); + const containerName = `agentcore-dev-${agentName}`.toLowerCase(); await execInContainer(positionalPrompt, containerName); - return { success: true }; + return { success: true as const }; } ); - if (!execResult.success) throw execResult.error; + // TODO: Remove cast once withCommandRunTelemetry's return type is narrowed + if (!execResult.success) throw (execResult as unknown as { error: Error }).error; return; } // If a prompt is provided, invoke a running dev server const invokePrompt = positionalPrompt; if (invokePrompt !== undefined) { - const workingDir = getWorkingDirectory(); - const invokeProject = await loadProjectConfig(workingDir); - - // Determine which agent/port to invoke - let invokePort = port; - let targetAgent = invokeProject?.runtimes[0]; - if (opts.runtime && invokeProject) { - invokePort = getAgentPort(invokeProject, opts.runtime, port); - targetAgent = invokeProject.runtimes.find(a => a.name === opts.runtime); - } else if (invokeProject && invokeProject.runtimes.length > 1 && !opts.runtime) { - const names = invokeProject.runtimes.map(a => a.name).join(', '); - console.error(`Error: Multiple runtimes found. Use --runtime to specify which one.`); - console.error(`Available: ${names}`); - process.exit(1); - } - - const protocol = targetAgent?.protocol ?? 'HTTP'; - - // Override port for protocols with fixed framework ports - if (protocol === 'A2A') invokePort = 9000; - else if (protocol === 'MCP') invokePort = 8000; - const invokeResult = await withCommandRunTelemetry( 'dev', { dev_action: 'invoke' as const, ui_mode: 'terminal' as const, has_stream: opts.stream ?? false, - agent_protocol: standardize(AgentProtocol, protocol.toLowerCase()), + agent_protocol: standardize(AgentProtocol, 'unknown'), invoke_count: 1, }, - async (): Promise => { - // Protocol-aware dispatch + async recorder => { + const workingDir = getWorkingDirectory(); + const invokeProject = await loadProjectConfig(workingDir); + + let invokePort = port; + let targetAgent = invokeProject?.runtimes[0]; + if (opts.runtime && invokeProject) { + invokePort = getAgentPort(invokeProject, opts.runtime, port); + targetAgent = invokeProject.runtimes.find(a => a.name === opts.runtime); + } else if (invokeProject && invokeProject.runtimes.length > 1 && !opts.runtime) { + const names = invokeProject.runtimes.map(a => a.name).join(', '); + throw new ValidationError( + `Multiple runtimes found. Use --runtime to specify which one. Available: ${names}` + ); + } + + const protocol = targetAgent?.protocol ?? 'HTTP'; + recorder.set({ + agent_protocol: standardize(AgentProtocol, protocol.toLowerCase()), + }); + + if (protocol === 'A2A') invokePort = 9000; + else if (protocol === 'MCP') invokePort = 8000; + if (protocol === 'MCP') { await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers); } else if (protocol === 'A2A') { @@ -281,117 +282,111 @@ export const registerDev = (program: Command) => { } else { await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers); } - return { success: true }; + + return { success: true as const }; } ); - if (!invokeResult.success) throw invokeResult.error; + // TODO: Remove cast once withCommandRunTelemetry's return type is narrowed + if (!invokeResult.success) throw (invokeResult as unknown as { error: Error }).error; return; } - requireProject(); - const workingDir = getWorkingDirectory(); - const project = await loadProjectConfig(workingDir); - if (!project) { - render(); - process.exit(1); - } + const serverResult = await withCommandRunTelemetry( + 'dev', + { + dev_action: 'server' as const, + ui_mode: 'terminal' as const, + has_stream: false, + agent_protocol: standardize(AgentProtocol, 'unknown'), + invoke_count: 0, + }, + async recorder => { + const project = await loadProjectConfig(workingDir); + if (!project) { + throw new NoProjectError(); + } + if (!project.runtimes || project.runtimes.length === 0) { + throw new ValidationError('No agents defined in project. Run `agentcore add agent` to fix this.'); + } - if (!project.runtimes || project.runtimes.length === 0) { - render(); - process.exit(1); - } + const targetDevAgent = opts.runtime + ? project.runtimes.find(a => a.name === opts.runtime) + : project.runtimes[0]; + if (targetDevAgent?.networkMode === 'VPC') { + console.log( + '\x1b[33mWarning: This agent uses VPC network mode. Local dev server runs outside your VPC. Network behavior may differ from deployed environment.\x1b[0m\n' + ); + } - // Warn about VPC mode limitations in local dev - const targetDevAgent = opts.runtime ? project.runtimes.find(a => a.name === opts.runtime) : project.runtimes[0]; - if (targetDevAgent?.networkMode === 'VPC') { - console.log( - '\x1b[33mWarning: This agent uses VPC network mode. Local dev server runs outside your VPC. Network behavior may differ from deployed environment.\x1b[0m\n' - ); - } + const supportedAgents = getDevSupportedAgents(project); + if (supportedAgents.length === 0) { + throw new ValidationError('No agents support dev mode. Dev mode requires an agent with an entrypoint.'); + } - const supportedAgents = getDevSupportedAgents(project); - if (supportedAgents.length === 0) { - render(); - process.exit(1); - } + const configRoot = findConfigRoot(workingDir); + let otelEnvVars: Record = {}; + let collector: OtelCollector | undefined; - // Start local OTEL collector so agent traces are captured in dev mode. - // Persists traces to .cli/traces/ so they survive dev server restarts. - const configRoot = findConfigRoot(workingDir); - let otelEnvVars: Record = {}; - let collector: OtelCollector | undefined; - - if (opts.traces !== false) { - const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); - const otelResult = await startOtelCollector(persistTracesDir); - collector = otelResult.collector; - otelEnvVars = otelResult.otelEnvVars; - } + if (opts.traces !== false) { + const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); + const otelResult = await startOtelCollector(persistTracesDir); + collector = otelResult.collector; + otelEnvVars = otelResult.otelEnvVars; + } - // If --logs provided, run non-interactive mode - if (opts.logs) { - // Require --agent if multiple agents - if (project.runtimes.length > 1 && !opts.runtime) { - const names = project.runtimes.map(a => a.name).join(', '); - console.error(`Error: Multiple runtimes found. Use --runtime to specify which one.`); - console.error(`Available: ${names}`); - process.exit(1); - } + // --logs: non-interactive server mode + if (opts.logs) { + if (project.runtimes.length > 1 && !opts.runtime) { + const names = project.runtimes.map(a => a.name).join(', '); + throw new ValidationError( + `Multiple runtimes found. Use --runtime to specify which one. Available: ${names}` + ); + } - const agentName = opts.runtime ?? project.runtimes[0]?.name; - const { envVars } = await loadDevEnv(workingDir); - const mergedEnvVars = { ...envVars, ...otelEnvVars }; - const config = getDevConfig(workingDir, project, configRoot ?? undefined, agentName); + const agentName = opts.runtime ?? project.runtimes[0]?.name; + const { envVars } = await loadDevEnv(workingDir); + const mergedEnvVars = { ...envVars, ...otelEnvVars }; + const config = getDevConfig(workingDir, project, configRoot ?? undefined, agentName); - if (!config) { - console.error('Error: No dev-supported agents found.'); - process.exit(1); - } + if (!config) { + throw new ValidationError('No dev-supported agents found.'); + } - // Create logger for log file path - const logger = new ExecLogger({ command: 'dev' }); - - // Calculate port: A2A/MCP use fixed framework ports, HTTP uses configurable port - const isA2A = config.protocol === 'A2A'; - const isMcp = config.protocol === 'MCP'; - const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port); - const actualPort = await findAvailablePort(fixedPort); - if ((isA2A || isMcp) && actualPort !== fixedPort) { - console.error(`Error: Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.`); - process.exit(1); - } - if (actualPort !== fixedPort) { - console.log(`Port ${fixedPort} in use, using ${actualPort}`); - } + recorder.set({ + agent_protocol: standardize(AgentProtocol, config.protocol.toLowerCase()), + }); - // Get provider info from agent config - const providerInfo = '(see agent code)'; + const isA2A = config.protocol === 'A2A'; + const isMcp = config.protocol === 'MCP'; + const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port); + const actualPort = await findAvailablePort(fixedPort); + if ((isA2A || isMcp) && actualPort !== fixedPort) { + throw new ValidationError( + `Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.` + ); + } - console.log(`Starting dev server...`); - console.log(`Agent: ${config.agentName}`); - if (config.protocol !== 'MCP') { - console.log(`Provider: ${providerInfo}`); - } - if (config.protocol !== 'HTTP') { - console.log(`Protocol: ${config.protocol}`); - } - console.log(`Server: ${getEndpointUrl(actualPort, config.protocol)}`); - console.log(`Log: ${logger.getRelativeLogPath()}`); - console.log(`Press Ctrl+C to stop\n`); + const logger = new ExecLogger({ command: 'dev' }); - const devResult = await withCommandRunTelemetry( - 'dev', - { - dev_action: 'server' as const, - ui_mode: 'terminal' as const, - has_stream: false, - agent_protocol: standardize(AgentProtocol, (config.protocol ?? 'http').toLowerCase()), - invoke_count: 0, - }, - async (): Promise => { - await new Promise((resolve, reject) => { + if (actualPort !== fixedPort) { + console.log(`Port ${fixedPort} in use, using ${actualPort}`); + } + + console.log(`Starting dev server...`); + console.log(`Agent: ${config.agentName}`); + if (config.protocol !== 'MCP') { + console.log(`Provider: (see agent code)`); + } + if (config.protocol !== 'HTTP') { + console.log(`Protocol: ${config.protocol}`); + } + console.log(`Server: ${getEndpointUrl(actualPort, config.protocol)}`); + console.log(`Log: ${logger.getRelativeLogPath()}`); + console.log(`Press Ctrl+C to stop\n`); + + const serverPromise = new Promise((resolve, reject) => { const devCallbacks = { onLog: (level: string, msg: string) => { const prefix = level === 'error' ? '❌' : level === 'warn' ? '⚠️' : '→'; @@ -422,34 +417,23 @@ export const registerDev = (program: Command) => { server.kill(); }); }); - return { success: true as const }; + + return { success: true as const, blockingPromise: serverPromise }; } - ); - if (!devResult.success) throw devResult.error; - process.exit(0); - } + recorder.set({ + agent_protocol: standardize(AgentProtocol, (targetDevAgent?.protocol ?? 'http').toLowerCase()), + }); - // If --no-browser provided, launch terminal TUI mode - if (!opts.browser) { - requireTTY(); - // Enter alternate screen buffer for fullscreen mode - process.stdout.write(ENTER_ALT_SCREEN); + // --no-browser: terminal TUI mode + if (!opts.browser) { + requireTTY(); + process.stdout.write(ENTER_ALT_SCREEN); - const exitAltScreen = () => { - process.stdout.write(EXIT_ALT_SCREEN); - process.stdout.write(SHOW_CURSOR); - }; + const exitAltScreen = () => { + process.stdout.write(EXIT_ALT_SCREEN); + process.stdout.write(SHOW_CURSOR); + }; - const tuiResult = await withCommandRunTelemetry( - 'dev', - { - dev_action: 'server' as const, - ui_mode: 'terminal' as const, - has_stream: false, - agent_protocol: standardize(AgentProtocol, (targetDevAgent?.protocol ?? 'http').toLowerCase()), - invoke_count: 0, - }, - async (): Promise => { const { DevScreen } = await import('../../tui/screens/dev/DevScreen'); const { unmount, waitUntilExit } = render( @@ -466,44 +450,34 @@ export const registerDev = (program: Command) => { ); - await waitUntilExit(); - exitAltScreen(); - return { success: true }; + return { + success: true as const, + blockingPromise: waitUntilExit().finally(() => { + exitAltScreen(); + collector?.stop(); + }), + }; } - ); - if (!tuiResult.success) throw tuiResult.error; - collector?.stop(); - process.exit(0); - } - // Default: launch web UI in browser - // NOTE: Do not copy this pattern. runBrowserMode blocks forever (internal - // await new Promise(() => {})) so we cannot use withCommandRunTelemetry here. - // We emit telemetry eagerly before the blocking call. - { - const client = await TelemetryClientAccessor.get().catch(() => undefined); - if (client) { - client.emit('cli.command_run', 0, { - command_group: 'dev', - command: 'dev', - exit_reason: 'success', - dev_action: 'server', - ui_mode: 'browser', - has_stream: false, - agent_protocol: standardize(AgentProtocol, (targetDevAgent?.protocol ?? 'http').toLowerCase()), - invoke_count: 0, - }); - await client.flush(); + // Default: browser mode (blocks forever) + recorder.set({ ui_mode: 'browser' as const }); + return { + success: true as const, + blockingPromise: runBrowserMode({ + workingDir, + project, + port, + agentName: opts.runtime, + otelEnvVars, + collector, + }), + }; } - await runBrowserMode({ - workingDir, - project, - port, - agentName: opts.runtime, - otelEnvVars, - collector, - }); - } + ); + // TODO: Remove cast once withCommandRunTelemetry's return type is narrowed + if (!serverResult.success) throw (serverResult as unknown as { error: Error }).error; + await serverResult.blockingPromise; + process.exit(0); } catch (error) { console.error(`Error: ${getErrorMessage(error)}`); process.exit(1); diff --git a/src/cli/telemetry/__tests__/client.test.ts b/src/cli/telemetry/__tests__/client.test.ts index 949ae80ac..24263e6ad 100644 --- a/src/cli/telemetry/__tests__/client.test.ts +++ b/src/cli/telemetry/__tests__/client.test.ts @@ -166,4 +166,134 @@ describe('withCommandRunTelemetry', () => { error_name: 'UnknownError', }); }); + + describe('AttributeRecorder', () => { + it('recorder.set() overrides initial attributes on success', async () => { + await withCommandRunTelemetry( + 'dev', + { + dev_action: 'server', + ui_mode: 'terminal', + has_stream: false, + agent_protocol: 'http', + invoke_count: 0, + }, + async recorder => { + recorder.set({ + dev_action: 'invoke', + ui_mode: 'browser', + has_stream: true, + agent_protocol: 'a2a', + invoke_count: 5, + }); + return { success: true as const }; + } + ); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + dev_action: 'invoke', + ui_mode: 'browser', + has_stream: 'true', + agent_protocol: 'a2a', + invoke_count: 5, + }); + }); + + it('recorder.set() overrides initial attributes on failure result', async () => { + await withCommandRunTelemetry( + 'dev', + { + dev_action: 'server', + ui_mode: 'terminal', + has_stream: false, + agent_protocol: 'http', + invoke_count: 0, + }, + async recorder => { + recorder.set({ + agent_protocol: 'mcp', + }); + return { success: false as const, error: new Error('port in use') }; + } + ); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + exit_reason: 'failure', + agent_protocol: 'mcp', + }); + }); + + it('uses initial attributes when recorder.set() is never called', async () => { + await withCommandRunTelemetry( + 'dev', + { + dev_action: 'server', + ui_mode: 'terminal', + has_stream: false, + agent_protocol: 'http', + invoke_count: 0, + }, + async () => ({ success: true as const }) + ); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + agent_protocol: 'http', + dev_action: 'server', + }); + }); + + it('partial recorder.set() merges with initial attributes preserving non-overlapping keys', async () => { + await withCommandRunTelemetry( + 'dev', + { + dev_action: 'server', + ui_mode: 'terminal', + has_stream: false, + agent_protocol: 'http', + invoke_count: 0, + }, + async recorder => { + recorder.set({ + agent_protocol: 'mcp', + }); + return { success: true as const }; + } + ); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + dev_action: 'server', + ui_mode: 'terminal', + has_stream: 'false', + agent_protocol: 'mcp', + invoke_count: 0, + }); + }); + + it('recorder.set() called before throw is preserved in telemetry', async () => { + await withCommandRunTelemetry( + 'dev', + { + dev_action: 'server', + ui_mode: 'terminal', + has_stream: false, + agent_protocol: 'http', + invoke_count: 0, + }, + async recorder => { + recorder.set({ agent_protocol: 'a2a' }); + throw new Error('crash'); + } + ); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + exit_reason: 'failure', + agent_protocol: 'a2a', + }); + }); + }); }); diff --git a/src/cli/telemetry/attribute-recorder.ts b/src/cli/telemetry/attribute-recorder.ts new file mode 100644 index 000000000..36047c8a9 --- /dev/null +++ b/src/cli/telemetry/attribute-recorder.ts @@ -0,0 +1,16 @@ +export interface AttributeRecorder> { + set(attrs: Pick): void; + get(): Partial; +} + +export function createAttributeRecorder>(): AttributeRecorder { + let recorded: Partial = {}; + return { + set(attrs: Pick) { + recorded = { ...recorded, ...attrs }; + }, + get() { + return recorded; + }, + }; +} diff --git a/src/cli/telemetry/cli-command-run.ts b/src/cli/telemetry/cli-command-run.ts index 0ed290027..d60c0e8fd 100644 --- a/src/cli/telemetry/cli-command-run.ts +++ b/src/cli/telemetry/cli-command-run.ts @@ -1,5 +1,6 @@ import type { Result } from '../../lib/result'; import { getErrorMessage } from '../errors'; +import { type AttributeRecorder, createAttributeRecorder } from './attribute-recorder.js'; import { TelemetryClientAccessor } from './client-accessor.js'; import { TelemetryClient } from './client.js'; import { classifyError } from './error.js'; @@ -7,6 +8,8 @@ import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } import { type CommandResult, CommandResultSchema, resilientParse } from './schemas/common-shapes.js'; import { performance } from 'perf_hooks'; +export type { AttributeRecorder } from './attribute-recorder.js'; + async function getTelemetryClient() { try { return await TelemetryClientAccessor.get(); @@ -78,37 +81,53 @@ async function trackCommandRun( * is returned to the caller. If the callback throws, telemetry is recorded and * the exception is converted to a result type such that callers do not need to handle result + try/catch. * If telemetry is unavailable, the callback runs untracked. + * + * The callback receives an AttributeRecorder to dynamically set or override attributes. + * Initial attributes are seeded into the recorder; the callback may call recorder.set() + * to override or supplement them at any point during execution. */ export async function withCommandRunTelemetry( command: C, - attrs: CommandAttrs, - fn: () => R | Promise + attributes: CommandAttrs, + fn: (recorder: AttributeRecorder>) => R | Promise ): Promise { const client = await getTelemetryClient(); - - let result: R | undefined; + const recorder = createAttributeRecorder>(); + recorder.set(attributes); + const start = performance.now(); try { - if (!client) return fn(); - await trackCommandRun( - client, - command, - async () => { - result = await fn(); - if (!result.success) throw result.error; - return attrs; - }, - attrs - ); + const result = await fn(recorder); + if (client) { + const durationMs = Math.round(performance.now() - start); + if (!result.success) { + const { category, source } = classifyError(result.error); + recordCommandRun( + client, + command, + { exit_reason: 'failure', error_name: category, error_source: source }, + recorder.get(), + durationMs + ); + } else { + recordCommandRun(client, command, { exit_reason: 'success' }, recorder.get(), durationMs); + } + } + return result; } catch (e) { - // trackCommandRun re-throws after recording failure telemetry. - // If result was set, fn() returned a failure result — return it directly. - // If not, fn() itself threw — convert to a failure result so callers - // that don't wrap in try/catch (e.g. TUI hooks) don't leak unhandled rejections. - if (!result) { - return { success: false, error: e instanceof Error ? e : new Error(getErrorMessage(e)) } as R; + if (client) { + const { category, source } = classifyError(e); + recordCommandRun( + client, + command, + { exit_reason: 'failure', error_name: category, error_source: source }, + recorder.get(), + Math.round(performance.now() - start) + ); } + return { success: false, error: e instanceof Error ? e : new Error(getErrorMessage(e)) } as R; + } finally { + await client?.flush(); } - return result!; } /**