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 () => {