Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 90 additions & 10 deletions packages/core/src/domain/error/error.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,16 +234,6 @@ describe('flattenErrorCauses', () => {
expect(errorCauses).toEqual(undefined)
})

it('should return undefined if cause is not of type Error', () => {
const error = new Error('foo') as ErrorWithCause
const nestedError = { biz: 'buz', cause: new Error('boo') } as unknown as Error

error.cause = nestedError

const errorCauses = flattenErrorCauses(error, ErrorSource.LOGGER)
expect(errorCauses?.length).toEqual(undefined)
})

it('should use error to extract stack trace', () => {
const error = new Error('foo') as ErrorWithCause

Expand All @@ -259,6 +249,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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 suggestion: ‏For reporting thrown string we also stringify the message since we add a prefix the quotes act like a separator, ex: Uncaught "string cause".
Not sure the quotes add much value in this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to affect how they look in the UI. Also, it improves the readability when they are objects.

Thoughts?

source: ErrorSource.CUSTOM,
type: undefined,
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: undefined,
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: undefined,
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: undefined,
stack: undefined,
})
})

it('should stop chain after non-Error cause', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ question: ‏that sounds ok to me but I wonder if it is a choice you made or if it is based on spec.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no specifications regarding how to handle complaint objects that are not errors. I made this decision to prevent a plain object with a cause field from causing any issues.

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).toBeUndefined()
})

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', () => {
Expand Down
63 changes: 44 additions & 19 deletions packages/core/src/domain/error/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
}
Expand All @@ -56,15 +76,17 @@ 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)
// TODO rework tracekit integration to avoid scattering error building logic
return stackTrace?.message && stackTrace?.name
? stackTrace.message
: !isErrorInstance
? `${nonErrorPrefix} ${jsonStringify(sanitize(originalError))!}`
? nonErrorPrefix
? `${nonErrorPrefix} ${jsonStringify(sanitize(originalError))!}`
: jsonStringify(sanitize(originalError))!
: 'Empty message'
}

Expand All @@ -87,17 +109,20 @@ 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,
let currentCause = error.cause

while (currentCause !== undefined && currentCause !== null && causes.length < 10) {
const causeBase = computeErrorBase({
originalError: currentCause,
source: parentSource,
type: stackTrace?.name,
stack: stackTrace && toStackTraceString(stackTrace),
useFallbackStack: false,
})
currentError = currentError.cause

causes.push(causeBase)

currentCause = isError(currentCause) ? (currentCause as ErrorWithCause).cause : undefined
}

return causes.length ? causes : undefined
}
21 changes: 21 additions & 0 deletions packages/rum-core/src/domain/error/errorCollection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: undefined,
stack: undefined,
})
})

it('should extract fingerprint from error', () => {
setupErrorCollection()

Expand Down