From 413be8cf3589c01bc5fe6ef42101d4029ab7c17a Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Fri, 17 Apr 2026 16:19:34 +0200 Subject: [PATCH 1/5] sdk-core: Serialize error.cause --- .../src/model/report/BacktraceReport.ts | 26 ++++-- .../sdk-core/tests/report/reportTests.spec.ts | 89 +++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/sdk-core/src/model/report/BacktraceReport.ts b/packages/sdk-core/src/model/report/BacktraceReport.ts index 889c47fa..5bbb34de 100644 --- a/packages/sdk-core/src/model/report/BacktraceReport.ts +++ b/packages/sdk-core/src/model/report/BacktraceReport.ts @@ -71,12 +71,7 @@ export class BacktraceReport { let errorType: BacktraceErrorType = 'Exception'; if (data instanceof Error) { this.message = this.generateErrorMessage(data.message); - this.annotations['error'] = { - ...data, - message: this.message, - name: data.name, - stack: data.stack, - }; + this.annotations['error'] = this.unwrapErrorToAnnotation(data); this.classifiers = [data.name]; this.stackTrace['main'] = { stack: data.stack ?? '', @@ -111,6 +106,25 @@ export class BacktraceReport { } } + private unwrapErrorToAnnotation( + error: Error, + seen = new WeakSet(), + ): Record { + seen.add(error); + return { + ...error, + message: this.generateErrorMessage(error.message), + name: error.name, + stack: error.stack, + cause: + error.cause instanceof Error && !seen.has(error.cause) + ? this.unwrapErrorToAnnotation(error.cause, seen) + : error.cause != null + ? { value: String(error.cause) } + : undefined, + }; + } + private generateErrorMessage(data: unknown) { return typeof data === 'object' ? JSON.stringify(data, jsonEscaper()) : (data?.toString() ?? ''); } diff --git a/packages/sdk-core/tests/report/reportTests.spec.ts b/packages/sdk-core/tests/report/reportTests.spec.ts index f62528c9..9137a861 100644 --- a/packages/sdk-core/tests/report/reportTests.spec.ts +++ b/packages/sdk-core/tests/report/reportTests.spec.ts @@ -100,4 +100,93 @@ describe('Backtrace report generation tests', () => { expect(messageReport.stackTrace[name]).toEqual(expect.objectContaining(expected)); }); }); + + describe('error annotation cause unwrapping', () => { + type ErrorWithCause = Error & { cause?: unknown }; + + /** + * This function is a helper to fix a potential type issue between different + * version of TypeScript's built-in Error type, which may or may not include the `cause` property. + */ + function createError(message: string, cause?: unknown): ErrorWithCause { + const error: ErrorWithCause = new Error(message); + error.cause = cause; + return error; + } + + it('should include cause in error annotation', () => { + const cause = new Error('root cause'); + const error = createError('top level', cause); + const report = new BacktraceReport(error); + + const annotation = report.annotations['error'] as Record; + const causeAnnotation = annotation.cause as Record; + expect(causeAnnotation.message).toBe('root cause'); + expect(causeAnnotation.name).toBe('Error'); + }); + + it('should unwrap nested cause chain', () => { + const root = new Error('root'); + const mid = createError('mid', root); + const top = createError('top', mid); + const report = new BacktraceReport(top); + + const annotation = report.annotations['error'] as Record; + const midAnnotation = annotation.cause as Record; + const rootAnnotation = midAnnotation.cause as Record; + expect(midAnnotation.message).toBe('mid'); + expect(rootAnnotation.message).toBe('root'); + expect(rootAnnotation.cause).toBeUndefined(); + }); + + it('should handle circular cause without stack overflow', () => { + const a = createError('error a'); + const b = createError('error b'); + a.cause = b; + b.cause = a; + + const report = new BacktraceReport(a); + + const annotation = report.annotations['error'] as Record; + const causeAnnotation = annotation.cause as Record; + expect(causeAnnotation.message).toBe('error b'); + // circular reference back to `a` — not recursed, falls through to string fallback + expect(causeAnnotation.cause).toEqual({ value: 'Error: error a' }); + }); + + it('should handle self-referencing cause without stack overflow', () => { + const error = createError('self'); + error.cause = error; + + const report = new BacktraceReport(error); + + const annotation = report.annotations['error'] as Record; + // cause points to itself — not recursed, falls through to string fallback + expect(annotation.cause).toEqual({ value: 'Error: self' }); + }); + + it('should handle non-Error cause as string value', () => { + const error = createError('fail', 'timeout'); + const report = new BacktraceReport(error); + + const annotation = report.annotations['error'] as Record; + expect(annotation.cause).toEqual({ value: 'timeout' }); + }); + + it('should handle non-Error object cause as string value', () => { + const error = createError('fail', { code: 'ENOENT' }); + const report = new BacktraceReport(error); + + const annotation = report.annotations['error'] as Record; + expect(annotation.cause).toEqual({ value: '[object Object]' }); + }); + + it('should set cause to undefined when no cause exists', () => { + const error = new Error('no cause'); + const report = new BacktraceReport(error); + + const annotation = report.annotations['error'] as Record; + expect(annotation.cause).toBeUndefined(); + }); + }); }); From 37316782b29fefbfb34112b45e15dfb603263016 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Fri, 17 Apr 2026 16:33:21 +0200 Subject: [PATCH 2/5] sdk-core: serialize everything possible during error serialization that is not type error --- .../src/model/report/BacktraceReport.ts | 2 +- .../sdk-core/tests/report/reportTests.spec.ts | 47 ++++++++++--------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/sdk-core/src/model/report/BacktraceReport.ts b/packages/sdk-core/src/model/report/BacktraceReport.ts index 5bbb34de..5c96fd31 100644 --- a/packages/sdk-core/src/model/report/BacktraceReport.ts +++ b/packages/sdk-core/src/model/report/BacktraceReport.ts @@ -120,7 +120,7 @@ export class BacktraceReport { error.cause instanceof Error && !seen.has(error.cause) ? this.unwrapErrorToAnnotation(error.cause, seen) : error.cause != null - ? { value: String(error.cause) } + ? { ...error.cause } : undefined, }; } diff --git a/packages/sdk-core/tests/report/reportTests.spec.ts b/packages/sdk-core/tests/report/reportTests.spec.ts index 9137a861..d7b800b6 100644 --- a/packages/sdk-core/tests/report/reportTests.spec.ts +++ b/packages/sdk-core/tests/report/reportTests.spec.ts @@ -102,7 +102,7 @@ describe('Backtrace report generation tests', () => { }); describe('error annotation cause unwrapping', () => { - type ErrorWithCause = Error & { cause?: unknown }; + type ErrorWithCause = Error & { cause?: ErrorWithCause }; /** * This function is a helper to fix a potential type issue between different @@ -115,13 +115,14 @@ describe('Backtrace report generation tests', () => { } it('should include cause in error annotation', () => { - const cause = new Error('root cause'); + const causeMessage = 'cause'; + const cause = new Error(causeMessage); const error = createError('top level', cause); const report = new BacktraceReport(error); - const annotation = report.annotations['error'] as Record; - const causeAnnotation = annotation.cause as Record; - expect(causeAnnotation.message).toBe('root cause'); + const annotation = report.annotations['error'] as ErrorWithCause; + const causeAnnotation = annotation.cause as ErrorWithCause; + expect(causeAnnotation.message).toBe(causeMessage); expect(causeAnnotation.name).toBe('Error'); }); @@ -131,25 +132,25 @@ describe('Backtrace report generation tests', () => { const top = createError('top', mid); const report = new BacktraceReport(top); - const annotation = report.annotations['error'] as Record; - const midAnnotation = annotation.cause as Record; - const rootAnnotation = midAnnotation.cause as Record; - expect(midAnnotation.message).toBe('mid'); - expect(rootAnnotation.message).toBe('root'); + const annotation = report.annotations['error'] as ErrorWithCause; + const midAnnotation = annotation.cause as ErrorWithCause; + const rootAnnotation = midAnnotation.cause as ErrorWithCause; + expect(midAnnotation.message).toBe(mid.message); + expect(rootAnnotation.message).toBe(root.message); expect(rootAnnotation.cause).toBeUndefined(); }); it('should handle circular cause without stack overflow', () => { - const a = createError('error a'); - const b = createError('error b'); - a.cause = b; - b.cause = a; + const topLevelError = createError('error a'); + const cause = createError('error b'); + topLevelError.cause = cause; + cause.cause = topLevelError; - const report = new BacktraceReport(a); + const report = new BacktraceReport(topLevelError); - const annotation = report.annotations['error'] as Record; - const causeAnnotation = annotation.cause as Record; - expect(causeAnnotation.message).toBe('error b'); + const annotation = report.annotations['error'] as ErrorWithCause; + const causeAnnotation = annotation.cause as ErrorWithCause; + expect(causeAnnotation.message).toBe(cause.message); // circular reference back to `a` — not recursed, falls through to string fallback expect(causeAnnotation.cause).toEqual({ value: 'Error: error a' }); }); @@ -160,7 +161,7 @@ describe('Backtrace report generation tests', () => { const report = new BacktraceReport(error); - const annotation = report.annotations['error'] as Record; + const annotation = report.annotations['error'] as ErrorWithCause; // cause points to itself — not recursed, falls through to string fallback expect(annotation.cause).toEqual({ value: 'Error: self' }); }); @@ -169,7 +170,7 @@ describe('Backtrace report generation tests', () => { const error = createError('fail', 'timeout'); const report = new BacktraceReport(error); - const annotation = report.annotations['error'] as Record; + const annotation = report.annotations['error'] as ErrorWithCause; expect(annotation.cause).toEqual({ value: 'timeout' }); }); @@ -177,15 +178,15 @@ describe('Backtrace report generation tests', () => { const error = createError('fail', { code: 'ENOENT' }); const report = new BacktraceReport(error); - const annotation = report.annotations['error'] as Record; - expect(annotation.cause).toEqual({ value: '[object Object]' }); + const annotation = report.annotations['error'] as ErrorWithCause; + expect(annotation.cause).toEqual({ code: 'ENOENT' }); }); it('should set cause to undefined when no cause exists', () => { const error = new Error('no cause'); const report = new BacktraceReport(error); - const annotation = report.annotations['error'] as Record; + const annotation = report.annotations['error'] as ErrorWithCause; expect(annotation.cause).toBeUndefined(); }); }); From 663e88d3d5a6e8c1fe9f4f7b4430142065adda91 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Fri, 17 Apr 2026 16:40:08 +0200 Subject: [PATCH 3/5] sdk-core: Fix invalid cast --- packages/sdk-core/tests/report/reportTests.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-core/tests/report/reportTests.spec.ts b/packages/sdk-core/tests/report/reportTests.spec.ts index d7b800b6..53ad5df4 100644 --- a/packages/sdk-core/tests/report/reportTests.spec.ts +++ b/packages/sdk-core/tests/report/reportTests.spec.ts @@ -102,7 +102,7 @@ describe('Backtrace report generation tests', () => { }); describe('error annotation cause unwrapping', () => { - type ErrorWithCause = Error & { cause?: ErrorWithCause }; + type ErrorWithCause = Error & { cause?: unknown }; /** * This function is a helper to fix a potential type issue between different From 618ac825f7ebfdff7d73bd2a971ae634e2661002 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Fri, 17 Apr 2026 17:20:47 +0200 Subject: [PATCH 4/5] sdk-core: handle properly circural reference and objects that can be available in cause --- .../sdk-core/src/model/report/BacktraceReport.ts | 12 ++++++++---- packages/sdk-core/tests/report/reportTests.spec.ts | 10 +++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/sdk-core/src/model/report/BacktraceReport.ts b/packages/sdk-core/src/model/report/BacktraceReport.ts index 5c96fd31..52692e70 100644 --- a/packages/sdk-core/src/model/report/BacktraceReport.ts +++ b/packages/sdk-core/src/model/report/BacktraceReport.ts @@ -117,10 +117,14 @@ export class BacktraceReport { name: error.name, stack: error.stack, cause: - error.cause instanceof Error && !seen.has(error.cause) - ? this.unwrapErrorToAnnotation(error.cause, seen) - : error.cause != null - ? { ...error.cause } + error.cause instanceof Error + ? seen.has(error.cause) + ? `[Circular] ${error.cause.message}` + : this.unwrapErrorToAnnotation(error.cause, seen) + : error.cause + ? typeof error.cause === 'object' + ? { ...error.cause } + : String(error.cause) : undefined, }; } diff --git a/packages/sdk-core/tests/report/reportTests.spec.ts b/packages/sdk-core/tests/report/reportTests.spec.ts index 53ad5df4..98df4aca 100644 --- a/packages/sdk-core/tests/report/reportTests.spec.ts +++ b/packages/sdk-core/tests/report/reportTests.spec.ts @@ -151,8 +151,8 @@ describe('Backtrace report generation tests', () => { const annotation = report.annotations['error'] as ErrorWithCause; const causeAnnotation = annotation.cause as ErrorWithCause; expect(causeAnnotation.message).toBe(cause.message); - // circular reference back to `a` — not recursed, falls through to string fallback - expect(causeAnnotation.cause).toEqual({ value: 'Error: error a' }); + // circular reference back to `topLevelError` — not recursed, produces circular placeholder + expect(causeAnnotation.cause).toEqual(`[Circular] ${topLevelError.message}`); }); it('should handle self-referencing cause without stack overflow', () => { @@ -162,8 +162,8 @@ describe('Backtrace report generation tests', () => { const report = new BacktraceReport(error); const annotation = report.annotations['error'] as ErrorWithCause; - // cause points to itself — not recursed, falls through to string fallback - expect(annotation.cause).toEqual({ value: 'Error: self' }); + // cause points to itself — not recursed, produces circular placeholder + expect(annotation.cause).toEqual(`[Circular] ${error.message}`); }); it('should handle non-Error cause as string value', () => { @@ -171,7 +171,7 @@ describe('Backtrace report generation tests', () => { const report = new BacktraceReport(error); const annotation = report.annotations['error'] as ErrorWithCause; - expect(annotation.cause).toEqual({ value: 'timeout' }); + expect(annotation.cause).toEqual('timeout'); }); it('should handle non-Error object cause as string value', () => { From b7e652700c0b6fc026a09425da84de407c853e0b Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Fri, 17 Apr 2026 17:55:27 +0200 Subject: [PATCH 5/5] sdk-core: simplify object serialization fallback --- packages/sdk-core/src/model/report/BacktraceReport.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/sdk-core/src/model/report/BacktraceReport.ts b/packages/sdk-core/src/model/report/BacktraceReport.ts index 52692e70..0c548a78 100644 --- a/packages/sdk-core/src/model/report/BacktraceReport.ts +++ b/packages/sdk-core/src/model/report/BacktraceReport.ts @@ -106,10 +106,7 @@ export class BacktraceReport { } } - private unwrapErrorToAnnotation( - error: Error, - seen = new WeakSet(), - ): Record { + private unwrapErrorToAnnotation(error: Error, seen = new WeakSet()): Record { seen.add(error); return { ...error, @@ -121,11 +118,7 @@ export class BacktraceReport { ? seen.has(error.cause) ? `[Circular] ${error.cause.message}` : this.unwrapErrorToAnnotation(error.cause, seen) - : error.cause - ? typeof error.cause === 'object' - ? { ...error.cause } - : String(error.cause) - : undefined, + : error.cause, }; }