Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/event-schemas/js-error-event.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@
},
"stack": {
"type": "string"
},
"cause": {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are unfortunately some backend changes we need to for this. Thanks for addressing this issue from years back. I'll merge this in as soon as we are ready for it.

"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,
Expand Down
115 changes: 115 additions & 0 deletions src/plugins/event-plugins/__tests__/JsErrorPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
60 changes: 60 additions & 0 deletions src/plugins/utils/js-error-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down