Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
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
74 changes: 68 additions & 6 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, spanJsonToStreamedSpanJSON } from './spanJsonToStreamedSpan';
import { scopeContextsToSpanAttributes } from './scopeContextAttributes';
import { DEFAULT_ENVIRONMENT } from '../../constants';
import {
Expand Down Expand Up @@ -126,17 +128,15 @@ function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client
});
}

function applyCommonSpanAttributes(
spanJSON: StreamedSpanJSON,
function commonSpanAttributes(
serializedSegmentSpan: StreamedSpanJSON,
client: Client,
scopeData: ScopeData,
): void {
): 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 @@ -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);
}

/**
Expand Down
39 changes: 34 additions & 5 deletions packages/core/src/tracing/spans/spanJsonToStreamedSpan.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) };

for (const [field, attribute] of TOP_LEVEL_ATTRIBUTE_FIELDS) {
attributes[attribute] = span[field] ?? attributes[attribute];
}

return {
Comment thread
cursor[bot] marked this conversation as resolved.
trace_id: span.trace_id,
span_id: span.span_id,
parent_span_id: span.parent_span_id,
Expand All @@ -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<Record<string, unknown>>) },
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));
}
Loading
Loading