diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/init.js
new file mode 100644
index 000000000000..c536f134da0f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/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',
+ sampleLinkedTracesConsistently: true
+ }),
+ ],
+ tracePropagationTargets: ['someurl.com'],
+ tracesSampleRate: 1,
+ debug: true,
+ sendClientReports: true
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/subject.js
new file mode 100644
index 000000000000..e200e8eb6382
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/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/previous-trace-links/consistent-sampling-meta-negative/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/template.html
new file mode 100644
index 000000000000..6347fa37fc00
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/template.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/test.ts
new file mode 100644
index 000000000000..d97c27308662
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta-negative/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,
+ getMultipleSentryEnvelopeRequests,
+ shouldSkipTracingTest,
+ waitForClientReportRequest,
+} from '../../../../../utils/helpers';
+
+const metaTagSampleRand = 0.9;
+const metaTagSampleRate = 0.2;
+const metaTagTraceId = '12345678901234567890123456789012';
+
+sentryTest.describe('When `sampleLinkedTracesConsistently` 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 () => {
+ let sentryTrace = undefined;
+ let baggage = undefined;
+
+ await page.route('https://someUrl.com', (route, req) => {
+ baggage = req.headers()['baggage'];
+ sentryTrace = req.headers()['sentry-trace'];
+ return route.fulfill({ status: 200, body: 'ok' });
+ });
+
+ await page.locator('#btn2').click();
+
+ 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 page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: function () {
+ return 'hidden';
+ },
+ });
+
+ // Dispatch the visibilitychange event to notify listeners
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+
+ 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/previous-trace-links/consistent-sampling-meta/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta/init.js
new file mode 100644
index 000000000000..42d1329f51f4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/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',
+ sampleLinkedTracesConsistently: 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/previous-trace-links/consistent-sampling-meta/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta/subject.js
new file mode 100644
index 000000000000..e200e8eb6382
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/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/previous-trace-links/consistent-sampling-meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta/template.html
new file mode 100644
index 000000000000..c6a798a60c24
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta/template.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta/test.ts
new file mode 100644
index 000000000000..89134b9de316
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling-meta/test.ts
@@ -0,0 +1,182 @@
+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,
+ waitForTransactionRequest,
+} from '../../../../../utils/helpers';
+
+const metaTagSampleRand = 0.051121;
+const metaTagSampleRate = 0.2;
+
+sentryTest.describe('When `sampleLinkedTracesConsistently` 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 () => {
+ let sentryTrace = undefined;
+ let baggage = undefined;
+
+ await page.route('https://someUrl.com', (route, req) => {
+ baggage = req.headers()['baggage'];
+ sentryTrace = req.headers()['sentry-trace'];
+ return route.fulfill({ status: 200, body: 'ok' });
+ });
+
+ const fetchTracePromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
+
+ await page.locator('#btn2').click();
+
+ 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/previous-trace-links/consistent-sampling/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/init.js
new file mode 100644
index 000000000000..87222e42e539
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/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',
+ sampleLinkedTracesConsistently: 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/previous-trace-links/consistent-sampling/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/subject.js
new file mode 100644
index 000000000000..e200e8eb6382
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/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/previous-trace-links/consistent-sampling/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/template.html
new file mode 100644
index 000000000000..f27a71d043f9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/test.ts
new file mode 100644
index 000000000000..5d0606da024b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/consistent-sampling/test.ts
@@ -0,0 +1,159 @@
+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,
+ waitForTransactionRequest,
+} from '../../../../../utils/helpers';
+
+sentryTest.describe('When `sampleLinkedTracesConsistently` 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 () => {
+ let sentryTrace = undefined;
+ let baggage = undefined;
+
+ await page.route('https://someUrl.com', (route, req) => {
+ baggage = req.headers()['baggage'];
+ sentryTrace = req.headers()['sentry-trace'];
+ return route.fulfill({ status: 200, body: 'ok' });
+ });
+
+ const fetchTracePromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom');
+
+ await page.locator('#btn2').click();
+
+ 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/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts
index d08ffccd7831..61a64f472dd9 100644
--- a/dev-packages/browser-integration-tests/utils/helpers.ts
+++ b/dev-packages/browser-integration-tests/utils/helpers.ts
@@ -1,6 +1,8 @@
+/* eslint-disable max-lines */
import type { Page, Request } from '@playwright/test';
import { parseEnvelope } from '@sentry/core';
import type {
+ ClientReport,
Envelope,
EnvelopeItem,
EnvelopeItemType,
@@ -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();
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index fab45cd1ed4f..a345cb07e702 100644
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -14,6 +14,7 @@ import {
GLOBAL_OBJ,
SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
TRACING_DEFAULTS,
addNonEnumerableProperty,
@@ -36,10 +37,10 @@ import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from '../helpers';
import { registerBackgroundTabDetection } from './backgroundtab';
import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request';
-import type { PreviousTraceInfo } from './previousTrace';
import {
addPreviousTraceSpanLink,
getPreviousTraceFromSessionStorage,
+ spanContextSampled,
storePreviousTraceInSessionStorage,
} from './previousTrace';
@@ -172,6 +173,23 @@ export interface BrowserTracingOptions {
*/
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 lets you 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.
+ */
+ sampleLinkedTracesConsistently: boolean;
+
/**
* _experiments allows the user to send options to define how this integration works.
*
@@ -213,6 +231,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
enableLongAnimationFrame: true,
enableInp: true,
linkPreviousTrace: 'in-memory',
+ sampleLinkedTracesConsistently: false,
_experiments: {},
...defaultRequestInstrumentationOptions,
};
@@ -253,6 +272,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);
+ const scope = getCurrentScope();
+ const oldPropagationContext = scope.getPropagationContext();
+ inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span, oldPropagationContext);
+
+ if (useSessionStorage) {
+ storePreviousTraceInSessionStorage(inMemoryPreviousTraceInfo);
}
});
+
+ if (sampleLinkedTracesConsistently) {
+ /*
+ This is a massive hack I'm really not proud of:
+
+ When users opt into `sampleLinkedTracesConsistently`, we need to make sure that we "propagate"
+ the previous trace's sample rate and rand to the current trace. This is necessary because otherwise, span
+ metric extrapolation is off, as we'd be propagating a too high 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();
+
+ scope.setPropagationContext({
+ ...currentPropagationContext,
+ dsc: {
+ ...currentPropagationContext.dsc,
+ // The fallback to 0 should never happen; this is rather to satisfy the types
+ sample_rate: String(inMemoryPreviousTraceInfo.sampleRate ?? 0),
+ sampled: String(spanContextSampled(inMemoryPreviousTraceInfo.spanContext)),
+ },
+ sampleRand: inMemoryPreviousTraceInfo.sampleRand,
+ });
+
+ mutableSamplingContextData.parentSampled = spanContextSampled(inMemoryPreviousTraceInfo.spanContext);
+ mutableSamplingContextData.parentSampleRate = inMemoryPreviousTraceInfo.sampleRate;
+
+ mutableSamplingContextData.spanAttributes = {
+ ...mutableSamplingContextData.spanAttributes,
+ // record an attribute that this span was "force-sampled", so that we can later check on this.
+ [SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]: inMemoryPreviousTraceInfo.sampleRate,
+ };
+ });
+ }
}
if (WINDOW.location) {
diff --git a/packages/browser/src/tracing/previousTrace.ts b/packages/browser/src/tracing/previousTrace.ts
index 91e52b519dad..40b0c09702b2 100644
--- a/packages/browser/src/tracing/previousTrace.ts
+++ b/packages/browser/src/tracing/previousTrace.ts
@@ -1,5 +1,11 @@
-import type { Span } from '@sentry/core';
-import { logger, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanToJSON, type SpanContextData } from '@sentry/core';
+import type { PropagationContext, Span } from '@sentry/core';
+import {
+ logger,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE,
+ spanToJSON,
+ type SpanContextData,
+} from '@sentry/core';
import { WINDOW } from '../exports';
import { DEBUG_BUILD } from '../debug-build';
@@ -13,6 +19,16 @@ export interface PreviousTraceInfo {
* 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
@@ -33,14 +49,29 @@ export const PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE = 'sentry.previous_trace';
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 {
- spanContext: span.spanContext(),
- startTimestamp: spanJson.start_timestamp,
- };
+ return updatedPreviousTraceInfo;
}
const previousTraceSpanCtx = previousTraceInfo.spanContext;
@@ -80,15 +111,12 @@ export function addPreviousTraceSpanLink(
span.setAttribute(
PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE,
`${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${
- previousTraceSpanCtx.traceFlags === 0x1 ? 1 : 0
+ spanContextSampled(previousTraceSpanCtx) ? 1 : 0
}`,
);
}
- return {
- spanContext: span.spanContext(),
- startTimestamp: spanToJSON(span).start_timestamp,
- };
+ return updatedPreviousTraceInfo;
}
/**
@@ -115,3 +143,10 @@ export function getPreviousTraceFromSessionStorage(): PreviousTraceInfo | undefi
return undefined;
}
}
+
+/**
+ * see {@link import('@sentry/core').spanIsSampled}
+ */
+export const spanContextSampled = (ctx: SpanContextData): boolean => {
+ return ctx.traceFlags === 0x1;
+};
diff --git a/packages/browser/test/tracing/previousTrace.test.ts b/packages/browser/test/tracing/previousTrace.test.ts
index e3e3e3cc597e..9e4d563d78da 100644
--- a/packages/browser/test/tracing/previousTrace.test.ts
+++ b/packages/browser/test/tracing/previousTrace.test.ts
@@ -6,6 +6,7 @@ import {
PREVIOUS_TRACE_KEY,
PREVIOUS_TRACE_MAX_DURATION,
PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE,
+ spanContextSampled,
} from '../../src/tracing/previousTrace';
import { SentrySpan, spanToJSON, timestampInSeconds } from '@sentry/core';
import { storePreviousTraceInSessionStorage } from '../../src/tracing/previousTrace';
@@ -22,6 +23,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 +36,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 +65,8 @@ describe('addPreviousTraceSpanLink', () => {
expect(updatedPreviousTraceInfo).toEqual({
spanContext: currentSpan.spanContext(),
startTimestamp: currentSpanStart,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
});
});
@@ -68,6 +80,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 +89,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 +108,8 @@ describe('addPreviousTraceSpanLink', () => {
expect(updatedPreviousTraceInfo).toEqual({
spanContext: currentSpan.spanContext(),
startTimestamp: currentSpanStart,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
});
});
@@ -98,6 +121,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 +151,7 @@ describe('addPreviousTraceSpanLink', () => {
startTimestamp: currentSpanStart,
});
- const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan);
+ const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext);
expect(spanToJSON(currentSpan).links).toEqual([
{
@@ -143,6 +175,8 @@ describe('addPreviousTraceSpanLink', () => {
expect(updatedPreviousTraceInfo).toEqual({
spanContext: currentSpan.spanContext(),
startTimestamp: currentSpanStart,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
});
});
@@ -150,13 +184,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 +221,18 @@ describe('addPreviousTraceSpanLink', () => {
traceFlags: 1,
},
startTimestamp: currentSpanStart - 1,
+ sampleRand: 0.0126,
+ sampleRate: 0.5,
};
- 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);
expect(spanJson.links).toBeUndefined();
@@ -213,6 +265,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 +285,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 +294,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 3599448abf4c..35a9be1929c1 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -509,6 +509,7 @@ export abstract class Client {
spanAttributes: SpanAttributes;
spanName: string;
parentSampled?: boolean;
+ parentSampleRate?: number;
parentContext?: SpanContextData;
},
samplingDecision: { decision: boolean },
@@ -709,6 +710,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 12cf4ca11ca6..1412ebdfc41f 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, Span } from '../types-hoist';
import {
baggageHeaderToDynamicSamplingContext,
@@ -84,7 +88,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 00e004a80131..3b70f07b4f1a 100644
--- a/packages/core/src/tracing/trace.ts
+++ b/packages/core/src/tracing/trace.ts
@@ -407,7 +407,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
@@ -417,8 +427,8 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent
options,
{
name,
- parentSampled,
- attributes,
+ parentSampled: finalParentSampled,
+ attributes: finalAttributes,
parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate),
},
currentPropagationContext.sampleRand,
@@ -430,7 +440,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,
});