diff --git a/.size-limit.js b/.size-limit.js
index fcc455808948..1e6e8d951464 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.1 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: '134 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: '57 KB',
+ limit: '59 KB',
},
// Node SDK (ESM)
{
@@ -326,14 +326,14 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
- limit: '176 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: '117 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..ea50f09f2361
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts
@@ -0,0 +1,99 @@
+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: 'span',
+ quantity: expect.any(Number),
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ // exact number depends on performance observer emissions
+ expect(clientReport.discarded_events[0].quantity).toBeGreaterThanOrEqual(10);
+ });
+
+ 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..367b48e70eda
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts
@@ -0,0 +1,118 @@
+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: 'span',
+ quantity: expect.any(Number),
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ // exact number depends on performance observer emissions
+ expect(clientReport.discarded_events[0].quantity).toBeGreaterThanOrEqual(3);
+ });
+
+ 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..d661a4548e94
--- /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: 'span',
+ 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: 'span',
+ 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/ignoreSpans-streamed/child/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js
new file mode 100644
index 000000000000..086383ccdd54
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/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.spanStreamingIntegration()],
+ ignoreSpans: [/ignore/, { op: 'ignored-op' }],
+ parentSpanIsAlwaysRootSpan: false,
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js
new file mode 100644
index 000000000000..47c336e973cd
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js
@@ -0,0 +1,31 @@
+Sentry.startSpan({ name: 'parent-span' }, () => {
+ Sentry.startSpan({ name: 'keep-me' }, () => {});
+
+ // This child matches ignoreSpans —> dropped
+ Sentry.startSpan({ name: 'ignore-child' }, () => {
+ // dropped
+ Sentry.startSpan({ name: 'ignore-grandchild-1' }, () => {
+ // kept
+ Sentry.startSpan({ name: 'great-grandchild-1' }, () => {
+ // dropped
+ Sentry.startSpan({ name: 'ignore-great-great-grandchild-1' }, () => {
+ // kept
+ Sentry.startSpan({ name: 'great-great-great-grandchild-1' }, () => {});
+ });
+ });
+ });
+ // Grandchild is reparented to 'parent-span' —> kept
+ Sentry.startSpan({ name: 'grandchild-2' }, () => {});
+ });
+
+ // both dropped
+ Sentry.startSpan({ name: 'name-passes-but-op-not-span-1', op: 'ignored-op' }, () => {});
+ Sentry.startSpan(
+ // sentry.op attribute has precedence over top op argument
+ { name: 'name-passes-but-op-not-span-2', op: 'keep', attributes: { 'sentry.op': 'ignored-op' } },
+ () => {},
+ );
+
+ // kept
+ Sentry.startSpan({ name: 'another-keeper' }, () => {});
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts
new file mode 100644
index 000000000000..967ef101092d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts
@@ -0,0 +1,59 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+} from '../../../../utils/helpers';
+import { waitForStreamedSpans } from '../../../../utils/spanUtils';
+import type { ClientReport } from '@sentry/core';
+
+sentryTest('ignored child spans are dropped and their children are reparented', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'parent-span'));
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const spans = await spansPromise;
+
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+
+ const segmentSpanId = spans.find(s => s.name === 'parent-span')?.span_id;
+
+ expect(spans.length).toBe(6);
+
+ expect(spans.some(s => s.name === 'keep-me')).toBe(true);
+ expect(spans.some(s => s.name === 'another-keeper')).toBe(true);
+
+ expect(spans.some(s => s.name?.includes('ignore'))).toBe(false);
+
+ const greatGrandChild1 = spans.find(s => s.name === 'great-grandchild-1');
+ const grandchild2 = spans.find(s => s.name === 'grandchild-2');
+ const greatGreatGreatGrandChild1 = spans.find(s => s.name === 'great-great-great-grandchild-1');
+
+ expect(greatGrandChild1).toBeDefined();
+ expect(grandchild2).toBeDefined();
+ expect(greatGreatGreatGrandChild1).toBeDefined();
+
+ expect(greatGrandChild1?.parent_span_id).toBe(segmentSpanId);
+ expect(grandchild2?.parent_span_id).toBe(segmentSpanId);
+ expect(greatGreatGreatGrandChild1?.parent_span_id).toBe(greatGrandChild1?.span_id);
+
+ expect(spans.every(s => s.name === 'parent-span' || !s.is_segment)).toBe(true);
+
+ expect(clientReport.discarded_events).toEqual([
+ {
+ category: 'span',
+ quantity: 5, // 5 ignored child spans
+ reason: 'ignored',
+ },
+ ]);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js
new file mode 100644
index 000000000000..3a710324f0e1
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/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.spanStreamingIntegration()],
+ ignoreSpans: [/ignore/],
+ tracesSampleRate: 1,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js
new file mode 100644
index 000000000000..645668376b36
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js
@@ -0,0 +1,11 @@
+// This segment span matches ignoreSpans — should NOT produce a transaction
+Sentry.startSpan({ name: 'ignore-segment' }, () => {
+ Sentry.startSpan({ name: 'child-of-ignored-segment' }, () => {});
+});
+
+setTimeout(() => {
+ // This segment span does NOT match — should produce a transaction
+ Sentry.startSpan({ name: 'normal-segment' }, () => {
+ Sentry.startSpan({ name: 'child-span' }, () => {});
+ });
+}, 1000);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts
new file mode 100644
index 000000000000..93042cc5469e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts
@@ -0,0 +1,44 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+} from '../../../../utils/helpers';
+import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils';
+import type { ClientReport } from '@sentry/core';
+
+sentryTest('ignored segment span drops entire trace', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ observeStreamedSpan(page, span => {
+ if (span.name === 'ignore-segment' || span.name === 'child-of-ignored-segment') {
+ throw new Error('Ignored span found');
+ }
+ return false; // means we keep on looking for unwanted spans
+ });
+
+ const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'normal-segment'));
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await page.goto(url);
+
+ expect((await spansPromise)?.length).toBe(2);
+
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+
+ expect(clientReport.discarded_events).toEqual([
+ {
+ category: 'span',
+ quantity: 2, // segment + child span
+ reason: 'ignored',
+ },
+ ]);
+});
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/metrics/web-vitals-cls-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js
new file mode 100644
index 000000000000..0c1792f0bd3f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/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({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js
new file mode 100644
index 000000000000..9742a4a5cc29
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js
@@ -0,0 +1,17 @@
+import { simulateCLS } from '../../../../utils/web-vitals/cls.ts';
+
+// Simulate Layout shift right at the beginning of the page load, depending on the URL hash
+// don't run if expected CLS is NaN
+const expectedCLS = Number(location.hash.slice(1));
+if (expectedCLS && expectedCLS >= 0) {
+ simulateCLS(expectedCLS).then(() => window.dispatchEvent(new Event('cls-done')));
+}
+
+// Simulate layout shift whenever the trigger-cls event is dispatched
+// Cannot trigger via a button click because expected layout shift after
+// an interaction doesn't contribute to CLS.
+window.addEventListener('trigger-cls', () => {
+ simulateCLS(0.1).then(() => {
+ window.dispatchEvent(new Event('cls-done'));
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html
new file mode 100644
index 000000000000..10e2e22f7d6a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+ Some content
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts
new file mode 100644
index 000000000000..cf995f7a912d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts
@@ -0,0 +1,77 @@
+import type { Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest.beforeEach(async ({ browserName, page }) => {
+ if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') {
+ sentryTest.skip();
+ }
+
+ await page.setViewportSize({ width: 800, height: 1200 });
+});
+
+function waitForLayoutShift(page: Page): Promise {
+ return page.evaluate(() => {
+ return new Promise(resolve => {
+ window.addEventListener('cls-done', () => resolve());
+ });
+ });
+}
+
+function hidePage(page: Page): Promise {
+ return page.evaluate(() => {
+ window.dispatchEvent(new Event('pagehide'));
+ });
+}
+
+sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
+
+ await page.goto(`${url}#0.15`);
+ await waitForLayoutShift(page);
+ await hidePage(page);
+
+ const clsSpan = await clsSpanPromise;
+
+ expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' });
+ expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' });
+ expect(clsSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
+ expect(clsSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
+
+ // Check browser.web_vital.cls.source attributes
+ expect(clsSpan.attributes?.['browser.web_vital.cls.source.1']?.value).toEqual(
+ expect.stringContaining('body > div#content > p'),
+ );
+
+ // Check pageload span id is present
+ expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/);
+
+ // CLS is a point-in-time metric
+ expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp);
+
+ expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+});
+
+sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => {
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls');
+
+ await page.goto(`${url}#0.1`);
+ await waitForLayoutShift(page);
+ await hidePage(page);
+
+ const clsSpan = await clsSpanPromise;
+
+ // The CLS value should be set as a browser.web_vital.cls.value attribute
+ expect(clsSpan.attributes?.['browser.web_vital.cls.value']?.type).toBe('double');
+ // Flakey value dependent on timings -> we check for a range
+ const clsValue = clsSpan.attributes?.['browser.web_vital.cls.value']?.value as number;
+ expect(clsValue).toBeGreaterThan(0.05);
+ expect(clsValue).toBeLessThan(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png
new file mode 100644
index 000000000000..353b7233d6bf
Binary files /dev/null and b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png differ
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js
new file mode 100644
index 000000000000..0c1792f0bd3f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/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({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html
new file mode 100644
index 000000000000..b613a556aca4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts
new file mode 100644
index 000000000000..c91ebd2bbd51
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts
@@ -0,0 +1,66 @@
+import type { Page, Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest.beforeEach(async ({ browserName, page }) => {
+ if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') {
+ sentryTest.skip();
+ }
+
+ await page.setViewportSize({ width: 800, height: 1200 });
+});
+
+function hidePage(page: Page): Promise {
+ return page.evaluate(() => {
+ window.dispatchEvent(new Event('pagehide'));
+ });
+}
+
+sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => {
+ page.route('**', route => route.continue());
+ page.route('**/my/image.png', async (route: Route) => {
+ return route.fulfill({
+ path: `${__dirname}/assets/sentry-logo-600x179.png`,
+ });
+ });
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp');
+
+ await page.goto(url);
+
+ // Wait for LCP to be captured
+ await page.waitForTimeout(1000);
+
+ await hidePage(page);
+
+ const lcpSpan = await lcpSpanPromise;
+
+ expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' });
+ expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' });
+ expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 });
+ expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome'));
+
+ // Check browser.web_vital.lcp.* attributes
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img'));
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe(
+ 'https://sentry-test-site.example/my/image.png',
+ );
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number));
+
+ // Check web vital value attribute
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toBe('double');
+ expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0);
+
+ // Check pageload span id is present
+ expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/);
+
+ // Span should have meaningful duration (navigation start -> LCP event)
+ expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp);
+
+ expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+});
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 879e672b6c87..ff0398d6b209 100644
--- a/dev-packages/browser-integration-tests/utils/helpers.ts
+++ b/dev-packages/browser-integration-tests/utils/helpers.ts
@@ -64,7 +64,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/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts
new file mode 100644
index 000000000000..cf8c1be967f4
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts
@@ -0,0 +1,30 @@
+import * as Sentry from '@sentry/node-core';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+import { setupOtel } from '../../../../utils/setupOtel';
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ integrations: [Sentry.spanStreamingIntegration()],
+ transport: loggingTransport,
+ release: '1.0.0',
+});
+
+setupOtel(client);
+
+Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => {
+ Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => {
+ // noop
+ });
+
+ const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' });
+ inactiveSpan.addLink({ context: segmentSpan.spanContext(), attributes: { 'sentry.link.type': 'some_relation' } });
+ inactiveSpan.end();
+
+ Sentry.startSpanManual({ name: 'test-manual-span' }, span => {
+ span.end();
+ });
+});
+
+void Sentry.flush();
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts
new file mode 100644
index 000000000000..3184aae69d64
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts
@@ -0,0 +1,148 @@
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ 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,
+} from '@sentry/core';
+import { expect, test } from 'vitest';
+import { createRunner } from '../../../../utils/runner';
+
+test('sends a streamed span envelope with correct envelope header', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expectHeader({
+ span: {
+ sent_at: expect.any(String),
+ sdk: {
+ name: 'sentry.javascript.node-core',
+ version: SDK_VERSION,
+ },
+ trace: expect.objectContaining({
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ transaction: 'test-span',
+ }),
+ },
+ })
+ .start()
+ .completed();
+});
+
+test('sends a streamed span envelope with correct spans for a manually started span with children', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({
+ span: container => {
+ const spans = container.items;
+ expect(spans.length).toBe(4);
+
+ const segmentSpan = spans.find(s => !!s.is_segment);
+ expect(segmentSpan).toBeDefined();
+
+ const segmentSpanId = segmentSpan!.span_id;
+ const traceId = segmentSpan!.trace_id;
+
+ const childSpan = spans.find(s => s.name === 'test-child-span');
+ expect(childSpan).toBeDefined();
+ expect(childSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'test-child',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ name: 'test-child-span',
+ is_segment: false,
+ parent_span_id: segmentSpanId,
+ trace_id: traceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+
+ const inactiveSpan = spans.find(s => s.name === 'test-inactive-span');
+ expect(inactiveSpan).toBeDefined();
+ expect(inactiveSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ links: [
+ {
+ attributes: {
+ 'sentry.link.type': {
+ type: 'string',
+ value: 'some_relation',
+ },
+ },
+ sampled: true,
+ span_id: segmentSpanId,
+ trace_id: traceId,
+ },
+ ],
+ name: 'test-inactive-span',
+ is_segment: false,
+ parent_span_id: segmentSpanId,
+ trace_id: traceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+
+ const manualSpan = spans.find(s => s.name === 'test-manual-span');
+ expect(manualSpan).toBeDefined();
+ expect(manualSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ name: 'test-manual-span',
+ is_segment: false,
+ parent_span_id: segmentSpanId,
+ trace_id: traceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+
+ expect(segmentSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ name: 'test-span',
+ is_segment: true,
+ trace_id: traceId,
+ span_id: segmentSpanId,
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts
new file mode 100644
index 000000000000..048b06f29178
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts
@@ -0,0 +1,36 @@
+import * as Sentry from '@sentry/node-core';
+import { loggingTransport } from '@sentry-internal/node-core-integration-tests';
+import { setupOtel } from '../../../../utils/setupOtel';
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+setupOtel(client);
+
+Sentry.getCurrentScope().setPropagationContext({
+ parentSpanId: '1234567890123456',
+ traceId: '12345678901234567890123456789012',
+ sampleRand: Math.random(),
+});
+
+const spanIdTraceId = Sentry.startSpan(
+ {
+ name: 'test_span_1',
+ },
+ span1 => span1.spanContext().traceId,
+);
+
+Sentry.startSpan(
+ {
+ name: 'test_span_2',
+ attributes: { spanIdTraceId },
+ },
+ () => undefined,
+);
+
+Sentry.flush();
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts
new file mode 100644
index 000000000000..f332715bbc42
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts
@@ -0,0 +1,32 @@
+import { afterAll, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('sends manually started streamed parallel root spans in root context', async () => {
+ expect.assertions(7);
+
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({ span: { items: [{ name: 'test_span_1' }] } })
+ .expect({
+ span: spanContainer => {
+ expect(spanContainer).toBeDefined();
+ const traceId = spanContainer.items[0]!.trace_id;
+ expect(traceId).toMatch(/^[0-9a-f]{32}$/);
+
+ // It ignores propagation context of the root context
+ expect(traceId).not.toBe('12345678901234567890123456789012');
+ expect(spanContainer.items[0]!.parent_span_id).toBeUndefined();
+
+ // Different trace ID than the first span
+ const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value;
+ expect(trace1Id).toMatch(/^[0-9a-f]{32}$/);
+
+ expect(trace1Id).not.toBe(traceId);
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts
new file mode 100644
index 000000000000..fd283637b0b6
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts
@@ -0,0 +1,32 @@
+import * as Sentry from '@sentry/node-core';
+import { loggingTransport } from '@sentry-internal/node-core-integration-tests';
+import { setupOtel } from '../../../../utils/setupOtel';
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+setupOtel(client);
+
+Sentry.withScope(() => {
+ const spanIdTraceId = Sentry.startSpan(
+ {
+ name: 'test_span_1',
+ },
+ span1 => span1.spanContext().traceId,
+ );
+
+ Sentry.startSpan(
+ {
+ name: 'test_span_2',
+ attributes: { spanIdTraceId },
+ },
+ () => undefined,
+ );
+});
+
+Sentry.flush();
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts
new file mode 100644
index 000000000000..02456c9e93b2
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts
@@ -0,0 +1,29 @@
+import { afterAll, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('sends manually started streamed parallel root spans outside of root context', async () => {
+ expect.assertions(6);
+
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({ span: { items: [{ name: 'test_span_1' }] } })
+ .expect({
+ span: spanContainer => {
+ expect(spanContainer).toBeDefined();
+ const traceId = spanContainer.items[0]!.trace_id;
+ expect(traceId).toMatch(/^[0-9a-f]{32}$/);
+ expect(spanContainer.items[0]!.parent_span_id).toBeUndefined();
+
+ const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value;
+ expect(trace1Id).toMatch(/^[0-9a-f]{32}$/);
+
+ // Different trace ID as the first span
+ expect(trace1Id).not.toBe(traceId);
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts
new file mode 100644
index 000000000000..367ce6eda6fa
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts
@@ -0,0 +1,38 @@
+import * as Sentry from '@sentry/node-core';
+import { loggingTransport } from '@sentry-internal/node-core-integration-tests';
+import { setupOtel } from '../../../../utils/setupOtel';
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+setupOtel(client);
+
+Sentry.withScope(scope => {
+ scope.setPropagationContext({
+ parentSpanId: '1234567890123456',
+ traceId: '12345678901234567890123456789012',
+ sampleRand: Math.random(),
+ });
+
+ const spanIdTraceId = Sentry.startSpan(
+ {
+ name: 'test_span_1',
+ },
+ span1 => span1.spanContext().traceId,
+ );
+
+ Sentry.startSpan(
+ {
+ name: 'test_span_2',
+ attributes: { spanIdTraceId },
+ },
+ () => undefined,
+ );
+});
+
+Sentry.flush();
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts
new file mode 100644
index 000000000000..325f0f0d5d42
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts
@@ -0,0 +1,29 @@
+import { afterAll, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('sends manually started streamed parallel root spans outside of root context with parentSpanId', async () => {
+ expect.assertions(6);
+
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({ span: { items: [{ name: 'test_span_1' }] } })
+ .expect({
+ span: spanContainer => {
+ expect(spanContainer).toBeDefined();
+ const traceId = spanContainer.items[0]!.trace_id;
+ expect(traceId).toMatch(/^[0-9a-f]{32}$/);
+ expect(spanContainer.items[0]!.parent_span_id).toBeUndefined();
+
+ const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value;
+ expect(trace1Id).toMatch(/^[0-9a-f]{32}$/);
+
+ // Different trace ID as the first span
+ expect(trace1Id).not.toBe(traceId);
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts
new file mode 100644
index 000000000000..8512fb954b8e
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts
@@ -0,0 +1,22 @@
+import * as Sentry from '@sentry/node-core';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+import { setupOtel } from '../../../../utils/setupOtel';
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+setupOtel(client);
+
+Sentry.startSpan(
+ { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } },
+ (span: Sentry.Span) => {
+ span.updateName('new name');
+ },
+);
+
+void Sentry.flush();
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts
new file mode 100644
index 000000000000..09cc140278ed
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts
@@ -0,0 +1,26 @@
+import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node-core';
+import { afterAll, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('updates the span name when calling `span.updateName` (streamed)', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({
+ span: {
+ items: [
+ {
+ name: 'new name',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ },
+ },
+ ],
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts
new file mode 100644
index 000000000000..34892b20d692
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts
@@ -0,0 +1,22 @@
+import * as Sentry from '@sentry/node-core';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+import { setupOtel } from '../../../../utils/setupOtel';
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+setupOtel(client);
+
+Sentry.startSpan(
+ { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } },
+ (span: Sentry.Span) => {
+ Sentry.updateSpanName(span, 'new name');
+ },
+);
+
+void Sentry.flush();
diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts
new file mode 100644
index 000000000000..8ff4b71ed5e6
--- /dev/null
+++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts
@@ -0,0 +1,26 @@
+import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node-core';
+import { afterAll, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('updates the span name and source when calling `updateSpanName` (streamed)', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({
+ span: {
+ items: [
+ {
+ name: 'new name',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' },
+ },
+ },
+ ],
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-core-integration-tests/utils/assertions.ts b/dev-packages/node-core-integration-tests/utils/assertions.ts
index 00c6019fab0c..4f08141d9f93 100644
--- a/dev-packages/node-core-integration-tests/utils/assertions.ts
+++ b/dev-packages/node-core-integration-tests/utils/assertions.ts
@@ -6,12 +6,19 @@ import type {
SerializedLogContainer,
SerializedMetricContainer,
SerializedSession,
+ SerializedStreamedSpanContainer,
SessionAggregates,
TransactionEvent,
} from '@sentry/core';
import { SDK_VERSION } from '@sentry/core';
import { expect } from 'vitest';
+export type DeepPartial = T extends object
+ ? {
+ [P in keyof T]?: DeepPartial;
+ }
+ : T;
+
/**
* Asserts against a Sentry Event ignoring non-deterministic properties
*
@@ -86,6 +93,16 @@ export function assertSentryMetricContainer(
});
}
+export function assertSentrySpanContainer(
+ actual: SerializedStreamedSpanContainer,
+ expected: DeepPartial,
+): void {
+ expect(actual).toMatchObject({
+ items: expect.any(Array),
+ ...expected,
+ });
+}
+
export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void {
expect(actual).toEqual({
event_id: expect.any(String),
@@ -97,3 +114,14 @@ export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void {
+ expect(actual).toEqual({
+ sent_at: expect.any(String),
+ sdk: {
+ name: 'sentry.javascript.node-core',
+ version: SDK_VERSION,
+ },
+ ...expected,
+ });
+}
diff --git a/dev-packages/node-core-integration-tests/utils/runner.ts b/dev-packages/node-core-integration-tests/utils/runner.ts
index 416d55d803a1..d27c65fc81be 100644
--- a/dev-packages/node-core-integration-tests/utils/runner.ts
+++ b/dev-packages/node-core-integration-tests/utils/runner.ts
@@ -9,6 +9,7 @@ import type {
SerializedLogContainer,
SerializedMetricContainer,
SerializedSession,
+ SerializedStreamedSpanContainer,
SessionAggregates,
TransactionEvent,
} from '@sentry/core';
@@ -17,6 +18,7 @@ import { execSync, spawn, spawnSync } from 'child_process';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
import { afterAll, beforeAll, describe, test } from 'vitest';
+import type { DeepPartial } from './assertions';
import {
assertEnvelopeHeader,
assertSentryCheckIn,
@@ -26,7 +28,9 @@ import {
assertSentryMetricContainer,
assertSentrySession,
assertSentrySessions,
+ assertSentrySpanContainer,
assertSentryTransaction,
+ assertSpanEnvelopeHeader,
} from './assertions';
import { createBasicSentryServer } from './server';
@@ -125,6 +129,9 @@ type ExpectedCheckIn = Partial | ((event: SerializedCheckIn)
type ExpectedClientReport = Partial | ((event: ClientReport) => void);
type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void);
type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void);
+type ExpectedSpanContainer =
+ | DeepPartial
+ | ((container: SerializedStreamedSpanContainer) => void);
type Expected =
| {
@@ -150,6 +157,9 @@ type Expected =
}
| {
trace_metric: ExpectedMetricContainer;
+ }
+ | {
+ span: ExpectedSpanContainer;
};
type ExpectedEnvelopeHeader =
@@ -157,7 +167,8 @@ type ExpectedEnvelopeHeader =
| { transaction: Partial }
| { session: Partial }
| { sessions: Partial }
- | { log: Partial };
+ | { log: Partial }
+ | { span: Partial };
type StartResult = {
completed(): Promise;
@@ -360,7 +371,11 @@ export function createRunner(...paths: string[]) {
return;
}
- assertEnvelopeHeader(header, expected);
+ if (envelopeItemType === 'span') {
+ assertSpanEnvelopeHeader(header, expected);
+ } else {
+ assertEnvelopeHeader(header, expected);
+ }
expectCallbackCalled();
} catch (e) {
@@ -412,6 +427,9 @@ export function createRunner(...paths: string[]) {
} else if ('trace_metric' in expected) {
expectMetric(item[1] as SerializedMetricContainer, expected.trace_metric);
expectCallbackCalled();
+ } else if ('span' in expected) {
+ expectSpanContainer(item[1] as SerializedStreamedSpanContainer, expected.span);
+ expectCallbackCalled();
} else {
throw new Error(
`Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`,
@@ -666,6 +684,14 @@ function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricC
}
}
+function expectSpanContainer(item: SerializedStreamedSpanContainer, expected: ExpectedSpanContainer): void {
+ if (typeof expected === 'function') {
+ expected(item);
+ } else {
+ assertSentrySpanContainer(item, expected);
+ }
+}
+
/**
* Converts ESM import statements to CommonJS require statements
* @param content The content of an ESM file
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts
new file mode 100644
index 000000000000..ce3f914d51b8
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/scenario.ts
@@ -0,0 +1,29 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ integrations: [Sentry.spanStreamingIntegration()],
+ transport: loggingTransport,
+ release: '1.0.0',
+});
+
+Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => {
+ Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => {
+ // noop
+ });
+
+ const inactiveSpan = Sentry.startInactiveSpan({
+ name: 'test-inactive-span',
+ links: [{ context: segmentSpan.spanContext(), attributes: { 'sentry.link.type': 'some_relation' } }],
+ });
+ inactiveSpan.end();
+
+ Sentry.startSpanManual({ name: 'test-manual-span' }, span => {
+ span.end();
+ });
+});
+
+void Sentry.flush();
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts
new file mode 100644
index 000000000000..b31ca320df53
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts
@@ -0,0 +1,148 @@
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ 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,
+} from '@sentry/core';
+import { expect, test } from 'vitest';
+import { createRunner } from '../../../../utils/runner';
+
+test('sends a streamed span envelope with correct envelope header', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expectHeader({
+ span: {
+ sent_at: expect.any(String),
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: SDK_VERSION,
+ },
+ trace: expect.objectContaining({
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ transaction: 'test-span',
+ }),
+ },
+ })
+ .start()
+ .completed();
+});
+
+test('sends a streamed span envelope with correct spans for a manually started span with children', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({
+ span: container => {
+ const spans = container.items;
+ expect(spans.length).toBe(4);
+
+ const segmentSpan = spans.find(s => !!s.is_segment);
+ expect(segmentSpan).toBeDefined();
+
+ const segmentSpanId = segmentSpan!.span_id;
+ const traceId = segmentSpan!.trace_id;
+
+ const childSpan = spans.find(s => s.name === 'test-child-span');
+ expect(childSpan).toBeDefined();
+ expect(childSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'test-child',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ name: 'test-child-span',
+ is_segment: false,
+ parent_span_id: segmentSpanId,
+ trace_id: traceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+
+ const inactiveSpan = spans.find(s => s.name === 'test-inactive-span');
+ expect(inactiveSpan).toBeDefined();
+ expect(inactiveSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ links: [
+ {
+ attributes: {
+ 'sentry.link.type': {
+ type: 'string',
+ value: 'some_relation',
+ },
+ },
+ sampled: true,
+ span_id: segmentSpanId,
+ trace_id: traceId,
+ },
+ ],
+ name: 'test-inactive-span',
+ is_segment: false,
+ parent_span_id: segmentSpanId,
+ trace_id: traceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+
+ const manualSpan = spans.find(s => s.name === 'test-manual-span');
+ expect(manualSpan).toBeDefined();
+ expect(manualSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ name: 'test-manual-span',
+ is_segment: false,
+ parent_span_id: segmentSpanId,
+ trace_id: traceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+
+ expect(segmentSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' },
+ [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_RELEASE]: { type: 'string', value: '1.0.0' },
+ },
+ name: 'test-span',
+ is_segment: true,
+ trace_id: traceId,
+ span_id: segmentSpanId,
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ status: 'ok',
+ });
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts
new file mode 100644
index 000000000000..5e8aedc0458a
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/scenario.ts
@@ -0,0 +1,33 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+Sentry.getCurrentScope().setPropagationContext({
+ parentSpanId: '1234567890123456',
+ traceId: '12345678901234567890123456789012',
+ sampleRand: Math.random(),
+});
+
+const spanIdTraceId = Sentry.startSpan(
+ {
+ name: 'test_span_1',
+ },
+ span1 => span1.spanContext().traceId,
+);
+
+Sentry.startSpan(
+ {
+ name: 'test_span_2',
+ attributes: { spanIdTraceId },
+ },
+ () => undefined,
+);
+
+Sentry.flush();
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts
new file mode 100644
index 000000000000..f332715bbc42
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts
@@ -0,0 +1,32 @@
+import { afterAll, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('sends manually started streamed parallel root spans in root context', async () => {
+ expect.assertions(7);
+
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({ span: { items: [{ name: 'test_span_1' }] } })
+ .expect({
+ span: spanContainer => {
+ expect(spanContainer).toBeDefined();
+ const traceId = spanContainer.items[0]!.trace_id;
+ expect(traceId).toMatch(/^[0-9a-f]{32}$/);
+
+ // It ignores propagation context of the root context
+ expect(traceId).not.toBe('12345678901234567890123456789012');
+ expect(spanContainer.items[0]!.parent_span_id).toBeUndefined();
+
+ // Different trace ID than the first span
+ const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value;
+ expect(trace1Id).toMatch(/^[0-9a-f]{32}$/);
+
+ expect(trace1Id).not.toBe(traceId);
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts
new file mode 100644
index 000000000000..d688d3c3adf7
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/scenario.ts
@@ -0,0 +1,29 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+Sentry.withScope(() => {
+ const spanIdTraceId = Sentry.startSpan(
+ {
+ name: 'test_span_1',
+ },
+ span1 => span1.spanContext().traceId,
+ );
+
+ Sentry.startSpan(
+ {
+ name: 'test_span_2',
+ attributes: { spanIdTraceId },
+ },
+ () => undefined,
+ );
+});
+
+Sentry.flush();
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts
new file mode 100644
index 000000000000..02456c9e93b2
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts
@@ -0,0 +1,29 @@
+import { afterAll, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('sends manually started streamed parallel root spans outside of root context', async () => {
+ expect.assertions(6);
+
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({ span: { items: [{ name: 'test_span_1' }] } })
+ .expect({
+ span: spanContainer => {
+ expect(spanContainer).toBeDefined();
+ const traceId = spanContainer.items[0]!.trace_id;
+ expect(traceId).toMatch(/^[0-9a-f]{32}$/);
+ expect(spanContainer.items[0]!.parent_span_id).toBeUndefined();
+
+ const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value;
+ expect(trace1Id).toMatch(/^[0-9a-f]{32}$/);
+
+ // Different trace ID as the first span
+ expect(trace1Id).not.toBe(traceId);
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts
new file mode 100644
index 000000000000..64f194549572
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/scenario.ts
@@ -0,0 +1,35 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+Sentry.withScope(scope => {
+ scope.setPropagationContext({
+ parentSpanId: '1234567890123456',
+ traceId: '12345678901234567890123456789012',
+ sampleRand: Math.random(),
+ });
+
+ const spanIdTraceId = Sentry.startSpan(
+ {
+ name: 'test_span_1',
+ },
+ span1 => span1.spanContext().traceId,
+ );
+
+ Sentry.startSpan(
+ {
+ name: 'test_span_2',
+ attributes: { spanIdTraceId },
+ },
+ () => undefined,
+ );
+});
+
+Sentry.flush();
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts
new file mode 100644
index 000000000000..325f0f0d5d42
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts
@@ -0,0 +1,29 @@
+import { afterAll, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('sends manually started streamed parallel root spans outside of root context with parentSpanId', async () => {
+ expect.assertions(6);
+
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({ span: { items: [{ name: 'test_span_1' }] } })
+ .expect({
+ span: spanContainer => {
+ expect(spanContainer).toBeDefined();
+ const traceId = spanContainer.items[0]!.trace_id;
+ expect(traceId).toMatch(/^[0-9a-f]{32}$/);
+ expect(spanContainer.items[0]!.parent_span_id).toBeUndefined();
+
+ const trace1Id = spanContainer.items[0]!.attributes?.spanIdTraceId?.value;
+ expect(trace1Id).toMatch(/^[0-9a-f]{32}$/);
+
+ // Different trace ID as the first span
+ expect(trace1Id).not.toBe(traceId);
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts
new file mode 100644
index 000000000000..2cbbaef888ae
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/scenario.ts
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+Sentry.startSpan(
+ { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } },
+ (span: Sentry.Span) => {
+ span.updateName('new name');
+ },
+);
+
+void Sentry.flush();
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts
new file mode 100644
index 000000000000..f9d15cf60e30
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method-streamed/test.ts
@@ -0,0 +1,26 @@
+import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node';
+import { afterAll, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('updates the span name when calling `span.updateName` (streamed)', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({
+ span: {
+ items: [
+ {
+ name: 'new name',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ },
+ },
+ ],
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts
new file mode 100644
index 000000000000..93d653d107aa
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/scenario.ts
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ traceLifecycle: 'stream',
+ transport: loggingTransport,
+});
+
+Sentry.startSpan(
+ { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } },
+ (span: Sentry.Span) => {
+ Sentry.updateSpanName(span, 'new name');
+ },
+);
+
+void Sentry.flush();
diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts
new file mode 100644
index 000000000000..ace2f0ca0d76
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function-streamed/test.ts
@@ -0,0 +1,26 @@
+import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node';
+import { afterAll, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('updates the span name and source when calling `updateSpanName` (streamed)', async () => {
+ await createRunner(__dirname, 'scenario.ts')
+ .expect({
+ span: {
+ items: [
+ {
+ name: 'new name',
+ is_segment: true,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' },
+ },
+ },
+ ],
+ },
+ })
+ .start()
+ .completed();
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs
new file mode 100644
index 000000000000..04f3908530c0
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+ traceLifecycle: 'stream',
+ ignoreSpans: ['middleware - expressInit', /custom-to-drop/, { op: 'ignored-op' }],
+ clientReportFlushInterval: 1_000,
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs
new file mode 100644
index 000000000000..a79ef373ec11
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs
@@ -0,0 +1,45 @@
+import express from 'express';
+import cors from 'cors';
+import * as Sentry from '@sentry/node';
+import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+
+const app = express();
+
+app.use(cors());
+
+app.get('/test/express', (_req, res) => {
+ Sentry.startSpan(
+ {
+ name: 'custom-to-drop',
+ op: 'custom',
+ },
+ () => {
+ Sentry.startSpan(
+ {
+ name: 'custom',
+ op: 'custom',
+ },
+ () => {
+ Sentry.startSpan({ name: 'custom-grandchild', op: 'custom' }, () => {
+ Sentry.startSpan({ name: 'custom-to-drop-grand-grandchild', op: 'custom' }, () => {
+ Sentry.startSpan({ name: 'custom-grand-grand-grandchild', op: 'custom' }, () => {});
+ });
+ });
+ Sentry.startSpan({ name: 'custom-grandchild-2', op: 'custom' }, () => {});
+ },
+ );
+ },
+ );
+
+ Sentry.startSpan({ name: 'name-passes-but-op-not-span-1', op: 'ignored-op' }, () => {});
+ Sentry.startSpan(
+ // sentry.op attribute has precedence over top op argument
+ { name: 'name-passes-but-op-not-span-2', op: 'keep', attributes: { 'sentry.op': 'ignored-op' } },
+ () => {},
+ );
+ res.send({ response: 'response 1' });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts
new file mode 100644
index 000000000000..263f8f74c1c3
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts
@@ -0,0 +1,69 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
+
+describe('filtering child spans with ignoreSpans (streaming)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('child spans are dropped and remaining spans correctly parented', async () => {
+ const runner = createRunner()
+ .unignore('client_report')
+ .expect({
+ client_report: {
+ discarded_events: [
+ {
+ category: 'span',
+ quantity: 5,
+ reason: 'ignored',
+ },
+ ],
+ },
+ })
+ .expect({
+ span: container => {
+ // 5 spans: 1 root, 2 middleware, 1 request handler, 1 custom
+ // Would be 7 if we didn't ignore the 'middleware - expressInit' and 'custom-to-drop' spans
+ expect(container.items).toHaveLength(8);
+ const getSpan = (name: string, op: string) =>
+ container.items.find(
+ item => item.name === name && item.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === op,
+ );
+ const queryMiddlewareSpan = getSpan('query', 'middleware.express');
+ const corsMiddlewareSpan = getSpan('corsMiddleware', 'middleware.express');
+ const requestHandlerSpan = getSpan('/test/express', 'request_handler.express');
+ const httpServerSpan = getSpan('GET /test/express', 'http.server');
+ const customSpan = getSpan('custom', 'custom');
+ const customGrandchildSpan = getSpan('custom-grandchild', 'custom');
+ const customGrandchild2Span = getSpan('custom-grandchild-2', 'custom');
+ const customGrandGrandGrandChildSpan = getSpan('custom-grand-grand-grandchild', 'custom');
+
+ expect(queryMiddlewareSpan).toBeDefined();
+ expect(corsMiddlewareSpan).toBeDefined();
+ expect(requestHandlerSpan).toBeDefined();
+ expect(httpServerSpan).toBeDefined();
+ expect(customSpan).toBeDefined();
+ expect(customGrandchildSpan).toBeDefined();
+ expect(customGrandchild2Span).toBeDefined();
+ expect(customGrandGrandGrandChildSpan).toBeDefined();
+
+ expect(customGrandchildSpan?.parent_span_id).toBe(customSpan?.span_id);
+ expect(customGrandchild2Span?.parent_span_id).toBe(customSpan?.span_id);
+ expect(customGrandGrandGrandChildSpan?.parent_span_id).toBe(customGrandchildSpan?.span_id);
+ expect(customSpan?.parent_span_id).toBe(requestHandlerSpan?.span_id);
+ expect(requestHandlerSpan?.parent_span_id).toBe(httpServerSpan?.span_id);
+ expect(queryMiddlewareSpan?.parent_span_id).toBe(httpServerSpan?.span_id);
+ expect(corsMiddlewareSpan?.parent_span_id).toBe(httpServerSpan?.span_id);
+ expect(httpServerSpan?.parent_span_id).toBeUndefined();
+ },
+ })
+ .start();
+
+ runner.makeRequest('get', '/test/express');
+
+ await runner.completed();
+ });
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs
new file mode 100644
index 000000000000..4d14e615745b
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+ traceLifecycle: 'stream',
+ ignoreSpans: [/\/health/],
+ clientReportFlushInterval: 1_000,
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs
new file mode 100644
index 000000000000..f9c7f136aef2
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs
@@ -0,0 +1,20 @@
+import express from 'express';
+import cors from 'cors';
+import * as Sentry from '@sentry/node';
+import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+
+const app = express();
+
+app.use(cors());
+
+app.get('/health', (_req, res) => {
+ res.send({ status: 'ok-health' });
+});
+
+app.get('/ok', (_req, res) => {
+ res.send({ status: 'ok' });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts
new file mode 100644
index 000000000000..538c9c77a532
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts
@@ -0,0 +1,45 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+
+describe('filtering segment spans with ignoreSpans (streaming)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('segment spans matching ignoreSpans are dropped including all children', async () => {
+ const runner = createRunner()
+ .unignore('client_report')
+ .expect({
+ client_report: {
+ discarded_events: [
+ {
+ category: 'span',
+ quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored)
+ reason: 'ignored',
+ },
+ ],
+ },
+ })
+ .expect({
+ span: container => {
+ expect(container.items).toHaveLength(5);
+ const segmentSpan = container.items.find(s => s.name === 'GET /ok' && !!s.is_segment);
+
+ expect(segmentSpan).toBeDefined();
+ expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true);
+ },
+ })
+
+ .start();
+
+ const res = await runner.makeRequest('get', '/health');
+ expect((res as { status: string }).status).toBe('ok-health');
+
+ const res2 = await runner.makeRequest('get', '/ok'); // contains all spans
+ expect((res2 as { status: string }).status).toBe('ok');
+
+ await runner.completed();
+ });
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs
new file mode 100644
index 000000000000..cbf0bbf2e2fc
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampler: ({ inheritOrSampleWith, name }) => {
+ if (name === 'GET /health') {
+ return inheritOrSampleWith(0);
+ }
+ return inheritOrSampleWith(1);
+ },
+ transport: loggingTransport,
+ clientReportFlushInterval: 1_000,
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs
new file mode 100644
index 000000000000..f9c7f136aef2
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs
@@ -0,0 +1,20 @@
+import express from 'express';
+import cors from 'cors';
+import * as Sentry from '@sentry/node';
+import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+
+const app = express();
+
+app.use(cors());
+
+app.get('/health', (_req, res) => {
+ res.send({ status: 'ok-health' });
+});
+
+app.get('/ok', (_req, res) => {
+ res.send({ status: 'ok' });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts
new file mode 100644
index 000000000000..8eb57ccdd54e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts
@@ -0,0 +1,40 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
+
+describe('negative sampling (static)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('records sample_rate outcome for root span/transaction', async () => {
+ const runner = createRunner()
+ .unignore('client_report')
+ .expect({
+ transaction: {
+ transaction: 'GET /ok',
+ },
+ })
+ .expect({
+ client_report: {
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 1,
+ reason: 'sample_rate',
+ },
+ ],
+ },
+ })
+ .start();
+
+ const res = await runner.makeRequest('get', '/health');
+ expect((res as { status: string }).status).toBe('ok-health');
+
+ const res2 = await runner.makeRequest('get', '/ok'); // contains all spans
+ expect((res2 as { status: string }).status).toBe('ok');
+
+ await runner.completed();
+ });
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs
new file mode 100644
index 000000000000..713a676ede3d
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport } from '@sentry-internal/node-integration-tests';
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampler: ({ inheritOrSampleWith, name }) => {
+ if (name === 'GET /health') {
+ return inheritOrSampleWith(0);
+ }
+ return inheritOrSampleWith(1);
+ },
+ transport: loggingTransport,
+ traceLifecycle: 'stream',
+ clientReportFlushInterval: 1_000,
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs
new file mode 100644
index 000000000000..f9c7f136aef2
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs
@@ -0,0 +1,20 @@
+import express from 'express';
+import cors from 'cors';
+import * as Sentry from '@sentry/node';
+import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+
+const app = express();
+
+app.use(cors());
+
+app.get('/health', (_req, res) => {
+ res.send({ status: 'ok-health' });
+});
+
+app.get('/ok', (_req, res) => {
+ res.send({ status: 'ok' });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts
new file mode 100644
index 000000000000..d98d2f82afb0
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts
@@ -0,0 +1,44 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
+
+describe('negative sampling (streaming)', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
+ test('records sample_rate outcome for segment and child spans of negatively sampled segment', async () => {
+ const runner = createRunner()
+ .unignore('client_report')
+ .expect({
+ client_report: {
+ discarded_events: [
+ {
+ category: 'span',
+ quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored)
+ reason: 'sample_rate',
+ },
+ ],
+ },
+ })
+ .expect({
+ span: container => {
+ expect(container.items).toHaveLength(5);
+ const segmentSpan = container.items.find(s => s.name === 'GET /ok' && !!s.is_segment);
+
+ expect(segmentSpan).toBeDefined();
+ expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true);
+ },
+ })
+ .start();
+
+ const res = await runner.makeRequest('get', '/health');
+ expect((res as { status: string }).status).toBe('ok-health');
+
+ const res2 = await runner.makeRequest('get', '/ok'); // contains all spans
+ expect((res2 as { status: string }).status).toBe('ok');
+
+ await runner.completed();
+ });
+ });
+});
diff --git a/dev-packages/node-integration-tests/utils/assertions.ts b/dev-packages/node-integration-tests/utils/assertions.ts
index 8d9fb5f2251f..b6c45ebc043c 100644
--- a/dev-packages/node-integration-tests/utils/assertions.ts
+++ b/dev-packages/node-integration-tests/utils/assertions.ts
@@ -6,12 +6,19 @@ import type {
SerializedLogContainer,
SerializedMetricContainer,
SerializedSession,
+ SerializedStreamedSpanContainer,
SessionAggregates,
TransactionEvent,
} from '@sentry/core';
import { SDK_VERSION } from '@sentry/core';
import { expect } from 'vitest';
+export type DeepPartial = T extends object
+ ? {
+ [P in keyof T]?: DeepPartial;
+ }
+ : T;
+
/**
* Asserts against a Sentry Event ignoring non-deterministic properties
*
@@ -86,6 +93,16 @@ export function assertSentryMetricContainer(
});
}
+export function assertSentrySpanContainer(
+ actual: SerializedStreamedSpanContainer,
+ expected: DeepPartial,
+): void {
+ expect(actual).toMatchObject({
+ items: expect.any(Array),
+ ...expected,
+ });
+}
+
export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void {
expect(actual).toEqual({
event_id: expect.any(String),
@@ -97,3 +114,14 @@ export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void {
+ expect(actual).toEqual({
+ sent_at: expect.any(String),
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: SDK_VERSION,
+ },
+ ...expected,
+ });
+}
diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts
index ee2fae0bc06b..7690fa40ee8b 100644
--- a/dev-packages/node-integration-tests/utils/runner.ts
+++ b/dev-packages/node-integration-tests/utils/runner.ts
@@ -9,6 +9,7 @@ import type {
SerializedLogContainer,
SerializedMetricContainer,
SerializedSession,
+ SerializedStreamedSpanContainer,
SessionAggregates,
TransactionEvent,
} from '@sentry/core';
@@ -20,6 +21,7 @@ import { cp, mkdir, readFile, rm, writeFile } from 'fs/promises';
import { basename, join } from 'path';
import { inspect, promisify } from 'util';
import { afterAll, beforeAll, describe, test } from 'vitest';
+import type { DeepPartial } from './assertions';
import {
assertEnvelopeHeader,
assertSentryCheckIn,
@@ -29,7 +31,9 @@ import {
assertSentryMetricContainer,
assertSentrySession,
assertSentrySessions,
+ assertSentrySpanContainer,
assertSentryTransaction,
+ assertSpanEnvelopeHeader,
} from './assertions';
const execPromise = promisify(exec);
@@ -135,6 +139,9 @@ type ExpectedCheckIn = Partial | ((event: SerializedCheckIn)
type ExpectedClientReport = Partial | ((event: ClientReport) => void);
type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void);
type ExpectedMetricContainer = Partial | ((event: SerializedMetricContainer) => void);
+type ExpectedSpanContainer =
+ | DeepPartial
+ | ((container: SerializedStreamedSpanContainer) => void);
type Expected =
| {
@@ -160,6 +167,9 @@ type Expected =
}
| {
trace_metric: ExpectedMetricContainer;
+ }
+ | {
+ span: ExpectedSpanContainer;
};
type ExpectedEnvelopeHeader =
@@ -167,7 +177,8 @@ type ExpectedEnvelopeHeader =
| { transaction: Partial }
| { session: Partial }
| { sessions: Partial }
- | { log: Partial };
+ | { log: Partial }
+ | { span: Partial };
type StartResult = {
completed(): Promise;
@@ -479,7 +490,11 @@ export function createRunner(...paths: string[]) {
return;
}
- assertEnvelopeHeader(header, expected);
+ if (envelopeItemType === 'span') {
+ assertSpanEnvelopeHeader(header, expected);
+ } else {
+ assertEnvelopeHeader(header, expected);
+ }
expectCallbackCalled();
} catch (e) {
@@ -531,6 +546,9 @@ export function createRunner(...paths: string[]) {
} else if ('trace_metric' in expected) {
expectMetric(item[1] as SerializedMetricContainer, expected.trace_metric);
expectCallbackCalled();
+ } else if ('span' in expected) {
+ expectSpanContainer(item[1] as SerializedStreamedSpanContainer, expected.span);
+ expectCallbackCalled();
} else {
throw new Error(
`Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`,
@@ -797,6 +815,14 @@ function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricC
}
}
+function expectSpanContainer(item: SerializedStreamedSpanContainer, expected: ExpectedSpanContainer): void {
+ if (typeof expected === 'function') {
+ expected(item);
+ } else {
+ assertSentrySpanContainer(item, expected);
+ }
+}
+
/**
* Converts ESM import statements to CommonJS require statements
* @param content The content of an ESM file
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index 1b893f885e87..fb03d5fc57af 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -173,6 +173,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 8cf0a93d01bd..a5044bc6b29e 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -160,6 +160,7 @@ export {
unleashIntegration,
growthbookIntegration,
metrics,
+ spanStreamingIntegration,
} from '@sentry/node';
export {
diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts
index 2b2d4b7f9397..888524ed7c21 100644
--- a/packages/browser-utils/src/index.ts
+++ b/packages/browser-utils/src/index.ts
@@ -20,6 +20,8 @@ export { elementTimingIntegration, startTrackingElementTiming } from './metrics/
export { extractNetworkProtocol } from './metrics/utils';
+export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans';
+
export { addClickKeypressInstrumentationHandler } from './instrument/dom';
export { addHistoryInstrumentationHandler } from './instrument/history';
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
index 28d1f2bfaec8..1e6c974b543d 100644
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -75,8 +75,18 @@ let _lcpEntry: LargestContentfulPaint | undefined;
let _clsEntry: LayoutShift | undefined;
interface StartTrackingWebVitalsOptions {
- recordClsStandaloneSpans: boolean;
- recordLcpStandaloneSpans: boolean;
+ /**
+ * When `true`, CLS is tracked as a standalone span. When `false`, CLS is
+ * recorded as a measurement on the pageload span. When `undefined`, CLS
+ * tracking is skipped entirely (e.g. because span streaming handles it).
+ */
+ recordClsStandaloneSpans: boolean | undefined;
+ /**
+ * When `true`, LCP is tracked as a standalone span. When `false`, LCP is
+ * recorded as a measurement on the pageload span. When `undefined`, LCP
+ * tracking is skipped entirely (e.g. because span streaming handles it).
+ */
+ recordLcpStandaloneSpans: boolean | undefined;
client: Client;
}
@@ -97,9 +107,22 @@ export function startTrackingWebVitals({
if (performance.mark) {
WINDOW.performance.mark('sentry-tracing-init');
}
- const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP();
+
+ const lcpCleanupCallback =
+ recordLcpStandaloneSpans === true
+ ? trackLcpAsStandaloneSpan(client)
+ : recordLcpStandaloneSpans === false
+ ? _trackLCP()
+ : undefined;
+
+ const clsCleanupCallback =
+ recordClsStandaloneSpans === true
+ ? trackClsAsStandaloneSpan(client)
+ : recordClsStandaloneSpans === false
+ ? _trackCLS()
+ : undefined;
+
const ttfbCleanupCallback = _trackTtfb();
- const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS();
return (): void => {
lcpCleanupCallback?.();
diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts
index 831565f07408..b348d8195c84 100644
--- a/packages/browser-utils/src/metrics/inp.ts
+++ b/packages/browser-utils/src/metrics/inp.ts
@@ -37,7 +37,7 @@ const ELEMENT_NAME_TIMESTAMP_MAP = new Map();
* 60 seconds is the maximum for a plausible INP value
* (source: Me)
*/
-const MAX_PLAUSIBLE_INP_DURATION = 60;
+export const MAX_PLAUSIBLE_INP_DURATION = 60;
/**
* Start tracking INP webvital events.
*/
@@ -54,7 +54,7 @@ export function startTrackingINP(): () => void {
return () => undefined;
}
-const INP_ENTRY_MAP: Record = {
+export const INP_ENTRY_MAP: Record = {
click: 'click',
pointerdown: 'click',
pointerup: 'click',
diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts
index 4c461ec6776c..5dc9d78f4ce8 100644
--- a/packages/browser-utils/src/metrics/instrument.ts
+++ b/packages/browser-utils/src/metrics/instrument.ts
@@ -114,7 +114,6 @@ let _previousCls: Metric | undefined;
let _previousLcp: Metric | undefined;
let _previousTtfb: Metric | undefined;
let _previousInp: Metric | undefined;
-
/**
* Add a callback that will be triggered when a CLS metric is available.
* Returns a cleanup callback which can be called to remove the instrumentation handler.
diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts
new file mode 100644
index 000000000000..deeec8ede191
--- /dev/null
+++ b/packages/browser-utils/src/metrics/webVitalSpans.ts
@@ -0,0 +1,266 @@
+import type { Client, SpanAttributes } from '@sentry/core';
+import {
+ browserPerformanceTimeOrigin,
+ debug,
+ getActiveSpan,
+ getCurrentScope,
+ getRootSpan,
+ htmlTreeAsString,
+ SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
+ SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
+ SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ spanToJSON,
+ startInactiveSpan,
+ timestampInSeconds,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+import { WINDOW } from '../types';
+import { INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp';
+import type { InstrumentationHandlerCallback } from './instrument';
+import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument';
+import { listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils';
+
+interface WebVitalSpanOptions {
+ name: string;
+ op: string;
+ origin: string;
+ metricName: string;
+ value: number;
+ unit: string;
+ attributes?: SpanAttributes;
+ pageloadSpanId?: string;
+ startTime: number;
+ endTime?: number;
+}
+
+/**
+ * Emits a web vital span that flows through the span streaming pipeline.
+ */
+export function _emitWebVitalSpan(options: WebVitalSpanOptions): void {
+ const {
+ name,
+ op,
+ origin,
+ metricName,
+ value,
+ unit,
+ attributes: passedAttributes,
+ pageloadSpanId,
+ startTime,
+ endTime,
+ } = options;
+
+ const routeName = getCurrentScope().getScopeData().transactionName;
+
+ const attributes: SpanAttributes = {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
+ [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0,
+ [`browser.web_vital.${metricName}.value`]: value,
+ transaction: routeName,
+ // Web vital score calculation relies on the user agent
+ 'user_agent.original': WINDOW.navigator?.userAgent,
+ ...passedAttributes,
+ };
+
+ if (pageloadSpanId) {
+ attributes['sentry.pageload.span_id'] = pageloadSpanId;
+ }
+
+ const span = startInactiveSpan({
+ name,
+ attributes,
+ startTime,
+ });
+
+ if (span) {
+ span.addEvent(metricName, {
+ [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit,
+ [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value,
+ });
+
+ span.end(endTime ?? startTime);
+ }
+}
+
+/**
+ * Tracks LCP as a streamed span.
+ */
+export function trackLcpAsSpan(client: Client): void {
+ let lcpValue = 0;
+ let lcpEntry: LargestContentfulPaint | undefined;
+
+ if (!supportsWebVital('largest-contentful-paint')) {
+ return;
+ }
+
+ const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
+ const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
+ if (!entry) {
+ return;
+ }
+ lcpValue = metric.value;
+ lcpEntry = entry;
+ }, true);
+
+ listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => {
+ _sendLcpSpan(lcpValue, lcpEntry, pageloadSpanId);
+ cleanupLcpHandler();
+ });
+}
+
+/**
+ * Exported only for testing.
+ */
+export function _sendLcpSpan(
+ lcpValue: number,
+ entry: LargestContentfulPaint | undefined,
+ pageloadSpanId: string,
+): void {
+ DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`);
+
+ const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0;
+ const timeOrigin = msToSec(performanceTimeOrigin);
+ const endTime = msToSec(performanceTimeOrigin + (entry?.startTime || 0));
+ const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint';
+
+ const attributes: SpanAttributes = {};
+
+ if (entry) {
+ entry.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element));
+ entry.id && (attributes['browser.web_vital.lcp.id'] = entry.id);
+ entry.url && (attributes['browser.web_vital.lcp.url'] = entry.url);
+ entry.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime);
+ entry.renderTime != null && (attributes['browser.web_vital.lcp.render_time'] = entry.renderTime);
+ entry.size != null && (attributes['browser.web_vital.lcp.size'] = entry.size);
+ }
+
+ _emitWebVitalSpan({
+ name,
+ op: 'ui.webvital.lcp',
+ origin: 'auto.http.browser.lcp',
+ metricName: 'lcp',
+ value: lcpValue,
+ unit: 'millisecond',
+ attributes,
+ pageloadSpanId,
+ startTime: timeOrigin,
+ endTime,
+ });
+}
+
+/**
+ * Tracks CLS as a streamed span.
+ */
+export function trackClsAsSpan(client: Client): void {
+ let clsValue = 0;
+ let clsEntry: LayoutShift | undefined;
+
+ if (!supportsWebVital('layout-shift')) {
+ return;
+ }
+
+ const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => {
+ const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined;
+ if (!entry) {
+ return;
+ }
+ clsValue = metric.value;
+ clsEntry = entry;
+ }, true);
+
+ listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => {
+ _sendClsSpan(clsValue, clsEntry, pageloadSpanId);
+ cleanupClsHandler();
+ });
+}
+
+/**
+ * Exported only for testing.
+ */
+export function _sendClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string): void {
+ DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`);
+
+ const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds();
+ const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift';
+
+ const attributes: SpanAttributes = {};
+
+ if (entry?.sources) {
+ entry.sources.forEach((source, index) => {
+ attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node);
+ });
+ }
+
+ _emitWebVitalSpan({
+ name,
+ op: 'ui.webvital.cls',
+ origin: 'auto.http.browser.cls',
+ metricName: 'cls',
+ value: clsValue,
+ unit: '',
+ attributes,
+ pageloadSpanId,
+ startTime,
+ });
+}
+
+/**
+ * Tracks INP as a streamed span.
+ */
+export function trackInpAsSpan(_client: Client): void {
+ const onInp: InstrumentationHandlerCallback = ({ metric }) => {
+ if (metric.value == null) {
+ return;
+ }
+
+ // Guard against unrealistically long INP values (matching standalone INP handler)
+ if (msToSec(metric.value) > MAX_PLAUSIBLE_INP_DURATION) {
+ return;
+ }
+
+ const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]);
+
+ if (!entry) {
+ return;
+ }
+
+ _sendInpSpan(metric.value, entry);
+ };
+
+ addInpInstrumentationHandler(onInp);
+}
+
+/**
+ * Exported only for testing.
+ */
+export function _sendInpSpan(
+ inpValue: number,
+ entry: { name: string; startTime: number; duration: number; target?: unknown | null },
+): void {
+ DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`);
+
+ const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime);
+ const interactionType = INP_ENTRY_MAP[entry.name];
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
+ const routeName = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName;
+ const name = htmlTreeAsString(entry.target);
+
+ _emitWebVitalSpan({
+ name,
+ op: `ui.interaction.${interactionType}`,
+ origin: 'auto.http.browser.inp',
+ metricName: 'inp',
+ value: inpValue,
+ unit: 'millisecond',
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration,
+ transaction: routeName,
+ },
+ startTime,
+ endTime: startTime + msToSec(entry.duration),
+ });
+}
diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts
new file mode 100644
index 000000000000..44f91a779b64
--- /dev/null
+++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts
@@ -0,0 +1,377 @@
+import * as SentryCore from '@sentry/core';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans';
+
+vi.mock('@sentry/core', async () => {
+ const actual = await vi.importActual('@sentry/core');
+ return {
+ ...actual,
+ browserPerformanceTimeOrigin: vi.fn(),
+ timestampInSeconds: vi.fn(),
+ getCurrentScope: vi.fn(),
+ htmlTreeAsString: vi.fn(),
+ startInactiveSpan: vi.fn(),
+ getActiveSpan: vi.fn(),
+ getRootSpan: vi.fn(),
+ spanToJSON: vi.fn(),
+ };
+});
+
+// Mock WINDOW
+vi.mock('../../src/types', () => ({
+ WINDOW: {
+ navigator: { userAgent: 'test-user-agent' },
+ performance: {
+ getEntriesByType: vi.fn().mockReturnValue([]),
+ },
+ },
+}));
+
+describe('_emitWebVitalSpan', () => {
+ const mockSpan = {
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-transaction',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('creates a non-standalone span with correct attributes', () => {
+ _emitWebVitalSpan({
+ name: 'Test Vital',
+ op: 'ui.webvital.test',
+ origin: 'auto.http.browser.test',
+ metricName: 'test',
+ value: 100,
+ unit: 'millisecond',
+ startTime: 1.5,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({
+ name: 'Test Vital',
+ attributes: {
+ 'sentry.origin': 'auto.http.browser.test',
+ 'sentry.op': 'ui.webvital.test',
+ 'sentry.exclusive_time': 0,
+ 'browser.web_vital.test.value': 100,
+ transaction: 'test-transaction',
+ 'user_agent.original': 'test-user-agent',
+ },
+ startTime: 1.5,
+ });
+
+ // No standalone flag
+ expect(SentryCore.startInactiveSpan).not.toHaveBeenCalledWith(
+ expect.objectContaining({ experimental: expect.anything() }),
+ );
+
+ expect(mockSpan.addEvent).toHaveBeenCalledWith('test', {
+ 'sentry.measurement_unit': 'millisecond',
+ 'sentry.measurement_value': 100,
+ });
+
+ expect(mockSpan.end).toHaveBeenCalledWith(1.5);
+ });
+
+ it('includes pageloadSpanId when provided', () => {
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.webvital.test',
+ origin: 'auto.http.browser.test',
+ metricName: 'test',
+ value: 50,
+ unit: 'millisecond',
+ pageloadSpanId: 'abc123',
+ startTime: 1.0,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'sentry.pageload.span_id': 'abc123',
+ }),
+ }),
+ );
+ });
+
+ it('merges additional attributes', () => {
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.webvital.test',
+ origin: 'auto.http.browser.test',
+ metricName: 'test',
+ value: 50,
+ unit: 'millisecond',
+ attributes: { 'custom.attr': 'value' },
+ startTime: 1.0,
+ });
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'custom.attr': 'value',
+ }),
+ }),
+ );
+ });
+
+ it('handles when startInactiveSpan returns undefined', () => {
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(undefined as any);
+
+ expect(() => {
+ _emitWebVitalSpan({
+ name: 'Test',
+ op: 'ui.webvital.test',
+ origin: 'auto.http.browser.test',
+ metricName: 'test',
+ value: 50,
+ unit: 'millisecond',
+ startTime: 1.0,
+ });
+ }).not.toThrow();
+ });
+});
+
+describe('_sendLcpSpan', () => {
+ const mockSpan = {
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-route',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('sends a streamed LCP span with entry data', () => {
+ const mockEntry = {
+ element: { tagName: 'img' } as Element,
+ id: 'hero',
+ url: 'https://example.com/hero.jpg',
+ loadTime: 100,
+ renderTime: 150,
+ size: 50000,
+ startTime: 200,
+ } as LargestContentfulPaint;
+
+ _sendLcpSpan(250, mockEntry, 'pageload-123');
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: '
',
+ attributes: expect.objectContaining({
+ 'sentry.origin': 'auto.http.browser.lcp',
+ 'sentry.op': 'ui.webvital.lcp',
+ 'sentry.exclusive_time': 0,
+ 'sentry.pageload.span_id': 'pageload-123',
+ 'browser.web_vital.lcp.element': '
',
+ 'browser.web_vital.lcp.id': 'hero',
+ 'browser.web_vital.lcp.url': 'https://example.com/hero.jpg',
+ 'browser.web_vital.lcp.load_time': 100,
+ 'browser.web_vital.lcp.render_time': 150,
+ 'browser.web_vital.lcp.size': 50000,
+ }),
+ startTime: 1, // timeOrigin: 1000 / 1000
+ }),
+ );
+
+ expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', {
+ 'sentry.measurement_unit': 'millisecond',
+ 'sentry.measurement_value': 250,
+ });
+
+ // endTime = timeOrigin + entry.startTime = (1000 + 200) / 1000 = 1.2
+ expect(mockSpan.end).toHaveBeenCalledWith(1.2);
+ });
+
+ it('sends a streamed LCP span without entry data', () => {
+ _sendLcpSpan(0, undefined, 'pageload-456');
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Largest contentful paint',
+ startTime: 1, // timeOrigin: 1000 / 1000
+ }),
+ );
+ });
+});
+
+describe('_sendClsSpan', () => {
+ const mockSpan = {
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-route',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5);
+ vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
+ vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('sends a streamedCLS span with entry data and sources', () => {
+ const mockEntry: LayoutShift = {
+ name: 'layout-shift',
+ entryType: 'layout-shift',
+ startTime: 100,
+ duration: 0,
+ value: 0.1,
+ hadRecentInput: false,
+ sources: [
+ // @ts-expect-error - other properties are irrelevant
+ { node: { tagName: 'div' } as Element },
+ // @ts-expect-error - other properties are irrelevant
+ { node: { tagName: 'span' } as Element },
+ ],
+ toJSON: vi.fn(),
+ };
+
+ vi.mocked(SentryCore.htmlTreeAsString)
+ .mockReturnValueOnce('') // for the name
+ .mockReturnValueOnce('
') // for source 1
+ .mockReturnValueOnce('
'); // for source 2
+
+ _sendClsSpan(0.1, mockEntry, 'pageload-789');
+
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: '',
+ attributes: expect.objectContaining({
+ 'sentry.origin': 'auto.http.browser.cls',
+ 'sentry.op': 'ui.webvital.cls',
+ 'sentry.pageload.span_id': 'pageload-789',
+ 'browser.web_vital.cls.source.1': '
',
+ 'browser.web_vital.cls.source.2': '
',
+ }),
+ }),
+ );
+
+ expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', {
+ 'sentry.measurement_unit': '',
+ 'sentry.measurement_value': 0.1,
+ });
+ });
+
+ it('sends a streamedCLS span without entry data', () => {
+ _sendClsSpan(0, undefined, 'pageload-000');
+
+ expect(SentryCore.timestampInSeconds).toHaveBeenCalled();
+ expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Layout shift',
+ startTime: 1.5,
+ }),
+ );
+ });
+});
+
+describe('_sendInpSpan', () => {
+ const mockSpan = {
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ };
+
+ const mockScope = {
+ getScopeData: vi.fn().mockReturnValue({
+ transactionName: 'test-route',
+ }),
+ };
+
+ beforeEach(() => {
+ vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
+ vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
+ vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('