Skip to content

Commit 4b51a3e

Browse files
committed
feat: record command attrs on telemetry failure via fallbackAttrs
Add optional fallbackAttrs parameter to client.withCommandRun so command-specific attributes are recorded even when the callback throws. - client.ts: accept fallbackAttrs, use on failure instead of {} - client.ts: run resilientParse on all non-empty attrs (not just success) - cli-command-run.ts: withCommandRunTelemetry passes attrs as fallbackAttrs - cli-command-run.ts: runCliCommand accepts optional knownAttrs param - command.tsx: extract knownAttrs upfront, pass to runCliCommand - client.test.ts: add unit tests for fallbackAttrs behavior - create-edge-cases.test.ts: assert attrs present on failure entry
1 parent bceddde commit 4b51a3e

5 files changed

Lines changed: 76 additions & 22 deletions

File tree

integ-tests/create-edge-cases.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create edge cases',
3838
telemetry.assertMetricEmitted({
3939
command: 'create',
4040
exit_reason: 'failure',
41+
language: 'python',
42+
has_agent: 'true',
4143
});
4244
});
4345

src/cli/commands/create/command.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ async function handleCreateCLI(options: CreateOptions): Promise<void> {
114114
process.exit(0);
115115
}
116116

117+
const knownAttrs = {
118+
language: standardize(Language, options.language),
119+
framework: standardize(Framework, options.framework),
120+
model_provider: standardize(ModelProviderEnum, options.modelProvider),
121+
memory: standardize(Memory, options.memory ?? 'none'),
122+
protocol: standardize(Protocol, options.protocol ?? 'http'),
123+
build: standardize(Build, options.build ?? 'codezip'),
124+
agent_type: standardize(AgentType, options.type ?? 'create'),
125+
network_mode: standardize(NetworkModeEnum, options.networkMode ?? 'public'),
126+
has_agent: options.agent !== false,
127+
};
128+
117129
await runCliCommand('create', !!options.json, async () => {
118130
const validation = validateCreateOptions(options, cwd);
119131
if (!validation.valid) {
@@ -188,18 +200,8 @@ async function handleCreateCLI(options: CreateOptions): Promise<void> {
188200
}
189201
}
190202

191-
return {
192-
language: standardize(Language, options.language),
193-
framework: standardize(Framework, options.framework),
194-
model_provider: standardize(ModelProviderEnum, options.modelProvider),
195-
memory: standardize(Memory, options.memory ?? 'none'),
196-
protocol: standardize(Protocol, options.protocol ?? 'http'),
197-
build: standardize(Build, options.build ?? 'codezip'),
198-
agent_type: standardize(AgentType, options.type ?? 'create'),
199-
network_mode: standardize(NetworkModeEnum, options.networkMode ?? 'public'),
200-
has_agent: options.agent !== false,
201-
};
202-
});
203+
return knownAttrs;
204+
}, knownAttrs);
203205
}
204206

205207
export const registerCreate = (program: Command) => {

src/cli/telemetry/__tests__/client.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,5 +173,54 @@ describe('TelemetryClient', () => {
173173
exit_reason: 'cancel',
174174
});
175175
});
176+
177+
it('records fallbackAttrs on failure when provided', async () => {
178+
const sink = new InMemorySink();
179+
const client = new TelemetryClient(sink);
180+
181+
await expect(
182+
client.withCommandRun(
183+
'create',
184+
async () => {
185+
throw new Error('validation failed');
186+
},
187+
{
188+
language: 'python',
189+
framework: 'strands',
190+
model_provider: 'bedrock',
191+
memory: 'none',
192+
protocol: 'http',
193+
build: 'codezip',
194+
agent_type: 'create',
195+
network_mode: 'public',
196+
has_agent: true,
197+
}
198+
)
199+
).rejects.toThrow('validation failed');
200+
201+
expect(sink.metrics).toHaveLength(1);
202+
expect(sink.metrics[0]!.attrs).toMatchObject({
203+
exit_reason: 'failure',
204+
error_name: 'UnknownError',
205+
language: 'python',
206+
framework: 'strands',
207+
model_provider: 'bedrock',
208+
has_agent: 'true',
209+
});
210+
});
211+
212+
it('records empty attrs on failure when fallbackAttrs not provided', async () => {
213+
const sink = new InMemorySink();
214+
const client = new TelemetryClient(sink);
215+
216+
await expect(
217+
client.withCommandRun('deploy', async () => {
218+
throw new Error('boom');
219+
})
220+
).rejects.toThrow('boom');
221+
222+
expect(sink.metrics).toHaveLength(1);
223+
expect(sink.metrics[0]!.attrs.language).toBeUndefined();
224+
});
176225
});
177226
});

