@@ -71,7 +71,15 @@ export interface TelemetryCommonFields {
7171
7272export type TelemetryEvent = TelemetryCommonFields & Record < string , unknown > ;
7373
74- /** Bounded vocabulary of error classes. Add new entries only when seen in the wild. */
74+ /**
75+ * Bounded vocabulary of error classes. Add new entries only when seen in the wild.
76+ *
77+ * Backend schema: `error_class varchar(40)` — keep slugs short.
78+ *
79+ * Order of evaluation inside `classifyError` matters: specific node error codes
80+ * (ERR_*) must be matched BEFORE the generic JS error kinds, or they'll collapse
81+ * into `type_error` / `reference_error` and lose signal.
82+ */
7583export type ErrorClass =
7684 | "prompt_too_long"
7785 | "api_error"
@@ -84,6 +92,15 @@ export type ErrorClass =
8492 | "permission_denied"
8593 | "disk_full"
8694 | "config_invalid"
95+ // Node-specific error codes (ERR_*). Match before the generic JS kinds below.
96+ | "node_invalid_arg" // ERR_INVALID_ARG_TYPE / fileURLToPath(undefined) — B-006
97+ | "module_not_found" // ERR_MODULE_NOT_FOUND / Cannot find module
98+ | "spawn_error" // spawn ENOENT / subprocess failed to start
99+ | "out_of_memory" // ENOMEM / JavaScript heap out of memory
100+ // Generic JS error kinds. Last-resort before "unknown" so a bare TypeError
101+ // at least lands in a non-empty bucket for triage.
102+ | "type_error"
103+ | "reference_error"
87104 | "unknown" ;
88105
89106// --- Process state ---
@@ -399,19 +416,67 @@ export async function sendStartupEvents(): Promise<void> {
399416/**
400417 * Map a caught exception to a bounded ErrorClass slug.
401418 * Never sends raw exception messages to telemetry — only the slug.
419+ *
420+ * Match order is load-bearing: specific node error codes must be checked
421+ * before generic JS error kinds, and domain-specific substrings (transcript
422+ * not found, prompt too long) before broad fallbacks (spawn_error).
402423 */
403424export function classifyError ( err : unknown ) : ErrorClass {
404425 const msg = err instanceof Error ? err . message . toLowerCase ( ) : String ( err ) . toLowerCase ( ) ;
426+ // Include the Error subtype name so we can catch TypeError / ReferenceError
427+ // even when the message text is too bland to identify on its own.
428+ const name = err instanceof Error ? err . name . toLowerCase ( ) : "" ;
429+
430+ // Domain-specific signals (our code or LLM provider) — check first.
405431 if ( msg . includes ( "prompt is too long" ) || msg . includes ( "max tokens" ) || msg . includes ( "context length" ) ) return "prompt_too_long" ;
406432 if ( msg . includes ( "rate limit" ) || msg . includes ( "429" ) ) return "api_rate_limit" ;
407433 if ( msg . includes ( "authentication" ) || msg . includes ( "api key" ) || msg . includes ( "apikey" ) || msg . includes ( "authtoken" ) ) return "oauth_missing" ;
408434 if ( msg . includes ( "timeout" ) || msg . includes ( "timed out" ) || msg . includes ( "aborted" ) ) return "timeout" ;
409- if ( msg . includes ( "enoent" ) || msg . includes ( "transcript not found" ) ) return "transcript_not_found" ;
435+ if ( msg . includes ( "transcript not found" ) ) return "transcript_not_found" ;
436+
437+ // Node-specific error codes. Check these BEFORE the generic TypeError /
438+ // ReferenceError / ENOENT fallbacks so ERR_INVALID_ARG_TYPE (B-006),
439+ // ERR_MODULE_NOT_FOUND, and spawn ENOENT don't collapse into the generic
440+ // bucket and lose their triage signal.
441+ if ( msg . includes ( "err_invalid_arg_type" ) || msg . includes ( "fileurltopath" ) ||
442+ ( msg . includes ( "argument must be of type" ) && msg . includes ( "received undefined" ) ) ) {
443+ return "node_invalid_arg" ;
444+ }
445+ if ( msg . includes ( "err_module_not_found" ) || msg . includes ( "cannot find module" ) ||
446+ msg . includes ( "cannot find package" ) ) {
447+ return "module_not_found" ;
448+ }
449+ if ( msg . includes ( "spawn enoent" ) || msg . includes ( "spawn eacces" ) ||
450+ msg . includes ( "child_process" ) && msg . includes ( "enoent" ) ) {
451+ return "spawn_error" ;
452+ }
453+ if ( msg . includes ( "enomem" ) || msg . includes ( "heap out of memory" ) ||
454+ msg . includes ( "allocation failed" ) || msg . includes ( "out of memory" ) ) {
455+ return "out_of_memory" ;
456+ }
457+
458+ // Filesystem / OS errors. ENOENT here (after transcript_not_found / spawn
459+ // ENOENT) is a generic missing-file hit.
460+ if ( msg . includes ( "enoent" ) ) return "transcript_not_found" ;
410461 if ( msg . includes ( "eacces" ) || msg . includes ( "permission denied" ) ) return "permission_denied" ;
411462 if ( msg . includes ( "enospc" ) || msg . includes ( "no space" ) ) return "disk_full" ;
412- if ( msg . includes ( "network" ) || msg . includes ( "econnrefused" ) || msg . includes ( "fetch failed" ) || msg . includes ( "dns" ) ) return "network_error" ;
463+
464+ // Network.
465+ if ( msg . includes ( "network" ) || msg . includes ( "econnrefused" ) || msg . includes ( "econnreset" ) ||
466+ msg . includes ( "fetch failed" ) || msg . includes ( "dns" ) ) return "network_error" ;
467+
468+ // Parsing.
413469 if ( msg . includes ( "unexpected token" ) || msg . includes ( "invalid json" ) || msg . includes ( "parse" ) ) return "parse_error" ;
470+
471+ // Remote API. Keep after the specific 429 (rate_limit) check above.
414472 if ( msg . includes ( "api error" ) || msg . includes ( "500" ) || msg . includes ( "503" ) ) return "api_error" ;
473+
474+ // Generic JS error kinds. Last resort before "unknown" so a bare TypeError
475+ // at least lands in a non-empty bucket and we can distinguish bundler bugs
476+ // (ReferenceError is almost always a missing import) from shape mismatches.
477+ if ( name === "referenceerror" ) return "reference_error" ;
478+ if ( name === "typeerror" ) return "type_error" ;
479+
415480 return "unknown" ;
416481}
417482
0 commit comments