Skip to content

Commit e0eeadb

Browse files
konraddysputKonrad Dysput
andauthored
sdk-core: Serialize error.cause (#360)
* sdk-core: Serialize error.cause * sdk-core: serialize everything possible during error serialization that is not type error * sdk-core: Fix invalid cast * sdk-core: handle properly circural reference and objects that can be available in cause * sdk-core: simplify object serialization fallback --------- Co-authored-by: Konrad Dysput <konrad.dysput@saucelabs.com>
1 parent 3b7225a commit e0eeadb

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

packages/sdk-core/src/model/report/BacktraceReport.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,7 @@ export class BacktraceReport {
7171
let errorType: BacktraceErrorType = 'Exception';
7272
if (data instanceof Error) {
7373
this.message = this.generateErrorMessage(data.message);
74-
this.annotations['error'] = {
75-
...data,
76-
message: this.message,
77-
name: data.name,
78-
stack: data.stack,
79-
};
74+
this.annotations['error'] = this.unwrapErrorToAnnotation(data);
8075
this.classifiers = [data.name];
8176
this.stackTrace['main'] = {
8277
stack: data.stack ?? '',
@@ -111,6 +106,22 @@ export class BacktraceReport {
111106
}
112107
}
113108

109+
private unwrapErrorToAnnotation(error: Error, seen = new WeakSet<object>()): Record<string, unknown> {
110+
seen.add(error);
111+
return {
112+
...error,
113+
message: this.generateErrorMessage(error.message),
114+
name: error.name,
115+
stack: error.stack,
116+
cause:
117+
error.cause instanceof Error
118+
? seen.has(error.cause)
119+
? `[Circular] ${error.cause.message}`
120+
: this.unwrapErrorToAnnotation(error.cause, seen)
121+
: error.cause,
122+
};
123+
}
124+
114125
private generateErrorMessage(data: unknown) {
115126
return typeof data === 'object' ? JSON.stringify(data, jsonEscaper()) : (data?.toString() ?? '');
116127
}

packages/sdk-core/tests/report/reportTests.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,94 @@ describe('Backtrace report generation tests', () => {
100100
expect(messageReport.stackTrace[name]).toEqual(expect.objectContaining(expected));
101101
});
102102
});
103+
104+
describe('error annotation cause unwrapping', () => {
105+
type ErrorWithCause = Error & { cause?: unknown };
106+
107+
/**
108+
* This function is a helper to fix a potential type issue between different
109+
* version of TypeScript's built-in Error type, which may or may not include the `cause` property.
110+
*/
111+
function createError(message: string, cause?: unknown): ErrorWithCause {
112+
const error: ErrorWithCause = new Error(message);
113+
error.cause = cause;
114+
return error;
115+
}
116+
117+
it('should include cause in error annotation', () => {
118+
const causeMessage = 'cause';
119+
const cause = new Error(causeMessage);
120+
const error = createError('top level', cause);
121+
const report = new BacktraceReport(error);
122+
123+
const annotation = report.annotations['error'] as ErrorWithCause;
124+
const causeAnnotation = annotation.cause as ErrorWithCause;
125+
expect(causeAnnotation.message).toBe(causeMessage);
126+
expect(causeAnnotation.name).toBe('Error');
127+
});
128+
129+
it('should unwrap nested cause chain', () => {
130+
const root = new Error('root');
131+
const mid = createError('mid', root);
132+
const top = createError('top', mid);
133+
const report = new BacktraceReport(top);
134+
135+
const annotation = report.annotations['error'] as ErrorWithCause;
136+
const midAnnotation = annotation.cause as ErrorWithCause;
137+
const rootAnnotation = midAnnotation.cause as ErrorWithCause;
138+
expect(midAnnotation.message).toBe(mid.message);
139+
expect(rootAnnotation.message).toBe(root.message);
140+
expect(rootAnnotation.cause).toBeUndefined();
141+
});
142+
143+
it('should handle circular cause without stack overflow', () => {
144+
const topLevelError = createError('error a');
145+
const cause = createError('error b');
146+
topLevelError.cause = cause;
147+
cause.cause = topLevelError;
148+
149+
const report = new BacktraceReport(topLevelError);
150+
151+
const annotation = report.annotations['error'] as ErrorWithCause;
152+
const causeAnnotation = annotation.cause as ErrorWithCause;
153+
expect(causeAnnotation.message).toBe(cause.message);
154+
// circular reference back to `topLevelError` — not recursed, produces circular placeholder
155+
expect(causeAnnotation.cause).toEqual(`[Circular] ${topLevelError.message}`);
156+
});
157+
158+
it('should handle self-referencing cause without stack overflow', () => {
159+
const error = createError('self');
160+
error.cause = error;
161+
162+
const report = new BacktraceReport(error);
163+
164+
const annotation = report.annotations['error'] as ErrorWithCause;
165+
// cause points to itself — not recursed, produces circular placeholder
166+
expect(annotation.cause).toEqual(`[Circular] ${error.message}`);
167+
});
168+
169+
it('should handle non-Error cause as string value', () => {
170+
const error = createError('fail', 'timeout');
171+
const report = new BacktraceReport(error);
172+
173+
const annotation = report.annotations['error'] as ErrorWithCause;
174+
expect(annotation.cause).toEqual('timeout');
175+
});
176+
177+
it('should handle non-Error object cause as string value', () => {
178+
const error = createError('fail', { code: 'ENOENT' });
179+
const report = new BacktraceReport(error);
180+
181+
const annotation = report.annotations['error'] as ErrorWithCause;
182+
expect(annotation.cause).toEqual({ code: 'ENOENT' });
183+
});
184+
185+
it('should set cause to undefined when no cause exists', () => {
186+
const error = new Error('no cause');
187+
const report = new BacktraceReport(error);
188+
189+
const annotation = report.annotations['error'] as ErrorWithCause;
190+
expect(annotation.cause).toBeUndefined();
191+
});
192+
});
103193
});

0 commit comments

Comments
 (0)