src/cli/telemetry/cli-command-run.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export async function withCommandRunTelemetry<C extends Command, R extends Opera
3535
result = await fn();
3636
if (!result.success) throw new Error(result.error);
3737
return attrs;
38-
});
38+
}, attrs);
3939
} catch (e) {
4040
// withCommandRun re-throws after recording failure telemetry.
4141
// If result was set, fn() returned a failure result — return it directly.
@@ -52,19 +52,21 @@ export async function withCommandRunTelemetry<C extends Command, R extends Opera
5252
* Record telemetry, print errors, and exit the process.
5353
* Use in CLI command handlers where the command is the final action.
5454
* The callback returns attrs on success and throws on failure.
55+
* Pass knownAttrs to record command-specific attributes even on failure.
5556
*/
5657
export async function runCliCommand<C extends Command>(
5758
command: C,
5859
json: boolean,
59-
fn: () => Promise<CommandAttrs<C>>
60+
fn: () => Promise<CommandAttrs<C>>,
61+
knownAttrs?: Partial<CommandAttrs<C>>
6062
): Promise<never> {
6163
try {
6264
const client = await getTelemetryClient();
6365
if (!client) {
6466
await fn();
6567
process.exit(0);
6668
}
67-
await client.withCommandRun(command, fn);
69+
await client.withCommandRun(command, fn, knownAttrs);
6870
process.exit(0);
6971
} catch (error) {
7072
if (json) {

src/cli/telemetry/client.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export class TelemetryClient {
2626
*/
2727
async withCommandRun<C extends Command>(
2828
command: C,
29-
fn: () => CommandAttrs<C> | typeof CANCELLED | Promise<CommandAttrs<C> | typeof CANCELLED>
29+
fn: () => CommandAttrs<C> | typeof CANCELLED | Promise<CommandAttrs<C> | typeof CANCELLED>,
30+
fallbackAttrs?: Partial<CommandAttrs<C>>
3031
): Promise<void> {
3132
const start = performance.now();
3233
try {
@@ -43,7 +44,7 @@ export class TelemetryClient {
4344
error_name: classifyError(err),
4445
is_user_error: isUserError(err),
4546
};
46-
this.recordCommandRun(command, failureResult, {}, Math.round(performance.now() - start));
47+
this.recordCommandRun(command, failureResult, fallbackAttrs ?? {}, Math.round(performance.now() - start));
4748
throw err;
4849
} finally {
4950
try {
@@ -75,11 +76,9 @@ export class TelemetryClient {
7576

7677
// Validate command attrs resiliently: invalid fields default to 'unknown'
7778
// instead of dropping the entire metric.
78-
// On failure/cancel the callback attrs are empty so validation is skipped.
79-
const validatedAttrs =
80-
result.exit_reason !== 'failure' && result.exit_reason !== 'cancel'
81-
? resilientParse(COMMAND_SCHEMAS[command], attrs as Record<string, unknown>)
82-
: attrs;
79+
const validatedAttrs = Object.keys(attrs as Record<string, unknown>).length > 0
80+
? resilientParse(COMMAND_SCHEMAS[command], attrs as Record<string, unknown>)
81+
: attrs;
8382

8483
const otelAttrs: Record<string, string | number> = {
8584
command_group: deriveCommandGroup(command),

0 commit comments

Comments
 (0)