diff --git a/.size-limit.js b/.size-limit.js
index baded21f5200..aef521dc83f6 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -15,7 +15,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init'),
gzip: true,
- limit: '24.5 KB',
+ limit: '25 KB',
modifyWebpackConfig: function (config) {
const webpack = require('webpack');
@@ -103,7 +103,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'sendFeedback'),
gzip: true,
- limit: '31 KB',
+ limit: '32 KB',
},
{
name: '@sentry/browser (incl. FeedbackAsync)',
@@ -117,7 +117,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'metrics'),
gzip: true,
- limit: '27 KB',
+ limit: '28 KB',
},
{
name: '@sentry/browser (incl. Logs)',
@@ -148,7 +148,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
- limit: '45 KB',
+ limit: '46 KB',
},
// Vue SDK (ESM)
{
@@ -220,13 +220,13 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing, Replay, Feedback)',
path: createCDNPath('bundle.tracing.replay.feedback.min.js'),
gzip: true,
- limit: '86 KB',
+ limit: '87 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)',
path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'),
gzip: true,
- limit: '87 KB',
+ limit: '88 KB',
},
// browser CDN bundles (non-gzipped)
{
@@ -241,7 +241,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.min.js'),
gzip: false,
brotli: false,
- limit: '129 KB',
+ limit: '130 KB',
},
{
name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed',
@@ -255,7 +255,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.logs.metrics.min.js'),
gzip: false,
brotli: false,
- limit: '132 KB',
+ limit: '133 KB',
},
{
name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed',
@@ -269,7 +269,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.replay.min.js'),
gzip: false,
brotli: false,
- limit: '246 KB',
+ limit: '247 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed',
@@ -317,7 +317,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
- limit: '53 KB',
+ limit: '55 KB',
},
// Node SDK (ESM)
{
@@ -326,14 +326,14 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
- limit: '175 KB',
+ limit: '177 KB',
},
{
name: '@sentry/node - without tracing',
path: 'packages/node/build/esm/index.js',
import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'),
gzip: true,
- limit: '98 KB',
+ limit: '100 KB',
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
modifyWebpackConfig: function (config) {
const webpack = require('webpack');
@@ -356,7 +356,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
- limit: '114 KB',
+ limit: '116 KB',
},
];
diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js
new file mode 100644
index 000000000000..aaafd3396f14
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1.0,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js
new file mode 100644
index 000000000000..7e4395e06708
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/subject.js
@@ -0,0 +1,13 @@
+Sentry.startSpan({ name: 'test-span', op: 'test' }, () => {
+ Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => {
+ // noop
+ });
+
+ const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' });
+ inactiveSpan.end();
+
+ Sentry.startSpanManual({ name: 'test-manual-span' }, span => {
+ // noop
+ span.end();
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts
new file mode 100644
index 000000000000..b5f8f41ab4b4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts
@@ -0,0 +1,217 @@
+import { expect } from '@playwright/test';
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'sends a streamed span envelope if spanStreamingIntegration is enabled',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const spanEnvelopePromise = waitForStreamedSpanEnvelope(page);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const spanEnvelope = await spanEnvelopePromise;
+
+ const envelopeHeader = spanEnvelope[0];
+ const envelopeItem = spanEnvelope[1];
+ const spans = envelopeItem[0][1].items;
+
+ expect(envelopeHeader).toEqual({
+ sdk: {
+ name: 'sentry.javascript.browser',
+ version: SDK_VERSION,
+ },
+ sent_at: expect.any(String),
+ trace: {
+ environment: 'production',
+ public_key: 'public',
+ sample_rand: expect.any(String),
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ transaction: 'test-span',
+ },
+ });
+
+ const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!);
+ const traceId = envelopeHeader.trace!.trace_id;
+
+ expect(Number.isNaN(numericSampleRand)).toBe(false);
+
+ expect(envelopeItem).toEqual([
+ [
+ { content_type: 'application/vnd.sentry.items.span.v2+json', item_count: 4, type: 'span' },
+ {
+ items: expect.any(Array),
+ },
+ ],
+ ]);
+
+ const segmentSpanId = spans.find(s => !!s.is_segment)?.span_id;
+ expect(segmentSpanId).toBeDefined();
+
+ expect(spans).toEqual([
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'test-child',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: segmentSpanId,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: 'test-span',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: false,
+ name: 'test-child-span',
+ parent_span_id: segmentSpanId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: traceId,
+ },
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: segmentSpanId,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: 'test-span',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: false,
+ name: 'test-inactive-span',
+ parent_span_id: segmentSpanId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: traceId,
+ },
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: segmentSpanId,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: 'test-span',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: false,
+ name: 'test-manual-span',
+ parent_span_id: segmentSpanId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: traceId,
+ },
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'test',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: segmentSpanId,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: 'test-span',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'custom',
+ },
+ 'sentry.span.source': {
+ type: 'string',
+ value: 'custom',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ name: 'test-span',
+ span_id: segmentSpanId,
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: traceId,
+ },
+ ]);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js
new file mode 100644
index 000000000000..749560a5c459
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js
new file mode 100644
index 000000000000..b657f38ac009
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/subject.js
@@ -0,0 +1,8 @@
+document.getElementById('go-background').addEventListener('click', () => {
+ setTimeout(() => {
+ Object.defineProperty(document, 'hidden', { value: true, writable: true });
+ const ev = document.createEvent('Event');
+ ev.initEvent('visibilitychange');
+ document.dispatchEvent(ev);
+ }, 250);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html
new file mode 100644
index 000000000000..8083ddc80694
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/template.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts
new file mode 100644
index 000000000000..10e58acb81ad
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-pageload-streamed/test.ts
@@ -0,0 +1,18 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest('finishes streamed pageload span when the page goes background', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+ await page.locator('#go-background').click();
+ const pageloadSpan = await pageloadSpanPromise;
+
+ // TODO: Is this what we want?
+ expect(pageloadSpan.status).toBe('ok');
+ expect(pageloadSpan.attributes?.['sentry.cancellation_reason']?.value).toBe('document.hidden');
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js
new file mode 100644
index 000000000000..7eff1a54e9ff
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 1000,
+ _experiments: {
+ enableHTTPTimings: true,
+ },
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+ traceLifecycle: 'stream',
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js
new file mode 100644
index 000000000000..e19cc07e28f5
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/subject.js
@@ -0,0 +1,3 @@
+fetch('http://sentry-test-site.example/0').then(
+ fetch('http://sentry-test-site.example/1').then(fetch('http://sentry-test-site.example/2')),
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts
new file mode 100644
index 000000000000..25d4ac497992
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts
@@ -0,0 +1,63 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'adds http timing to http.client spans in span streaming mode',
+ async ({ browserName, getLocalTestUrl, page }) => {
+ const supportedBrowsers = ['chromium', 'firefox'];
+
+ sentryTest.skip(shouldSkipTracingTest() || !supportedBrowsers.includes(browserName) || testingCdnBundle());
+
+ await page.route('http://sentry-test-site.example/*', async route => {
+ const request = route.request();
+ const postData = await request.postDataJSON();
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(Object.assign({ id: 1 }, postData)),
+ });
+ });
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'http.client'));
+ await page.goto(url);
+
+ const requestSpans = (await spansPromise).filter(s => getSpanOp(s) === 'http.client');
+ const pageloadSpan = (await spansPromise).find(s => getSpanOp(s) === 'pageload');
+
+ expect(pageloadSpan).toBeDefined();
+ expect(requestSpans).toHaveLength(3);
+
+ requestSpans?.forEach((span, index) =>
+ expect(span).toMatchObject({
+ name: `GET http://sentry-test-site.example/${index}`,
+ parent_span_id: pageloadSpan?.span_id,
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: pageloadSpan?.trace_id,
+ status: 'ok',
+ attributes: expect.objectContaining({
+ 'http.request.redirect_start': expect.any(Object),
+ 'http.request.redirect_end': expect.any(Object),
+ 'http.request.worker_start': expect.any(Object),
+ 'http.request.fetch_start': expect.any(Object),
+ 'http.request.domain_lookup_start': expect.any(Object),
+ 'http.request.domain_lookup_end': expect.any(Object),
+ 'http.request.connect_start': expect.any(Object),
+ 'http.request.secure_connection_start': expect.any(Object),
+ 'http.request.connection_end': expect.any(Object),
+ 'http.request.request_start': expect.any(Object),
+ 'http.request.response_start': expect.any(Object),
+ 'http.request.response_end': expect.any(Object),
+ 'http.request.time_to_first_byte': expect.any(Object),
+ 'network.protocol.version': expect.any(Object),
+ }),
+ }),
+ );
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js
new file mode 100644
index 000000000000..385e9ed6b6cf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 1000,
+ enableLongTask: false,
+ _experiments: {
+ enableInteractions: true,
+ },
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js
new file mode 100644
index 000000000000..ff9057926396
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js
@@ -0,0 +1,16 @@
+const blockUI = e => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 70) {
+ //
+ }
+
+ e.target.classList.add('clicked');
+};
+
+document.querySelector('[data-test-id=interaction-button]').addEventListener('click', blockUI);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html
new file mode 100644
index 000000000000..64e944054632
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts
new file mode 100644
index 000000000000..fd384d0d3ff9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts
@@ -0,0 +1,134 @@
+import { expect } from '@playwright/test';
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('captures streamed interaction span tree. @firefox', async ({ browserName, getLocalTestUrl, page }) => {
+ const supportedBrowsers = ['chromium', 'firefox'];
+
+ sentryTest.skip(shouldSkipTracingTest() || !supportedBrowsers.includes(browserName) || testingCdnBundle());
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const interactionSpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(span => getSpanOp(span) === 'ui.action.click'),
+ );
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ // wait for pageload span to finish before clicking the interaction button
+ const pageloadSpan = await pageloadSpanPromise;
+
+ await page.locator('[data-test-id=interaction-button]').click();
+ await page.locator('.clicked[data-test-id=interaction-button]').isVisible();
+
+ const interactionSpanTree = await interactionSpansPromise;
+
+ const interactionSegmentSpan = interactionSpanTree.find(span => !!span.is_segment);
+
+ expect(interactionSegmentSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'ui.action.click',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual', // TODO: This is incorrect but not from span streaming.
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: interactionSegmentSpan!.span_id,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: '/index.html',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ 'sentry.span.source': {
+ type: 'string',
+ value: 'url',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ name: '/index.html',
+ span_id: interactionSegmentSpan!.span_id,
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: pageloadSpan.trace_id, // same trace id as pageload
+ });
+
+ const loAFSpans = interactionSpanTree.filter(span => getSpanOp(span)?.startsWith('ui.long-animation-frame'));
+ expect(loAFSpans).toHaveLength(1);
+
+ const interactionSpan = interactionSpanTree.find(span => getSpanOp(span) === 'ui.interaction.click');
+ expect(interactionSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'ui.interaction.click',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.ui.browser.metrics',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: interactionSegmentSpan!.span_id,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: '/index.html',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: false,
+ name: 'body > button.clicked',
+ parent_span_id: interactionSegmentSpan!.span_id,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: pageloadSpan.trace_id, // same trace id as pageload
+ });
+
+ const interactionSpanDuration = (interactionSpan!.end_timestamp - interactionSpan!.start_timestamp) * 1000;
+ expect(interactionSpanDuration).toBeGreaterThan(65);
+ expect(interactionSpanDuration).toBeLessThan(200);
+ expect(interactionSpan?.status).toBe('ok');
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js
new file mode 100644
index 000000000000..63afee65329a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js
@@ -0,0 +1,22 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampler: ctx => {
+ if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') {
+ return 1;
+ }
+ return ctx.inheritOrSampleWith(0);
+ },
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html
new file mode 100644
index 000000000000..f26a602c7c6f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts
new file mode 100644
index 000000000000..a97e13a4890a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts
@@ -0,0 +1,153 @@
+import { expect } from '@playwright/test';
+import { extractTraceparentData, parseBaggageHeader, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
+ sentryTest('continues sampling decision from initial pageload span', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const pageloadSampleRand = Number(envelope[0].trace?.sample_rand);
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1);
+ expect(Number.isNaN(pageloadSampleRand)).toBe(false);
+ expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
+ expect(pageloadSampleRand).toBeLessThanOrEqual(1);
+
+ return { pageloadSpan, pageloadSampleRand };
+ });
+
+ const customTraceSpan = await sentryTest.step('Custom trace', async () => {
+ const customEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+ await page.locator('#btn1').click();
+ const envelope = await customEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!;
+
+ expect(span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ // although we "continue the trace" from pageload, this is actually a root span,
+ // so there must not be a parent span id
+ expect(span.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand);
+
+ return span;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#foo`);
+ const envelope = await navigationEnvelopePromise;
+ const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id);
+ expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(navSpan.links).toEqual([
+ {
+ trace_id: customTraceSpan.trace_id,
+ span_id: customTraceSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+ expect(navSpan.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand);
+ });
+ });
+
+ sentryTest('Propagates continued sampling decision to outgoing requests', async ({ page, getLocalTestUrl }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const pageloadSampleRand = Number(envelope[0].trace?.sample_rand);
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand);
+ expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
+ expect(pageloadSampleRand).toBeLessThanOrEqual(1);
+ expect(Number.isNaN(pageloadSampleRand)).toBe(false);
+
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1);
+
+ return { pageloadSpan, pageloadSampleRand };
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const fetchEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+ const fetchEnvelope = await fetchEnvelopePromise;
+
+ const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand);
+ const fetchTraceSpans = fetchEnvelope[1][0][1].items;
+ const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!;
+ const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client');
+
+ expect(fetchTraceSampleRand).toBe(pageloadSampleRand);
+
+ expect(fetchTraceSpan.attributes?.['sentry.sample_rate']?.value).toEqual(
+ pageloadSpan.attributes?.['sentry.sample_rate']?.value,
+ );
+ expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: fetchTraceSpan.trace_id,
+ parentSpanId: httpClientSpan?.span_id,
+ parentSampled: true,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${pageloadSampleRand}`,
+ 'sentry-sample_rate': '1',
+ 'sentry-sampled': 'true',
+ 'sentry-trace_id': fetchTraceSpan.trace_id,
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js
new file mode 100644
index 000000000000..d570ac45144c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ traceLifecycle: 'stream',
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampleRate: 1,
+ debug: true,
+ sendClientReports: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html
new file mode 100644
index 000000000000..6347fa37fc00
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts
new file mode 100644
index 000000000000..73b4bea99e22
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts
@@ -0,0 +1,97 @@
+import { expect } from '@playwright/test';
+import type { ClientReport } from '@sentry/core';
+import { extractTraceparentData, parseBaggageHeader } from '@sentry/core';
+import type { SerializedStreamedSpan } from '@sentry/core/src';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+ waitForTracingHeadersOnUrl,
+} from '../../../../../../utils/helpers';
+import { observeStreamedSpan } from '../../../../../../utils/spanUtils';
+
+const metaTagSampleRand = 0.9;
+const metaTagSampleRate = 0.2;
+const metaTagTraceId = '12345678901234567890123456789012';
+
+sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => {
+ sentryTest(
+ 'Continues negative sampling decision from meta tag across all traces and downstream propagations',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansReceived: SerializedStreamedSpan[] = [];
+ observeStreamedSpan(page, span => {
+ spansReceived.push(span);
+ return false;
+ });
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await sentryTest.step('Initial pageload', async () => {
+ await page.goto(url);
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Custom instrumented button click', async () => {
+ await page.locator('#btn1').click();
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ await page.goto(`${url}#foo`);
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: expect.not.stringContaining(metaTagTraceId),
+ parentSpanId: expect.stringMatching(/^[\da-f]{16}$/),
+ parentSampled: false,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${metaTagSampleRand}`,
+ 'sentry-sample_rate': `${metaTagSampleRate}`,
+ 'sentry-sampled': 'false',
+ 'sentry-trace_id': expect.not.stringContaining(metaTagTraceId),
+ 'sentry-transaction': 'custom root span 2',
+ });
+
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Client report', async () => {
+ await hidePage(page);
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 4,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ expect(spansReceived).toHaveLength(0);
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js
new file mode 100644
index 000000000000..177fe4c4aeaf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js
@@ -0,0 +1,20 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'session-storage',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampler: ({ inheritOrSampleWith }) => {
+ return inheritOrSampleWith(0);
+ },
+ debug: true,
+ sendClientReports: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html
new file mode 100644
index 000000000000..9a0719b7e505
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Another Page
+ Go To the next page
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html
new file mode 100644
index 000000000000..27cd47bba7c1
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Another Page
+ Go To the next page
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js
new file mode 100644
index 000000000000..ec0264fa49ef
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1?.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2?.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html
new file mode 100644
index 000000000000..eab1fecca6c4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Go To another page
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts
new file mode 100644
index 000000000000..4cafe023b57d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts
@@ -0,0 +1,116 @@
+import { expect } from '@playwright/test';
+import type { ClientReport } from '@sentry/core';
+import { extractTraceparentData, parseBaggageHeader } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+ waitForTracingHeadersOnUrl,
+} from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+const metaTagSampleRand = 0.9;
+const metaTagSampleRate = 0.2;
+const metaTagTraceIdIndex = '12345678901234567890123456789012';
+const metaTagTraceIdPage1 = 'a2345678901234567890123456789012';
+
+sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => {
+ sentryTest(
+ 'meta tag decision has precedence over sampling decision from previous trace in session storage',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await sentryTest.step('Initial pageload', async () => {
+ // negative sampling decision -> no pageload span
+ await page.goto(url);
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ // The fetch requests starts a new trace on purpose. So we only want the
+ // sampling decision and rand to be the same as from the meta tag but not the trace id or DSC
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: expect.not.stringContaining(metaTagTraceIdIndex),
+ parentSpanId: expect.stringMatching(/^[\da-f]{16}$/),
+ parentSampled: false,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${metaTagSampleRand}`,
+ 'sentry-sample_rate': `${metaTagSampleRate}`,
+ 'sentry-sampled': 'false',
+ 'sentry-trace_id': expect.not.stringContaining(metaTagTraceIdIndex),
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+
+ await sentryTest.step('Client report', async () => {
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 2,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ await sentryTest.step('Navigate to another page with meta tags', async () => {
+ const page1PageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload' && s.trace_id === metaTagTraceIdPage1),
+ );
+ await page.locator('a').click();
+
+ const envelope = await page1PageloadEnvelopePromise;
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2);
+ expect(pageloadSpan.trace_id).toEqual(metaTagTraceIdPage1);
+ });
+
+ await sentryTest.step('Navigate to another page without meta tags', async () => {
+ const page2PageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env =>
+ !!env[1][0][1].items.find(
+ s =>
+ getSpanOp(s) === 'pageload' && s.trace_id !== metaTagTraceIdPage1 && s.trace_id !== metaTagTraceIdIndex,
+ ),
+ );
+ await page.locator('a').click();
+
+ const envelope = await page2PageloadEnvelopePromise;
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2);
+ expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdPage1);
+ expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdIndex);
+ });
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js
new file mode 100644
index 000000000000..a1ddc5465950
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ // only take into account sampling from meta tag; otherwise sample negatively
+ tracesSampleRate: 0,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html
new file mode 100644
index 000000000000..7ceca6fec2a3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts
new file mode 100644
index 000000000000..08cee9111b8a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts
@@ -0,0 +1,171 @@
+import { expect } from '@playwright/test';
+import {
+ extractTraceparentData,
+ parseBaggageHeader,
+ SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+const metaTagSampleRand = 0.051121;
+const metaTagSampleRate = 0.2;
+
+sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => {
+ sentryTest('Continues sampling decision across all traces from meta tag', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(span.attributes?.['sentry.sample_rate']).toBeUndefined();
+ expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined();
+
+ return span;
+ });
+
+ const customTraceSpan = await sentryTest.step('Custom trace', async () => {
+ const customEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+
+ await page.locator('#btn1').click();
+
+ const envelope = await customEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!;
+
+ expect(span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(span.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate);
+ expect(envelope[0].trace?.sampled).toBe('true');
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(span.attributes?.['sentry.sample_rate']).toBeUndefined();
+
+ // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header)
+ expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate);
+
+ return span;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+
+ await page.goto(`${url}#foo`);
+
+ const envelope = await navigationEnvelopePromise;
+ const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id);
+
+ expect(navSpan.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toEqual(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toEqual(metaTagSampleRate);
+ expect(envelope[0].trace?.sampled).toEqual('true');
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(navSpan.attributes?.['sentry.sample_rate']).toBeUndefined();
+
+ // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header)
+ expect(navSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate);
+ });
+ });
+
+ sentryTest(
+ 'Propagates continued tag sampling decision to outgoing requests',
+ async ({ page, getLocalTestUrl }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(span.attributes?.['sentry.sample_rate']).toBeUndefined();
+ expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined();
+
+ return span;
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const fetchEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+ const fetchEnvelope = await fetchEnvelopePromise;
+
+ const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand);
+ const fetchTraceSpans = fetchEnvelope[1][0][1].items;
+ const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!;
+ const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client');
+
+ expect(fetchTraceSampleRand).toEqual(metaTagSampleRand);
+
+ expect(fetchTraceSpan.attributes?.['sentry.sample_rate']).toBeUndefined();
+ expect(fetchTraceSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(
+ metaTagSampleRate,
+ );
+
+ expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: fetchTraceSpan.trace_id,
+ parentSpanId: httpClientSpan?.span_id,
+ parentSampled: true,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${metaTagSampleRand}`,
+ 'sentry-sample_rate': `${metaTagSampleRate}`,
+ 'sentry-sampled': 'true',
+ 'sentry-trace_id': fetchTraceSpan.trace_id,
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js
new file mode 100644
index 000000000000..623db0ecc028
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js
@@ -0,0 +1,29 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ enableInp: false,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampler: ctx => {
+ if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') {
+ return 1;
+ }
+ if (ctx.name === 'custom root span 1') {
+ return 0;
+ }
+ if (ctx.name === 'custom root span 2') {
+ return 1;
+ }
+ return ctx.inheritOrSampleWith(0);
+ },
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html
new file mode 100644
index 000000000000..f26a602c7c6f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts
new file mode 100644
index 000000000000..46805496a676
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts
@@ -0,0 +1,152 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser';
+import type { ClientReport } from '@sentry/core';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+} from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+/**
+ * This test demonstrates that:
+ * - explicit sampling decisions in `tracesSampler` has precedence over consistent sampling
+ * - despite consistentTraceSampling being activated, there are still a lot of cases where the trace chain can break
+ */
+sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
+ sentryTest('explicit sampling decisions in `tracesSampler` have precedence', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadSpan } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(pageloadSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1);
+ expect(Number(envelope[0].trace?.sample_rand)).toBeGreaterThanOrEqual(0);
+
+ return { pageloadSpan };
+ });
+
+ await sentryTest.step('Custom trace is sampled negatively (explicitly in tracesSampler)', async () => {
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await page.locator('#btn1').click();
+
+ await page.waitForTimeout(500);
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 1,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ await sentryTest.step('Subsequent navigation trace is also sampled negatively', async () => {
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await page.goto(`${url}#foo`);
+
+ await page.waitForTimeout(500);
+
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 1,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ const { customTrace2Span } = await sentryTest.step(
+ 'Custom trace 2 is sampled positively (explicitly in tracesSampler)',
+ async () => {
+ const customEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+
+ await page.locator('#btn2').click();
+
+ const envelope = await customEnvelopePromise;
+ const customTrace2Span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!;
+
+ expect(customTrace2Span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1);
+ expect(customTrace2Span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(customTrace2Span.parent_span_id).toBeUndefined();
+
+ expect(customTrace2Span.links).toEqual([
+ {
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ sampled: false,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ },
+ ]);
+
+ return { customTrace2Span };
+ },
+ );
+
+ await sentryTest.step('Navigation trace is sampled positively (inherited from previous trace)', async () => {
+ const navigationEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => env[0].trace?.sampled === 'true' && !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+
+ await page.goto(`${url}#bar`);
+
+ const envelope = await navigationEnvelopePromise;
+ const navigationSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navigationSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1);
+ expect(navigationSpan.trace_id).not.toEqual(customTrace2Span.trace_id);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ expect(navigationSpan.links).toEqual([
+ {
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ sampled: true,
+ span_id: customTrace2Span.span_id,
+ trace_id: customTrace2Span.trace_id,
+ },
+ ]);
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js
new file mode 100644
index 000000000000..2a929a7e5083
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js
@@ -0,0 +1,14 @@
+const btn1 = document.getElementById('btn1');
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, () => {});
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html
new file mode 100644
index 000000000000..f26a602c7c6f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts
new file mode 100644
index 000000000000..d6e45901f959
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts
@@ -0,0 +1,63 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest('manually started custom traces are linked correctly in the chain', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ return pageloadSpanPromise;
+ });
+
+ const customTraceSpan = await sentryTest.step('Custom trace', async () => {
+ const customSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'custom');
+ await page.locator('#btn1').click();
+ const span = await customSpanPromise;
+
+ expect(span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(span.links).toEqual([
+ {
+ trace_id: pageloadSpan.trace_id,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ return span;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navSpan = await navigationSpanPromise;
+
+ expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id);
+ expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(navSpan.links).toEqual([
+ {
+ trace_id: customTraceSpan.trace_id,
+ span_id: customTraceSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts
new file mode 100644
index 000000000000..80e500437f79
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts
@@ -0,0 +1,95 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const navigation1SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navigation1Span = await navigation1SpanPromise;
+
+ const navigation2SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#bar`);
+ const navigation2Span = await navigation2SpanPromise;
+
+ const pageloadTraceId = pageloadSpan.trace_id;
+ const navigation1TraceId = navigation1Span.trace_id;
+ const navigation2TraceId = navigation2Span.trace_id;
+
+ expect(pageloadSpan.links).toBeUndefined();
+
+ expect(navigation1Span.links).toEqual([
+ {
+ trace_id: pageloadTraceId,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigation1Span.attributes?.['sentry.previous_trace']).toEqual({
+ type: 'string',
+ value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`,
+ });
+
+ expect(navigation2Span.links).toEqual([
+ {
+ trace_id: navigation1TraceId,
+ span_id: navigation1Span.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigation2Span.attributes?.['sentry.previous_trace']).toEqual({
+ type: 'string',
+ value: `${navigation1TraceId}-${navigation1Span.span_id}-1`,
+ });
+
+ expect(pageloadTraceId).not.toEqual(navigation1TraceId);
+ expect(navigation1TraceId).not.toEqual(navigation2TraceId);
+ expect(pageloadTraceId).not.toEqual(navigation2TraceId);
+});
+
+sentryTest("doesn't link between hard page reloads by default", async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await sentryTest.step('First pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageload1Span = await pageloadSpanPromise;
+
+ expect(pageload1Span).toBeDefined();
+ expect(pageload1Span.links).toBeUndefined();
+ });
+
+ await sentryTest.step('Second pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.reload();
+ const pageload2Span = await pageloadSpanPromise;
+
+ expect(pageload2Span).toBeDefined();
+ expect(pageload2Span.links).toBeUndefined();
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js
new file mode 100644
index 000000000000..749560a5c459
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js
new file mode 100644
index 000000000000..f07f76ecd692
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tracesSampleRate: 1,
+ integrations: [
+ Sentry.browserTracingIntegration({ _experiments: { enableInteractions: true } }),
+ Sentry.spanStreamingIntegration(),
+ ],
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html
new file mode 100644
index 000000000000..7f6845239468
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts
new file mode 100644
index 000000000000..c34aba99dbdd
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts
@@ -0,0 +1,79 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+/*
+ This is quite peculiar behavior but it's a result of the route-based trace lifetime.
+ Once we shortened trace lifetime, this whole scenario will change as the interaction
+ spans will be their own trace. So most likely, we can replace this test with a new one
+ that covers the new default behavior.
+*/
+sentryTest(
+ 'only the first root spans in the trace link back to the previous trace',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const span = await pageloadSpanPromise;
+
+ expect(span).toBeDefined();
+ expect(span.links).toBeUndefined();
+
+ return span;
+ });
+
+ await sentryTest.step('Click Before navigation', async () => {
+ const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click');
+ await page.click('#btn');
+ const interactionSpan = await interactionSpanPromise;
+
+ // sanity check: route-based trace lifetime means the trace_id should be the same
+ expect(interactionSpan.trace_id).toBe(pageloadSpan.trace_id);
+
+ // no links yet as previous root span belonged to same trace
+ expect(interactionSpan.links).toBeUndefined();
+ });
+
+ const navigationSpan = await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const span = await navigationSpanPromise;
+
+ expect(getSpanOp(span)).toBe('navigation');
+ expect(span.links).toEqual([
+ {
+ trace_id: pageloadSpan.trace_id,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(span.trace_id).not.toEqual(span.links![0].trace_id);
+ return span;
+ });
+
+ await sentryTest.step('Click After navigation', async () => {
+ const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click');
+ await page.click('#btn');
+ const interactionSpan = await interactionSpanPromise;
+
+ // sanity check: route-based trace lifetime means the trace_id should be the same
+ expect(interactionSpan.trace_id).toBe(navigationSpan.trace_id);
+
+ // since this is the second root span in the trace, it doesn't link back to the previous trace
+ expect(interactionSpan.links).toBeUndefined();
+ });
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html
new file mode 100644
index 000000000000..2221bd0fee1d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts
new file mode 100644
index 000000000000..cbcc231593ea
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts
@@ -0,0 +1,50 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ "links back to previous trace's local root span if continued from meta tags",
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const metaTagTraceId = '12345678901234567890123456789012';
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const span = await pageloadSpanPromise;
+
+ // sanity check
+ expect(span.trace_id).toBe(metaTagTraceId);
+ expect(span.links).toBeUndefined();
+
+ return span;
+ });
+
+ const navigationSpan = await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ return navigationSpanPromise;
+ });
+
+ expect(navigationSpan.links).toEqual([
+ {
+ trace_id: metaTagTraceId,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigationSpan.trace_id).not.toEqual(metaTagTraceId);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js
new file mode 100644
index 000000000000..778092cf026b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ // We want to ignore redirects for this test
+ integrations: [Sentry.browserTracingIntegration({ detectRedirects: false }), Sentry.spanStreamingIntegration()],
+ tracesSampler: ctx => {
+ if (ctx.attributes['sentry.origin'] === 'auto.pageload.browser') {
+ return 0;
+ }
+ return 1;
+ },
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts
new file mode 100644
index 000000000000..06366eb9921a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts
@@ -0,0 +1,44 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest('includes a span link to a previously negatively sampled span', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await sentryTest.step('Initial pageload', async () => {
+ // No span envelope expected here because this pageload span is sampled negatively!
+ await page.goto(url);
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navigationSpan = await navigationSpanPromise;
+
+ expect(getSpanOp(navigationSpan)).toBe('navigation');
+ expect(navigationSpan.links).toEqual([
+ {
+ trace_id: expect.stringMatching(/[a-f\d]{32}/),
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ sampled: false,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigationSpan.attributes?.['sentry.previous_trace']).toEqual({
+ type: 'string',
+ value: expect.stringMatching(/[a-f\d]{32}-[a-f\d]{16}-0/),
+ });
+
+ expect(navigationSpan.trace_id).not.toEqual(navigationSpan.links![0].trace_id);
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js
new file mode 100644
index 000000000000..e51af56c2a9d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ linkPreviousTrace: 'session-storage' }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts
new file mode 100644
index 000000000000..96a5bbeacc6d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts
@@ -0,0 +1,42 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest('adds link between hard page reloads when opting into sessionStorage', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageload1Span = await sentryTest.step('First pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const span = await pageloadSpanPromise;
+ expect(span).toBeDefined();
+ expect(span.links).toBeUndefined();
+ return span;
+ });
+
+ const pageload2Span = await sentryTest.step('Hard page reload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.reload();
+ return pageloadSpanPromise;
+ });
+
+ expect(pageload2Span.links).toEqual([
+ {
+ trace_id: pageload1Span.trace_id,
+ span_id: pageload1Span.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(pageload1Span.trace_id).not.toEqual(pageload2Span.trace_id);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js
new file mode 100644
index 000000000000..ee197adaa33c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongTask: false,
+ enableLongAnimationFrame: true,
+ instrumentPageLoad: false,
+ enableInp: false,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js
new file mode 100644
index 000000000000..b02ed6efa33b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js
@@ -0,0 +1,18 @@
+function getElapsed(startTime) {
+ const time = Date.now();
+ return time - startTime;
+}
+
+function handleClick() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+ window.history.pushState({}, '', `#myHeading`);
+}
+
+const button = document.getElementById('clickme');
+
+console.log('button', button);
+
+button.addEventListener('click', handleClick);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html
new file mode 100644
index 000000000000..6a6a89752f20
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+ My Heading
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts
new file mode 100644
index 000000000000..3054c1c84bcb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts
@@ -0,0 +1,25 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ "doesn't capture long animation frame that starts before a navigation.",
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation'));
+
+ await page.goto(url);
+
+ await page.locator('#clickme').click();
+
+ const spans = await navigationSpansPromise;
+
+ const loafSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+ expect(loafSpans).toHaveLength(0);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js
new file mode 100644
index 000000000000..195a094070be
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js
@@ -0,0 +1,12 @@
+(() => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 101) {
+ //
+ }
+})();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js
new file mode 100644
index 000000000000..965613d5464e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html
new file mode 100644
index 000000000000..62aed26413f8
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Rendered Before Long Animation Frame
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts
new file mode 100644
index 000000000000..7ba1dddd0c90
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts
@@ -0,0 +1,28 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'does not capture long animation frame when flag is disabled.',
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) =>
+ route.fulfill({ path: `${__dirname}/assets/script.js` }),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui'));
+
+ expect(uiSpans.length).toBe(0);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js
new file mode 100644
index 000000000000..10552eeb5bd5
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js
@@ -0,0 +1,25 @@
+function getElapsed(startTime) {
+ const time = Date.now();
+ return time - startTime;
+}
+
+function handleClick() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+function start() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+// trigger 2 long-animation-frame events
+// one from the top-level and the other from an event-listener
+start();
+
+const button = document.getElementById('clickme');
+button.addEventListener('click', handleClick);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js
new file mode 100644
index 000000000000..1f6cc0a8f463
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongTask: false,
+ enableLongAnimationFrame: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html
new file mode 100644
index 000000000000..c157aa80cb8d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+ Rendered Before Long Animation Frame
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts
new file mode 100644
index 000000000000..c1e7efa5e8d8
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts
@@ -0,0 +1,109 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'captures long animation frame span for top-level script.',
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) =>
+ route.fulfill({ path: `${__dirname}/assets/script.js` }),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(1);
+
+ const topLevelUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js',
+ )!;
+
+ expect(topLevelUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ 'browser.script.source_char_position': expect.objectContaining({ value: 0 }),
+ 'browser.script.invoker': {
+ type: 'string',
+ value: 'https://sentry-test-site.example/path/to/script.js',
+ },
+ 'browser.script.invoker_type': { type: 'string', value: 'classic-script' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = topLevelUISpan.start_timestamp ?? 0;
+ const end = topLevelUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+ },
+);
+
+sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ // trigger long animation frame function
+ await page.getByRole('button').click();
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(2);
+
+ const eventListenerUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick',
+ )!;
+
+ expect(eventListenerUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' },
+ 'browser.script.invoker_type': { type: 'string', value: 'event-listener' },
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = eventListenerUISpan.start_timestamp ?? 0;
+ const end = eventListenerUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js
new file mode 100644
index 000000000000..10552eeb5bd5
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js
@@ -0,0 +1,25 @@
+function getElapsed(startTime) {
+ const time = Date.now();
+ return time - startTime;
+}
+
+function handleClick() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+function start() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+// trigger 2 long-animation-frame events
+// one from the top-level and the other from an event-listener
+start();
+
+const button = document.getElementById('clickme');
+button.addEventListener('click', handleClick);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js
new file mode 100644
index 000000000000..3e3eedaf49b7
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongTask: true,
+ enableLongAnimationFrame: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html
new file mode 100644
index 000000000000..c157aa80cb8d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+ Rendered Before Long Animation Frame
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts
new file mode 100644
index 000000000000..4f9207fa1e34
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts
@@ -0,0 +1,111 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'captures long animation frame span for top-level script.',
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ // Long animation frame should take priority over long tasks
+
+ await page.route('**/path/to/script.js', (route: Route) =>
+ route.fulfill({ path: `${__dirname}/assets/script.js` }),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(1);
+
+ const topLevelUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js',
+ )!;
+
+ expect(topLevelUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ 'browser.script.source_char_position': expect.objectContaining({ value: 0 }),
+ 'browser.script.invoker': {
+ type: 'string',
+ value: 'https://sentry-test-site.example/path/to/script.js',
+ },
+ 'browser.script.invoker_type': { type: 'string', value: 'classic-script' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = topLevelUISpan.start_timestamp ?? 0;
+ const end = topLevelUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+ },
+);
+
+sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ // trigger long animation frame function
+ await page.getByRole('button').click();
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(2);
+
+ const eventListenerUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick',
+ )!;
+
+ expect(eventListenerUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' },
+ 'browser.script.invoker_type': { type: 'string', value: 'event-listener' },
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = eventListenerUISpan.start_timestamp ?? 0;
+ const end = eventListenerUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js
new file mode 100644
index 000000000000..f6e5ce777e06
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongAnimationFrame: false,
+ instrumentPageLoad: false,
+ instrumentNavigation: true,
+ enableInp: false,
+ enableLongTask: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js
new file mode 100644
index 000000000000..d814f8875715
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js
@@ -0,0 +1,17 @@
+const longTaskButton = document.getElementById('myButton');
+
+longTaskButton?.addEventListener('click', () => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 500) {
+ //
+ }
+
+ // trigger a navigation in the same event loop tick
+ window.history.pushState({}, '', '#myHeading');
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html
new file mode 100644
index 000000000000..c2cb2a8129fe
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+ Heading
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts
new file mode 100644
index 000000000000..74ce32706584
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts
@@ -0,0 +1,29 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ "doesn't capture long task spans starting before a navigation in the navigation transaction",
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long tasks only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('**/path/to/script.js', route => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation'));
+
+ await page.goto(url);
+
+ await page.locator('#myButton').click();
+
+ const spans = await navigationSpansPromise;
+
+ const navigationSpan = spans.find(s => getSpanOp(s) === 'navigation');
+ expect(navigationSpan).toBeDefined();
+
+ const longTaskSpans = spans.filter(s => getSpanOp(s) === 'ui.long-task');
+ expect(longTaskSpans).toHaveLength(0);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js
new file mode 100644
index 000000000000..195a094070be
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js
@@ -0,0 +1,12 @@
+(() => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 101) {
+ //
+ }
+})();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js
new file mode 100644
index 000000000000..965613d5464e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html
new file mode 100644
index 000000000000..b03231da2c65
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts
new file mode 100644
index 000000000000..83600f5d4a6a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts
@@ -0,0 +1,23 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest("doesn't capture long task spans when flag is disabled.", async ({ browserName, getLocalTestUrl, page }) => {
+ // Long tasks only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui'));
+
+ expect(uiSpans.length).toBe(0);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js
new file mode 100644
index 000000000000..b61592e05943
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js
@@ -0,0 +1,12 @@
+(() => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 105) {
+ //
+ }
+})();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js
new file mode 100644
index 000000000000..484350c14fcf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongAnimationFrame: false,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html
new file mode 100644
index 000000000000..b03231da2c65
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts
new file mode 100644
index 000000000000..8b73aa91dff6
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts
@@ -0,0 +1,42 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) => {
+ // Long tasks only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui'));
+ expect(uiSpans.length).toBeGreaterThan(0);
+
+ const [firstUISpan] = uiSpans;
+ expect(firstUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'sentry.op': { type: 'string', value: 'ui.long-task' },
+ }),
+ }),
+ );
+
+ const start = firstUISpan.start_timestamp ?? 0;
+ const end = firstUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js
new file mode 100644
index 000000000000..a93fc742bafb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts
new file mode 100644
index 000000000000..7128d2d5ecce
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts
@@ -0,0 +1,219 @@
+import { expect } from '@playwright/test';
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import {
+ getSpanOp,
+ getSpansFromEnvelope,
+ waitForStreamedSpan,
+ waitForStreamedSpanEnvelope,
+} from '../../../../utils/spanUtils';
+
+sentryTest('starts a streamed navigation span on page navigation', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'navigation'),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ // simulate navigation
+ page.goto(`${url}#foo`);
+
+ const navigationSpanEnvelope = await navigationSpanEnvelopePromise;
+
+ const navigationSpanEnvelopeHeader = navigationSpanEnvelope[0];
+ const navigationSpanEnvelopeItem = navigationSpanEnvelope[1];
+ const navigationSpans = navigationSpanEnvelopeItem[0][1].items;
+ const navigationSpan = navigationSpans.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navigationSpanEnvelopeHeader).toEqual({
+ sent_at: expect.any(String),
+ trace: {
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ environment: 'production',
+ public_key: 'public',
+ sample_rand: expect.any(String),
+ sample_rate: '1',
+ sampled: 'true',
+ },
+ sdk: {
+ name: 'sentry.javascript.browser',
+ version: SDK_VERSION,
+ },
+ });
+
+ const numericSampleRand = parseFloat(navigationSpanEnvelopeHeader.trace!.sample_rand!);
+ expect(Number.isNaN(numericSampleRand)).toBe(false);
+
+ const pageloadTraceId = pageloadSpan.trace_id;
+ const navigationTraceId = navigationSpan.trace_id;
+
+ expect(pageloadTraceId).toBeDefined();
+ expect(navigationTraceId).toBeDefined();
+ expect(pageloadTraceId).not.toEqual(navigationTraceId);
+
+ expect(pageloadSpan.name).toEqual('/index.html');
+
+ expect(navigationSpan).toEqual({
+ attributes: {
+ effectiveConnectionType: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ hardwareConcurrency: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ 'sentry.idle_span_finish_reason': {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'navigation',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.navigation.browser',
+ },
+ 'sentry.previous_trace': {
+ type: 'string',
+ value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`,
+ },
+ 'sentry.sample_rate': {
+ type: 'integer',
+ value: 1,
+ },
+ 'sentry.sdk.name': {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ 'sentry.sdk.version': {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ 'sentry.segment.id': {
+ type: 'string',
+ value: navigationSpan.span_id,
+ },
+ 'sentry.segment.name': {
+ type: 'string',
+ value: '/index.html',
+ },
+ 'sentry.source': {
+ type: 'string',
+ value: 'url',
+ },
+ 'sentry.span.source': {
+ type: 'string',
+ value: 'url',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ links: [
+ {
+ attributes: {
+ 'sentry.link.type': {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ sampled: true,
+ span_id: pageloadSpan.span_id,
+ trace_id: pageloadTraceId,
+ },
+ ],
+ name: '/index.html',
+ span_id: navigationSpan.span_id,
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: navigationTraceId,
+ });
+});
+
+sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const navigationSpan1Promise = waitForStreamedSpan(
+ page,
+ span => getSpanOp(span) === 'navigation' && span.name === '/sub-page',
+ );
+ const navigationSpan2Promise = waitForStreamedSpan(
+ page,
+ span => getSpanOp(span) === 'navigation' && span.name === '/sub-page-2',
+ );
+
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page`);");
+
+ const navigationSpan1 = await navigationSpan1Promise;
+
+ expect(navigationSpan1.name).toEqual('/sub-page');
+
+ expect(navigationSpan1.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.navigation.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'navigation',
+ },
+ });
+
+ await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page-2`);");
+
+ const navigationSpan2 = await navigationSpan2Promise;
+
+ expect(navigationSpan2.name).toEqual('/sub-page-2');
+
+ expect(navigationSpan2.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.navigation.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'navigation',
+ },
+ ['sentry.idle_span_finish_reason']: {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js
new file mode 100644
index 000000000000..bd3b6ed17872
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts
new file mode 100644
index 000000000000..47d9e00d4307
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts
@@ -0,0 +1,131 @@
+import { expect } from '@playwright/test';
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'creates a pageload streamed span envelope with url as pageload span name source',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const spanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'pageload'),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const spanEnvelope = await spanEnvelopePromise;
+ const envelopeHeader = spanEnvelope[0];
+ const envelopeItem = spanEnvelope[1];
+ const spans = envelopeItem[0][1].items;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload');
+
+ const timeOrigin = await page.evaluate('window._testBaseTimestamp');
+
+ expect(envelopeHeader).toEqual({
+ sdk: {
+ name: 'sentry.javascript.browser',
+ version: SDK_VERSION,
+ },
+ sent_at: expect.any(String),
+ trace: {
+ environment: 'production',
+ public_key: 'public',
+ sample_rand: expect.any(String),
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ },
+ });
+
+ const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!);
+ const traceId = envelopeHeader.trace!.trace_id;
+
+ expect(Number.isNaN(numericSampleRand)).toBe(false);
+
+ expect(envelopeItem[0][0].item_count).toBeGreaterThan(1);
+
+ expect(pageloadSpan?.start_timestamp).toBeCloseTo(timeOrigin, 1);
+
+ expect(pageloadSpan).toEqual({
+ attributes: {
+ effectiveConnectionType: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ hardwareConcurrency: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ 'performance.activationStart': {
+ type: 'integer',
+ value: expect.any(Number),
+ },
+ 'performance.timeOrigin': {
+ type: 'double',
+ value: expect.any(Number),
+ },
+ 'sentry.idle_span_finish_reason': {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'pageload',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.pageload.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: pageloadSpan?.span_id,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: '/index.html',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ 'sentry.span.source': {
+ type: 'string',
+ value: 'url',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ name: '/index.html',
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: traceId,
+ });
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js
new file mode 100644
index 000000000000..ded3ca204b6b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true }), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ debug: true,
+});
+
+setTimeout(() => {
+ Sentry.reportPageLoaded();
+}, 2500);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts
new file mode 100644
index 000000000000..fb6fa3ab2393
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts
@@ -0,0 +1,41 @@
+import { expect } from '@playwright/test';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/browser';
+import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ 'waits for Sentry.reportPageLoaded() to be called when `enableReportPageLoaded` is true',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp;
+
+ expect(pageloadSpan.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }),
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' },
+ });
+
+ // We wait for 2.5 seconds before calling Sentry.reportPageLoaded()
+ // the margins are to account for timing weirdness in CI to avoid flakes
+ expect(spanDurationSeconds).toBeGreaterThan(2);
+ expect(spanDurationSeconds).toBeLessThan(3);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js
new file mode 100644
index 000000000000..b1c19f779713
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableReportPageLoaded: true, finalTimeout: 3000 }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+ debug: true,
+});
+
+// not calling Sentry.reportPageLoaded() on purpose!
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts
new file mode 100644
index 000000000000..79df6a902e45
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts
@@ -0,0 +1,40 @@
+import { expect } from '@playwright/test';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/browser';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ 'final timeout cancels the pageload span even if `enableReportPageLoaded` is true',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp;
+
+ expect(pageloadSpan.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }),
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' },
+ 'sentry.idle_span_finish_reason': { type: 'string', value: 'finalTimeout' },
+ });
+
+ // We wait for 3 seconds before calling Sentry.reportPageLoaded()
+ // the margins are to account for timing weirdness in CI to avoid flakes
+ expect(spanDurationSeconds).toBeGreaterThan(2.5);
+ expect(spanDurationSeconds).toBeLessThan(3.5);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js
new file mode 100644
index 000000000000..ac42880742a3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js
@@ -0,0 +1,22 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableReportPageLoaded: true, instrumentNavigation: false }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+ debug: true,
+});
+
+setTimeout(() => {
+ Sentry.startBrowserTracingNavigationSpan(Sentry.getClient(), { name: 'custom_navigation' });
+}, 1000);
+
+setTimeout(() => {
+ Sentry.reportPageLoaded();
+}, 2500);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts
new file mode 100644
index 000000000000..77f138f34053
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts
@@ -0,0 +1,38 @@
+import { expect } from '@playwright/test';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/browser';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ 'starting a navigation span cancels the pageload span even if `enableReportPageLoaded` is true',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp;
+
+ expect(pageloadSpan.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }),
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' },
+ 'sentry.idle_span_finish_reason': { type: 'string', value: 'cancelled' },
+ });
+
+ // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes
+ expect(spanDurationSeconds).toBeLessThan(1.5);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js
new file mode 100644
index 000000000000..9afcee48dc4a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js
new file mode 100644
index 000000000000..510fb07540ad
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js
@@ -0,0 +1,28 @@
+// REGULAR ---
+const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' });
+rootSpan1.end();
+
+Sentry.startSpan({ name: 'rootSpan2' }, rootSpan2 => {
+ rootSpan2.addLink({
+ context: rootSpan1.spanContext(),
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ });
+});
+
+// NESTED ---
+Sentry.startSpan({ name: 'rootSpan3' }, async rootSpan3 => {
+ Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => {
+ childSpan1.addLink({
+ context: rootSpan1.spanContext(),
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ });
+
+ childSpan1.end();
+ });
+
+ Sentry.startSpan({ name: 'childSpan3.2' }, async childSpan2 => {
+ childSpan2.addLink({ context: rootSpan3.spanContext() });
+
+ childSpan2.end();
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts
new file mode 100644
index 000000000000..dc35f0c8fcf1
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts
@@ -0,0 +1,66 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers';
+import { waitForStreamedSpan, waitForStreamedSpans } from '../../../utils/spanUtils';
+
+sentryTest('links spans with addLink() in trace context', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment);
+ const rootSpan2Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan2' && !!s.is_segment);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const rootSpan1 = await rootSpan1Promise;
+ const rootSpan2 = await rootSpan2Promise;
+
+ expect(rootSpan1.name).toBe('rootSpan1');
+ expect(rootSpan1.links).toBeUndefined();
+
+ expect(rootSpan2.name).toBe('rootSpan2');
+ expect(rootSpan2.links).toHaveLength(1);
+ expect(rootSpan2.links?.[0]).toMatchObject({
+ attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } },
+ sampled: true,
+ span_id: rootSpan1.span_id,
+ trace_id: rootSpan1.trace_id,
+ });
+});
+
+sentryTest('links spans with addLink() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment);
+ const rootSpan3SpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(s => s.name === 'rootSpan3' && s.is_segment),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const rootSpan1 = await rootSpan1Promise;
+ const rootSpan3Spans = await rootSpan3SpansPromise;
+
+ const rootSpan3 = rootSpan3Spans.find(s => s.name === 'rootSpan3')!;
+ const childSpan1 = rootSpan3Spans.find(s => s.name === 'childSpan3.1')!;
+ const childSpan2 = rootSpan3Spans.find(s => s.name === 'childSpan3.2')!;
+
+ expect(rootSpan3.name).toBe('rootSpan3');
+
+ expect(childSpan1.name).toBe('childSpan3.1');
+ expect(childSpan1.links).toHaveLength(1);
+ expect(childSpan1.links?.[0]).toMatchObject({
+ attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } },
+ sampled: true,
+ span_id: rootSpan1.span_id,
+ trace_id: rootSpan1.trace_id,
+ });
+
+ expect(childSpan2.name).toBe('childSpan3.2');
+ expect(childSpan2.links?.[0]).toMatchObject({
+ sampled: true,
+ span_id: rootSpan3.span_id,
+ trace_id: rootSpan3.trace_id,
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js
new file mode 100644
index 000000000000..c4c8791cf32c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ autoSessionTracking: false,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js
new file mode 100644
index 000000000000..482a738009c2
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js
@@ -0,0 +1,5 @@
+fetch('http://sentry-test-site.example/0').then(
+ fetch('http://sentry-test-site.example/1', { headers: { 'X-Test-Header': 'existing-header' } }).then(
+ fetch('http://sentry-test-site.example/2'),
+ ),
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts
new file mode 100644
index 000000000000..201c3e4979f2
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts
@@ -0,0 +1,43 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(
+ page,
+ spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3,
+ );
+
+ await page.goto(url);
+
+ const allSpans = await spansPromise;
+ const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload');
+ const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client');
+
+ expect(requestSpans).toHaveLength(3);
+
+ requestSpans.forEach((span, index) =>
+ expect(span).toMatchObject({
+ name: `GET http://sentry-test-site.example/${index}`,
+ parent_span_id: pageloadSpan?.span_id,
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: pageloadSpan?.trace_id,
+ attributes: expect.objectContaining({
+ 'http.method': { type: 'string', value: 'GET' },
+ 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ url: { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ 'server.address': { type: 'string', value: 'sentry-test-site.example' },
+ type: { type: 'string', value: 'fetch' },
+ }),
+ }),
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js
new file mode 100644
index 000000000000..c4c8791cf32c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ autoSessionTracking: false,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js
new file mode 100644
index 000000000000..9c584bf743cb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js
@@ -0,0 +1,12 @@
+const xhr_1 = new XMLHttpRequest();
+xhr_1.open('GET', 'http://sentry-test-site.example/0');
+xhr_1.send();
+
+const xhr_2 = new XMLHttpRequest();
+xhr_2.open('GET', 'http://sentry-test-site.example/1');
+xhr_2.setRequestHeader('X-Test-Header', 'existing-header');
+xhr_2.send();
+
+const xhr_3 = new XMLHttpRequest();
+xhr_3.open('GET', 'http://sentry-test-site.example/2');
+xhr_3.send();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts
new file mode 100644
index 000000000000..d3f20fd36453
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts
@@ -0,0 +1,43 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(
+ page,
+ spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3,
+ );
+
+ await page.goto(url);
+
+ const allSpans = await spansPromise;
+ const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload');
+ const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client');
+
+ expect(requestSpans).toHaveLength(3);
+
+ requestSpans.forEach((span, index) =>
+ expect(span).toMatchObject({
+ name: `GET http://sentry-test-site.example/${index}`,
+ parent_span_id: pageloadSpan?.span_id,
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: pageloadSpan?.trace_id,
+ attributes: expect.objectContaining({
+ 'http.method': { type: 'string', value: 'GET' },
+ 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ url: { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ 'server.address': { type: 'string', value: 'sentry-test-site.example' },
+ type: { type: 'string', value: 'xhr' },
+ }),
+ }),
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js
new file mode 100644
index 000000000000..9afcee48dc4a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js
new file mode 100644
index 000000000000..0ce39588eb1b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js
@@ -0,0 +1,14 @@
+const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
+Sentry.setActiveSpanInBrowser(checkoutSpan);
+
+Sentry.startSpan({ name: 'checkout-step-1' }, () => {
+ Sentry.startSpan({ name: 'checkout-step-1-1' }, () => {
+ // ... `
+ });
+});
+
+Sentry.startSpan({ name: 'checkout-step-2' }, () => {
+ // ... `
+});
+
+checkoutSpan.end();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts
new file mode 100644
index 000000000000..a144e171a93a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts
@@ -0,0 +1,35 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('sets an inactive span active and adds child spans to it', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => s.name === 'checkout-flow' && s.is_segment));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const checkoutSpan = spans.find(s => s.name === 'checkout-flow');
+ const checkoutSpanId = checkoutSpan?.span_id;
+ expect(checkoutSpanId).toMatch(/[a-f\d]{16}/);
+
+ expect(spans.filter(s => !s.is_segment)).toHaveLength(3);
+
+ const checkoutStep1 = spans.find(s => s.name === 'checkout-step-1');
+ const checkoutStep11 = spans.find(s => s.name === 'checkout-step-1-1');
+ const checkoutStep2 = spans.find(s => s.name === 'checkout-step-2');
+
+ expect(checkoutStep1).toBeDefined();
+ expect(checkoutStep11).toBeDefined();
+ expect(checkoutStep2).toBeDefined();
+
+ expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId);
+ expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId);
+
+ // despite 1-1 being called within 1, it's still parented to the root span
+ // due to this being default behaviour in browser environments
+ expect(checkoutStep11?.parent_span_id).toBe(checkoutSpanId);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js
new file mode 100644
index 000000000000..5b4cff73e95d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ parentSpanIsAlwaysRootSpan: false,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js
new file mode 100644
index 000000000000..dc601cbf4d30
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js
@@ -0,0 +1,22 @@
+const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
+Sentry.setActiveSpanInBrowser(checkoutSpan);
+
+Sentry.startSpan({ name: 'checkout-step-1' }, () => {});
+
+const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' });
+Sentry.setActiveSpanInBrowser(checkoutStep2);
+
+Sentry.startSpan({ name: 'checkout-step-2-1' }, () => {
+ // ... `
+});
+checkoutStep2.end();
+
+Sentry.startSpan({ name: 'checkout-step-3' }, () => {});
+
+checkoutSpan.end();
+
+Sentry.startSpan({ name: 'post-checkout' }, () => {
+ Sentry.startSpan({ name: 'post-checkout-1' }, () => {
+ // ... `
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts
new file mode 100644
index 000000000000..8f5e54e1fba0
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts
@@ -0,0 +1,58 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'nested calls to setActiveSpanInBrowser with parentSpanIsAlwaysRootSpan=false result in correct parenting',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const checkoutSpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(s => s.name === 'checkout-flow' && s.is_segment),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const checkoutSpans = await checkoutSpansPromise;
+
+ const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
+ const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout');
+
+ const checkoutSpanId = checkoutSpan?.span_id;
+ const postCheckoutSpanId = postCheckoutSpan?.span_id;
+
+ expect(checkoutSpanId).toMatch(/[a-f\d]{16}/);
+ expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/);
+
+ expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5);
+
+ const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1');
+ const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2');
+ const checkoutStep21 = checkoutSpans.find(s => s.name === 'checkout-step-2-1');
+ const checkoutStep3 = checkoutSpans.find(s => s.name === 'checkout-step-3');
+
+ expect(checkoutStep1).toBeDefined();
+ expect(checkoutStep2).toBeDefined();
+ expect(checkoutStep21).toBeDefined();
+ expect(checkoutStep3).toBeDefined();
+
+ expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId);
+ expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId);
+
+ // with parentSpanIsAlwaysRootSpan=false, 2-1 is parented to 2 because
+ // 2 was the active span when 2-1 was started
+ expect(checkoutStep21?.parent_span_id).toBe(checkoutStep2?.span_id);
+
+ // since the parent of three is `checkoutSpan`, we correctly reset
+ // the active span to `checkoutSpan` after 2 ended
+ expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId);
+
+ // post-checkout trace is started as a new trace because ending checkoutSpan removes the active
+ // span on the scope
+ const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1');
+ expect(postCheckoutStep1).toBeDefined();
+ expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js
new file mode 100644
index 000000000000..9afcee48dc4a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/init.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js
new file mode 100644
index 000000000000..dc601cbf4d30
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/subject.js
@@ -0,0 +1,22 @@
+const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
+Sentry.setActiveSpanInBrowser(checkoutSpan);
+
+Sentry.startSpan({ name: 'checkout-step-1' }, () => {});
+
+const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' });
+Sentry.setActiveSpanInBrowser(checkoutStep2);
+
+Sentry.startSpan({ name: 'checkout-step-2-1' }, () => {
+ // ... `
+});
+checkoutStep2.end();
+
+Sentry.startSpan({ name: 'checkout-step-3' }, () => {});
+
+checkoutSpan.end();
+
+Sentry.startSpan({ name: 'post-checkout' }, () => {
+ Sentry.startSpan({ name: 'post-checkout-1' }, () => {
+ // ... `
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts
new file mode 100644
index 000000000000..1b04553090bc
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts
@@ -0,0 +1,53 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'nested calls to setActiveSpanInBrowser still parent to root span by default',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const checkoutSpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(s => s.name === 'checkout-flow' && s.is_segment),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const checkoutSpans = await checkoutSpansPromise;
+
+ const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
+ const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout');
+
+ const checkoutSpanId = checkoutSpan?.span_id;
+ const postCheckoutSpanId = postCheckoutSpan?.span_id;
+
+ expect(checkoutSpanId).toMatch(/[a-f\d]{16}/);
+ expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/);
+
+ expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5);
+
+ const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1');
+ const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2');
+ const checkoutStep21 = checkoutSpans.find(s => s.name === 'checkout-step-2-1');
+ const checkoutStep3 = checkoutSpans.find(s => s.name === 'checkout-step-3');
+
+ expect(checkoutStep1).toBeDefined();
+ expect(checkoutStep2).toBeDefined();
+ expect(checkoutStep21).toBeDefined();
+ expect(checkoutStep3).toBeDefined();
+
+ expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId);
+ expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId);
+ expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId);
+
+ // despite 2-1 being called within 2 AND setting 2 as active span, it's still parented to the
+ // root span due to this being default behaviour in browser environments
+ expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId);
+
+ const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1');
+ expect(postCheckoutStep1).toBeDefined();
+ expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js
new file mode 100644
index 000000000000..3dd77207e103
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+// Import this separately so that generatePlugin can handle it for CDN scenarios
+import { feedbackIntegration } from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts
new file mode 100644
index 000000000000..28f3e5039910
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts
@@ -0,0 +1,318 @@
+import { expect } from '@playwright/test';
+import type { Event } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import type { EventAndTraceHeader } from '../../../../utils/helpers';
+import {
+ eventAndTraceHeaderRequestParser,
+ getFirstSentryEnvelopeRequest,
+ shouldSkipFeedbackTest,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+} from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';
+
+sentryTest('creates a new trace and sample_rand on each navigation', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ // Wait for and skip the initial pageload span
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigation1SpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#foo`);
+ const navigation1SpanEnvelope = await navigation1SpanEnvelopePromise;
+
+ const navigation2SpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#bar`);
+ const navigation2SpanEnvelope = await navigation2SpanEnvelopePromise;
+
+ const navigation1TraceId = navigation1SpanEnvelope[0].trace?.trace_id;
+ const navigation1SampleRand = navigation1SpanEnvelope[0].trace?.sample_rand;
+ const navigation2TraceId = navigation2SpanEnvelope[0].trace?.trace_id;
+ const navigation2SampleRand = navigation2SpanEnvelope[0].trace?.sample_rand;
+
+ const navigation1Span = navigation1SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+ const navigation2Span = navigation2SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(getSpanOp(navigation1Span)).toEqual('navigation');
+ expect(navigation1TraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigation1Span.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigation1Span.parent_span_id).toBeUndefined();
+
+ expect(navigation1SpanEnvelope[0].trace).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigation1TraceId,
+ sample_rand: expect.any(String),
+ });
+
+ expect(getSpanOp(navigation2Span)).toEqual('navigation');
+ expect(navigation2TraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigation2Span.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigation2Span.parent_span_id).toBeUndefined();
+
+ expect(navigation2SpanEnvelope[0].trace).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigation2TraceId,
+ sample_rand: expect.any(String),
+ });
+
+ expect(navigation1TraceId).not.toEqual(navigation2TraceId);
+ expect(navigation1SampleRand).not.toEqual(navigation2SampleRand);
+});
+
+sentryTest('error after navigation has navigation traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#foo`);
+ const [navigationSpan, navigationSpanEnvelope] = await Promise.all([
+ navigationSpanPromise,
+ navigationSpanEnvelopePromise,
+ ]);
+
+ const navigationTraceId = navigationSpan.trace_id;
+
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ expect(navigationSpanEnvelope[0].trace).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigationTraceId,
+ sample_rand: expect.any(String),
+ });
+
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+ await page.locator('#errorBtn').click();
+ const [errorEvent, errorTraceHeader] = await errorEventPromise;
+
+ expect(errorEvent.type).toEqual(undefined);
+
+ const errorTraceContext = errorEvent.contexts?.trace;
+ expect(errorTraceContext).toEqual({
+ trace_id: navigationTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigationTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest('error during navigation has new navigation traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+
+ await page.goto(`${url}#foo`);
+ await page.locator('#errorBtn').click();
+ const [navigationSpan, [errorEvent, errorTraceHeader]] = await Promise.all([
+ navigationSpanPromise,
+ errorEventPromise,
+ ]);
+
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(errorEvent.type).toEqual(undefined);
+
+ const navigationTraceId = navigationSpan.trace_id;
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ const errorTraceContext = errorEvent?.contexts?.trace;
+ expect(errorTraceContext).toEqual({
+ trace_id: navigationTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigationTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest(
+ 'outgoing fetch request during navigation has navigation traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(`${url}#foo`);
+ await page.locator('#fetchBtn').click();
+ const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]);
+
+ const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand;
+
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toEqual(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest(
+ 'outgoing XHR request during navigation has navigation traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ // ensure navigation span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(`${url}#foo`);
+ await page.locator('#xhrBtn').click();
+ const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]);
+
+ const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand;
+
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toEqual(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest(
+ 'user feedback event after navigation has navigation traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navigationSpan = await navigationSpanPromise;
+
+ const navigationTraceId = navigationSpan.trace_id;
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ const feedbackEventPromise = getFirstSentryEnvelopeRequest(page);
+
+ await page.getByText('Report a Bug').click();
+ expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1);
+ await page.locator('[name="name"]').fill('Jane Doe');
+ await page.locator('[name="email"]').fill('janedoe@example.org');
+ await page.locator('[name="message"]').fill('my example feedback');
+ await page.locator('[data-sentry-feedback] .btn--primary').click();
+
+ const feedbackEvent = await feedbackEventPromise;
+
+ expect(feedbackEvent.type).toEqual('feedback');
+
+ const feedbackTraceContext = feedbackEvent.contexts?.trace;
+
+ expect(feedbackTraceContext).toMatchObject({
+ trace_id: navigationTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js
new file mode 100644
index 000000000000..3dd77207e103
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+// Import this separately so that generatePlugin can handle it for CDN scenarios
+import { feedbackIntegration } from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts
new file mode 100644
index 000000000000..1b4458991559
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts
@@ -0,0 +1,238 @@
+import { expect } from '@playwright/test';
+import type { Event } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import type { EventAndTraceHeader } from '../../../../utils/helpers';
+import {
+ eventAndTraceHeaderRequestParser,
+ getFirstSentryEnvelopeRequest,
+ shouldSkipFeedbackTest,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+} from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';
+
+sentryTest('creates a new trace for a navigation after the initial pageload', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ page.goto(`${url}#foo`);
+
+ const navigationSpan = await navigationSpanPromise;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(navigationSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ expect(pageloadSpan.span_id).not.toEqual(navigationSpan.span_id);
+ expect(pageloadSpan.trace_id).not.toEqual(navigationSpan.trace_id);
+});
+
+sentryTest('error after pageload has pageload traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+ const pageloadTraceId = pageloadSpan.trace_id;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+ await page.locator('#errorBtn').click();
+ const [errorEvent, errorTraceHeader] = await errorEventPromise;
+
+ const errorTraceContext = errorEvent.contexts?.trace;
+ expect(errorEvent.type).toEqual(undefined);
+
+ expect(errorTraceContext).toEqual({
+ trace_id: pageloadTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: pageloadTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest('error during pageload has pageload traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+
+ await page.goto(url);
+ await page.locator('#errorBtn').click();
+ const [pageloadSpan, [errorEvent, errorTraceHeader]] = await Promise.all([pageloadSpanPromise, errorEventPromise]);
+
+ const pageloadTraceId = pageloadSpan.trace_id;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ const errorTraceContext = errorEvent?.contexts?.trace;
+ expect(errorEvent.type).toEqual(undefined);
+
+ expect(errorTraceContext).toEqual({
+ trace_id: pageloadTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: pageloadTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest(
+ 'outgoing fetch request during pageload has pageload traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(url);
+ await page.locator('#fetchBtn').click();
+ const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]);
+
+ const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand;
+
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toBe(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest(
+ 'outgoing XHR request during pageload has pageload traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(url);
+ await page.locator('#xhrBtn').click();
+ const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]);
+
+ const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand;
+
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toBe(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest('user feedback event after pageload has pageload traceId in headers', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageloadSpan = await pageloadSpanPromise;
+ const pageloadTraceId = pageloadSpan.trace_id;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ const feedbackEventPromise = getFirstSentryEnvelopeRequest(page);
+
+ await page.getByText('Report a Bug').click();
+ expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1);
+ await page.locator('[name="name"]').fill('Jane Doe');
+ await page.locator('[name="email"]').fill('janedoe@example.org');
+ await page.locator('[name="message"]').fill('my example feedback');
+ await page.locator('[data-sentry-feedback] .btn--primary').click();
+
+ const feedbackEvent = await feedbackEventPromise;
+
+ expect(feedbackEvent.type).toEqual('feedback');
+
+ const feedbackTraceContext = feedbackEvent.contexts?.trace;
+
+ expect(feedbackTraceContext).toMatchObject({
+ trace_id: pageloadTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js
new file mode 100644
index 000000000000..187e07624fdf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js
new file mode 100644
index 000000000000..3bb1e489ccb6
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js
@@ -0,0 +1,15 @@
+const newTraceBtn = document.getElementById('newTrace');
+newTraceBtn.addEventListener('click', async () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ op: 'ui.interaction.click', name: 'new-trace' }, async () => {
+ await fetch('http://sentry-test-site.example');
+ });
+ });
+});
+
+const oldTraceBtn = document.getElementById('oldTrace');
+oldTraceBtn.addEventListener('click', async () => {
+ Sentry.startSpan({ op: 'ui.interaction.click', name: 'old-trace' }, async () => {
+ await fetch('http://sentry-test-site.example');
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html
new file mode 100644
index 000000000000..f78960343dd0
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts
new file mode 100644
index 000000000000..d294efcd2e3b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts
@@ -0,0 +1,44 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'creates a new trace if `startNewTrace` is called and leaves old trace valid outside the callback',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const newTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'new-trace');
+ const oldTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'old-trace');
+
+ await page.locator('#newTrace').click();
+ await page.locator('#oldTrace').click();
+
+ const [newTraceSpan, oldTraceSpan] = await Promise.all([newTraceSpanPromise, oldTraceSpanPromise]);
+
+ expect(getSpanOp(newTraceSpan)).toEqual('ui.interaction.click');
+ expect(newTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(newTraceSpan.span_id).toMatch(/^[\da-f]{16}$/);
+
+ expect(getSpanOp(oldTraceSpan)).toEqual('ui.interaction.click');
+ expect(oldTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(oldTraceSpan.span_id).toMatch(/^[\da-f]{16}$/);
+
+ expect(oldTraceSpan.trace_id).toEqual(pageloadSpan.trace_id);
+ expect(newTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts
index 50150c6bee20..5dade230e1e4 100644
--- a/dev-packages/browser-integration-tests/utils/helpers.ts
+++ b/dev-packages/browser-integration-tests/utils/helpers.ts
@@ -62,7 +62,7 @@ export const eventAndTraceHeaderRequestParser = (request: Request | null): Event
return getEventAndTraceHeader(envelope);
};
-const properFullEnvelopeParser = (request: Request | null): T => {
+export const properFullEnvelopeParser = (request: Request | null): T => {
// https://develop.sentry.dev/sdk/envelopes/
const envelope = request?.postData() || '';
diff --git a/dev-packages/browser-integration-tests/utils/spanUtils.ts b/dev-packages/browser-integration-tests/utils/spanUtils.ts
new file mode 100644
index 000000000000..67b5798b66f1
--- /dev/null
+++ b/dev-packages/browser-integration-tests/utils/spanUtils.ts
@@ -0,0 +1,133 @@
+import type { Page } from '@playwright/test';
+import type { SerializedStreamedSpan, StreamedSpanEnvelope } from '@sentry/core';
+import { properFullEnvelopeParser } from './helpers';
+
+/**
+ * Wait for a full span v2 envelope
+ * Useful for testing the entire envelope shape
+ */
+export async function waitForStreamedSpanEnvelope(
+ page: Page,
+ callback?: (spanEnvelope: StreamedSpanEnvelope) => boolean,
+): Promise {
+ const req = await page.waitForRequest(req => {
+ const postData = req.postData();
+ if (!postData) {
+ return false;
+ }
+
+ try {
+ const spanEnvelope = properFullEnvelopeParser(req);
+
+ const envelopeItemHeader = spanEnvelope[1][0][0];
+
+ if (
+ envelopeItemHeader?.type !== 'span' ||
+ envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json'
+ ) {
+ return false;
+ }
+
+ if (callback) {
+ return callback(spanEnvelope);
+ }
+
+ return true;
+ } catch {
+ return false;
+ }
+ });
+
+ return properFullEnvelopeParser(req);
+}
+
+/**
+ * Wait for v2 spans sent in one envelope.
+ * Useful for testing multiple spans in one envelope.
+ * @param page
+ * @param callback - Callback being called with all spans
+ */
+export async function waitForStreamedSpans(
+ page: Page,
+ callback?: (spans: SerializedStreamedSpan[]) => boolean,
+): Promise {
+ const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => {
+ if (callback) {
+ return callback(envelope[1][0][1].items);
+ }
+ return true;
+ });
+ return spanEnvelope[1][0][1].items;
+}
+
+export async function waitForStreamedSpan(
+ page: Page,
+ callback: (span: SerializedStreamedSpan) => boolean,
+): Promise {
+ const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => {
+ if (callback) {
+ const spans = envelope[1][0][1].items;
+ return spans.some(span => callback(span));
+ }
+ return true;
+ });
+ const firstMatchingSpan = spanEnvelope[1][0][1].items.find(span => callback(span));
+ if (!firstMatchingSpan) {
+ throw new Error(
+ 'No matching span found but envelope search matched previously. Something is likely off with this function. Debug me.',
+ );
+ }
+ return firstMatchingSpan;
+}
+
+/**
+ * Observes outgoing requests and looks for sentry envelope requests. If an envelope request is found, it applies
+ * @param callback to check for a matching span.
+ *
+ * Important: This function only observes requests and does not block the test when it ends. Use this primarily to
+ * throw errors if you encounter unwanted spans. You most likely want to use {@link waitForStreamedSpan} or {@link waitForStreamedSpans} instead!
+ */
+export async function observeStreamedSpan(
+ page: Page,
+ callback: (span: SerializedStreamedSpan) => boolean,
+): Promise {
+ page.on('request', request => {
+ const postData = request.postData();
+ if (!postData) {
+ return;
+ }
+
+ try {
+ const spanEnvelope = properFullEnvelopeParser(request);
+
+ const envelopeItemHeader = spanEnvelope[1][0][0];
+
+ if (
+ envelopeItemHeader?.type !== 'span' ||
+ envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json'
+ ) {
+ return false;
+ }
+
+ const spans = spanEnvelope[1][0][1].items;
+
+ for (const span of spans) {
+ if (callback(span)) {
+ return true;
+ }
+ }
+
+ return false;
+ } catch {
+ return false;
+ }
+ });
+}
+
+export function getSpanOp(span: SerializedStreamedSpan): string | undefined {
+ return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes?.['sentry.op']?.value : undefined;
+}
+
+export function getSpansFromEnvelope(envelope: StreamedSpanEnvelope): SerializedStreamedSpan[] {
+ return envelope[1][0][1].items;
+}
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index ac01ff0647a7..cf4c6dc6e9bb 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -170,6 +170,7 @@ export {
statsigIntegration,
unleashIntegration,
growthbookIntegration,
+ spanStreamingIntegration,
metrics,
} from '@sentry/node';
diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts
index 2e51ab1b0f1e..8dfffdcbd852 100644
--- a/packages/astro/src/index.types.ts
+++ b/packages/astro/src/index.types.ts
@@ -20,6 +20,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | NodeO
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
export declare const getDefaultIntegrations: (options: Options) => Integration[];
export declare const defaultStackParser: StackParser;
diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts
index 1c980e4cae2d..b49b214b65d7 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -157,6 +157,7 @@ export {
unleashIntegration,
growthbookIntegration,
metrics,
+ spanStreamingIntegration,
} from '@sentry/node';
export {
diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts
index 70a6595d07d9..071f38f72ca9 100644
--- a/packages/browser/src/index.ts
+++ b/packages/browser/src/index.ts
@@ -41,6 +41,7 @@ export {
} from './tracing/browserTracingIntegration';
export { reportPageLoaded } from './tracing/reportPageLoaded';
export { setActiveSpanInBrowser } from './tracing/setActiveSpan';
+export { spanStreamingIntegration } from './integrations/spanstreaming';
export type { RequestInstrumentationOptions } from './tracing/request';
export {
diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts
new file mode 100644
index 000000000000..9d1a296b5e57
--- /dev/null
+++ b/packages/browser/src/integrations/spanstreaming.ts
@@ -0,0 +1,71 @@
+import type { IntegrationFn } from '@sentry/core';
+import {
+ captureSpan,
+ debug,
+ defineIntegration,
+ hasSpanStreamingEnabled,
+ isStreamedBeforeSendSpanCallback,
+ SpanBuffer,
+ spanIsSampled,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+
+export const spanStreamingIntegration = defineIntegration(() => {
+ return {
+ name: 'SpanStreaming',
+
+ beforeSetup(client) {
+ // If users only set spanStreamingIntegration, without traceLifecycle, we set it to "stream" for them.
+ // This avoids the classic double-opt-in problem we'd otherwise have in the browser SDK.
+ const clientOptions = client.getOptions();
+ if (!clientOptions.traceLifecycle) {
+ DEBUG_BUILD && debug.warn('[SpanStreaming] set `traceLifecycle` to "stream"');
+ clientOptions.traceLifecycle = 'stream';
+ }
+ },
+
+ setup(client) {
+ const initialMessage = 'SpanStreaming integration requires';
+ const fallbackMsg = 'Falling back to static trace lifecycle.';
+ const clientOptions = client.getOptions();
+
+ if (!hasSpanStreamingEnabled(client)) {
+ clientOptions.traceLifecycle = 'static';
+ DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
+ return;
+ }
+
+ const beforeSendSpan = clientOptions.beforeSendSpan;
+ // If users misconfigure their SDK by opting into span streaming but
+ // using an incompatible beforeSendSpan callback, we fall back to the static trace lifecycle.
+ if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) {
+ clientOptions.traceLifecycle = 'static';
+ DEBUG_BUILD &&
+ debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`);
+ return;
+ }
+
+ const buffer = new SpanBuffer(client);
+
+ client.on('afterSpanEnd', span => {
+ // Negatively sampled spans must not be captured.
+ // This happens because OTel and we create non-recording spans for negatively sampled spans
+ // that go through the same life cycle as recording spans.
+ if (!spanIsSampled(span)) {
+ return;
+ }
+ buffer.add(captureSpan(span, client));
+ });
+
+ // In addition to capturing the span, we also flush the trace when the segment
+ // span ends to ensure things are sent timely. We never know when the browser
+ // is closed, users navigate away, etc.
+ client.on('afterSegmentSpanEnd', segmentSpan => {
+ const traceId = segmentSpan.spanContext().traceId;
+ setTimeout(() => {
+ buffer.flush(traceId);
+ }, 500);
+ });
+ },
+ };
+}) satisfies IntegrationFn;
diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts
index 85faf0871a55..6211cf72947a 100644
--- a/packages/browser/src/tracing/request.ts
+++ b/packages/browser/src/tracing/request.ts
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines */
import type {
Client,
HandlerDataXhr,
@@ -5,6 +6,7 @@ import type {
ResponseHookInfo,
SentryWrappedXMLHttpRequest,
Span,
+ SpanTimeInput,
} from '@sentry/core';
import {
addFetchEndInstrumentationHandler,
@@ -14,6 +16,7 @@ import {
getLocationHref,
getTraceData,
hasSpansEnabled,
+ hasSpanStreamingEnabled,
instrumentFetchRequest,
parseUrl,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
@@ -25,6 +28,7 @@ import {
stringMatchesSomePattern,
stripDataUrlContent,
stripUrlQueryAndFragment,
+ timestampInSeconds,
} from '@sentry/core';
import type { XhrHint } from '@sentry-internal/browser-utils';
import {
@@ -205,7 +209,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial {
+ // Clean up the performance observer and other resources
+ // We have to wait here because otherwise this cleans itself up before it is fully done.
+ // Default (non-streaming): just deregister the observer.
+ let onEntryFound = (): void => void setTimeout(unsubscribePerformanceObsever);
+
+ // For streamed spans, we have to artificially delay the ending of the span until we
+ // either receive the timing data, or HTTP_TIMING_WAIT_MS elapses.
+ if (hasSpanStreamingEnabled(client)) {
+ const originalEnd = span.end.bind(span);
+
+ span.end = (endTimestamp?: SpanTimeInput) => {
+ const capturedEndTimestamp = endTimestamp ?? timestampInSeconds();
+ let isEnded = false;
+
+ const endSpanAndCleanup = (): void => {
+ if (isEnded) {
+ return;
+ }
+ isEnded = true;
+ setTimeout(unsubscribePerformanceObsever);
+ originalEnd(capturedEndTimestamp);
+ clearTimeout(fallbackTimeout);
+ };
+
+ onEntryFound = endSpanAndCleanup;
+
+ // Fallback: always end the span after HTTP_TIMING_WAIT_MS even if no
+ // PerformanceResourceTiming entry arrives (e.g. cross-origin without
+ // Timing-Allow-Origin, or the browser didn't fire the observer in time).
+ const fallbackTimeout = setTimeout(endSpanAndCleanup, HTTP_TIMING_WAIT_MS);
+ };
+ }
+
+ const unsubscribePerformanceObsever = addPerformanceInstrumentationHandler('resource', ({ entries }) => {
entries.forEach(entry => {
if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) {
span.setAttributes(resourceTimingToSpanAttributes(entry));
- // In the next tick, clean this handler up
- // We have to wait here because otherwise this cleans itself up before it is fully done
- setTimeout(cleanup);
+ onEntryFound();
}
});
});
diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts
new file mode 100644
index 000000000000..1d5d587290a3
--- /dev/null
+++ b/packages/browser/test/integrations/spanstreaming.test.ts
@@ -0,0 +1,199 @@
+import * as SentryCore from '@sentry/core';
+import { debug } from '@sentry/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { BrowserClient, spanStreamingIntegration } from '../../src';
+import { getDefaultBrowserClientOptions } from '../helper/browser-client-options';
+
+// Mock SpanBuffer as a class that can be instantiated
+const mockSpanBufferInstance = vi.hoisted(() => ({
+ flush: vi.fn(),
+ add: vi.fn(),
+ drain: vi.fn(),
+}));
+
+const MockSpanBuffer = vi.hoisted(() => {
+ return vi.fn(() => mockSpanBufferInstance);
+});
+
+vi.mock('@sentry/core', async () => {
+ const original = await vi.importActual('@sentry/core');
+ return {
+ ...original,
+ SpanBuffer: MockSpanBuffer,
+ };
+});
+
+describe('spanStreamingIntegration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('has the correct hooks', () => {
+ const integration = spanStreamingIntegration();
+ expect(integration.name).toBe('SpanStreaming');
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(integration.beforeSetup).toBeDefined();
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(integration.setup).toBeDefined();
+ });
+
+ it('sets traceLifecycle to "stream" if not set', () => {
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ expect(client.getOptions().traceLifecycle).toBe('stream');
+ });
+
+ it.each(['static', 'somethingElse'])(
+ 'logs a warning if traceLifecycle is not set to "stream" but to %s',
+ traceLifecycle => {
+ const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ // @ts-expect-error - we want to test the warning for invalid traceLifecycle values
+ traceLifecycle,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ expect(debugSpy).toHaveBeenCalledWith(
+ 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.',
+ );
+ debugSpy.mockRestore();
+
+ expect(client.getOptions().traceLifecycle).toBe('static');
+ },
+ );
+
+ it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => {
+ const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ beforeSendSpan: (span: Span) => span,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ expect(debugSpy).toHaveBeenCalledWith(
+ 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.',
+ );
+ debugSpy.mockRestore();
+
+ expect(client.getOptions().traceLifecycle).toBe('static');
+ });
+
+ it('does nothing if traceLifecycle set to "stream"', () => {
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ expect(client.getOptions().traceLifecycle).toBe('stream');
+ });
+
+ it('enqueues a span into the buffer when the span ends', () => {
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ const span = new SentryCore.SentrySpan({ name: 'test', sampled: true });
+ client.emit('afterSpanEnd', span);
+
+ expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({
+ _segmentSpan: span,
+ trace_id: span.spanContext().traceId,
+ span_id: span.spanContext().spanId,
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ name: 'test',
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ attributes: {
+ 'sentry.origin': {
+ type: 'string',
+ value: 'manual',
+ },
+ 'sentry.sdk.name': {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ 'sentry.sdk.version': {
+ type: 'string',
+ value: expect.any(String),
+ },
+ 'sentry.segment.id': {
+ type: 'string',
+ value: span.spanContext().spanId,
+ },
+ 'sentry.segment.name': {
+ type: 'string',
+ value: 'test',
+ },
+ },
+ });
+ });
+
+ it('does not enqueue a span into the buffer when the span is not sampled', () => {
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ const span = new SentryCore.SentrySpan({ name: 'test', sampled: false });
+ client.emit('afterSpanEnd', span);
+
+ expect(mockSpanBufferInstance.add).not.toHaveBeenCalled();
+ expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled();
+ });
+
+ it('flushes the trace when the segment span ends after a delay for close to finished child spans', () => {
+ vi.useFakeTimers();
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ const span = new SentryCore.SentrySpan({ name: 'test' });
+ client.emit('afterSegmentSpanEnd', span);
+
+ vi.advanceTimersByTime(500);
+
+ expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId);
+
+ vi.useRealTimers();
+ });
+});
diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts
index c2990e6262a7..6f52a92ab6da 100644
--- a/packages/bun/src/index.ts
+++ b/packages/bun/src/index.ts
@@ -177,6 +177,7 @@ export {
statsigIntegration,
unleashIntegration,
metrics,
+ spanStreamingIntegration,
} from '@sentry/node';
export {
diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts
index d3255d76b0e9..1f4a6638f577 100644
--- a/packages/core/src/attributes.ts
+++ b/packages/core/src/attributes.ts
@@ -1,4 +1,6 @@
import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement';
+import type { Primitive } from './types-hoist/misc';
+import { isPrimitive } from './utils/is';
export type RawAttributes = T & ValidatedAttributes;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -127,6 +129,46 @@ export function serializeAttributes(
return serializedAttributes;
}
+/**
+ * Estimates the serialized byte size of {@link Attributes},
+ * with a couple of heuristics for performance.
+ */
+export function estimateTypedAttributesSizeInBytes(attributes: Attributes | undefined): number {
+ if (!attributes) {
+ return 0;
+ }
+ let weight = 0;
+ for (const [key, attr] of Object.entries(attributes)) {
+ weight += key.length * 2;
+ weight += attr.type.length * 2;
+ weight += (attr.unit?.length ?? 0) * 2;
+ const val = attr.value;
+
+ if (Array.isArray(val)) {
+ // Assumption: Individual array items have the same type and roughly the same size
+ // probably not always true but allows us to cut down on runtime
+ weight += estimatePrimitiveSizeInBytes(val[0]) * val.length;
+ } else if (isPrimitive(val)) {
+ weight += estimatePrimitiveSizeInBytes(val);
+ } else {
+ // default fallback for anything else (objects)
+ weight += 100;
+ }
+ }
+ return weight;
+}
+
+function estimatePrimitiveSizeInBytes(value: Primitive): number {
+ if (typeof value === 'string') {
+ return value.length * 2;
+ } else if (typeof value === 'boolean') {
+ return 4;
+ } else if (typeof value === 'number') {
+ return 8;
+ }
+ return 0;
+}
+
/**
* NOTE: We intentionally do not return anything for non-primitive values:
* - array support will come in the future but if we stringify arrays now,
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 8d69411aacfd..6c3ca949f38e 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -11,6 +11,7 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
+import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base';
import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb';
import type { CheckIn, MonitorConfig } from './types-hoist/checkin';
@@ -31,7 +32,7 @@ import type { RequestEventData } from './types-hoist/request';
import type { SdkMetadata } from './types-hoist/sdkmetadata';
import type { Session, SessionAggregates } from './types-hoist/session';
import type { SeverityLevel } from './types-hoist/severity';
-import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span';
+import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types-hoist/span';
import type { StartSpanOptions } from './types-hoist/startSpanOptions';
import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport';
import { createClientReportEnvelope } from './utils/clientreport';
@@ -503,6 +504,10 @@ export abstract class Client {
public addIntegration(integration: Integration): void {
const isAlreadyInstalled = this._integrations[integration.name];
+ if (!isAlreadyInstalled && integration.beforeSetup) {
+ integration.beforeSetup(this);
+ }
+
// This hook takes care of only installing if not already installed
setupIntegration(this, integration, this._integrations);
// Here we need to check manually to make sure to not run this multiple times
@@ -613,6 +618,28 @@ export abstract class Client {
*/
public on(hook: 'spanEnd', callback: (span: Span) => void): () => void;
+ /**
+ * Register a callback for after a span is ended and the `spanEnd` hook has run.
+ * NOTE: The span cannot be mutated anymore in this callback.
+ */
+ public on(hook: 'afterSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void;
+
+ /**
+ * Register a callback for after a segment span is ended and the `segmentSpanEnd` hook has run.
+ * NOTE: The segment span cannot be mutated anymore in this callback.
+ */
+ public on(hook: 'afterSegmentSpanEnd', callback: (immutableSegmentSpan: Readonly) => void): () => void;
+
+ /**
+ * Register a callback for when a span JSON is processed, to add some data to the span JSON.
+ */
+ public on(hook: 'processSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void;
+
+ /**
+ * Register a callback for when a segment span JSON is processed, to add some data to the segment span JSON.
+ */
+ public on(hook: 'processSegmentSpan', callback: (streamedSpanJSON: StreamedSpanJSON) => void): () => void;
+
/**
* Register a callback for when an idle span is allowed to auto-finish.
* @returns {() => void} A function that, when executed, removes the registered callback.
@@ -885,6 +912,26 @@ export abstract class Client {
/** Fire a hook whenever a span ends. */
public emit(hook: 'spanEnd', span: Span): void;
+ /**
+ * Fire a hook event after a span ends and the `spanEnd` hook has run.
+ */
+ public emit(hook: 'afterSpanEnd', immutableSpan: Readonly): void;
+
+ /**
+ * Fire a hook event after a segment span ends and the `spanEnd` hook has run.
+ */
+ public emit(hook: 'afterSegmentSpanEnd', immutableSegmentSpan: Readonly): void;
+
+ /**
+ * Fire a hook event when a span JSON is processed, to add some data to the span JSON.
+ */
+ public emit(hook: 'processSpan', streamedSpanJSON: StreamedSpanJSON): void;
+
+ /**
+ * Fire a hook event for when a segment span JSON is processed, to add some data to the segment span JSON.
+ */
+ public emit(hook: 'processSegmentSpan', streamedSpanJSON: StreamedSpanJSON): void;
+
/**
* Fire a hook indicating that an idle span is allowed to auto finish.
*/
@@ -1513,7 +1560,9 @@ function processBeforeSend(
event: Event,
hint: EventHint,
): PromiseLike | Event | null {
- const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options;
+ const { beforeSend, beforeSendTransaction, ignoreSpans } = options;
+ const beforeSendSpan = !isStreamedBeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan;
+
let processedEvent = event;
if (isErrorEvent(processedEvent) && beforeSend) {
diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts
index 875056890e0e..dd91d077f45c 100644
--- a/packages/core/src/envelope.ts
+++ b/packages/core/src/envelope.ts
@@ -1,6 +1,7 @@
import type { Client } from './client';
import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext';
import type { SentrySpan } from './tracing/sentrySpan';
+import { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
import type { LegacyCSPReport } from './types-hoist/csp';
import type { DsnComponents } from './types-hoist/dsn';
import type {
@@ -152,7 +153,7 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client?
const convertToSpanJSON = beforeSendSpan
? (span: SentrySpan) => {
const spanJson = spanToJSON(span);
- const processedSpan = beforeSendSpan(spanJson);
+ const processedSpan = !isStreamedBeforeSendSpanCallback(beforeSendSpan) ? beforeSendSpan(spanJson) : spanJson;
if (!processedSpan) {
showSpanDropWarning();
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 61865ea7ba3c..539405f09adc 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -68,6 +68,8 @@ export { prepareEvent } from './utils/prepareEvent';
export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent';
export { createCheckInEnvelope } from './checkin';
export { hasSpansEnabled } from './utils/hasSpansEnabled';
+export { withStreamedSpan } from './tracing/spans/beforeSendSpan';
+export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan';
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
export { handleCallbackErrors } from './utils/handleCallbackErrors';
export { parameterize, fmt } from './utils/parameterize';
@@ -81,11 +83,13 @@ export {
convertSpanLinksForEnvelope,
spanToTraceHeader,
spanToJSON,
+ spanToStreamedSpanJSON,
spanIsSampled,
spanToTraceContext,
getSpanDescendants,
getStatusMessage,
getRootSpan,
+ INTERNAL_getSegmentSpan,
getActiveSpan,
addChildSpanToSpan,
spanTimeInputToSeconds,
@@ -177,6 +181,11 @@ export type {
GoogleGenAIOptions,
GoogleGenAIIstrumentedMethod,
} from './tracing/google-genai/types';
+
+export { SpanBuffer } from './tracing/spans/spanBuffer';
+export { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled';
+export { spanStreamingIntegration } from './integrations/spanStreaming';
+
export type { FeatureFlag } from './utils/featureFlags';
export {
@@ -391,6 +400,7 @@ export type {
ProfileChunkEnvelope,
ProfileChunkItem,
SpanEnvelope,
+ StreamedSpanEnvelope,
SpanItem,
LogEnvelope,
MetricEnvelope,
@@ -458,6 +468,8 @@ export type {
SpanJSON,
SpanContextData,
TraceFlag,
+ SerializedStreamedSpan,
+ StreamedSpanJSON,
} from './types-hoist/span';
export type { SpanStatus } from './types-hoist/spanStatus';
export type { Log, LogSeverityLevel } from './types-hoist/log';
diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts
index 892228476824..b8e7240cf748 100644
--- a/packages/core/src/integration.ts
+++ b/packages/core/src/integration.ts
@@ -76,6 +76,12 @@ export function getIntegrationsToSetup(
export function setupIntegrations(client: Client, integrations: Integration[]): IntegrationIndex {
const integrationIndex: IntegrationIndex = {};
+ integrations.forEach((integration: Integration | undefined) => {
+ if (integration?.beforeSetup) {
+ integration.beforeSetup(client);
+ }
+ });
+
integrations.forEach((integration: Integration | undefined) => {
// guard against empty provided integrations
if (integration) {
diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts
new file mode 100644
index 000000000000..541be6b7f5e9
--- /dev/null
+++ b/packages/core/src/integrations/spanStreaming.ts
@@ -0,0 +1,44 @@
+import type { IntegrationFn } from '../types-hoist/integration';
+import { DEBUG_BUILD } from '../debug-build';
+import { defineIntegration } from '../integration';
+import { isStreamedBeforeSendSpanCallback } from '../tracing/spans/beforeSendSpan';
+import { captureSpan } from '../tracing/spans/captureSpan';
+import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled';
+import { SpanBuffer } from '../tracing/spans/spanBuffer';
+import { debug } from '../utils/debug-logger';
+import { spanIsSampled } from '../utils/spanUtils';
+
+export const spanStreamingIntegration = defineIntegration(() => {
+ return {
+ name: 'SpanStreaming',
+
+ setup(client) {
+ const initialMessage = 'SpanStreaming integration requires';
+ const fallbackMsg = 'Falling back to static trace lifecycle.';
+ const clientOptions = client.getOptions();
+
+ if (!hasSpanStreamingEnabled(client)) {
+ clientOptions.traceLifecycle = 'static';
+ DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
+ return;
+ }
+
+ const beforeSendSpan = clientOptions.beforeSendSpan;
+ if (beforeSendSpan && !isStreamedBeforeSendSpanCallback(beforeSendSpan)) {
+ clientOptions.traceLifecycle = 'static';
+ DEBUG_BUILD &&
+ debug.warn(`${initialMessage} a beforeSendSpan callback using \`withStreamedSpan\`! ${fallbackMsg}`);
+ return;
+ }
+
+ const buffer = new SpanBuffer(client);
+
+ client.on('afterSpanEnd', span => {
+ if (!spanIsSampled(span)) {
+ return;
+ }
+ buffer.add(captureSpan(span, client));
+ });
+ },
+ };
+}) satisfies IntegrationFn;
diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts
index 88b0f470dfa3..02b6a4ec08a6 100644
--- a/packages/core/src/semanticAttributes.ts
+++ b/packages/core/src/semanticAttributes.ts
@@ -1,7 +1,7 @@
/**
- * Use this attribute to represent the source of a span.
- * Should be one of: custom, url, route, view, component, task, unknown
- *
+ * Use this attribute to represent the source of a span name.
+ * Must be one of: custom, url, route, view, component, task
+ * TODO(v11): rename this to sentry.span.source'
*/
export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source';
@@ -40,6 +40,28 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un
/** The value of a measurement, which may be stored as a TimedEvent. */
export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value';
+/** The release version of the application */
+export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release';
+/** The environment name (e.g., "production", "staging", "development") */
+export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment';
+/** The segment name (e.g., "GET /users") */
+export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME = 'sentry.segment.name';
+/** The id of the segment that this span belongs to. */
+export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID = 'sentry.segment.id';
+/** The name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") */
+export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name';
+/** The version of the Sentry SDK */
+export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version';
+
+/** The user ID (gated by sendDefaultPii) */
+export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id';
+/** The user email (gated by sendDefaultPii) */
+export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email';
+/** The user IP address (gated by sendDefaultPii) */
+export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address';
+/** The user username (gated by sendDefaultPii) */
+export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name';
+
/**
* A custom span name set by users guaranteed to be taken over any automatically
* inferred name. This attribute is removed before the span is sent.
diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts
index 47d5657a7d87..7cf79e53d07b 100644
--- a/packages/core/src/tracing/dynamicSamplingContext.ts
+++ b/packages/core/src/tracing/dynamicSamplingContext.ts
@@ -119,7 +119,8 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly {
+ * // span is of type StreamedSpanJSON
+ * return span;
+ * }),
+ * });
+ *
+ * @param callback - The callback function that receives and returns a {@link StreamedSpanJSON}.
+ * @returns A callback that is compatible with the `beforeSendSpan` option when using `traceLifecycle: 'stream'`.
+ */
+export function withStreamedSpan(
+ callback: (span: StreamedSpanJSON) => StreamedSpanJSON,
+): BeforeSendStramedSpanCallback {
+ addNonEnumerableProperty(callback, '_streamed', true);
+ return callback;
+}
+
+/**
+ * Typesafe check to identify if a `beforeSendSpan` callback expects the streamed span JSON format.
+ *
+ * @param callback - The `beforeSendSpan` callback to check.
+ * @returns `true` if the callback was wrapped with {@link withStreamedSpan}.
+ */
+export function isStreamedBeforeSendSpanCallback(
+ callback: ClientOptions['beforeSendSpan'],
+): callback is BeforeSendStramedSpanCallback {
+ return !!callback && '_streamed' in callback && !!callback._streamed;
+}
diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts
new file mode 100644
index 000000000000..979c7b460af1
--- /dev/null
+++ b/packages/core/src/tracing/spans/captureSpan.ts
@@ -0,0 +1,150 @@
+import type { RawAttributes } from '../../attributes';
+import type { Client } from '../../client';
+import type { ScopeData } from '../../scope';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
+ SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ SEMANTIC_ATTRIBUTE_USER_EMAIL,
+ SEMANTIC_ATTRIBUTE_USER_ID,
+ SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS,
+ SEMANTIC_ATTRIBUTE_USER_USERNAME,
+} from '../../semanticAttributes';
+import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span';
+import { getCombinedScopeData } from '../../utils/scopeData';
+import {
+ INTERNAL_getSegmentSpan,
+ showSpanDropWarning,
+ spanToStreamedSpanJSON,
+ streamedSpanJsonToSerializedSpan,
+} from '../../utils/spanUtils';
+import { getCapturedScopesOnSpan } from '../utils';
+import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan';
+
+export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & {
+ _segmentSpan: Span;
+};
+
+/**
+ * Captures a span and returns a JSON representation to be enqueued for sending.
+ *
+ * IMPORTANT: This function converts the span to JSON immediately to avoid writing
+ * to an already-ended OTel span instance (which is blocked by the OTel Span class).
+ *
+ * @returns the final serialized span with a reference to its segment span. This reference
+ * is needed later on to compute the DSC for the span envelope.
+ */
+export function captureSpan(span: Span, client: Client): SerializedStreamedSpanWithSegmentSpan {
+ // Convert to JSON FIRST - we cannot write to an already-ended span
+ const spanJSON = spanToStreamedSpanJSON(span);
+
+ const segmentSpan = INTERNAL_getSegmentSpan(span);
+ const serializedSegmentSpan = spanToStreamedSpanJSON(segmentSpan);
+
+ const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span);
+
+ const finalScopeData = getCombinedScopeData(spanIsolationScope, spanScope);
+
+ applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData);
+
+ if (spanJSON.is_segment) {
+ applyScopeToSegmentSpan(spanJSON, finalScopeData);
+ // Allow hook subscribers to mutate the segment span JSON
+ client.emit('processSegmentSpan', spanJSON);
+ }
+
+ // Allow hook subscribers to mutate the span JSON
+ client.emit('processSpan', spanJSON);
+
+ const { beforeSendSpan } = client.getOptions();
+ const processedSpan =
+ beforeSendSpan && isStreamedBeforeSendSpanCallback(beforeSendSpan)
+ ? applyBeforeSendSpanCallback(spanJSON, beforeSendSpan)
+ : spanJSON;
+
+ // Backfill sentry.span.source from sentry.source. Only `sentry.span.source` is respected by Sentry.
+ // TODO(v11): Remove this backfill once we renamed SEMANTIC_ATTRIBUTE_SENTRY_SOURCE to sentry.span.source
+ const spanNameSource = processedSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
+ if (spanNameSource) {
+ safeSetSpanJSONAttributes(processedSpan, {
+ // Purposefully not using a constant defined here like in other attributes:
+ // This will be the name for SEMANTIC_ATTRIBUTE_SENTRY_SOURCE in v11
+ 'sentry.span.source': spanNameSource,
+ });
+ }
+
+ return {
+ ...streamedSpanJsonToSerializedSpan(processedSpan),
+ _segmentSpan: segmentSpan,
+ };
+}
+
+function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void {
+ // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span
+ // This will follow in a separate PR
+}
+
+function applyCommonSpanAttributes(
+ spanJSON: StreamedSpanJSON,
+ serializedSegmentSpan: StreamedSpanJSON,
+ client: Client,
+ scopeData: ScopeData,
+): void {
+ const sdk = client.getSdkMetadata();
+ const { release, environment, sendDefaultPii } = client.getOptions();
+
+ // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation)
+ safeSetSpanJSONAttributes(spanJSON, {
+ [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version,
+ ...(sendDefaultPii
+ ? {
+ [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id,
+ [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,
+ });
+}
+
+/**
+ * Apply a user-provided beforeSendSpan callback to a span JSON.
+ */
+export function applyBeforeSendSpanCallback(
+ span: StreamedSpanJSON,
+ beforeSendSpan: (span: StreamedSpanJSON) => StreamedSpanJSON,
+): StreamedSpanJSON {
+ const modifedSpan = beforeSendSpan(span);
+ if (!modifedSpan) {
+ showSpanDropWarning();
+ return span;
+ }
+ return modifedSpan;
+}
+
+/**
+ * Safely set attributes on a span JSON.
+ * If an attribute already exists, it will not be overwritten.
+ */
+export function safeSetSpanJSONAttributes(
+ spanJSON: StreamedSpanJSON,
+ newAttributes: RawAttributes>,
+): void {
+ const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {});
+
+ Object.entries(newAttributes).forEach(([key, value]) => {
+ if (value != null && !(key in originalAttributes)) {
+ originalAttributes[key] = value;
+ }
+ });
+}
diff --git a/packages/core/src/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts
new file mode 100644
index 000000000000..8429b22d7e1c
--- /dev/null
+++ b/packages/core/src/tracing/spans/envelope.ts
@@ -0,0 +1,36 @@
+import type { Client } from '../../client';
+import type { DynamicSamplingContext, SpanContainerItem, StreamedSpanEnvelope } from '../../types-hoist/envelope';
+import type { SerializedStreamedSpan } from '../../types-hoist/span';
+import { dsnToString } from '../../utils/dsn';
+import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope';
+
+/**
+ * Creates a span v2 span streaming envelope
+ */
+export function createStreamedSpanEnvelope(
+ serializedSpans: Array,
+ dsc: Partial,
+ client: Client,
+): StreamedSpanEnvelope {
+ const dsn = client.getDsn();
+ const tunnel = client.getOptions().tunnel;
+ const sdk = getSdkMetadataForEnvelopeHeader(client.getOptions()._metadata);
+
+ const headers: StreamedSpanEnvelope[0] = {
+ sent_at: new Date().toISOString(),
+ ...(dscHasRequiredProps(dsc) && { trace: dsc }),
+ ...(sdk && { sdk }),
+ ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }),
+ };
+
+ const spanContainer: SpanContainerItem = [
+ { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' },
+ { items: serializedSpans },
+ ];
+
+ return createEnvelope(headers, [spanContainer]);
+}
+
+function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext {
+ return !!dsc.trace_id && !!dsc.public_key;
+}
diff --git a/packages/core/src/tracing/spans/estimateSize.ts b/packages/core/src/tracing/spans/estimateSize.ts
new file mode 100644
index 000000000000..7d5781862d62
--- /dev/null
+++ b/packages/core/src/tracing/spans/estimateSize.ts
@@ -0,0 +1,37 @@
+import { estimateTypedAttributesSizeInBytes } from '../../attributes';
+import type { SerializedStreamedSpan } from '../../types-hoist/span';
+
+/**
+ * Estimates the serialized byte size of a {@link SerializedStreamedSpan}.
+ *
+ * Uses 2 bytes per character as a UTF-16 approximation, and 8 bytes per number.
+ * The estimate is intentionally conservative and may be slightly lower than the
+ * actual byte size on the wire.
+ * We compensate for this by setting the span buffers internal limit well below the limit
+ * of how large an actual span v2 envelope may be.
+ */
+export function estimateSerializedSpanSizeInBytes(span: SerializedStreamedSpan): number {
+ /*
+ * Fixed-size fields are pre-computed as a constant for performance:
+ * - two timestamps (8 bytes each = 16)
+ * - is_segment boolean (5 bytes, assumed false for most spans)
+ * - trace_id – always 32 hex chars (64 bytes)
+ * - span_id – always 16 hex chars (32 bytes)
+ * - parent_span_id – 16 hex chars, assumed present for most spans (32 bytes)
+ * - status "ok" – most common value (8 bytes)
+ * = 156 bytes total base
+ */
+ let weight = 156;
+ weight += span.name.length * 2;
+ weight += estimateTypedAttributesSizeInBytes(span.attributes);
+ if (span.links && span.links.length > 0) {
+ // Assumption: Links are roughly equal in number of attributes
+ // probably not always true but allows us to cut down on runtime
+ const firstLink = span.links[0];
+ const attributes = firstLink?.attributes;
+ // Fixed size 100 due to span_id, trace_id and sampled flag (see above)
+ const linkWeight = 100 + (attributes ? estimateTypedAttributesSizeInBytes(attributes) : 0);
+ weight += linkWeight * span.links.length;
+ }
+ return weight;
+}
diff --git a/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts
new file mode 100644
index 000000000000..7d5fa2861c21
--- /dev/null
+++ b/packages/core/src/tracing/spans/hasSpanStreamingEnabled.ts
@@ -0,0 +1,8 @@
+import type { Client } from '../../client';
+
+/**
+ * Determines if span streaming is enabled for the given client
+ */
+export function hasSpanStreamingEnabled(client: Client): boolean {
+ return client.getOptions().traceLifecycle === 'stream';
+}
diff --git a/packages/core/src/tracing/spans/spanBuffer.ts b/packages/core/src/tracing/spans/spanBuffer.ts
new file mode 100644
index 000000000000..cd011df5ac49
--- /dev/null
+++ b/packages/core/src/tracing/spans/spanBuffer.ts
@@ -0,0 +1,195 @@
+import type { Client } from '../../client';
+import { DEBUG_BUILD } from '../../debug-build';
+import type { SerializedStreamedSpan } from '../../types-hoist/span';
+import { debug } from '../../utils/debug-logger';
+import { safeUnref } from '../../utils/timer';
+import { getDynamicSamplingContextFromSpan } from '../dynamicSamplingContext';
+import type { SerializedStreamedSpanWithSegmentSpan } from './captureSpan';
+import { createStreamedSpanEnvelope } from './envelope';
+import { estimateSerializedSpanSizeInBytes } from './estimateSize';
+
+/**
+ * We must not send more than 1000 spans in one envelope.
+ * Otherwise the envelope is dropped by Relay.
+ */
+const MAX_SPANS_PER_ENVELOPE = 1000;
+
+const MAX_TRACE_WEIGHT_IN_BYTES = 5_000_000;
+
+interface TraceBucket {
+ spans: Set;
+ size: number;
+ timeout: ReturnType;
+}
+
+export interface SpanBufferOptions {
+ /**
+ * Max spans per trace before auto-flush
+ * Must not exceed 1000.
+ *
+ * @default 1_000
+ */
+ maxSpanLimit?: number;
+
+ /**
+ * Per-trace flush timeout in ms. A timeout is started when a trace bucket is first created
+ * and fires flush() for that specific trace when it expires.
+ * Must be greater than 0.
+ *
+ * @default 5_000
+ */
+ flushInterval?: number;
+
+ /**
+ * Max accumulated byte weight of spans per trace before auto-flush.
+ * Size is estimated, not exact. Uses 2 bytes per character for strings (UTF-16).
+ *
+ * @default 5_000_000 (5 MB)
+ */
+ maxTraceWeightInBytes?: number;
+}
+
+/**
+ * A buffer for serialized streamed span JSON objects that flushes them to Sentry in Span v2 envelopes.
+ * Handles per-trace timeout-based flushing, size thresholds, and graceful shutdown.
+ * Also handles computation of the Dynamic Sampling Context (DSC) for the trace, if it wasn't yet
+ * frozen onto the segment span.
+ *
+ * For this, we need the reference to the segment span instance, from
+ * which we compute the DSC. Doing this in the buffer ensures that we compute the DSC as late as possible,
+ * allowing span name and data updates up to this point. Worth noting here that the segment span is likely
+ * still active and modifyable when child spans are added to the buffer.
+ */
+export class SpanBuffer {
+ /* Bucket spans by their trace id, along with accumulated size and a per-trace flush timeout */
+ private _traceBuckets: Map;
+
+ private _client: Client;
+ private _maxSpanLimit: number;
+ private _flushInterval: number;
+ private _maxTraceWeight: number;
+
+ public constructor(client: Client, options?: SpanBufferOptions) {
+ this._traceBuckets = new Map();
+ this._client = client;
+
+ const { maxSpanLimit, flushInterval, maxTraceWeightInBytes } = options ?? {};
+
+ this._maxSpanLimit =
+ maxSpanLimit && maxSpanLimit > 0 && maxSpanLimit <= MAX_SPANS_PER_ENVELOPE
+ ? maxSpanLimit
+ : MAX_SPANS_PER_ENVELOPE;
+ this._flushInterval = flushInterval && flushInterval > 0 ? flushInterval : 5_000;
+ this._maxTraceWeight =
+ maxTraceWeightInBytes && maxTraceWeightInBytes > 0 ? maxTraceWeightInBytes : MAX_TRACE_WEIGHT_IN_BYTES;
+
+ this._client.on('flush', () => {
+ this.drain();
+ });
+
+ this._client.on('close', () => {
+ // No need to drain the buffer here as `Client.close()` internally already calls `Client.flush()`
+ // which already invokes the `flush` hook and thus drains the buffer.
+ this._traceBuckets.forEach(bucket => {
+ clearTimeout(bucket.timeout);
+ });
+ this._traceBuckets.clear();
+ });
+ }
+
+ /**
+ * Add a span to the buffer.
+ */
+ public add(spanJSON: SerializedStreamedSpanWithSegmentSpan): void {
+ const traceId = spanJSON.trace_id;
+ let bucket = this._traceBuckets.get(traceId);
+
+ if (!bucket) {
+ bucket = {
+ spans: new Set(),
+ size: 0,
+ timeout: safeUnref(
+ setTimeout(() => {
+ this.flush(traceId);
+ }, this._flushInterval),
+ ),
+ };
+ this._traceBuckets.set(traceId, bucket);
+ }
+
+ bucket.spans.add(spanJSON);
+ bucket.size += estimateSerializedSpanSizeInBytes(spanJSON);
+
+ if (bucket.spans.size >= this._maxSpanLimit || bucket.size >= this._maxTraceWeight) {
+ this.flush(traceId);
+ }
+ }
+
+ /**
+ * Drain and flush all buffered traces.
+ */
+ public drain(): void {
+ if (!this._traceBuckets.size) {
+ return;
+ }
+
+ DEBUG_BUILD && debug.log(`Flushing span tree map with ${this._traceBuckets.size} traces`);
+
+ this._traceBuckets.forEach((_, traceId) => {
+ this.flush(traceId);
+ });
+ }
+
+ /**
+ * Flush spans of a specific trace.
+ * In contrast to {@link SpanBuffer.drain}, this method does not flush all traces, but only the one with the given traceId.
+ */
+ public flush(traceId: string): void {
+ const bucket = this._traceBuckets.get(traceId);
+ if (!bucket) {
+ return;
+ }
+
+ if (!bucket.spans.size) {
+ // we should never get here, given we always add a span when we create a new bucket
+ // and delete the bucket once we flush out the trace
+ this._removeTrace(traceId);
+ return;
+ }
+
+ const spans = Array.from(bucket.spans);
+
+ const segmentSpan = spans[0]?._segmentSpan;
+ if (!segmentSpan) {
+ DEBUG_BUILD && debug.warn('No segment span reference found on span JSON, cannot compute DSC');
+ this._removeTrace(traceId);
+ return;
+ }
+
+ const dsc = getDynamicSamplingContextFromSpan(segmentSpan);
+
+ const cleanedSpans: SerializedStreamedSpan[] = spans.map(spanJSON => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { _segmentSpan, ...cleanSpanJSON } = spanJSON;
+ return cleanSpanJSON;
+ });
+
+ const envelope = createStreamedSpanEnvelope(cleanedSpans, dsc, this._client);
+
+ DEBUG_BUILD && debug.log(`Sending span envelope for trace ${traceId} with ${cleanedSpans.length} spans`);
+
+ this._client.sendEnvelope(envelope).then(null, reason => {
+ DEBUG_BUILD && debug.error('Error while sending streamed span envelope:', reason);
+ });
+
+ this._removeTrace(traceId);
+ }
+
+ private _removeTrace(traceId: string): void {
+ const bucket = this._traceBuckets.get(traceId);
+ if (bucket) {
+ clearTimeout(bucket.timeout);
+ }
+ this._traceBuckets.delete(traceId);
+ }
+}
diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts
index 28a5bccd4147..59b00bb018c1 100644
--- a/packages/core/src/tracing/trace.ts
+++ b/packages/core/src/tracing/trace.ts
@@ -492,6 +492,7 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp
// If it has an endTimestamp, it's already ended
if (spanArguments.endTimestamp) {
client.emit('spanEnd', childSpan);
+ client.emit('afterSpanEnd', childSpan);
}
}
diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts
index 272f8cde9f62..d8b8a1822b04 100644
--- a/packages/core/src/types-hoist/envelope.ts
+++ b/packages/core/src/types-hoist/envelope.ts
@@ -11,7 +11,7 @@ import type { Profile, ProfileChunk } from './profiling';
import type { ReplayEvent, ReplayRecordingData } from './replay';
import type { SdkInfo } from './sdkinfo';
import type { SerializedSession, SessionAggregates } from './session';
-import type { SpanJSON } from './span';
+import type { SerializedStreamedSpanContainer, SpanJSON } from './span';
// Based on: https://develop.sentry.dev/sdk/envelopes/
@@ -91,6 +91,21 @@ type CheckInItemHeaders = { type: 'check_in' };
type ProfileItemHeaders = { type: 'profile' };
type ProfileChunkItemHeaders = { type: 'profile_chunk' };
type SpanItemHeaders = { type: 'span' };
+type SpanContainerItemHeaders = {
+ /**
+ * Same as v1 span item type but this envelope is distinguished by {@link SpanContainerItemHeaders.content_type}.
+ */
+ type: 'span';
+ /**
+ * The number of span items in the container. This must be the same as the number of span items in the payload.
+ */
+ item_count: number;
+ /**
+ * The content type of the span items. This must be `application/vnd.sentry.items.span.v2+json`.
+ * (the presence of this field also distinguishes the span item from the v1 span item)
+ */
+ content_type: 'application/vnd.sentry.items.span.v2+json';
+};
type LogContainerItemHeaders = {
type: 'log';
/**
@@ -123,6 +138,7 @@ export type FeedbackItem = BaseEnvelopeItem;
export type ProfileItem = BaseEnvelopeItem;
export type ProfileChunkItem = BaseEnvelopeItem;
export type SpanItem = BaseEnvelopeItem>;
+export type SpanContainerItem = BaseEnvelopeItem;
export type LogContainerItem = BaseEnvelopeItem;
export type MetricContainerItem = BaseEnvelopeItem;
export type RawSecurityItem = BaseEnvelopeItem;
@@ -133,6 +149,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext };
type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders;
type ReplayEnvelopeHeaders = BaseEnvelopeHeaders;
type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext };
+type StreamedSpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext };
type LogEnvelopeHeaders = BaseEnvelopeHeaders;
type MetricEnvelopeHeaders = BaseEnvelopeHeaders;
export type EventEnvelope = BaseEnvelope<
@@ -144,6 +161,7 @@ export type ClientReportEnvelope = BaseEnvelope;
export type SpanEnvelope = BaseEnvelope;
+export type StreamedSpanEnvelope = BaseEnvelope;
export type ProfileChunkEnvelope = BaseEnvelope;
export type RawSecurityEnvelope = BaseEnvelope;
export type LogEnvelope = BaseEnvelope;
@@ -157,6 +175,7 @@ export type Envelope =
| ReplayEnvelope
| CheckInEnvelope
| SpanEnvelope
+ | StreamedSpanEnvelope
| RawSecurityEnvelope
| LogEnvelope
| MetricEnvelope;
diff --git a/packages/core/src/types-hoist/integration.ts b/packages/core/src/types-hoist/integration.ts
index 120cb1acc884..fc80cf3f524a 100644
--- a/packages/core/src/types-hoist/integration.ts
+++ b/packages/core/src/types-hoist/integration.ts
@@ -14,6 +14,15 @@ export interface Integration {
*/
setupOnce?(): void;
+ /**
+ * Called before the `setup` hook of any integration is called.
+ * This is useful if an integration needs to e.g. modify client options prior to other integrations
+ * reading client options.
+ *
+ * @param client
+ */
+ beforeSetup?(client: Client): void;
+
/**
* Set up an integration for the given client.
* Receives the client as argument.
diff --git a/packages/core/src/types-hoist/link.ts b/packages/core/src/types-hoist/link.ts
index a330dc108b00..9a117258200b 100644
--- a/packages/core/src/types-hoist/link.ts
+++ b/packages/core/src/types-hoist/link.ts
@@ -22,9 +22,9 @@ export interface SpanLink {
* Link interface for the event envelope item. It's a flattened representation of `SpanLink`.
* Can include additional fields defined by OTel.
*/
-export interface SpanLinkJSON extends Record {
+export interface SpanLinkJSON extends Record {
span_id: string;
trace_id: string;
sampled?: boolean;
- attributes?: SpanLinkAttributes;
+ attributes?: TAttributes;
}
diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts
index 92292f8e6e3d..63310a66c3d2 100644
--- a/packages/core/src/types-hoist/options.ts
+++ b/packages/core/src/types-hoist/options.ts
@@ -6,7 +6,7 @@ import type { Log } from './log';
import type { Metric } from './metric';
import type { TracesSamplerSamplingContext } from './samplingcontext';
import type { SdkMetadata } from './sdkmetadata';
-import type { SpanJSON } from './span';
+import type { SpanJSON, StreamedSpanJSON } from './span';
import type { StackLineParser, StackParser } from './stacktrace';
import type { TracePropagationTargets } from './tracing';
import type { BaseTransportOptions, Transport } from './transport';
@@ -500,6 +500,14 @@ export interface ClientOptions SpanJSON;
+ beforeSendSpan?: ((span: SpanJSON) => SpanJSON) | BeforeSendStramedSpanCallback;
/**
* An event-processing callback for transaction events, guaranteed to be invoked after all other event
@@ -615,6 +626,19 @@ export interface ClientOptions Breadcrumb | null;
}
+/**
+ * A callback for processing streamed spans before they are sent.
+ *
+ * @see {@link StreamedSpanJSON} for the streamed span format used with `traceLifecycle: 'stream'`
+ */
+export type BeforeSendStramedSpanCallback = ((span: StreamedSpanJSON) => StreamedSpanJSON) & {
+ /**
+ * When true, indicates this callback is designed to handle the {@link StreamedSpanJSON} format
+ * used with `traceLifecycle: 'stream'`. Set this by wrapping your callback with `withStreamedSpan`.
+ */
+ _streamed?: true;
+};
+
/** Base configuration options for every SDK. */
export interface CoreOptions extends Omit<
Partial>,
diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts
index d82463768b7f..a918cc57859c 100644
--- a/packages/core/src/types-hoist/span.ts
+++ b/packages/core/src/types-hoist/span.ts
@@ -1,3 +1,4 @@
+import type { Attributes, RawAttributes } from '../attributes';
import type { SpanLink, SpanLinkJSON } from './link';
import type { Measurements } from './measurement';
import type { HrTime } from './opentelemetry';
@@ -34,6 +35,43 @@ export type SpanAttributes = Partial<{
/** This type is aligned with the OpenTelemetry TimeInput type. */
export type SpanTimeInput = HrTime | number | Date;
+/**
+ * Intermediate JSON reporesentation of a v2 span, which users and our SDK integrations will interact with.
+ * This is NOT the final serialized JSON span, but an intermediate step still holding raw attributes.
+ * The final, serialized span is a {@link SerializedStreamedSpan}.
+ * Main reason: Make it easier and safer for users to work with attributes.
+ */
+export interface StreamedSpanJSON {
+ trace_id: string;
+ parent_span_id?: string;
+ span_id: string;
+ name: string;
+ start_timestamp: number;
+ end_timestamp: number;
+ status: 'ok' | 'error';
+ is_segment: boolean;
+ attributes?: RawAttributes>;
+ links?: SpanLinkJSON>>[];
+}
+
+/**
+ * Serialized span item.
+ * This is the final, serialized span format that is sent to Sentry.
+ * The intermediate representation is {@link StreamedSpanJSON}.
+ * Main difference: Attributes are converted to {@link Attributes}, thus including the `type` annotation.
+ */
+export type SerializedStreamedSpan = Omit & {
+ attributes?: Attributes;
+ links?: SpanLinkJSON[];
+};
+
+/**
+ * Envelope span item container.
+ */
+export type SerializedStreamedSpanContainer = {
+ items: Array;
+};
+
/** A JSON representation of a span. */
export interface SpanJSON {
data: SpanAttributes;
diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts
index d7c261ecd73c..d22905670efc 100644
--- a/packages/core/src/utils/spanUtils.ts
+++ b/packages/core/src/utils/spanUtils.ts
@@ -1,4 +1,6 @@
import { getAsyncContextStrategy } from '../asyncContext';
+import type { RawAttributes } from '../attributes';
+import { serializeAttributes } from '../attributes';
import { getMainCarrier } from '../carrier';
import { getCurrentScope } from '../currentScopes';
import {
@@ -12,7 +14,15 @@ import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus';
import { getCapturedScopesOnSpan } from '../tracing/utils';
import type { TraceContext } from '../types-hoist/context';
import type { SpanLink, SpanLinkJSON } from '../types-hoist/link';
-import type { Span, SpanAttributes, SpanJSON, SpanOrigin, SpanTimeInput } from '../types-hoist/span';
+import type {
+ SerializedStreamedSpan,
+ Span,
+ SpanAttributes,
+ SpanJSON,
+ SpanOrigin,
+ SpanTimeInput,
+ StreamedSpanJSON,
+} from '../types-hoist/span';
import type { SpanStatus } from '../types-hoist/spanStatus';
import { addNonEnumerableProperty } from '../utils/object';
import { generateSpanId } from '../utils/propagationContext';
@@ -105,6 +115,27 @@ export function convertSpanLinksForEnvelope(links?: SpanLink[]): SpanLinkJSON[]
}
}
+/**
+ * Converts the span links array to a flattened version with serialized attributes for V2 spans.
+ *
+ * If the links array is empty, it returns `undefined` so the empty value can be dropped before it's sent.
+ */
+export function getStreamedSpanLinks(
+ links?: SpanLink[],
+): SpanLinkJSON>>[] | undefined {
+ if (links?.length) {
+ return links.map(({ context: { spanId, traceId, traceFlags, ...restContext }, attributes }) => ({
+ span_id: spanId,
+ trace_id: traceId,
+ sampled: traceFlags === TRACE_FLAG_SAMPLED,
+ attributes,
+ ...restContext,
+ }));
+ } else {
+ return undefined;
+ }
+}
+
/**
* Convert a span time input into a timestamp in seconds.
*/
@@ -150,23 +181,12 @@ export function spanToJSON(span: Span): SpanJSON {
if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) {
const { attributes, startTime, name, endTime, status, links } = span;
- // In preparation for the next major of OpenTelemetry, we want to support
- // looking up the parent span id according to the new API
- // In OTel v1, the parent span id is accessed as `parentSpanId`
- // In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext`
- const parentSpanId =
- 'parentSpanId' in span
- ? span.parentSpanId
- : 'parentSpanContext' in span
- ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId
- : undefined;
-
return {
span_id,
trace_id,
data: attributes,
description: name,
- parent_span_id: parentSpanId,
+ parent_span_id: getOtelParentSpanId(span),
start_timestamp: spanTimeInputToSeconds(startTime),
// This is [0,0] by default in OTEL, in which case we want to interpret this as no end time
timestamp: spanTimeInputToSeconds(endTime) || undefined,
@@ -187,6 +207,77 @@ export function spanToJSON(span: Span): SpanJSON {
};
}
+/**
+ * Convert a span to the intermediate {@link StreamedSpanJSON} representation.
+ */
+export function spanToStreamedSpanJSON(span: Span): StreamedSpanJSON {
+ if (spanIsSentrySpan(span)) {
+ return span.getStreamedSpanJSON();
+ }
+
+ const { spanId: span_id, traceId: trace_id } = span.spanContext();
+
+ // Handle a span from @opentelemetry/sdk-base-trace's `Span` class
+ if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) {
+ const { attributes, startTime, name, endTime, status, links } = span;
+
+ return {
+ name,
+ span_id,
+ trace_id,
+ parent_span_id: getOtelParentSpanId(span),
+ start_timestamp: spanTimeInputToSeconds(startTime),
+ end_timestamp: spanTimeInputToSeconds(endTime),
+ is_segment: span === INTERNAL_getSegmentSpan(span),
+ status: getSimpleStatusMessage(status),
+ attributes,
+ links: getStreamedSpanLinks(links),
+ };
+ }
+
+ // Finally, as a fallback, at least we have `spanContext()`....
+ // This should not actually happen in reality, but we need to handle it for type safety.
+ return {
+ span_id,
+ trace_id,
+ start_timestamp: 0,
+ name: '',
+ end_timestamp: 0,
+ status: 'ok',
+ is_segment: span === INTERNAL_getSegmentSpan(span),
+ };
+}
+
+/**
+ * In preparation for the next major of OpenTelemetry, we want to support
+ * looking up the parent span id according to the new API
+ * In OTel v1, the parent span id is accessed as `parentSpanId`
+ * In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext`
+ */
+function getOtelParentSpanId(span: OpenTelemetrySdkTraceBaseSpan): string | undefined {
+ return 'parentSpanId' in span
+ ? span.parentSpanId
+ : 'parentSpanContext' in span
+ ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId
+ : undefined;
+}
+
+/**
+ * Converts a {@link StreamedSpanJSON} to a {@link SerializedSpan}.
+ * This is the final serialized span format that is sent to Sentry.
+ * The returned serilaized spans must not be consumed by users or SDK integrations.
+ */
+export function streamedSpanJsonToSerializedSpan(spanJson: StreamedSpanJSON): SerializedStreamedSpan {
+ return {
+ ...spanJson,
+ attributes: serializeAttributes(spanJson.attributes),
+ links: spanJson.links?.map(link => ({
+ ...link,
+ attributes: serializeAttributes(link.attributes),
+ })),
+ };
+}
+
function spanIsOpenTelemetrySdkTraceBaseSpan(span: Span): span is OpenTelemetrySdkTraceBaseSpan {
const castSpan = span as Partial;
return !!castSpan.attributes && !!castSpan.startTime && !!castSpan.name && !!castSpan.endTime && !!castSpan.status;
@@ -237,6 +328,18 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef
return status.message || 'internal_error';
}
+/**
+ * Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default).
+ */
+export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' {
+ return !status ||
+ status.code === SPAN_STATUS_OK ||
+ status.code === SPAN_STATUS_UNSET ||
+ status.message === 'cancelled'
+ ? 'ok'
+ : 'error';
+}
+
const CHILD_SPANS_FIELD = '_sentryChildSpans';
const ROOT_SPAN_FIELD = '_sentryRootSpan';
@@ -298,7 +401,12 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] {
/**
* Returns the root span of a given span.
*/
-export function getRootSpan(span: SpanWithPotentialChildren): Span {
+export const getRootSpan = INTERNAL_getSegmentSpan;
+
+/**
+ * Returns the segment span of a given span.
+ */
+export function INTERNAL_getSegmentSpan(span: SpanWithPotentialChildren): Span {
return span[ROOT_SPAN_FIELD] || span;
}
diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts
new file mode 100644
index 000000000000..8b2badf575ff
--- /dev/null
+++ b/packages/core/test/integrations/spanStreaming.test.ts
@@ -0,0 +1,139 @@
+import * as SentryCore from '../../src';
+import { debug } from '../../src';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { spanStreamingIntegration } from '../../src/integrations/spanStreaming';
+import { TestClient, getDefaultTestClientOptions } from '../mocks/client';
+
+const mockSpanBufferInstance = vi.hoisted(() => ({
+ flush: vi.fn(),
+ add: vi.fn(),
+ drain: vi.fn(),
+}));
+
+const MockSpanBuffer = vi.hoisted(() => {
+ return vi.fn(() => mockSpanBufferInstance);
+});
+
+vi.mock('../../src/tracing/spans/spanBuffer', async () => {
+ const original = await vi.importActual('../../src/tracing/spans/spanBuffer');
+ return {
+ ...original,
+ SpanBuffer: MockSpanBuffer,
+ };
+});
+
+describe('spanStreamingIntegration (core)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('has the correct name and setup hook', () => {
+ const integration = spanStreamingIntegration();
+ expect(integration.name).toBe('SpanStreaming');
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(integration.setup).toBeDefined();
+ });
+
+ it.each(['static', 'somethingElse'])(
+ 'logs a warning if traceLifecycle is not set to "stream" but to %s',
+ traceLifecycle => {
+ const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
+ const client = new TestClient({
+ ...getDefaultTestClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ // @ts-expect-error - we want to test the warning for invalid traceLifecycle values
+ traceLifecycle,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ expect(debugSpy).toHaveBeenCalledWith(
+ 'SpanStreaming integration requires `traceLifecycle` to be set to "stream"! Falling back to static trace lifecycle.',
+ );
+ debugSpy.mockRestore();
+
+ expect(client.getOptions().traceLifecycle).toBe('static');
+ },
+ );
+
+ it('falls back to static trace lifecycle if beforeSendSpan is not compatible with span streaming', () => {
+ const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
+ const client = new TestClient({
+ ...getDefaultTestClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ beforeSendSpan: (span: SentryCore.SpanJSON) => span,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ expect(debugSpy).toHaveBeenCalledWith(
+ 'SpanStreaming integration requires a beforeSendSpan callback using `withStreamedSpan`! Falling back to static trace lifecycle.',
+ );
+ debugSpy.mockRestore();
+
+ expect(client.getOptions().traceLifecycle).toBe('static');
+ });
+
+ it('sets up buffer when traceLifecycle is "stream"', () => {
+ const client = new TestClient({
+ ...getDefaultTestClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ expect(MockSpanBuffer).toHaveBeenCalledWith(client);
+ expect(client.getOptions().traceLifecycle).toBe('stream');
+ });
+
+ it('enqueues a span into the buffer when the span ends', () => {
+ const client = new TestClient({
+ ...getDefaultTestClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ const span = new SentryCore.SentrySpan({ name: 'test', sampled: true });
+ client.emit('afterSpanEnd', span);
+
+ expect(mockSpanBufferInstance.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ _segmentSpan: span,
+ trace_id: span.spanContext().traceId,
+ span_id: span.spanContext().spanId,
+ name: 'test',
+ }),
+ );
+ });
+
+ it('does not enqueue a span into the buffer when the span is not sampled', () => {
+ const client = new TestClient({
+ ...getDefaultTestClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ const span = new SentryCore.SentrySpan({ name: 'test', sampled: false });
+ client.emit('afterSpanEnd', span);
+
+ expect(mockSpanBufferInstance.add).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts
new file mode 100644
index 000000000000..79fd838a1b27
--- /dev/null
+++ b/packages/core/test/lib/tracing/spans/beforeSendSpan.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from 'vitest';
+import { withStreamedSpan } from '../../../../src';
+import { isStreamedBeforeSendSpanCallback } from '../../../../src/tracing/spans/beforeSendSpan';
+
+describe('beforeSendSpan for span streaming', () => {
+ describe('withStreamedSpan', () => {
+ it('should be able to modify the span', () => {
+ const beforeSendSpan = vi.fn();
+ const wrapped = withStreamedSpan(beforeSendSpan);
+ expect(wrapped._streamed).toBe(true);
+ });
+ });
+
+ describe('isStreamedBeforeSendSpanCallback', () => {
+ it('returns true if the callback is wrapped with withStreamedSpan', () => {
+ const beforeSendSpan = vi.fn();
+ const wrapped = withStreamedSpan(beforeSendSpan);
+ expect(isStreamedBeforeSendSpanCallback(wrapped)).toBe(true);
+ });
+
+ it('returns false if the callback is not wrapped with withStreamedSpan', () => {
+ const beforeSendSpan = vi.fn();
+ expect(isStreamedBeforeSendSpanCallback(beforeSendSpan)).toBe(false);
+ });
+ });
+});
diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts
new file mode 100644
index 000000000000..d429d50714a2
--- /dev/null
+++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts
@@ -0,0 +1,485 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { StreamedSpanJSON } from '../../../../src';
+import {
+ captureSpan,
+ SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ SEMANTIC_ATTRIBUTE_USER_EMAIL,
+ SEMANTIC_ATTRIBUTE_USER_ID,
+ SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS,
+ SEMANTIC_ATTRIBUTE_USER_USERNAME,
+ startInactiveSpan,
+ startSpan,
+ withScope,
+ withStreamedSpan,
+} from '../../../../src';
+import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan';
+import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
+
+describe('captureSpan', () => {
+ it('captures user attributes iff sendDefaultPii is true', () => {
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ sendDefaultPii: true,
+ }),
+ );
+
+ const span = withScope(scope => {
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ username: 'testuser',
+ ip_address: '127.0.0.1',
+ });
+
+ const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } });
+ span.end();
+
+ return span;
+ });
+
+ const serializedSpan = captureSpan(span, client);
+
+ expect(serializedSpan).toStrictEqual({
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ parent_span_id: undefined,
+ links: undefined,
+ start_timestamp: expect.any(Number),
+ name: 'my-span',
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'http.client',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ value: 'my-span',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ value: span.spanContext().spanId,
+ type: 'string',
+ },
+ 'sentry.span.source': {
+ value: 'custom',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ value: 'custom',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: {
+ value: '1.0.0',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: {
+ value: 'staging',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_USER_ID]: {
+ value: '123',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_USER_EMAIL]: {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_USER_USERNAME]: {
+ value: 'testuser',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: {
+ value: '127.0.0.1',
+ type: 'string',
+ },
+ },
+ _segmentSpan: span,
+ });
+ });
+
+ it.each([false, undefined])("doesn't capture user attributes if sendDefaultPii is %s", sendDefaultPii => {
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ sendDefaultPii,
+ }),
+ );
+
+ const span = withScope(scope => {
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ username: 'testuser',
+ ip_address: '127.0.0.1',
+ });
+
+ const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } });
+ span.end();
+
+ return span;
+ });
+
+ expect(captureSpan(span, client)).toStrictEqual({
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ parent_span_id: undefined,
+ links: undefined,
+ start_timestamp: expect.any(Number),
+ name: 'my-span',
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'http.client',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ value: 'my-span',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ value: span.spanContext().spanId,
+ type: 'string',
+ },
+ 'sentry.span.source': {
+ value: 'custom',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ value: 'custom',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: {
+ value: '1.0.0',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: {
+ value: 'staging',
+ type: 'string',
+ },
+ },
+ _segmentSpan: span,
+ });
+ });
+
+ it('captures sdk name and version if available', () => {
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ _metadata: {
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: '1.0.0',
+ integrations: ['UnhandledRejection', 'Dedupe'],
+ },
+ },
+ }),
+ );
+
+ const span = withScope(scope => {
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ username: 'testuser',
+ ip_address: '127.0.0.1',
+ });
+
+ const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } });
+ span.end();
+
+ return span;
+ });
+
+ expect(captureSpan(span, client)).toStrictEqual({
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ parent_span_id: undefined,
+ links: undefined,
+ start_timestamp: expect.any(Number),
+ name: 'my-span',
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'http.client',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ value: 'my-span',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ value: span.spanContext().spanId,
+ type: 'string',
+ },
+ 'sentry.span.source': {
+ value: 'custom',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ value: 'custom',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: {
+ value: '1.0.0',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: {
+ value: 'staging',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ value: 'sentry.javascript.node',
+ type: 'string',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ value: '1.0.0',
+ type: 'string',
+ },
+ },
+ _segmentSpan: span,
+ });
+ });
+
+ describe('client hooks', () => {
+ it('calls processSpan and processSegmentSpan hooks for a segment span', () => {
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ }),
+ );
+
+ const processSpanFn = vi.fn();
+ const processSegmentSpanFn = vi.fn();
+ client.on('processSpan', processSpanFn);
+ client.on('processSegmentSpan', processSegmentSpanFn);
+
+ const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } });
+
+ captureSpan(span, client);
+
+ expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId }));
+ expect(processSegmentSpanFn).toHaveBeenCalledWith(
+ expect.objectContaining({ span_id: span.spanContext().spanId }),
+ );
+ });
+
+ it('only calls processSpan hook for a child span', () => {
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ sendDefaultPii: true,
+ }),
+ );
+
+ const processSpanFn = vi.fn();
+ const processSegmentSpanFn = vi.fn();
+ client.on('processSpan', processSpanFn);
+ client.on('processSegmentSpan', processSegmentSpanFn);
+
+ const serializedChildSpan = withScope(scope => {
+ scope.setClient(client);
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ username: 'testuser',
+ ip_address: '127.0.0.1',
+ });
+
+ return startSpan({ name: 'segment' }, () => {
+ const childSpan = startInactiveSpan({ name: 'child' });
+ childSpan.end();
+ return captureSpan(childSpan, client);
+ });
+ });
+
+ expect(serializedChildSpan?.name).toBe('child');
+ expect(serializedChildSpan?.is_segment).toBe(false);
+
+ expect(processSpanFn).toHaveBeenCalledWith(expect.objectContaining({ span_id: serializedChildSpan?.span_id }));
+ expect(processSegmentSpanFn).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('beforeSendSpan', () => {
+ it('applies beforeSendSpan if it is a span streaming compatible callback', () => {
+ const beforeSendSpan = withStreamedSpan(vi.fn(span => span));
+
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ beforeSendSpan,
+ }),
+ );
+
+ const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } });
+ span.end();
+
+ captureSpan(span, client);
+
+ expect(beforeSendSpan).toHaveBeenCalledWith(expect.objectContaining({ span_id: span.spanContext().spanId }));
+ });
+
+ it("doesn't apply beforeSendSpan if it is not a span streaming compatible callback", () => {
+ const beforeSendSpan = vi.fn(span => span);
+
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ beforeSendSpan,
+ }),
+ );
+
+ const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } });
+ span.end();
+
+ captureSpan(span, client);
+
+ expect(beforeSendSpan).not.toHaveBeenCalled();
+ });
+
+ it('logs a warning if the beforeSendSpan callback returns null', () => {
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ // @ts-expect-error - the types dissallow returning null but this is javascript, so we need to test it
+ const beforeSendSpan = withStreamedSpan(() => null);
+
+ const client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://dsn@ingest.f00.f00/1',
+ tracesSampleRate: 1,
+ release: '1.0.0',
+ environment: 'staging',
+ beforeSendSpan,
+ }),
+ );
+
+ const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } });
+ span.end();
+
+ captureSpan(span, client);
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ '[Sentry] Returning null from `beforeSendSpan` is disallowed. To drop certain spans, configure the respective integrations directly or use `ignoreSpans`.',
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
+ });
+});
+
+describe('safeSetSpanJSONAttributes', () => {
+ it('sets attributes that do not exist', () => {
+ const spanJSON = { attributes: { a: 1, b: 2 } };
+
+ // @ts-expect-error - only passing a partial object for this test
+ safeSetSpanJSONAttributes(spanJSON, { c: 3 });
+
+ expect(spanJSON.attributes).toEqual({ a: 1, b: 2, c: 3 });
+ });
+
+ it("doesn't set attributes that already exist", () => {
+ const spanJSON = { attributes: { a: 1, b: 2 } };
+ // @ts-expect-error - only passing a partial object for this test
+ safeSetSpanJSONAttributes(spanJSON, { a: 3 });
+
+ expect(spanJSON.attributes).toEqual({ a: 1, b: 2 });
+ });
+
+ it.each([null, undefined])("doesn't overwrite attributes previously set to %s", val => {
+ const spanJSON = { attributes: { a: val, b: 2 } };
+
+ // @ts-expect-error - only passing a partial object for this test
+ safeSetSpanJSONAttributes(spanJSON, { a: 1 });
+
+ expect(spanJSON.attributes).toEqual({ a: val, b: 2 });
+ });
+
+ it("doesn't overwrite falsy attribute values (%s)", () => {
+ const spanJSON = { attributes: { a: false, b: '', c: 0 } };
+
+ // @ts-expect-error - only passing a partial object for this test
+ safeSetSpanJSONAttributes(spanJSON, { a: 1, b: 'test', c: 1 });
+
+ expect(spanJSON.attributes).toEqual({ a: false, b: '', c: 0 });
+ });
+
+ it('handles an undefined attributes property', () => {
+ const spanJSON: Partial = {};
+
+ // @ts-expect-error - only passing a partial object for this test
+ safeSetSpanJSONAttributes(spanJSON, { a: 1 });
+
+ expect(spanJSON.attributes).toEqual({ a: 1 });
+ });
+
+ it("doesn't apply undefined or null values to attributes", () => {
+ const spanJSON = { attributes: {} };
+
+ // @ts-expect-error - only passing a partial object for this test
+ safeSetSpanJSONAttributes(spanJSON, { a: undefined, b: null });
+
+ expect(spanJSON.attributes).toEqual({});
+ });
+});
diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts
new file mode 100644
index 000000000000..197b7ed40365
--- /dev/null
+++ b/packages/core/test/lib/tracing/spans/envelope.test.ts
@@ -0,0 +1,232 @@
+import { describe, expect, it } from 'vitest';
+import { createStreamedSpanEnvelope } from '../../../../src/tracing/spans/envelope';
+import type { DynamicSamplingContext } from '../../../../src/types-hoist/envelope';
+import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span';
+import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
+
+function createMockSerializedSpan(overrides: Partial = {}): SerializedStreamedSpan {
+ return {
+ trace_id: 'abc123',
+ span_id: 'def456',
+ name: 'test-span',
+ start_timestamp: 1713859200,
+ end_timestamp: 1713859201,
+ status: 'ok',
+ is_segment: false,
+ ...overrides,
+ };
+}
+
+describe('createStreamedSpanEnvelope', () => {
+ describe('envelope headers', () => {
+ it('creates an envelope with sent_at header', () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: Partial = {};
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).toHaveProperty('sent_at', expect.any(String));
+ });
+
+ it('includes trace header when DSC has required props (trace_id and public_key)', () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: DynamicSamplingContext = {
+ trace_id: 'trace-123',
+ public_key: 'public-key-abc',
+ sample_rate: '1.0',
+ release: 'v1.0.0',
+ };
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).toHaveProperty('trace', dsc);
+ });
+
+ it("does't include trace header when DSC is missing trace_id", () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: Partial = {
+ public_key: 'public-key-abc',
+ };
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).not.toHaveProperty('trace');
+ });
+
+ it("does't include trace header when DSC is missing public_key", () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: Partial = {
+ trace_id: 'trace-123',
+ };
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).not.toHaveProperty('trace');
+ });
+
+ it('includes SDK info when available in client options', () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(
+ getDefaultTestClientOptions({
+ _metadata: {
+ sdk: { name: 'sentry.javascript.browser', version: '8.0.0' },
+ },
+ }),
+ );
+ const dsc: Partial = {};
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).toHaveProperty('sdk', { name: 'sentry.javascript.browser', version: '8.0.0' });
+ });
+
+ it("does't include SDK info when not available", () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: Partial = {};
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).not.toHaveProperty('sdk');
+ });
+
+ it('includes DSN when tunnel and DSN are configured', () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://abc123@example.sentry.io/456',
+ tunnel: 'https://tunnel.example.com',
+ }),
+ );
+ const dsc: Partial = {};
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).toHaveProperty('dsn', 'https://abc123@example.sentry.io/456');
+ });
+
+ it("does't include DSN when tunnel is not configured", () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://abc123@example.sentry.io/456',
+ }),
+ );
+ const dsc: Partial = {};
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).not.toHaveProperty('dsn');
+ });
+
+ it("does't include DSN when DSN is not available", () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(
+ getDefaultTestClientOptions({
+ tunnel: 'https://tunnel.example.com',
+ }),
+ );
+ const dsc: Partial = {};
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).not.toHaveProperty('dsn');
+ });
+
+ it('includes all headers when all options are provided', () => {
+ const mockSpan = createMockSerializedSpan();
+ const mockClient = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://abc123@example.sentry.io/456',
+ tunnel: 'https://tunnel.example.com',
+ _metadata: {
+ sdk: { name: 'sentry.javascript.node', version: '10.38.0' },
+ },
+ }),
+ );
+ const dsc: DynamicSamplingContext = {
+ trace_id: 'trace-123',
+ public_key: 'public-key-abc',
+ environment: 'production',
+ };
+
+ const result = createStreamedSpanEnvelope([mockSpan], dsc, mockClient);
+
+ expect(result[0]).toEqual({
+ sent_at: expect.any(String),
+ trace: dsc,
+ sdk: { name: 'sentry.javascript.node', version: '10.38.0' },
+ dsn: 'https://abc123@example.sentry.io/456',
+ });
+ });
+ });
+
+ describe('envelope item', () => {
+ it('creates a span container item with correct structure', () => {
+ const mockSpan = createMockSerializedSpan({ name: 'span-1' });
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: Partial = {};
+
+ const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1];
+
+ expect(envelopeItems).toEqual([
+ [
+ {
+ content_type: 'application/vnd.sentry.items.span.v2+json',
+ item_count: 1,
+ type: 'span',
+ },
+ {
+ items: [mockSpan],
+ },
+ ],
+ ]);
+ });
+
+ it('sets correct item_count for multiple spans', () => {
+ const mockSpan1 = createMockSerializedSpan({ span_id: 'span-1' });
+ const mockSpan2 = createMockSerializedSpan({ span_id: 'span-2' });
+ const mockSpan3 = createMockSerializedSpan({ span_id: 'span-3' });
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: Partial = {};
+
+ const envelopeItems = createStreamedSpanEnvelope([mockSpan1, mockSpan2, mockSpan3], dsc, mockClient)[1];
+
+ expect(envelopeItems).toEqual([
+ [
+ { type: 'span', item_count: 3, content_type: 'application/vnd.sentry.items.span.v2+json' },
+ { items: [mockSpan1, mockSpan2, mockSpan3] },
+ ],
+ ]);
+ });
+
+ it('handles empty spans array', () => {
+ const mockClient = new TestClient(getDefaultTestClientOptions());
+ const dsc: Partial = {};
+
+ const result = createStreamedSpanEnvelope([], dsc, mockClient);
+
+ expect(result).toEqual([
+ {
+ sent_at: expect.any(String),
+ },
+ [
+ [
+ {
+ content_type: 'application/vnd.sentry.items.span.v2+json',
+ item_count: 0,
+ type: 'span',
+ },
+ {
+ items: [],
+ },
+ ],
+ ],
+ ]);
+ });
+ });
+});
diff --git a/packages/core/test/lib/tracing/spans/estimateSize.test.ts b/packages/core/test/lib/tracing/spans/estimateSize.test.ts
new file mode 100644
index 000000000000..35d569691dea
--- /dev/null
+++ b/packages/core/test/lib/tracing/spans/estimateSize.test.ts
@@ -0,0 +1,177 @@
+import { describe, expect, it } from 'vitest';
+import { estimateSerializedSpanSizeInBytes } from '../../../../src/tracing/spans/estimateSize';
+import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span';
+
+// Produces a realistic trace_id (32 hex chars) and span_id (16 hex chars)
+const TRACE_ID = 'a1b2c3d4e5f607189a0b1c2d3e4f5060';
+const SPAN_ID = 'a1b2c3d4e5f60718';
+
+describe('estimateSerializedSpanSizeInBytes', () => {
+ it('estimates a minimal span (no attributes, no links, no parent) within a reasonable range of JSON.stringify', () => {
+ const span: SerializedStreamedSpan = {
+ trace_id: TRACE_ID,
+ span_id: SPAN_ID,
+ name: 'GET /api/users',
+ start_timestamp: 1740000000.123,
+ end_timestamp: 1740000001.456,
+ status: 'ok',
+ is_segment: true,
+ };
+
+ const estimate = estimateSerializedSpanSizeInBytes(span);
+ const actual = JSON.stringify(span).length;
+
+ expect(estimate).toBe(184);
+ expect(actual).toBe(196);
+
+ expect(estimate).toBeLessThanOrEqual(actual * 1.2);
+ expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
+ });
+
+ it('estimates a span with a parent_span_id within a reasonable range', () => {
+ const span: SerializedStreamedSpan = {
+ trace_id: TRACE_ID,
+ span_id: SPAN_ID,
+ parent_span_id: 'b2c3d4e5f6071890',
+ name: 'db.query',
+ start_timestamp: 1740000000.0,
+ end_timestamp: 1740000000.05,
+ status: 'ok',
+ is_segment: false,
+ };
+
+ const estimate = estimateSerializedSpanSizeInBytes(span);
+ const actual = JSON.stringify(span).length;
+
+ expect(estimate).toBe(172);
+ expect(actual).toBe(222);
+
+ expect(estimate).toBeLessThanOrEqual(actual * 1.1);
+ expect(estimate).toBeGreaterThanOrEqual(actual * 0.7);
+ });
+
+ it('estimates a span with string attributes within a reasonable range', () => {
+ const span: SerializedStreamedSpan = {
+ trace_id: TRACE_ID,
+ span_id: SPAN_ID,
+ name: 'GET /api/users',
+ start_timestamp: 1740000000.0,
+ end_timestamp: 1740000000.1,
+ status: 'ok',
+ is_segment: false,
+ attributes: {
+ 'http.method': { type: 'string', value: 'GET' },
+ 'http.url': { type: 'string', value: 'https://example.com/api/users?page=1&limit=100' },
+ 'http.status_code': { type: 'integer', value: 200 },
+ 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = $1' },
+ 'sentry.origin': { type: 'string', value: 'auto.http.fetch' },
+ },
+ };
+
+ const estimate = estimateSerializedSpanSizeInBytes(span);
+ const actual = JSON.stringify(span).length;
+
+ expect(estimate).toBeLessThanOrEqual(actual * 1.2);
+ expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
+ });
+
+ it('estimates a span with numeric attributes within a reasonable range', () => {
+ const span: SerializedStreamedSpan = {
+ trace_id: TRACE_ID,
+ span_id: SPAN_ID,
+ name: 'process.task',
+ start_timestamp: 1740000000.0,
+ end_timestamp: 1740000005.0,
+ status: 'ok',
+ is_segment: false,
+ attributes: {
+ 'items.count': { type: 'integer', value: 42 },
+ 'duration.ms': { type: 'double', value: 5000.5 },
+ 'retry.count': { type: 'integer', value: 3 },
+ },
+ };
+
+ const estimate = estimateSerializedSpanSizeInBytes(span);
+ const actual = JSON.stringify(span).length;
+
+ expect(estimate).toBeLessThanOrEqual(actual * 1.2);
+ expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
+ });
+
+ it('estimates a span with boolean attributes within a reasonable range', () => {
+ const span: SerializedStreamedSpan = {
+ trace_id: TRACE_ID,
+ span_id: SPAN_ID,
+ name: 'cache.get',
+ start_timestamp: 1740000000.0,
+ end_timestamp: 1740000000.002,
+ status: 'ok',
+ is_segment: false,
+ attributes: {
+ 'cache.hit': { type: 'boolean', value: true },
+ 'cache.miss': { type: 'boolean', value: false },
+ },
+ };
+
+ const estimate = estimateSerializedSpanSizeInBytes(span);
+ const actual = JSON.stringify(span).length;
+
+ expect(estimate).toBeLessThanOrEqual(actual * 1.2);
+ expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
+ });
+
+ it('estimates a span with array attributes within a reasonable range', () => {
+ const span: SerializedStreamedSpan = {
+ trace_id: TRACE_ID,
+ span_id: SPAN_ID,
+ name: 'batch.process',
+ start_timestamp: 1740000000.0,
+ end_timestamp: 1740000002.0,
+ status: 'ok',
+ is_segment: false,
+ attributes: {
+ 'item.ids': { type: 'string[]', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] },
+ scores: { type: 'double[]', value: [1.1, 2.2, 3.3, 4.4] },
+ flags: { type: 'boolean[]', value: [true, false, true] },
+ },
+ };
+
+ const estimate = estimateSerializedSpanSizeInBytes(span);
+ const actual = JSON.stringify(span).length;
+
+ expect(estimate).toBeLessThanOrEqual(actual * 1.2);
+ expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
+ });
+
+ it('estimates a span with links within a reasonable range', () => {
+ const span: SerializedStreamedSpan = {
+ trace_id: TRACE_ID,
+ span_id: SPAN_ID,
+ name: 'linked.operation',
+ start_timestamp: 1740000000.0,
+ end_timestamp: 1740000001.0,
+ status: 'ok',
+ is_segment: true,
+ links: [
+ {
+ trace_id: 'b2c3d4e5f607189a0b1c2d3e4f506070',
+ span_id: 'c3d4e5f607189a0b',
+ sampled: true,
+ attributes: {
+ 'sentry.link.type': { type: 'string', value: 'previous_trace' },
+ },
+ },
+ {
+ trace_id: 'c3d4e5f607189a0b1c2d3e4f50607080',
+ span_id: 'd4e5f607189a0b1c',
+ },
+ ],
+ };
+
+ const estimate = estimateSerializedSpanSizeInBytes(span);
+ const actual = JSON.stringify(span).length;
+
+ expect(estimate).toBeLessThanOrEqual(actual * 1.2);
+ expect(estimate).toBeGreaterThanOrEqual(actual * 0.8);
+ });
+});
diff --git a/packages/core/test/lib/tracing/spans/spanBuffer.test.ts b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts
new file mode 100644
index 000000000000..cbcb1bf7ea59
--- /dev/null
+++ b/packages/core/test/lib/tracing/spans/spanBuffer.test.ts
@@ -0,0 +1,359 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Client, StreamedSpanEnvelope } from '../../../../src';
+import { SentrySpan, setCurrentClient, SpanBuffer } from '../../../../src';
+import type { SerializedStreamedSpanWithSegmentSpan } from '../../../../src/tracing/spans/captureSpan';
+import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
+
+describe('SpanBuffer', () => {
+ let client: TestClient;
+ let sendEnvelopeSpy: ReturnType;
+
+ let sentEnvelopes: Array = [];
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ sentEnvelopes = [];
+ sendEnvelopeSpy = vi.fn().mockImplementation(e => {
+ sentEnvelopes.push(e);
+ return Promise.resolve();
+ });
+
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://username@domain/123',
+ tracesSampleRate: 1.0,
+ }),
+ );
+ client.sendEnvelope = sendEnvelopeSpy;
+ client.init();
+ setCurrentClient(client as Client);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('flushes all traces on drain()', () => {
+ const buffer = new SpanBuffer(client);
+
+ const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace123' });
+ const segmentSpan2 = new SentrySpan({ name: 'segment', sampled: true, traceId: 'trace456' });
+
+ buffer.add({
+ trace_id: 'trace123',
+ span_id: 'span1',
+ name: 'test span',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan1,
+ });
+
+ buffer.add({
+ trace_id: 'trace456',
+ span_id: 'span2',
+ name: 'test span',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan2,
+ });
+
+ buffer.drain();
+
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2);
+ expect(sentEnvelopes).toHaveLength(2);
+ expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace123');
+ expect(sentEnvelopes[1]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace456');
+ });
+
+ it('flushes trace after per-trace timeout', () => {
+ const buffer = new SpanBuffer(client, { flushInterval: 1000 });
+
+ const segmentSpan1 = new SentrySpan({ name: 'segment', sampled: true });
+ const span1 = {
+ trace_id: 'trace123',
+ span_id: 'span1',
+ name: 'test span',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan1,
+ };
+
+ const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true });
+ const span2 = {
+ trace_id: 'trace123',
+ span_id: 'span2',
+ name: 'test span',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan2,
+ };
+
+ buffer.add(span1 as SerializedStreamedSpanWithSegmentSpan);
+ buffer.add(span2 as SerializedStreamedSpanWithSegmentSpan);
+
+ expect(sendEnvelopeSpy).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(1000);
+
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+
+ // the trace bucket was removed after flushing, so no timeout remains and no further sends occur
+ vi.advanceTimersByTime(1000);
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('flushes when maxSpanLimit is reached', () => {
+ const buffer = new SpanBuffer(client, { maxSpanLimit: 2 });
+
+ const segmentSpan = new SentrySpan({ name: 'segment', sampled: true });
+
+ buffer.add({
+ trace_id: 'trace123',
+ span_id: 'span1',
+ name: 'test span 1',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan,
+ });
+
+ expect(sendEnvelopeSpy).not.toHaveBeenCalled();
+
+ buffer.add({
+ trace_id: 'trace123',
+ span_id: 'span2',
+ name: 'test span 2',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan,
+ });
+
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+
+ buffer.add({
+ trace_id: 'trace123',
+ span_id: 'span3',
+ name: 'test span 3',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan,
+ });
+
+ // we added another span after flushing but neither limit nor time interval should have been reached
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+
+ // draining will flush out the remaining span
+ buffer.drain();
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('flushes on client flush event', () => {
+ const buffer = new SpanBuffer(client);
+
+ const segmentSpan = new SentrySpan({ name: 'segment', sampled: true });
+
+ buffer.add({
+ trace_id: 'trace123',
+ span_id: 'span1',
+ name: 'test span',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan,
+ });
+
+ expect(sendEnvelopeSpy).not.toHaveBeenCalled();
+
+ client.emit('flush');
+
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('groups spans by traceId', () => {
+ const buffer = new SpanBuffer(client);
+
+ const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true });
+ const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true });
+
+ buffer.add({
+ trace_id: 'trace1',
+ span_id: 'span1',
+ name: 'test span 1',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan1,
+ });
+
+ buffer.add({
+ trace_id: 'trace2',
+ span_id: 'span2',
+ name: 'test span 2',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan2,
+ });
+
+ buffer.drain();
+
+ // Should send 2 envelopes, one for each trace
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('flushes a specific trace on flush(traceId)', () => {
+ const buffer = new SpanBuffer(client);
+
+ const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true });
+ const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true });
+
+ buffer.add({
+ trace_id: 'trace1',
+ span_id: 'span1',
+ name: 'test span 1',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan1,
+ });
+
+ buffer.add({
+ trace_id: 'trace2',
+ span_id: 'span2',
+ name: 'test span 2',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan2,
+ });
+
+ buffer.flush('trace1');
+
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ expect(sentEnvelopes[0]?.[1]?.[0]?.[1]?.items[0]?.trace_id).toBe('trace1');
+ });
+
+ it('handles flushing a non-existing trace', () => {
+ const buffer = new SpanBuffer(client);
+
+ buffer.flush('trace1');
+
+ expect(sendEnvelopeSpy).not.toHaveBeenCalled();
+ });
+
+ describe('weight-based flushing', () => {
+ function makeSpan(
+ traceId: string,
+ spanId: string,
+ segmentSpan: InstanceType,
+ overrides: Partial = {},
+ ): SerializedStreamedSpanWithSegmentSpan {
+ return {
+ trace_id: traceId,
+ span_id: spanId,
+ name: 'test span',
+ start_timestamp: Date.now() / 1000,
+ end_timestamp: Date.now() / 1000,
+ status: 'ok',
+ is_segment: false,
+ _segmentSpan: segmentSpan,
+ ...overrides,
+ };
+ }
+
+ it('flushes a trace when its weight limit is exceeded', () => {
+ // Use a very small weight threshold so a single span with attributes tips it over
+ const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 });
+ const segmentSpan = new SentrySpan({ name: 'segment', sampled: true });
+
+ // First span: small, under threshold
+ buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a' }));
+ expect(sendEnvelopeSpy).not.toHaveBeenCalled();
+
+ // Second span: has a large name that pushes it over 200 bytes
+ buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'a'.repeat(80) }));
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not flush when weight stays below the threshold', () => {
+ const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 10_000 });
+ const segmentSpan = new SentrySpan({ name: 'segment', sampled: true });
+
+ buffer.add(makeSpan('trace1', 'span1', segmentSpan));
+ buffer.add(makeSpan('trace1', 'span2', segmentSpan));
+
+ expect(sendEnvelopeSpy).not.toHaveBeenCalled();
+ });
+
+ it('resets weight tracking after a weight-triggered flush so new spans accumulate fresh weight', () => {
+ // Base estimate per span is 152 bytes. With threshold 400:
+ // - big span ('a' * 200): 152 + 200*2 = 552 bytes → exceeds 400, triggers flush
+ // - small span (name 'b'): 152 + 1*2 = 154 bytes
+ // - two small spans combined: 308 bytes < 400 → no second flush
+ const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 400 });
+ const segmentSpan = new SentrySpan({ name: 'segment', sampled: true });
+
+ buffer.add(makeSpan('trace1', 'span1', segmentSpan, { name: 'a'.repeat(200) }));
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+
+ buffer.add(makeSpan('trace1', 'span2', segmentSpan, { name: 'b' }));
+ buffer.add(makeSpan('trace1', 'span3', segmentSpan, { name: 'c' }));
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks weight independently per trace', () => {
+ const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 200 });
+ const segmentSpan1 = new SentrySpan({ name: 'segment1', sampled: true });
+ const segmentSpan2 = new SentrySpan({ name: 'segment2', sampled: true });
+
+ // trace1 gets a heavy span that exceeds the limit
+ buffer.add(makeSpan('trace1', 'span1', segmentSpan1, { name: 'a'.repeat(80) }));
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ expect((sentEnvelopes[0]?.[1]?.[0]?.[1] as { items: Array<{ trace_id: string }> })?.items[0]?.trace_id).toBe(
+ 'trace1',
+ );
+
+ // trace2 only has a small span and should not be flushed
+ buffer.add(makeSpan('trace2', 'span2', segmentSpan2, { name: 'b' }));
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('estimates spans with attributes as heavier than bare spans', () => {
+ // Use a threshold that a bare span cannot reach but an attributed span can
+ const buffer = new SpanBuffer(client, { maxTraceWeightInBytes: 300 });
+ const segmentSpan = new SentrySpan({ name: 'segment', sampled: true });
+
+ // A span with many string attributes should tip it over
+ buffer.add(
+ makeSpan('trace1', 'span1', segmentSpan, {
+ attributes: {
+ 'http.method': { type: 'string', value: 'GET' },
+ 'http.url': { type: 'string', value: 'https://example.com/api/v1/users?page=1&limit=100' },
+ 'db.statement': { type: 'string', value: 'SELECT * FROM users WHERE id = 1' },
+ },
+ }),
+ );
+
+ expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts
index bca9a406dd50..e4a0b31990d7 100644
--- a/packages/core/test/lib/utils/spanUtils.test.ts
+++ b/packages/core/test/lib/utils/spanUtils.test.ts
@@ -4,6 +4,7 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE,
SentrySpan,
setCurrentClient,
SPAN_STATUS_ERROR,
@@ -16,7 +17,7 @@ import {
TRACEPARENT_REGEXP,
} from '../../../src';
import type { SpanLink } from '../../../src/types-hoist/link';
-import type { Span, SpanAttributes, SpanTimeInput } from '../../../src/types-hoist/span';
+import type { Span, SpanAttributes, SpanTimeInput, StreamedSpanJSON } from '../../../src/types-hoist/span';
import type { SpanStatus } from '../../../src/types-hoist/spanStatus';
import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils';
import {
@@ -24,7 +25,9 @@ import {
spanIsSampled,
spanTimeInputToSeconds,
spanToJSON,
+ spanToStreamedSpanJSON,
spanToTraceContext,
+ streamedSpanJsonToSerializedSpan,
TRACE_FLAG_NONE,
TRACE_FLAG_SAMPLED,
updateSpanName,
@@ -41,6 +44,7 @@ function createMockedOtelSpan({
status = { code: SPAN_STATUS_UNSET },
endTime = Date.now(),
parentSpanId,
+ links = undefined,
}: {
spanId: string;
traceId: string;
@@ -51,6 +55,7 @@ function createMockedOtelSpan({
status?: SpanStatus;
endTime?: SpanTimeInput;
parentSpanId?: string;
+ links?: SpanLink[];
}): Span {
return {
spanContext: () => {
@@ -66,6 +71,7 @@ function createMockedOtelSpan({
status,
endTime,
parentSpanId,
+ links,
} as OpenTelemetrySdkTraceBaseSpan;
}
@@ -409,6 +415,233 @@ describe('spanToJSON', () => {
});
});
+ describe('spanToStreamedSpanJSON', () => {
+ describe('SentrySpan', () => {
+ it('converts a minimal span', () => {
+ const span = new SentrySpan();
+ expect(spanToStreamedSpanJSON(span)).toEqual({
+ span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
+ trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
+ name: '',
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual',
+ },
+ });
+ });
+
+ it('converts a full span', () => {
+ const span = new SentrySpan({
+ op: 'test op',
+ name: 'test name',
+ parentSpanId: '1234',
+ spanId: '5678',
+ traceId: 'abcd',
+ startTimestamp: 123,
+ endTimestamp: 456,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto',
+ attr1: 'value1',
+ attr2: 2,
+ attr3: true,
+ },
+ links: [
+ {
+ context: {
+ spanId: 'span1',
+ traceId: 'trace1',
+ traceFlags: TRACE_FLAG_SAMPLED,
+ },
+ attributes: {
+ 'sentry.link.type': 'previous_trace',
+ },
+ },
+ ],
+ });
+ span.setStatus({ code: SPAN_STATUS_OK });
+ span.setAttribute('attr4', [1, 2, 3]);
+
+ expect(spanToStreamedSpanJSON(span)).toEqual({
+ name: 'test name',
+ parent_span_id: '1234',
+ span_id: '5678',
+ trace_id: 'abcd',
+ start_timestamp: 123,
+ end_timestamp: 456,
+ status: 'ok',
+ is_segment: true,
+ attributes: {
+ attr1: 'value1',
+ attr2: 2,
+ attr3: true,
+ attr4: [1, 2, 3],
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto',
+ },
+ links: [
+ {
+ span_id: 'span1',
+ trace_id: 'trace1',
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
+ },
+ },
+ ],
+ });
+ });
+ });
+ describe('OpenTelemetry Span', () => {
+ it('converts a simple span', () => {
+ const span = createMockedOtelSpan({
+ spanId: 'SPAN-1',
+ traceId: 'TRACE-1',
+ name: 'test span',
+ startTime: 123,
+ endTime: [0, 0],
+ attributes: {},
+ status: { code: SPAN_STATUS_UNSET },
+ });
+
+ expect(spanToStreamedSpanJSON(span)).toEqual({
+ span_id: 'SPAN-1',
+ trace_id: 'TRACE-1',
+ parent_span_id: undefined,
+ start_timestamp: 123,
+ end_timestamp: 0,
+ name: 'test span',
+ is_segment: true,
+ status: 'ok',
+ attributes: {},
+ });
+ });
+
+ it('converts a full span', () => {
+ const span = createMockedOtelSpan({
+ spanId: 'SPAN-1',
+ traceId: 'TRACE-1',
+ parentSpanId: 'PARENT-1',
+ name: 'test span',
+ startTime: 123,
+ endTime: 456,
+ attributes: {
+ attr1: 'value1',
+ attr2: 2,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto',
+ },
+ links: [
+ {
+ context: {
+ spanId: 'span1',
+ traceId: 'trace1',
+ traceFlags: TRACE_FLAG_SAMPLED,
+ },
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
+ },
+ },
+ ],
+ status: { code: SPAN_STATUS_ERROR, message: 'unknown_error' },
+ });
+
+ expect(spanToStreamedSpanJSON(span)).toEqual({
+ span_id: 'SPAN-1',
+ trace_id: 'TRACE-1',
+ parent_span_id: 'PARENT-1',
+ start_timestamp: 123,
+ end_timestamp: 456,
+ name: 'test span',
+ is_segment: true,
+ status: 'error',
+ attributes: {
+ attr1: 'value1',
+ attr2: 2,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto',
+ },
+ links: [
+ {
+ span_id: 'span1',
+ trace_id: 'trace1',
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
+ },
+ },
+ ],
+ });
+ });
+ });
+ });
+
+ describe('streamedSpanJsonToSerializedSpan', () => {
+ it('converts a streamed span JSON with links to a serialized span', () => {
+ const spanJson: StreamedSpanJSON = {
+ name: 'test name',
+ parent_span_id: '1234',
+ span_id: '5678',
+ trace_id: 'abcd',
+ start_timestamp: 123,
+ end_timestamp: 456,
+ status: 'ok',
+ is_segment: true,
+ attributes: {
+ attr1: 'value1',
+ attr2: 2,
+ attr3: true,
+ attr4: [1, 2, 3],
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto',
+ },
+ links: [
+ {
+ span_id: 'span1',
+ trace_id: 'trace1',
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
+ },
+ },
+ ],
+ };
+
+ expect(streamedSpanJsonToSerializedSpan(spanJson)).toEqual({
+ name: 'test name',
+ parent_span_id: '1234',
+ span_id: '5678',
+ trace_id: 'abcd',
+ start_timestamp: 123,
+ end_timestamp: 456,
+ status: 'ok',
+ is_segment: true,
+ attributes: {
+ attr1: { type: 'string', value: 'value1' },
+ attr2: { type: 'integer', value: 2 },
+ attr3: { type: 'boolean', value: true },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test op' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto' },
+ // notice the absence of `attr4`!
+ // for now, we don't yet serialize array attributes. This test will fail
+ // once we allow serializing them.
+ },
+ links: [
+ {
+ span_id: 'span1',
+ trace_id: 'trace1',
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { type: 'string', value: 'previous_trace' },
+ },
+ },
+ ],
+ });
+ });
+ });
+
it('returns minimal object for unknown span implementation', () => {
const span = {
// This is the minimal interface we require from a span
diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts
index 9478b98f5a58..a881f911e31c 100644
--- a/packages/google-cloud-serverless/src/index.ts
+++ b/packages/google-cloud-serverless/src/index.ts
@@ -157,6 +157,7 @@ export {
statsigIntegration,
unleashIntegration,
metrics,
+ spanStreamingIntegration,
} from '@sentry/node';
export {
diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts
index 7c92fecd7834..1ac235711ca5 100644
--- a/packages/nextjs/src/index.types.ts
+++ b/packages/nextjs/src/index.types.ts
@@ -23,6 +23,7 @@ export declare function init(
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
// Different implementation in server and worker
export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration;
diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts
index 3fff4100b352..4877fc159e88 100644
--- a/packages/node-core/src/common-exports.ts
+++ b/packages/node-core/src/common-exports.ts
@@ -115,6 +115,7 @@ export {
consoleIntegration,
wrapMcpServerWithSentry,
featureFlagsIntegration,
+ spanStreamingIntegration,
metrics,
envToBool,
} from '@sentry/core';
diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts
index ef9229f28de6..77b62e9ab2f9 100644
--- a/packages/node-core/src/light/sdk.ts
+++ b/packages/node-core/src/light/sdk.ts
@@ -12,6 +12,7 @@ import {
linkedErrorsIntegration,
propagationContextFromHeaders,
requestDataIntegration,
+ spanStreamingIntegration,
stackParserFromStackParserOptions,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
@@ -162,12 +163,18 @@ function getClientOptions(
const integrations = options.integrations;
const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions);
+ const resolvedIntegrations = getIntegrationsToSetup({
+ defaultIntegrations,
+ integrations,
+ });
+
+ if (mergedOptions.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) {
+ resolvedIntegrations.push(spanStreamingIntegration());
+ }
+
return {
...mergedOptions,
- integrations: getIntegrationsToSetup({
- defaultIntegrations,
- integrations,
- }),
+ integrations: resolvedIntegrations,
};
}
diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts
index de1569135cfd..5ae840e6e976 100644
--- a/packages/node-core/src/sdk/index.ts
+++ b/packages/node-core/src/sdk/index.ts
@@ -14,6 +14,7 @@ import {
linkedErrorsIntegration,
propagationContextFromHeaders,
requestDataIntegration,
+ spanStreamingIntegration,
stackParserFromStackParserOptions,
} from '@sentry/core';
import {
@@ -212,12 +213,18 @@ function getClientOptions(
const integrations = options.integrations;
const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions);
+ const resolvedIntegrations = getIntegrationsToSetup({
+ defaultIntegrations,
+ integrations,
+ });
+
+ if (mergedOptions.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) {
+ resolvedIntegrations.push(spanStreamingIntegration());
+ }
+
return {
...mergedOptions,
- integrations: getIntegrationsToSetup({
- defaultIntegrations,
- integrations,
- }),
+ integrations: resolvedIntegrations,
};
}
diff --git a/packages/node-core/test/light/sdk.test.ts b/packages/node-core/test/light/sdk.test.ts
index 8b0ef03700d9..48cac52022b0 100644
--- a/packages/node-core/test/light/sdk.test.ts
+++ b/packages/node-core/test/light/sdk.test.ts
@@ -106,6 +106,39 @@ describe('Light Mode | SDK', () => {
expect(integrationNames).toContain('NodeFetch');
});
+
+ it('does not include spanStreaming integration', () => {
+ const integrations = Sentry.getDefaultIntegrations({ traceLifecycle: 'stream' });
+ const integrationNames = integrations.map(i => i.name);
+
+ expect(integrationNames).not.toContain('SpanStreaming');
+ });
+ });
+
+ describe('spanStreamingIntegration', () => {
+ it('installs spanStreaming integration when traceLifecycle is "stream"', () => {
+ const client = mockLightSdkInit({ traceLifecycle: 'stream' });
+ const integrationNames = client?.getOptions().integrations.map(i => i.name);
+
+ expect(integrationNames).toContain('SpanStreaming');
+ });
+
+ it('does not install spanStreaming integration when traceLifecycle is not "stream"', () => {
+ const client = mockLightSdkInit();
+ const integrationNames = client?.getOptions().integrations.map(i => i.name);
+
+ expect(integrationNames).not.toContain('SpanStreaming');
+ });
+
+ it('installs spanStreaming integration even with custom defaultIntegrations', () => {
+ const client = mockLightSdkInit({
+ traceLifecycle: 'stream',
+ defaultIntegrations: [],
+ });
+ const integrationNames = client?.getOptions().integrations.map(i => i.name);
+
+ expect(integrationNames).toContain('SpanStreaming');
+ });
});
describe('isInitialized', () => {
diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts
index 144ff3e2dc37..6ee986c3be75 100644
--- a/packages/node-core/test/sdk/init.test.ts
+++ b/packages/node-core/test/sdk/init.test.ts
@@ -81,6 +81,39 @@ describe('init()', () => {
expect(mockIntegrations[1]?.setupOnce as Mock).toHaveBeenCalledTimes(1);
});
+ it('installs spanStreaming integration when traceLifecycle is "stream"', () => {
+ init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream' });
+ const client = getClient();
+
+ expect(client?.getOptions()).toEqual(
+ expect.objectContaining({
+ integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
+ }),
+ );
+ });
+
+ it("doesn't install spanStreaming integration when traceLifecycle is not 'stream'", () => {
+ init({ dsn: PUBLIC_DSN });
+ const client = getClient();
+
+ expect(client?.getOptions()).toEqual(
+ expect.objectContaining({
+ integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
+ }),
+ );
+ });
+
+ it('installs spanStreaming integration even with custom defaultIntegrations', () => {
+ init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', defaultIntegrations: [] });
+ const client = getClient();
+
+ expect(client?.getOptions()).toEqual(
+ expect.objectContaining({
+ integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
+ }),
+ );
+ });
+
it('installs integrations returned from a callback function', () => {
const mockDefaultIntegrations = [
new MockIntegration('Some mock integration 3.1'),
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index 8458dee5f6a7..9e1e892ed7ce 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -140,6 +140,7 @@ export {
consoleIntegration,
wrapMcpServerWithSentry,
featureFlagsIntegration,
+ spanStreamingIntegration,
createLangChainCallbackHandler,
instrumentLangGraph,
instrumentStateGraphCompile,
diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts
index a6a76a4439bd..26fe2d9933e6 100644
--- a/packages/node/test/sdk/init.test.ts
+++ b/packages/node/test/sdk/init.test.ts
@@ -143,6 +143,39 @@ describe('init()', () => {
}),
);
});
+
+ it('installs spanStreaming integration when traceLifecycle is "stream"', () => {
+ init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream' });
+ const client = getClient();
+
+ expect(client?.getOptions()).toEqual(
+ expect.objectContaining({
+ integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
+ }),
+ );
+ });
+
+ it("doesn't install spanStreaming integration when traceLifecycle is not 'stream'", () => {
+ init({ dsn: PUBLIC_DSN });
+
+ const client = getClient();
+ expect(client?.getOptions()).toEqual(
+ expect.objectContaining({
+ integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
+ }),
+ );
+ });
+
+ it('installs spanStreaming integration even with custom defaultIntegrations', () => {
+ init({ dsn: PUBLIC_DSN, traceLifecycle: 'stream', defaultIntegrations: [] });
+ const client = getClient();
+
+ expect(client?.getOptions()).toEqual(
+ expect.objectContaining({
+ integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
+ }),
+ );
+ });
});
describe('OpenTelemetry', () => {
diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts
index 7109e7ad9c78..9a0283cfcee8 100644
--- a/packages/nuxt/src/index.types.ts
+++ b/packages/nuxt/src/index.types.ts
@@ -16,6 +16,7 @@ export * from './index.server';
export declare function init(options: Options | SentryNuxtClientOptions | SentryNuxtServerOptions): Client | undefined;
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
export declare const getDefaultIntegrations: (options: Options) => Integration[];
export declare const defaultStackParser: StackParser;
diff --git a/packages/opentelemetry/src/spanProcessor.ts b/packages/opentelemetry/src/spanProcessor.ts
index 3430456caaee..b4b913c5535e 100644
--- a/packages/opentelemetry/src/spanProcessor.ts
+++ b/packages/opentelemetry/src/spanProcessor.ts
@@ -6,6 +6,7 @@ import {
getClient,
getDefaultCurrentScope,
getDefaultIsolationScope,
+ hasSpanStreamingEnabled,
logSpanEnd,
logSpanStart,
setCapturedScopesOnSpan,
@@ -14,50 +15,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes
import { SentrySpanExporter } from './spanExporter';
import { getScopesFromContext } from './utils/contextData';
import { setIsSetup } from './utils/setupCheck';
-
-function onSpanStart(span: Span, parentContext: Context): void {
- // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK
- const parentSpan = trace.getSpan(parentContext);
-
- let scopes = getScopesFromContext(parentContext);
-
- // We need access to the parent span in order to be able to move up the span tree for breadcrumbs
- if (parentSpan && !parentSpan.spanContext().isRemote) {
- addChildSpanToSpan(parentSpan, span);
- }
-
- // We need this in the span exporter
- if (parentSpan?.spanContext().isRemote) {
- span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE, true);
- }
-
- // The root context does not have scopes stored, so we check for this specifically
- // As fallback we attach the global scopes
- if (parentContext === ROOT_CONTEXT) {
- scopes = {
- scope: getDefaultCurrentScope(),
- isolationScope: getDefaultIsolationScope(),
- };
- }
-
- // We need the scope at time of span creation in order to apply it to the event when the span is finished
- if (scopes) {
- setCapturedScopesOnSpan(span, scopes.scope, scopes.isolationScope);
- }
-
- logSpanStart(span);
-
- const client = getClient();
- client?.emit('spanStart', span);
-}
-
-function onSpanEnd(span: Span): void {
- logSpanEnd(span);
-
- const client = getClient();
- client?.emit('spanEnd', span);
-}
-
/**
* Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via
* the Sentry SDK.
@@ -88,13 +45,52 @@ export class SentrySpanProcessor implements SpanProcessorInterface {
* @inheritDoc
*/
public onStart(span: Span, parentContext: Context): void {
- onSpanStart(span, parentContext);
+ // This is a reliable way to get the parent span - because this is exactly how the parent is identified in the OTEL SDK
+ const parentSpan = trace.getSpan(parentContext);
+
+ let scopes = getScopesFromContext(parentContext);
+
+ // We need access to the parent span in order to be able to move up the span tree for breadcrumbs
+ if (parentSpan && !parentSpan.spanContext().isRemote) {
+ addChildSpanToSpan(parentSpan, span);
+ }
+
+ // We need this in the span exporter
+ if (parentSpan?.spanContext().isRemote) {
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE, true);
+ }
+
+ // The root context does not have scopes stored, so we check for this specifically
+ // As fallback we attach the global scopes
+ if (parentContext === ROOT_CONTEXT) {
+ scopes = {
+ scope: getDefaultCurrentScope(),
+ isolationScope: getDefaultIsolationScope(),
+ };
+ }
+
+ // We need the scope at time of span creation in order to apply it to the event when the span is finished
+ if (scopes) {
+ setCapturedScopesOnSpan(span, scopes.scope, scopes.isolationScope);
+ }
+
+ logSpanStart(span);
+
+ const client = getClient();
+ client?.emit('spanStart', span);
}
/** @inheritDoc */
public onEnd(span: Span & ReadableSpan): void {
- onSpanEnd(span);
+ logSpanEnd(span);
+
+ const client = getClient();
+ client?.emit('spanEnd', span);
- this._exporter.export(span);
+ if (client && hasSpanStreamingEnabled(client)) {
+ client.emit('afterSpanEnd', span);
+ } else {
+ this._exporter.export(span);
+ }
}
}
diff --git a/packages/opentelemetry/test/spanProcessor.test.ts b/packages/opentelemetry/test/spanProcessor.test.ts
new file mode 100644
index 000000000000..2e3b0b5b999e
--- /dev/null
+++ b/packages/opentelemetry/test/spanProcessor.test.ts
@@ -0,0 +1,57 @@
+import { getClient, startInactiveSpan } from '@sentry/core';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SentrySpanExporter } from '../src/spanExporter';
+import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit';
+
+const exportSpy = vi.spyOn(SentrySpanExporter.prototype, 'export');
+
+describe('SentrySpanProcessor', () => {
+ beforeEach(() => {
+ exportSpy.mockClear();
+ });
+
+ describe('with traceLifecycle: static (default)', () => {
+ beforeEach(() => {
+ mockSdkInit({ tracesSampleRate: 1 });
+ });
+
+ afterEach(async () => {
+ await cleanupOtel();
+ });
+
+ it('exports spans via the exporter', () => {
+ const span = startInactiveSpan({ name: 'test' });
+ span.end();
+
+ expect(exportSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('with traceLifecycle: stream', () => {
+ beforeEach(() => {
+ mockSdkInit({ tracesSampleRate: 1, traceLifecycle: 'stream' });
+ });
+
+ afterEach(async () => {
+ await cleanupOtel();
+ });
+
+ it('does not export spans via the exporter', () => {
+ const span = startInactiveSpan({ name: 'test' });
+ span.end();
+
+ expect(exportSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits afterSpanEnd', () => {
+ const afterSpanEndCallback = vi.fn();
+ const client = getClient()!;
+ client.on('afterSpanEnd', afterSpanEndCallback);
+
+ const span = startInactiveSpan({ name: 'test' });
+ span.end();
+
+ expect(afterSpanEndCallback).toHaveBeenCalledWith(span);
+ });
+ });
+});
diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts
index c9c5cb371763..4d0abcb6b0a8 100644
--- a/packages/react-router/src/index.types.ts
+++ b/packages/react-router/src/index.types.ts
@@ -16,6 +16,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
export declare const defaultStackParser: StackParser;
export declare const getDefaultIntegrations: (options: Options) => Integration[];
diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts
index 61d4f7e0b9bb..1cac41c1aacf 100644
--- a/packages/remix/src/index.types.ts
+++ b/packages/remix/src/index.types.ts
@@ -18,6 +18,7 @@ export declare function init(options: RemixOptions): Client | undefined;
export declare const browserTracingIntegration: typeof clientSdk.browserTracingIntegration;
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
export declare const getDefaultIntegrations: (options: Options) => Integration[];
export declare const defaultStackParser: StackParser;
diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts
index 1533a1ca7221..eccafe08a637 100644
--- a/packages/remix/src/server/index.ts
+++ b/packages/remix/src/server/index.ts
@@ -131,6 +131,7 @@ export {
consoleLoggingIntegration,
createConsolaReporter,
createSentryWinstonTransport,
+ spanStreamingIntegration,
} from '@sentry/node';
// Keeping the `*` exports for backwards compatibility and types
diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts
index 4c5ff491c740..58e642ecba22 100644
--- a/packages/solidstart/src/index.types.ts
+++ b/packages/solidstart/src/index.types.ts
@@ -18,6 +18,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
export declare const getDefaultIntegrations: (options: Options) => Integration[];
export declare const defaultStackParser: StackParser;
diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts
index 6e2bc1cb9f61..ffc73956a530 100644
--- a/packages/solidstart/src/server/index.ts
+++ b/packages/solidstart/src/server/index.ts
@@ -130,6 +130,7 @@ export {
consoleLoggingIntegration,
createConsolaReporter,
createSentryWinstonTransport,
+ spanStreamingIntegration,
} from '@sentry/node';
// We can still leave this for the carrier init and type exports
diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts
index d46e88e720ed..78cb27e2e47d 100644
--- a/packages/sveltekit/src/index.types.ts
+++ b/packages/sveltekit/src/index.types.ts
@@ -47,6 +47,7 @@ export declare function wrapLoadWithSentry any>(orig
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
// Different implementation in server and worker
export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration;
diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts
index d42975ef7876..8db039bb4369 100644
--- a/packages/sveltekit/src/server/index.ts
+++ b/packages/sveltekit/src/server/index.ts
@@ -135,6 +135,7 @@ export {
createSentryWinstonTransport,
vercelAIIntegration,
metrics,
+ spanStreamingIntegration,
} from '@sentry/node';
// We can still leave this for the carrier init and type exports
diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts
index 7ab05bfc3831..0545fbf5c87b 100644
--- a/packages/tanstackstart-react/src/index.types.ts
+++ b/packages/tanstackstart-react/src/index.types.ts
@@ -18,6 +18,7 @@ export declare function init(options: Options | clientSdk.BrowserOptions | serve
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
export declare const getDefaultIntegrations: (options: Options) => Integration[];
export declare const defaultStackParser: StackParser;