Skip to content

✨ [RUM-10145] collect errors on module evaluation #3622

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 17, 2025
Merged
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ export default tseslint.config(

{
files: ['packages/core/src/tools/**/*.ts'],
ignores: [SPEC_FILES],
rules: {
'no-restricted-imports': [
'error',
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/domain/bufferedData.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { registerCleanupTask } from '../../test'
import { Observable } from '../tools/observable'
import { clocksNow } from '../tools/utils/timeUtils'
import { BufferedDataType, startBufferingData } from './bufferedData'
import { ErrorHandling, ErrorSource, type RawError } from './error/error.types'

describe('startBufferingData', () => {
it('collects runtime errors', (done) => {
const runtimeErrorObservable = new Observable<RawError>()
const { observable, stop } = startBufferingData(() => runtimeErrorObservable)
registerCleanupTask(stop)

const rawError = {
startClocks: clocksNow(),
source: ErrorSource.SOURCE,
type: 'Error',
stack: 'Error: error!',
handling: ErrorHandling.UNHANDLED,
causes: undefined,
fingerprint: undefined,
message: 'error!',
}

runtimeErrorObservable.notify(rawError)

observable.subscribe((data) => {
expect(data).toEqual({
type: BufferedDataType.RUNTIME_ERROR,
error: rawError,
})
done()
})
})
})
32 changes: 32 additions & 0 deletions packages/core/src/domain/bufferedData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { BufferedObservable } from '../tools/observable'
import type { RawError } from './error/error.types'
import { trackRuntimeError } from './error/trackRuntimeError'

const BUFFER_LIMIT = 500
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 suggestion : Could we put a single const for the buffer limit or is it expected since the other is deprecated?
(see boundedBuffer.ts)

Copy link
Member Author

Choose a reason for hiding this comment

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

I'd like to keep BufferedObservable as generic as possible to encourage using it in more places. But I could add a default value in the BufferedObservable constructor, to keep it configurable while not having to define a limit every single time when it doesn't matter much. WDYT?


export const enum BufferedDataType {
RUNTIME_ERROR,
}

export interface BufferedData {
type: BufferedDataType.RUNTIME_ERROR
error: RawError
}

export function startBufferingData(trackRuntimeErrorImpl = trackRuntimeError) {
const observable = new BufferedObservable<BufferedData>(BUFFER_LIMIT)

const runtimeErrorSubscription = trackRuntimeErrorImpl().subscribe((error) => {
observable.notify({
type: BufferedDataType.RUNTIME_ERROR,
error,
})
})

return {
observable,
stop: () => {
runtimeErrorSubscription.unsubscribe()
},
}
}
4 changes: 1 addition & 3 deletions packages/core/src/domain/error/trackRuntimeError.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { disableJasmineUncaughtExceptionTracking, wait } from '../../../test'
import { Observable } from '../../tools/observable'
import type { UnhandledErrorCallback } from './trackRuntimeError'
import { instrumentOnError, instrumentUnhandledRejection, trackRuntimeError } from './trackRuntimeError'
import type { RawError } from './error.types'
Expand All @@ -10,11 +9,10 @@ describe('trackRuntimeError', () => {
const errorViaTrackRuntimeError = async (callback: () => void): Promise<RawError> => {
disableJasmineUncaughtExceptionTracking()

const errorObservable = new Observable<RawError>()
const errorObservable = trackRuntimeError()
const errorNotification = new Promise<RawError>((resolve) => {
errorObservable.subscribe((e: RawError) => resolve(e))
})
const { stop } = trackRuntimeError(errorObservable)

try {
await invokeAndWaitForErrorHandlers(callback)
Expand Down
43 changes: 22 additions & 21 deletions packages/core/src/domain/error/trackRuntimeError.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,40 @@
import { instrumentMethod } from '../../tools/instrumentMethod'
import type { Observable } from '../../tools/observable'
import { Observable } from '../../tools/observable'
import { clocksNow } from '../../tools/utils/timeUtils'
import type { StackTrace } from '../../tools/stackTrace/computeStackTrace'
import { computeStackTraceFromOnErrorMessage } from '../../tools/stackTrace/computeStackTrace'
import { getGlobalObject } from '../../tools/getGlobalObject'
import { computeRawError, isError } from './error'
import type { RawError } from './error.types'
import { ErrorHandling, ErrorSource, NonErrorPrefix } from './error.types'

export type UnhandledErrorCallback = (originalError: unknown, stackTrace?: StackTrace) => any

export function trackRuntimeError(errorObservable: Observable<RawError>) {
const handleRuntimeError = (originalError: unknown, stackTrace?: StackTrace) => {
const rawError = computeRawError({
stackTrace,
originalError,
startClocks: clocksNow(),
nonErrorPrefix: NonErrorPrefix.UNCAUGHT,
source: ErrorSource.SOURCE,
handling: ErrorHandling.UNHANDLED,
})
errorObservable.notify(rawError)
}
const { stop: stopInstrumentingOnError } = instrumentOnError(handleRuntimeError)
const { stop: stopInstrumentingOnUnhandledRejection } = instrumentUnhandledRejection(handleRuntimeError)
export function trackRuntimeError() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

💬 suggestion: ‏Why not using BufferedObservable directly in here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I tried, but:

  • It makes a circular dependency between trackRuntimeError and bufferedData
  • The "Buffered Data" concept leaks everywhere in trackRuntimeError. It makes it more complex as it's not scoped to rawError anymore.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hum, fine by me! Let’s move forward and maybe refine the design later as this pattern is used more widely :)

return new Observable<RawError>((observer) => {
const handleRuntimeError = (originalError: unknown, stackTrace?: StackTrace) => {
const rawError = computeRawError({
stackTrace,
originalError,
startClocks: clocksNow(),
nonErrorPrefix: NonErrorPrefix.UNCAUGHT,
source: ErrorSource.SOURCE,
handling: ErrorHandling.UNHANDLED,
})
observer.notify(rawError)
}
const { stop: stopInstrumentingOnError } = instrumentOnError(handleRuntimeError)
const { stop: stopInstrumentingOnUnhandledRejection } = instrumentUnhandledRejection(handleRuntimeError)

return {
stop: () => {
return () => {
stopInstrumentingOnError()
stopInstrumentingOnUnhandledRejection()
},
}
}
})
}

export function instrumentOnError(callback: UnhandledErrorCallback) {
return instrumentMethod(window, 'onerror', ({ parameters: [messageObj, url, line, column, errorObj] }) => {
return instrumentMethod(getGlobalObject(), 'onerror', ({ parameters: [messageObj, url, line, column, errorObj] }) => {
let stackTrace
if (!isError(errorObj)) {
stackTrace = computeStackTraceFromOnErrorMessage(messageObj, url, line, column)
Expand All @@ -43,7 +44,7 @@ export function instrumentOnError(callback: UnhandledErrorCallback) {
}

export function instrumentUnhandledRejection(callback: UnhandledErrorCallback) {
return instrumentMethod(window, 'onunhandledrejection', ({ parameters: [e] }) => {
return instrumentMethod(getGlobalObject(), 'onunhandledrejection', ({ parameters: [e] }) => {
callback(e.reason || 'Empty reason')
})
}
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export {
} from './domain/telemetry'
export { monitored, monitor, callMonitored, setDebugMode, monitorError } from './tools/monitor'
export type { Subscription } from './tools/observable'
export { Observable } from './tools/observable'
export { Observable, BufferedObservable } from './tools/observable'
export type { SessionManager } from './domain/session/sessionManager'
export { startSessionManager, stopSessionManager } from './domain/session/sessionManager'
export {
Expand Down Expand Up @@ -141,6 +141,7 @@ export {
} from './domain/synthetics/syntheticsWorkerValues'
export { checkContext } from './domain/context/contextUtils'
export * from './domain/resourceUtils'
export * from './domain/bufferedData'
export * from './tools/utils/polyfills'
export * from './tools/utils/timezone'
export * from './tools/utils/numberUtils'
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/tools/boundedBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { removeItem } from './utils/arrayUtils'

const BUFFER_LIMIT = 500

/**
* @deprecated Use `BufferedObservable` instead.
*/
export interface BoundedBuffer<T = void> {
add: (callback: (arg: T) => void) => void
remove: (callback: (arg: T) => void) => void
drain: (arg: T) => void
}

/**
* @deprecated Use `BufferedObservable` instead.
*/
export function createBoundedBuffer<T = void>(): BoundedBuffer<T> {
const buffer: Array<(arg: T) => void> = []

Expand Down
143 changes: 142 additions & 1 deletion packages/core/src/tools/observable.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mergeObservables, Observable } from './observable'
import { BufferedObservable, mergeObservables, Observable } from './observable'

describe('observable', () => {
let observable: Observable<void>
Expand Down Expand Up @@ -119,3 +119,144 @@ describe('mergeObservables', () => {
expect(subscriber).not.toHaveBeenCalled()
})
})

describe('BufferedObservable', () => {
it('invokes the observer with buffered data', async () => {
const observable = new BufferedObservable<string>(100)
observable.notify('first')
observable.notify('second')

const observer = jasmine.createSpy('observer')
observable.subscribe(observer)

await nextMicrotask()

expect(observer).toHaveBeenCalledTimes(2)
})

it('invokes the observer asynchronously', async () => {
const observable = new BufferedObservable<string>(100)
observable.notify('first')

const observer = jasmine.createSpy('observer')
observable.subscribe(observer)

expect(observer).not.toHaveBeenCalled()

await nextMicrotask()

expect(observer).toHaveBeenCalledWith('first')
})

it('invokes the observer when new data is notified after subscription', async () => {
const observable = new BufferedObservable<string>(100)

const observer = jasmine.createSpy('observer')
observable.subscribe(observer)

observable.notify('first')

await nextMicrotask()

observable.notify('second')

expect(observer).toHaveBeenCalledTimes(2)
expect(observer).toHaveBeenCalledWith('first')
expect(observer).toHaveBeenCalledWith('second')
})

it('drops data when the buffer is full', async () => {
const observable = new BufferedObservable<string>(2)
observable.notify('first') // This should be dropped
observable.notify('second')
observable.notify('third')

const observer = jasmine.createSpy('observer')
observable.subscribe(observer)

await nextMicrotask()

expect(observer).toHaveBeenCalledTimes(2)
expect(observer).toHaveBeenCalledWith('second')
expect(observer).toHaveBeenCalledWith('third')
})

it('allows to unsubscribe from the observer, the middle of buffered data', async () => {
const observable = new BufferedObservable<string>(100)
observable.notify('first')
observable.notify('second')

const observer = jasmine.createSpy('observer').and.callFake(() => {
subscription.unsubscribe()
})
const subscription = observable.subscribe(observer)

await nextMicrotask()

expect(observer).toHaveBeenCalledTimes(1)
})

it('allows to unsubscribe before the buffered data', async () => {
const observable = new BufferedObservable<string>(100)
observable.notify('first')

const observer = jasmine.createSpy('observer')
const subscription = observable.subscribe(observer)

subscription.unsubscribe()

await nextMicrotask()

expect(observer).not.toHaveBeenCalled()
})

it('allows to unsubscribe after the buffered data', async () => {
const observable = new BufferedObservable<string>(100)

const observer = jasmine.createSpy('observer')
const subscription = observable.subscribe(observer)

await nextMicrotask()

subscription.unsubscribe()

observable.notify('first')

expect(observer).not.toHaveBeenCalled()
})

it('calling unbuffer() removes buffered data', async () => {
const observable = new BufferedObservable<string>(2)
observable.notify('first')
observable.notify('second')

observable.unbuffer()
await nextMicrotask()

const observer = jasmine.createSpy('observer')
observable.subscribe(observer)
await nextMicrotask()

expect(observer).not.toHaveBeenCalled()
})

it('when calling unbuffer() right after subscription, buffered data should still be notified', async () => {
const observable = new BufferedObservable<string>(2)
observable.notify('first')
observable.notify('second')

const observer = jasmine.createSpy('observer')
observable.subscribe(observer)

observable.unbuffer()
await nextMicrotask()

expect(observer).toHaveBeenCalledTimes(2)
expect(observer).toHaveBeenCalledWith('first')
expect(observer).toHaveBeenCalledWith('second')
})
})

function nextMicrotask() {
return Promise.resolve()
}
Loading