Skip to content

Commit a4c37a2

Browse files
authored
feat: make parsing resilient to individual failures (#1062)
1 parent 3aec000 commit a4c37a2

4 files changed

Lines changed: 117 additions & 9 deletions

File tree

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,49 @@ describe('TelemetryClient', () => {
116116
expect(sink.metrics[0]!.attrs.check_only).toBe('true');
117117
});
118118

119-
it('silently drops invalid success payloads', async () => {
119+
it('publishes metric with unknown defaults for incomplete success payloads', async () => {
120120
const sink = new InMemorySink();
121121
const client = new TelemetryClient(sink);
122122

123-
// Missing required attrs for 'create' — should silently drop
123+
// Missing required attrs for 'create' — should still publish with 'unknown' defaults
124124
await client.withCommandRun(
125125
'create',
126126
// @ts-expect-error — intentionally incomplete
127127
async () => ({ language: 'python' })
128128
);
129129

130-
expect(sink.metrics).toHaveLength(0);
130+
expect(sink.metrics).toHaveLength(1);
131+
expect(sink.metrics[0]!.attrs).toMatchObject({
132+
exit_reason: 'success',
133+
language: 'python',
134+
framework: 'unknown',
135+
model_provider: 'unknown',
136+
});
137+
});
138+
139+
it('defaults invalid attrs to unknown while preserving valid ones', async () => {
140+
const sink = new InMemorySink();
141+
const client = new TelemetryClient(sink);
142+
143+
await client.withCommandRun(
144+
'create',
145+
// @ts-expect-error — intentionally invalid enum value
146+
async () => ({
147+
language: 'rust', // invalid enum
148+
framework: 'strands',
149+
model_provider: 'bedrock',
150+
memory: 'shortterm',
151+
protocol: 'mcp',
152+
build: 'codezip',
153+
agent_type: 'create',
154+
network_mode: 'public',
155+
has_agent: true,
156+
})
157+
);
158+
159+
expect(sink.metrics).toHaveLength(1);
160+
expect(sink.metrics[0]!.attrs.language).toBe('unknown');
161+
expect(sink.metrics[0]!.attrs.framework).toBe('strands');
131162
});
132163

133164
it('records cancel when callback returns CANCELLED', async () => {

src/cli/telemetry/client.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { classifyError, isUserError } from './error-classification.js';
22
import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } from './schemas/command-run.js';
3-
import { type CommandResult, CommandResultSchema } from './schemas/common-shapes.js';
3+
import { type CommandResult, CommandResultSchema, resilientParse } from './schemas/common-shapes.js';
44
import type { MetricSink } from './sinks/metric-sink.js';
55
import { performance } from 'perf_hooks';
66

@@ -69,17 +69,24 @@ export class TelemetryClient {
6969
durationMs: number
7070
): void {
7171
try {
72+
// CommandResult is built internally — hard parse is intentional since
73+
// a metric without a valid exit_reason is meaningless.
7274
CommandResultSchema.parse(result);
73-
if (result.exit_reason !== 'failure' && result.exit_reason !== 'cancel') {
74-
COMMAND_SCHEMAS[command].parse(attrs);
75-
}
75+
76+
// Validate command attrs resiliently: invalid fields default to 'unknown'
77+
// 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;
7683

7784
const otelAttrs: Record<string, string | number> = {
7885
command_group: deriveCommandGroup(command),
7986
command,
8087
};
8188

82-
for (const obj of [result, attrs]) {
89+
for (const obj of [result, validatedAttrs]) {
8390
for (const [k, v] of Object.entries(obj)) {
8491
if (typeof v === 'boolean') {
8592
otelAttrs[k] = String(v);

src/cli/telemetry/schemas/__tests__/command-run.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } from '../command-run';
22
import { ResourceAttributesSchema } from '../common-attributes';
3-
import { CommandResultSchema } from '../common-shapes';
3+
import { CommandResultSchema, resilientParse } from '../common-shapes';
44
import { describe, expect, expectTypeOf, it } from 'vitest';
55
import { z } from 'zod';
66

@@ -170,3 +170,55 @@ describe('type safety', () => {
170170
}
171171
});
172172
});
173+
174+
describe('resilientParse', () => {
175+
it('passes valid attrs through unchanged', () => {
176+
const attrs = {
177+
language: 'python',
178+
framework: 'strands',
179+
model_provider: 'bedrock',
180+
memory: 'shortterm',
181+
protocol: 'mcp',
182+
build: 'codezip',
183+
agent_type: 'create',
184+
network_mode: 'public',
185+
has_agent: true,
186+
};
187+
expect(resilientParse(COMMAND_SCHEMAS.create, attrs)).toEqual(attrs);
188+
});
189+
190+
it('defaults a single invalid enum field to unknown', () => {
191+
const attrs = {
192+
language: 'rust', // invalid
193+
framework: 'strands',
194+
model_provider: 'bedrock',
195+
memory: 'shortterm',
196+
protocol: 'mcp',
197+
build: 'codezip',
198+
agent_type: 'create',
199+
network_mode: 'public',
200+
has_agent: true,
201+
};
202+
const result = resilientParse(COMMAND_SCHEMAS.create, attrs);
203+
expect(result.language).toBe('unknown');
204+
expect(result.framework).toBe('strands');
205+
});
206+
207+
it('defaults missing required fields to unknown', () => {
208+
const result = resilientParse(COMMAND_SCHEMAS.create, { language: 'python' });
209+
expect(result.language).toBe('python');
210+
expect(result.framework).toBe('unknown');
211+
expect(result.model_provider).toBe('unknown');
212+
});
213+
214+
it('defaults all fields to unknown when all are invalid', () => {
215+
const result = resilientParse(COMMAND_SCHEMAS.create, {});
216+
for (const value of Object.values(result)) {
217+
expect(value).toBe('unknown');
218+
}
219+
});
220+
221+
it('returns empty object for no-attrs schemas', () => {
222+
expect(resilientParse(COMMAND_SCHEMAS['telemetry.disable'], {})).toEqual({});
223+
});
224+
});

src/cli/telemetry/schemas/common-shapes.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ export function safeSchema<T extends Record<string, SafeField>>(shape: T) {
88
return z.object(shape);
99
}
1010

11+
/**
12+
* Validate each field in a schema individually, defaulting to 'unknown' on failure.
13+
* This ensures a single invalid attribute never blocks the entire metric from being published.
14+
* Keys in attrs not present in the schema are omitted from the result.
15+
*/
16+
export function resilientParse(
17+
schema: z.ZodObject<z.ZodRawShape>,
18+
attrs: Record<string, unknown>
19+
): Record<string, unknown> {
20+
const result: Record<string, unknown> = {};
21+
for (const key of Object.keys(schema.shape)) {
22+
const field = schema.shape[key] as z.ZodType;
23+
const parsed = field.safeParse(attrs[key]);
24+
result[key] = parsed.success ? parsed.data : 'unknown';
25+
}
26+
return result;
27+
}
28+
1129
// Primitive types
1230
export const Count = z.number().int().nonnegative();
1331

0 commit comments

Comments
 (0)