diff --git a/MIGRATION.md b/MIGRATION.md index 006380d9f4e0..e12208fcba74 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -345,6 +345,29 @@ Sentry.init({ - The experimental `_experiments.enableStandaloneClsSpans` and `_experiments.enableStandaloneLcpSpans` options were removed from both `browserTracingIntegration` and `webVitalsIntegration`. CLS and LCP are no longer configurable: they are recorded as measurements on the pageload span, unless span streaming is enabled (`traceLifecycle: 'stream'`), in which case they are sent as dedicated spans. - INP is now always sent as a web vital span (streamed when span streaming is enabled, standalone otherwise) that carries its value as a `browser.web_vital.inp.value` attribute. Previously, with span streaming disabled, INP was sent as a standalone span that carried its value as a span measurement. +- `browserTracingIntegration` no longer captures spans created by `performance.mark()` and `performance.measure()` by default. Add `userTimingIntegration()` to continue capturing them. The `ignorePerformanceApiSpans` option moved to the new integration as `ignore`. + +```js +// before +Sentry.init({ + integrations: [ + Sentry.browserTracingIntegration({ + ignorePerformanceApiSpans: ['third-party-mark'], + }), + ], +}); + +// after +Sentry.init({ + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.userTimingIntegration({ + ignore: ['third-party-mark'], + }), + ], +}); +``` + ### `@sentry/node` / Server-side SDKs - `SentryContextManager` is no longer exported. It is no longer needed now that Sentry does not set up OpenTelemetry by default. diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/test.ts deleted file mode 100644 index 6c1348b3185f..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Route } from '@playwright/test'; -import { expect } from '@playwright/test'; -import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; - -sentryTest( - 'should ignore mark and measure spans that match `ignorePerformanceApiSpans`', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - await page.route('**/path/to/script.js', (route: Route) => - route.fulfill({ path: `${__dirname}/assets/script.js` }), - ); - - const url = await getLocalTestUrl({ testDir: __dirname }); - - const transactionRequestPromise = waitForTransactionRequest( - page, - evt => evt.type === 'transaction' && evt.contexts?.trace?.op === 'pageload', - ); - - await page.goto(url); - - const transactionEvent = envelopeRequestParser(await transactionRequestPromise); - const markAndMeasureSpans = transactionEvent.spans?.filter(({ op }) => op && ['mark', 'measure'].includes(op)); - - expect(markAndMeasureSpans?.length).toBe(3); - expect(markAndMeasureSpans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'mark-pass', - op: 'mark', - }), - expect.objectContaining({ - description: 'measure-pass', - op: 'measure', - }), - expect.objectContaining({ - description: 'sentry-tracing-init', - op: 'mark', - }), - ]), - ); - }, -); diff --git a/dev-packages/browser-integration-tests/suites/tracing/user-timing/disabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/user-timing/disabled/init.js new file mode 100644 index 000000000000..d9b15c028d89 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/disabled/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, +}); + +performance.mark('app-ready'); +performance.measure('app-initialization'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/user-timing/disabled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/user-timing/disabled/test.ts new file mode 100644 index 000000000000..8d69125cbd1c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/disabled/test.ts @@ -0,0 +1,18 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('does not capture mark and measure spans by default', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + const spansPromise = waitForStreamedSpans(page, spans => spans.some(span => getSpanOp(span) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const userTimingSpans = spans.filter(span => ['mark', 'measure'].includes(getSpanOp(span) ?? '')); + + expect(userTimingSpans).toHaveLength(0); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans-domexception-details/init.js similarity index 90% rename from dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js rename to dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans-domexception-details/init.js index dcbf047b0b04..d96c6f65b682 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans-domexception-details/init.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; - +import { userTimingIntegration } from '@sentry/browser'; // Create measures BEFORE SDK initializes // Create a measure with detail @@ -24,7 +24,7 @@ window.Sentry = Sentry; Sentry.init({ traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({})], + integrations: [Sentry.browserTracingIntegration(), userTimingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans-domexception-details/test.ts similarity index 93% rename from dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans-domexception-details/test.ts index a990694b46bf..7a39e97420e3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans-domexception-details/test.ts @@ -25,7 +25,7 @@ sentryTest( expect(restrictedMeasure).toBeDefined(); expect(restrictedMeasure?.data).toMatchObject({ 'sentry.op': 'measure', - 'sentry.origin': 'auto.resource.browser.metrics', + 'sentry.origin': 'auto.browser.user_timing.measure', }); // Verify no detail attributes were added due to the permission error @@ -39,7 +39,7 @@ sentryTest( expect(normalMeasure?.data).toMatchObject({ 'sentry.browser.measure.detail': 'this-should-work', 'sentry.op': 'measure', - 'sentry.origin': 'auto.resource.browser.metrics', + 'sentry.origin': 'auto.browser.user_timing.measure', }); // Test 3: Verify the complex detail object is captured correctly @@ -47,7 +47,7 @@ sentryTest( expect(complexMeasure).toBeDefined(); expect(complexMeasure?.data).toMatchObject({ 'sentry.op': 'measure', - 'sentry.origin': 'auto.resource.browser.metrics', + 'sentry.origin': 'auto.browser.user_timing.measure', // The entire nested object is stringified as a single value 'sentry.browser.measure.detail.nested': JSON.stringify({ array: [1, 2, 3], diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans/init.js similarity index 71% rename from dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js rename to dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans/init.js index c80e4049e83d..3c2db904fabe 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans/init.js @@ -1,5 +1,6 @@ // Add measure before SDK initializes import * as Sentry from '@sentry/browser'; +import { userTimingIntegration } from '@sentry/browser'; const end = performance.now(); performance.measure('Next.js-before-hydration', { @@ -12,6 +13,6 @@ window.Sentry = Sentry; Sentry.init({ traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration()], + integrations: [Sentry.browserTracingIntegration(), userTimingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans/test.ts similarity index 96% rename from dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans/test.ts index 8d331118028e..ade0c97799ef 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/pageload-measure-spans/test.ts @@ -30,6 +30,6 @@ sentryTest('should add browser-related spans to pageload transaction', async ({ 'sentry.browser.measure_happened_before_request': true, 'sentry.browser.measure_start_time': expect.any(Number), 'sentry.op': 'measure', - 'sentry.origin': 'auto.resource.browser.metrics', + 'sentry.origin': 'auto.browser.user_timing.measure', }); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js b/dev-packages/browser-integration-tests/suites/tracing/user-timing/spans/init.js similarity index 63% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js rename to dev-packages/browser-integration-tests/suites/tracing/user-timing/spans/init.js index 71956ea21570..c908c7d2c9c2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/spans/init.js @@ -1,15 +1,11 @@ import * as Sentry from '@sentry/browser'; +import { userTimingIntegration } from '@sentry/browser'; window.Sentry = Sentry; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ - ignorePerformanceApiSpans: ['measure-ignore', /mark-i/], - }), - ], + integrations: [Sentry.browserTracingIntegration(), userTimingIntegration({ ignore: ['measure-ignore', /mark-i/] })], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/user-timing/spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/user-timing/spans/test.ts new file mode 100644 index 000000000000..4e01eb11d375 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/user-timing/spans/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('captures non-ignored mark and measure spans', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + const spansPromise = waitForStreamedSpans(page, spans => spans.some(span => getSpanOp(span) === 'pageload')); + + await page.goto(url); + + const spans = await spansPromise; + const userTimingSpans = spans + .filter(span => ['mark', 'measure'].includes(getSpanOp(span) ?? '')) + .map(span => ({ + name: span.name, + op: getSpanOp(span), + origin: span.attributes['sentry.origin']?.value, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + expect(userTimingSpans).toEqual([ + { name: 'mark-pass', op: 'mark', origin: 'auto.browser.user_timing.mark' }, + { name: 'measure-pass', op: 'measure', origin: 'auto.browser.user_timing.measure' }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index 084629a9d437..df6916a6ffa4 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -33,6 +33,7 @@ const IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS: Record = { contextLinesIntegration: 'contextlines', extraErrorDataIntegration: 'extraerrordata', reportingObserverIntegration: 'reportingobserver', + userTimingIntegration: 'usertiming', feedbackIntegration: 'feedback', moduleMetadataIntegration: 'modulemetadata', graphqlClientIntegration: 'graphqlclient', diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 8e839a99251f..7722fa9962cf 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -20,6 +20,8 @@ export { // eslint-disable-next-line typescript/no-deprecated export { elementTimingIntegration, startTrackingElementTiming } from './metrics/elementTiming'; +export { userTimingIntegration } from './metrics/userTiming'; + export { extractNetworkProtocol } from './metrics/utils'; export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 902e1abd135b..f1e28a7d50b5 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -1,16 +1,14 @@ /* eslint-disable max-lines */ -import type { Client, Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core'; +import type { Client, Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, getActiveSpan, getComponentName, - isPrimitive, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, spanToJSON, - stringMatchesSomePattern, } from '@sentry/core'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; @@ -90,11 +88,6 @@ interface StartTrackingWebVitalsOptions { export function startTrackingWebVitals({ trackCls, trackLcp }: StartTrackingWebVitalsOptions): () => void { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin()) { - // @ts-expect-error we want to make sure all of these are available, even if TS is sure they are - if (performance.mark) { - WINDOW.performance.mark('sentry-tracing-init'); - } - const lcpCleanupCallback = trackLcp ? _trackLCP() : undefined; const clsCleanupCallback = trackCls ? _trackCLS() : undefined; const ttfbCleanupCallback = _trackTtfb(); @@ -305,15 +298,6 @@ interface AddPerformanceEntriesOptions { */ ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; - /** - * Performance spans created from browser Performance APIs, - * `performance.mark(...)` nand `performance.measure(...)` - * with `name`s matching strings in the array will not be emitted. - * - * Default: [] - */ - ignorePerformanceApiSpans: Array; - /** * Whether span streaming is enabled. */ @@ -354,7 +338,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries return; } - const { spanStreamingEnabled, ignorePerformanceApiSpans, ignoreResourceSpans } = options; + const { spanStreamingEnabled, ignoreResourceSpans } = options; const timeOrigin = msToSec(origin); @@ -381,10 +365,8 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries _addNavigationSpans(span, entry as PerformanceNavigationTiming, timeOrigin); break; } - case 'mark': - case 'paint': - case 'measure': { - _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans); + case 'paint': { + _addPaintSpan(span, entry, startTime, duration, timeOrigin); break; } case 'resource': { @@ -489,126 +471,23 @@ function resetWebVitalState(): void { _measurements = {}; } -/** - * React 19.2+ creates performance.measure entries for component renders. - * We can identify them by the `detail.devtools.track` property being set to 'Components ⚛'. - * see: https://react.dev/reference/dev-tools/react-performance-tracks - * see: https://github.com/facebook/react/blob/06fcc8f380c6a905c7bc18d94453f623cf8cbc81/packages/react-reconciler/src/ReactFiberPerformanceTrack.js#L454-L473 - */ -function isReact19MeasureEntry(entry: PerformanceEntry | null): boolean | void { - if (entry?.entryType !== 'measure') { - return; - } - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return (entry as PerformanceMeasure).detail.devtools.track === 'Components ⚛'; - } catch { - return; - } -} - -/** - * Create measure related spans. - * Exported only for tests. - */ -export function _addMeasureSpans( +/** Create a span for a browser paint performance entry. */ +function _addPaintSpan( span: Span, entry: PerformanceEntry, startTime: number, duration: number, timeOrigin: number, - ignorePerformanceApiSpans: AddPerformanceEntriesOptions['ignorePerformanceApiSpans'], ): void { - if (isReact19MeasureEntry(entry)) { - return; - } - - if ( - ['mark', 'measure'].includes(entry.entryType) && - stringMatchesSomePattern(entry.name, ignorePerformanceApiSpans) - ) { - return; - } - - const navEntry = getNavigationEntry(false); - - const requestTime = msToSec(navEntry ? navEntry.requestStart : 0); - // Because performance.measure accepts arbitrary timestamps it can produce - // spans that happen before the browser even makes a request for the page. - // - // An example of this is the automatically generated Next.js-before-hydration - // spans created by the Next.js framework. - // - // To prevent this we will pin the start timestamp to the request start time - // This does make duration inaccurate, so if this does happen, we will add - // an attribute to the span - const measureStartTimestamp = timeOrigin + Math.max(startTime, requestTime); - const startTimeStamp = timeOrigin + startTime; - const measureEndTimestamp = startTimeStamp + duration; - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', - }; - - if (measureStartTimestamp !== startTimeStamp) { - attributes['sentry.browser.measure_happened_before_request'] = true; - attributes['sentry.browser.measure_start_time'] = measureStartTimestamp; - } - - _addDetailToSpanAttributes(attributes, entry as PerformanceMeasure); - - // Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process. - if (measureStartTimestamp <= measureEndTimestamp) { - startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { - name: entry.name, - op: entry.entryType, - attributes, - }); - } -} - -function _addDetailToSpanAttributes(attributes: SpanAttributes, performanceMeasure: PerformanceMeasure): void { - try { - // Accessing detail might throw in some browsers (e.g., Firefox) due to security restrictions - const detail = performanceMeasure.detail; - - if (!detail) { - return; - } - - // Process detail based on its type - if (typeof detail === 'object') { - // Handle object details - for (const [key, value] of Object.entries(detail)) { - if (value && isPrimitive(value)) { - attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue; - } else if (value !== undefined) { - try { - // This is user defined so we can't guarantee it's serializable - attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value); - } catch { - // Skip values that can't be stringified - } - } - } - return; - } - - if (isPrimitive(detail)) { - // Handle primitive details - attributes['sentry.browser.measure.detail'] = detail as SpanAttributeValue; - return; - } + const startTimestamp = timeOrigin + startTime; - try { - attributes['sentry.browser.measure.detail'] = JSON.stringify(detail); - } catch { - // Skip if stringification fails - } - } catch { - // Silently ignore any errors when accessing detail - // This handles the Firefox "Permission denied to access object" error - } + startAndEndSpan(span, startTimestamp, startTimestamp + duration, { + name: entry.name, + op: entry.entryType, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', + }, + }); } /** diff --git a/packages/browser-utils/src/metrics/userTiming.ts b/packages/browser-utils/src/metrics/userTiming.ts new file mode 100644 index 000000000000..822a47c13991 --- /dev/null +++ b/packages/browser-utils/src/metrics/userTiming.ts @@ -0,0 +1,191 @@ +import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import type { IntegrationFn, Span, SpanAttributes, SpanAttributeValue } from '@sentry/core'; +import { + browserPerformanceTimeOrigin, + defineIntegration, + isPrimitive, + spanToJSON, + stringMatchesSomePattern, +} from '@sentry/core'; +import { getBrowserPerformanceAPI, msToSec, startAndEndSpan } from './utils'; +import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry'; + +interface UserTimingOptions { + /** + * User Timing entries with names matching any of these strings or regular expressions will not be emitted. + * + * Default: [] + */ + ignore?: Array; +} + +const INTEGRATION_NAME = 'UserTiming'; + +const _userTimingIntegration = ((options: UserTimingOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + const performance = getBrowserPerformanceAPI(); + const timeOrigin = browserPerformanceTimeOrigin(); + if (!performance?.getEntries || !timeOrigin) { + return; + } + const timeOriginInSeconds = msToSec(timeOrigin); + let performanceCursor = 0; + + client.on('beforeIdleSpanEnd', idleSpan => { + const { op: parentOp, start_timestamp: parentStartTimestamp } = spanToJSON(idleSpan); + if (parentOp !== 'pageload' && parentOp !== 'navigation') { + return; + } + + const requestTime = msToSec(getNavigationEntry(false)?.requestStart ?? 0); + const performanceEntries = performance.getEntries(); + + for (const entry of performanceEntries.slice(performanceCursor)) { + if (entry.entryType !== 'mark' && entry.entryType !== 'measure') { + continue; + } + + const startTime = msToSec(entry.startTime); + const absoluteStartTime = timeOriginInSeconds + startTime; + + if (parentOp === 'navigation' && parentStartTimestamp && absoluteStartTime < parentStartTimestamp) { + continue; + } + + _addUserTimingSpan( + idleSpan, + entry, + startTime, + msToSec(Math.max(0, entry.duration)), + timeOriginInSeconds, + requestTime, + options.ignore ?? [], + ); + } + + performanceCursor = performanceEntries.length; + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Captures spans created with the browser's User Timing APIs, `performance.mark` and `performance.measure`. + * + * The integration must be explicitly added to `Sentry.init`. Entries are attached to the active pageload or + * navigation span when it ends. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.browserTracingIntegration(), + * Sentry.userTimingIntegration({ + * ignore: ['third-party-mark', /framework-measure/], + * }), + * ], + * }); + * ``` + */ +export const userTimingIntegration = defineIntegration(_userTimingIntegration); + +/** + * Creates a span for a browser User Timing entry. + * Exported only for tests. + */ +export function _addUserTimingSpan( + parentSpan: Span, + entry: PerformanceEntry, + startTime: number, + duration: number, + timeOrigin: number, + requestTime: number, + ignore: Array, +): void { + if (isReact19MeasureEntry(entry) || stringMatchesSomePattern(entry.name, ignore)) { + return; + } + + // Measures can reference arbitrary timestamps, including timestamps before the page request started. + const spanStartTimestamp = timeOrigin + Math.max(startTime, requestTime); + const originalStartTimestamp = timeOrigin + startTime; + const spanEndTimestamp = originalStartTimestamp + duration; + + const attributes: SpanAttributes = { + [SENTRY_ORIGIN]: `auto.browser.user_timing.${entry.entryType}`, + }; + + if (spanStartTimestamp !== originalStartTimestamp) { + attributes['sentry.browser.measure_happened_before_request'] = true; + attributes['sentry.browser.measure_start_time'] = spanStartTimestamp; + } + + addDetailToSpanAttributes(attributes, entry as PerformanceMeasure); + + // Third-party measurements can contain timestamps which would produce invalid spans. + if (spanStartTimestamp <= spanEndTimestamp) { + startAndEndSpan(parentSpan, spanStartTimestamp, spanEndTimestamp, { + name: entry.name, + op: entry.entryType, + attributes, + }); + } +} + +/** + * React 19.2+ creates performance.measure entries for component renders. + * We can identify them by the `detail.devtools.track` property being set to 'Components ⚛'. + * See https://react.dev/reference/dev-tools/react-performance-tracks. + */ +function isReact19MeasureEntry(entry: PerformanceEntry): boolean | void { + if (entry.entryType !== 'measure') { + return; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return (entry as PerformanceMeasure).detail.devtools.track === 'Components ⚛'; + } catch { + return; + } +} + +function addDetailToSpanAttributes(attributes: SpanAttributes, entry: PerformanceMeasure): void { + try { + // Accessing detail can throw in some browsers due to security restrictions. + const detail = entry.detail; + if (!detail) { + return; + } + + if (typeof detail === 'object') { + for (const [key, value] of Object.entries(detail)) { + if (value && isPrimitive(value)) { + attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue; + } else if (value !== undefined) { + try { + attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value); + } catch { + // User-provided detail values are not guaranteed to be serializable. + } + } + } + return; + } + + if (isPrimitive(detail)) { + attributes['sentry.browser.measure.detail'] = detail as SpanAttributeValue; + return; + } + + try { + attributes['sentry.browser.measure.detail'] = JSON.stringify(detail); + } catch { + // User-provided detail values are not guaranteed to be serializable. + } + } catch { + // Accessing detail can throw in some browsers due to security restrictions. + } +} diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index 8acb93833c2a..fd1ae31d178a 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -11,7 +11,6 @@ import { } from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { - _addMeasureSpans, _addNavigationSpans, _addResourceSpans, _setResourceRequestAttributes, @@ -145,216 +144,6 @@ describe('addWebVitalsToSpan', () => { }); }); -describe('_addMeasureSpans', () => { - const span = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); - - beforeEach(() => { - getCurrentScope().clear(); - getIsolationScope().clear(); - - const client = new TestClient( - getDefaultClientOptions({ - tracesSampleRate: 1, - }), - ); - setCurrentClient(client); - client.init(); - }); - - it('adds measure spans to a span', () => { - const spans: Span[] = []; - - getClient()?.on('spanEnd', span => { - spans.push(span); - }); - - const entry = { - entryType: 'measure', - name: 'measure-1', - duration: 10, - startTime: 12, - } as PerformanceEntry; - - const timeOrigin = 100; - const startTime = 23; - const duration = 356; - - _addMeasureSpans(span, entry, startTime, duration, timeOrigin, []); - - expect(spans).toHaveLength(1); - expect(spanToJSON(spans[0]!)).toEqual( - expect.objectContaining({ - description: 'measure-1', - start_timestamp: timeOrigin + startTime, - timestamp: timeOrigin + startTime + duration, - op: 'measure', - origin: 'auto.resource.browser.metrics', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics', - }, - }), - ); - }); - - it('drops measurement spans with negative duration', () => { - const spans: Span[] = []; - - getClient()?.on('spanEnd', span => { - spans.push(span); - }); - - const entry = { - entryType: 'measure', - name: 'measure-1', - duration: 10, - startTime: 12, - } as PerformanceEntry; - - const timeOrigin = 100; - const startTime = 23; - const duration = -50; - - _addMeasureSpans(span, entry, startTime, duration, timeOrigin, []); - - expect(spans).toHaveLength(0); - }); - - it('ignores performance spans that match ignorePerformanceApiSpans', () => { - const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); - const spans: Span[] = []; - - getClient()?.on('spanEnd', span => { - spans.push(span); - }); - - const entries: PerformanceEntry[] = [ - { - entryType: 'measure', - name: 'measure-pass', - duration: 10, - startTime: 12, - toJSON: () => ({}), - }, - { - entryType: 'measure', - name: 'measure-ignore', - duration: 10, - startTime: 12, - toJSON: () => ({}), - }, - { - entryType: 'mark', - name: 'mark-pass', - duration: 0, - startTime: 12, - toJSON: () => ({}), - }, - { - entryType: 'mark', - name: 'mark-ignore', - duration: 0, - startTime: 12, - toJSON: () => ({}), - }, - { - entryType: 'paint', - name: 'mark-ignore', - duration: 0, - startTime: 12, - toJSON: () => ({}), - }, - ]; - - const timeOrigin = 100; - const startTime = 23; - const duration = 356; - - entries.forEach(e => { - _addMeasureSpans(pageloadSpan, e, startTime, duration, timeOrigin, ['measure-i', /mark-ign/]); - }); - - expect(spans).toHaveLength(3); - expect(spans.map(spanToJSON)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ description: 'measure-pass', op: 'measure' }), - expect.objectContaining({ description: 'mark-pass', op: 'mark' }), - // name matches but type is not (mark|measure) => should not be ignored - expect.objectContaining({ description: 'mark-ignore', op: 'paint' }), - ]), - ); - }); - - it('ignores React 19.2+ measure spans', () => { - const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); - const spans: Span[] = []; - - getClient()?.on('spanEnd', span => { - spans.push(span); - }); - - const entries: PerformanceMeasure[] = [ - { - entryType: 'measure', - name: '\u200bLayout', - duration: 0.3, - startTime: 12, - detail: { - devtools: { - track: 'Components ⚛', - }, - }, - toJSON: () => ({ foo: 'bar' }), - }, - { - entryType: 'measure', - name: '\u200bButton', - duration: 0.1, - startTime: 13, - detail: { - devtools: { - track: 'Components ⚛', - }, - }, - toJSON: () => ({}), - }, - { - entryType: 'measure', - name: 'Unmount', - duration: 0.1, - startTime: 14, - detail: { - devtools: { - track: 'Components ⚛', - }, - }, - toJSON: () => ({}), - }, - { - entryType: 'measure', - name: 'my-measurement', - duration: 0, - startTime: 12, - detail: null, - toJSON: () => ({}), - }, - ]; - - const timeOrigin = 100; - const startTime = 23; - const duration = 356; - - entries.forEach(e => { - _addMeasureSpans(pageloadSpan, e, startTime, duration, timeOrigin, []); - }); - - expect(spans).toHaveLength(1); - expect(spans.map(spanToJSON)).toEqual( - expect.arrayContaining([expect.objectContaining({ description: 'my-measurement', op: 'measure' })]), - ); - }); -}); - describe('_addResourceSpans', () => { const span = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); diff --git a/packages/browser-utils/test/metrics/userTiming.test.ts b/packages/browser-utils/test/metrics/userTiming.test.ts new file mode 100644 index 000000000000..73e007f286ee --- /dev/null +++ b/packages/browser-utils/test/metrics/userTiming.test.ts @@ -0,0 +1,213 @@ +import type { Span } from '@sentry/core'; +import { getCurrentScope, getIsolationScope, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { _addUserTimingSpan, userTimingIntegration } from '../../src/metrics/userTiming'; +import * as utils from '../../src/metrics/utils'; +import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; + +describe('userTimingIntegration', () => { + let client: TestClient; + let performanceEntries: PerformanceEntry[]; + let spans: Span[]; + + beforeEach(() => { + vi.restoreAllMocks(); + getCurrentScope().clear(); + getIsolationScope().clear(); + + client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + + performanceEntries = []; + vi.spyOn(utils, 'getBrowserPerformanceAPI').mockReturnValue({ + getEntries: () => performanceEntries, + } as Performance); + + spans = []; + client.on('spanEnd', span => { + spans.push(span); + }); + }); + + it('captures mark and measure entries created before setup', () => { + performanceEntries.push( + createPerformanceEntry('mark', 'app-ready', 12, 0), + createPerformanceEntry('measure', 'hydrate', 14, 25), + ); + + userTimingIntegration().setup?.(client); + const parentSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + + client.emit('beforeIdleSpanEnd', parentSpan); + + expect(spans).toHaveLength(2); + expect(spans.map(span => spanToJSON(span).description)).toEqual(['app-ready', 'hydrate']); + expect(spans.map(span => spanToJSON(span).op)).toEqual(['mark', 'measure']); + expect(spanToJSON(spans[0]!).timestamp).toBe(spanToJSON(spans[0]!).start_timestamp); + expect(spanToJSON(spans[1]!).timestamp! - spanToJSON(spans[1]!).start_timestamp).toBeCloseTo(0.025); + expect(spanToJSON(spans[1]!).parent_span_id).toBe(parentSpan.spanContext().spanId); + }); + + it('captures only entries added since the previous idle span ended', () => { + userTimingIntegration().setup?.(client); + performanceEntries.push(createPerformanceEntry('mark', 'initial-render', 12, 0)); + + client.emit('beforeIdleSpanEnd', new SentrySpan({ op: 'pageload', name: '/', sampled: true })); + performanceEntries.push(createPerformanceEntry('measure', 'route-render', 30, 10)); + client.emit( + 'beforeIdleSpanEnd', + new SentrySpan({ + op: 'navigation', + name: '/settings', + sampled: true, + startTimestamp: performance.timeOrigin / 1000 + 0.02, + }), + ); + + expect(spans).toHaveLength(2); + expect(spans.map(span => spanToJSON(span).description)).toEqual(['initial-render', 'route-render']); + }); + + it('reads the latest entries immediately before the segment ends', () => { + userTimingIntegration().setup?.(client); + const parentSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + + performanceEntries.push(createPerformanceEntry('measure', 'last-moment-work', 14, 25)); + client.emit('beforeIdleSpanEnd', parentSpan); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!).description).toBe('last-moment-work'); + }); + + it('does not capture entries for unrelated idle spans', () => { + userTimingIntegration().setup?.(client); + const idleSpan = new SentrySpan({ op: 'ui.action', name: 'click', sampled: true }); + performanceEntries.push(createPerformanceEntry('measure', 'work', 14, 25)); + + client.emit('beforeIdleSpanEnd', idleSpan); + + expect(spans).toHaveLength(0); + }); + + it('ignores entries matching strings and regular expressions', () => { + userTimingIntegration({ ignore: ['extension-mark', /^framework-/] }).setup?.(client); + const parentSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + + performanceEntries.push( + createPerformanceEntry('mark', 'extension-mark', 10, 0), + createPerformanceEntry('mark', 'application-mark', 11, 0), + createPerformanceEntry('measure', 'framework-render', 12, 10), + createPerformanceEntry('measure', 'application-render', 13, 10), + ); + client.emit('beforeIdleSpanEnd', parentSpan); + + expect(spans).toHaveLength(2); + expect(spans.map(span => spanToJSON(span).description)).toEqual(['application-mark', 'application-render']); + }); + + it('does not attach entries preceding a navigation span', () => { + userTimingIntegration().setup?.(client); + const timeOrigin = performance.timeOrigin / 1000; + const parentSpan = new SentrySpan({ + op: 'navigation', + name: '/settings', + sampled: true, + startTimestamp: timeOrigin + 0.02, + }); + + performanceEntries.push( + createPerformanceEntry('measure', 'previous-route', 10, 5), + createPerformanceEntry('measure', 'current-route', 30, 5), + ); + client.emit('beforeIdleSpanEnd', parentSpan); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!).description).toBe('current-route'); + }); +}); + +describe('_addUserTimingSpan', () => { + let parentSpan: Span; + let spans: Span[]; + + beforeEach(() => { + vi.restoreAllMocks(); + getCurrentScope().clear(); + getIsolationScope().clear(); + + const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + + parentSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + spans = []; + client.on('spanEnd', span => { + spans.push(span); + }); + }); + + it('adds measure detail as span attributes', () => { + const entry = { + ...createPerformanceEntry('measure', 'hydrate', 12, 10), + detail: { + phase: 'client', + counts: { components: 4 }, + }, + } as PerformanceMeasure; + + _addUserTimingSpan(parentSpan, entry, 0.012, 0.01, 100, 0, []); + + expect(spans).toHaveLength(1); + expect(spanToJSON(spans[0]!).data).toEqual({ + 'sentry.browser.measure.detail.phase': 'client', + 'sentry.browser.measure.detail.counts': '{"components":4}', + 'sentry.op': 'measure', + 'sentry.origin': 'auto.browser.user_timing.measure', + }); + }); + + it('ignores React component performance measures', () => { + const entry = { + ...createPerformanceEntry('measure', '​SettingsPanel', 12, 10), + detail: { + devtools: { + track: 'Components ⚛', + }, + }, + } as PerformanceMeasure; + + _addUserTimingSpan(parentSpan, entry, 0.012, 0.01, 100, 0, []); + + expect(spans).toHaveLength(0); + }); + + it('drops entries whose adjusted start is after their end', () => { + _addUserTimingSpan( + parentSpan, + createPerformanceEntry('measure', 'before-request', 10, 10), + 0.01, + 0.01, + 100, + 0.05, + [], + ); + + expect(spans).toHaveLength(0); + }); +}); + +function createPerformanceEntry( + entryType: 'mark' | 'measure', + name: string, + startTime: number, + duration: number, +): PerformanceEntry { + return { + entryType, + name, + startTime, + duration, + toJSON: () => ({}), + }; +} diff --git a/packages/browser/rollup.bundle.config.mjs b/packages/browser/rollup.bundle.config.mjs index 3306278fe2ae..e779f0cba66d 100644 --- a/packages/browser/rollup.bundle.config.mjs +++ b/packages/browser/rollup.bundle.config.mjs @@ -2,7 +2,13 @@ import { makeBaseBundleConfig, makeBundleConfigVariants } from '@sentry-internal const builds = []; -const browserPluggableIntegrationFiles = ['contextlines', 'httpclient', 'reportingobserver', 'browserprofiling']; +const browserPluggableIntegrationFiles = [ + 'contextlines', + 'httpclient', + 'reportingobserver', + 'browserprofiling', + 'usertiming', +]; const reexportedPluggableIntegrationFiles = [ 'captureconsole', diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 46fec05bbb0a..9a08d33f25e6 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -48,6 +48,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { spanStreamingIntegration } from './integrations/spanstreaming'; export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance'; export { webVitalsIntegration } from './integrations/webVitals'; +export { userTimingIntegration } from './integrations/usertiming'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/usertiming.ts b/packages/browser/src/integrations/usertiming.ts new file mode 100644 index 000000000000..788dddf6929a --- /dev/null +++ b/packages/browser/src/integrations/usertiming.ts @@ -0,0 +1 @@ +export { userTimingIntegration } from '@sentry/browser-utils'; diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 60f8f7707ad8..97f639cad216 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -192,41 +192,6 @@ export interface BrowserTracingOptions { */ ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; - /** - * Spans created from the following browser Performance APIs, - * - * - [`performance.mark(...)`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/mark) - * - [`performance.measure(...)`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure) - * - * will not be emitted if their names match strings in this array. - * - * This is useful, if you come across `mark` or `measure` spans in your Sentry traces - * that you want to ignore. For example, sometimes, browser extensions or libraries - * emit these entries on their own, which might not be relevant to your application. - * - * * @example - * ```ts - * Sentry.init({ - * integrations: [ - * Sentry.browserTracingIntegration({ - * ignorePerformanceApiSpans: ['myMeasurement', /myMark/], - * }), - * ], - * }); - * - * // no spans will be created for these: - * performance.mark('myMark'); - * performance.measure('myMeasurement'); - * - * // spans will be created for these: - * performance.mark('authenticated'); - * performance.measure('input-duration', ...); - * ``` - * - * Default: [] - By default, all `mark` and `measure` entries are sent as spans. - */ - ignorePerformanceApiSpans: Array; - /** * By default, the SDK will try to detect redirects and avoid creating separate spans for them. * If you want to opt-out of this behavior, you can set this option to `false`. @@ -339,7 +304,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongAnimationFrame: true, enableInp: true, ignoreResourceSpans: [], - ignorePerformanceApiSpans: [], detectRedirects: true, linkPreviousTrace: 'in-memory', consistentTraceSampling: false, @@ -395,7 +359,6 @@ export const browserTracingIntegration = ((options: Partial { addPerformanceEntries(span, { ignoreResourceSpans, - ignorePerformanceApiSpans, spanStreamingEnabled: hasSpanStreamingEnabled(client), }); setActiveIdleSpan(client, undefined); diff --git a/packages/browser/src/utils/lazyLoadIntegration.ts b/packages/browser/src/utils/lazyLoadIntegration.ts index 6b93c3f3a00b..2e33db8ea6ea 100644 --- a/packages/browser/src/utils/lazyLoadIntegration.ts +++ b/packages/browser/src/utils/lazyLoadIntegration.ts @@ -22,6 +22,7 @@ const LAZY_LOADABLE_NAMES = [ 'reportingObserverIntegration', 'rewriteFramesIntegration', 'browserProfilingIntegration', + 'userTimingIntegration', 'moduleMetadataIntegration', ] as const; diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 87aa268c2248..9ba95626cca2 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -1489,9 +1489,8 @@ describe('browserTracingIntegration', () => { vi.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout); // idle span itself is now ended - // there is also the `sentry-tracing-init` span included - expect(spans).toHaveLength(3); - expect(spans[2]).toBe(idleSpan); + expect(spans).toHaveLength(2); + expect(spans[1]).toBe(idleSpan); }); it('can be a custom value', () => { @@ -1523,9 +1522,8 @@ describe('browserTracingIntegration', () => { vi.advanceTimersByTime(2000); // idle span itself is now ended - // there is also the `sentry-tracing-init` span included - expect(spans).toHaveLength(3); - expect(spans[2]).toBe(idleSpan); + expect(spans).toHaveLength(2); + expect(spans[1]).toBe(idleSpan); }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 6a11959e9983..836b4ca521c0 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -662,6 +662,9 @@ export abstract class Client { */ public on(hook: 'spanEnd', callback: (span: Span) => void): () => void; + /** Register a callback before an idle span ends and its end timestamp is finalized. */ + public on(hook: 'beforeIdleSpanEnd', callback: (idleSpan: Span) => void): () => void; + /** * Register a callback for after a span is ended and the `spanEnd` hook has run. * NOTE: The span cannot be mutated anymore in this callback. @@ -975,6 +978,9 @@ export abstract class Client { /** Fire a hook whenever a span ends. */ public emit(hook: 'spanEnd', span: Span): void; + /** Fire a hook before an idle span ends and its end timestamp is finalized. */ + public emit(hook: 'beforeIdleSpanEnd', idleSpan: Span): void; + /** * Fire a hook event after a span ends and the `spanEnd` hook has run. */ diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 7deb0fe94c96..d38576f15e47 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -140,6 +140,8 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // eslint-disable-next-line @typescript-eslint/unbound-method span.end = new Proxy(span.end, { apply(target, thisArg, args: Parameters) { + client.emit('beforeIdleSpanEnd', span); + if (beforeSpanEnd) { beforeSpanEnd(span); } diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 9ef6c834d251..c7cd1433a230 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -282,6 +282,24 @@ describe('startIdleSpan', () => { expect(transaction.spans).toEqual([expect.objectContaining({ description: 'from beforeSpanEnd' })]); }); + it('runs beforeIdleSpanEnd before trimming the idle span', () => { + const baseTimeInSeconds = Math.floor(Date.now() / 1000) - 9999; + const beforeIdleSpanEnd = vi.fn((span: Span) => { + expect(spanToJSON(span).timestamp).toBeUndefined(); + const childSpan = startInactiveSpan({ name: 'last-moment child', startTime: baseTimeInSeconds }); + childSpan.end(baseTimeInSeconds + 1); + }); + getClient()!.on('beforeIdleSpanEnd', beforeIdleSpanEnd); + + const idleSpan = startIdleSpan({ name: 'idle span', startTime: baseTimeInSeconds }); + vi.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout + 1); + vi.runOnlyPendingTimers(); + + expect(beforeIdleSpanEnd).toHaveBeenCalledOnce(); + expect(beforeIdleSpanEnd).toHaveBeenCalledWith(idleSpan); + expect(spanToJSON(idleSpan).timestamp).toBe(baseTimeInSeconds + 1); + }); + it('filters spans on end', () => { const transactions: Event[] = []; const beforeSendTransaction = vi.fn(event => {