diff --git a/packages/core/src/tools/stackTrace/handlingStack.ts b/packages/core/src/tools/stackTrace/handlingStack.ts index 2a1100c205..b7a6f54186 100644 --- a/packages/core/src/tools/stackTrace/handlingStack.ts +++ b/packages/core/src/tools/stackTrace/handlingStack.ts @@ -9,7 +9,7 @@ import { computeStackTrace } from './computeStackTrace' * - No monitored function should encapsulate it, that is why we need to use callMonitored inside it. */ export function createHandlingStack( - type: 'console error' | 'action' | 'error' | 'instrumented method' | 'log' | 'react error' + type: 'console error' | 'action' | 'error' | 'instrumented method' | 'log' | 'react error' | 'view' | 'vital' ): string { /** * Skip the two internal frames: diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 9f68e3e60a..349be1eb57 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -593,7 +593,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startView('foo') - expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo' }) + expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo', handlingStack: jasmine.any(String) }) }) it('should call RUM results startView with the view options', () => { @@ -610,6 +610,7 @@ describe('rum public api', () => { service: 'bar', version: 'baz', context: { foo: 'bar' }, + handlingStack: jasmine.any(String), }) }) }) @@ -685,6 +686,7 @@ describe('rum public api', () => { expect(startDurationVitalSpy).toHaveBeenCalledWith('foo', { description: 'description-value', context: { foo: 'bar' }, + handlingStack: jasmine.any(String), }) }) }) @@ -827,6 +829,7 @@ describe('rum public api', () => { duration: 100, context: { foo: 'bar' }, description: 'description-value', + handlingStack: jasmine.any(String), type: VitalType.DURATION, }) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 0ac8f2639b..d0b9aa7c83 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -625,11 +625,14 @@ export function makeRumPublicApi( const startView: { (name?: string): void (options: ViewOptions): void - } = monitor((options?: string | ViewOptions) => { - const sanitizedOptions = typeof options === 'object' ? options : { name: options } - strategy.startView(sanitizedOptions) - addTelemetryUsage({ feature: 'start-view' }) - }) + } = (options?: string | ViewOptions) => { + const handlingStack = createHandlingStack('view') + callMonitored(() => { + const sanitizedOptions = typeof options === 'object' ? options : { name: options } + strategy.startView({ ...sanitizedOptions, handlingStack }) + addTelemetryUsage({ feature: 'start-view' }) + }) + } const rumPublicApi: RumPublicApi = makePublicApi({ init: (initConfiguration) => { @@ -838,25 +841,33 @@ export function makeRumPublicApi( stopSessionReplayRecording: monitor(() => recorderApi.stop()), - addDurationVital: monitor((name, options) => { - addTelemetryUsage({ feature: 'add-duration-vital' }) - strategy.addDurationVital({ - name: sanitize(name)!, - type: VitalType.DURATION, - startClocks: timeStampToClocks(options.startTime as TimeStamp), - duration: options.duration as Duration, - context: sanitize(options && options.context) as Context, - description: sanitize(options && options.description) as string | undefined, + addDurationVital: (name, options) => { + const handlingStack = createHandlingStack('vital') + callMonitored(() => { + addTelemetryUsage({ feature: 'add-duration-vital' }) + strategy.addDurationVital({ + name: sanitize(name)!, + type: VitalType.DURATION, + startClocks: timeStampToClocks(options.startTime as TimeStamp), + duration: options.duration as Duration, + context: sanitize(options && options.context) as Context, + description: sanitize(options && options.description) as string | undefined, + handlingStack, + }) }) - }), + }, - startDurationVital: monitor((name, options) => { - addTelemetryUsage({ feature: 'start-duration-vital' }) - return strategy.startDurationVital(sanitize(name)!, { - context: sanitize(options && options.context) as Context, - description: sanitize(options && options.description) as string | undefined, - }) - }), + startDurationVital: (name, options) => { + const handlingStack = createHandlingStack('vital') + return callMonitored(() => { + addTelemetryUsage({ feature: 'start-duration-vital' }) + return strategy.startDurationVital(sanitize(name)!, { + context: sanitize(options && options.context) as Context, + description: sanitize(options && options.description) as string | undefined, + handlingStack, + }) + }) as DurationVitalReference + }, stopDurationVital: monitor((nameOrRef, options) => { addTelemetryUsage({ feature: 'stop-duration-vital' }) diff --git a/packages/rum-core/src/domain/event/eventCollection.spec.ts b/packages/rum-core/src/domain/event/eventCollection.spec.ts index 032e6aa265..e08a7b3bc5 100644 --- a/packages/rum-core/src/domain/event/eventCollection.spec.ts +++ b/packages/rum-core/src/domain/event/eventCollection.spec.ts @@ -28,7 +28,7 @@ describe('eventCollection', () => { duration: 100, }, } - const domainContext: RumEventDomainContext = { custom: 'context' } + const domainContext: RumEventDomainContext = {} eventCollection.addEvent(startTime, event, domainContext, duration) diff --git a/packages/rum-core/src/domain/view/trackViews.spec.ts b/packages/rum-core/src/domain/view/trackViews.spec.ts index 4ba9151d99..2e1a20052a 100644 --- a/packages/rum-core/src/domain/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/view/trackViews.spec.ts @@ -864,6 +864,15 @@ describe('start view', () => { expect(getViewUpdate(1).duration).toBe(50 as Duration) expect(getViewUpdate(2).startClocks.relative).toBe(50 as RelativeTime) }) + + it('should create view with handling stack', () => { + const { startView, getViewUpdate } = viewTest + + startView({ name: 'foo', handlingStack: 'Error\n at foo\n at bar' }) + + // The new view is at index 2 (after the initial view end and the new view start) + expect(getViewUpdate(2).handlingStack).toBe('Error\n at foo\n at bar') + }) }) describe('view event count', () => { diff --git a/packages/rum-core/src/domain/view/trackViews.ts b/packages/rum-core/src/domain/view/trackViews.ts index 13fa18bb33..356678de73 100644 --- a/packages/rum-core/src/domain/view/trackViews.ts +++ b/packages/rum-core/src/domain/view/trackViews.ts @@ -50,6 +50,7 @@ export interface ViewEvent { version?: string context?: Context location: Readonly + handlingStack?: string commonViewMetrics: CommonViewMetrics initialViewMetrics: InitialViewMetrics customTimings: ViewCustomTimings @@ -99,6 +100,7 @@ export interface ViewOptions { service?: RumInitConfiguration['service'] version?: RumInitConfiguration['version'] context?: Context + handlingStack?: string } export function trackViews( @@ -228,6 +230,7 @@ function newView( const service = viewOptions?.service || configuration.service const version = viewOptions?.version || configuration.version const context = viewOptions?.context + const handlingStack = viewOptions?.handlingStack if (context) { contextManager.setContext(context) @@ -323,6 +326,7 @@ function newView( context: contextManager.getContext(), loadingType, location, + handlingStack, startClocks, commonViewMetrics: getCommonViewMetrics(), initialViewMetrics, diff --git a/packages/rum-core/src/domain/view/viewCollection.ts b/packages/rum-core/src/domain/view/viewCollection.ts index 3d46c4aa1d..9ed35e204e 100644 --- a/packages/rum-core/src/domain/view/viewCollection.ts +++ b/packages/rum-core/src/domain/view/viewCollection.ts @@ -167,6 +167,7 @@ function processViewUpdate( duration: view.duration, domainContext: { location: view.location, + handlingStack: view.handlingStack, }, } } diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts index 41f2ef96e9..37258af4ae 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts @@ -224,6 +224,17 @@ describe('vitalCollection', () => { expect(rawRumEvents[0].domainContext).toEqual({}) }) + it('should create vital with handling stack', () => { + vitalCollection.startDurationVital('foo', { + handlingStack: 'Error\n at foo\n at bar', + }) + vitalCollection.stopDurationVital('foo') + + expect(rawRumEvents[0].domainContext).toEqual({ + handlingStack: 'Error\n at foo\n at bar', + }) + }) + it('should collect raw rum event from operation step vital', () => { mockExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL]) vitalCollection.addOperationStepVital('foo', 'start') diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts index 56d1af1842..828472bf14 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -34,8 +34,14 @@ export interface VitalOptions { /** * Duration vital options */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface DurationVitalOptions extends VitalOptions {} + +export interface DurationVitalOptions extends VitalOptions { + /** + * Handling stack (internal use only) + */ + handlingStack?: string +} + export interface FeatureOperationOptions extends VitalOptions { operationKey?: string } @@ -64,11 +70,13 @@ export interface DurationVitalReference { export interface DurationVitalStart extends DurationVitalOptions { name: string startClocks: ClocksState + handlingStack?: string } interface BaseVital extends VitalOptions { name: string startClocks: ClocksState + handlingStack?: string } export interface DurationVital extends BaseVital { type: typeof VitalType.DURATION @@ -200,11 +208,12 @@ function buildDurationVital( duration: elapsed(startClocks.timeStamp, stopClocks.timeStamp), context: combine(vitalStart.context, stopOptions.context), description: stopOptions.description ?? vitalStart.description, + handlingStack: vitalStart.handlingStack, } } function processVital(vital: DurationVital | OperationStepVital): RawRumEventCollectedData { - const { startClocks, type, name, description, context } = vital + const { startClocks, type, name, description, context, handlingStack } = vital const vitalData = { id: generateUUID(), type, @@ -228,6 +237,6 @@ function processVital(vital: DurationVital | OperationStepVital): RawRumEventCol }, startTime: startClocks.relative, duration: type === VitalType.DURATION ? vital.duration : undefined, - domainContext: {}, + domainContext: handlingStack ? { handlingStack } : {}, } } diff --git a/packages/rum-core/src/domainContext.types.ts b/packages/rum-core/src/domainContext.types.ts index b95870b70e..35e891bc5c 100644 --- a/packages/rum-core/src/domainContext.types.ts +++ b/packages/rum-core/src/domainContext.types.ts @@ -20,6 +20,7 @@ export type RumEventDomainContext = T extends type export interface RumViewEventDomainContext { location: Readonly + handlingStack?: string } export interface RumActionEventDomainContext { @@ -57,5 +58,6 @@ export interface RumLongTaskEventDomainContext { performanceEntry: PerformanceEntry } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface RumVitalEventDomainContext {} +export interface RumVitalEventDomainContext { + handlingStack?: string +} diff --git a/test/e2e/scenario/microfrontend.scenario.ts b/test/e2e/scenario/microfrontend.scenario.ts index 3628265211..ef26f22f45 100644 --- a/test/e2e/scenario/microfrontend.scenario.ts +++ b/test/e2e/scenario/microfrontend.scenario.ts @@ -166,6 +166,47 @@ test.describe('microfrontend', () => { expect(event?.context?.handlingStack).toMatch(HANDLING_STACK_REGEX) }) + createTest('expose handling stack for DD_RUM.startView') + .withRum(RUM_CONFIG) + .withRumInit((configuration) => { + window.DD_RUM!.init(configuration) + + function testHandlingStack() { + window.DD_RUM!.startView({ name: 'test-view' }) + } + + testHandlingStack() + }) + .run(async ({ intakeRegistry, flushEvents }) => { + await flushEvents() + + const event = intakeRegistry.rumViewEvents.find((event) => event.view.name === 'test-view') + + expect(event).toBeTruthy() + expect(event?.context?.handlingStack).toMatch(HANDLING_STACK_REGEX) + }) + + createTest('expose handling stack for DD_RUM.startDurationVital') + .withRum(RUM_CONFIG) + .withRumInit((configuration) => { + window.DD_RUM!.init(configuration) + + function testHandlingStack() { + const ref = window.DD_RUM!.startDurationVital('test-vital') + window.DD_RUM!.stopDurationVital(ref) + } + + testHandlingStack() + }) + .run(async ({ intakeRegistry, flushEvents }) => { + await flushEvents() + + const event = intakeRegistry.rumVitalEvents.find((event) => event.vital.name === 'test-vital') + + expect(event).toBeTruthy() + expect(event?.context?.handlingStack).toMatch(HANDLING_STACK_REGEX) + }) + test.describe('console apis', () => { createTest('expose handling stack for console.log') .withLogs(LOGS_CONFIG) @@ -375,5 +416,31 @@ test.describe('microfrontend', () => { expect(longTaskEvent).toMatchObject({ service: 'mf-service', version: '0.1.0' }) }) + + createTest('manual views should have service and version from source code context') + .withRum(RUM_CONFIG) + .withBody(createBody("window.DD_RUM.startView({ name: 'test-view' })")) + .run(async ({ intakeRegistry, flushEvents, page, baseUrl }) => { + await setSourceCodeContext(page, baseUrl) + await page.locator('button').click() + await flushEvents() + + const viewEvent = intakeRegistry.rumViewEvents.find((event) => event.view.name === 'test-view') + expect(viewEvent).toMatchObject({ service: 'mf-service', version: '0.1.0' }) + }) + + createTest('duration vitals should have service and version from source code context') + .withRum(RUM_CONFIG) + .withBody( + createBody("const ref = window.DD_RUM.startDurationVital('test-vital'); window.DD_RUM.stopDurationVital(ref)") + ) + .run(async ({ intakeRegistry, flushEvents, page, baseUrl }) => { + await setSourceCodeContext(page, baseUrl) + await page.locator('button').click() + await flushEvents() + + const vitalEvent = intakeRegistry.rumVitalEvents.find((event) => event.vital.name === 'test-vital') + expect(vitalEvent).toMatchObject({ service: 'mf-service', version: '0.1.0' }) + }) }) })