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' } }],