diff --git a/.size-limit.js b/.size-limit.js
index d66ece2b690d..8a91e0465b6b 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
- limit: '76 KB',
+ limit: '77 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
@@ -219,7 +219,7 @@ module.exports = [
import: createImport('init'),
ignore: ['$app/stores'],
gzip: true,
- limit: '38.5 KB',
+ limit: '39 KB',
},
// Node SDK (ESM)
{
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/init.js
new file mode 100644
index 000000000000..1415ef740b55
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/init.js
@@ -0,0 +1,21 @@
+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,
+ }),
+ ],
+ tracePropagationTargets: ['someurl.com'],
+ 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/consistent-sampling/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/subject.js
new file mode 100644
index 000000000000..1feeadf34b10
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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('https://someUrl.com');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/template.html
new file mode 100644
index 000000000000..f27a71d043f9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts
new file mode 100644
index 000000000000..915c91f2599e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts
@@ -0,0 +1,153 @@
+import { expect } from '@playwright/test';
+import {
+ extractTraceparentData,
+ parseBaggageHeader,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ eventAndTraceHeaderRequestParser,
+ shouldSkipTracingTest,
+ waitForTracingHeadersOnUrl,
+ waitForTransactionRequest,
+} from '../../../../../../utils/helpers';
+
+sentryTest.describe('When `consistentTraceSampling` is `true`', () => {
+ sentryTest('Continues sampling decision from initial pageload', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadTraceContext, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadRequestPromise = waitForTransactionRequest(page, evt => {
+ return evt.contexts?.trace?.op === 'pageload';
+ });
+ await page.goto(url);
+
+ const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise);
+ const pageloadSampleRand = Number(res[1]?.sample_rand);
+ const pageloadTraceContext = res[0].contexts?.trace;
+
+ expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1);
+ expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
+
+ return { pageloadTraceContext: res[0].contexts?.trace, pageloadSampleRand };
+ });
+
+ const customTraceContext = await sentryTest.step('Custom trace', async () => {
+ const customTrace1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
+ await page.locator('#btn1').click();
+ const [customTrace1Event, customTraceTraceHeader] = eventAndTraceHeaderRequestParser(
+ await customTrace1RequestPromise,
+ );
+
+ const customTraceContext = customTrace1Event.contexts?.trace;
+
+ expect(customTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.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(customTraceContext?.parent_span_id).toBeUndefined();
+
+ expect(pageloadSampleRand).toEqual(Number(customTraceTraceHeader?.sample_rand));
+
+ return customTraceContext;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigation1RequestPromise = waitForTransactionRequest(
+ page,
+ evt => evt.contexts?.trace?.op === 'navigation',
+ );
+ await page.goto(`${url}#foo`);
+ const [navigationEvent, navigationTraceHeader] = eventAndTraceHeaderRequestParser(
+ await navigation1RequestPromise,
+ );
+ const navTraceContext = navigationEvent.contexts?.trace;
+
+ expect(navTraceContext?.trace_id).not.toEqual(customTraceContext?.trace_id);
+ expect(navTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
+
+ expect(navTraceContext?.links).toEqual([
+ {
+ trace_id: customTraceContext?.trace_id,
+ span_id: customTraceContext?.span_id,
+ sampled: true,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
+ },
+ },
+ ]);
+ expect(navTraceContext?.parent_span_id).toBeUndefined();
+
+ expect(pageloadSampleRand).toEqual(Number(navigationTraceHeader?.sample_rand));
+ });
+ });
+
+ sentryTest('Propagates continued sampling decision to outgoing requests', async ({ page, getLocalTestUrl }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadTraceContext, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
+ await page.goto(url);
+
+ const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise);
+ const pageloadSampleRand = Number(res[1]?.sample_rand);
+
+ expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
+
+ const pageloadTraceContext = res[0].contexts?.trace;
+
+ expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1);
+
+ return { pageloadTraceContext: pageloadTraceContext, pageloadSampleRand };
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const fetchTracePromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+
+ const [fetchTraceEvent, fetchTraceTraceHeader] = eventAndTraceHeaderRequestParser(await fetchTracePromise);
+
+ const fetchTraceSampleRand = Number(fetchTraceTraceHeader?.sample_rand);
+ const fetchTraceTraceContext = fetchTraceEvent.contexts?.trace;
+ const httpClientSpan = fetchTraceEvent.spans?.find(span => span.op === 'http.client');
+
+ expect(fetchTraceSampleRand).toEqual(pageloadSampleRand);
+
+ expect(fetchTraceTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toEqual(
+ pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE],
+ );
+ expect(fetchTraceTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: fetchTraceTraceContext?.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': fetchTraceTraceContext?.trace_id,
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/init.js
new file mode 100644
index 000000000000..0b26aa6be474
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/init.js
@@ -0,0 +1,17 @@
+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,
+ }),
+ ],
+ tracePropagationTargets: ['someurl.com'],
+ tracesSampleRate: 1,
+ debug: true,
+ sendClientReports: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/subject.js
new file mode 100644
index 000000000000..1feeadf34b10
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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('https://someUrl.com');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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/consistent-sampling/meta-negative/template.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts
new file mode 100644
index 000000000000..8c73bde21c9a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts
@@ -0,0 +1,100 @@
+import { expect } from '@playwright/test';
+import type { ClientReport } from '@sentry/core';
+import { extractTraceparentData, parseBaggageHeader } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ getMultipleSentryEnvelopeRequests,
+ hidePage,
+ shouldSkipTracingTest,
+ waitForClientReportRequest,
+ waitForTracingHeadersOnUrl,
+} from '../../../../../../utils/helpers';
+
+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 }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ let txnsReceived = 0;
+ // @ts-expect-error - no need to return something valid here
+ getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'transaction' }, () => {
+ ++txnsReceived;
+ return {};
+ });
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await sentryTest.step('Initial pageload', async () => {
+ await page.goto(url);
+ expect(txnsReceived).toEqual(0);
+ });
+
+ await sentryTest.step('Custom instrumented button click', async () => {
+ await page.locator('#btn1').click();
+ expect(txnsReceived).toEqual(0);
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ await page.goto(`${url}#foo`);
+ expect(txnsReceived).toEqual(0);
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com');
+
+ 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(/^[0-9a-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',
+ });
+ });
+
+ 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',
+ },
+ ],
+ });
+ });
+
+ await sentryTest.step('Wait for transactions to be discarded', async () => {
+ // give it a little longer just in case a txn is pending to be sent
+ await page.waitForTimeout(1000);
+ expect(txnsReceived).toEqual(0);
+ });
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/init.js
new file mode 100644
index 000000000000..4c65e3d977de
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/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: 'session-storage',
+ consistentTraceSampling: true,
+ }),
+ ],
+ tracePropagationTargets: ['someurl.com'],
+ tracesSampler: ({ inheritOrSampleWith }) => {
+ return inheritOrSampleWith(0);
+ },
+ debug: true,
+ sendClientReports: true,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/page-1.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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/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/consistent-sampling/meta-precedence/page-2.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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/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/consistent-sampling/meta-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/subject.js
new file mode 100644
index 000000000000..376b2102e462
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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('https://someUrl.com');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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/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/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts
new file mode 100644
index 000000000000..840c465a9b0d
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts
@@ -0,0 +1,105 @@
+import { expect } from '@playwright/test';
+import type { ClientReport } from '@sentry/core';
+import { extractTraceparentData, parseBaggageHeader } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ eventAndTraceHeaderRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ waitForClientReportRequest,
+ waitForTracingHeadersOnUrl,
+ waitForTransactionRequest,
+} from '../../../../../../utils/helpers';
+
+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 }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const clientReportPromise = waitForClientReportRequest(page);
+
+ await sentryTest.step('Initial pageload', async () => {
+ await page.goto(url);
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com');
+
+ 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(/^[0-9a-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 page1Pageload = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
+ await page.locator('a').click();
+
+ const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await page1Pageload);
+ const pageloadTraceContext = pageloadEvent.contexts?.trace;
+
+ expect(Number(pageloadTraceHeader?.sample_rand)).toBe(0.12);
+ expect(Number(pageloadTraceHeader?.sample_rate)).toBe(0.2);
+ expect(pageloadTraceContext?.trace_id).toEqual(metaTagTraceIdPage1);
+ });
+
+ await sentryTest.step('Navigate to another page without meta tags', async () => {
+ const page2Pageload = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
+ await page.locator('a').click();
+
+ const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await page2Pageload);
+ const pageloadTraceContext = pageloadEvent.contexts?.trace;
+
+ expect(Number(pageloadTraceHeader?.sample_rand)).toBe(0.12);
+ expect(Number(pageloadTraceHeader?.sample_rate)).toBe(0.2);
+ expect(pageloadTraceContext?.trace_id).not.toEqual(metaTagTraceIdPage1);
+ expect(pageloadTraceContext?.trace_id).not.toEqual(metaTagTraceIdIndex);
+ });
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/init.js
new file mode 100644
index 000000000000..e100eb49469a
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/init.js
@@ -0,0 +1,17 @@
+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,
+ }),
+ ],
+ tracePropagationTargets: ['someurl.com'],
+ // 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/consistent-sampling/meta/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/subject.js
new file mode 100644
index 000000000000..1feeadf34b10
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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('https://someUrl.com');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/template.html
new file mode 100644
index 000000000000..c6a798a60c24
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/template.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/test.ts
new file mode 100644
index 000000000000..54f374b6ca11
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/test.ts
@@ -0,0 +1,176 @@
+import { expect } from '@playwright/test';
+import {
+ extractTraceparentData,
+ parseBaggageHeader,
+ SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ eventAndTraceHeaderRequestParser,
+ shouldSkipTracingTest,
+ waitForTracingHeadersOnUrl,
+ waitForTransactionRequest,
+} from '../../../../../../utils/helpers';
+
+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 }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => {
+ const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
+
+ await page.goto(url);
+
+ const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await pageloadRequestPromise);
+ const pageloadTraceContext = pageloadEvent.contexts?.trace;
+
+ expect(Number(pageloadTraceHeader?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(pageloadTraceHeader?.sample_rate)).toBe(metaTagSampleRate);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined();
+ expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined();
+
+ return pageloadTraceContext;
+ });
+
+ const customTraceContext = await sentryTest.step('Custom trace', async () => {
+ const customTrace1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
+
+ await page.locator('#btn1').click();
+
+ const [customTrace1Event, customTraceTraceHeader] = eventAndTraceHeaderRequestParser(
+ await customTrace1RequestPromise,
+ );
+
+ const customTraceContext = customTrace1Event.contexts?.trace;
+
+ expect(customTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
+ expect(customTraceContext?.parent_span_id).toBeUndefined();
+
+ expect(Number(customTraceTraceHeader?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(customTraceTraceHeader?.sample_rate)).toBe(metaTagSampleRate);
+ expect(Boolean(customTraceTraceHeader?.sampled)).toBe(true);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(customTraceContext?.data?.[SEMANTIC_ATTRIBUTE_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(customTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBe(metaTagSampleRate);
+
+ return customTraceContext;
+ });
+
+ await sentryTest.step('Navigation', async () => {
+ const navigation1RequestPromise = waitForTransactionRequest(
+ page,
+ evt => evt.contexts?.trace?.op === 'navigation',
+ );
+
+ await page.goto(`${url}#foo`);
+
+ const [navigationEvent, navigationTraceHeader] = eventAndTraceHeaderRequestParser(
+ await navigation1RequestPromise,
+ );
+
+ const navigationTraceContext = navigationEvent.contexts?.trace;
+
+ expect(navigationTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
+ expect(navigationTraceContext?.trace_id).not.toEqual(customTraceContext?.trace_id);
+
+ expect(navigationTraceContext?.parent_span_id).toBeUndefined();
+
+ expect(Number(navigationTraceHeader?.sample_rand)).toEqual(metaTagSampleRand);
+ expect(Number(navigationTraceHeader?.sample_rate)).toEqual(metaTagSampleRate);
+ expect(Boolean(navigationTraceHeader?.sampled)).toEqual(true);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(navigationTraceContext?.data?.[SEMANTIC_ATTRIBUTE_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(navigationTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBe(
+ metaTagSampleRate,
+ );
+ });
+ });
+
+ sentryTest(
+ 'Propagates continued tag sampling decision to outgoing requests',
+ async ({ page, getLocalTestUrl }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => {
+ const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
+
+ await page.goto(url);
+
+ const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await pageloadRequestPromise);
+ const pageloadTraceContext = pageloadEvent.contexts?.trace;
+
+ expect(Number(pageloadTraceHeader?.sample_rand)).toBe(metaTagSampleRand);
+ expect(Number(pageloadTraceHeader?.sample_rate)).toBe(metaTagSampleRate);
+
+ // since the local sample rate was not applied, the sample rate attribute shouldn't be set
+ expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined();
+ expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined();
+
+ return pageloadTraceContext;
+ });
+
+ await sentryTest.step('Make fetch request', async () => {
+ const fetchTracePromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
+ const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com');
+
+ await page.locator('#btn2').click();
+
+ const { baggage, sentryTrace } = await tracingHeadersPromise;
+
+ const [fetchTraceEvent, fetchTraceTraceHeader] = eventAndTraceHeaderRequestParser(await fetchTracePromise);
+
+ const fetchTraceSampleRand = Number(fetchTraceTraceHeader?.sample_rand);
+ const fetchTraceTraceContext = fetchTraceEvent.contexts?.trace;
+ const httpClientSpan = fetchTraceEvent.spans?.find(span => span.op === 'http.client');
+
+ expect(fetchTraceSampleRand).toEqual(metaTagSampleRand);
+
+ expect(fetchTraceTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined();
+ expect(fetchTraceTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBe(
+ metaTagSampleRate,
+ );
+
+ expect(fetchTraceTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
+
+ expect(sentryTrace).toBeDefined();
+ expect(baggage).toBeDefined();
+
+ expect(extractTraceparentData(sentryTrace)).toEqual({
+ traceId: fetchTraceTraceContext?.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': fetchTraceTraceContext?.trace_id,
+ 'sentry-transaction': 'custom root span 2',
+ });
+ });
+ },
+ );
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/init.js
new file mode 100644
index 000000000000..686bbef5f992
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/init.js
@@ -0,0 +1,28 @@
+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,
+ }),
+ ],
+ tracePropagationTargets: ['someurl.com'],
+ 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/consistent-sampling/tracesSampler-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/subject.js
new file mode 100644
index 000000000000..1feeadf34b10
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/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('https://someUrl.com');
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/template.html
new file mode 100644
index 000000000000..f27a71d043f9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts
new file mode 100644
index 000000000000..9e896798be90
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts
@@ -0,0 +1,139 @@
+import { expect } from '@playwright/test';
+import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser';
+import type { ClientReport } from '@sentry/core';
+import { sentryTest } from '../../../../../../utils/fixtures';
+import {
+ envelopeRequestParser,
+ eventAndTraceHeaderRequestParser,
+ hidePage,
+ shouldSkipTracingTest,
+ waitForClientReportRequest,
+ waitForTransactionRequest,
+} from '../../../../../../utils/helpers';
+
+/**
+ * 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 }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const { pageloadTraceContext } = await sentryTest.step('Initial pageload', async () => {
+ const pageloadRequestPromise = waitForTransactionRequest(page, evt => {
+ return evt.contexts?.trace?.op === 'pageload';
+ });
+ await page.goto(url);
+
+ const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise);
+ const pageloadSampleRand = Number(res[1]?.sample_rand);
+ const pageloadTraceContext = res[0].contexts?.trace;
+
+ expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1);
+ expect(pageloadSampleRand).toBeGreaterThanOrEqual(0);
+
+ return { pageloadTraceContext: res[0].contexts?.trace, pageloadSampleRand };
+ });
+
+ 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 { customTrace2Context } = await sentryTest.step(
+ 'Custom trace 2 is sampled positively (explicitly in tracesSampler)',
+ async () => {
+ const customTrace2RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
+
+ await page.locator('#btn2').click();
+
+ const [customTrace2Event] = eventAndTraceHeaderRequestParser(await customTrace2RequestPromise);
+
+ const customTrace2Context = customTrace2Event.contexts?.trace;
+
+ expect(customTrace2Context?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1);
+ expect(customTrace2Context?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
+ expect(customTrace2Context?.parent_span_id).toBeUndefined();
+
+ expect(customTrace2Context?.links).toEqual([
+ {
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ sampled: false,
+ span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
+ trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
+ },
+ ]);
+
+ return { customTrace2Context };
+ },
+ );
+
+ await sentryTest.step('Navigation trace is sampled positively (inherited from previous trace)', async () => {
+ const navigationRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation');
+
+ await page.goto(`${url}#bar`);
+
+ const [navigationEvent] = eventAndTraceHeaderRequestParser(await navigationRequestPromise);
+
+ const navigationTraceContext = navigationEvent.contexts?.trace;
+
+ expect(navigationTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1);
+ expect(navigationTraceContext?.trace_id).not.toEqual(customTrace2Context?.trace_id);
+ expect(navigationTraceContext?.parent_span_id).toBeUndefined();
+
+ expect(navigationTraceContext?.links).toEqual([
+ {
+ attributes: { 'sentry.link.type': 'previous_trace' },
+ sampled: true,
+ span_id: customTrace2Context?.span_id,
+ trace_id: customTrace2Context?.trace_id,
+ },
+ ]);
+ });
+ });
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/subject.js
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/subject.js
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/subject.js
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/template.html
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/template.html
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/template.html
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/test.ts
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/default/test.ts
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/init.js
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/init.js
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/init.js
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/init.js
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/init.js
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/init.js
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/template.html
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/template.html
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/template.html
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/test.ts
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/template.html
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/template.html
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/template.html
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/test.ts
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/init.js
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/test.ts
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/init.js
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/init.js
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/init.js
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts
similarity index 100%
rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/test.ts
rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts
diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts
index feecd7c5ce09..e4ebd8b19313 100644
--- a/dev-packages/browser-integration-tests/utils/helpers.ts
+++ b/dev-packages/browser-integration-tests/utils/helpers.ts
@@ -1,9 +1,11 @@
+/* eslint-disable max-lines */
import type { Page, Request } from '@playwright/test';
import type {
+ ClientReport,
Envelope,
EnvelopeItem,
EnvelopeItemType,
- Event,
+ Event as SentryEvent,
EventEnvelope,
EventEnvelopeHeaders,
SessionContext,
@@ -27,7 +29,7 @@ export const envelopeParser = (request: Request | null): unknown[] => {
});
};
-export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): T => {
+export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): T => {
return envelopeParser(request)[envelopeIndex] as T;
};
@@ -48,7 +50,7 @@ export const properEnvelopeParser = (request: Request | null): EnvelopeItem[] =>
return items;
};
-export type EventAndTraceHeader = [Event, EventEnvelopeHeaders['trace']];
+export type EventAndTraceHeader = [SentryEvent, EventEnvelopeHeaders['trace']];
/**
* Returns the first event item and `trace` envelope header from an envelope.
@@ -67,7 +69,7 @@ const properFullEnvelopeParser = (request: Request | null):
};
function getEventAndTraceHeader(envelope: EventEnvelope): EventAndTraceHeader {
- const event = envelope[1][0]?.[1] as Event | undefined;
+ const event = envelope[1][0]?.[1] as SentryEvent | undefined;
const trace = envelope[0]?.trace;
if (!event || !trace) {
@@ -77,7 +79,7 @@ function getEventAndTraceHeader(envelope: EventEnvelope): EventAndTraceHeader {
return [event, trace];
}
-export const properEnvelopeRequestParser = (request: Request | null, envelopeIndex = 1): T => {
+export const properEnvelopeRequestParser = (request: Request | null, envelopeIndex = 1): T => {
return properEnvelopeParser(request)[0]?.[envelopeIndex] as T;
};
@@ -180,13 +182,13 @@ export async function runScriptInSandbox(
*
* @param {Page} page
* @param {string} [url]
- * @return {*} {Promise>}
+ * @return {*} {Promise>}
*/
-export async function getSentryEvents(page: Page, url?: string): Promise> {
+export async function getSentryEvents(page: Page, url?: string): Promise> {
if (url) {
await page.goto(url);
}
- const eventsHandle = await page.evaluateHandle>('window.events');
+ const eventsHandle = await page.evaluateHandle>('window.events');
return eventsHandle.jsonValue();
}
@@ -201,7 +203,7 @@ export async function waitForTransactionRequestOnUrl(page: Page, url: string): P
return req;
}
-export function waitForErrorRequest(page: Page, callback?: (event: Event) => boolean): Promise {
+export function waitForErrorRequest(page: Page, callback?: (event: SentryEvent) => boolean): Promise {
return page.waitForRequest(req => {
const postData = req.postData();
if (!postData) {
@@ -254,6 +256,31 @@ export function waitForTransactionRequest(
});
}
+export function waitForClientReportRequest(page: Page, callback?: (report: ClientReport) => boolean): Promise {
+ return page.waitForRequest(req => {
+ const postData = req.postData();
+ if (!postData) {
+ return false;
+ }
+
+ try {
+ const maybeReport = envelopeRequestParser>(req);
+
+ if (typeof maybeReport.discarded_events !== 'object') {
+ return false;
+ }
+
+ if (callback) {
+ return callback(maybeReport as ClientReport);
+ }
+
+ return true;
+ } catch {
+ return false;
+ }
+ });
+}
+
export async function waitForSession(page: Page): Promise {
const req = await page.waitForRequest(req => {
const postData = req.postData();
@@ -419,3 +446,36 @@ export async function getFirstSentryEnvelopeRequest(
return req;
}
+
+export async function hidePage(page: Page): Promise {
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ // Dispatch the visibilitychange event to notify listeners
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+}
+
+export async function waitForTracingHeadersOnUrl(
+ page: Page,
+ url: string,
+): Promise<{ baggage: string; sentryTrace: string }> {
+ return new Promise<{ baggage: string; sentryTrace: string }>(resolve => {
+ page
+ .route(url, (route, req) => {
+ const baggage = req.headers()['baggage'];
+ const sentryTrace = req.headers()['sentry-trace'];
+ resolve({ baggage, sentryTrace });
+ return route.fulfill({ status: 200, body: 'ok' });
+ })
+ .catch(error => {
+ // Handle any routing setup errors
+ throw error;
+ });
+ });
+}
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index 92b7ddcea364..99036353d4d7 100644
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -10,7 +10,6 @@ import {
getDynamicSamplingContextFromSpan,
getIsolationScope,
getLocationHref,
- getRootSpan,
GLOBAL_OBJ,
logger,
propagationContextFromHeaders,
@@ -36,12 +35,7 @@ import {
import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from '../helpers';
import { registerBackgroundTabDetection } from './backgroundtab';
-import type { PreviousTraceInfo } from './previousTrace';
-import {
- addPreviousTraceSpanLink,
- getPreviousTraceFromSessionStorage,
- storePreviousTraceInSessionStorage,
-} from './previousTrace';
+import { linkTraces } from './linkedTraces';
import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request';
export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing';
@@ -166,13 +160,32 @@ export interface BrowserTracingOptions {
*
* - `'off'`: The previous trace data will not be stored or linked.
*
- * Note that your `tracesSampleRate` or `tracesSampler` config significantly influences
- * how often traces will be linked.
+ * You can also use {@link BrowserTracingOptions.consistentTraceSampling} to get
+ * consistent trace sampling of subsequent traces. Otherwise, by default, your
+ * `tracesSampleRate` or `tracesSampler` config significantly influences how often
+ * traces will be linked.
*
* @default 'in-memory' - see explanation above
*/
linkPreviousTrace: 'in-memory' | 'session-storage' | 'off';
+ /**
+ * If true, Sentry will consistently sample subsequent traces based on the
+ * sampling decision of the initial trace. For example, if the initial page
+ * load trace was sampled positively, all subsequent traces (e.g. navigations)
+ * are also sampled positively. In case the initial trace was sampled negatively,
+ * all subsequent traces are also sampled negatively.
+ *
+ * This option allows you to get consistent, linked traces within a user journey
+ * while maintaining an overall quota based on your trace sampling settings.
+ *
+ * This option is only effective if {@link BrowserTracingOptions.linkPreviousTrace}
+ * is enabled (i.e. not set to `'off'`).
+ *
+ * @default `false` - this is an opt-in feature.
+ */
+ consistentTraceSampling: boolean;
+
/**
* _experiments allows the user to send options to define how this integration works.
*
@@ -214,6 +227,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
enableLongAnimationFrame: true,
enableInp: true,
linkPreviousTrace: 'in-memory',
+ consistentTraceSampling: false,
_experiments: {},
...defaultRequestInstrumentationOptions,
};
@@ -265,6 +279,7 @@ export const browserTracingIntegration = ((_options: Partial {
- if (getRootSpan(span) !== span) {
- return;
- }
-
- if (linkPreviousTrace === 'session-storage') {
- const updatedPreviousTraceInfo = addPreviousTraceSpanLink(getPreviousTraceFromSessionStorage(), span);
- storePreviousTraceInSessionStorage(updatedPreviousTraceInfo);
- } else {
- inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span);
- }
- });
+ linkTraces(client, { linkPreviousTrace, consistentTraceSampling });
}
if (WINDOW.location) {
diff --git a/packages/browser/src/tracing/linkedTraces.ts b/packages/browser/src/tracing/linkedTraces.ts
new file mode 100644
index 000000000000..cd487aad9afa
--- /dev/null
+++ b/packages/browser/src/tracing/linkedTraces.ts
@@ -0,0 +1,239 @@
+import type { Client, PropagationContext, Span } from '@sentry/core';
+import {
+ type SpanContextData,
+ getCurrentScope,
+ getRootSpan,
+ logger,
+ SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE,
+ spanToJSON,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+import { WINDOW } from '../exports';
+
+export interface PreviousTraceInfo {
+ /**
+ * Span context of the previous trace's local root span
+ */
+ spanContext: SpanContextData;
+
+ /**
+ * Timestamp in seconds when the previous trace was started
+ */
+ startTimestamp: number;
+
+ /**
+ * sample rate of the previous trace
+ */
+ sampleRate: number;
+
+ /**
+ * The sample rand of the previous trace
+ */
+ sampleRand: number;
+}
+
+// 1h in seconds
+export const PREVIOUS_TRACE_MAX_DURATION = 3600;
+
+// session storage key
+export const PREVIOUS_TRACE_KEY = 'sentry_previous_trace';
+
+export const PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE = 'sentry.previous_trace';
+
+/**
+ * Takes care of linking traces and applying the (consistent) sampling behavoiour based on the passed options
+ * @param options - options for linking traces and consistent trace sampling (@see BrowserTracingOptions)
+ * @param client - Sentry client
+ */
+export function linkTraces(
+ client: Client,
+ {
+ linkPreviousTrace,
+ consistentTraceSampling,
+ }: {
+ linkPreviousTrace: 'session-storage' | 'in-memory';
+ consistentTraceSampling: boolean;
+ },
+): void {
+ const useSessionStorage = linkPreviousTrace === 'session-storage';
+
+ let inMemoryPreviousTraceInfo = useSessionStorage ? getPreviousTraceFromSessionStorage() : undefined;
+
+ client.on('spanStart', span => {
+ if (getRootSpan(span) !== span) {
+ return;
+ }
+
+ const oldPropagationContext = getCurrentScope().getPropagationContext();
+ inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span, oldPropagationContext);
+
+ if (useSessionStorage) {
+ storePreviousTraceInSessionStorage(inMemoryPreviousTraceInfo);
+ }
+ });
+
+ let isFirstTraceOnPageload = true;
+ if (consistentTraceSampling) {
+ /*
+ When users opt into `consistentTraceSampling`, we need to ensure that we propagate
+ the previous trace's sample rate and rand to the current trace. This is necessary because otherwise, span
+ metric extrapolation is inaccurate, as we'd propagate too high of a sample rate for the subsequent traces.
+
+ So therefore, we pretend that the previous trace was the parent trace of the newly started trace. To do that,
+ we mutate the propagation context of the current trace and set the sample rate and sample rand of the previous trace.
+ Timing-wise, it is fine because it happens before we even sample the root span.
+
+ @see https://github.com/getsentry/sentry-javascript/issues/15754
+ */
+ client.on('beforeSampling', mutableSamplingContextData => {
+ if (!inMemoryPreviousTraceInfo) {
+ return;
+ }
+
+ const scope = getCurrentScope();
+ const currentPropagationContext = scope.getPropagationContext();
+
+ // We do not want to force-continue the sampling decision if we continue a trace
+ // that was started on the backend. Most prominently, this will happen in MPAs where
+ // users hard-navigate between pages. In this case, the sampling decision of a potentially
+ // started trace on the server takes precedence.
+ // Why? We want to prioritize inter-trace consistency over intra-trace consistency.
+ if (isFirstTraceOnPageload && currentPropagationContext.parentSpanId) {
+ isFirstTraceOnPageload = false;
+ return;
+ }
+
+ scope.setPropagationContext({
+ ...currentPropagationContext,
+ dsc: {
+ ...currentPropagationContext.dsc,
+ sample_rate: String(inMemoryPreviousTraceInfo.sampleRate),
+ sampled: String(spanContextSampled(inMemoryPreviousTraceInfo.spanContext)),
+ },
+ sampleRand: inMemoryPreviousTraceInfo.sampleRand,
+ });
+
+ mutableSamplingContextData.parentSampled = spanContextSampled(inMemoryPreviousTraceInfo.spanContext);
+ mutableSamplingContextData.parentSampleRate = inMemoryPreviousTraceInfo.sampleRate;
+
+ mutableSamplingContextData.spanAttributes = {
+ ...mutableSamplingContextData.spanAttributes,
+ [SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]: inMemoryPreviousTraceInfo.sampleRate,
+ };
+ });
+ }
+}
+
+/**
+ * Adds a previous_trace span link to the passed span if the passed
+ * previousTraceInfo is still valid.
+ *
+ * @returns the updated previous trace info (based on the current span/trace) to
+ * be used on the next call
+ */
+export function addPreviousTraceSpanLink(
+ previousTraceInfo: PreviousTraceInfo | undefined,
+ span: Span,
+ oldPropagationContext: PropagationContext,
+): PreviousTraceInfo {
+ const spanJson = spanToJSON(span);
+
+ function getSampleRate(): number {
+ try {
+ return (
+ Number(oldPropagationContext.dsc?.sample_rate) ?? Number(spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE])
+ );
+ } catch {
+ return 0;
+ }
+ }
+
+ const updatedPreviousTraceInfo = {
+ spanContext: span.spanContext(),
+ startTimestamp: spanJson.start_timestamp,
+ sampleRate: getSampleRate(),
+ sampleRand: oldPropagationContext.sampleRand,
+ };
+
+ if (!previousTraceInfo) {
+ return updatedPreviousTraceInfo;
+ }
+
+ const previousTraceSpanCtx = previousTraceInfo.spanContext;
+ if (previousTraceSpanCtx.traceId === spanJson.trace_id) {
+ // This means, we're still in the same trace so let's not update the previous trace info
+ // or add a link to the current span.
+ // Once we move away from the long-lived, route-based trace model, we can remove this cases
+ return previousTraceInfo;
+ }
+
+ // Only add the link if the startTimeStamp of the previous trace's root span is within
+ // PREVIOUS_TRACE_MAX_DURATION (1h) of the current root span's startTimestamp
+ // This is done to
+ // - avoid adding links to "stale" traces
+ // - enable more efficient querying for previous/next traces in Sentry
+ if (Date.now() / 1000 - previousTraceInfo.startTimestamp <= PREVIOUS_TRACE_MAX_DURATION) {
+ if (DEBUG_BUILD) {
+ logger.info(
+ `Adding previous_trace ${previousTraceSpanCtx} link to span ${{
+ op: spanJson.op,
+ ...span.spanContext(),
+ }}`,
+ );
+ }
+
+ span.addLink({
+ context: previousTraceSpanCtx,
+ attributes: {
+ [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
+ },
+ });
+
+ // TODO: Remove this once EAP can store span links. We currently only set this attribute so that we
+ // can obtain the previous trace information from the EAP store. Long-term, EAP will handle
+ // span links and then we should remove this again. Also throwing in a TODO(v10), to remind us
+ // to check this at v10 time :)
+ span.setAttribute(
+ PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE,
+ `${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${
+ spanContextSampled(previousTraceSpanCtx) ? 1 : 0
+ }`,
+ );
+ }
+
+ return updatedPreviousTraceInfo;
+}
+
+/**
+ * Stores @param previousTraceInfo in sessionStorage.
+ */
+export function storePreviousTraceInSessionStorage(previousTraceInfo: PreviousTraceInfo): void {
+ try {
+ WINDOW.sessionStorage.setItem(PREVIOUS_TRACE_KEY, JSON.stringify(previousTraceInfo));
+ } catch (e) {
+ // Ignore potential errors (e.g. if sessionStorage is not available)
+ DEBUG_BUILD && logger.warn('Could not store previous trace in sessionStorage', e);
+ }
+}
+
+/**
+ * Retrieves the previous trace from sessionStorage if available.
+ */
+export function getPreviousTraceFromSessionStorage(): PreviousTraceInfo | undefined {
+ try {
+ const previousTraceInfo = WINDOW.sessionStorage?.getItem(PREVIOUS_TRACE_KEY);
+ // @ts-expect-error - intentionally risking JSON.parse throwing when previousTraceInfo is null to save bundle size
+ return JSON.parse(previousTraceInfo);
+ } catch (e) {
+ return undefined;
+ }
+}
+
+/**
+ * see {@link import('@sentry/core').spanIsSampled}
+ */
+export function spanContextSampled(ctx: SpanContextData): boolean {
+ return ctx.traceFlags === 0x1;
+}
diff --git a/packages/browser/src/tracing/previousTrace.ts b/packages/browser/src/tracing/previousTrace.ts
deleted file mode 100644
index 6d53833d718d..000000000000
--- a/packages/browser/src/tracing/previousTrace.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import type { Span } from '@sentry/core';
-import { type SpanContextData, logger, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanToJSON } from '@sentry/core';
-import { DEBUG_BUILD } from '../debug-build';
-import { WINDOW } from '../exports';
-
-export interface PreviousTraceInfo {
- /**
- * Span context of the previous trace's local root span
- */
- spanContext: SpanContextData;
-
- /**
- * Timestamp in seconds when the previous trace was started
- */
- startTimestamp: number;
-}
-
-// 1h in seconds
-export const PREVIOUS_TRACE_MAX_DURATION = 3600;
-
-// session storage key
-export const PREVIOUS_TRACE_KEY = 'sentry_previous_trace';
-
-export const PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE = 'sentry.previous_trace';
-
-/**
- * Adds a previous_trace span link to the passed span if the passed
- * previousTraceInfo is still valid.
- *
- * @returns the updated previous trace info (based on the current span/trace) to
- * be used on the next call
- */
-export function addPreviousTraceSpanLink(
- previousTraceInfo: PreviousTraceInfo | undefined,
- span: Span,
-): PreviousTraceInfo {
- const spanJson = spanToJSON(span);
-
- if (!previousTraceInfo) {
- return {
- spanContext: span.spanContext(),
- startTimestamp: spanJson.start_timestamp,
- };
- }
-
- const previousTraceSpanCtx = previousTraceInfo.spanContext;
- if (previousTraceSpanCtx.traceId === spanJson.trace_id) {
- // This means, we're still in the same trace so let's not update the previous trace info
- // or add a link to the current span.
- // Once we move away from the long-lived, route-based trace model, we can remove this cases
- return previousTraceInfo;
- }
-
- // Only add the link if the startTimeStamp of the previous trace's root span is within
- // PREVIOUS_TRACE_MAX_DURATION (1h) of the current root span's startTimestamp
- // This is done to
- // - avoid adding links to "stale" traces
- // - enable more efficient querying for previous/next traces in Sentry
- if (Date.now() / 1000 - previousTraceInfo.startTimestamp <= PREVIOUS_TRACE_MAX_DURATION) {
- if (DEBUG_BUILD) {
- logger.info(
- `Adding previous_trace ${previousTraceSpanCtx} link to span ${{
- op: spanJson.op,
- ...span.spanContext(),
- }}`,
- );
- }
-
- span.addLink({
- context: previousTraceSpanCtx,
- attributes: {
- [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace',
- },
- });
-
- // TODO: Remove this once EAP can store span links. We currently only set this attribute so that we
- // can obtain the previous trace information from the EAP store. Long-term, EAP will handle
- // span links and then we should remove this again. Also throwing in a TODO(v10), to remind us
- // to check this at v10 time :)
- span.setAttribute(
- PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE,
- `${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${
- previousTraceSpanCtx.traceFlags === 0x1 ? 1 : 0
- }`,
- );
- }
-
- return {
- spanContext: span.spanContext(),
- startTimestamp: spanToJSON(span).start_timestamp,
- };
-}
-
-/**
- * Stores @param previousTraceInfo in sessionStorage.
- */
-export function storePreviousTraceInSessionStorage(previousTraceInfo: PreviousTraceInfo): void {
- try {
- WINDOW.sessionStorage.setItem(PREVIOUS_TRACE_KEY, JSON.stringify(previousTraceInfo));
- } catch (e) {
- // Ignore potential errors (e.g. if sessionStorage is not available)
- DEBUG_BUILD && logger.warn('Could not store previous trace in sessionStorage', e);
- }
-}
-
-/**
- * Retrieves the previous trace from sessionStorage if available.
- */
-export function getPreviousTraceFromSessionStorage(): PreviousTraceInfo | undefined {
- try {
- const previousTraceInfo = WINDOW.sessionStorage?.getItem(PREVIOUS_TRACE_KEY);
- // @ts-expect-error - intentionally risking JSON.parse throwing when previousTraceInfo is null to save bundle size
- return JSON.parse(previousTraceInfo);
- } catch (e) {
- return undefined;
- }
-}
diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts
index 7d6c308a3ca5..728bee5fd1dd 100644
--- a/packages/browser/test/tracing/browserTracingIntegration.test.ts
+++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts
@@ -28,7 +28,7 @@ import {
startBrowserTracingNavigationSpan,
startBrowserTracingPageLoadSpan,
} from '../../src/tracing/browserTracingIntegration';
-import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/previousTrace';
+import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/linkedTraces';
import { getDefaultBrowserClientOptions } from '../helper/browser-client-options';
const oldTextEncoder = global.window.TextEncoder;
diff --git a/packages/browser/test/tracing/previousTrace.test.ts b/packages/browser/test/tracing/linkedTraces.test.ts
similarity index 52%
rename from packages/browser/test/tracing/previousTrace.test.ts
rename to packages/browser/test/tracing/linkedTraces.test.ts
index f11e2f0d67e5..7c075da588ef 100644
--- a/packages/browser/test/tracing/previousTrace.test.ts
+++ b/packages/browser/test/tracing/linkedTraces.test.ts
@@ -1,14 +1,146 @@
-import { SentrySpan, spanToJSON, timestampInSeconds } from '@sentry/core';
+import type { Span } from '@sentry/core';
+import { addChildSpanToSpan, SentrySpan, spanToJSON, timestampInSeconds } from '@sentry/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import type { PreviousTraceInfo } from '../../src/tracing/previousTrace';
+import { BrowserClient } from '../../src';
+import type { PreviousTraceInfo } from '../../src/tracing/linkedTraces';
import {
addPreviousTraceSpanLink,
getPreviousTraceFromSessionStorage,
+ linkTraces,
PREVIOUS_TRACE_KEY,
PREVIOUS_TRACE_MAX_DURATION,
PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE,
+ spanContextSampled,
storePreviousTraceInSessionStorage,
-} from '../../src/tracing/previousTrace';
+} from '../../src/tracing/linkedTraces';
+
+describe('linkTraces', () => {
+ describe('adds a previous trace span link on span start', () => {
+ // @ts-expect-error - mock contains only necessary API
+ const client = new BrowserClient({ transport: () => {}, integrations: [], stackParser: () => [] });
+
+ let spanStartCb: (span: Span) => void;
+
+ // @ts-expect-error - this is fine for testing
+ const clientOnSpy = vi.spyOn(client, 'on').mockImplementation((event, cb) => {
+ // @ts-expect-error - this is fine for testing
+ if (event === 'spanStart') {
+ spanStartCb = cb;
+ }
+ });
+
+ it('registers a spanStart handler', () => {
+ expect(clientOnSpy).toHaveBeenCalledWith('spanStart', expect.any(Function));
+ expect(clientOnSpy).toHaveBeenCalledOnce();
+ });
+
+ beforeEach(() => {
+ linkTraces(client, { linkPreviousTrace: 'in-memory', consistentTraceSampling: false });
+ });
+
+ it("doesn't add a link if the passed span is not the root span", () => {
+ const rootSpan = new SentrySpan({
+ name: 'test',
+ parentSpanId: undefined,
+ sampled: true,
+ spanId: '123',
+ traceId: '456',
+ });
+
+ const childSpan = new SentrySpan({
+ name: 'test',
+ parentSpanId: '123',
+ spanId: '456',
+ traceId: '789',
+ sampled: true,
+ });
+
+ addChildSpanToSpan(rootSpan, childSpan);
+
+ spanStartCb(childSpan);
+
+ expect(spanToJSON(childSpan).links).toBeUndefined();
+ });
+
+ it('adds a link from the first trace root span to the second trace root span', () => {
+ const rootSpanTrace1 = new SentrySpan({
+ name: 'test',
+ parentSpanId: undefined,
+ sampled: true,
+ spanId: '123',
+ traceId: '456',
+ });
+
+ spanStartCb(rootSpanTrace1);
+
+ expect(spanToJSON(rootSpanTrace1).links).toBeUndefined();
+
+ const rootSpanTrace2 = new SentrySpan({
+ name: 'test',
+ parentSpanId: undefined,
+ sampled: true,
+ spanId: '789',
+ traceId: 'def',
+ });
+
+ spanStartCb(rootSpanTrace2);
+
+ expect(spanToJSON(rootSpanTrace2).links).toEqual([
+ {
+ attributes: {
+ 'sentry.link.type': 'previous_trace',
+ },
+ span_id: '123',
+ trace_id: '456',
+ sampled: true,
+ },
+ ]);
+ });
+
+ it("doesn't add a link to the second root span if it is part of the same trace", () => {
+ const rootSpanTrace1 = new SentrySpan({
+ name: 'test',
+ parentSpanId: undefined,
+ sampled: true,
+ spanId: '123',
+ traceId: 'def',
+ });
+
+ spanStartCb(rootSpanTrace1);
+
+ expect(spanToJSON(rootSpanTrace1).links).toBeUndefined();
+
+ const rootSpan2Trace = new SentrySpan({
+ name: 'test',
+ parentSpanId: undefined,
+ sampled: true,
+ spanId: '789',
+ traceId: 'def',
+ });
+
+ spanStartCb(rootSpan2Trace);
+
+ expect(spanToJSON(rootSpan2Trace).links).toBeUndefined();
+ });
+ });
+
+ // only basic tests here, rest is tested in browser-integration-tests
+ describe('consistentTraceSampling', () => {
+ // @ts-expect-error - mock contains only necessary API
+ const client = new BrowserClient({ transport: () => {}, integrations: [], stackParser: () => [] });
+ const clientOnSpy = vi.spyOn(client, 'on');
+
+ beforeEach(() => {
+ linkTraces(client, { linkPreviousTrace: 'in-memory', consistentTraceSampling: true });
+ });
+
+ it('registers a beforeSampling handler', () => {
+ expect(clientOnSpy).toHaveBeenCalledWith('spanStart', expect.any(Function));
+ expect(clientOnSpy).toHaveBeenCalledWith('beforeSampling', expect.any(Function));
+ expect(clientOnSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+});
describe('addPreviousTraceSpanLink', () => {
it(`adds a previous_trace span link to startSpanOptions if the previous trace was created within ${PREVIOUS_TRACE_MAX_DURATION}s`, () => {
@@ -22,6 +154,8 @@ describe('addPreviousTraceSpanLink', () => {
},
// max time reached almost exactly
startTimestamp: currentSpanStart - PREVIOUS_TRACE_MAX_DURATION + 1,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
};
const currentSpan = new SentrySpan({
@@ -33,7 +167,14 @@ describe('addPreviousTraceSpanLink', () => {
sampled: true,
});
- const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan);
+ const oldPropagationContext = {
+ sampleRand: 0.0126,
+ traceId: '123',
+ sampled: true,
+ dsc: { sample_rand: '0.0126', sample_rate: '0.5' },
+ };
+
+ const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext);
const spanJson = spanToJSON(currentSpan);
@@ -55,6 +196,8 @@ describe('addPreviousTraceSpanLink', () => {
expect(updatedPreviousTraceInfo).toEqual({
spanContext: currentSpan.spanContext(),
startTimestamp: currentSpanStart,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
});
});
@@ -68,6 +211,8 @@ describe('addPreviousTraceSpanLink', () => {
traceFlags: 0,
},
startTimestamp: Date.now() / 1000 - PREVIOUS_TRACE_MAX_DURATION - 1,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
};
const currentSpan = new SentrySpan({
@@ -75,7 +220,14 @@ describe('addPreviousTraceSpanLink', () => {
startTimestamp: currentSpanStart,
});
- const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan);
+ const oldPropagationContext = {
+ sampleRand: 0.0126,
+ traceId: '123',
+ sampled: true,
+ dsc: { sample_rand: '0.0126', sample_rate: '0.5' },
+ };
+
+ const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext);
const spanJson = spanToJSON(currentSpan);
@@ -87,6 +239,8 @@ describe('addPreviousTraceSpanLink', () => {
expect(updatedPreviousTraceInfo).toEqual({
spanContext: currentSpan.spanContext(),
startTimestamp: currentSpanStart,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
});
});
@@ -98,6 +252,15 @@ describe('addPreviousTraceSpanLink', () => {
traceFlags: 1,
},
startTimestamp: Date.now() / 1000,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
+ };
+
+ const oldPropagationContext = {
+ sampleRand: 0.0126,
+ traceId: '123',
+ sampled: true,
+ dsc: { sample_rand: '0.0126', sample_rate: '0.5' },
};
const currentSpanStart = timestampInSeconds();
@@ -119,7 +282,7 @@ describe('addPreviousTraceSpanLink', () => {
startTimestamp: currentSpanStart,
});
- const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan);
+ const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext);
expect(spanToJSON(currentSpan).links).toEqual([
{
@@ -143,6 +306,8 @@ describe('addPreviousTraceSpanLink', () => {
expect(updatedPreviousTraceInfo).toEqual({
spanContext: currentSpan.spanContext(),
startTimestamp: currentSpanStart,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
});
});
@@ -150,13 +315,22 @@ describe('addPreviousTraceSpanLink', () => {
const currentSpanStart = timestampInSeconds();
const currentSpan = new SentrySpan({ name: 'test', startTimestamp: currentSpanStart });
- const updatedPreviousTraceInfo = addPreviousTraceSpanLink(undefined, currentSpan);
+ const oldPropagationContext = {
+ sampleRand: 0.0126,
+ traceId: '123',
+ sampled: false,
+ dsc: { sample_rand: '0.0126', sample_rate: '0.5', sampled: 'false' },
+ };
+
+ const updatedPreviousTraceInfo = addPreviousTraceSpanLink(undefined, currentSpan, oldPropagationContext);
const spanJson = spanToJSON(currentSpan);
expect(spanJson.links).toBeUndefined();
expect(Object.keys(spanJson.data)).not.toContain(PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE);
expect(updatedPreviousTraceInfo).toEqual({
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
spanContext: currentSpan.spanContext(),
startTimestamp: currentSpanStart,
});
@@ -178,9 +352,18 @@ describe('addPreviousTraceSpanLink', () => {
traceFlags: 1,
},
startTimestamp: currentSpanStart - 1,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
+ };
+
+ const oldPropagationContext = {
+ sampleRand: 0.0126,
+ traceId: '123',
+ sampled: true,
+ dsc: { sample_rand: '0.0126', sample_rate: '0.5' },
};
- const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan);
+ const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext);
const spanJson = spanToJSON(currentSpan);
expect(spanJson.links).toBeUndefined();
@@ -213,6 +396,8 @@ describe('store and retrieve previous trace data via sessionStorage ', () => {
traceFlags: 1,
},
startTimestamp: Date.now() / 1000,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
};
storePreviousTraceInSessionStorage(previousTraceInfo);
@@ -231,6 +416,8 @@ describe('store and retrieve previous trace data via sessionStorage ', () => {
traceFlags: 1,
},
startTimestamp: Date.now() / 1000,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
};
expect(() => storePreviousTraceInSessionStorage(previousTraceInfo)).not.toThrow();
@@ -238,3 +425,24 @@ describe('store and retrieve previous trace data via sessionStorage ', () => {
expect(getPreviousTraceFromSessionStorage()).toBeUndefined();
});
});
+
+describe('spanContextSampled', () => {
+ it('returns true if traceFlags is 1', () => {
+ const spanContext = {
+ traceId: '123',
+ spanId: '456',
+ traceFlags: 1,
+ };
+
+ expect(spanContextSampled(spanContext)).toBe(true);
+ });
+
+ it.each([0, 2, undefined as unknown as number])('returns false if traceFlags is %s', flags => {
+ const spanContext = {
+ traceId: '123',
+ spanId: '456',
+ traceFlags: flags,
+ };
+ expect(spanContextSampled(spanContext)).toBe(false);
+ });
+});
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 46854c7992bd..0dda6c86fd26 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -491,6 +491,7 @@ export abstract class Client {
spanAttributes: SpanAttributes;
spanName: string;
parentSampled?: boolean;
+ parentSampleRate?: number;
parentContext?: SpanContextData;
},
samplingDecision: { decision: boolean },
@@ -691,6 +692,7 @@ export abstract class Client {
spanAttributes: SpanAttributes;
spanName: string;
parentSampled?: boolean;
+ parentSampleRate?: number;
parentContext?: SpanContextData;
},
samplingDecision: { decision: boolean },
diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts
index aa25b70f7304..9b90809c0091 100644
--- a/packages/core/src/semanticAttributes.ts
+++ b/packages/core/src/semanticAttributes.ts
@@ -13,6 +13,14 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source';
*/
export const SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE = 'sentry.sample_rate';
+/**
+ * Attribute holding the sample rate of the previous trace.
+ * This is used to sample consistently across subsequent traces in the browser SDK.
+ *
+ * Note: Only defined on root spans, if opted into consistent sampling
+ */
+export const SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE = 'sentry.previous_trace_sample_rate';
+
/**
* Use this attribute to represent the operation of a span.
*/
diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts
index ce2d9ad7eadd..9380c75dd3be 100644
--- a/packages/core/src/tracing/dynamicSamplingContext.ts
+++ b/packages/core/src/tracing/dynamicSamplingContext.ts
@@ -2,7 +2,11 @@ import type { Client } from '../client';
import { DEFAULT_ENVIRONMENT } from '../constants';
import { getClient } from '../currentScopes';
import type { Scope } from '../scope';
-import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../semanticAttributes';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '../semanticAttributes';
import type { DynamicSamplingContext } from '../types-hoist/envelope';
import type { Span } from '../types-hoist/span';
import { hasSpansEnabled } from '../utils/hasSpansEnabled';
@@ -85,7 +89,10 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly): Partial {
if (typeof rootSpanSampleRate === 'number' || typeof rootSpanSampleRate === 'string') {
dsc.sample_rate = `${rootSpanSampleRate}`;
diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts
index fb8a89f0f860..a96159692ac3 100644
--- a/packages/core/src/tracing/trace.ts
+++ b/packages/core/src/tracing/trace.ts
@@ -401,7 +401,17 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent
const client = getClient();
const options: Partial = client?.getOptions() || {};
- const { name = '', attributes } = spanArguments;
+ const { name = '' } = spanArguments;
+
+ const mutableSpanSamplingData = { spanAttributes: { ...spanArguments.attributes }, spanName: name, parentSampled };
+
+ // we don't care about the decision for the moment; this is just a placeholder
+ client?.emit('beforeSampling', mutableSpanSamplingData, { decision: false });
+
+ // If hook consumers override the parentSampled flag, we will use that value instead of the actual one
+ const finalParentSampled = mutableSpanSamplingData.parentSampled ?? parentSampled;
+ const finalAttributes = mutableSpanSamplingData.spanAttributes;
+
const currentPropagationContext = scope.getPropagationContext();
const [sampled, sampleRate, localSampleRateWasApplied] = scope.getScopeData().sdkProcessingMetadata[
SUPPRESS_TRACING_KEY
@@ -411,8 +421,8 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent
options,
{
name,
- parentSampled,
- attributes,
+ parentSampled: finalParentSampled,
+ attributes: finalAttributes,
parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate),
},
currentPropagationContext.sampleRand,
@@ -424,7 +434,7 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]:
sampleRate !== undefined && localSampleRateWasApplied ? sampleRate : undefined,
- ...spanArguments.attributes,
+ ...finalAttributes,
},
sampled,
});