Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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',
},
});
Original file line number Diff line number Diff line change
@@ -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());
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div>Rendered Before Long Task</div>
<button data-test-id="slow-button" data-sentry-element="SlowButton">Slow</button>
<button data-test-id="not-so-slow-button" data-sentry-element="NotSoSlowButton">Not so slow</button>
<button data-test-id="normal-button" data-sentry-element="NormalButton">Click Me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
logaretm marked this conversation as resolved.

// 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);
});
32 changes: 31 additions & 1 deletion packages/browser-utils/src/metrics/webVitalSpans.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<ReplayIntegration>('Replay');
const replayId = replay?.getReplayId(true);
if (!replayId) {
return {};
}

return {
'sentry.replay_id': replayId,
'sentry._internal.replay_is_buffering': replay!.getRecordingMode() === 'buffer' ? true : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-null assertion lacks safety comment

Low Severity

getReplayAttributes uses a non-null assertion on replay with no comment explaining why a safer narrow isn't possible. The same replay lookup in logs/metrics already avoids this with optional chaining after the replayId check.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit fc7c722. Configure here.

};
}

/**
* Tracks LCP as a streamed span.
*/
Expand Down
67 changes: 67 additions & 0 deletions packages/browser-utils/test/metrics/webVitalSpans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/tracing/sentrySpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
63 changes: 56 additions & 7 deletions packages/core/src/tracing/spans/captureSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Record<string, unknown>> {
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,
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Callback op changes not preserved

Medium Severity

captureStandaloneSpanWithStaticCallback converts the post-callback v1 SpanJSON with spanJsonToSerializedStreamedSpan, which only copies data into v2 attributes. Top-level op and origin edits from a static beforeSendSpan therefore never reach the sent INP span, even though description and data changes do.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fc7c722. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shut up, we know

}

/**
Expand Down
Loading
Loading