Skip to content

Commit d2c7e30

Browse files
✨ [RUM-16925] attach debug IDs from DD_SOURCE_CODE_CONTEXT to error and long task events (#4812)
1 parent 1e8224a commit d2c7e30

32 files changed

Lines changed: 818 additions & 334 deletions

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

Lines changed: 165 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import { clocksNow } from '@datadog/js-core/time'
22
import type { StackTrace } from '../../tools/stackTrace/computeStackTrace'
3-
import { registerCleanupTask } from '../../../test'
4-
import {
5-
computeRawError,
6-
getFileFromStackTraceString,
7-
flattenErrorCauses,
8-
isError,
9-
NO_ERROR_STACK_PRESENT_MESSAGE,
10-
} from './error'
3+
import { mockSourceCodeContext, registerCleanupTask } from '../../../test'
4+
import { computeRawError, getFileFromStackTraceString, isError, NO_ERROR_STACK_PRESENT_MESSAGE } from './error'
115
import type { RawErrorCause, ErrorWithCause } from './error.types'
126
import { ErrorHandling, ErrorSource, NonErrorPrefix } from './error.types'
137

@@ -77,7 +71,7 @@ describe('computeRawError', () => {
7771
expect(formatted.stack).toEqual(NO_ERROR_STACK_PRESENT_MESSAGE)
7872
})
7973

80-
it('does not define the stack if useFallbackStack is false', () => {
74+
it('should not define the stack if useFallbackStack is false', () => {
8175
const error = 'foo is undefined'
8276

8377
const formatted = computeRawError({
@@ -90,7 +84,7 @@ describe('computeRawError', () => {
9084
expect(formatted.stack).toEqual(undefined)
9185
})
9286

93-
it('uses the provided stack trace object', () => {
87+
it('should use the provided stack trace object', () => {
9488
const stackTrace: StackTrace = {
9589
message: 'oh snap!',
9690
name: 'TypeError',
@@ -154,42 +148,30 @@ describe('computeRawError', () => {
154148
).toEqual(ErrorHandling.UNHANDLED)
155149
})
156150

157-
it('should compute an object error with causes', () => {
158-
const error = new Error('foo: bar') as ErrorWithCause
159-
error.stack = 'Error: foo: bar\n at <anonymous>:1:15'
151+
it('should attach debug IDs for stack frame URLs present in the source code context', () => {
152+
const url = 'http://path/to/debug-id.js'
153+
mockSourceCodeContext({ [`Error: ctx\n at fn (${url}:1:1)`]: { service: 'svc', ddDebugId: 'debug-id-1' } })
160154

161-
const nestedError = new Error('biz: buz') as ErrorWithCause
162-
nestedError.stack = 'NestedError: biz: buz\n at <anonymous>:2:15'
163-
164-
const deepNestedError = new TypeError('fiz: buz') as ErrorWithCause
165-
deepNestedError.stack = 'NestedError: fiz: buz\n at <anonymous>:3:15'
166-
167-
error.cause = nestedError
168-
nestedError.cause = deepNestedError
155+
const error = new Error('oh snap!')
156+
error.stack = `Error: oh snap!\n at foo (${url}:52:15)`
169157

170158
const formatted = computeRawError({
171159
...DEFAULT_RAW_ERROR_PARAMS,
172160
originalError: error,
173161
handling: ErrorHandling.HANDLED,
174-
source: ErrorSource.SOURCE,
175162
})
176163

177-
expect(formatted.type).toEqual('Error')
178-
expect(formatted.message).toEqual('foo: bar')
179-
expect(formatted.causes!.length).toBe(2)
180-
expect(formatted.stack).toContain('Error: foo: bar')
181-
182-
const causes = formatted.causes as RawErrorCause[]
164+
expect(formatted.debugIds).toEqual({ [url]: 'debug-id-1' })
165+
})
183166

184-
expect(causes[0].message).toContain(nestedError.message)
185-
expect(causes[0].source).toContain(ErrorSource.SOURCE)
186-
expect(causes[0].type).toEqual(nestedError.name)
187-
expect(causes[0].stack).toContain('Error: biz: buz')
167+
it('should not attach debug IDs when the error has no stack', () => {
168+
const formatted = computeRawError({
169+
...DEFAULT_RAW_ERROR_PARAMS,
170+
originalError: 'oh snap!',
171+
handling: ErrorHandling.HANDLED,
172+
})
188173

189-
expect(causes[1].message).toContain(deepNestedError.message)
190-
expect(causes[1].source).toContain(ErrorSource.SOURCE)
191-
expect(causes[1].type).toEqual(deepNestedError.name)
192-
expect(causes[1].stack).toContain('Error: fiz: buz')
174+
expect(formatted.debugIds).toBeUndefined()
193175
})
194176

195177
it('should propagate the original error without modifications', () => {
@@ -210,105 +192,156 @@ describe('computeRawError', () => {
210192
expect(formattedWithStackTrace.originalError).toBe(error)
211193
expect(formattedWithoutStackTrace.originalError).toBe(error)
212194
})
213-
})
214195

215-
describe('getFileFromStackTraceString', () => {
216-
it('should get the first source file of the stack', () => {
217-
expect(
218-
getFileFromStackTraceString(`TypeError: oh snap!
219-
at foo(1, bar) @ http://path/to/file.js:52:15
220-
at <anonymous> @ http://path/to/file.js:12
221-
at <anonymous>(baz) @ http://path/to/file.js`)
222-
).toEqual('http://path/to/file.js:52:15')
223-
})
196+
describe('causes', () => {
197+
it('should compute an object error with causes', () => {
198+
const error = new Error('foo: bar') as ErrorWithCause
199+
error.stack = 'Error: foo: bar\n at <anonymous>:1:15'
224200

225-
it('should get undefined if no source file is in the stack', () => {
226-
expect(getFileFromStackTraceString('TypeError: oh snap!')).not.toBeDefined()
227-
})
228-
})
201+
const nestedError = new Error('biz: buz') as ErrorWithCause
202+
nestedError.stack = 'NestedError: biz: buz\n at <anonymous>:2:15'
229203

230-
describe('flattenErrorCauses', () => {
231-
it('should return undefined if no cause found', () => {
232-
const error = new Error('foo') as ErrorWithCause
233-
const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
234-
expect(errorCauses).toEqual(undefined)
235-
})
204+
const deepNestedError = new TypeError('fiz: buz') as ErrorWithCause
205+
deepNestedError.stack = 'NestedError: fiz: buz\n at <anonymous>:3:15'
206+
207+
error.cause = nestedError
208+
nestedError.cause = deepNestedError
209+
210+
const formatted = computeRawError({
211+
...DEFAULT_RAW_ERROR_PARAMS,
212+
originalError: error,
213+
handling: ErrorHandling.HANDLED,
214+
source: ErrorSource.SOURCE,
215+
})
236216

237-
it('should use error to extract stack trace', () => {
238-
const error = new Error('foo') as ErrorWithCause
217+
expect(formatted.type).toEqual('Error')
218+
expect(formatted.message).toEqual('foo: bar')
219+
expect(formatted.causes!.length).toBe(2)
220+
expect(formatted.stack).toContain('Error: foo: bar')
239221

240-
error.cause = new Error('bar')
222+
const causes = formatted.causes as RawErrorCause[]
241223

242-
const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
243-
expect(errorCauses?.[0].type).toEqual('Error')
244-
})
224+
expect(causes[0].message).toContain(nestedError.message)
225+
expect(causes[0].source).toContain(ErrorSource.SOURCE)
226+
expect(causes[0].type).toEqual(nestedError.name)
227+
expect(causes[0].stack).toContain('Error: biz: buz')
245228

246-
it('should only return the first 10 errors if nested chain is longer', () => {
247-
const error = new Error('foo') as ErrorWithCause
248-
error.cause = error
249-
const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
250-
expect(errorCauses?.length).toEqual(10)
251-
})
229+
expect(causes[1].message).toContain(deepNestedError.message)
230+
expect(causes[1].source).toContain(ErrorSource.SOURCE)
231+
expect(causes[1].type).toEqual(deepNestedError.name)
232+
expect(causes[1].stack).toContain('Error: fiz: buz')
233+
})
234+
235+
it('should attach debug IDs for stack frame URLs coming from a cause in another bundle', () => {
236+
const url = 'http://path/to/debug-id.js'
237+
const causeUrl = 'http://path/to/cause-bundle.js'
238+
mockSourceCodeContext({
239+
[`Error: ctx\n at fn (${url}:1:1)`]: { ddDebugId: 'debug-id-1' },
240+
[`Error: ctx\n at fn (${causeUrl}:1:1)`]: { ddDebugId: 'debug-id-cause' },
241+
})
242+
243+
const cause = new Error('root cause')
244+
cause.stack = `Error: root cause\n at bar (${causeUrl}:11:7)`
245+
const error = new Error('oh snap!') as ErrorWithCause
246+
error.stack = `Error: oh snap!\n at foo (${url}:52:15)`
247+
error.cause = cause
248+
249+
const formatted = computeRawError({
250+
...DEFAULT_RAW_ERROR_PARAMS,
251+
originalError: error,
252+
handling: ErrorHandling.HANDLED,
253+
})
254+
255+
expect(formatted.debugIds).toEqual({ [url]: 'debug-id-1', [causeUrl]: 'debug-id-cause' })
256+
})
252257

253-
describe('with non-Error values', () => {
254-
it('should handle string cause with consistent structure', () => {
258+
it('should return undefined causes when error has no cause', () => {
259+
const error = new Error('main')
260+
const formatted = computeRawError({
261+
...DEFAULT_RAW_ERROR_PARAMS,
262+
originalError: error,
263+
handling: ErrorHandling.HANDLED,
264+
})
265+
expect(formatted.causes).toBeUndefined()
266+
})
267+
268+
it('should cap causes at 10 for a circular reference', () => {
269+
const error = new Error('foo') as ErrorWithCause
270+
error.cause = error
271+
const formatted = computeRawError({
272+
...DEFAULT_RAW_ERROR_PARAMS,
273+
originalError: error,
274+
handling: ErrorHandling.HANDLED,
275+
})
276+
expect(formatted.causes?.length).toBe(10)
277+
})
278+
279+
it('should handle string cause', () => {
255280
const error = new Error('main') as ErrorWithCause
256281
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
282+
const formatted = computeRawError({
283+
...DEFAULT_RAW_ERROR_PARAMS,
284+
originalError: error,
285+
handling: ErrorHandling.HANDLED,
286+
})
287+
expect(formatted.causes?.length).toBe(1)
288+
expect(formatted.causes?.[0]).toEqual({
289+
message: '"string cause"',
262290
source: ErrorSource.CUSTOM,
263291
type: undefined,
264292
stack: undefined,
265293
})
266294
})
267295

268-
it('should handle object cause with consistent structure', () => {
296+
it('should handle object cause', () => {
269297
const error = new Error('main') as ErrorWithCause
270298
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({
299+
const formatted = computeRawError({
300+
...DEFAULT_RAW_ERROR_PARAMS,
301+
originalError: error,
302+
handling: ErrorHandling.HANDLED,
303+
})
304+
expect(formatted.causes?.length).toBe(1)
305+
expect(formatted.causes?.[0]).toEqual({
275306
message: '{"code":"ERR_001","details":"Invalid input"}',
276307
source: ErrorSource.CUSTOM,
277308
type: undefined,
278309
stack: undefined,
279310
})
280311
})
281312

282-
it('should handle number cause with consistent structure', () => {
313+
it('should handle number cause', () => {
283314
const error = new Error('main') as ErrorWithCause
284315
error.cause = 42
285-
286-
const causes = flattenErrorCauses(error, ErrorSource.CUSTOM)
287-
expect(causes?.length).toBe(1)
288-
expect(causes?.[0]).toEqual({
316+
const formatted = computeRawError({
317+
...DEFAULT_RAW_ERROR_PARAMS,
318+
originalError: error,
319+
handling: ErrorHandling.HANDLED,
320+
})
321+
expect(formatted.causes?.length).toBe(1)
322+
expect(formatted.causes?.[0]).toEqual({
289323
message: '42',
290324
source: ErrorSource.CUSTOM,
291325
type: undefined,
292326
stack: undefined,
293327
})
294328
})
295329

296-
it('should handle mixed Error and non-Error chain', () => {
330+
it('should handle mixed Error and non-Error cause chain', () => {
297331
const error1 = new Error('first') as ErrorWithCause
298332
const error2 = new Error('second') as ErrorWithCause
299333
error1.cause = error2
300334
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({
335+
const formatted = computeRawError({
336+
...DEFAULT_RAW_ERROR_PARAMS,
337+
originalError: error1,
338+
handling: ErrorHandling.HANDLED,
339+
})
340+
expect(formatted.causes?.length).toBe(2)
341+
expect(formatted.causes?.[0].message).toBe('second')
342+
expect(formatted.causes?.[0].type).toBe('Error')
343+
expect(formatted.causes?.[0].stack).toContain('Error')
344+
expect(formatted.causes?.[1]).toEqual({
312345
message: '{"code":"ERR_ROOT"}',
313346
source: ErrorSource.CUSTOM,
314347
type: undefined,
@@ -319,28 +352,55 @@ describe('flattenErrorCauses', () => {
319352
it('should stop chain after non-Error cause', () => {
320353
const error = new Error('main') as ErrorWithCause
321354
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()
355+
const formatted = computeRawError({
356+
...DEFAULT_RAW_ERROR_PARAMS,
357+
originalError: error,
358+
handling: ErrorHandling.HANDLED,
359+
})
360+
expect(formatted.causes?.length).toBe(1)
361+
expect(formatted.causes?.[0].message).toContain('"value":"data"')
362+
expect(formatted.causes?.[0].type).toBeUndefined()
328363
})
329364

330365
it('should handle null cause', () => {
331366
const error = new Error('main') as ErrorWithCause
332367
error.cause = null
333-
expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined()
368+
const formatted = computeRawError({
369+
...DEFAULT_RAW_ERROR_PARAMS,
370+
originalError: error,
371+
handling: ErrorHandling.HANDLED,
372+
})
373+
expect(formatted.causes).toBeUndefined()
334374
})
335375

336376
it('should handle undefined cause', () => {
337377
const error = new Error('main') as ErrorWithCause
338378
error.cause = undefined
339-
expect(flattenErrorCauses(error, ErrorSource.CUSTOM)).toBeUndefined()
379+
const formatted = computeRawError({
380+
...DEFAULT_RAW_ERROR_PARAMS,
381+
originalError: error,
382+
handling: ErrorHandling.HANDLED,
383+
})
384+
expect(formatted.causes).toBeUndefined()
340385
})
341386
})
342387
})
343388

389+
describe('getFileFromStackTraceString', () => {
390+
it('should get the first source file of the stack', () => {
391+
expect(
392+
getFileFromStackTraceString(`TypeError: oh snap!
393+
at foo(1, bar) @ http://path/to/file.js:52:15
394+
at <anonymous> @ http://path/to/file.js:12
395+
at <anonymous>(baz) @ http://path/to/file.js`)
396+
).toEqual('http://path/to/file.js:52:15')
397+
})
398+
399+
it('should get undefined if no source file is in the stack', () => {
400+
expect(getFileFromStackTraceString('TypeError: oh snap!')).not.toBeDefined()
401+
})
402+
})
403+
344404
describe('isError', () => {
345405
it('should correctly identify an error object from a different window context', () => {
346406
const iframe = document.createElement('iframe')

0 commit comments

Comments
 (0)