feat(telemetry): include error details for failures (#254)

This commit is contained in:
Andrey Avtomonov 2026-06-02 17:23:51 +02:00 committed by GitHub
parent 494618ab14
commit 6da8c3452a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1259 additions and 999 deletions

View file

@ -1,4 +1,4 @@
import { scrubErrorClass } from './scrubber.js';
import { formatErrorDetail, scrubErrorClass } from './scrubber.js';
export type CommandOutcome = 'ok' | 'error' | 'aborted';
@ -16,6 +16,7 @@ export interface CompletedCommandSpan {
durationMs: number;
outcome: CommandOutcome;
errorClass?: string;
errorDetail?: string;
flagsPresent: Record<string, boolean>;
hasProject: boolean;
projectDir?: string;
@ -40,12 +41,14 @@ export function completeCommandSpan(input: {
}
const errorClass = input.error ? scrubErrorClass(input.error) : undefined;
const errorDetail = input.error ? formatErrorDetail(input.error) : undefined;
return {
commandPath: span.commandPath,
durationMs: Math.max(0, input.completedAt - span.startedAt),
outcome: input.outcome,
...(errorClass ? { errorClass } : {}),
...(errorDetail ? { errorDetail } : {}),
flagsPresent: span.flagsPresent,
hasProject: span.hasProject,
projectDir: span.projectDir,

View file

@ -26,6 +26,7 @@
"durationMs",
"outcome",
"errorClass",
"errorDetail",
"flagsPresent",
"hasProject",
"projectGroupAttached"
@ -37,7 +38,8 @@
"fields": [
"step",
"outcome",
"durationMs"
"durationMs",
"errorDetail"
]
},
{
@ -56,6 +58,7 @@
"isDemoConnection",
"outcome",
"errorClass",
"errorDetail",
"durationMs",
"serverVersion"
]
@ -84,7 +87,8 @@
"rowsBucket",
"durationMs",
"outcome",
"errorClass"
"errorClass",
"errorDetail"
]
},
{
@ -98,7 +102,8 @@
"declaredFkCount",
"durationMs",
"outcome",
"errorClass"
"errorClass",
"errorDetail"
]
},
{
@ -296,6 +301,10 @@
"errorClass": {
"type": "string"
},
"errorDetail": {
"type": "string",
"maxLength": 1000
},
"flagsPresent": {
"type": "object",
"propertyNames": {
@ -384,6 +393,10 @@
"durationMs": {
"type": "number",
"minimum": 0
},
"errorDetail": {
"type": "string",
"maxLength": 1000
}
},
"required": [
@ -494,6 +507,10 @@
"errorClass": {
"type": "string"
},
"errorDetail": {
"type": "string",
"maxLength": 1000
},
"durationMs": {
"type": "number",
"minimum": 0
@ -673,6 +690,10 @@
},
"errorClass": {
"type": "string"
},
"errorDetail": {
"type": "string",
"maxLength": 1000
}
},
"required": [
@ -759,6 +780,10 @@
},
"errorClass": {
"type": "string"
},
"errorDetail": {
"type": "string",
"maxLength": 1000
}
},
"required": [

View file

@ -21,6 +21,7 @@ const commandSchema = telemetryCommonEnvelopeSchema
durationMs: z.number().nonnegative(),
outcome: z.enum(['ok', 'error', 'aborted']),
errorClass: z.string().optional(),
errorDetail: z.string().max(1000).optional(),
flagsPresent: z.record(z.string(), z.boolean()),
hasProject: z.boolean(),
projectGroupAttached: z.boolean(),
@ -45,6 +46,7 @@ const setupStepSchema = telemetryCommonEnvelopeSchema
]),
outcome: z.enum(['completed', 'skipped', 'abandoned']),
durationMs: z.number().nonnegative(),
errorDetail: z.string().max(1000).optional(),
})
.strict();
@ -61,6 +63,7 @@ const connectionTestSchema = telemetryCommonEnvelopeSchema
isDemoConnection: z.boolean(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
errorDetail: z.string().max(1000).optional(),
durationMs: z.number().nonnegative(),
serverVersion: z.string().optional(),
})
@ -90,6 +93,7 @@ const ingestCompletedSchema = telemetryCommonEnvelopeSchema
durationMs: z.number().nonnegative(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
errorDetail: z.string().max(1000).optional(),
})
.strict();
@ -103,6 +107,7 @@ const scanCompletedSchema = telemetryCommonEnvelopeSchema
durationMs: z.number().nonnegative(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
errorDetail: z.string().max(1000).optional(),
})
.strict();
@ -237,6 +242,7 @@ export const telemetryEventCatalog = [
'durationMs',
'outcome',
'errorClass',
'errorDetail',
'flagsPresent',
'hasProject',
'projectGroupAttached',
@ -245,7 +251,7 @@ export const telemetryEventCatalog = [
{
name: 'setup_step',
description: 'Emitted after an interactive setup step completes, skips, or aborts.',
fields: ['step', 'outcome', 'durationMs'],
fields: ['step', 'outcome', 'durationMs', 'errorDetail'],
},
{
name: 'connection_added',
@ -255,7 +261,7 @@ export const telemetryEventCatalog = [
{
name: 'connection_test',
description: 'Emitted after ktx connection test completes.',
fields: ['driver', 'isDemoConnection', 'outcome', 'errorClass', 'durationMs', 'serverVersion'],
fields: ['driver', 'isDemoConnection', 'outcome', 'errorClass', 'errorDetail', 'durationMs', 'serverVersion'],
},
{
name: 'project_stack_snapshot',
@ -275,6 +281,7 @@ export const telemetryEventCatalog = [
'durationMs',
'outcome',
'errorClass',
'errorDetail',
],
},
{
@ -289,6 +296,7 @@ export const telemetryEventCatalog = [
'durationMs',
'outcome',
'errorClass',
'errorDetail',
],
},
{

View file

@ -26,3 +26,27 @@ export function scrubErrorClass(error: unknown): string | undefined {
return constructorName;
}
const MAX_ERROR_DETAIL_LENGTH = 1000;
/**
* Human-readable failure detail for telemetry: the error's `.code` (when
* present) prefixed onto its `message`, collapsed to a single line and
* length-capped. Captures the message only never the stack.
*
* This intentionally forwards raw error text, which can include identifiers from
* the user's environment (table/column names, hostnames, usernames), so that
* funnel failures are diagnosable. Callers must gate it to the failure path.
*/
export function formatErrorDetail(error: unknown): string | undefined {
if (error === undefined || error === null) {
return undefined;
}
const code = (error as { code?: unknown }).code;
const message = error instanceof Error ? error.message : String(error);
const prefix = typeof code === 'string' || typeof code === 'number' ? `${code}: ` : '';
const detail = `${prefix}${message}`.replace(/\s+/g, ' ').trim();
return detail.length > 0 ? detail.slice(0, MAX_ERROR_DETAIL_LENGTH) : undefined;
}