From 671d0da06473c20470db276ccb4ebbb9a25e616e Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Wed, 26 Nov 2025 16:54:10 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20accept=20non-error=20as=20error?= =?UTF-8?q?=20cause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/domain/error/error.spec.ts | 103 +++++++++++++++++- packages/core/src/domain/error/error.ts | 38 +++++-- .../src/domain/error/errorCollection.spec.ts | 21 ++++ 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/packages/core/src/domain/error/error.spec.ts b/packages/core/src/domain/error/error.spec.ts index b0580ecc37..eaa328fb6d 100644 --- a/packages/core/src/domain/error/error.spec.ts +++ b/packages/core/src/domain/error/error.spec.ts @@ -234,14 +234,19 @@ describe('flattenErrorCauses', () => { expect(errorCauses).toEqual(undefined) }) - it('should return undefined if cause is not of type Error', () => { + it('should support non-Error causes with consistent structure', () => { const error = new Error('foo') as ErrorWithCause - const nestedError = { biz: 'buz', cause: new Error('boo') } as unknown as Error - + const nestedError = { biz: 'buz', cause: new Error('boo') } error.cause = nestedError const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER) - expect(errorCauses?.length).toEqual(undefined) + expect(errorCauses?.length).toEqual(1) + expect(errorCauses?.[0]).toEqual({ + message: '{"biz":"buz","cause":{}}', // JSON stringified, cause is sanitized + source: ErrorSource.LOGGER, + type: 'object', + stack: undefined, + }) }) it('should use error to extract stack trace', () => { @@ -259,6 +264,96 @@ describe('flattenErrorCauses', () => { const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER) expect(errorCauses?.length).toEqual(10) }) + + describe('with non-Error values', () => { + it('should handle string cause with consistent structure', () => { + const error = new Error('main') as ErrorWithCause + error.cause = 'string cause' + + const causes = flattenErrorCauses(error, ErrorSource.CUSTOM) + expect(causes?.length).toBe(1) + expect(causes?.[0]).toEqual({ + message: '"string cause"', // JSON stringified + source: ErrorSource.CUSTOM, + type: 'string', + stack: undefined, + }) + }) + + it('should handle object cause with consistent structure', () => { + const error = new Error('main') as ErrorWithCause + error.cause = { code: 'ERR_001', details: 'Invalid input' } + + const causes = flattenErrorCauses(error, ErrorSource.CUSTOM) + expect(causes?.length).toBe(1) + expect(causes?.[0]).toEqual({ + message: '{"code":"ERR_001","details":"Invalid input"}', + source: ErrorSource.CUSTOM, + type: 'object', + stack: undefined, + }) + }) + + it('should handle number cause with consistent structure', () => { + const error = new Error('main') as ErrorWithCause + error.cause = 42 + + const causes = flattenErrorCauses(error, ErrorSource.CUSTOM) + expect(causes?.length).toBe(1) + expect(causes?.[0]).toEqual({ + message: '42', + source: ErrorSource.CUSTOM, + type: 'number', + stack: undefined, + }) + }) + + it('should handle mixed Error and non-Error chain', () => { + const error1 = new Error('first') as ErrorWithCause + const error2 = new Error('second') as ErrorWithCause + error1.cause = error2 + error2.cause = { code: 'ERR_ROOT' } + + const causes = flattenErrorCauses(error1, ErrorSource.CUSTOM) + expect(causes?.length).toBe(2) + + // First cause: Error with full structure + expect(causes?.[0].message).toBe('second') + expect(causes?.[0].type).toBe('Error') + expect(causes?.[0].stack).toContain('Error') + + // Second cause: Object with normalized structure + expect(causes?.[1]).toEqual({ + message: '{"code":"ERR_ROOT"}', + source: ErrorSource.CUSTOM, + type: 'object', + stack: undefined, + }) + }) + + it('should stop chain after non-Error cause', () => { + const error = new Error('main') as ErrorWithCause + error.cause = { value: 'data', cause: new Error('ignored') } + + const causes = flattenErrorCauses(error, ErrorSource.CUSTOM) + expect(causes?.length).toBe(1) + // The entire object is captured, nested cause is sanitized + expect(causes?.[0].message).toContain('"value":"data"') + expect(causes?.[0].type).toBe('object') + }) + + it('should handle null cause', () => { + const error = new Error('main') as ErrorWithCause + error.cause = null + expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined() + }) + + it('should handle undefined cause', () => { + const error = new Error('main') as ErrorWithCause + error.cause = undefined + expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined() + }) + }) }) describe('isError', () => { diff --git a/packages/core/src/domain/error/error.ts b/packages/core/src/domain/error/error.ts index 52a42fb5a5..0166c60ed5 100644 --- a/packages/core/src/domain/error/error.ts +++ b/packages/core/src/domain/error/error.ts @@ -87,17 +87,35 @@ export function isError(error: unknown): error is Error { } export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSource): RawErrorCause[] | undefined { - let currentError = error const causes: RawErrorCause[] = [] - while (isError(currentError?.cause) && causes.length < 10) { - const stackTrace = computeStackTrace(currentError.cause) - causes.push({ - message: currentError.cause.message, - source: parentSource, - type: stackTrace?.name, - stack: stackTrace && toStackTraceString(stackTrace), - }) - currentError = currentError.cause + let currentCause = error.cause + + while (currentCause !== undefined && currentCause !== null && causes.length < 10) { + if (isError(currentCause)) { + // Handle Error objects: extract structured data + const stackTrace = computeStackTrace(currentCause) + causes.push({ + message: currentCause.message, + source: parentSource, + type: stackTrace?.name, + stack: stackTrace && toStackTraceString(stackTrace), + }) + + // Continue chain through Error's cause + currentCause = (currentCause as ErrorWithCause).cause + } else { + // Handle non-Error values: normalize to same structure + causes.push({ + message: jsonStringify(sanitize(currentCause))!, + source: parentSource, + type: typeof currentCause, + stack: undefined, + }) + + // Terminate chain after non-Error cause + currentCause = undefined + } } + return causes.length ? causes : undefined } diff --git a/packages/rum-core/src/domain/error/errorCollection.spec.ts b/packages/rum-core/src/domain/error/errorCollection.spec.ts index 2efeb35fda..d55ad662c7 100644 --- a/packages/rum-core/src/domain/error/errorCollection.spec.ts +++ b/packages/rum-core/src/domain/error/errorCollection.spec.ts @@ -126,6 +126,27 @@ describe('error collection', () => { expect(error?.causes?.[1].source).toEqual(ErrorSource.CUSTOM) }) + it('should extract non-Error causes with consistent structure', () => { + setupErrorCollection() + const error = new Error('RSA key generation failed') as ErrorWithCause + error.cause = { code: 'NonInteger', values: [3.14, 2.71] } + + addError({ + error, + handlingStack: 'Error: handling', + startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, + }) + + const { error: rumError } = rawRumEvents[0].rawRumEvent as RawRumErrorEvent + expect(rumError.causes?.length).toBe(1) + expect(rumError.causes?.[0]).toEqual({ + message: '{"code":"NonInteger","values":[3.14,2.71]}', + source: ErrorSource.CUSTOM, + type: 'object', + stack: undefined, + }) + }) + it('should extract fingerprint from error', () => { setupErrorCollection() From f91c1a0a1461b4f145c9cc088a1a1f1c676e2dcd Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Wed, 26 Nov 2025 17:10:10 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=91=B7=20remove=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/domain/error/error.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/src/domain/error/error.ts b/packages/core/src/domain/error/error.ts index 0166c60ed5..eb7bab80b0 100644 --- a/packages/core/src/domain/error/error.ts +++ b/packages/core/src/domain/error/error.ts @@ -92,7 +92,6 @@ export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSou while (currentCause !== undefined && currentCause !== null && causes.length < 10) { if (isError(currentCause)) { - // Handle Error objects: extract structured data const stackTrace = computeStackTrace(currentCause) causes.push({ message: currentCause.message, @@ -101,10 +100,8 @@ export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSou stack: stackTrace && toStackTraceString(stackTrace), }) - // Continue chain through Error's cause currentCause = (currentCause as ErrorWithCause).cause } else { - // Handle non-Error values: normalize to same structure causes.push({ message: jsonStringify(sanitize(currentCause))!, source: parentSource, @@ -112,7 +109,6 @@ export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSou stack: undefined, }) - // Terminate chain after non-Error cause currentCause = undefined } } From 8f881d2340b0be3b337c24121609f6a909be99ec Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Wed, 26 Nov 2025 17:18:20 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/domain/error/error.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/domain/error/error.spec.ts b/packages/core/src/domain/error/error.spec.ts index eaa328fb6d..5702b09186 100644 --- a/packages/core/src/domain/error/error.spec.ts +++ b/packages/core/src/domain/error/error.spec.ts @@ -242,7 +242,7 @@ describe('flattenErrorCauses', () => { const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER) expect(errorCauses?.length).toEqual(1) expect(errorCauses?.[0]).toEqual({ - message: '{"biz":"buz","cause":{}}', // JSON stringified, cause is sanitized + message: '{"biz":"buz","cause":{}}', // JSON stringified, cause is sanitized source: ErrorSource.LOGGER, type: 'object', stack: undefined, @@ -273,7 +273,7 @@ describe('flattenErrorCauses', () => { const causes = flattenErrorCauses(error, ErrorSource.CUSTOM) expect(causes?.length).toBe(1) expect(causes?.[0]).toEqual({ - message: '"string cause"', // JSON stringified + message: '"string cause"', // JSON stringified source: ErrorSource.CUSTOM, type: 'string', stack: undefined, From 089bed772d3ac780273b3fbe298e2d182a2f5943 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Wed, 26 Nov 2025 18:14:21 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20remove=20strange=20edge=20ca?= =?UTF-8?q?se=20for=20safari?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/domain/error/error.spec.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/core/src/domain/error/error.spec.ts b/packages/core/src/domain/error/error.spec.ts index 5702b09186..4c57ba8509 100644 --- a/packages/core/src/domain/error/error.spec.ts +++ b/packages/core/src/domain/error/error.spec.ts @@ -234,21 +234,6 @@ describe('flattenErrorCauses', () => { expect(errorCauses).toEqual(undefined) }) - it('should support non-Error causes with consistent structure', () => { - const error = new Error('foo') as ErrorWithCause - const nestedError = { biz: 'buz', cause: new Error('boo') } - error.cause = nestedError - - const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER) - expect(errorCauses?.length).toEqual(1) - expect(errorCauses?.[0]).toEqual({ - message: '{"biz":"buz","cause":{}}', // JSON stringified, cause is sanitized - source: ErrorSource.LOGGER, - type: 'object', - stack: undefined, - }) - }) - it('should use error to extract stack trace', () => { const error = new Error('foo') as ErrorWithCause From 245b580ab6100d61a77abb141149471969aa3460 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Thu, 27 Nov 2025 11:08:29 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20pr-feedback:=20type=20undefi?= =?UTF-8?q?ned=20for=20custom=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/domain/error/error.spec.ts | 10 +++++----- packages/core/src/domain/error/error.ts | 2 +- .../rum-core/src/domain/error/errorCollection.spec.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/domain/error/error.spec.ts b/packages/core/src/domain/error/error.spec.ts index 4c57ba8509..ce6c9f4a44 100644 --- a/packages/core/src/domain/error/error.spec.ts +++ b/packages/core/src/domain/error/error.spec.ts @@ -260,7 +260,7 @@ describe('flattenErrorCauses', () => { expect(causes?.[0]).toEqual({ message: '"string cause"', // JSON stringified source: ErrorSource.CUSTOM, - type: 'string', + type: undefined, stack: undefined, }) }) @@ -274,7 +274,7 @@ describe('flattenErrorCauses', () => { expect(causes?.[0]).toEqual({ message: '{"code":"ERR_001","details":"Invalid input"}', source: ErrorSource.CUSTOM, - type: 'object', + type: undefined, stack: undefined, }) }) @@ -288,7 +288,7 @@ describe('flattenErrorCauses', () => { expect(causes?.[0]).toEqual({ message: '42', source: ErrorSource.CUSTOM, - type: 'number', + type: undefined, stack: undefined, }) }) @@ -311,7 +311,7 @@ describe('flattenErrorCauses', () => { expect(causes?.[1]).toEqual({ message: '{"code":"ERR_ROOT"}', source: ErrorSource.CUSTOM, - type: 'object', + type: undefined, stack: undefined, }) }) @@ -324,7 +324,7 @@ describe('flattenErrorCauses', () => { expect(causes?.length).toBe(1) // The entire object is captured, nested cause is sanitized expect(causes?.[0].message).toContain('"value":"data"') - expect(causes?.[0].type).toBe('object') + expect(causes?.[0].type).toBeUndefined() }) it('should handle null cause', () => { diff --git a/packages/core/src/domain/error/error.ts b/packages/core/src/domain/error/error.ts index eb7bab80b0..d7ae37d577 100644 --- a/packages/core/src/domain/error/error.ts +++ b/packages/core/src/domain/error/error.ts @@ -105,7 +105,7 @@ export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSou causes.push({ message: jsonStringify(sanitize(currentCause))!, source: parentSource, - type: typeof currentCause, + type: undefined, stack: undefined, }) diff --git a/packages/rum-core/src/domain/error/errorCollection.spec.ts b/packages/rum-core/src/domain/error/errorCollection.spec.ts index d55ad662c7..2a3d203d44 100644 --- a/packages/rum-core/src/domain/error/errorCollection.spec.ts +++ b/packages/rum-core/src/domain/error/errorCollection.spec.ts @@ -142,7 +142,7 @@ describe('error collection', () => { expect(rumError.causes?.[0]).toEqual({ message: '{"code":"NonInteger","values":[3.14,2.71]}', source: ErrorSource.CUSTOM, - type: 'object', + type: undefined, stack: undefined, }) }) From 763bee750fcdce205dca63544e3609b896344bfe Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Thu, 27 Nov 2025 11:20:08 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=90=9B=20pr-feedback:=20refactor=20er?= =?UTF-8?q?ror=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/domain/error/error.ts | 73 ++++++++++++++----------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/packages/core/src/domain/error/error.ts b/packages/core/src/domain/error/error.ts index d7ae37d577..94a2da2145 100644 --- a/packages/core/src/domain/error/error.ts +++ b/packages/core/src/domain/error/error.ts @@ -21,6 +21,32 @@ interface RawErrorParams { handling: ErrorHandling } +function computeErrorBase({ + originalError, + stackTrace, + source, + useFallbackStack = true, + nonErrorPrefix, +}: { + originalError: unknown + stackTrace?: StackTrace + source: ErrorSource + useFallbackStack?: boolean + nonErrorPrefix?: NonErrorPrefix +}) { + const isErrorInstance = isError(originalError) + if (!stackTrace && isErrorInstance) { + stackTrace = computeStackTrace(originalError) + } + + return { + source, + type: stackTrace ? stackTrace.name : undefined, + message: computeMessage(stackTrace, isErrorInstance, nonErrorPrefix, originalError), + stack: stackTrace ? toStackTraceString(stackTrace) : useFallbackStack ? NO_ERROR_STACK_PRESENT_MESSAGE : undefined, + } +} + export function computeRawError({ stackTrace, originalError, @@ -32,22 +58,16 @@ export function computeRawError({ source, handling, }: RawErrorParams): RawError { - const isErrorInstance = isError(originalError) - if (!stackTrace && isErrorInstance) { - stackTrace = computeStackTrace(originalError) - } + const errorBase = computeErrorBase({ originalError, stackTrace, source, useFallbackStack, nonErrorPrefix }) return { startClocks, - source, handling, handlingStack, componentStack, originalError, - type: stackTrace ? stackTrace.name : undefined, - message: computeMessage(stackTrace, isErrorInstance, nonErrorPrefix, originalError), - stack: stackTrace ? toStackTraceString(stackTrace) : useFallbackStack ? NO_ERROR_STACK_PRESENT_MESSAGE : undefined, - causes: isErrorInstance ? flattenErrorCauses(originalError as ErrorWithCause, source) : undefined, + ...errorBase, + causes: isError(originalError) ? flattenErrorCauses(originalError as ErrorWithCause, source) : undefined, fingerprint: tryToGetFingerprint(originalError), context: tryToGetErrorContext(originalError), } @@ -56,7 +76,7 @@ export function computeRawError({ function computeMessage( stackTrace: StackTrace | undefined, isErrorInstance: boolean, - nonErrorPrefix: NonErrorPrefix, + nonErrorPrefix: NonErrorPrefix | undefined, originalError: unknown ) { // Favor stackTrace message only if tracekit has really been able to extract something meaningful (message + name) @@ -64,7 +84,9 @@ function computeMessage( return stackTrace?.message && stackTrace?.name ? stackTrace.message : !isErrorInstance - ? `${nonErrorPrefix} ${jsonStringify(sanitize(originalError))!}` + ? nonErrorPrefix + ? `${nonErrorPrefix} ${jsonStringify(sanitize(originalError))!}` + : jsonStringify(sanitize(originalError))! : 'Empty message' } @@ -91,26 +113,15 @@ export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSou let currentCause = error.cause while (currentCause !== undefined && currentCause !== null && causes.length < 10) { - if (isError(currentCause)) { - const stackTrace = computeStackTrace(currentCause) - causes.push({ - message: currentCause.message, - source: parentSource, - type: stackTrace?.name, - stack: stackTrace && toStackTraceString(stackTrace), - }) - - currentCause = (currentCause as ErrorWithCause).cause - } else { - causes.push({ - message: jsonStringify(sanitize(currentCause))!, - source: parentSource, - type: undefined, - stack: undefined, - }) - - currentCause = undefined - } + const causeBase = computeErrorBase({ + originalError: currentCause, + source: parentSource, + useFallbackStack: false, + }) + + causes.push(causeBase) + + currentCause = isError(currentCause) ? (currentCause as ErrorWithCause).cause : undefined } return causes.length ? causes : undefined