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, });