diff --git a/.size-limit.js b/.size-limit.js
index 955b2eed9aa0..44e701b3466b 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -124,7 +124,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'logger'),
gzip: true,
- limit: '27 KB',
+ limit: '28 KB',
},
{
name: '@sentry/browser (incl. Metrics & Logs)',
@@ -148,7 +148,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
- limit: '45 KB',
+ limit: '46 KB',
},
// Vue SDK (ESM)
{
@@ -262,7 +262,7 @@ module.exports = [
path: createCDNPath('bundle.replay.logs.metrics.min.js'),
gzip: false,
brotli: false,
- limit: '209 KB',
+ limit: '210 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
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..ffa63937bf32
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts
@@ -0,0 +1,71 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+/**
+ * This test details a limitation of span streaming in comparison to transaction-based tracing:
+ * We can no longer attach http PerformanceResourceTiming attributes to http.client spans in
+ * span streaming mode. The reason is that we track `http.client` spans in real time but only
+ * get the detailed timing information after the span already ended.
+ * We can probably fix this (somehat at least) but will do so in a follow-up PR.
+ * @see https://github.com/getsentry/sentry-javascript/issues/19613
+ */
+sentryTest(
+ "[limitation] doesn't add 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.not.objectContaining({
+ 'http.request.redirect_start': expect.any(Object),
+ 'http.request.redirect_end': expect.any(Object),
+ 'http.request.worker_start': expect.any(Object),
+ 'http.request.fetch_start': expect.any(Object),
+ 'http.request.domain_lookup_start': expect.any(Object),
+ 'http.request.domain_lookup_end': expect.any(Object),
+ 'http.request.connect_start': expect.any(Object),
+ 'http.request.secure_connection_start': expect.any(Object),
+ 'http.request.connection_end': expect.any(Object),
+ 'http.request.request_start': expect.any(Object),
+ 'http.request.response_start': expect.any(Object),
+ 'http.request.response_end': expect.any(Object),
+ 'http.request.time_to_first_byte': expect.any(Object),
+ 'network.protocol.version': expect.any(Object),
+ }),
+ }),
+ );
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js
new file mode 100644
index 000000000000..385e9ed6b6cf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 1000,
+ enableLongTask: false,
+ _experiments: {
+ enableInteractions: true,
+ },
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js
new file mode 100644
index 000000000000..ff9057926396
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/subject.js
@@ -0,0 +1,16 @@
+const blockUI = e => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 70) {
+ //
+ }
+
+ e.target.classList.add('clicked');
+};
+
+document.querySelector('[data-test-id=interaction-button]').addEventListener('click', blockUI);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html
new file mode 100644
index 000000000000..64e944054632
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/template.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts
new file mode 100644
index 000000000000..fd384d0d3ff9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts
@@ -0,0 +1,134 @@
+import { expect } from '@playwright/test';
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('captures streamed interaction span tree. @firefox', async ({ browserName, getLocalTestUrl, page }) => {
+ const supportedBrowsers = ['chromium', 'firefox'];
+
+ sentryTest.skip(shouldSkipTracingTest() || !supportedBrowsers.includes(browserName) || testingCdnBundle());
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const interactionSpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(span => getSpanOp(span) === 'ui.action.click'),
+ );
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ // wait for pageload span to finish before clicking the interaction button
+ const pageloadSpan = await pageloadSpanPromise;
+
+ await page.locator('[data-test-id=interaction-button]').click();
+ await page.locator('.clicked[data-test-id=interaction-button]').isVisible();
+
+ const interactionSpanTree = await interactionSpansPromise;
+
+ const interactionSegmentSpan = interactionSpanTree.find(span => !!span.is_segment);
+
+ expect(interactionSegmentSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'ui.action.click',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'manual', // TODO: This is incorrect but not from span streaming.
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: interactionSegmentSpan!.span_id,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: '/index.html',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ 'sentry.span.source': {
+ type: 'string',
+ value: 'url',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ name: '/index.html',
+ span_id: interactionSegmentSpan!.span_id,
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: pageloadSpan.trace_id, // same trace id as pageload
+ });
+
+ const loAFSpans = interactionSpanTree.filter(span => getSpanOp(span)?.startsWith('ui.long-animation-frame'));
+ expect(loAFSpans).toHaveLength(1);
+
+ const interactionSpan = interactionSpanTree.find(span => getSpanOp(span) === 'ui.interaction.click');
+ expect(interactionSpan).toEqual({
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'ui.interaction.click',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.ui.browser.metrics',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: interactionSegmentSpan!.span_id,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: '/index.html',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: false,
+ name: 'body > button.clicked',
+ parent_span_id: interactionSegmentSpan!.span_id,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: pageloadSpan.trace_id, // same trace id as pageload
+ });
+
+ const interactionSpanDuration = (interactionSpan!.end_timestamp - interactionSpan!.start_timestamp) * 1000;
+ expect(interactionSpanDuration).toBeGreaterThan(65);
+ expect(interactionSpanDuration).toBeLessThan(200);
+ expect(interactionSpan?.status).toBe('ok');
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js
new file mode 100644
index 000000000000..63afee65329a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/init.js
@@ -0,0 +1,22 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampler: ctx => {
+ if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') {
+ return 1;
+ }
+ return ctx.inheritOrSampleWith(0);
+ },
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html
new file mode 100644
index 000000000000..f26a602c7c6f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/template.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts
new file mode 100644
index 000000000000..a97e13a4890a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts
@@ -0,0 +1,153 @@
+import { expect } from '@playwright/test';
+import { extractTraceparentData, parseBaggageHeader, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
+ sentryTest('continues sampling decision from initial pageload span', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const pageloadSampleRand = Number(envelope[0].trace?.sample_rand);
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1);
+ expect(Number.isNaN(pageloadSampleRand)).toBe(false);
+ expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
+ expect(pageloadSampleRand).toBeLessThanOrEqual(1);
+
+ return { pageloadSpan, pageloadSampleRand };
+ });
+
+ const customTraceSpan = await sentryTest.step('Custom trace', async () => {
+ const customEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+ await page.locator('#btn1').click();
+ const envelope = await customEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!;
+
+ expect(span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ // although we "continue the trace" from pageload, this is actually a root span,
+ // so there must not be a parent span id
+ expect(span.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand);
+
+ return span;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#foo`);
+ const envelope = await navigationEnvelopePromise;
+ const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id);
+ expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(navSpan.links).toEqual([
+ {
+ trace_id: customTraceSpan.trace_id,
+ span_id: customTraceSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+ expect(navSpan.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand);
+ });
+ });
+
+ sentryTest('Propagates continued sampling decision to outgoing requests', async ({ page, getLocalTestUrl }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadSpan, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const pageloadSampleRand = Number(envelope[0].trace?.sample_rand);
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(pageloadSampleRand);
+ expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
+ expect(pageloadSampleRand).toBeLessThanOrEqual(1);
+ expect(Number.isNaN(pageloadSampleRand)).toBe(false);
+
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(pageloadSpan.attributes?.['sentry.sample_rate']?.value).toBe(1);
+
+ return { pageloadSpan, pageloadSampleRand };
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const fetchEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+ const fetchEnvelope = await fetchEnvelopePromise;
+
+ const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand);
+ const fetchTraceSpans = fetchEnvelope[1][0][1].items;
+ const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!;
+ const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client');
+
+ expect(fetchTraceSampleRand).toBe(pageloadSampleRand);
+
+ expect(fetchTraceSpan.attributes?.['sentry.sample_rate']?.value).toEqual(
+ pageloadSpan.attributes?.['sentry.sample_rate']?.value,
+ );
+ expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: fetchTraceSpan.trace_id,
+ parentSpanId: httpClientSpan?.span_id,
+ parentSampled: true,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${pageloadSampleRand}`,
+ 'sentry-sample_rate': '1',
+ 'sentry-sampled': 'true',
+ 'sentry-trace_id': fetchTraceSpan.trace_id,
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js
new file mode 100644
index 000000000000..d570ac45144c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/init.js
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ traceLifecycle: 'stream',
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampleRate: 1,
+ debug: true,
+ sendClientReports: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html
new file mode 100644
index 000000000000..6347fa37fc00
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/template.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts
new file mode 100644
index 000000000000..73b4bea99e22
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts
@@ -0,0 +1,97 @@
+import { expect } from '@playwright/test';
+import type { ClientReport } from '@sentry/core';
+import { extractTraceparentData, parseBaggageHeader } from '@sentry/core';
+import type { SerializedStreamedSpan } from '@sentry/core/src';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+ waitForTracingHeadersOnUrl,
+} from '../../../../../../utils/helpers';
+import { observeStreamedSpan } from '../../../../../../utils/spanUtils';
+
+const metaTagSampleRand = 0.9;
+const metaTagSampleRate = 0.2;
+const metaTagTraceId = '12345678901234567890123456789012';
+
+sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => {
+ sentryTest(
+ 'Continues negative sampling decision from meta tag across all traces and downstream propagations',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansReceived: SerializedStreamedSpan[] = [];
+ observeStreamedSpan(page, span => {
+ spansReceived.push(span);
+ return false;
+ });
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await sentryTest.step('Initial pageload', async () => {
+ await page.goto(url);
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Custom instrumented button click', async () => {
+ await page.locator('#btn1').click();
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ await page.goto(`${url}#foo`);
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: expect.not.stringContaining(metaTagTraceId),
+ parentSpanId: expect.stringMatching(/^[\da-f]{16}$/),
+ parentSampled: false,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${metaTagSampleRand}`,
+ 'sentry-sample_rate': `${metaTagSampleRate}`,
+ 'sentry-sampled': 'false',
+ 'sentry-trace_id': expect.not.stringContaining(metaTagTraceId),
+ 'sentry-transaction': 'custom root span 2',
+ });
+
+ expect(spansReceived).toHaveLength(0);
+ });
+
+ await sentryTest.step('Client report', async () => {
+ await hidePage(page);
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 4,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ expect(spansReceived).toHaveLength(0);
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js
new file mode 100644
index 000000000000..177fe4c4aeaf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/init.js
@@ -0,0 +1,20 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'session-storage',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampler: ({ inheritOrSampleWith }) => {
+ return inheritOrSampleWith(0);
+ },
+ debug: true,
+ sendClientReports: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html
new file mode 100644
index 000000000000..9a0719b7e505
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-1.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Another Page
+ Go To the next page
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html
new file mode 100644
index 000000000000..27cd47bba7c1
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/page-2.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Another Page
+ Go To the next page
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js
new file mode 100644
index 000000000000..ec0264fa49ef
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1?.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2?.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html
new file mode 100644
index 000000000000..eab1fecca6c4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/template.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Go To another page
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts
new file mode 100644
index 000000000000..4cafe023b57d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts
@@ -0,0 +1,116 @@
+import { expect } from '@playwright/test';
+import type { ClientReport } from '@sentry/core';
+import { extractTraceparentData, parseBaggageHeader } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+ waitForTracingHeadersOnUrl,
+} from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+const metaTagSampleRand = 0.9;
+const metaTagSampleRate = 0.2;
+const metaTagTraceIdIndex = '12345678901234567890123456789012';
+const metaTagTraceIdPage1 = 'a2345678901234567890123456789012';
+
+sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => {
+ sentryTest(
+ 'meta tag decision has precedence over sampling decision from previous trace in session storage',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await sentryTest.step('Initial pageload', async () => {
+ // negative sampling decision -> no pageload span
+ await page.goto(url);
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ // The fetch requests starts a new trace on purpose. So we only want the
+ // sampling decision and rand to be the same as from the meta tag but not the trace id or DSC
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: expect.not.stringContaining(metaTagTraceIdIndex),
+ parentSpanId: expect.stringMatching(/^[\da-f]{16}$/),
+ parentSampled: false,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${metaTagSampleRand}`,
+ 'sentry-sample_rate': `${metaTagSampleRate}`,
+ 'sentry-sampled': 'false',
+ 'sentry-trace_id': expect.not.stringContaining(metaTagTraceIdIndex),
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+
+ await sentryTest.step('Client report', async () => {
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 2,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ await sentryTest.step('Navigate to another page with meta tags', async () => {
+ const page1PageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload' && s.trace_id === metaTagTraceIdPage1),
+ );
+ await page.locator('a').click();
+
+ const envelope = await page1PageloadEnvelopePromise;
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2);
+ expect(pageloadSpan.trace_id).toEqual(metaTagTraceIdPage1);
+ });
+
+ await sentryTest.step('Navigate to another page without meta tags', async () => {
+ const page2PageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env =>
+ !!env[1][0][1].items.find(
+ s =>
+ getSpanOp(s) === 'pageload' && s.trace_id !== metaTagTraceIdPage1 && s.trace_id !== metaTagTraceIdIndex,
+ ),
+ );
+ await page.locator('a').click();
+
+ const envelope = await page2PageloadEnvelopePromise;
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(0.12);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(0.2);
+ expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdPage1);
+ expect(pageloadSpan.trace_id).not.toEqual(metaTagTraceIdIndex);
+ });
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js
new file mode 100644
index 000000000000..a1ddc5465950
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/init.js
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ // only take into account sampling from meta tag; otherwise sample negatively
+ tracesSampleRate: 0,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html
new file mode 100644
index 000000000000..7ceca6fec2a3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/template.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts
new file mode 100644
index 000000000000..08cee9111b8a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta/test.ts
@@ -0,0 +1,171 @@
+import { expect } from '@playwright/test';
+import {
+ extractTraceparentData,
+ parseBaggageHeader,
+ SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+const metaTagSampleRand = 0.051121;
+const metaTagSampleRate = 0.2;
+
+sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => {
+ sentryTest('Continues sampling decision across all traces from meta tag', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(span.attributes?.['sentry.sample_rate']).toBeUndefined();
+ expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined();
+
+ return span;
+ });
+
+ const customTraceSpan = await sentryTest.step('Custom trace', async () => {
+ const customEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+
+ await page.locator('#btn1').click();
+
+ const envelope = await customEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!;
+
+ expect(span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(span.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate);
+ expect(envelope[0].trace?.sampled).toBe('true');
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(span.attributes?.['sentry.sample_rate']).toBeUndefined();
+
+ // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header)
+ expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate);
+
+ return span;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+
+ await page.goto(`${url}#foo`);
+
+ const envelope = await navigationEnvelopePromise;
+ const navSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id);
+
+ expect(navSpan.parent_span_id).toBeUndefined();
+
+ expect(Number(envelope[0].trace?.sample_rand)).toEqual(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toEqual(metaTagSampleRate);
+ expect(envelope[0].trace?.sampled).toEqual('true');
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(navSpan.attributes?.['sentry.sample_rate']).toBeUndefined();
+
+ // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header)
+ expect(navSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(metaTagSampleRate);
+ });
+ });
+
+ sentryTest(
+ 'Propagates continued tag sampling decision to outgoing requests',
+ async ({ page, getLocalTestUrl }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(Number(envelope[0].trace?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(envelope[0].trace?.sample_rate)).toBe(metaTagSampleRate);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(span.attributes?.['sentry.sample_rate']).toBeUndefined();
+ expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined();
+
+ return span;
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const fetchEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+ const fetchEnvelope = await fetchEnvelopePromise;
+
+ const fetchTraceSampleRand = Number(fetchEnvelope[0].trace?.sample_rand);
+ const fetchTraceSpans = fetchEnvelope[1][0][1].items;
+ const fetchTraceSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'custom')!;
+ const httpClientSpan = fetchTraceSpans.find(s => getSpanOp(s) === 'http.client');
+
+ expect(fetchTraceSampleRand).toEqual(metaTagSampleRand);
+
+ expect(fetchTraceSpan.attributes?.['sentry.sample_rate']).toBeUndefined();
+ expect(fetchTraceSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]?.value).toBe(
+ metaTagSampleRate,
+ );
+
+ expect(fetchTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: fetchTraceSpan.trace_id,
+ parentSpanId: httpClientSpan?.span_id,
+ parentSampled: true,
+ });
+
+ expect(parseBaggageHeader(baggage)).toEqual({
+ 'sentry-environment': 'production',
+ 'sentry-public_key': 'public',
+ 'sentry-sample_rand': `${metaTagSampleRand}`,
+ 'sentry-sample_rate': `${metaTagSampleRate}`,
+ 'sentry-sampled': 'true',
+ 'sentry-trace_id': fetchTraceSpan.trace_id,
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js
new file mode 100644
index 000000000000..623db0ecc028
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/init.js
@@ -0,0 +1,29 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: true,
+ enableInp: false,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracePropagationTargets: ['sentry-test-external.io'],
+ tracesSampler: ctx => {
+ if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') {
+ return 1;
+ }
+ if (ctx.name === 'custom root span 1') {
+ return 0;
+ }
+ if (ctx.name === 'custom root span 2') {
+ return 1;
+ }
+ return ctx.inheritOrSampleWith(0);
+ },
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js
new file mode 100644
index 000000000000..de60904fab3a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/subject.js
@@ -0,0 +1,17 @@
+const btn1 = document.getElementById('btn1');
+
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => {
+ await fetch('http://sentry-test-external.io');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html
new file mode 100644
index 000000000000..f26a602c7c6f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/template.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts
new file mode 100644
index 000000000000..46805496a676
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts
@@ -0,0 +1,152 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser';
+import type { ClientReport } from '@sentry/core';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+ waitForClientReportRequest,
+} from '../../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils';
+
+/**
+ * This test demonstrates that:
+ * - explicit sampling decisions in `tracesSampler` has precedence over consistent sampling
+ * - despite consistentTraceSampling being activated, there are still a lot of cases where the trace chain can break
+ */
+sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
+ sentryTest('explicit sampling decisions in `tracesSampler` have precedence', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadSpan } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ await page.goto(url);
+
+ const envelope = await pageloadEnvelopePromise;
+ const pageloadSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'pageload')!;
+
+ expect(pageloadSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1);
+ expect(Number(envelope[0].trace?.sample_rand)).toBeGreaterThanOrEqual(0);
+
+ return { pageloadSpan };
+ });
+
+ await sentryTest.step('Custom trace is sampled negatively (explicitly in tracesSampler)', async () => {
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await page.locator('#btn1').click();
+
+ await page.waitForTimeout(500);
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 1,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ await sentryTest.step('Subsequent navigation trace is also sampled negatively', async () => {
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await page.goto(`${url}#foo`);
+
+ await page.waitForTimeout(500);
+
+ await hidePage(page);
+
+ const clientReport = envelopeRequestParser(await clientReportPromise);
+
+ expect(clientReport).toEqual({
+ timestamp: expect.any(Number),
+ discarded_events: [
+ {
+ category: 'transaction',
+ quantity: 1,
+ reason: 'sample_rate',
+ },
+ ],
+ });
+ });
+
+ const { customTrace2Span } = await sentryTest.step(
+ 'Custom trace 2 is sampled positively (explicitly in tracesSampler)',
+ async () => {
+ const customEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'custom'),
+ );
+
+ await page.locator('#btn2').click();
+
+ const envelope = await customEnvelopePromise;
+ const customTrace2Span = envelope[1][0][1].items.find(s => getSpanOp(s) === 'custom')!;
+
+ expect(customTrace2Span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1);
+ expect(customTrace2Span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(customTrace2Span.parent_span_id).toBeUndefined();
+
+ expect(customTrace2Span.links).toEqual([
+ {
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ sampled: false,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ },
+ ]);
+
+ return { customTrace2Span };
+ },
+ );
+
+ await sentryTest.step('Navigation trace is sampled positively (inherited from previous trace)', async () => {
+ const navigationEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => env[0].trace?.sampled === 'true' && !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+
+ await page.goto(`${url}#bar`);
+
+ const envelope = await navigationEnvelopePromise;
+ const navigationSpan = envelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navigationSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]?.value).toBe(1);
+ expect(navigationSpan.trace_id).not.toEqual(customTrace2Span.trace_id);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ expect(navigationSpan.links).toEqual([
+ {
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ sampled: true,
+ span_id: customTrace2Span.span_id,
+ trace_id: customTrace2Span.trace_id,
+ },
+ ]);
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js
new file mode 100644
index 000000000000..2a929a7e5083
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/subject.js
@@ -0,0 +1,14 @@
+const btn1 = document.getElementById('btn1');
+const btn2 = document.getElementById('btn2');
+
+btn1.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {});
+ });
+});
+
+btn2.addEventListener('click', () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, () => {});
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html
new file mode 100644
index 000000000000..f26a602c7c6f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/template.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts
new file mode 100644
index 000000000000..d6e45901f959
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts
@@ -0,0 +1,63 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest('manually started custom traces are linked correctly in the chain', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ return pageloadSpanPromise;
+ });
+
+ const customTraceSpan = await sentryTest.step('Custom trace', async () => {
+ const customSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'custom');
+ await page.locator('#btn1').click();
+ const span = await customSpanPromise;
+
+ expect(span.trace_id).not.toEqual(pageloadSpan.trace_id);
+ expect(span.links).toEqual([
+ {
+ trace_id: pageloadSpan.trace_id,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ return span;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navSpan = await navigationSpanPromise;
+
+ expect(navSpan.trace_id).not.toEqual(customTraceSpan.trace_id);
+ expect(navSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+
+ expect(navSpan.links).toEqual([
+ {
+ trace_id: customTraceSpan.trace_id,
+ span_id: customTraceSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts
new file mode 100644
index 000000000000..80e500437f79
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts
@@ -0,0 +1,95 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const navigation1SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navigation1Span = await navigation1SpanPromise;
+
+ const navigation2SpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#bar`);
+ const navigation2Span = await navigation2SpanPromise;
+
+ const pageloadTraceId = pageloadSpan.trace_id;
+ const navigation1TraceId = navigation1Span.trace_id;
+ const navigation2TraceId = navigation2Span.trace_id;
+
+ expect(pageloadSpan.links).toBeUndefined();
+
+ expect(navigation1Span.links).toEqual([
+ {
+ trace_id: pageloadTraceId,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigation1Span.attributes?.['sentry.previous_trace']).toEqual({
+ type: 'string',
+ value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`,
+ });
+
+ expect(navigation2Span.links).toEqual([
+ {
+ trace_id: navigation1TraceId,
+ span_id: navigation1Span.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigation2Span.attributes?.['sentry.previous_trace']).toEqual({
+ type: 'string',
+ value: `${navigation1TraceId}-${navigation1Span.span_id}-1`,
+ });
+
+ expect(pageloadTraceId).not.toEqual(navigation1TraceId);
+ expect(navigation1TraceId).not.toEqual(navigation2TraceId);
+ expect(pageloadTraceId).not.toEqual(navigation2TraceId);
+});
+
+sentryTest("doesn't link between hard page reloads by default", async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await sentryTest.step('First pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageload1Span = await pageloadSpanPromise;
+
+ expect(pageload1Span).toBeDefined();
+ expect(pageload1Span.links).toBeUndefined();
+ });
+
+ await sentryTest.step('Second pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.reload();
+ const pageload2Span = await pageloadSpanPromise;
+
+ expect(pageload2Span).toBeDefined();
+ expect(pageload2Span.links).toBeUndefined();
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js
new file mode 100644
index 000000000000..749560a5c459
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ debug: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js
new file mode 100644
index 000000000000..f07f76ecd692
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tracesSampleRate: 1,
+ integrations: [
+ Sentry.browserTracingIntegration({ _experiments: { enableInteractions: true } }),
+ Sentry.spanStreamingIntegration(),
+ ],
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html
new file mode 100644
index 000000000000..7f6845239468
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/template.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts
new file mode 100644
index 000000000000..c34aba99dbdd
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts
@@ -0,0 +1,79 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+/*
+ This is quite peculiar behavior but it's a result of the route-based trace lifetime.
+ Once we shortened trace lifetime, this whole scenario will change as the interaction
+ spans will be their own trace. So most likely, we can replace this test with a new one
+ that covers the new default behavior.
+*/
+sentryTest(
+ 'only the first root spans in the trace link back to the previous trace',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const span = await pageloadSpanPromise;
+
+ expect(span).toBeDefined();
+ expect(span.links).toBeUndefined();
+
+ return span;
+ });
+
+ await sentryTest.step('Click Before navigation', async () => {
+ const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click');
+ await page.click('#btn');
+ const interactionSpan = await interactionSpanPromise;
+
+ // sanity check: route-based trace lifetime means the trace_id should be the same
+ expect(interactionSpan.trace_id).toBe(pageloadSpan.trace_id);
+
+ // no links yet as previous root span belonged to same trace
+ expect(interactionSpan.links).toBeUndefined();
+ });
+
+ const navigationSpan = await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const span = await navigationSpanPromise;
+
+ expect(getSpanOp(span)).toBe('navigation');
+ expect(span.links).toEqual([
+ {
+ trace_id: pageloadSpan.trace_id,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(span.trace_id).not.toEqual(span.links![0].trace_id);
+ return span;
+ });
+
+ await sentryTest.step('Click After navigation', async () => {
+ const interactionSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.action.click');
+ await page.click('#btn');
+ const interactionSpan = await interactionSpanPromise;
+
+ // sanity check: route-based trace lifetime means the trace_id should be the same
+ expect(interactionSpan.trace_id).toBe(navigationSpan.trace_id);
+
+ // since this is the second root span in the trace, it doesn't link back to the previous trace
+ expect(interactionSpan.links).toBeUndefined();
+ });
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html
new file mode 100644
index 000000000000..2221bd0fee1d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts
new file mode 100644
index 000000000000..cbcc231593ea
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts
@@ -0,0 +1,50 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ "links back to previous trace's local root span if continued from meta tags",
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const metaTagTraceId = '12345678901234567890123456789012';
+
+ const pageloadSpan = await sentryTest.step('Initial pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const span = await pageloadSpanPromise;
+
+ // sanity check
+ expect(span.trace_id).toBe(metaTagTraceId);
+ expect(span.links).toBeUndefined();
+
+ return span;
+ });
+
+ const navigationSpan = await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ return navigationSpanPromise;
+ });
+
+ expect(navigationSpan.links).toEqual([
+ {
+ trace_id: metaTagTraceId,
+ span_id: pageloadSpan.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigationSpan.trace_id).not.toEqual(metaTagTraceId);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js
new file mode 100644
index 000000000000..778092cf026b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/init.js
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ // We want to ignore redirects for this test
+ integrations: [Sentry.browserTracingIntegration({ detectRedirects: false }), Sentry.spanStreamingIntegration()],
+ tracesSampler: ctx => {
+ if (ctx.attributes['sentry.origin'] === 'auto.pageload.browser') {
+ return 0;
+ }
+ return 1;
+ },
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts
new file mode 100644
index 000000000000..06366eb9921a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts
@@ -0,0 +1,44 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest('includes a span link to a previously negatively sampled span', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await sentryTest.step('Initial pageload', async () => {
+ // No span envelope expected here because this pageload span is sampled negatively!
+ await page.goto(url);
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navigationSpan = await navigationSpanPromise;
+
+ expect(getSpanOp(navigationSpan)).toBe('navigation');
+ expect(navigationSpan.links).toEqual([
+ {
+ trace_id: expect.stringMatching(/[a-f\d]{32}/),
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ sampled: false,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(navigationSpan.attributes?.['sentry.previous_trace']).toEqual({
+ type: 'string',
+ value: expect.stringMatching(/[a-f\d]{32}-[a-f\d]{16}-0/),
+ });
+
+ expect(navigationSpan.trace_id).not.toEqual(navigationSpan.links![0].trace_id);
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js
new file mode 100644
index 000000000000..e51af56c2a9d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ linkPreviousTrace: 'session-storage' }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts
new file mode 100644
index 000000000000..96a5bbeacc6d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts
@@ -0,0 +1,42 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest('adds link between hard page reloads when opting into sessionStorage', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageload1Span = await sentryTest.step('First pageload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const span = await pageloadSpanPromise;
+ expect(span).toBeDefined();
+ expect(span.links).toBeUndefined();
+ return span;
+ });
+
+ const pageload2Span = await sentryTest.step('Hard page reload', async () => {
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.reload();
+ return pageloadSpanPromise;
+ });
+
+ expect(pageload2Span.links).toEqual([
+ {
+ trace_id: pageload1Span.trace_id,
+ span_id: pageload1Span.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ },
+ ]);
+
+ expect(pageload1Span.trace_id).not.toEqual(pageload2Span.trace_id);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js
new file mode 100644
index 000000000000..ee197adaa33c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js
@@ -0,0 +1,18 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongTask: false,
+ enableLongAnimationFrame: true,
+ instrumentPageLoad: false,
+ enableInp: false,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js
new file mode 100644
index 000000000000..b02ed6efa33b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/subject.js
@@ -0,0 +1,18 @@
+function getElapsed(startTime) {
+ const time = Date.now();
+ return time - startTime;
+}
+
+function handleClick() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+ window.history.pushState({}, '', `#myHeading`);
+}
+
+const button = document.getElementById('clickme');
+
+console.log('button', button);
+
+button.addEventListener('click', handleClick);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html
new file mode 100644
index 000000000000..6a6a89752f20
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+ My Heading
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts
new file mode 100644
index 000000000000..3054c1c84bcb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/test.ts
@@ -0,0 +1,25 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ "doesn't capture long animation frame that starts before a navigation.",
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation'));
+
+ await page.goto(url);
+
+ await page.locator('#clickme').click();
+
+ const spans = await navigationSpansPromise;
+
+ const loafSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+ expect(loafSpans).toHaveLength(0);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js
new file mode 100644
index 000000000000..195a094070be
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/assets/script.js
@@ -0,0 +1,12 @@
+(() => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 101) {
+ //
+ }
+})();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js
new file mode 100644
index 000000000000..965613d5464e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html
new file mode 100644
index 000000000000..62aed26413f8
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Rendered Before Long Animation Frame
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts
new file mode 100644
index 000000000000..7ba1dddd0c90
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/test.ts
@@ -0,0 +1,28 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'does not capture long animation frame when flag is disabled.',
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) =>
+ route.fulfill({ path: `${__dirname}/assets/script.js` }),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui'));
+
+ expect(uiSpans.length).toBe(0);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js
new file mode 100644
index 000000000000..10552eeb5bd5
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/assets/script.js
@@ -0,0 +1,25 @@
+function getElapsed(startTime) {
+ const time = Date.now();
+ return time - startTime;
+}
+
+function handleClick() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+function start() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+// trigger 2 long-animation-frame events
+// one from the top-level and the other from an event-listener
+start();
+
+const button = document.getElementById('clickme');
+button.addEventListener('click', handleClick);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js
new file mode 100644
index 000000000000..1f6cc0a8f463
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongTask: false,
+ enableLongAnimationFrame: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html
new file mode 100644
index 000000000000..c157aa80cb8d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+ Rendered Before Long Animation Frame
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts
new file mode 100644
index 000000000000..c1e7efa5e8d8
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/test.ts
@@ -0,0 +1,109 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'captures long animation frame span for top-level script.',
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) =>
+ route.fulfill({ path: `${__dirname}/assets/script.js` }),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(1);
+
+ const topLevelUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js',
+ )!;
+
+ expect(topLevelUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ 'browser.script.source_char_position': expect.objectContaining({ value: 0 }),
+ 'browser.script.invoker': {
+ type: 'string',
+ value: 'https://sentry-test-site.example/path/to/script.js',
+ },
+ 'browser.script.invoker_type': { type: 'string', value: 'classic-script' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = topLevelUISpan.start_timestamp ?? 0;
+ const end = topLevelUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+ },
+);
+
+sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ // trigger long animation frame function
+ await page.getByRole('button').click();
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(2);
+
+ const eventListenerUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick',
+ )!;
+
+ expect(eventListenerUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' },
+ 'browser.script.invoker_type': { type: 'string', value: 'event-listener' },
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = eventListenerUISpan.start_timestamp ?? 0;
+ const end = eventListenerUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js
new file mode 100644
index 000000000000..10552eeb5bd5
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/assets/script.js
@@ -0,0 +1,25 @@
+function getElapsed(startTime) {
+ const time = Date.now();
+ return time - startTime;
+}
+
+function handleClick() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+function start() {
+ const startTime = Date.now();
+ while (getElapsed(startTime) < 105) {
+ //
+ }
+}
+
+// trigger 2 long-animation-frame events
+// one from the top-level and the other from an event-listener
+start();
+
+const button = document.getElementById('clickme');
+button.addEventListener('click', handleClick);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js
new file mode 100644
index 000000000000..3e3eedaf49b7
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongTask: true,
+ enableLongAnimationFrame: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html
new file mode 100644
index 000000000000..c157aa80cb8d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+ Rendered Before Long Animation Frame
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts
new file mode 100644
index 000000000000..4f9207fa1e34
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/test.ts
@@ -0,0 +1,111 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'captures long animation frame span for top-level script.',
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ // Long animation frame should take priority over long tasks
+
+ await page.route('**/path/to/script.js', (route: Route) =>
+ route.fulfill({ path: `${__dirname}/assets/script.js` }),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(1);
+
+ const topLevelUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'https://sentry-test-site.example/path/to/script.js',
+ )!;
+
+ expect(topLevelUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ 'browser.script.source_char_position': expect.objectContaining({ value: 0 }),
+ 'browser.script.invoker': {
+ type: 'string',
+ value: 'https://sentry-test-site.example/path/to/script.js',
+ },
+ 'browser.script.invoker_type': { type: 'string', value: 'classic-script' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = topLevelUISpan.start_timestamp ?? 0;
+ const end = topLevelUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+ },
+);
+
+sentryTest('captures long animation frame span for event listener.', async ({ browserName, getLocalTestUrl, page }) => {
+ // Long animation frames only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ // trigger long animation frame function
+ await page.getByRole('button').click();
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui.long-animation-frame'));
+
+ expect(uiSpans.length).toBeGreaterThanOrEqual(2);
+
+ const eventListenerUISpan = uiSpans.find(
+ s => s.attributes?.['browser.script.invoker']?.value === 'BUTTON#clickme.onclick',
+ )!;
+
+ expect(eventListenerUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'browser.script.invoker': { type: 'string', value: 'BUTTON#clickme.onclick' },
+ 'browser.script.invoker_type': { type: 'string', value: 'event-listener' },
+ 'code.filepath': { type: 'string', value: 'https://sentry-test-site.example/path/to/script.js' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'ui.long-animation-frame' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.ui.browser.metrics' },
+ }),
+ }),
+ );
+
+ const start = eventListenerUISpan.start_timestamp ?? 0;
+ const end = eventListenerUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js
new file mode 100644
index 000000000000..f6e5ce777e06
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js
@@ -0,0 +1,19 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongAnimationFrame: false,
+ instrumentPageLoad: false,
+ instrumentNavigation: true,
+ enableInp: false,
+ enableLongTask: true,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js
new file mode 100644
index 000000000000..d814f8875715
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/subject.js
@@ -0,0 +1,17 @@
+const longTaskButton = document.getElementById('myButton');
+
+longTaskButton?.addEventListener('click', () => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 500) {
+ //
+ }
+
+ // trigger a navigation in the same event loop tick
+ window.history.pushState({}, '', '#myHeading');
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html
new file mode 100644
index 000000000000..c2cb2a8129fe
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/template.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+ Heading
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts
new file mode 100644
index 000000000000..74ce32706584
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/test.ts
@@ -0,0 +1,29 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest(
+ "doesn't capture long task spans starting before a navigation in the navigation transaction",
+ async ({ browserName, getLocalTestUrl, page }) => {
+ // Long tasks only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('**/path/to/script.js', route => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const navigationSpansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'navigation'));
+
+ await page.goto(url);
+
+ await page.locator('#myButton').click();
+
+ const spans = await navigationSpansPromise;
+
+ const navigationSpan = spans.find(s => getSpanOp(s) === 'navigation');
+ expect(navigationSpan).toBeDefined();
+
+ const longTaskSpans = spans.filter(s => getSpanOp(s) === 'ui.long-task');
+ expect(longTaskSpans).toHaveLength(0);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js
new file mode 100644
index 000000000000..195a094070be
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/assets/script.js
@@ -0,0 +1,12 @@
+(() => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 101) {
+ //
+ }
+})();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js
new file mode 100644
index 000000000000..965613d5464e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html
new file mode 100644
index 000000000000..b03231da2c65
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts
new file mode 100644
index 000000000000..83600f5d4a6a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/test.ts
@@ -0,0 +1,23 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest("doesn't capture long task spans when flag is disabled.", async ({ browserName, getLocalTestUrl, page }) => {
+ // Long tasks only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui'));
+
+ expect(uiSpans.length).toBe(0);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js
new file mode 100644
index 000000000000..b61592e05943
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/assets/script.js
@@ -0,0 +1,12 @@
+(() => {
+ const startTime = Date.now();
+
+ function getElapsed() {
+ const time = Date.now();
+ return time - startTime;
+ }
+
+ while (getElapsed() < 105) {
+ //
+ }
+})();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js
new file mode 100644
index 000000000000..484350c14fcf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({
+ idleTimeout: 2000,
+ enableLongAnimationFrame: false,
+ }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html
new file mode 100644
index 000000000000..b03231da2c65
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Rendered Before Long Task
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts
new file mode 100644
index 000000000000..8b73aa91dff6
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/test.ts
@@ -0,0 +1,42 @@
+import type { Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('captures long task.', async ({ browserName, getLocalTestUrl, page }) => {
+ // Long tasks only work on chrome
+ sentryTest.skip(shouldSkipTracingTest() || browserName !== 'chromium' || testingCdnBundle());
+
+ await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload'));
+
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload')!;
+
+ const uiSpans = spans.filter(s => getSpanOp(s)?.startsWith('ui'));
+ expect(uiSpans.length).toBeGreaterThan(0);
+
+ const [firstUISpan] = uiSpans;
+ expect(firstUISpan).toEqual(
+ expect.objectContaining({
+ name: 'Main UI thread blocked',
+ parent_span_id: pageloadSpan.span_id,
+ attributes: expect.objectContaining({
+ 'sentry.op': { type: 'string', value: 'ui.long-task' },
+ }),
+ }),
+ );
+
+ const start = firstUISpan.start_timestamp ?? 0;
+ const end = firstUISpan.end_timestamp ?? 0;
+ const duration = end - start;
+
+ expect(duration).toBeGreaterThanOrEqual(0.1);
+ expect(duration).toBeLessThanOrEqual(0.15);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js
new file mode 100644
index 000000000000..a93fc742bafb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/init.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts
new file mode 100644
index 000000000000..7128d2d5ecce
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts
@@ -0,0 +1,219 @@
+import { expect } from '@playwright/test';
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import {
+ getSpanOp,
+ getSpansFromEnvelope,
+ waitForStreamedSpan,
+ waitForStreamedSpanEnvelope,
+} from '../../../../utils/spanUtils';
+
+sentryTest('starts a streamed navigation span on page navigation', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'navigation'),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ // simulate navigation
+ page.goto(`${url}#foo`);
+
+ const navigationSpanEnvelope = await navigationSpanEnvelopePromise;
+
+ const navigationSpanEnvelopeHeader = navigationSpanEnvelope[0];
+ const navigationSpanEnvelopeItem = navigationSpanEnvelope[1];
+ const navigationSpans = navigationSpanEnvelopeItem[0][1].items;
+ const navigationSpan = navigationSpans.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(navigationSpanEnvelopeHeader).toEqual({
+ sent_at: expect.any(String),
+ trace: {
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ environment: 'production',
+ public_key: 'public',
+ sample_rand: expect.any(String),
+ sample_rate: '1',
+ sampled: 'true',
+ },
+ sdk: {
+ name: 'sentry.javascript.browser',
+ version: SDK_VERSION,
+ },
+ });
+
+ const numericSampleRand = parseFloat(navigationSpanEnvelopeHeader.trace!.sample_rand!);
+ expect(Number.isNaN(numericSampleRand)).toBe(false);
+
+ const pageloadTraceId = pageloadSpan.trace_id;
+ const navigationTraceId = navigationSpan.trace_id;
+
+ expect(pageloadTraceId).toBeDefined();
+ expect(navigationTraceId).toBeDefined();
+ expect(pageloadTraceId).not.toEqual(navigationTraceId);
+
+ expect(pageloadSpan.name).toEqual('/index.html');
+
+ expect(navigationSpan).toEqual({
+ attributes: {
+ effectiveConnectionType: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ hardwareConcurrency: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ 'sentry.idle_span_finish_reason': {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'navigation',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.navigation.browser',
+ },
+ 'sentry.previous_trace': {
+ type: 'string',
+ value: `${pageloadTraceId}-${pageloadSpan.span_id}-1`,
+ },
+ 'sentry.sample_rate': {
+ type: 'integer',
+ value: 1,
+ },
+ 'sentry.sdk.name': {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ 'sentry.sdk.version': {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ 'sentry.segment.id': {
+ type: 'string',
+ value: navigationSpan.span_id,
+ },
+ 'sentry.segment.name': {
+ type: 'string',
+ value: '/index.html',
+ },
+ 'sentry.source': {
+ type: 'string',
+ value: 'url',
+ },
+ 'sentry.span.source': {
+ type: 'string',
+ value: 'url',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ links: [
+ {
+ attributes: {
+ 'sentry.link.type': {
+ type: 'string',
+ value: 'previous_trace',
+ },
+ },
+ sampled: true,
+ span_id: pageloadSpan.span_id,
+ trace_id: pageloadTraceId,
+ },
+ ],
+ name: '/index.html',
+ span_id: navigationSpan.span_id,
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: navigationTraceId,
+ });
+});
+
+sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const navigationSpan1Promise = waitForStreamedSpan(
+ page,
+ span => getSpanOp(span) === 'navigation' && span.name === '/sub-page',
+ );
+ const navigationSpan2Promise = waitForStreamedSpan(
+ page,
+ span => getSpanOp(span) === 'navigation' && span.name === '/sub-page-2',
+ );
+
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page`);");
+
+ const navigationSpan1 = await navigationSpan1Promise;
+
+ expect(navigationSpan1.name).toEqual('/sub-page');
+
+ expect(navigationSpan1.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.navigation.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'navigation',
+ },
+ });
+
+ await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page-2`);");
+
+ const navigationSpan2 = await navigationSpan2Promise;
+
+ expect(navigationSpan2.name).toEqual('/sub-page-2');
+
+ expect(navigationSpan2.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.navigation.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'navigation',
+ },
+ ['sentry.idle_span_finish_reason']: {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js
new file mode 100644
index 000000000000..bd3b6ed17872
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ traceLifecycle: 'stream',
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts
new file mode 100644
index 000000000000..47d9e00d4307
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts
@@ -0,0 +1,131 @@
+import { expect } from '@playwright/test';
+import {
+ SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
+ SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'creates a pageload streamed span envelope with url as pageload span name source',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const spanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!getSpansFromEnvelope(env).find(s => getSpanOp(s) === 'pageload'),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const spanEnvelope = await spanEnvelopePromise;
+ const envelopeHeader = spanEnvelope[0];
+ const envelopeItem = spanEnvelope[1];
+ const spans = envelopeItem[0][1].items;
+ const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload');
+
+ const timeOrigin = await page.evaluate('window._testBaseTimestamp');
+
+ expect(envelopeHeader).toEqual({
+ sdk: {
+ name: 'sentry.javascript.browser',
+ version: SDK_VERSION,
+ },
+ sent_at: expect.any(String),
+ trace: {
+ environment: 'production',
+ public_key: 'public',
+ sample_rand: expect.any(String),
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ },
+ });
+
+ const numericSampleRand = parseFloat(envelopeHeader.trace!.sample_rand!);
+ const traceId = envelopeHeader.trace!.trace_id;
+
+ expect(Number.isNaN(numericSampleRand)).toBe(false);
+
+ expect(envelopeItem[0][0].item_count).toBeGreaterThan(1);
+
+ expect(pageloadSpan?.start_timestamp).toBeCloseTo(timeOrigin, 1);
+
+ expect(pageloadSpan).toEqual({
+ attributes: {
+ effectiveConnectionType: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ hardwareConcurrency: {
+ type: 'string',
+ value: expect.any(String),
+ },
+ 'performance.activationStart': {
+ type: 'integer',
+ value: expect.any(Number),
+ },
+ 'performance.timeOrigin': {
+ type: 'double',
+ value: expect.any(Number),
+ },
+ 'sentry.idle_span_finish_reason': {
+ type: 'string',
+ value: 'idleTimeout',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
+ type: 'string',
+ value: 'pageload',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
+ type: 'string',
+ value: 'auto.pageload.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: {
+ type: 'integer',
+ value: 1,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: {
+ type: 'string',
+ value: 'sentry.javascript.browser',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: {
+ type: 'string',
+ value: SDK_VERSION,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: {
+ type: 'string',
+ value: pageloadSpan?.span_id,
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: {
+ type: 'string',
+ value: '/index.html',
+ },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: {
+ type: 'string',
+ value: 'url',
+ },
+ 'sentry.span.source': {
+ type: 'string',
+ value: 'url',
+ },
+ },
+ end_timestamp: expect.any(Number),
+ is_segment: true,
+ name: '/index.html',
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ trace_id: traceId,
+ });
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js
new file mode 100644
index 000000000000..ded3ca204b6b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/init.js
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration({ enableReportPageLoaded: true }), Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ debug: true,
+});
+
+setTimeout(() => {
+ Sentry.reportPageLoaded();
+}, 2500);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts
new file mode 100644
index 000000000000..fb6fa3ab2393
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts
@@ -0,0 +1,41 @@
+import { expect } from '@playwright/test';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/browser';
+import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/core';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ 'waits for Sentry.reportPageLoaded() to be called when `enableReportPageLoaded` is true',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp;
+
+ expect(pageloadSpan.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }),
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' },
+ });
+
+ // We wait for 2.5 seconds before calling Sentry.reportPageLoaded()
+ // the margins are to account for timing weirdness in CI to avoid flakes
+ expect(spanDurationSeconds).toBeGreaterThan(2);
+ expect(spanDurationSeconds).toBeLessThan(3);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js
new file mode 100644
index 000000000000..b1c19f779713
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableReportPageLoaded: true, finalTimeout: 3000 }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+ debug: true,
+});
+
+// not calling Sentry.reportPageLoaded() on purpose!
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts
new file mode 100644
index 000000000000..79df6a902e45
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/finalTimeout/test.ts
@@ -0,0 +1,40 @@
+import { expect } from '@playwright/test';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/browser';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ 'final timeout cancels the pageload span even if `enableReportPageLoaded` is true',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp;
+
+ expect(pageloadSpan.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }),
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' },
+ 'sentry.idle_span_finish_reason': { type: 'string', value: 'finalTimeout' },
+ });
+
+ // We wait for 3 seconds before calling Sentry.reportPageLoaded()
+ // the margins are to account for timing weirdness in CI to avoid flakes
+ expect(spanDurationSeconds).toBeGreaterThan(2.5);
+ expect(spanDurationSeconds).toBeLessThan(3.5);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js
new file mode 100644
index 000000000000..ac42880742a3
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/init.js
@@ -0,0 +1,22 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window._testBaseTimestamp = performance.timeOrigin / 1000;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [
+ Sentry.browserTracingIntegration({ enableReportPageLoaded: true, instrumentNavigation: false }),
+ Sentry.spanStreamingIntegration(),
+ ],
+ tracesSampleRate: 1,
+ debug: true,
+});
+
+setTimeout(() => {
+ Sentry.startBrowserTracingNavigationSpan(Sentry.getClient(), { name: 'custom_navigation' });
+}, 1000);
+
+setTimeout(() => {
+ Sentry.reportPageLoaded();
+}, 2500);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts
new file mode 100644
index 000000000000..77f138f34053
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/navigation/test.ts
@@ -0,0 +1,38 @@
+import { expect } from '@playwright/test';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/browser';
+import { sentryTest } from '../../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils';
+
+sentryTest(
+ 'starting a navigation span cancels the pageload span even if `enableReportPageLoaded` is true',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const spanDurationSeconds = pageloadSpan.end_timestamp - pageloadSpan.start_timestamp;
+
+ expect(pageloadSpan.attributes).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.pageload.browser' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }),
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' },
+ 'sentry.idle_span_finish_reason': { type: 'string', value: 'cancelled' },
+ });
+
+ // ending span after 1s but adding a margin of 0.5s to account for timing weirdness in CI to avoid flakes
+ expect(spanDurationSeconds).toBeLessThan(1.5);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js
new file mode 100644
index 000000000000..9afcee48dc4a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/init.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js
new file mode 100644
index 000000000000..510fb07540ad
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/subject.js
@@ -0,0 +1,28 @@
+// REGULAR ---
+const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' });
+rootSpan1.end();
+
+Sentry.startSpan({ name: 'rootSpan2' }, rootSpan2 => {
+ rootSpan2.addLink({
+ context: rootSpan1.spanContext(),
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ });
+});
+
+// NESTED ---
+Sentry.startSpan({ name: 'rootSpan3' }, async rootSpan3 => {
+ Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => {
+ childSpan1.addLink({
+ context: rootSpan1.spanContext(),
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ });
+
+ childSpan1.end();
+ });
+
+ Sentry.startSpan({ name: 'childSpan3.2' }, async childSpan2 => {
+ childSpan2.addLink({ context: rootSpan3.spanContext() });
+
+ childSpan2.end();
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts
new file mode 100644
index 000000000000..dc35f0c8fcf1
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink-streamed/test.ts
@@ -0,0 +1,66 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers';
+import { waitForStreamedSpan, waitForStreamedSpans } from '../../../utils/spanUtils';
+
+sentryTest('links spans with addLink() in trace context', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment);
+ const rootSpan2Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan2' && !!s.is_segment);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const rootSpan1 = await rootSpan1Promise;
+ const rootSpan2 = await rootSpan2Promise;
+
+ expect(rootSpan1.name).toBe('rootSpan1');
+ expect(rootSpan1.links).toBeUndefined();
+
+ expect(rootSpan2.name).toBe('rootSpan2');
+ expect(rootSpan2.links).toHaveLength(1);
+ expect(rootSpan2.links?.[0]).toMatchObject({
+ attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } },
+ sampled: true,
+ span_id: rootSpan1.span_id,
+ trace_id: rootSpan1.trace_id,
+ });
+});
+
+sentryTest('links spans with addLink() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const rootSpan1Promise = waitForStreamedSpan(page, s => s.name === 'rootSpan1' && !!s.is_segment);
+ const rootSpan3SpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(s => s.name === 'rootSpan3' && s.is_segment),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const rootSpan1 = await rootSpan1Promise;
+ const rootSpan3Spans = await rootSpan3SpansPromise;
+
+ const rootSpan3 = rootSpan3Spans.find(s => s.name === 'rootSpan3')!;
+ const childSpan1 = rootSpan3Spans.find(s => s.name === 'childSpan3.1')!;
+ const childSpan2 = rootSpan3Spans.find(s => s.name === 'childSpan3.2')!;
+
+ expect(rootSpan3.name).toBe('rootSpan3');
+
+ expect(childSpan1.name).toBe('childSpan3.1');
+ expect(childSpan1.links).toHaveLength(1);
+ expect(childSpan1.links?.[0]).toMatchObject({
+ attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } },
+ sampled: true,
+ span_id: rootSpan1.span_id,
+ trace_id: rootSpan1.trace_id,
+ });
+
+ expect(childSpan2.name).toBe('childSpan3.2');
+ expect(childSpan2.links?.[0]).toMatchObject({
+ sampled: true,
+ span_id: rootSpan3.span_id,
+ trace_id: rootSpan3.trace_id,
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js
new file mode 100644
index 000000000000..c4c8791cf32c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ autoSessionTracking: false,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js
new file mode 100644
index 000000000000..482a738009c2
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/subject.js
@@ -0,0 +1,5 @@
+fetch('http://sentry-test-site.example/0').then(
+ fetch('http://sentry-test-site.example/1', { headers: { 'X-Test-Header': 'existing-header' } }).then(
+ fetch('http://sentry-test-site.example/2'),
+ ),
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts
new file mode 100644
index 000000000000..201c3e4979f2
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts
@@ -0,0 +1,43 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(
+ page,
+ spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3,
+ );
+
+ await page.goto(url);
+
+ const allSpans = await spansPromise;
+ const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload');
+ const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client');
+
+ expect(requestSpans).toHaveLength(3);
+
+ requestSpans.forEach((span, index) =>
+ expect(span).toMatchObject({
+ name: `GET http://sentry-test-site.example/${index}`,
+ parent_span_id: pageloadSpan?.span_id,
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: pageloadSpan?.trace_id,
+ attributes: expect.objectContaining({
+ 'http.method': { type: 'string', value: 'GET' },
+ 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ url: { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ 'server.address': { type: 'string', value: 'sentry-test-site.example' },
+ type: { type: 'string', value: 'fetch' },
+ }),
+ }),
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js
new file mode 100644
index 000000000000..c4c8791cf32c
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/init.js
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+ autoSessionTracking: false,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js
new file mode 100644
index 000000000000..9c584bf743cb
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/subject.js
@@ -0,0 +1,12 @@
+const xhr_1 = new XMLHttpRequest();
+xhr_1.open('GET', 'http://sentry-test-site.example/0');
+xhr_1.send();
+
+const xhr_2 = new XMLHttpRequest();
+xhr_2.open('GET', 'http://sentry-test-site.example/1');
+xhr_2.setRequestHeader('X-Test-Header', 'existing-header');
+xhr_2.send();
+
+const xhr_3 = new XMLHttpRequest();
+xhr_3.open('GET', 'http://sentry-test-site.example/2');
+xhr_3.send();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts
new file mode 100644
index 000000000000..d3f20fd36453
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts
@@ -0,0 +1,43 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' }));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const spansPromise = waitForStreamedSpans(
+ page,
+ spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3,
+ );
+
+ await page.goto(url);
+
+ const allSpans = await spansPromise;
+ const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload');
+ const requestSpans = allSpans.filter(s => getSpanOp(s) === 'http.client');
+
+ expect(requestSpans).toHaveLength(3);
+
+ requestSpans.forEach((span, index) =>
+ expect(span).toMatchObject({
+ name: `GET http://sentry-test-site.example/${index}`,
+ parent_span_id: pageloadSpan?.span_id,
+ span_id: expect.stringMatching(/[a-f\d]{16}/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ trace_id: pageloadSpan?.trace_id,
+ attributes: expect.objectContaining({
+ 'http.method': { type: 'string', value: 'GET' },
+ 'http.url': { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ url: { type: 'string', value: `http://sentry-test-site.example/${index}` },
+ 'server.address': { type: 'string', value: 'sentry-test-site.example' },
+ type: { type: 'string', value: 'xhr' },
+ }),
+ }),
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js
new file mode 100644
index 000000000000..9afcee48dc4a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/init.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js
new file mode 100644
index 000000000000..0ce39588eb1b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/subject.js
@@ -0,0 +1,14 @@
+const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
+Sentry.setActiveSpanInBrowser(checkoutSpan);
+
+Sentry.startSpan({ name: 'checkout-step-1' }, () => {
+ Sentry.startSpan({ name: 'checkout-step-1-1' }, () => {
+ // ... `
+ });
+});
+
+Sentry.startSpan({ name: 'checkout-step-2' }, () => {
+ // ... `
+});
+
+checkoutSpan.end();
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts
new file mode 100644
index 000000000000..a144e171a93a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/default/test.ts
@@ -0,0 +1,35 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { waitForStreamedSpans } from '../../../../utils/spanUtils';
+
+sentryTest('sets an inactive span active and adds child spans to it', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => s.name === 'checkout-flow' && s.is_segment));
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const spans = await spansPromise;
+ const checkoutSpan = spans.find(s => s.name === 'checkout-flow');
+ const checkoutSpanId = checkoutSpan?.span_id;
+ expect(checkoutSpanId).toMatch(/[a-f\d]{16}/);
+
+ expect(spans.filter(s => !s.is_segment)).toHaveLength(3);
+
+ const checkoutStep1 = spans.find(s => s.name === 'checkout-step-1');
+ const checkoutStep11 = spans.find(s => s.name === 'checkout-step-1-1');
+ const checkoutStep2 = spans.find(s => s.name === 'checkout-step-2');
+
+ expect(checkoutStep1).toBeDefined();
+ expect(checkoutStep11).toBeDefined();
+ expect(checkoutStep2).toBeDefined();
+
+ expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId);
+ expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId);
+
+ // despite 1-1 being called within 1, it's still parented to the root span
+ // due to this being default behaviour in browser environments
+ expect(checkoutStep11?.parent_span_id).toBe(checkoutSpanId);
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js
new file mode 100644
index 000000000000..5b4cff73e95d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ parentSpanIsAlwaysRootSpan: false,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js
new file mode 100644
index 000000000000..dc601cbf4d30
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/subject.js
@@ -0,0 +1,22 @@
+const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
+Sentry.setActiveSpanInBrowser(checkoutSpan);
+
+Sentry.startSpan({ name: 'checkout-step-1' }, () => {});
+
+const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' });
+Sentry.setActiveSpanInBrowser(checkoutStep2);
+
+Sentry.startSpan({ name: 'checkout-step-2-1' }, () => {
+ // ... `
+});
+checkoutStep2.end();
+
+Sentry.startSpan({ name: 'checkout-step-3' }, () => {});
+
+checkoutSpan.end();
+
+Sentry.startSpan({ name: 'post-checkout' }, () => {
+ Sentry.startSpan({ name: 'post-checkout-1' }, () => {
+ // ... `
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts
new file mode 100644
index 000000000000..58728bba07f9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts
@@ -0,0 +1,63 @@
+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 postCheckoutSpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(s => s.name === 'post-checkout' && s.is_segment),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const checkoutSpans = await checkoutSpansPromise;
+ const postCheckoutSpans = await postCheckoutSpansPromise;
+
+ const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
+ const postCheckoutSpan = postCheckoutSpans.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(4);
+ expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1);
+
+ 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 = postCheckoutSpans.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..4d11a36982b7
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/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 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 postCheckoutSpansPromise = waitForStreamedSpans(page, spans =>
+ spans.some(s => s.name === 'post-checkout' && s.is_segment),
+ );
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const checkoutSpans = await checkoutSpansPromise;
+ const postCheckoutSpans = await postCheckoutSpansPromise;
+
+ const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
+ const postCheckoutSpan = postCheckoutSpans.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(4);
+ expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1);
+
+ 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 = postCheckoutSpans.find(s => s.name === 'post-checkout-1');
+ expect(postCheckoutStep1).toBeDefined();
+ expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js
new file mode 100644
index 000000000000..3dd77207e103
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+// Import this separately so that generatePlugin can handle it for CDN scenarios
+import { feedbackIntegration } from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts
new file mode 100644
index 000000000000..28f3e5039910
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/navigation-streamed/test.ts
@@ -0,0 +1,318 @@
+import { expect } from '@playwright/test';
+import type { Event } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import type { EventAndTraceHeader } from '../../../../utils/helpers';
+import {
+ eventAndTraceHeaderRequestParser,
+ getFirstSentryEnvelopeRequest,
+ shouldSkipFeedbackTest,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+} from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';
+
+sentryTest('creates a new trace and sample_rand on each navigation', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ // Wait for and skip the initial pageload span
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigation1SpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#foo`);
+ const navigation1SpanEnvelope = await navigation1SpanEnvelopePromise;
+
+ const navigation2SpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#bar`);
+ const navigation2SpanEnvelope = await navigation2SpanEnvelopePromise;
+
+ const navigation1TraceId = navigation1SpanEnvelope[0].trace?.trace_id;
+ const navigation1SampleRand = navigation1SpanEnvelope[0].trace?.sample_rand;
+ const navigation2TraceId = navigation2SpanEnvelope[0].trace?.trace_id;
+ const navigation2SampleRand = navigation2SpanEnvelope[0].trace?.sample_rand;
+
+ const navigation1Span = navigation1SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+ const navigation2Span = navigation2SpanEnvelope[1][0][1].items.find(s => getSpanOp(s) === 'navigation')!;
+
+ expect(getSpanOp(navigation1Span)).toEqual('navigation');
+ expect(navigation1TraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigation1Span.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigation1Span.parent_span_id).toBeUndefined();
+
+ expect(navigation1SpanEnvelope[0].trace).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigation1TraceId,
+ sample_rand: expect.any(String),
+ });
+
+ expect(getSpanOp(navigation2Span)).toEqual('navigation');
+ expect(navigation2TraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigation2Span.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigation2Span.parent_span_id).toBeUndefined();
+
+ expect(navigation2SpanEnvelope[0].trace).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigation2TraceId,
+ sample_rand: expect.any(String),
+ });
+
+ expect(navigation1TraceId).not.toEqual(navigation2TraceId);
+ expect(navigation1SampleRand).not.toEqual(navigation2SampleRand);
+});
+
+sentryTest('error after navigation has navigation traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ await page.goto(`${url}#foo`);
+ const [navigationSpan, navigationSpanEnvelope] = await Promise.all([
+ navigationSpanPromise,
+ navigationSpanEnvelopePromise,
+ ]);
+
+ const navigationTraceId = navigationSpan.trace_id;
+
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ expect(navigationSpanEnvelope[0].trace).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigationTraceId,
+ sample_rand: expect.any(String),
+ });
+
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+ await page.locator('#errorBtn').click();
+ const [errorEvent, errorTraceHeader] = await errorEventPromise;
+
+ expect(errorEvent.type).toEqual(undefined);
+
+ const errorTraceContext = errorEvent.contexts?.trace;
+ expect(errorTraceContext).toEqual({
+ trace_id: navigationTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigationTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest('error during navigation has new navigation traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+
+ await page.goto(`${url}#foo`);
+ await page.locator('#errorBtn').click();
+ const [navigationSpan, [errorEvent, errorTraceHeader]] = await Promise.all([
+ navigationSpanPromise,
+ errorEventPromise,
+ ]);
+
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(errorEvent.type).toEqual(undefined);
+
+ const navigationTraceId = navigationSpan.trace_id;
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ const errorTraceContext = errorEvent?.contexts?.trace;
+ expect(errorTraceContext).toEqual({
+ trace_id: navigationTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: navigationTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest(
+ 'outgoing fetch request during navigation has navigation traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(`${url}#foo`);
+ await page.locator('#fetchBtn').click();
+ const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]);
+
+ const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand;
+
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toEqual(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest(
+ 'outgoing XHR request during navigation has navigation traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ // ensure navigation span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'navigation'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(`${url}#foo`);
+ await page.locator('#xhrBtn').click();
+ const [navigationSpanEnvelope, request] = await Promise.all([navigationSpanEnvelopePromise, requestPromise]);
+
+ const navigationTraceId = navigationSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = navigationSpanEnvelope[0].trace?.sample_rand;
+
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${navigationTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toEqual(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${navigationTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest(
+ 'user feedback event after navigation has navigation traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true });
+
+ // ensure pageload span is finished
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ await pageloadSpanPromise;
+
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+ await page.goto(`${url}#foo`);
+ const navigationSpan = await navigationSpanPromise;
+
+ const navigationTraceId = navigationSpan.trace_id;
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(navigationTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ const feedbackEventPromise = getFirstSentryEnvelopeRequest(page);
+
+ await page.getByText('Report a Bug').click();
+ expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1);
+ await page.locator('[name="name"]').fill('Jane Doe');
+ await page.locator('[name="email"]').fill('janedoe@example.org');
+ await page.locator('[name="message"]').fill('my example feedback');
+ await page.locator('[data-sentry-feedback] .btn--primary').click();
+
+ const feedbackEvent = await feedbackEventPromise;
+
+ expect(feedbackEvent.type).toEqual('feedback');
+
+ const feedbackTraceContext = feedbackEvent.contexts?.trace;
+
+ expect(feedbackTraceContext).toMatchObject({
+ trace_id: navigationTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js
new file mode 100644
index 000000000000..3dd77207e103
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+// Import this separately so that generatePlugin can handle it for CDN scenarios
+import { feedbackIntegration } from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), feedbackIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts
new file mode 100644
index 000000000000..1b4458991559
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-streamed/test.ts
@@ -0,0 +1,238 @@
+import { expect } from '@playwright/test';
+import type { Event } from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import type { EventAndTraceHeader } from '../../../../utils/helpers';
+import {
+ eventAndTraceHeaderRequestParser,
+ getFirstSentryEnvelopeRequest,
+ shouldSkipFeedbackTest,
+ shouldSkipTracingTest,
+ testingCdnBundle,
+} from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils';
+
+sentryTest('creates a new trace for a navigation after the initial pageload', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const navigationSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'navigation');
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+
+ page.goto(`${url}#foo`);
+
+ const navigationSpan = await navigationSpanPromise;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ expect(getSpanOp(navigationSpan)).toEqual('navigation');
+ expect(navigationSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(navigationSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(navigationSpan.parent_span_id).toBeUndefined();
+
+ expect(pageloadSpan.span_id).not.toEqual(navigationSpan.span_id);
+ expect(pageloadSpan.trace_id).not.toEqual(navigationSpan.trace_id);
+});
+
+sentryTest('error after pageload has pageload traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+ await page.goto(url);
+
+ const pageloadSpan = await pageloadSpanPromise;
+ const pageloadTraceId = pageloadSpan.trace_id;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+ await page.locator('#errorBtn').click();
+ const [errorEvent, errorTraceHeader] = await errorEventPromise;
+
+ const errorTraceContext = errorEvent.contexts?.trace;
+ expect(errorEvent.type).toEqual(undefined);
+
+ expect(errorTraceContext).toEqual({
+ trace_id: pageloadTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: pageloadTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest('error during pageload has pageload traceId', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ const errorEventPromise = getFirstSentryEnvelopeRequest(
+ page,
+ undefined,
+ eventAndTraceHeaderRequestParser,
+ );
+
+ await page.goto(url);
+ await page.locator('#errorBtn').click();
+ const [pageloadSpan, [errorEvent, errorTraceHeader]] = await Promise.all([pageloadSpanPromise, errorEventPromise]);
+
+ const pageloadTraceId = pageloadSpan.trace_id;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ const errorTraceContext = errorEvent?.contexts?.trace;
+ expect(errorEvent.type).toEqual(undefined);
+
+ expect(errorTraceContext).toEqual({
+ trace_id: pageloadTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+
+ expect(errorTraceHeader).toEqual({
+ environment: 'production',
+ public_key: 'public',
+ sample_rate: '1',
+ sampled: 'true',
+ trace_id: pageloadTraceId,
+ sample_rand: expect.any(String),
+ });
+});
+
+sentryTest(
+ 'outgoing fetch request during pageload has pageload traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(url);
+ await page.locator('#fetchBtn').click();
+ const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]);
+
+ const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand;
+
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toBe(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest(
+ 'outgoing XHR request during pageload has pageload traceId in headers',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ const pageloadSpanEnvelopePromise = waitForStreamedSpanEnvelope(
+ page,
+ env => !!env[1][0][1].items.find(s => getSpanOp(s) === 'pageload'),
+ );
+ const requestPromise = page.waitForRequest('http://sentry-test-site.example/*');
+ await page.goto(url);
+ await page.locator('#xhrBtn').click();
+ const [pageloadSpanEnvelope, request] = await Promise.all([pageloadSpanEnvelopePromise, requestPromise]);
+
+ const pageloadTraceId = pageloadSpanEnvelope[0].trace?.trace_id;
+ const sampleRand = pageloadSpanEnvelope[0].trace?.sample_rand;
+
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+
+ const headers = request.headers();
+
+ // sampling decision is propagated from active span sampling decision
+ expect(headers['sentry-trace']).toMatch(new RegExp(`^${pageloadTraceId}-[0-9a-f]{16}-1$`));
+ expect(headers['baggage']).toBe(
+ `sentry-environment=production,sentry-public_key=public,sentry-trace_id=${pageloadTraceId},sentry-sampled=true,sentry-sample_rand=${sampleRand},sentry-sample_rate=1`,
+ );
+ },
+);
+
+sentryTest('user feedback event after pageload has pageload traceId in headers', async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || shouldSkipFeedbackTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageloadSpan = await pageloadSpanPromise;
+ const pageloadTraceId = pageloadSpan.trace_id;
+
+ expect(getSpanOp(pageloadSpan)).toEqual('pageload');
+ expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/);
+ expect(pageloadSpan.span_id).toMatch(/^[\da-f]{16}$/);
+ expect(pageloadSpan.parent_span_id).toBeUndefined();
+
+ const feedbackEventPromise = getFirstSentryEnvelopeRequest(page);
+
+ await page.getByText('Report a Bug').click();
+ expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1);
+ await page.locator('[name="name"]').fill('Jane Doe');
+ await page.locator('[name="email"]').fill('janedoe@example.org');
+ await page.locator('[name="message"]').fill('my example feedback');
+ await page.locator('[data-sentry-feedback] .btn--primary').click();
+
+ const feedbackEvent = await feedbackEventPromise;
+
+ expect(feedbackEvent.type).toEqual('feedback');
+
+ const feedbackTraceContext = feedbackEvent.contexts?.trace;
+
+ expect(feedbackTraceContext).toMatchObject({
+ trace_id: pageloadTraceId,
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js
new file mode 100644
index 000000000000..187e07624fdf
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
+ tracePropagationTargets: ['http://sentry-test-site.example'],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js
new file mode 100644
index 000000000000..3bb1e489ccb6
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/subject.js
@@ -0,0 +1,15 @@
+const newTraceBtn = document.getElementById('newTrace');
+newTraceBtn.addEventListener('click', async () => {
+ Sentry.startNewTrace(() => {
+ Sentry.startSpan({ op: 'ui.interaction.click', name: 'new-trace' }, async () => {
+ await fetch('http://sentry-test-site.example');
+ });
+ });
+});
+
+const oldTraceBtn = document.getElementById('oldTrace');
+oldTraceBtn.addEventListener('click', async () => {
+ Sentry.startSpan({ op: 'ui.interaction.click', name: 'old-trace' }, async () => {
+ await fetch('http://sentry-test-site.example');
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html
new file mode 100644
index 000000000000..f78960343dd0
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts
new file mode 100644
index 000000000000..d294efcd2e3b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-streamed/test.ts
@@ -0,0 +1,44 @@
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
+import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';
+
+sentryTest(
+ 'creates a new trace if `startNewTrace` is called and leaves old trace valid outside the callback',
+ async ({ getLocalTestUrl, page }) => {
+ sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle());
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.route('http://sentry-test-site.example/**', route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({}),
+ });
+ });
+
+ const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
+ await page.goto(url);
+ const pageloadSpan = await pageloadSpanPromise;
+
+ const newTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'new-trace');
+ const oldTraceSpanPromise = waitForStreamedSpan(page, span => span.name === 'old-trace');
+
+ await page.locator('#newTrace').click();
+ await page.locator('#oldTrace').click();
+
+ const [newTraceSpan, oldTraceSpan] = await Promise.all([newTraceSpanPromise, oldTraceSpanPromise]);
+
+ expect(getSpanOp(newTraceSpan)).toEqual('ui.interaction.click');
+ expect(newTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(newTraceSpan.span_id).toMatch(/^[\da-f]{16}$/);
+
+ expect(getSpanOp(oldTraceSpan)).toEqual('ui.interaction.click');
+ expect(oldTraceSpan.trace_id).toMatch(/^[\da-f]{32}$/);
+ expect(oldTraceSpan.span_id).toMatch(/^[\da-f]{16}$/);
+
+ expect(oldTraceSpan.trace_id).toEqual(pageloadSpan.trace_id);
+ expect(newTraceSpan.trace_id).not.toEqual(pageloadSpan.trace_id);
+ },
+);
diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts
index 50150c6bee20..5dade230e1e4 100644
--- a/dev-packages/browser-integration-tests/utils/helpers.ts
+++ b/dev-packages/browser-integration-tests/utils/helpers.ts
@@ -62,7 +62,7 @@ export const eventAndTraceHeaderRequestParser = (request: Request | null): Event
return getEventAndTraceHeader(envelope);
};
-const properFullEnvelopeParser = (request: Request | null): T => {
+export const properFullEnvelopeParser = (request: Request | null): T => {
// https://develop.sentry.dev/sdk/envelopes/
const envelope = request?.postData() || '';
diff --git a/dev-packages/browser-integration-tests/utils/spanUtils.ts b/dev-packages/browser-integration-tests/utils/spanUtils.ts
new file mode 100644
index 000000000000..67b5798b66f1
--- /dev/null
+++ b/dev-packages/browser-integration-tests/utils/spanUtils.ts
@@ -0,0 +1,133 @@
+import type { Page } from '@playwright/test';
+import type { SerializedStreamedSpan, StreamedSpanEnvelope } from '@sentry/core';
+import { properFullEnvelopeParser } from './helpers';
+
+/**
+ * Wait for a full span v2 envelope
+ * Useful for testing the entire envelope shape
+ */
+export async function waitForStreamedSpanEnvelope(
+ page: Page,
+ callback?: (spanEnvelope: StreamedSpanEnvelope) => boolean,
+): Promise {
+ const req = await page.waitForRequest(req => {
+ const postData = req.postData();
+ if (!postData) {
+ return false;
+ }
+
+ try {
+ const spanEnvelope = properFullEnvelopeParser(req);
+
+ const envelopeItemHeader = spanEnvelope[1][0][0];
+
+ if (
+ envelopeItemHeader?.type !== 'span' ||
+ envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json'
+ ) {
+ return false;
+ }
+
+ if (callback) {
+ return callback(spanEnvelope);
+ }
+
+ return true;
+ } catch {
+ return false;
+ }
+ });
+
+ return properFullEnvelopeParser(req);
+}
+
+/**
+ * Wait for v2 spans sent in one envelope.
+ * Useful for testing multiple spans in one envelope.
+ * @param page
+ * @param callback - Callback being called with all spans
+ */
+export async function waitForStreamedSpans(
+ page: Page,
+ callback?: (spans: SerializedStreamedSpan[]) => boolean,
+): Promise {
+ const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => {
+ if (callback) {
+ return callback(envelope[1][0][1].items);
+ }
+ return true;
+ });
+ return spanEnvelope[1][0][1].items;
+}
+
+export async function waitForStreamedSpan(
+ page: Page,
+ callback: (span: SerializedStreamedSpan) => boolean,
+): Promise {
+ const spanEnvelope = await waitForStreamedSpanEnvelope(page, envelope => {
+ if (callback) {
+ const spans = envelope[1][0][1].items;
+ return spans.some(span => callback(span));
+ }
+ return true;
+ });
+ const firstMatchingSpan = spanEnvelope[1][0][1].items.find(span => callback(span));
+ if (!firstMatchingSpan) {
+ throw new Error(
+ 'No matching span found but envelope search matched previously. Something is likely off with this function. Debug me.',
+ );
+ }
+ return firstMatchingSpan;
+}
+
+/**
+ * Observes outgoing requests and looks for sentry envelope requests. If an envelope request is found, it applies
+ * @param callback to check for a matching span.
+ *
+ * Important: This function only observes requests and does not block the test when it ends. Use this primarily to
+ * throw errors if you encounter unwanted spans. You most likely want to use {@link waitForStreamedSpan} or {@link waitForStreamedSpans} instead!
+ */
+export async function observeStreamedSpan(
+ page: Page,
+ callback: (span: SerializedStreamedSpan) => boolean,
+): Promise {
+ page.on('request', request => {
+ const postData = request.postData();
+ if (!postData) {
+ return;
+ }
+
+ try {
+ const spanEnvelope = properFullEnvelopeParser(request);
+
+ const envelopeItemHeader = spanEnvelope[1][0][0];
+
+ if (
+ envelopeItemHeader?.type !== 'span' ||
+ envelopeItemHeader?.content_type !== 'application/vnd.sentry.items.span.v2+json'
+ ) {
+ return false;
+ }
+
+ const spans = spanEnvelope[1][0][1].items;
+
+ for (const span of spans) {
+ if (callback(span)) {
+ return true;
+ }
+ }
+
+ return false;
+ } catch {
+ return false;
+ }
+ });
+}
+
+export function getSpanOp(span: SerializedStreamedSpan): string | undefined {
+ return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes?.['sentry.op']?.value : undefined;
+}
+
+export function getSpansFromEnvelope(envelope: StreamedSpanEnvelope): SerializedStreamedSpan[] {
+ return envelope[1][0][1].items;
+}
diff --git a/packages/browser/src/integrations/spanstreaming.ts b/packages/browser/src/integrations/spanstreaming.ts
index e8e3f8bcd542..f741e2746885 100644
--- a/packages/browser/src/integrations/spanstreaming.ts
+++ b/packages/browser/src/integrations/spanstreaming.ts
@@ -6,6 +6,7 @@ import {
hasSpanStreamingEnabled,
isStreamedBeforeSendSpanCallback,
SpanBuffer,
+ spanIsSampled,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
@@ -44,7 +45,15 @@ export const spanStreamingIntegration = defineIntegration(() => {
const buffer = new SpanBuffer(client);
- client.on('afterSpanEnd', span => buffer.add(captureSpan(span, client)));
+ client.on('afterSpanEnd', span => {
+ // Negatively sampled spans must not be captured.
+ // This happens because OTel and we create non-recording spans for negatively sampled spans
+ // that go through the same life cycle as recording spans.
+ if (!spanIsSampled(span)) {
+ return;
+ }
+ buffer.add(captureSpan(span, client));
+ });
// In addition to capturing the span, we also flush the trace when the segment
// span ends to ensure things are sent timely. We never know when the browser
diff --git a/packages/browser/test/integrations/spanstreaming.test.ts b/packages/browser/test/integrations/spanstreaming.test.ts
index 6993e494f9ce..63e07738570c 100644
--- a/packages/browser/test/integrations/spanstreaming.test.ts
+++ b/packages/browser/test/integrations/spanstreaming.test.ts
@@ -1,6 +1,6 @@
import * as SentryCore from '@sentry/core';
import { debug } from '@sentry/core';
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BrowserClient, spanStreamingIntegration } from '../../src';
import { getDefaultBrowserClientOptions } from '../helper/browser-client-options';
@@ -24,6 +24,10 @@ vi.mock('@sentry/core', async () => {
});
describe('spanStreamingIntegration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
it('has the correct hooks', () => {
const integration = spanStreamingIntegration();
expect(integration.name).toBe('SpanStreaming');
@@ -106,12 +110,13 @@ describe('spanStreamingIntegration', () => {
...getDefaultBrowserClientOptions(),
dsn: 'https://username@domain/123',
integrations: [spanStreamingIntegration()],
+ tracesSampleRate: 1,
});
SentryCore.setCurrentClient(client);
client.init();
- const span = new SentryCore.SentrySpan({ name: 'test' });
+ const span = new SentryCore.SentrySpan({ name: 'test', sampled: true });
client.emit('afterSpanEnd', span);
expect(mockSpanBufferInstance.add).toHaveBeenCalledWith({
@@ -148,6 +153,24 @@ describe('spanStreamingIntegration', () => {
});
});
+ it('does not enqueue a span into the buffer when the span is not sampled', () => {
+ const client = new BrowserClient({
+ ...getDefaultBrowserClientOptions(),
+ dsn: 'https://username@domain/123',
+ integrations: [spanStreamingIntegration()],
+ tracesSampleRate: 1,
+ });
+
+ SentryCore.setCurrentClient(client);
+ client.init();
+
+ const span = new SentryCore.SentrySpan({ name: 'test', sampled: false });
+ client.emit('afterSpanEnd', span);
+
+ expect(mockSpanBufferInstance.add).not.toHaveBeenCalled();
+ expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled();
+ });
+
it('flushes the trace when the segment span ends', () => {
const client = new BrowserClient({
...getDefaultBrowserClientOptions(),
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index d5d926f51769..a2af055232a1 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -467,6 +467,7 @@ export type {
SpanJSON,
SpanContextData,
TraceFlag,
+ SerializedStreamedSpan,
StreamedSpanJSON,
} from './types-hoist/span';
export type { SpanStatus } from './types-hoist/spanStatus';
diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts
index 2168530a9c91..d22905670efc 100644
--- a/packages/core/src/utils/spanUtils.ts
+++ b/packages/core/src/utils/spanUtils.ts
@@ -332,7 +332,12 @@ export function getStatusMessage(status: SpanStatus | undefined): string | undef
* Convert the various statuses to the simple onces expected by Sentry for steamed spans ('ok' is default).
*/
export function getSimpleStatusMessage(status: SpanStatus | undefined): 'ok' | 'error' {
- return !status || status.code === SPAN_STATUS_OK || status.code === SPAN_STATUS_UNSET ? 'ok' : 'error';
+ return !status ||
+ status.code === SPAN_STATUS_OK ||
+ status.code === SPAN_STATUS_UNSET ||
+ status.message === 'cancelled'
+ ? 'ok'
+ : 'error';
}
const CHILD_SPANS_FIELD = '_sentryChildSpans';