Skip to content

Commit 9f14cc4

Browse files
authored
πŸ› add support for arbitrary Error causes (#3860)
✨ accept non-error as error cause πŸ‘· remove comments πŸ› format πŸ› remove strange edge case for safari πŸ› pr-feedback: type undefined for custom errors πŸ› pr-feedback: refactor error base Co-authored-by: adrian.delarosa <adrian.delarosa@datadoghq.com>
1 parent 9632043 commit 9f14cc4

File tree

3 files changed

+155
-29
lines changed

3 files changed

+155
-29
lines changed

β€Žpackages/core/src/domain/error/error.spec.tsβ€Ž

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

237-
it('should return undefined if cause is not of type Error', () => {
238-
const error = new Error('foo') as ErrorWithCause
239-
const nestedError = { biz: 'buz', cause: new Error('boo') } as unknown as Error
240-
241-
error.cause = nestedError
242-
243-
const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
244-
expect(errorCauses?.length).toEqual(undefined)
245-
})
246-
247237
it('should use error to extract stack trace', () => {
248238
const error = new Error('foo') as ErrorWithCause
249239

@@ -259,6 +249,96 @@ describe('flattenErrorCauses', () => {
259249
const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
260250
expect(errorCauses?.length).toEqual(10)
261251
})
252+
253+
describe('with non-Error values', () => {
254+
it('should handle string cause with consistent structure', () => {
255+
const error = new Error('main') as ErrorWithCause
256+
error.cause = 'string cause'
257+
258+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
259+
expect(causes?.length).toBe(1)
260+
expect(causes?.[0]).toEqual({
261+
message: '"string cause"', // JSON stringified
262+
source: ErrorSource.CUSTOM,
263+
type: undefined,
264+
stack: undefined,
265+
})
266+
})
267+
268+
it('should handle object cause with consistent structure', () => {
269+
const error = new Error('main') as ErrorWithCause
270+
error.cause = { code: 'ERR_001', details: 'Invalid input' }
271+
272+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
273+
expect(causes?.length).toBe(1)
274+
expect(causes?.[0]).toEqual({
275+
message: '{"code":"ERR_001","details":"Invalid input"}',
276+
source: ErrorSource.CUSTOM,
277+
type: undefined,
278+
stack: undefined,
279+
})
280+
})
281+
282+
it('should handle number cause with consistent structure', () => {
283+
const error = new Error('main') as ErrorWithCause
284+
error.cause = 42
285+
286+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
287+
expect(causes?.length).toBe(1)
288+
expect(causes?.[0]).toEqual({
289+
message: '42',
290+
source: ErrorSource.CUSTOM,
291+
type: undefined,
292+
stack: undefined,
293+
})
294+
})
295+
296+
it('should handle mixed Error and non-Error chain', () => {
297+
const error1 = new Error('first') as ErrorWithCause
298+
const error2 = new Error('second') as ErrorWithCause
299+
error1.cause = error2
300+
error2.cause = { code: 'ERR_ROOT' }
301+
302+
const causes = flattenErrorCauses(error1, ErrorSource.CUSTOM)
303+
expect(causes?.length).toBe(2)
304+
305+
// First cause: Error with full structure
306+
expect(causes?.[0].message).toBe('second')
307+
expect(causes?.[0].type).toBe('Error')
308+
expect(causes?.[0].stack).toContain('Error')
309+
310+
// Second cause: Object with normalized structure
311+
expect(causes?.[1]).toEqual({
312+
message: '{"code":"ERR_ROOT"}',
313+
source: ErrorSource.CUSTOM,
314+
type: undefined,
315+
stack: undefined,
316+
})
317+
})
318+
319+
it('should stop chain after non-Error cause', () => {
320+
const error = new Error('main') as ErrorWithCause
321+
error.cause = { value: 'data', cause: new Error('ignored') }
322+
323+
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
324+
expect(causes?.length).toBe(1)
325+
// The entire object is captured, nested cause is sanitized
326+
expect(causes?.[0].message).toContain('"value":"data"')
327+
expect(causes?.[0].type).toBeUndefined()
328+
})
329+
330+
it('should handle null cause', () => {
331+
const error = new Error('main') as ErrorWithCause
332+
error.cause = null
333+
expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined()
334+
})
335+
336+
it('should handle undefined cause', () => {
337+
const error = new Error('main') as ErrorWithCause
338+
error.cause = undefined
339+
expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined()
340+
})
341+
})
262342
})
263343

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

β€Žpackages/core/src/domain/error/error.tsβ€Ž

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@ interface RawErrorParams {
2121
handling: ErrorHandling
2222
}
2323

24+
function computeErrorBase({
25+
originalError,
26+
stackTrace,
27+
source,
28+
useFallbackStack = true,
29+
nonErrorPrefix,
30+
}: {
31+
originalError: unknown
32+
stackTrace?: StackTrace
33+
source: ErrorSource
34+
useFallbackStack?: boolean
35+
nonErrorPrefix?: NonErrorPrefix
36+
}) {
37+
const isErrorInstance = isError(originalError)
38+
if (!stackTrace && isErrorInstance) {
39+
stackTrace = computeStackTrace(originalError)
40+
}
41+
42+
return {
43+
source,
44+
type: stackTrace ? stackTrace.name : undefined,
45+
message: computeMessage(stackTrace, isErrorInstance, nonErrorPrefix, originalError),
46+
stack: stackTrace ? toStackTraceString(stackTrace) : useFallbackStack ? NO_ERROR_STACK_PRESENT_MESSAGE : undefined,
47+
}
48+
}
49+
2450
export function computeRawError({
2551
stackTrace,
2652
originalError,
@@ -32,22 +58,16 @@ export function computeRawError({
3258
source,
3359
handling,
3460
}: RawErrorParams): RawError {
35-
const isErrorInstance = isError(originalError)
36-
if (!stackTrace && isErrorInstance) {
37-
stackTrace = computeStackTrace(originalError)
38-
}
61+
const errorBase = computeErrorBase({ originalError, stackTrace, source, useFallbackStack, nonErrorPrefix })
3962

4063
return {
4164
startClocks,
42-
source,
4365
handling,
4466
handlingStack,
4567
componentStack,
4668
originalError,
47-
type: stackTrace ? stackTrace.name : undefined,
48-
message: computeMessage(stackTrace, isErrorInstance, nonErrorPrefix, originalError),
49-
stack: stackTrace ? toStackTraceString(stackTrace) : useFallbackStack ? NO_ERROR_STACK_PRESENT_MESSAGE : undefined,
50-
causes: isErrorInstance ? flattenErrorCauses(originalError as ErrorWithCause, source) : undefined,
69+
...errorBase,
70+
causes: isError(originalError) ? flattenErrorCauses(originalError as ErrorWithCause, source) : undefined,
5171
fingerprint: tryToGetFingerprint(originalError),
5272
context: tryToGetErrorContext(originalError),
5373
}
@@ -56,15 +76,17 @@ export function computeRawError({
5676
function computeMessage(
5777
stackTrace: StackTrace | undefined,
5878
isErrorInstance: boolean,
59-
nonErrorPrefix: NonErrorPrefix,
79+
nonErrorPrefix: NonErrorPrefix | undefined,
6080
originalError: unknown
6181
) {
6282
// Favor stackTrace message only if tracekit has really been able to extract something meaningful (message + name)
6383
// TODO rework tracekit integration to avoid scattering error building logic
6484
return stackTrace?.message && stackTrace?.name
6585
? stackTrace.message
6686
: !isErrorInstance
67-
? `${nonErrorPrefix} ${jsonStringify(sanitize(originalError))!}`
87+
? nonErrorPrefix
88+
? `${nonErrorPrefix} ${jsonStringify(sanitize(originalError))!}`
89+
: jsonStringify(sanitize(originalError))!
6890
: 'Empty message'
6991
}
7092

@@ -87,17 +109,20 @@ export function isError(error: unknown): error is Error {
87109
}
88110

89111
export function flattenErrorCauses(error: ErrorWithCause, parentSource: ErrorSource): RawErrorCause[] | undefined {
90-
let currentError = error
91112
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,
113+
let currentCause = error.cause
114+
115+
while (currentCause !== undefined && currentCause !== null && causes.length < 10) {
116+
const causeBase = computeErrorBase({
117+
originalError: currentCause,
96118
source: parentSource,
97-
type: stackTrace?.name,
98-
stack: stackTrace && toStackTraceString(stackTrace),
119+
useFallbackStack: false,
99120
})
100-
currentError = currentError.cause
121+
122+
causes.push(causeBase)
123+
124+
currentCause = isError(currentCause) ? (currentCause as ErrorWithCause).cause : undefined
101125
}
126+
102127
return causes.length ? causes : undefined
103128
}

β€Ž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: undefined,
146+
stack: undefined,
147+
})
148+
})
149+
129150
it('should extract fingerprint from error', () => {
130151
setupErrorCollection()
131152

0 commit comments

Comments
Β (0)