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/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..39027d1d9b86 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, spanJsonToStreamedSpanJSON } from './spanJsonToStreamedSpan'; import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; import { DEFAULT_ENVIRONMENT } from '../../constants'; import { @@ -126,17 +128,15 @@ function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client }); } -function applyCommonSpanAttributes( - spanJSON: StreamedSpanJSON, +function commonSpanAttributes( serializedSegmentSpan: StreamedSpanJSON, client: Client, scopeData: ScopeData, -): void { +): 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, @@ -149,7 +149,69 @@ function applyCommonSpanAttributes( [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, ...scopeData.attributes, + }; +} + +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 and the common attributes + * are applied. It is then converted forward to the intermediate v2 span JSON, on which the + * `preprocessSpan`/`processSpan` hooks run (so integrations like Replay enrich it, e.g. attaching + * `sentry.replay_id`), matching the order in {@link captureSpan} where hooks run before `beforeSendSpan`. + * The enrichment is reflected back onto the v1 JSON so the callback sees it, the callback runs in its + * native format, and the result is serialized. This mirrors how gen_ai spans reach the v2 span path + * from a static transaction, 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); + + const commonAttributes = commonSpanAttributes(serializedSegmentSpan, client, finalScopeData); + Object.entries(commonAttributes).forEach(([key, value]) => { + if (value != null && !(key in spanJSON.data)) { + spanJSON.data[key] = value as SpanAttributeValue; + } }); + + // A standalone span is never a segment span (see `spanJsonToStreamedSpanJSON`), so we only run the + // regular span hooks. These let integrations enrich the span the same way they do in the streaming + // pipeline, e.g. Replay attaching `sentry.replay_id`. + const streamedSpanJSON = spanJsonToStreamedSpanJSON(spanJSON); + client.emit('preprocessSpan', streamedSpanJSON); + client.emit('processSpan', streamedSpanJSON); + + // Reflect the hook enrichment back onto the v1 JSON so `beforeSendSpan` (which runs on v1, after the + // hooks, as in `captureSpan`) sees it. Attributes and name map cleanly. The v1 status is a free-form + // message, but v2 only has `'ok' | 'error'`, so restore the original v1 status rather than lose detail. + const originalStatus = spanJSON.status; + spanJSON.data = streamedSpanJSON.attributes as SpanJSON['data']; + spanJSON.description = streamedSpanJSON.name; + spanJSON.status = originalStatus; + + const processedSpan = beforeSendSpan(spanJSON) || (showSpanDropWarning(), spanJSON); + + return spanJsonToSerializedStreamedSpan(processedSpan); } /** diff --git a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts index de664b802138..127622190103 100644 --- a/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts +++ b/packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts @@ -1,12 +1,36 @@ import type { RawAttributes } from '../../attributes'; +import { + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_PROFILE_ID, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '../../semanticAttributes'; import type { SerializedStreamedSpan, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { streamedSpanJsonToSerializedSpan } from '../../utils/spanUtils'; +// v1 SpanJSON mirrors some attributes as top-level fields (see `SentrySpan.getSpanJSON`). A +// `beforeSendSpan` callback edits the top-level field, so those edits have to be folded back into +// attributes, letting the top-level value win over the (initially identical) attribute. This is the +// inverse of `getSpanJSON` and mirrors how `convertSpanJsonToTransactionEvent` rebuilds `data`. +const TOP_LEVEL_ATTRIBUTE_FIELDS = [ + ['op', SEMANTIC_ATTRIBUTE_SENTRY_OP], + ['origin', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + ['profile_id', SEMANTIC_ATTRIBUTE_PROFILE_ID], + ['exclusive_time', SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME], +] as const; + /** - * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. + * Converts a v1 SpanJSON (from a legacy transaction) to the intermediate v2 {@link StreamedSpanJSON} + * (raw attributes), before serialization. Use this when a hook needs to mutate the span JSON. */ -export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { - const streamedSpan: StreamedSpanJSON = { +export function spanJsonToStreamedSpanJSON(span: SpanJSON): StreamedSpanJSON { + const attributes = { ...(span.data as RawAttributes>) }; + + for (const [field, attribute] of TOP_LEVEL_ATTRIBUTE_FIELDS) { + attributes[attribute] = span[field] ?? attributes[attribute]; + } + + return { trace_id: span.trace_id, span_id: span.span_id, parent_span_id: span.parent_span_id, @@ -15,9 +39,14 @@ export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStre end_timestamp: span.timestamp || span.start_timestamp, status: !span.status || span.status === 'ok' || span.status === 'cancelled' ? 'ok' : 'error', is_segment: false, - attributes: { ...(span.data as RawAttributes>) }, + attributes, links: span.links, }; +} - return streamedSpanJsonToSerializedSpan(streamedSpan); +/** + * Converts a v1 SpanJSON (from a legacy transaction) to a serialized v2 StreamedSpan. + */ +export function spanJsonToSerializedStreamedSpan(span: SpanJSON): SerializedStreamedSpan { + return streamedSpanJsonToSerializedSpan(spanJsonToStreamedSpanJSON(span)); } diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 7522b7061234..47fcd10822ac 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,61 @@ 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('runs `processSpan` hooks before a non-streamed `beforeSendSpan` for standalone spans', () => { + // Integrations (e.g. Replay attaching `sentry.replay_id`) enrich spans via the `processSpan` hook. + // The static-callback standalone path must run those hooks, and (as in `captureSpan`) run them + // before `beforeSendSpan`, so the callback sees the enrichment and its mutation lands on the span. + let seenReplayId: unknown; + const beforeSendSpan = vi.fn((span: SpanJSON) => { + seenReplayId = span.data['sentry.replay_id']; + span.data['sentry.replay_id'] = 'scrubbed'; + return span; + }); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + enableSend: true, + beforeSendSpan, + }), + ); + setCurrentClient(client); + + client.on('processSpan', span => { + (span.attributes ?? (span.attributes = {}))['sentry.replay_id'] = 'abc'; + }); + + const envelopes: Envelope[] = []; + client.on('beforeEnvelope', envelope => { + envelopes.push(envelope); + }); + + const span = new SentrySpan({ + name: 'test', + isStandalone: true, + startTimestamp: 1, + endTimestamp: 2, + sampled: true, + }); + span.end(); + + // The callback saw the hook-added attribute (hooks ran first)... + expect(seenReplayId).toBe('abc'); + + // ...and its own change to that attribute is what gets sent. + const spanItem = envelopes[0]?.[1][0] as [ + { type: string }, + { items: Array<{ attributes: Record }> }, + ]; + expect(spanItem[1].items[0]!.attributes['sentry.replay_id']!.value).toBe('scrubbed'); }); test('sends a standalone span on its own and excludes it from the parent transaction', async () => { diff --git a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts index 94b25c590d19..b18d8ec9c263 100644 --- a/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/spanJsonToStreamedSpan.test.ts @@ -79,6 +79,51 @@ describe('spanJsonToSerializedStreamedSpan', () => { expect(result.attributes['gen_ai.usage.output_tokens']).toEqual({ type: 'integer', value: 50 }); }); + it('maps top-level op/origin into sentry.op/sentry.origin attributes', () => { + const span = makeSpanJSON({ op: 'ui.interaction.click', origin: 'auto.http.browser.inp', data: {} }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' }); + expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' }); + }); + + it('lets a mutated top-level op/origin win over the original data attribute', () => { + // A `beforeSendSpan` callback mutates the top-level field while the data attribute stays stale. + const span = makeSpanJSON({ + op: 'changed.op', + origin: 'changed.origin', + data: { 'sentry.op': 'stale.op', 'sentry.origin': 'stale.origin' }, + }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'changed.op' }); + expect(result.attributes['sentry.origin']).toEqual({ type: 'string', value: 'changed.origin' }); + }); + + it('keeps the data attribute when there is no top-level op/origin', () => { + const span = makeSpanJSON({ op: undefined, origin: undefined, data: { 'sentry.op': 'from.data' } }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.op']).toEqual({ type: 'string', value: 'from.data' }); + expect(result.attributes['sentry.origin']).toBeUndefined(); + }); + + it('maps mutated top-level profile_id/exclusive_time back into attributes', () => { + const span = makeSpanJSON({ + profile_id: 'new-profile', + exclusive_time: 42, + data: { 'sentry.profile_id': 'stale-profile', 'sentry.exclusive_time': 1 }, + }); + + const result = spanJsonToSerializedStreamedSpan(span); + + expect(result.attributes['sentry.profile_id']).toEqual({ type: 'string', value: 'new-profile' }); + expect(result.attributes['sentry.exclusive_time']).toEqual({ type: 'integer', value: 42 }); + }); + it('carries over links', () => { const span = makeSpanJSON({ links: [{ trace_id: 'aabb', span_id: 'ccdd', sampled: true, attributes: { foo: 'bar' } }],