From 9d9d9cc1d44b2640163320e590ea49e07d90838a Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Thu, 12 Mar 2026 10:32:49 -0400 Subject: [PATCH] feat: capture Error.cause chain in JS error events Add support for the ES2022 Error.cause property when recording JS errors. The cause chain is walked recursively (up to 5 levels) and stored as an array of {type, message, stack} objects on the event. Resolves #549 --- src/event-schemas/js-error-event.json | 19 +++ .../__tests__/JsErrorPlugin.test.ts | 115 ++++++++++++++++++ src/plugins/utils/js-error-utils.ts | 60 +++++++++ 3 files changed, 194 insertions(+) diff --git a/src/event-schemas/js-error-event.json b/src/event-schemas/js-error-event.json index f384424d..b3b14014 100644 --- a/src/event-schemas/js-error-event.json +++ b/src/event-schemas/js-error-event.json @@ -27,6 +27,25 @@ }, "stack": { "type": "string" + }, + "cause": { + "type": "array", + "description": "Chain of Error.cause values. Each entry contains the type, message, and stack of a cause in the chain.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "type": "string" + } + }, + "additionalProperties": false + } } }, "additionalProperties": false, diff --git a/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts b/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts index e912c388..27dae626 100644 --- a/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts @@ -676,4 +676,119 @@ describe('JsErrorPlugin tests', () => { // Assert expect(record).toHaveBeenCalled(); }); + + test('when an Error has a nested cause chain then all causes are captured', async () => { + // Init + const plugin: JsErrorPlugin = new JsErrorPlugin(); + const rootCause = new TypeError('null is not an object'); + const midError = new Error('Database query failed'); + (midError as any).cause = rootCause; + const topError = new Error('Request handler failed'); + (topError as any).cause = midError; + + // Run + plugin.load(context); + plugin.record(topError); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalledTimes(1); + const event = record.mock.calls[0][1] as any; + expect(event.cause).toHaveLength(2); + expect(event.cause[0]).toMatchObject({ + type: 'Error', + message: 'Database query failed' + }); + expect(event.cause[1]).toMatchObject({ + type: 'TypeError', + message: 'null is not an object' + }); + }); + + test('when an Error has a primitive cause then the cause message is the stringified value', async () => { + // Init + const plugin: JsErrorPlugin = new JsErrorPlugin(); + const error = new Error('Something failed'); + (error as any).cause = 'root cause string'; + + // Run + plugin.load(context); + plugin.record(error); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalledTimes(1); + expect(record.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + cause: [{ message: 'root cause string' }] + }) + ); + }); + + test('when an Error has a circular cause reference then the chain stops without duplicates', async () => { + // Init + const plugin: JsErrorPlugin = new JsErrorPlugin(); + const error = new Error('circular'); + (error as any).cause = error; // points back to itself + + // Run + plugin.load(context); + plugin.record(error); + plugin.disable(); + + // Assert — the first cause is recorded, then the cycle is detected + expect(record).toHaveBeenCalledTimes(1); + const event = record.mock.calls[0][1] as any; + expect(event.cause).toHaveLength(1); + expect(event.cause[0]).toMatchObject({ + type: 'Error', + message: 'circular' + }); + }); + + test('when a cause is a plain object with no Error fields then a fallback message is used', async () => { + // Init + const plugin: JsErrorPlugin = new JsErrorPlugin(); + const error = new Error('wrapper'); + (error as any).cause = { code: 'ENOENT', path: '/tmp/foo' }; + + // Run + plugin.load(context); + plugin.record(error); + plugin.disable(); + + // Assert + expect(record).toHaveBeenCalledTimes(1); + const event = record.mock.calls[0][1] as any; + expect(event.cause).toHaveLength(1); + expect(event.cause[0].message).toBeDefined(); + expect(event.cause[0].message.length).toBeGreaterThan(0); + }); + + test('when a cause chain exceeds the max depth then it is truncated', async () => { + // Init + const plugin: JsErrorPlugin = new JsErrorPlugin(); + + // Build a chain of 8 errors (deeper than the max depth of 5) + let current = new Error('cause-7'); + for (let i = 6; i >= 0; i--) { + const next = new Error(`cause-${i}`); + (next as any).cause = current; + current = next; + } + const top = new Error('top'); + (top as any).cause = current; + + // Run + plugin.load(context); + plugin.record(top); + plugin.disable(); + + // Assert — only 5 causes should be captured + expect(record).toHaveBeenCalledTimes(1); + const event = record.mock.calls[0][1] as any; + expect(event.cause).toHaveLength(5); + expect(event.cause[0].message).toBe('cause-0'); + expect(event.cause[4].message).toBe('cause-4'); + }); }); diff --git a/src/plugins/utils/js-error-utils.ts b/src/plugins/utils/js-error-utils.ts index 860a3784..96e3708d 100644 --- a/src/plugins/utils/js-error-utils.ts +++ b/src/plugins/utils/js-error-utils.ts @@ -14,6 +14,7 @@ interface Error { lineNumber?: number; // non-standard Mozilla property columnNumber?: number; // non-standard Mozilla property stack?: string; // non-standard Mozilla and Chrome property + cause?: unknown; // ES2022 Error.cause property } const isObject = (error: any): boolean => { @@ -56,6 +57,59 @@ const appendErrorPrimitiveDetails = ( rumEvent.message = error.toString(); }; +const MAX_CAUSE_DEPTH = 5; + +const buildCauseChain = ( + cause: unknown, + stackTraceLength: number +): { type?: string; message?: string; stack?: string }[] => { + const chain: { type?: string; message?: string; stack?: string }[] = []; + const seen = new WeakSet(); + let current: unknown = cause; + let depth = 0; + + while ( + current !== undefined && + current !== null && + depth < MAX_CAUSE_DEPTH + ) { + if (isObject(current)) { + if (seen.has(current as object)) break; + seen.add(current as object); + const entry: { type?: string; message?: string; stack?: string } = + {}; + const err = current as Error; + if (err.name) { + entry.type = err.name; + } + if (err.message) { + entry.message = err.message; + } + if (stackTraceLength && err.stack) { + entry.stack = + err.stack.length > stackTraceLength + ? err.stack.substring(0, stackTraceLength) + '...' + : err.stack; + } + // Fall back to stringified representation for plain objects + // with no recognizable Error fields + if (!entry.type && !entry.message && !entry.stack) { + entry.message = String(current); + } + chain.push(entry); + current = err.cause; + } else if (isErrorPrimitive(current)) { + chain.push({ message: String(current) }); + break; + } else { + break; + } + depth++; + } + + return chain; +}; + const appendErrorObjectDetails = ( rumEvent: JSErrorEvent, error: Error, @@ -84,6 +138,12 @@ const appendErrorObjectDetails = ( ? error.stack.substring(0, stackTraceLength) + '...' : error.stack; } + if (error.cause !== undefined && error.cause !== null) { + const causeChain = buildCauseChain(error.cause, stackTraceLength); + if (causeChain.length > 0) { + rumEvent.cause = causeChain; + } + } }; export const isErrorPrimitive = (error: any): boolean => {