Skip to content

Commit 671d0da

Browse files
committed
✨ accept non-error as error cause
1 parent 70cf96a commit 671d0da

File tree

3 files changed

+148
-14
lines changed

3 files changed

+148
-14
lines changed

packages/core/src/domain/error/error.spec.ts

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,14 +234,19 @@ describe('flattenErrorCauses', () => {
234234
expect(errorCauses).toEqual(undefined)
235235
})
236236

237-
it('should return undefined if cause is not of type Error', () => {
237+
it('should support non-Error causes with consistent structure', () => {
238238
const error = new Error('foo') as ErrorWithCause
239-
const nestedError = { biz: 'buz', cause: new Error('boo') } as unknown as Error
240-
239+
const nestedError = { biz: 'buz', cause: new Error('boo') }
241240
error.cause = nestedError
242241

243242
const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
244-
expect(errorCauses?.length).toEqual(undefined)
243+
expect(errorCauses?.length).toEqual(1)
244+
expect(errorCauses?.[0]).toEqual({
245+
message: '{"biz":"buz","cause":{}}', // JSON stringified, cause is sanitized
246+
source: ErrorSource.LOGGER,
247+
type: 'object',
248+
stack: undefined,
249+
})
245250
})
246251

247252
it('should use error to extract stack trace', () => {
@@ -259,6 +264,96 @@ describe('flattenErrorCauses', () => {
259264
const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
260265
expect(errorCauses?.length).toEqual(10)
261266
})
267+
268+
describe('with non-Error values', () => {
269+
it('should handle string cause with consistent structure', () => {
270+
const error = new Error('main') as ErrorWithCause
271+
error.cause = 'string cause'
272+
273+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
274+
expect(causes?.length).toBe(1)
275+
expect(causes?.[0]).toEqual({
276+
message: '"string cause"', // JSON stringified
277+
source: ErrorSource.CUSTOM,
278+
type: 'string',
279+
stack: undefined,
280+
})
281+
})
282+
283+
it('should handle object cause with consistent structure', () => {
284+
const error = new Error('main') as ErrorWithCause
285+
error.cause = { code: 'ERR_001', details: 'Invalid input' }
286+
287+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
288+
expect(causes?.length).toBe(1)
289+
expect(causes?.[0]).toEqual({
290+
message: '{"code":"ERR_001","details":"Invalid input"}',
291+
source: ErrorSource.CUSTOM,
292+
type: 'object',
293+
stack: undefined,
294+
})
295+
})
296+
297+
it('should handle number cause with consistent structure', () => {
298+
const error = new Error('main') as ErrorWithCause
299+
error.cause = 42
300+
301+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
302+
expect(causes?.length).toBe(1)
303+
expect(causes?.[0]).toEqual({
304+
message: '42',
305+
source: ErrorSource.CUSTOM,
306+
type: 'number',
307+
stack: undefined,
308+
})
309+
})
310+
311+
it('should handle mixed Error and non-Error chain', () => {
312+
const error1 = new Error('first') as ErrorWithCause
313+
const error2 = new Error('second') as ErrorWithCause
314+
error1.cause = error2
315+
error2.cause = { code: 'ERR_ROOT' }
316+
317+
const causes = flattenErrorCauses(error1, ErrorSource.CUSTOM)
318+
expect(causes?.length).toBe(2)
319+
320+
// First cause: Error with full structure
321+
expect(causes?.[0].message).toBe('second')
322+
expect(causes?.[0].type).toBe('Error')
323+
expect(causes?.[0].stack).toContain('Error')
324+
325+
// Second cause: Object with normalized structure
326+
expect(causes?.[1]).toEqual({
327+
message: '{"code":"ERR_ROOT"}',
328+
source: ErrorSource.CUSTOM,
329+
type: 'object',
330+
stack: undefined,
331+
})
332+
})
333+
334+
it('should stop chain after non-Error cause', () => {
335+
const error = new Error('main') as ErrorWithCause
336+
error.cause = { value: 'data', cause: new Error('ignored') }
337+
338+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
339+
expect(causes?.length).toBe(1)
340+
// The entire object is captured, nested cause is sanitized
341+
expect(causes?.[0].message).toContain('"value":"data"')
342+
expect(causes?.[0].type).toBe('object')
343+
})
344+
345+
it('should handle null cause', () => {
346+
const error = new Error('main') as ErrorWithCause
347+
error.cause = null
348+
expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined()
349+
})
350+
351+
it('should handle undefined cause', () => {
352+
const error = new Error('main') as ErrorWithCause
353+
error.cause = undefined
354+
expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined()
355+
})
356+
})
262357
})
263358

264359
describe('isError', () => {

packages/core/src/domain/error/error.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,35 @@ export function isError(error: unknown): error is Error {
8787
}
8888

8989
export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSource): RawErrorCause[] | undefined {
90-
let currentError = error
9190
const causes: RawErrorCause[] = []
92-
while (isError(currentError?.cause) && causes.length < 10) {
93-
const stackTrace = computeStackTrace(currentError.cause)
94-
causes.push({
95-
message: currentError.cause.message,
96-
source: parentSource,
97-
type: stackTrace?.name,
98-
stack: stackTrace && toStackTraceString(stackTrace),
99-
})
100-
currentError = currentError.cause
91+
let currentCause = error.cause
92+
93+
while (currentCause !== undefined && currentCause !== null && causes.length < 10) {
94+
if (isError(currentCause)) {
95+
// Handle Error objects: extract structured data
96+
const stackTrace = computeStackTrace(currentCause)
97+
causes.push({
98+
message: currentCause.message,
99+
source: parentSource,
100+
type: stackTrace?.name,
101+
stack: stackTrace && toStackTraceString(stackTrace),
102+
})
103+
104+
// Continue chain through Error's cause
105+
currentCause = (currentCause as ErrorWithCause).cause
106+
} else {
107+
// Handle non-Error values: normalize to same structure
108+
causes.push({
109+
message: jsonStringify(sanitize(currentCause))!,
110+
source: parentSource,
111+
type: typeof currentCause,
112+
stack: undefined,
113+
})
114+
115+
// Terminate chain after non-Error cause
116+
currentCause = undefined
117+
}
101118
}
119+
102120
return causes.length ? causes : undefined
103121
}

packages/rum-core/src/domain/error/errorCollection.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,27 @@ describe('error collection', () => {
126126
expect(error?.causes?.[1].source).toEqual(ErrorSource.CUSTOM)
127127
})
128128

129+
it('should extract non-Error causes with consistent structure', () => {
130+
setupErrorCollection()
131+
const error = new Error('RSA key generation failed') as ErrorWithCause
132+
error.cause = { code: 'NonInteger', values: [3.14, 2.71] }
133+
134+
addError({
135+
error,
136+
handlingStack: 'Error: handling',
137+
startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp },
138+
})
139+
140+
const { error: rumError } = rawRumEvents[0].rawRumEvent as RawRumErrorEvent
141+
expect(rumError.causes?.length).toBe(1)
142+
expect(rumError.causes?.[0]).toEqual({
143+
message: '{"code":"NonInteger","values":[3.14,2.71]}',
144+
source: ErrorSource.CUSTOM,
145+
type: 'object',
146+
stack: undefined,
147+
})
148+
})
149+
129150
it('should extract fingerprint from error', () => {
130151
setupErrorCollection()
131152

0 commit comments

Comments
 (0)