diff --git a/eslint.config.mjs b/eslint.config.mjs index 56d427f3b4..b9ad314150 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -382,6 +382,7 @@ export default tseslint.config( { files: ['packages/core/src/tools/**/*.ts'], + ignores: [SPEC_FILES], rules: { 'no-restricted-imports': [ 'error', diff --git a/packages/core/src/domain/bufferedData.spec.ts b/packages/core/src/domain/bufferedData.spec.ts new file mode 100644 index 0000000000..d77da94a02 --- /dev/null +++ b/packages/core/src/domain/bufferedData.spec.ts @@ -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() + 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() + }) + }) +}) diff --git a/packages/core/src/domain/bufferedData.ts b/packages/core/src/domain/bufferedData.ts new file mode 100644 index 0000000000..c84049d58c --- /dev/null +++ b/packages/core/src/domain/bufferedData.ts @@ -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 + +export const enum BufferedDataType { + RUNTIME_ERROR, +} + +export interface BufferedData { + type: BufferedDataType.RUNTIME_ERROR + error: RawError +} + +export function startBufferingData(trackRuntimeErrorImpl = trackRuntimeError) { + const observable = new BufferedObservable(BUFFER_LIMIT) + + const runtimeErrorSubscription = trackRuntimeErrorImpl().subscribe((error) => { + observable.notify({ + type: BufferedDataType.RUNTIME_ERROR, + error, + }) + }) + + return { + observable, + stop: () => { + runtimeErrorSubscription.unsubscribe() + }, + } +} diff --git a/packages/core/src/domain/error/trackRuntimeError.spec.ts b/packages/core/src/domain/error/trackRuntimeError.spec.ts index 87477e6358..f61861e5b0 100644 --- a/packages/core/src/domain/error/trackRuntimeError.spec.ts +++ b/packages/core/src/domain/error/trackRuntimeError.spec.ts @@ -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' @@ -10,11 +9,10 @@ describe('trackRuntimeError', () => { const errorViaTrackRuntimeError = async (callback: () => void): Promise => { disableJasmineUncaughtExceptionTracking() - const errorObservable = new Observable() + const errorObservable = trackRuntimeError() const errorNotification = new Promise((resolve) => { errorObservable.subscribe((e: RawError) => resolve(e)) }) - const { stop } = trackRuntimeError(errorObservable) try { await invokeAndWaitForErrorHandlers(callback) diff --git a/packages/core/src/domain/error/trackRuntimeError.ts b/packages/core/src/domain/error/trackRuntimeError.ts index 3bcd55f119..3ad95f12b0 100644 --- a/packages/core/src/domain/error/trackRuntimeError.ts +++ b/packages/core/src/domain/error/trackRuntimeError.ts @@ -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) { - 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() { + return new Observable((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) @@ -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') }) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aab0eacca2..fad414558c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { @@ -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' diff --git a/packages/core/src/tools/boundedBuffer.ts b/packages/core/src/tools/boundedBuffer.ts index 3b48796966..976ac26434 100644 --- a/packages/core/src/tools/boundedBuffer.ts +++ b/packages/core/src/tools/boundedBuffer.ts @@ -2,12 +2,18 @@ import { removeItem } from './utils/arrayUtils' const BUFFER_LIMIT = 500 +/** + * @deprecated Use `BufferedObservable` instead. + */ export interface BoundedBuffer { add: (callback: (arg: T) => void) => void remove: (callback: (arg: T) => void) => void drain: (arg: T) => void } +/** + * @deprecated Use `BufferedObservable` instead. + */ export function createBoundedBuffer(): BoundedBuffer { const buffer: Array<(arg: T) => void> = [] diff --git a/packages/core/src/tools/observable.spec.ts b/packages/core/src/tools/observable.spec.ts index 5805343ef9..4bca2da87f 100644 --- a/packages/core/src/tools/observable.spec.ts +++ b/packages/core/src/tools/observable.spec.ts @@ -1,4 +1,4 @@ -import { mergeObservables, Observable } from './observable' +import { BufferedObservable, mergeObservables, Observable } from './observable' describe('observable', () => { let observable: Observable @@ -119,3 +119,144 @@ describe('mergeObservables', () => { expect(subscriber).not.toHaveBeenCalled() }) }) + +describe('BufferedObservable', () => { + it('invokes the observer with buffered data', async () => { + const observable = new BufferedObservable(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(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(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(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(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(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(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(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(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() +} diff --git a/packages/core/src/tools/observable.ts b/packages/core/src/tools/observable.ts index 60b672126b..4c881f18f8 100644 --- a/packages/core/src/tools/observable.ts +++ b/packages/core/src/tools/observable.ts @@ -1,32 +1,42 @@ +import { queueMicrotask } from './queueMicrotask' + export interface Subscription { unsubscribe: () => void } +type Observer = (data: T) => void + // eslint-disable-next-line no-restricted-syntax export class Observable { - private observers: Array<(data: T) => void> = [] + protected observers: Array> = [] private onLastUnsubscribe?: () => void constructor(private onFirstSubscribe?: (observable: Observable) => (() => void) | void) {} - subscribe(f: (data: T) => void): Subscription { - this.observers.push(f) - if (this.observers.length === 1 && this.onFirstSubscribe) { - this.onLastUnsubscribe = this.onFirstSubscribe(this) || undefined - } + subscribe(observer: Observer): Subscription { + this.addObserver(observer) return { - unsubscribe: () => { - this.observers = this.observers.filter((other) => f !== other) - if (!this.observers.length && this.onLastUnsubscribe) { - this.onLastUnsubscribe() - } - }, + unsubscribe: () => this.removeObserver(observer), } } notify(data: T) { this.observers.forEach((observer) => observer(data)) } + + protected addObserver(observer: Observer) { + this.observers.push(observer) + if (this.observers.length === 1 && this.onFirstSubscribe) { + this.onLastUnsubscribe = this.onFirstSubscribe(this) || undefined + } + } + + protected removeObserver(observer: Observer) { + this.observers = this.observers.filter((other) => observer !== other) + if (!this.observers.length && this.onLastUnsubscribe) { + this.onLastUnsubscribe() + } + } } export function mergeObservables(...observables: Array>) { @@ -37,3 +47,58 @@ export function mergeObservables(...observables: Array>) { return () => subscriptions.forEach((subscription) => subscription.unsubscribe()) }) } + +// eslint-disable-next-line no-restricted-syntax +export class BufferedObservable extends Observable { + private buffer: T[] = [] + + constructor(private maxBufferSize: number) { + super() + } + + notify(data: T) { + this.buffer.push(data) + if (this.buffer.length > this.maxBufferSize) { + this.buffer.shift() + } + super.notify(data) + } + + subscribe(observer: Observer): Subscription { + let closed = false + + const subscription = { + unsubscribe: () => { + closed = true + this.removeObserver(observer) + }, + } + + queueMicrotask(() => { + for (const data of this.buffer) { + if (closed) { + return + } + observer(data) + } + + if (!closed) { + this.addObserver(observer) + } + }) + + return subscription + } + + /** + * Drop buffered data and don't buffer future data. This is to avoid leaking memory when it's not + * needed anymore. This can be seen as a performance optimization, and things will work probably + * even if this method isn't called, but still useful to clarify our intent and lowering our + * memory impact. + */ + unbuffer() { + queueMicrotask(() => { + this.maxBufferSize = this.buffer.length = 0 + }) + } +} diff --git a/packages/core/src/tools/queueMicrotask.spec.ts b/packages/core/src/tools/queueMicrotask.spec.ts new file mode 100644 index 0000000000..cc887d57e5 --- /dev/null +++ b/packages/core/src/tools/queueMicrotask.spec.ts @@ -0,0 +1,24 @@ +import { startFakeTelemetry } from '../domain/telemetry' +import { queueMicrotask } from './queueMicrotask' + +describe('queueMicrotask', () => { + it('calls the callback in a microtask', async () => { + let called = false + queueMicrotask(() => { + called = true + }) + expect(called).toBe(false) + await Promise.resolve() // wait for the microtask to execute + expect(called).toBe(true) + }) + + it('monitors the callback', async () => { + const telemetryEvents = startFakeTelemetry() + queueMicrotask(() => { + throw new Error('test error') + }) + await Promise.resolve() // wait for the microtask to execute + + expect(telemetryEvents).toEqual([]) + }) +}) diff --git a/packages/core/src/tools/queueMicrotask.ts b/packages/core/src/tools/queueMicrotask.ts new file mode 100644 index 0000000000..37fbbca7cd --- /dev/null +++ b/packages/core/src/tools/queueMicrotask.ts @@ -0,0 +1,11 @@ +import { monitor } from './monitor' + +export function queueMicrotask(callback: () => void) { + const nativeImplementation = window.queueMicrotask + if (typeof nativeImplementation === 'function') { + nativeImplementation(monitor(callback)) + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises -- the callback is monitored, so it'll never throw + Promise.resolve().then(monitor(callback)) + } +} diff --git a/packages/logs/src/boot/logsPublicApi.ts b/packages/logs/src/boot/logsPublicApi.ts index 8ed154bbd1..aefdd94991 100644 --- a/packages/logs/src/boot/logsPublicApi.ts +++ b/packages/logs/src/boot/logsPublicApi.ts @@ -10,6 +10,7 @@ import { deepClone, createTrackingConsentState, defineContextMethod, + startBufferingData, } from '@datadog/browser-core' import type { LogsInitConfiguration } from '../domain/configuration' import type { HandlerType } from '../domain/logger' @@ -204,12 +205,18 @@ export interface Strategy { export function makeLogsPublicApi(startLogsImpl: StartLogs): LogsPublicApi { const trackingConsentState = createTrackingConsentState() + const bufferedDataObservable = startBufferingData().observable let strategy = createPreStartStrategy( buildCommonContext, trackingConsentState, (initConfiguration, configuration) => { - const startLogsResult = startLogsImpl(configuration, buildCommonContext, trackingConsentState) + const startLogsResult = startLogsImpl( + configuration, + buildCommonContext, + trackingConsentState, + bufferedDataObservable + ) strategy = createPostStartStrategy(initConfiguration, startLogsResult) return startLogsResult diff --git a/packages/logs/src/boot/startLogs.spec.ts b/packages/logs/src/boot/startLogs.spec.ts index 0d7f0fe8d8..ea255ea641 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -1,4 +1,4 @@ -import type { ContextManager, Payload } from '@datadog/browser-core' +import type { BufferedData, Payload } from '@datadog/browser-core' import { ErrorSource, display, @@ -10,6 +10,7 @@ import { setCookie, STORAGE_POLL_DELAY, ONE_MINUTE, + BufferedObservable, } from '@datadog/browser-core' import type { Clock, Request } from '@datadog/browser-core/test' import { @@ -25,9 +26,8 @@ import { import type { LogsConfiguration } from '../domain/configuration' import { validateAndBuildLogsConfiguration } from '../domain/configuration' -import { HandlerType, Logger } from '../domain/logger' +import { Logger } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' -import type { startLoggerCollection } from '../domain/logger/loggerCollection' import type { LogsEvent } from '../logsEvent.types' import { startLogs } from './startLogs' @@ -51,30 +51,34 @@ const COMMON_CONTEXT = { } const DEFAULT_PAYLOAD = {} as Payload +function startLogsWithDefaults({ configuration }: { configuration?: Partial } = {}) { + const endpointBuilder = mockEndpointBuilder('https://localhost/v1/input/log') + const { handleLog, stop, globalContext, accountContext, userContext } = startLogs( + { + ...validateAndBuildLogsConfiguration({ clientToken: 'xxx', service: 'service', telemetrySampleRate: 0 })!, + logsEndpointBuilder: endpointBuilder, + batchMessagesLimit: 1, + ...configuration, + }, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED), + new BufferedObservable(100) + ) + + registerCleanupTask(stop) + + const logger = new Logger(handleLog) + + return { handleLog, logger, endpointBuilder, globalContext, accountContext, userContext } +} + describe('logs', () => { - let baseConfiguration: LogsConfiguration let interceptor: ReturnType let requests: Request[] - let handleLog: ReturnType['handleLog'] - let stopLogs: () => void - let logger: Logger - let consoleLogSpy: jasmine.Spy - let displayLogSpy: jasmine.Spy - let globalContext: ContextManager - let accountContext: ContextManager - let userContext: ContextManager beforeEach(() => { - baseConfiguration = { - ...validateAndBuildLogsConfiguration({ clientToken: 'xxx', service: 'service', telemetrySampleRate: 0 })!, - logsEndpointBuilder: mockEndpointBuilder('https://localhost/v1/input/log'), - batchMessagesLimit: 1, - } - logger = new Logger((...params) => handleLog(...params)) interceptor = interceptRequests() requests = interceptor.requests - consoleLogSpy = spyOn(console, 'log') - displayLogSpy = spyOn(display, 'log') }) afterEach(() => { @@ -84,12 +88,7 @@ describe('logs', () => { describe('request', () => { it('should send the needed data', async () => { - ;({ handleLog, stop: stopLogs } = startLogs( - baseConfiguration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) + const { handleLog, logger, endpointBuilder } = startLogsWithDefaults() handleLog( { message: 'message', status: StatusType.warn, context: { foo: 'bar' } }, @@ -101,7 +100,7 @@ describe('logs', () => { await interceptor.waitForAllFetchCalls() expect(requests.length).toEqual(1) - expect(requests[0].url).toContain(baseConfiguration.logsEndpointBuilder.build('fetch', DEFAULT_PAYLOAD)) + expect(requests[0].url).toContain(endpointBuilder.build('fetch', DEFAULT_PAYLOAD)) expect(getLoggedMessage(requests, 0)).toEqual({ date: jasmine.any(Number), foo: 'bar', @@ -124,12 +123,9 @@ describe('logs', () => { }) it('should all use the same batch', async () => { - ;({ handleLog, stop: stopLogs } = startLogs( - { ...baseConfiguration, batchMessagesLimit: 3 }, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) + const { handleLog, logger } = startLogsWithDefaults({ + configuration: { batchMessagesLimit: 3 }, + }) handleLog(DEFAULT_MESSAGE, logger) handleLog(DEFAULT_MESSAGE, logger) @@ -142,12 +138,7 @@ describe('logs', () => { it('should send bridge event when bridge is present', () => { const sendSpy = spyOn(mockEventBridge(), 'send') - ;({ handleLog, stop: stopLogs } = startLogs( - baseConfiguration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) + const { handleLog, logger } = startLogsWithDefaults() handleLog(DEFAULT_MESSAGE, logger) @@ -162,27 +153,23 @@ describe('logs', () => { }) describe('sampling', () => { - it('should be applied when event bridge is present', () => { + it('should be applied when event bridge is present (rate 0)', () => { const sendSpy = spyOn(mockEventBridge(), 'send') - let configuration = { ...baseConfiguration, sessionSampleRate: 0 } - ;({ handleLog, stop: stopLogs } = startLogs( - configuration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) + const { handleLog, logger } = startLogsWithDefaults({ + configuration: { sessionSampleRate: 0 }, + }) handleLog(DEFAULT_MESSAGE, logger) expect(sendSpy).not.toHaveBeenCalled() + }) + + it('should be applied when event bridge is present (rate 100)', () => { + const sendSpy = spyOn(mockEventBridge(), 'send') - configuration = { ...baseConfiguration, sessionSampleRate: 100 } - ;({ handleLog, stop: stopLogs } = startLogs( - configuration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) + const { handleLog, logger } = startLogsWithDefaults({ + configuration: { sessionSampleRate: 100 }, + }) handleLog(DEFAULT_MESSAGE, logger) expect(sendSpy).toHaveBeenCalled() @@ -190,13 +177,11 @@ describe('logs', () => { }) it('should not print the log twice when console handler is enabled', () => { - logger.setHandler([HandlerType.console]) - ;({ handleLog, stop: stopLogs } = startLogs( - { ...baseConfiguration, forwardConsoleLogs: ['log'] }, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) + const consoleLogSpy = spyOn(console, 'log') + const displayLogSpy = spyOn(display, 'log') + startLogsWithDefaults({ + configuration: { forwardConsoleLogs: ['log'] }, + }) /* eslint-disable-next-line no-console */ console.log('foo', 'bar') @@ -207,37 +192,19 @@ describe('logs', () => { describe('logs session creation', () => { it('creates a session on normal conditions', () => { - ;({ handleLog, stop: stopLogs } = startLogs( - baseConfiguration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) - - expect(getCookie(SESSION_STORE_KEY)).not.toBeUndefined() + startLogsWithDefaults() + expect(getCookie(SESSION_STORE_KEY)).toBeDefined() }) it('does not create a session if event bridge is present', () => { mockEventBridge() - ;({ handleLog, stop: stopLogs } = startLogs( - baseConfiguration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) - + startLogsWithDefaults() expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() }) it('does not create a session if synthetics worker will inject RUM', () => { mockSyntheticsWorkerValues({ injectsRum: true }) - ;({ handleLog, stop: stopLogs } = startLogs( - baseConfiguration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) - + startLogsWithDefaults() expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() }) }) @@ -250,12 +217,7 @@ describe('logs', () => { it('sends logs without session id when the session expires ', async () => { setCookie(SESSION_STORE_KEY, 'id=foo&logs=1', ONE_MINUTE) - ;({ handleLog, stop: stopLogs } = startLogs( - baseConfiguration, - () => COMMON_CONTEXT, - createTrackingConsentState(TrackingConsent.GRANTED) - )) - registerCleanupTask(stopLogs) + const { handleLog, logger } = startLogsWithDefaults() interceptor.withFetch(DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK) @@ -281,18 +243,8 @@ describe('logs', () => { }) describe('contexts precedence', () => { - beforeEach(() => { - ;({ - handleLog, - stop: stopLogs, - globalContext, - accountContext, - userContext, - } = startLogs(baseConfiguration, () => COMMON_CONTEXT, createTrackingConsentState(TrackingConsent.GRANTED))) - registerCleanupTask(stopLogs) - }) - it('global context should take precedence over session', () => { + const { handleLog, logger, globalContext } = startLogsWithDefaults() globalContext.setContext({ session_id: 'from-global-context' }) handleLog({ status: StatusType.info, message: 'message 1' }, logger) @@ -302,6 +254,7 @@ describe('logs', () => { }) it('global context should take precedence over account', () => { + const { handleLog, logger, globalContext, accountContext } = startLogsWithDefaults() globalContext.setContext({ account: { id: 'from-global-context' } }) accountContext.setContext({ id: 'from-account-context' }) @@ -312,6 +265,7 @@ describe('logs', () => { }) it('global context should take precedence over usr', () => { + const { handleLog, logger, globalContext, userContext } = startLogsWithDefaults() globalContext.setContext({ usr: { id: 'from-global-context' } }) userContext.setContext({ id: 'from-user-context' }) @@ -322,6 +276,7 @@ describe('logs', () => { }) it('RUM context should take precedence over global context', () => { + const { handleLog, logger, globalContext } = startLogsWithDefaults() window.DD_RUM = { getInternalContext: () => ({ view: { url: 'from-rum-context' } }), } diff --git a/packages/logs/src/boot/startLogs.ts b/packages/logs/src/boot/startLogs.ts index 2f4c2520d2..2f5205cfff 100644 --- a/packages/logs/src/boot/startLogs.ts +++ b/packages/logs/src/boot/startLogs.ts @@ -1,4 +1,4 @@ -import type { Context, TrackingConsentState } from '@datadog/browser-core' +import type { BufferedObservable, Context, BufferedData, TrackingConsentState } from '@datadog/browser-core' import { sendToExtension, createPageMayExitObservable, @@ -41,7 +41,8 @@ export function startLogs( // `startLogs` and its subcomponents assume tracking consent is granted initially and starts // collecting logs unconditionally. As such, `startLogs` should be called with a // `trackingConsentState` set to "granted". - trackingConsentState: TrackingConsentState + trackingConsentState: TrackingConsentState, + bufferedDataObservable: BufferedObservable ) { const lifeCycle = new LifeCycle() const hooks = createHooks() @@ -78,7 +79,8 @@ export function startLogs( telemetry.setContextProvider('action.id', () => (getRUMInternalContext()?.user_action as Context)?.id) startNetworkErrorCollection(configuration, lifeCycle) - startRuntimeErrorCollection(configuration, lifeCycle) + startRuntimeErrorCollection(configuration, lifeCycle, bufferedDataObservable) + bufferedDataObservable.unbuffer() startConsoleCollection(configuration, lifeCycle) startReportCollection(configuration, lifeCycle) const { handleLog } = startLoggerCollection(lifeCycle) diff --git a/packages/logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts b/packages/logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts index e6e60da753..80dceee947 100644 --- a/packages/logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts +++ b/packages/logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts @@ -1,5 +1,6 @@ -import type { ErrorWithCause } from '@datadog/browser-core' -import { ErrorSource, ErrorHandling } from '@datadog/browser-core' +import type { BufferedData, RawError } from '@datadog/browser-core' +import { ErrorSource, ErrorHandling, Observable, BufferedDataType, clocksNow } from '@datadog/browser-core' +import { registerCleanupTask } from '../../../../core/test' import type { RawRuntimeLogsEvent } from '../../rawLogsEvent.types' import type { LogsConfiguration } from '../configuration' import { StatusType } from '../logger/isAuthorized' @@ -7,11 +8,37 @@ import type { RawLogsEventCollectedData } from '../lifeCycle' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import { startRuntimeErrorCollection } from './runtimeErrorCollection' +function startRuntimeErrorCollectionWithDefaults({ + forwardErrorsToLogs = true, +}: { forwardErrorsToLogs?: boolean } = {}) { + const rawLogsEvents: Array> = [] + const lifeCycle = new LifeCycle() + lifeCycle.subscribe(LifeCycleEventType.RAW_LOG_COLLECTED, (rawLogsEvent) => + rawLogsEvents.push(rawLogsEvent as RawLogsEventCollectedData) + ) + const bufferedDataObservable = new Observable() + const { stop } = startRuntimeErrorCollection( + { forwardErrorsToLogs } as LogsConfiguration, + lifeCycle, + bufferedDataObservable + ) + registerCleanupTask(stop) + + return { rawLogsEvents, bufferedDataObservable } +} + +const RAW_ERROR: RawError = { + startClocks: clocksNow(), + source: ErrorSource.SOURCE, + type: 'Error', + stack: 'Error: error!', + handling: ErrorHandling.UNHANDLED, + causes: undefined, + fingerprint: undefined, + message: 'error!', +} + describe('runtime error collection', () => { - const configuration = { forwardErrorsToLogs: true } as LogsConfiguration - let lifeCycle: LifeCycle - let stopRuntimeErrorCollection: () => void - let rawLogsEvents: Array> let onErrorSpy: jasmine.Spy let originalOnErrorHandler: OnErrorEventHandler @@ -19,124 +46,114 @@ describe('runtime error collection', () => { originalOnErrorHandler = window.onerror onErrorSpy = jasmine.createSpy() window.onerror = onErrorSpy - rawLogsEvents = [] - lifeCycle = new LifeCycle() - lifeCycle.subscribe(LifeCycleEventType.RAW_LOG_COLLECTED, (rawLogsEvent) => - rawLogsEvents.push(rawLogsEvent as RawLogsEventCollectedData) - ) }) afterEach(() => { - stopRuntimeErrorCollection() window.onerror = originalOnErrorHandler }) - it('should send runtime errors', (done) => { - ;({ stop: stopRuntimeErrorCollection } = startRuntimeErrorCollection(configuration, lifeCycle)) - setTimeout(() => { - throw new Error('error!') + it('should send runtime errors', () => { + const { rawLogsEvents, bufferedDataObservable } = startRuntimeErrorCollectionWithDefaults() + + bufferedDataObservable.notify({ + type: BufferedDataType.RUNTIME_ERROR, + error: RAW_ERROR, }) - setTimeout(() => { - expect(rawLogsEvents[0].rawLogsEvent).toEqual({ - date: jasmine.any(Number), - error: { - kind: 'Error', - stack: jasmine.any(String), - causes: undefined, - handling: ErrorHandling.UNHANDLED, - fingerprint: undefined, - message: undefined, - }, - message: 'error!', - status: StatusType.error, - origin: ErrorSource.SOURCE, - }) - done() - }, 10) + expect(rawLogsEvents[0].rawLogsEvent).toEqual({ + date: jasmine.any(Number), + error: { + kind: 'Error', + stack: jasmine.any(String), + causes: undefined, + handling: ErrorHandling.UNHANDLED, + fingerprint: undefined, + message: undefined, + }, + message: 'error!', + status: StatusType.error, + origin: ErrorSource.SOURCE, + }) }) - it('should send runtime errors with causes', (done) => { - const error = new Error('High level error') as ErrorWithCause - error.stack = 'Error: High level error' - - const nestedError = new Error('Mid level error') as ErrorWithCause - nestedError.stack = 'Error: Mid level error' - - const deepNestedError = new TypeError('Low level error') as ErrorWithCause - deepNestedError.stack = 'TypeError: Low level error' + it('should send runtime errors with causes', () => { + const { rawLogsEvents, bufferedDataObservable } = startRuntimeErrorCollectionWithDefaults() - nestedError.cause = deepNestedError - error.cause = nestedError - ;({ stop: stopRuntimeErrorCollection } = startRuntimeErrorCollection(configuration, lifeCycle)) - setTimeout(() => { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw error + bufferedDataObservable.notify({ + type: BufferedDataType.RUNTIME_ERROR, + error: { + ...RAW_ERROR, + message: 'High level error', + causes: [ + { + message: 'Mid level error', + type: 'Error', + stack: 'Error: Mid level error', + source: ErrorSource.SOURCE, + }, + { + message: 'Low level error', + type: 'TypeError', + stack: 'TypeError: Low level error', + source: ErrorSource.SOURCE, + }, + ], + }, }) - setTimeout(() => { - expect(rawLogsEvents[0].rawLogsEvent).toEqual({ - date: jasmine.any(Number), - error: { - kind: 'Error', - stack: jasmine.any(String), - handling: ErrorHandling.UNHANDLED, - causes: [ - { - source: ErrorSource.SOURCE, - type: 'Error', - stack: jasmine.any(String), - message: 'Mid level error', - }, - { - source: ErrorSource.SOURCE, - type: 'TypeError', - stack: jasmine.any(String), - message: 'Low level error', - }, - ], - fingerprint: undefined, - message: undefined, - }, - message: 'High level error', - status: StatusType.error, - origin: ErrorSource.SOURCE, - }) - done() - }, 10) + expect(rawLogsEvents[0].rawLogsEvent).toEqual({ + date: jasmine.any(Number), + error: { + kind: 'Error', + stack: jasmine.any(String), + handling: ErrorHandling.UNHANDLED, + causes: [ + { + source: ErrorSource.SOURCE, + type: 'Error', + stack: jasmine.any(String), + message: 'Mid level error', + }, + { + source: ErrorSource.SOURCE, + type: 'TypeError', + stack: jasmine.any(String), + message: 'Low level error', + }, + ], + fingerprint: undefined, + message: undefined, + }, + message: 'High level error', + status: StatusType.error, + origin: ErrorSource.SOURCE, + }) }) - it('should not send runtime errors when forwardErrorsToLogs is false', (done) => { - ;({ stop: stopRuntimeErrorCollection } = startRuntimeErrorCollection( - { ...configuration, forwardErrorsToLogs: false }, - lifeCycle - )) + it('should not send runtime errors when forwardErrorsToLogs is false', () => { + const { rawLogsEvents, bufferedDataObservable } = startRuntimeErrorCollectionWithDefaults({ + forwardErrorsToLogs: false, + }) - setTimeout(() => { - throw new Error('error!') + bufferedDataObservable.notify({ + type: BufferedDataType.RUNTIME_ERROR, + error: RAW_ERROR, }) - setTimeout(() => { - expect(rawLogsEvents.length).toEqual(0) - done() - }, 10) + expect(rawLogsEvents.length).toEqual(0) }) - it('should retrieve dd_context from runtime errors', (done) => { - interface DatadogError extends Error { - dd_context?: Record - } + it('should retrieve dd_context from runtime errors', () => { + const { rawLogsEvents, bufferedDataObservable } = startRuntimeErrorCollectionWithDefaults() - const error = new Error('Error with dd_context') as DatadogError - error.dd_context = { foo: 'barr' } - ;({ stop: stopRuntimeErrorCollection } = startRuntimeErrorCollection(configuration, lifeCycle)) - setTimeout(() => { - throw error + bufferedDataObservable.notify({ + type: BufferedDataType.RUNTIME_ERROR, + error: { + ...RAW_ERROR, + context: { foo: 'bar' }, + }, }) - setTimeout(() => { - expect(rawLogsEvents[0].messageContext).toEqual({ foo: 'barr' }) - done() - }, 10) + expect(rawLogsEvents[0].messageContext).toEqual({ foo: 'bar' }) }) }) diff --git a/packages/logs/src/domain/runtimeError/runtimeErrorCollection.ts b/packages/logs/src/domain/runtimeError/runtimeErrorCollection.ts index a6739c2509..b0fe777966 100644 --- a/packages/logs/src/domain/runtimeError/runtimeErrorCollection.ts +++ b/packages/logs/src/domain/runtimeError/runtimeErrorCollection.ts @@ -1,5 +1,5 @@ -import type { Context, RawError, ClocksState } from '@datadog/browser-core' -import { noop, ErrorSource, trackRuntimeError, Observable } from '@datadog/browser-core' +import type { Context, ClocksState, Observable, BufferedData } from '@datadog/browser-core' +import { noop, ErrorSource, BufferedDataType } from '@datadog/browser-core' import type { LogsConfiguration } from '../configuration' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' @@ -13,31 +13,33 @@ export interface ProvidedError { handlingStack: string } -export function startRuntimeErrorCollection(configuration: LogsConfiguration, lifeCycle: LifeCycle) { +export function startRuntimeErrorCollection( + configuration: LogsConfiguration, + lifeCycle: LifeCycle, + bufferedDataObservable: Observable +) { if (!configuration.forwardErrorsToLogs) { return { stop: noop } } - const rawErrorObservable = new Observable() - - const { stop: stopRuntimeErrorTracking } = trackRuntimeError(rawErrorObservable) - - const rawErrorSubscription = rawErrorObservable.subscribe((rawError) => { - lifeCycle.notify(LifeCycleEventType.RAW_LOG_COLLECTED, { - rawLogsEvent: { - message: rawError.message, - date: rawError.startClocks.timeStamp, - error: createErrorFieldFromRawError(rawError), - origin: ErrorSource.SOURCE, - status: StatusType.error, - }, - messageContext: rawError.context, - }) + const rawErrorSubscription = bufferedDataObservable.subscribe((bufferedData) => { + if (bufferedData.type === BufferedDataType.RUNTIME_ERROR) { + const error = bufferedData.error + lifeCycle.notify(LifeCycleEventType.RAW_LOG_COLLECTED, { + rawLogsEvent: { + message: error.message, + date: error.startClocks.timeStamp, + error: createErrorFieldFromRawError(error), + origin: ErrorSource.SOURCE, + status: StatusType.error, + }, + messageContext: error.context, + }) + } }) return { stop: () => { - stopRuntimeErrorTracking() rawErrorSubscription.unsubscribe() }, } diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index f6e7c599d7..f2de36f863 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -891,7 +891,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - const sdkName = startRumSpy.calls.argsFor(0)[7] + const sdkName = startRumSpy.calls.argsFor(0)[8] expect(sdkName).toBe('rum-slim') }) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 94d6c477fa..a8e63aa5b5 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -28,6 +28,7 @@ import { timeStampToClocks, CustomerContextKey, defineContextMethod, + startBufferingData, } from '@datadog/browser-core' import type { LifeCycle } from '../domain/lifeCycle' @@ -425,6 +426,7 @@ export function makeRumPublicApi( ): RumPublicApi { const trackingConsentState = createTrackingConsentState() const customVitalsState = createCustomVitalsState() + const bufferedDataObservable = startBufferingData().observable let strategy = createPreStartStrategy( options, @@ -441,6 +443,7 @@ export function makeRumPublicApi( : createIdentityEncoder, trackingConsentState, customVitalsState, + bufferedDataObservable, options.sdkName ) diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index dd17f83dec..8143b845e3 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -1,4 +1,4 @@ -import type { RawError, Duration, RelativeTime } from '@datadog/browser-core' +import type { RawError, Duration, RelativeTime, BufferedData } from '@datadog/browser-core' import { Observable, stopSessionManager, @@ -10,6 +10,7 @@ import { createIdentityEncoder, createTrackingConsentState, TrackingConsent, + BufferedObservable, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -329,6 +330,7 @@ describe('view events', () => { createIdentityEncoder, createTrackingConsentState(TrackingConsent.GRANTED), createCustomVitalsState(), + new BufferedObservable(100), 'rum' ) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 257271c3de..f837896521 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -1,4 +1,12 @@ -import type { Observable, RawError, DeflateEncoderStreamId, Encoder, TrackingConsentState } from '@datadog/browser-core' +import type { + Observable, + RawError, + DeflateEncoderStreamId, + Encoder, + TrackingConsentState, + BufferedData, + BufferedObservable, +} from '@datadog/browser-core' import { sendToExtension, createPageMayExitObservable, @@ -64,6 +72,7 @@ export function startRum( // `trackingConsentState` set to "granted". trackingConsentState: TrackingConsentState, customVitalsState: CustomVitalsState, + bufferedDataObservable: BufferedObservable, sdkName: 'rum' | 'rum-slim' | 'rum-synthetics' | undefined ) { const cleanupTasks: Array<() => void> = [] @@ -186,7 +195,8 @@ export function startRum( } } - const { addError } = startErrorCollection(lifeCycle, configuration) + const { addError } = startErrorCollection(lifeCycle, configuration, bufferedDataObservable) + bufferedDataObservable.unbuffer() startRequestCollection(lifeCycle, configuration, session, userContext, accountContext) diff --git a/packages/rum-core/src/domain/error/errorCollection.ts b/packages/rum-core/src/domain/error/errorCollection.ts index c19cf74fb6..7d195483bd 100644 --- a/packages/rum-core/src/domain/error/errorCollection.ts +++ b/packages/rum-core/src/domain/error/errorCollection.ts @@ -1,11 +1,11 @@ -import type { Context, RawError, ClocksState } from '@datadog/browser-core' +import type { Context, RawError, ClocksState, BufferedData } from '@datadog/browser-core' import { + BufferedDataType, + Observable, ErrorSource, generateUUID, computeRawError, ErrorHandling, - Observable, - trackRuntimeError, NonErrorPrefix, combine, } from '@datadog/browser-core' @@ -26,11 +26,20 @@ export interface ProvidedError { componentStack?: string } -export function startErrorCollection(lifeCycle: LifeCycle, configuration: RumConfiguration) { +export function startErrorCollection( + lifeCycle: LifeCycle, + configuration: RumConfiguration, + bufferedDataObservable: Observable +) { const errorObservable = new Observable() + bufferedDataObservable.subscribe((bufferedData) => { + if (bufferedData.type === BufferedDataType.RUNTIME_ERROR) { + errorObservable.notify(bufferedData.error) + } + }) + trackConsoleError(errorObservable) - trackRuntimeError(errorObservable) trackReportError(configuration, errorObservable) errorObservable.subscribe((error) => lifeCycle.notify(LifeCycleEventType.RAW_ERROR_COLLECTED, { error })) diff --git a/test/e2e/scenario/logs.scenario.ts b/test/e2e/scenario/logs.scenario.ts index 1653475aae..82cd770065 100644 --- a/test/e2e/scenario/logs.scenario.ts +++ b/test/e2e/scenario/logs.scenario.ts @@ -169,6 +169,27 @@ test.describe('logs', () => { expect(unreachableRequest.error!.stack).toContain('TypeError') }) + createTest('send runtime errors happening before initialization') + .withLogs({ forwardErrorsToLogs: true }) + .withLogsInit((configuration) => { + // Use a setTimeout to: + // * have a constant stack trace regardless of the setup used + // * avoid the exception to be swallowed by the `onReady` logic + setTimeout(() => { + throw new Error('oh snap') + }) + // Simulate a late initialization of the RUM SDK + setTimeout(() => window.DD_LOGS!.init(configuration)) + }) + .run(async ({ intakeRegistry, flushEvents, withBrowserLogs }) => { + await flushEvents() + expect(intakeRegistry.logsEvents).toHaveLength(1) + expect(intakeRegistry.logsEvents[0].message).toBe('oh snap') + withBrowserLogs((browserLogs) => { + expect(browserLogs).toHaveLength(1) + }) + }) + createTest('add RUM internal context to logs') .withRum() .withLogs() diff --git a/test/e2e/scenario/rum/errors.scenario.ts b/test/e2e/scenario/rum/errors.scenario.ts index cb3a34a1e8..0b1ae5343d 100644 --- a/test/e2e/scenario/rum/errors.scenario.ts +++ b/test/e2e/scenario/rum/errors.scenario.ts @@ -98,6 +98,32 @@ test.describe('rum errors', () => { }) }) + createTest('send runtime errors happening before initialization') + .withRum() + .withRumInit((configuration) => { + // Use a setTimeout to: + // * have a constant stack trace regardless of the setup used + // * avoid the exception to be swallowed by the `onReady` logic + setTimeout(() => { + throw new Error('oh snap') + }) + // Simulate a late initialization of the RUM SDK + setTimeout(() => window.DD_RUM!.init(configuration)) + }) + .run(async ({ intakeRegistry, flushEvents, withBrowserLogs, baseUrl }) => { + await flushEvents() + expect(intakeRegistry.rumErrorEvents).toHaveLength(1) + expectError(intakeRegistry.rumErrorEvents[0].error, { + message: 'oh snap', + source: 'source', + handling: 'unhandled', + stack: ['Error: oh snap', `at @ ${baseUrl}/:`], + }) + withBrowserLogs((browserLogs) => { + expect(browserLogs).toHaveLength(1) + }) + }) + createTest('send unhandled rejections') .withRum() .withBody(createBody('Promise.reject(foo())')) @@ -252,7 +278,7 @@ function expectStack(stack: string | undefined, expectedLines?: Array { if (typeof line !== 'string') {