diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js new file mode 100644 index 000000000000..64cd4ccff9d7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/init.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 4000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, + // A plain (non-streamed) `beforeSendSpan` operates on the v1 `SpanJSON`. INP is sent as a v2 span, + // so this verifies the static callback still runs and its changes are carried into the v2 span. + beforeSendSpan: span => { + if (span.op === 'ui.interaction.click') { + span.description = 'scrubbed'; + span.data['custom.attribute'] = 'from-before-send-span'; + } + + return span; + }, + debug: true, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-url', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/subject.js new file mode 100644 index 000000000000..64524952dfa7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/subject.js @@ -0,0 +1,20 @@ +const blockUI = + (delay = 70) => + e => { + const startTime = Date.now(); + + function getElasped() { + const time = Date.now(); + return time - startTime; + } + + while (getElasped() < delay) { + // + } + + e.target.classList.add('clicked'); + }; + +document.querySelector('[data-test-id=not-so-slow-button]').addEventListener('click', blockUI(300)); +document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450)); +document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/template.html new file mode 100644 index 000000000000..437426e9ab01 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/test.ts new file mode 100644 index 000000000000..add8a0ac71c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-before-send-span/test.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { hidePage, shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +// This app does not enable span streaming (`traceLifecycle: 'static'`) and defines a plain, non-streamed +// `beforeSendSpan` callback (operating on the v1 `SpanJSON`). INP is still emitted as a v2 span, so this +// verifies the static callback runs for INP and its modifications are carried into the v2 span. + +sentryTest('runs a non-streamed `beforeSendSpan` for the INP span', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spanEnvelopePromise = waitForStreamedSpanEnvelope( + page, + env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'ui.interaction.click'), + ); + + await page.goto(url); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await hidePage(page); + + const spanEnvelope = await spanEnvelopePromise; + const inpSpan = getSpansFromEnvelope(spanEnvelope).find(s => getSpanOp(s) === 'ui.interaction.click')!; + + // The callback rewrote the name and added a custom attribute. + expect(inpSpan.name).toBe('scrubbed'); + expect(inpSpan.attributes['custom.attribute']).toEqual({ value: 'from-before-send-span', type: 'string' }); + + // The span is still a valid v2 INP span carrying its web vital value. + const inpValue = inpSpan.attributes['browser.web_vital.inp.value']?.value as number; + expect(inpValue).toBeGreaterThan(0); +}); diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 85f09b801ef2..9c4f82249874 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -1,8 +1,9 @@ -import type { Client, Span, SpanAttributes } from '@sentry/core'; +import type { Client, Integration, Span, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, getActiveSpan, + getClient, getCurrentScope, getRootSpan, hasSpanStreamingEnabled, @@ -108,6 +109,15 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent; } + // A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see + // `captureStandaloneSpanWithStaticCallback`), so Replay can't attach the replay id itself. Set it + // here, mirroring Replay's `processSpan`, so INP keeps its replay association like it did on v1. + // TODO(standalone): remove once the static (transaction) trace lifecycle is dropped and INP always + // streams, at which point Replay's `processSpan` runs and attaches the replay id. + if (standalone) { + Object.assign(attributes, getReplayAttributes()); + } + const span = startInactiveSpan({ name, attributes, @@ -122,6 +132,26 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { } } +interface ReplayIntegration extends Integration { + getReplayId: (onlyIfSampled?: boolean) => string | undefined; + getRecordingMode: () => 'session' | 'buffer' | undefined; +} + +// TODO(standalone): remove once the static (transaction) trace lifecycle is dropped; Replay's +// `processSpan` then attaches the replay id to the streamed INP span instead. +function getReplayAttributes(): SpanAttributes { + const replay = getClient()?.getIntegrationByName('Replay'); + const replayId = replay?.getReplayId(true); + if (!replayId) { + return {}; + } + + return { + 'sentry.replay_id': replayId, + 'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined, + }; +} + /** * Tracks LCP as a streamed span. */ diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 53f248a4c640..ef938d7017f0 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -19,6 +19,7 @@ vi.mock('@sentry/core', async () => { browserPerformanceTimeOrigin: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), + getClient: vi.fn(), startInactiveSpan: vi.fn(), getActiveSpan: vi.fn(), getRootSpan: vi.fn(), @@ -64,6 +65,7 @@ describe('_emitWebVitalSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: {} } as any); + vi.mocked(SentryCore.getClient).mockReturnValue({ getIntegrationByName: () => undefined } as any); }); afterEach(() => { @@ -118,6 +120,71 @@ describe('_emitWebVitalSpan', () => { ); }); + it('adds the replay id to a standalone span when a replay is recording', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ + getIntegrationByName: () => ({ getReplayId: () => 'replay-123', getRecordingMode: () => 'session' }), + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 100, + startTime: 1.5, + standalone: true, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.replay_id': 'replay-123', + 'sentry._internal.replay_is_buffering': undefined, + }), + }), + ); + }); + + it('flags buffering when the replay is in buffer mode', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ + getIntegrationByName: () => ({ getReplayId: () => 'replay-123', getRecordingMode: () => 'buffer' }), + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 100, + startTime: 1.5, + standalone: true, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'sentry._internal.replay_is_buffering': true }), + }), + ); + }); + + it('does not add a replay id to non-standalone spans', () => { + vi.mocked(SentryCore.getClient).mockReturnValue({ + getIntegrationByName: () => ({ getReplayId: () => 'replay-123', getRecordingMode: () => 'session' }), + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 100, + startTime: 1.5, + }); + + const attributes = vi.mocked(SentryCore.startInactiveSpan).mock.calls[0]![0].attributes!; + expect(attributes['sentry.replay_id']).toBeUndefined(); + }); + it('includes pageload span id when parentSpan is a pageload span', () => { const mockPageloadSpan = createMockPageloadSpan('abc123'); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index b71b3dc2e486..06004521e417 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -46,7 +46,8 @@ import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanEnd } from './logSpans'; import { timedEventsToMeasurements } from './measurement'; import { getSegmentSpanCaptureStrategy, type SegmentSpanCaptureConvertOptions } from './segmentSpanCaptureStrategy'; -import { captureSpan } from './spans/captureSpan'; +import { isStreamedBeforeSendSpanCallback } from './spans/beforeSendSpan'; +import { captureSpan, captureStandaloneSpanWithStaticCallback } from './spans/captureSpan'; import { createStreamedSpanEnvelope } from './spans/envelope'; import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { @@ -556,6 +557,21 @@ function isStandaloneSpan(span: Span): boolean { * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped. */ function sendStandaloneSpan(span: SentrySpan, client: Client): void { + const { beforeSendSpan } = client.getOptions(); + + // A user who opted out of span streaming writes `beforeSendSpan` in the v1 `SpanJSON` format. That + // callback never runs through `captureSpan` (which only honors streamed callbacks), so scrub the + // span in its native v1 shape and convert it forward to v2, mirroring the gen_ai extraction path. + // TODO(standalone): remove this branch once the static trace lifecycle is dropped. + if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) { + const serializedSpan = captureStandaloneSpanWithStaticCallback(span, client, beforeSendSpan); + const dsc = getDynamicSamplingContextFromSpan(span); + // sendEnvelope should not throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.sendEnvelope(createStreamedSpanEnvelope([serializedSpan], dsc, client)); + return; + } + const { _segmentSpan, ...serializedSpan } = captureSpan(span, client); const dsc = getDynamicSamplingContextFromSpan(_segmentSpan); // sendEnvelope should not throw diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index b65f85b02d46..39ad44585c5f 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -11,16 +11,18 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; -import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types/span'; +import type { SerializedStreamedSpan, Span, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, + spanToJSON, spanToStreamedSpanJSON, streamedSpanJsonToSerializedSpan, } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { spanJsonToSerializedStreamedSpan } from './spanJsonToStreamedSpan'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { @@ -126,17 +128,18 @@ function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client }); } -function applyCommonSpanAttributes( - spanJSON: StreamedSpanJSON, +function commonSpanAttributes( serializedSegmentSpan: StreamedSpanJSON, client: Client, scopeData: ScopeData, -): void { + // TODO(standalone): remove this param (always include scope attributes) once the static (transaction) + // trace lifecycle is dropped and standalone spans no longer need to look transaction-shaped. + includeScopeAttributes = true, +): RawAttributes> { const sdk = client.getSdkMetadata(); const { release, environment } = client.getOptions(); - // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) - safeSetSpanJSONAttributes(spanJSON, { + return { [SENTRY_TRACE_LIFECYCLE]: 'stream', [SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name, [SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, @@ -148,8 +151,54 @@ function applyCommonSpanAttributes( [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, - ...scopeData.attributes, + ...(includeScopeAttributes ? scopeData.attributes : undefined), + }; +} + +function applyCommonSpanAttributes( + spanJSON: StreamedSpanJSON, + serializedSegmentSpan: StreamedSpanJSON, + client: Client, + scopeData: ScopeData, +): void { + // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) + safeSetSpanJSONAttributes(spanJSON, commonSpanAttributes(serializedSegmentSpan, client, scopeData)); +} + +/** + * Captures a standalone span whose `beforeSendSpan` callback expects the v1 {@link SpanJSON} format + * (i.e. the user opted out of span streaming). The span is serialized to v1, the common attributes are + * applied, the callback runs in its native format, and the result is converted forward to a serialized + * v2 span. This mirrors how gen_ai spans reach the v2 span path from a static transaction (a plain + * conversion, no `processSpan` hooks), so there is never a reverse v2 -> v1 conversion. + * + * TODO(standalone): remove once the static (transaction) trace lifecycle is dropped. + */ +export function captureStandaloneSpanWithStaticCallback( + span: Span, + client: Client, + beforeSendSpan: (span: SpanJSON) => SpanJSON, +): SerializedStreamedSpan { + const spanJSON = spanToJSON(span); + + const segmentSpan = INTERNAL_getSegmentSpan(span); + const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan); + + const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span); + const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope); + + // Skip scope attributes: their `{ unit, value }` shape is unexpected for a static callback, and like + // transactions, standalone spans don't get them. + const commonAttributes = commonSpanAttributes(serializedSegmentSpan, client, finalScopeData, false); + Object.entries(commonAttributes).forEach(([key, value]) => { + if (value != null && !(key in spanJSON.data)) { + spanJSON.data[key] = value as SpanAttributeValue; + } }); + + const processedSpan = beforeSendSpan(spanJSON) || (showSpanDropWarning(), spanJSON); + + return spanJsonToSerializedStreamedSpan(processedSpan); } /** diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 7522b7061234..7f509cc23c0e 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -295,10 +295,11 @@ describe('SentrySpan', () => { expect(mockSend).toHaveBeenCalled(); }); - test('ignores a non-streamed `beforeSendSpan` for standalone spans', () => { - // Standalone spans are sent as v2 streamed spans, which only honor a `beforeSendSpan` wrapped - // with `withStreamedSpan`. A plain callback is ignored, so the span is sent unmodified. - const beforeSendSpan = vi.fn(() => null as unknown as SpanJSON); + test('runs a non-streamed `beforeSendSpan` for standalone spans', () => { + // A standalone span is sent as a v2 streamed span, but a user opting out of span streaming still + // writes `beforeSendSpan` in the v1 `SpanJSON` format. We scrub the span in its v1 shape before + // converting it forward to v2, so the callback runs and its changes are applied. + const beforeSendSpan = vi.fn((span: SpanJSON) => ({ ...span, description: 'scrubbed' })); const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://username@domain/123', @@ -309,6 +310,10 @@ describe('SentrySpan', () => { setCurrentClient(client); const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + const envelopes: Envelope[] = []; + client.on('beforeEnvelope', envelope => { + envelopes.push(envelope); + }); // @ts-expect-error Accessing private transport API const mockSend = vi.spyOn(client._transport, 'send'); const span = new SentrySpan({ @@ -320,9 +325,43 @@ describe('SentrySpan', () => { }); span.end(); - expect(beforeSendSpan).not.toHaveBeenCalled(); + expect(beforeSendSpan).toHaveBeenCalledTimes(1); expect(mockSend).toHaveBeenCalled(); expect(recordDroppedEventSpy).not.toHaveBeenCalled(); + + const spanItem = envelopes[0]?.[1][0] as [{ type: string }, { items: Array<{ name: string }> }]; + expect(spanItem[0].type).toBe('span'); + expect(spanItem[1].items[0]!.name).toBe('scrubbed'); + }); + + test('does not apply scope attributes to standalone spans with a non-streamed `beforeSendSpan`', () => { + // Scope attributes can hold `{ unit, value }` objects, unexpected for a static callback, so they + // are not applied to the standalone (INP) span, just as they are not applied to transactions. + const seen: SpanJSON['data'][] = []; + const beforeSendSpan = vi.fn((span: SpanJSON) => { + seen.push({ ...span.data }); + return span; + }); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + beforeSendSpan, + }), + ); + setCurrentClient(client); + getCurrentScope().setAttribute('my.scope.attr', 'from-scope'); + + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + expect(seen[0]!['my.scope.attr']).toBeUndefined(); }); test('sends a standalone span on its own and excludes it from the parent transaction', async () => {