From f20838f65b7127926b12a215dd326ea626932e79 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 30 Oct 2026 13:20:12 +0100 Subject: [PATCH 1/2] fix(core): Create DSC locally when receiving an incoming trace without DSC When a request has a sentry-trace header but no sentry- baggage, we froze an empty DSC, so envelope headers and outgoing baggage carried almost no DSC values. Leave the DSC unfrozen instead, so it is created from our own segment span, as the DSC spec requires. For an inherited sampling decision, set sample_rate to 1 or 0 so that sample_rand < sample_rate still matches sampled. Co-Authored-By: Claude Opus 5.5 --- .../src/tracing/dynamicSamplingContext.ts | 19 ++++++++++++++----- packages/core/src/tracing/trace.ts | 10 ++-------- packages/core/src/types/tracing.ts | 4 +++- packages/core/src/utils/tracing.ts | 10 ++++++---- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 06b0c360fd8d..455f262c6f2b 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -166,17 +166,26 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly sampled` intact. + if (rootSpanSampleRate == null && typeof propagationContext?.sampled === 'boolean') { + dsc.sample_rate = sampled ? '1' : '0'; + } } applyLocalSampleRateToDsc(dsc); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index cb6f9f853184..d22312ff4012 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -406,7 +406,7 @@ function createChildOrRootSpan({ freezeDscOnSpan(span, dsc); } else { - const { traceId, dsc, parentSpanId, sampled: parentSampled, sampleRand } = scope.getPropagationContext(); + const { traceId, dsc, parentSpanId, sampled: parentSampled } = scope.getPropagationContext(); span = _startRootSpan( { @@ -420,13 +420,7 @@ function createChildOrRootSpan({ ); if (dsc) { - // A trace continued without incoming baggage carries an empty DSC (we are not the head of - // trace). Fold in the scope's `sample_rand` so it still propagates downstream and sampling - // decisions stay consistent across the trace. A populated frozen DSC (e.g. an OTel remote - // parent whose DSC came in via baggage/trace state) is left untouched. - const dscWithSampleRand = - Object.keys(dsc).length === 0 && sampleRand !== undefined ? { sample_rand: sampleRand.toString() } : dsc; - freezeDscOnSpan(span, dscWithSampleRand); + freezeDscOnSpan(span, dsc); } } diff --git a/packages/core/src/types/tracing.ts b/packages/core/src/types/tracing.ts index 60e59cdafa43..b54a0e10944f 100644 --- a/packages/core/src/types/tracing.ts +++ b/packages/core/src/types/tracing.ts @@ -45,7 +45,9 @@ export interface PropagationContext { propagationSpanId?: string; /** - * An undefined dsc in the propagation context means that the current SDK invocation is the head of trace and still free to modify and set the DSC for outgoing requests. + * The frozen DSC of an incoming trace. An undefined dsc means the DSC is not frozen yet and is created by the + * current SDK invocation. This is the case if we are the head of trace, or if we continue a trace that arrived + * without a DSC (a `sentry-trace` header but no `sentry-` values in `baggage`). * * The current SDK should not modify this value! */ diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index b121fa13ce85..d595a8dc1438 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -55,9 +55,9 @@ export function extractTraceparentData(traceparent?: string): TraceparentData | /** * Whether a propagation context continues an incoming trace, rather than being the head of a new one. * - * Both fields have to be checked. `parentSpanId` is unset when the incoming `sentry-trace` header - * carried no span id, which the header format allows. `dsc` is unset when a remote parent arrived - * without any incoming baggage. Either one on its own therefore misses a continued trace. + * A valid `sentry-trace` header always has a span id, so continuing it sets `parentSpanId`. A header + * with only a trace id is invalid and does not count. `dsc` is checked as well, because a frozen DSC + * carries the trace id, so the trace id must be kept even without a parent span id. */ export function isContinuingTrace(propagationContext: PropagationContext): boolean { return !!propagationContext.parentSpanId || !!propagationContext.dsc; @@ -94,7 +94,9 @@ export function propagationContextFromHeaders( traceId, parentSpanId, sampled: parentSampled, - dsc: dynamicSamplingContext || {}, // If we have traceparent data but no DSC it means we are not head of trace and we must freeze it + // Without incoming `sentry-` baggage values, the DSC stays unfrozen so it is created from our own segment span. + // See https://develop.sentry.dev/sdk/foundations/trace-propagation/dynamic-sampling-context/#incoming-trace-without-dsc + dsc: dynamicSamplingContext, sampleRand, }; } From b79a3cf67f89f2b986962ddb37b4b36fd4e3e87c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 30 Oct 2026 13:20:12 +0100 Subject: [PATCH 2/2] test: Cover DSC creation for incoming traces without DSC Add integration tests for Node, Bun, Deno, Cloudflare and browser that check the envelope trace header and the outgoing baggage for incoming traces without a DSC, in both trace lifecycles. Update existing tests that expected the old empty DSC. Co-Authored-By: Claude Opus 5.5 --- .../continueTrace/full-tracing/init.js | 1 + .../continueTrace/full-tracing/test.ts | 316 +++++++++++------- .../tracesSampleRate-zero/init.js | 1 + .../tracesSampleRate-zero/test.ts | 203 ++++++----- .../tracing-without-performance/test.ts | 52 ++- .../pageload-meta-no-dsc/deferred/init.js | 13 + .../deferred/template.html | 11 + .../pageload-meta-no-dsc/init.js | 13 + .../sampled-third-party-baggage/template.html | 12 + .../sampled/template.html | 11 + .../pageload-meta-no-dsc/subject.js | 9 + .../pageload-meta-no-dsc/test.ts | 186 +++++++++++ .../unsampled/template.html | 11 + .../node-suites/excludes.ts | 1 + .../index.ts | 34 ++ .../test.ts | 106 ++++++ .../index.ts | 17 + .../instrument.server.ts | 8 + .../test.ts | 90 +++++ .../vite.config.mts | 7 + .../wrangler.jsonc | 9 + .../deno-integration-tests/src/index.ts | 26 ++ .../test.ts | 225 +++++++++++++ .../express/sentry-trace/instrument.mjs | 1 + .../suites/express/sentry-trace/test.ts | 123 ++++--- .../tracing/continueTrace/instrument.mjs | 11 +- .../suites/tracing/continueTrace/scenario.mjs | 2 + .../suites/tracing/continueTrace/test.ts | 149 ++++++--- .../instrument-traces-sampler.mjs | 11 + .../incoming-trace-without-dsc/instrument.mjs | 11 + .../incoming-trace-without-dsc/server.mjs | 32 ++ .../incoming-trace-without-dsc/test.ts | 169 ++++++++++ .../tracing/sample-rand-propagation/server.js | 1 + .../tracing/sample-rand-propagation/test.ts | 35 +- .../no-tracing-enabled/server.js | 2 + .../no-tracing-enabled/test.ts | 22 ++ .../tracesSampleRate-0/server.js | 1 + .../tracesSampleRate-0/test.ts | 47 +++ .../tracesSampleRate/server.js | 1 + .../tracesSampleRate/test.ts | 47 +++ .../tracesSampler/server.js | 1 + .../tracesSampler/test.ts | 97 ++++-- .../tracing/browserTracingIntegration.test.ts | 18 +- .../tracing/dynamicSamplingContext.test.ts | 121 ++++++- .../core/test/lib/tracing/idleSpan.test.ts | 19 +- packages/core/test/lib/tracing/trace.test.ts | 24 +- .../core/test/lib/utils/traceData.test.ts | 12 +- packages/core/test/lib/utils/tracing.test.ts | 13 +- .../test/server-common/handle.test.ts | 17 +- 49 files changed, 1926 insertions(+), 423 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled-third-party-baggage/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/unsampled/template.html create mode 100644 dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/index.ts create mode 100644 dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/wrangler.jsonc create mode 100644 dev-packages/deno-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument-traces-sampler.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/init.js b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/init.js index b2280b70e307..30a73e06710e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/init.js @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/browser'; window.Sentry = Sentry; Sentry.init({ + traceLifecycle: window._testTraceLifecycle, dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], tracePropagationTargets: ['http://sentry-test-site.example'], diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/test.ts index ecca9689b964..d1dc09b2d6c3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/test.ts @@ -1,9 +1,13 @@ import { expect } from '@playwright/test'; +import { parseBaggageHeader } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { + envelopeRequestParser, eventAndTraceHeaderRequestParser, shouldSkipTracingTest, waitForErrorRequest, + waitForTransactionRequest, + waitForTransactionRequestOnUrl, } from '../../../../utils/helpers'; import { collectStreamedSpans, @@ -16,133 +20,201 @@ const SAMPLED_SPAN_ID = '1234567890123456'; const UNSAMPLED_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -sentryTest( - 'continueTrace continues a sampled incoming trace into span and outgoing request', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); - await page.route('http://sentry-test-site.example/**', route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); +for (const lifecycle of ['stream', 'static'] as const) { + sentryTest.describe(`traceLifecycle: ${lifecycle}`, () => { + sentryTest.beforeEach(async ({ page }) => { + sentryTest.skip(shouldSkipTracingTest()); + await page.addInitScript(lifecycle => { + (window as any)._testTraceLifecycle = lifecycle; + }, lifecycle); + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + }); }); - await waitForStreamedSpanAndTraceHeaderOnUrl(page, url); - - const spanPromise = waitForStreamedSpanAndTraceHeader( - page, - span => span.is_segment && span.name === 'continued-sampled', + sentryTest( + 'continueTrace continues a sampled incoming trace into span and outgoing request', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + await (lifecycle === 'stream' + ? waitForStreamedSpanAndTraceHeaderOnUrl(page, url) + : waitForTransactionRequestOnUrl(page, url)); + + const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); + + // The incoming DSC is frozen as-is. + const expectedTraceHeader = { + trace_id: SAMPLED_TRACE_ID, + sample_rate: '1', + sampled: 'true', + public_key: 'public', + sample_rand: '0.42', + }; + + if (lifecycle === 'stream') { + const spanPromise = waitForStreamedSpanAndTraceHeader( + page, + span => span.is_segment && span.name === 'continued-sampled', + ); + await page.locator('#sampled').click(); + const [span, traceHeader] = await spanPromise; + + expect(span).toMatchObject({ trace_id: SAMPLED_TRACE_ID, parent_span_id: SAMPLED_SPAN_ID }); + expect(traceHeader).toEqual(expectedTraceHeader); + } else { + const transactionPromise = waitForTransactionRequest( + page, + event => event.transaction === 'continued-sampled', + ); + await page.locator('#sampled').click(); + const [transaction, traceHeader] = eventAndTraceHeaderRequestParser(await transactionPromise); + + expect(transaction.contexts?.trace).toMatchObject({ + trace_id: SAMPLED_TRACE_ID, + parent_span_id: SAMPLED_SPAN_ID, + }); + expect(traceHeader).toEqual(expectedTraceHeader); + } + + const headers = await (await outgoingRequestPromise).allHeaders(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${SAMPLED_TRACE_ID}-[a-f0-9]{16}-1$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': SAMPLED_TRACE_ID, + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-public_key': 'public', + 'sentry-sample_rand': '0.42', + }); + }, ); - await page.locator('#sampled').click(); - - const span = await spanPromise; - const traceContext = span[0]; - - expect(traceContext.trace_id).toBe(SAMPLED_TRACE_ID); - expect(traceContext.parent_span_id).toBe(SAMPLED_SPAN_ID); - expect(span[0].name).toBe('continued-sampled'); - - // The incoming positive sampling decision is honored regardless of local config. - expect(span[1]?.sampled).toBe('true'); - - // Outgoing request carries the continued trace. - const outgoingRequest = await outgoingRequestPromise; - const headers = await outgoingRequest.allHeaders(); - expect(headers['sentry-trace']).toMatch(new RegExp(`^${SAMPLED_TRACE_ID}-[a-f0-9]{16}-1$`)); - expect(headers['baggage']).toContain(`sentry-trace_id=${SAMPLED_TRACE_ID}`); - }, -); - -sentryTest( - 'continueTrace continues an unsampled incoming trace without emitting a span', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); - await page.route('http://sentry-test-site.example/**', route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); - }); - - await waitForStreamedSpanAndTraceHeaderOnUrl(page, url); - - const errorPromise = waitForErrorRequest(page); - - const spans = collectStreamedSpans(page); - await page.locator('#unsampled').click(); - - const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise); - expect(errorEvent.contexts?.trace?.trace_id).toBe(UNSAMPLED_TRACE_ID); - - // Outgoing request carries the continued trace with the negative sampling decision. - const outgoingRequest = await outgoingRequestPromise; - const headers = await outgoingRequest.allHeaders(); - expect(headers['sentry-trace']).toMatch(new RegExp(`^${UNSAMPLED_TRACE_ID}-[a-f0-9]{16}-0$`)); - await page.evaluate(() => (window as any).Sentry.flush()); - expect(spans.some(span => span.trace_id === UNSAMPLED_TRACE_ID)).toBe(false); - }, -); - -sentryTest( - 'continueTrace continues a deferred-sampling incoming trace and applies the local sample rate', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - await page.route('http://sentry-test-site.example/**', route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); - }); - - await waitForStreamedSpanAndTraceHeaderOnUrl(page, url); - - const spanPromise = waitForStreamedSpanAndTraceHeader( - page, - span => span.is_segment && span.name === 'continued-deferred', + sentryTest( + 'continueTrace continues an unsampled incoming trace without emitting a span', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + await (lifecycle === 'stream' + ? waitForStreamedSpanAndTraceHeaderOnUrl(page, url) + : waitForTransactionRequestOnUrl(page, url)); + + const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); + const errorPromise = waitForErrorRequest(page); + const spans = collectStreamedSpans(page); + await page.locator('#unsampled').click(); + + const [errorEvent, traceHeader] = eventAndTraceHeaderRequestParser(await errorPromise); + expect(errorEvent.contexts?.trace?.trace_id).toBe(UNSAMPLED_TRACE_ID); + expect(traceHeader).toEqual({ + trace_id: UNSAMPLED_TRACE_ID, + public_key: 'public', + environment: 'production', + transaction: 'continued-unsampled', + sampled: 'false', + sample_rate: '0', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }); + + const headers = await (await outgoingRequestPromise).allHeaders(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${UNSAMPLED_TRACE_ID}-[a-f0-9]{16}-0$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': UNSAMPLED_TRACE_ID, + 'sentry-public_key': 'public', + 'sentry-environment': 'production', + 'sentry-transaction': 'continued-unsampled', + 'sentry-sampled': 'false', + 'sentry-sample_rate': '0', + 'sentry-sample_rand': traceHeader?.sample_rand, + }); + + if (lifecycle === 'stream') { + await page.evaluate(() => (window as any).Sentry.flush()); + expect(spans.some(span => span.trace_id === UNSAMPLED_TRACE_ID)).toBe(false); + } + }, ); - await page.locator('#deferred').click(); - - const span = await spanPromise; - expect(span[0].trace_id).toBe(DEFERRED_TRACE_ID); - expect(span[0].name).toBe('continued-deferred'); - }, -); - -sentryTest('continueTrace with no incoming trace starts a fresh trace', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); + sentryTest( + 'continueTrace continues a deferred-sampling incoming trace and applies the local sample rate', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + await (lifecycle === 'stream' + ? waitForStreamedSpanAndTraceHeaderOnUrl(page, url) + : waitForTransactionRequestOnUrl(page, url)); + + const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); + + const expectedTraceHeader = { + trace_id: DEFERRED_TRACE_ID, + public_key: 'public', + environment: 'production', + transaction: 'continued-deferred', + sampled: 'true', + sample_rate: '1', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }; + + if (lifecycle === 'stream') { + const spanPromise = waitForStreamedSpanAndTraceHeader( + page, + span => span.is_segment && span.name === 'continued-deferred', + ); + await page.locator('#deferred').click(); + const [span, traceHeader] = await spanPromise; + + expect(span.trace_id).toBe(DEFERRED_TRACE_ID); + expect(traceHeader).toEqual(expectedTraceHeader); + } else { + const transactionPromise = waitForTransactionRequest( + page, + event => event.transaction === 'continued-deferred', + ); + await page.locator('#deferred').click(); + const [transaction, traceHeader] = eventAndTraceHeaderRequestParser(await transactionPromise); + + expect(transaction.contexts?.trace?.trace_id).toBe(DEFERRED_TRACE_ID); + expect(traceHeader).toEqual(expectedTraceHeader); + } + + const headers = await (await outgoingRequestPromise).allHeaders(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${DEFERRED_TRACE_ID}-[a-f0-9]{16}-1$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': DEFERRED_TRACE_ID, + 'sentry-public_key': 'public', + 'sentry-environment': 'production', + 'sentry-transaction': 'continued-deferred', + 'sentry-sampled': 'true', + 'sentry-sample_rate': '1', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + }); + }, + ); - await page.route('http://sentry-test-site.example/**', route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + sentryTest('continueTrace with no incoming trace starts a fresh trace', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + if (lifecycle === 'stream') { + const [pageloadSpan] = await waitForStreamedSpanAndTraceHeaderOnUrl(page, url); + + const spanPromise = waitForStreamedSpanAndTraceHeader( + page, + span => span.is_segment && span.name === 'continued-noTrace', + ); + await page.locator('#noTrace').click(); + const [span] = await spanPromise; + + expect(span.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(span.trace_id).not.toBe(pageloadSpan.trace_id); + expect(span.parent_span_id).toBeUndefined(); + } else { + const pageload = envelopeRequestParser(await waitForTransactionRequestOnUrl(page, url)); + + const transactionPromise = waitForTransactionRequest(page, event => event.transaction === 'continued-noTrace'); + await page.locator('#noTrace').click(); + const transaction = envelopeRequestParser(await transactionPromise); + + expect(transaction.contexts?.trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(transaction.contexts?.trace?.trace_id).not.toBe(pageload.contexts?.trace?.trace_id); + expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); + } + }); }); - - const [pageloadEvent] = await waitForStreamedSpanAndTraceHeaderOnUrl(page, url); - const pageloadTraceId = pageloadEvent.trace_id; - - const spanPromise = waitForStreamedSpanAndTraceHeader( - page, - span => span.is_segment && span.name === 'continued-noTrace', - ); - - await page.locator('#noTrace').click(); - - const span = await spanPromise; - const traceId = span[0].trace_id; - - expect(traceId).toMatch(/^[a-f0-9]{32}$/); - expect(traceId).not.toBe(pageloadTraceId); - expect(span[0]).not.toHaveProperty('parent_span_id'); -}); +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/init.js b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/init.js index 5e9c86105ae7..b63d24ef6a58 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/init.js @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/browser'; window.Sentry = Sentry; Sentry.init({ + traceLifecycle: window._testTraceLifecycle, dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], tracePropagationTargets: ['http://sentry-test-site.example'], diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/test.ts b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/test.ts index e7321abb38ee..e8d32d826849 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/test.ts @@ -1,110 +1,129 @@ import { expect } from '@playwright/test'; +import { parseBaggageHeader } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { + countEnvelopes, eventAndTraceHeaderRequestParser, shouldSkipTracingTest, waitForErrorRequest, + waitForTransactionRequest, } from '../../../../utils/helpers'; -import { collectStreamedSpans, waitForStreamedSpanAndTraceHeader } from '../../../../utils/spanUtils'; +import { waitForStreamedSpanAndTraceHeader } from '../../../../utils/spanUtils'; const SAMPLED_TRACE_ID = '12345678901234567890123456789012'; const SAMPLED_SPAN_ID = '1234567890123456'; const UNSAMPLED_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -sentryTest( - 'continueTrace honors a positive incoming sampling decision over tracesSampleRate=0', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); - await page.route('http://sentry-test-site.example/**', route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); +for (const lifecycle of ['stream', 'static'] as const) { + sentryTest.describe(`traceLifecycle: ${lifecycle}`, () => { + sentryTest.beforeEach(async ({ page }) => { + sentryTest.skip(shouldSkipTracingTest()); + await page.addInitScript(lifecycle => { + (window as any)._testTraceLifecycle = lifecycle; + }, lifecycle); + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + }); }); - const spanPromise = waitForStreamedSpanAndTraceHeader( - page, - span => span.is_segment && span.name === 'continued-sampled', + sentryTest( + 'continueTrace honors a positive incoming sampling decision over tracesSampleRate=0', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); + + // The incoming DSC is frozen as-is. + const expectedTraceHeader = { + trace_id: SAMPLED_TRACE_ID, + sample_rate: '1', + sampled: 'true', + public_key: 'public', + sample_rand: '0.42', + }; + + if (lifecycle === 'stream') { + const spanPromise = waitForStreamedSpanAndTraceHeader( + page, + span => span.is_segment && span.name === 'continued-sampled', + ); + await page.goto(url); + await page.locator('#sampled').click(); + const [span, traceHeader] = await spanPromise; + + expect(span).toMatchObject({ trace_id: SAMPLED_TRACE_ID, parent_span_id: SAMPLED_SPAN_ID }); + expect(traceHeader).toEqual(expectedTraceHeader); + } else { + const transactionPromise = waitForTransactionRequest( + page, + event => event.transaction === 'continued-sampled', + ); + await page.goto(url); + await page.locator('#sampled').click(); + const [transaction, traceHeader] = eventAndTraceHeaderRequestParser(await transactionPromise); + + expect(transaction.contexts?.trace).toMatchObject({ + trace_id: SAMPLED_TRACE_ID, + parent_span_id: SAMPLED_SPAN_ID, + }); + expect(traceHeader).toEqual(expectedTraceHeader); + } + + const headers = await (await outgoingRequestPromise).allHeaders(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${SAMPLED_TRACE_ID}-[a-f0-9]{16}-1$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': SAMPLED_TRACE_ID, + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-public_key': 'public', + 'sentry-sample_rand': '0.42', + }); + }, ); - await page.goto(url); - await page.locator('#sampled').click(); - - const span = await spanPromise; - expect(span[0].trace_id).toBe(SAMPLED_TRACE_ID); - expect(span[0].parent_span_id).toBe(SAMPLED_SPAN_ID); - - const outgoingRequest = await outgoingRequestPromise; - const headers = await outgoingRequest.allHeaders(); - expect(headers['sentry-trace']).toMatch(new RegExp(`^${SAMPLED_TRACE_ID}-[a-f0-9]{16}-1$`)); - }, -); - -sentryTest( - 'continueTrace does not emit a span for a deferred decision with tracesSampleRate=0', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); - await page.route('http://sentry-test-site.example/**', route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); - }); - - await page.goto(url); - - const errorPromise = waitForErrorRequest(page); - - const spans = collectStreamedSpans(page); - await page.locator('#deferred').click(); - - const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise); - expect(errorEvent.contexts?.trace?.trace_id).toBe(DEFERRED_TRACE_ID); - - // The deferred decision resolves negatively against the local tracesSampleRate=0. - const outgoingRequest = await outgoingRequestPromise; - const headers = await outgoingRequest.allHeaders(); - expect(headers['sentry-trace']).toMatch(new RegExp(`^${DEFERRED_TRACE_ID}-[a-f0-9]{16}-0$`)); - await page.evaluate(() => (window as any).Sentry.flush()); - expect(spans.some(span => span.trace_id === DEFERRED_TRACE_ID)).toBe(false); - }, -); - -sentryTest( - 'continueTrace continues an unsampled incoming trace with tracesSampleRate=0', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); + for (const { button, traceId } of [ + { button: 'deferred', traceId: DEFERRED_TRACE_ID }, + { button: 'unsampled', traceId: UNSAMPLED_TRACE_ID }, + ]) { + sentryTest( + `continueTrace does not emit a span for the ${button} incoming trace with tracesSampleRate=0`, + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); + await page.goto(url); + + const errorPromise = waitForErrorRequest(page); + const segmentEnvelopeCount = countEnvelopes(page, { envelopeType: ['span', 'transaction'], timeout: 2000 }); + await page.locator(`#${button}`).click(); + + const [errorEvent, traceHeader] = eventAndTraceHeaderRequestParser(await errorPromise); + expect(errorEvent.contexts?.trace?.trace_id).toBe(traceId); + expect(traceHeader).toEqual({ + trace_id: traceId, + public_key: 'public', + environment: 'production', + transaction: `continued-${button}`, + sampled: 'false', + sample_rate: '0', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }); + + const headers = await (await outgoingRequestPromise).allHeaders(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f0-9]{16}-0$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': traceId, + 'sentry-public_key': 'public', + 'sentry-environment': 'production', + 'sentry-transaction': `continued-${button}`, + 'sentry-sampled': 'false', + 'sentry-sample_rate': '0', + 'sentry-sample_rand': traceHeader?.sample_rand, + }); + + await page.evaluate(() => (window as any).Sentry.flush()); + expect(await segmentEnvelopeCount).toBe(0); + }, + ); } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - const outgoingRequestPromise = page.waitForRequest('http://sentry-test-site.example/**'); - await page.route('http://sentry-test-site.example/**', route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); - }); - - await page.goto(url); - - const errorPromise = waitForErrorRequest(page); - - const spans = collectStreamedSpans(page); - await page.locator('#unsampled').click(); - - const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise); - expect(errorEvent.contexts?.trace?.trace_id).toBe(UNSAMPLED_TRACE_ID); - - const outgoingRequest = await outgoingRequestPromise; - const headers = await outgoingRequest.allHeaders(); - expect(headers['sentry-trace']).toMatch(new RegExp(`^${UNSAMPLED_TRACE_ID}-[a-f0-9]{16}-0$`)); - await page.evaluate(() => (window as any).Sentry.flush()); - expect(spans.some(span => span.trace_id === UNSAMPLED_TRACE_ID)).toBe(false); - }, -); + }); +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/test.ts index bbf746fdc4df..fea7e0dc21b5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { parseBaggageHeader } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { eventAndTraceHeaderRequestParser, @@ -12,11 +13,50 @@ const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; // In TwP mode no spans are emitted, so each variant is observed via the captured error event // (which carries the continued trace on its `contexts.trace`) and the outgoing request headers. +// Without incoming baggage there is no segment span to create the DSC from, so it only holds the values from options. const VARIANTS = [ - { button: 'sampled', traceId: SAMPLED_TRACE_ID, sampledFlag: '-1' }, - { button: 'unsampled', traceId: UNSAMPLED_TRACE_ID, sampledFlag: '-0' }, - { button: 'deferred', traceId: DEFERRED_TRACE_ID, sampledFlag: '' }, -] as const; + { + button: 'sampled', + traceId: SAMPLED_TRACE_ID, + sampledFlag: '-1', + traceHeader: { + trace_id: SAMPLED_TRACE_ID, + sample_rate: '1', + sampled: 'true', + public_key: 'public', + sample_rand: '0.42', + }, + baggage: { + 'sentry-trace_id': SAMPLED_TRACE_ID, + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-public_key': 'public', + 'sentry-sample_rand': '0.42', + }, + }, + { + button: 'unsampled', + traceId: UNSAMPLED_TRACE_ID, + sampledFlag: '-0', + traceHeader: { trace_id: UNSAMPLED_TRACE_ID, public_key: 'public', environment: 'production' }, + baggage: { + 'sentry-trace_id': UNSAMPLED_TRACE_ID, + 'sentry-public_key': 'public', + 'sentry-environment': 'production', + }, + }, + { + button: 'deferred', + traceId: DEFERRED_TRACE_ID, + sampledFlag: '', + traceHeader: { trace_id: DEFERRED_TRACE_ID, public_key: 'public', environment: 'production' }, + baggage: { + 'sentry-trace_id': DEFERRED_TRACE_ID, + 'sentry-public_key': 'public', + 'sentry-environment': 'production', + }, + }, +]; for (const variant of VARIANTS) { sentryTest( @@ -39,12 +79,14 @@ for (const variant of VARIANTS) { await page.locator(`#${variant.button}`).click(); - const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise); + const [errorEvent, traceHeader] = eventAndTraceHeaderRequestParser(await errorPromise); expect(errorEvent.contexts?.trace?.trace_id).toBe(variant.traceId); + expect(traceHeader).toEqual(variant.traceHeader); const outgoingRequest = await outgoingRequestPromise; const headers = await outgoingRequest.allHeaders(); expect(headers['sentry-trace']).toMatch(new RegExp(`^${variant.traceId}-[a-f0-9]{16}${variant.sampledFlag}$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual(variant.baggage); }, ); } diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/init.js new file mode 100644 index 000000000000..0fe3bdf291a9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: window._testTraceLifecycle, + dsn: 'https://public@o42.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 0.5, + release: '1.0.0', + environment: 'staging', +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/template.html new file mode 100644 index 000000000000..27ceb8723cb9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/deferred/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/init.js new file mode 100644 index 000000000000..60eb08a633e8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: window._testTraceLifecycle, + dsn: 'https://public@o42.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled-third-party-baggage/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled-third-party-baggage/template.html new file mode 100644 index 000000000000..4eddda078d9d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled-third-party-baggage/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled/template.html new file mode 100644 index 000000000000..92bdeb21e50a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/sampled/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/subject.js new file mode 100644 index 000000000000..aaf59968b0ff --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/subject.js @@ -0,0 +1,9 @@ +const errorBtn = document.getElementById('errorBtn'); +errorBtn.addEventListener('click', () => { + throw new Error(`Sentry Test Error ${Math.random()}`); +}); + +const fetchBtn = document.getElementById('fetchBtn'); +fetchBtn.addEventListener('click', async () => { + await fetch('http://sentry-test-site.example'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/test.ts new file mode 100644 index 000000000000..73722a1b8df7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/test.ts @@ -0,0 +1,186 @@ +import { expect } from '@playwright/test'; +import { parseBaggageHeader } from '@sentry/core'; +import path from 'path'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + countEnvelopes, + eventAndTraceHeaderRequestParser, + shouldSkipTracingTest, + waitForErrorRequest, + waitForTransactionRequest, +} from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpanAndTraceHeader } from '../../../../utils/spanUtils'; + +const META_TAG_TRACE_ID = '12345678901234567890123456789012'; +const META_TAG_PARENT_SPAN_ID = '1234567890123456'; + +for (const lifecycle of ['stream', 'static'] as const) { + sentryTest.describe(`pageload continued from a tag sentry-trace without a DSC (${lifecycle})`, () => { + sentryTest.beforeEach(async ({ page }) => { + sentryTest.skip(shouldSkipTracingTest()); + await page.addInitScript(lifecycle => { + (window as any)._testTraceLifecycle = lifecycle; + }, lifecycle); + await page.route(/^https:\/\/o42\.ingest\.sentry\.io\//, route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }), + ); + await page.route('http://sentry-test-site.example/**', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }), + ); + }); + + for (const variant of ['sampled', 'sampled-third-party-baggage']) { + sentryTest( + `creates the DSC from the pageload span for a positive inherited decision (${variant})`, + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: path.join(__dirname, variant) }); + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + + const expectedTraceHeader = { + trace_id: META_TAG_TRACE_ID, + public_key: 'public', + environment: 'staging', + release: '1.0.0', + org_id: '42', + sampled: 'true', + sample_rate: '1', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }; + + if (lifecycle === 'stream') { + const pageloadPromise = waitForStreamedSpanAndTraceHeader( + page, + span => span.is_segment && getSpanOp(span) === 'pageload', + ); + await page.goto(url); + await page.locator('#fetchBtn').click(); + const [pageloadSpan, traceHeader] = await pageloadPromise; + + expect(pageloadSpan).toMatchObject({ + trace_id: META_TAG_TRACE_ID, + parent_span_id: META_TAG_PARENT_SPAN_ID, + }); + expect(traceHeader).toEqual(expectedTraceHeader); + } else { + const pageloadPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + await page.goto(url); + await page.locator('#fetchBtn').click(); + const [pageloadTransaction, traceHeader] = eventAndTraceHeaderRequestParser(await pageloadPromise); + + expect(pageloadTransaction.contexts?.trace).toMatchObject({ + trace_id: META_TAG_TRACE_ID, + parent_span_id: META_TAG_PARENT_SPAN_ID, + }); + expect(traceHeader).toEqual(expectedTraceHeader); + } + + const headers = (await requestPromise).headers(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${META_TAG_TRACE_ID}-[a-f\\d]{16}-1$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': META_TAG_TRACE_ID, + 'sentry-public_key': 'public', + 'sentry-environment': 'staging', + 'sentry-release': '1.0.0', + 'sentry-org_id': '42', + 'sentry-sampled': 'true', + 'sentry-sample_rate': '1', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + }); + + const errorPromise = waitForErrorRequest(page); + await page.locator('#errorBtn').click(); + const [errorEvent, errorTraceHeader] = eventAndTraceHeaderRequestParser(await errorPromise); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(META_TAG_TRACE_ID); + expect(errorTraceHeader).toEqual(expectedTraceHeader); + }, + ); + } + + sentryTest( + 'creates the DSC from the unsampled pageload span for a negative inherited decision', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: path.join(__dirname, 'unsampled') }); + const segmentEnvelopeCount = countEnvelopes(page, { envelopeType: ['span', 'transaction'], timeout: 3000 }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + const errorPromise = waitForErrorRequest(page); + await page.goto(url); + await page.locator('#fetchBtn').click(); + await page.locator('#errorBtn').click(); + + const [errorEvent, errorTraceHeader] = eventAndTraceHeaderRequestParser(await errorPromise); + expect(errorEvent.contexts?.trace?.trace_id).toBe(META_TAG_TRACE_ID); + expect(errorTraceHeader).toEqual({ + trace_id: META_TAG_TRACE_ID, + public_key: 'public', + environment: 'staging', + release: '1.0.0', + org_id: '42', + sampled: 'false', + sample_rate: '0', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }); + + const headers = (await requestPromise).headers(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${META_TAG_TRACE_ID}-[a-f\\d]{16}-0$`)); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': META_TAG_TRACE_ID, + 'sentry-public_key': 'public', + 'sentry-environment': 'staging', + 'sentry-release': '1.0.0', + 'sentry-org_id': '42', + 'sentry-sampled': 'false', + 'sentry-sample_rate': '0', + 'sentry-sample_rand': errorTraceHeader?.sample_rand, + }); + + await page.evaluate(() => (window as any).Sentry.flush()); + expect(await segmentEnvelopeCount).toBe(0); + }, + ); + + sentryTest( + 'creates the DSC with the local tracesSampleRate for a deferred decision', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: path.join(__dirname, 'deferred') }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/*'); + const errorPromise = waitForErrorRequest(page); + await page.goto(url); + await page.locator('#fetchBtn').click(); + await page.locator('#errorBtn').click(); + + const [errorEvent, errorTraceHeader] = eventAndTraceHeaderRequestParser(await errorPromise); + const sampled = Number(errorTraceHeader?.sample_rand) < 0.5; + + expect(errorEvent.contexts?.trace?.trace_id).toBe(META_TAG_TRACE_ID); + expect(errorTraceHeader).toEqual({ + trace_id: META_TAG_TRACE_ID, + public_key: 'public', + environment: 'staging', + release: '1.0.0', + org_id: '42', + sampled: String(sampled), + sample_rate: '0.5', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }); + + const headers = (await requestPromise).headers(); + expect(headers['sentry-trace']).toMatch( + new RegExp(`^${META_TAG_TRACE_ID}-[a-f\\d]{16}-${sampled ? '1' : '0'}$`), + ); + expect(parseBaggageHeader(headers['baggage'])).toEqual({ + 'sentry-trace_id': META_TAG_TRACE_ID, + 'sentry-public_key': 'public', + 'sentry-environment': 'staging', + 'sentry-release': '1.0.0', + 'sentry-org_id': '42', + 'sentry-sampled': String(sampled), + 'sentry-sample_rate': '0.5', + 'sentry-sample_rand': errorTraceHeader?.sample_rand, + }); + }, + ); + }); +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/unsampled/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/unsampled/template.html new file mode 100644 index 000000000000..4ef8996fafc9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta-no-dsc/unsampled/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 30c08744ef1d..0f51187d8a58 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -20,6 +20,7 @@ const NODE_ONLY = [ // so the `@sentry/bun` project runs these suites, except the ones in `SENTRY_BUN_EXCLUDE`. const NO_HTTP_SERVER_SPANS = [ 'suites/sessions/**', + 'suites/tracing/envelope-header/incoming-trace-without-dsc/test.ts', 'suites/tracing/envelope-header/sampleRate-propagation/test.ts', 'suites/tracing/httpIntegration-streamed/test.ts', 'suites/tracing/httpIntegration/test.ts', diff --git a/dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/index.ts b/dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/index.ts new file mode 100644 index 000000000000..d1d5fe21fae0 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/index.ts @@ -0,0 +1,34 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/bun'; + +const targetServer = Bun.serve({ + port: 0, + fetch(request) { + return Response.json({ headers: Object.fromEntries(request.headers.entries()) }); + }, +}); + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: 'production', + release: '1.0', + tracesSampleRate: Number(process.env.TRACES_SAMPLE_RATE ?? '1'), + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', +}); + +async function fetchAndEcho(): Promise { + const response = await fetch(`http://localhost:${targetServer.port}/target`); + const data = await response.json(); + Sentry.captureException(new Error('outgoing fetch done')); + return Response.json(data); +} + +const server = Bun.serve({ + port: 0, + routes: { + '/fetch/:id': fetchAndEcho, + }, + fetch: fetchAndEcho, +}); + +sendPortToRunner(server.port!); diff --git a/dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts b/dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts new file mode 100644 index 000000000000..fe36954503e6 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts @@ -0,0 +1,106 @@ +import { parseBaggageHeader, SDK_VERSION } from '@sentry/core'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +const TRACE_ID = '12312012123120121231201212312012'; +const PARENT_SPAN_ID = '1121201211212012'; + +type Dsc = { sample_rate: string; sampled: string; transaction?: string }; + +type Case = { + sentryTrace: string; + baggage?: string; + path: string; + tracesSampleRate: string; + sampled: boolean; + dsc: Dsc; +}; + +type Lifecycle = 'stream' | 'static'; + +async function runCase( + lifecycle: Lifecycle, + { sentryTrace, baggage, path, tracesSampleRate, sampled, dsc }: Case, +): Promise { + const trace = { + trace_id: TRACE_ID, + public_key: 'public', + environment: 'production', + release: '1.0', + sample_rand: expect.stringMatching(/^0\.\d+$/), + ...dsc, + }; + + const header = { sdk: { name: 'sentry.javascript.bun', version: SDK_VERSION }, trace }; + + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .withEnv({ TRACES_SAMPLE_RATE: tracesSampleRate, TRACE_LIFECYCLE: lifecycle }) + .ignore(...(sampled ? (['event'] as const) : (['span', 'transaction'] as const))) + .expectHeader(!sampled ? { event: header } : lifecycle === 'stream' ? { span: header } : { transaction: header }) + .start(); + + const response = await runner.makeRequest<{ headers: Record }>('get', path, { + headers: { 'sentry-trace': sentryTrace, ...(baggage ? { baggage } : {}) }, + }); + + const sampledFlag = dsc.sampled === 'true' ? '1' : '0'; + expect(response?.headers['sentry-trace']).toMatch(new RegExp(`^${TRACE_ID}-[\\da-f]{16}-${sampledFlag}$`)); + + const outgoingBaggage = parseBaggageHeader(response?.headers.baggage) ?? {}; + expect(outgoingBaggage).toEqual( + Object.fromEntries(Object.entries(trace).map(([key, value]) => [`sentry-${key}`, value])), + ); + + const sampleRand = Number(outgoingBaggage['sentry-sample_rand']); + expect(sampleRand < Number(dsc.sample_rate)).toBe(dsc.sampled === 'true'); + + await runner.completed(); +} + +describe.each(['stream', 'static'])('traceLifecycle: %s', lifecycle => { + test('creates the DSC for an incoming sampled trace without baggage', async () => { + await runCase(lifecycle, { + sentryTrace: `${TRACE_ID}-${PARENT_SPAN_ID}-1`, + path: '/fetch/123', + tracesSampleRate: '0', + sampled: true, + dsc: { sample_rate: '1', sampled: 'true', transaction: 'GET /fetch/:id' }, + }); + }); + + test('creates the DSC for an incoming sampled trace with only third-party baggage', async () => { + await runCase(lifecycle, { + sentryTrace: `${TRACE_ID}-${PARENT_SPAN_ID}-1`, + baggage: 'foo=bar', + path: '/fetch/123', + tracesSampleRate: '0', + sampled: true, + dsc: { sample_rate: '1', sampled: 'true', transaction: 'GET /fetch/:id' }, + }); + }); + + test('creates the DSC for an incoming unsampled trace without baggage', async () => { + await runCase(lifecycle, { + sentryTrace: `${TRACE_ID}-${PARENT_SPAN_ID}-0`, + path: '/fetch/123', + tracesSampleRate: '1', + sampled: false, + dsc: { sample_rate: '0', sampled: 'false', transaction: 'GET /fetch/:id' }, + }); + }); + + test('creates the DSC for an incoming deferred trace without baggage, omitting a url-sourced transaction', async () => { + await runCase(lifecycle, { + sentryTrace: `${TRACE_ID}-${PARENT_SPAN_ID}`, + path: '/plain', + tracesSampleRate: '1', + sampled: true, + dsc: { sample_rate: '1', sampled: 'true' }, + }); + }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/index.ts new file mode 100644 index 000000000000..c41ad2b2ecd0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/index.ts @@ -0,0 +1,17 @@ +interface Env { + SERVER_URL: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(request: Request, env: Env): Promise { + await fetch(`${env.SERVER_URL}/outgoing`); + + if (new URL(request.url).pathname === '/error') { + throw new Error('Test error'); + } + + return Response.json({ status: 'ok' }); + }, +}; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/instrument.server.ts new file mode 100644 index 000000000000..33738d8e8891 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/instrument.server.ts @@ -0,0 +1,8 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string; SERVER_URL: string }) => ({ + dsn: env.SENTRY_DSN, + release: '1.0.0', + tracesSampleRate: 0.5, + tracePropagationTargets: [env.SERVER_URL], +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/test.ts new file mode 100644 index 000000000000..43920fae8228 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/test.ts @@ -0,0 +1,90 @@ +import type { DynamicSamplingContext } from '@sentry/core'; +import { parseBaggageHeader } from '@sentry/core'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect, it, onTestFinished } from 'vitest'; +import { createRunner } from '../../../runner'; + +const TRACE_ID = '12345678901234567890123456789012'; +const PARENT_SPAN_ID = '1234567890123456'; + +const CLIENT_DSC = { + trace_id: TRACE_ID, + public_key: 'public', + environment: 'production', + release: '1.0.0', +}; + +const CASES: { name: string; path: string; headers: Record; expectedDsc: Record }[] = [ + { + name: 'an inherited positive decision', + path: '/', + headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-1` }, + expectedDsc: { sampled: 'true', sample_rate: '1', transaction: 'GET /' }, + }, + { + name: 'an inherited positive decision with only third-party baggage', + path: '/', + headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-1`, baggage: 'foo=bar' }, + expectedDsc: { sampled: 'true', sample_rate: '1', transaction: 'GET /' }, + }, + { + name: 'an inherited negative decision', + path: '/error', + headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-0` }, + expectedDsc: { sampled: 'false', sample_rate: '0' }, + }, + { + name: 'a deferred decision', + path: '/error', + headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}` }, + expectedDsc: { sampled: expect.stringMatching(/^(true|false)$/), sample_rate: '0.5' }, + }, +]; + +function toSentryBaggage(dsc: Record): Record { + return Object.fromEntries(Object.entries(dsc).map(([key, value]) => [`sentry-${key}`, value])); +} + +describe.for(['stream', 'static'] as const)('traceLifecycle: %s', traceLifecycle => { + it.for(CASES)( + 'creates the DSC from the segment span for $name', + async ({ path, headers, expectedDsc }, { signal }) => { + let outgoingHeaders: Record = {}; + const [serverUrl, closeTestServer] = await createTestServer() + .get('/outgoing', requestHeaders => { + outgoingHeaders = requestHeaders; + }) + .start(); + onTestFinished(closeTestServer); + + const isError = path === '/error'; + const expectedItemType = isError ? 'event' : traceLifecycle === 'static' ? 'transaction' : 'span'; + let trace: DynamicSamplingContext | undefined; + + const runner = createRunner(__dirname) + .withServerUrl(serverUrl) + .withWranglerArgs(...(traceLifecycle === 'static' ? ['--var', 'SENTRY_TRACE_LIFECYCLE:static'] : [])) + .start(signal); + await runner.makeRequestAndWaitForEnvelope( + 'get', + path, + envelope => { + expect(envelope[1][0][0].type).toBe(expectedItemType); + trace = envelope[0].trace as DynamicSamplingContext; + }, + { headers, expectError: isError }, + ); + + expect(trace).toEqual({ + ...CLIENT_DSC, + sample_rand: expect.stringMatching(/^0\.\d+$/), + ...expectedDsc, + }); + expect(Number(trace?.sample_rand) < Number(trace?.sample_rate)).toBe(trace?.sampled === 'true'); + expect(outgoingHeaders['sentry-trace']).toMatch( + new RegExp(`^${TRACE_ID}-[\\da-f]{16}-${trace?.sampled === 'true' ? 1 : 0}$`), + ); + expect(parseBaggageHeader(outgoingHeaders.baggage)).toEqual(toSentryBaggage(trace ?? {})); + }, + ); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/wrangler.jsonc new file mode 100644 index 000000000000..b36fd692a226 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-incoming-trace-without-baggage/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "dsc-incoming-trace-without-baggage", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/deno-integration-tests/src/index.ts b/dev-packages/deno-integration-tests/src/index.ts index 3c18c1154838..ad52bdebcb7f 100644 --- a/dev-packages/deno-integration-tests/src/index.ts +++ b/dev-packages/deno-integration-tests/src/index.ts @@ -129,3 +129,29 @@ export function spanSink(): { }), }; } + +/** + * A `transport` that records every envelope, headers included, and lets a test `await` the first one + * matching a predicate. Use it to assert envelope headers, which `spanSink` discards. + */ +export function envelopeSink(): { + envelopes: Envelope[]; + waitFor: (predicate: (envelope: Envelope) => boolean) => Promise; + transport: () => Transport; +} { + const envelopes: Envelope[] = []; + const sink = eventSink(); + + return { + envelopes, + waitFor: sink.waitFor, + transport: () => ({ + send: (envelope: Envelope) => { + envelopes.push(envelope); + sink.beforeSend(envelope); + return Promise.resolve({}); + }, + flush: () => Promise.resolve(true), + }), + }; +} diff --git a/dev-packages/deno-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts b/dev-packages/deno-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts new file mode 100644 index 000000000000..01ec845df9ea --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/dsc-incoming-trace-without-baggage/test.ts @@ -0,0 +1,225 @@ +// + +import type { + DynamicSamplingContext, + Envelope, + Event, + SerializedStreamedSpanContainer, + TransactionEvent, +} from '@sentry/core'; +import type { DenoOptions } from '@sentry/deno'; +import * as Sentry from '@sentry/deno'; +import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; +import { assertMatch } from 'https://deno.land/std@0.212.0/assert/assert_match.ts'; +import { envelopeSink, getSpanOp, resetGlobals, withTimeout } from '../../src/index.ts'; + +const TRACE_ID = '12312012123120121231201212312012'; +const PARENT_SPAN_ID = '1121201211212012'; +const ERROR_MESSAGE = 'dsc-incoming-trace-without-baggage'; + +type TraceLifecycle = 'stream' | 'static'; + +interface ScenarioResult { + outgoingHeaders: Headers; + errorEnvelope: Envelope; + segmentEnvelope: Envelope | undefined; +} + +function isHandlerSpanEnvelope(envelope: Envelope): boolean { + return envelope[1].some( + ([header, payload]) => + header.type === 'span' && + (payload as SerializedStreamedSpanContainer).items.some( + span => + span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/handler', + ), + ); +} + +function isHandlerTransactionEnvelope(envelope: Envelope): boolean { + return envelope[1].some( + ([header, payload]) => + header.type === 'transaction' && + (payload as TransactionEvent).contexts?.trace?.op === 'http.server' && + (payload as TransactionEvent).transaction === 'GET /handler', + ); +} + +function isHandlerErrorEnvelope(envelope: Envelope): boolean { + return envelope[1].some( + ([header, payload]) => + header.type === 'event' && (payload as Event).exception?.values?.[0]?.value === ERROR_MESSAGE, + ); +} + +async function runScenario( + traceLifecycle: TraceLifecycle, + options: Pick, + requestHeaders: Record, +): Promise { + resetGlobals(); + const sink = envelopeSink(); + + Sentry.init({ + dsn: 'https://public@o123.ingest.sentry.io/456', + environment: 'dsc-test', + release: '1.0.0', + tracePropagationTargets: [/\/echo$/], + // The echo server continues the same trace in-process, so its segment would share the span envelope. + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + Sentry.denoServeIntegration({ ignoreIncomingRequests: urlPath => urlPath === '/echo' }), + ], + transport: sink.transport, + ...(traceLifecycle === 'static' && { traceLifecycle }), + ...options, + }); + + const abortController = new AbortController(); + + let onEchoListen: ((_: unknown) => void) | undefined; + const echoListening = new Promise(resolve => (onEchoListen = resolve)); + let resolveOutgoingHeaders: ((headers: Headers) => void) | undefined; + const outgoingHeaders = new Promise(resolve => (resolveOutgoingHeaders = resolve)); + const echoServer = Deno.serve({ port: 0, signal: abortController.signal, onListen: onEchoListen }, request => { + resolveOutgoingHeaders?.(new Headers(request.headers)); + return new Response('echo'); + }); + await echoListening; + + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async () => { + const echoResponse = await fetch(`http://localhost:${echoServer.addr.port}/echo`); + await echoResponse.text(); + Sentry.captureException(new Error(ERROR_MESSAGE)); + return new Response('OK'); + }); + await listening; + + try { + const errorEnvelopePromise = withTimeout(sink.waitFor(isHandlerErrorEnvelope), 5_000, 'error envelope'); + + const response = await fetch(`http://localhost:${server.addr.port}/handler`, { headers: requestHeaders }); + assertEquals(await response.text(), 'OK'); + + await Sentry.flush(); + + return { + outgoingHeaders: await withTimeout(outgoingHeaders, 5_000, 'outgoing request to echo server'), + errorEnvelope: await errorEnvelopePromise, + segmentEnvelope: sink.envelopes.find( + traceLifecycle === 'static' ? isHandlerTransactionEnvelope : isHandlerSpanEnvelope, + ), + }; + } finally { + abortController.abort(); + await Promise.all([server.finished, echoServer.finished]); + } +} + +function baggageToDsc(baggage: string | null): Record { + assertExists(baggage); + return Object.fromEntries( + baggage.split(',').map(entry => { + const [key, value] = entry.trim().split('='); + assert(key?.startsWith('sentry-'), `unexpected non-sentry baggage entry: ${entry}`); + return [key.slice('sentry-'.length), decodeURIComponent(value ?? '')]; + }), + ); +} + +function assertDscForIncomingTraceWithoutBaggage( + { outgoingHeaders, errorEnvelope, segmentEnvelope }: ScenarioResult, + expectedSampleRate: string, +): void { + const outgoingDsc = baggageToDsc(outgoingHeaders.get('baggage')); + const sampled = outgoingDsc.sampled; + assert(sampled === 'true' || sampled === 'false', `unexpected sampled value: ${sampled}`); + + const sampleRand = Number(outgoingDsc.sample_rand); + assert(sampleRand >= 0 && sampleRand < 1, `sample_rand out of range: ${outgoingDsc.sample_rand}`); + assertEquals(sampleRand < Number(expectedSampleRate), sampled === 'true'); + + const expectedDsc: Partial = { + environment: 'dsc-test', + release: '1.0.0', + public_key: 'public', + trace_id: TRACE_ID, + org_id: '123', + sampled, + sample_rand: outgoingDsc.sample_rand, + sample_rate: expectedSampleRate, + }; + + assertMatch( + outgoingHeaders.get('sentry-trace') ?? '', + new RegExp(`^${TRACE_ID}-[0-9a-f]{16}-${sampled === 'true' ? 1 : 0}$`), + ); + assertEquals(outgoingDsc, expectedDsc); + assertEquals(errorEnvelope[0].trace, expectedDsc); + + if (sampled === 'true') { + assertExists(segmentEnvelope, 'envelope for the sampled /handler segment'); + assertEquals(segmentEnvelope[0].trace, expectedDsc); + } else { + assertEquals(segmentEnvelope, undefined); + } +} + +const cases: { + name: string; + options: Pick; + requestHeaders: Record; + expectedSampled?: 'true' | 'false'; + expectedSampleRate: string; +}[] = [ + { + name: 'incoming sampled trace without baggage creates the DSC from the segment span', + options: { tracesSampleRate: 0.5 }, + requestHeaders: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-1` }, + expectedSampled: 'true', + expectedSampleRate: '1', + }, + { + name: 'incoming sampled trace with only third-party baggage creates the DSC from the segment span', + options: { tracesSampleRate: 0.5 }, + requestHeaders: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-1`, baggage: 'foo=bar' }, + expectedSampled: 'true', + expectedSampleRate: '1', + }, + { + name: 'incoming unsampled trace without baggage creates the DSC from the segment span', + options: { tracesSampleRate: 0.5 }, + requestHeaders: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-0` }, + expectedSampled: 'false', + expectedSampleRate: '0', + }, + { + name: 'incoming deferred trace without baggage applies the local tracesSampleRate to the DSC', + options: { tracesSampleRate: 0.5 }, + requestHeaders: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}` }, + expectedSampleRate: '0.5', + }, + { + name: 'incoming deferred trace without baggage applies the tracesSampler rate to the DSC', + options: { tracesSampler: () => 0.25 }, + requestHeaders: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}` }, + expectedSampleRate: '0.25', + }, +]; + +for (const traceLifecycle of ['stream', 'static'] as const) { + for (const { name, options, requestHeaders, expectedSampled, expectedSampleRate } of cases) { + Deno.test(`[${traceLifecycle}] ${name}`, async () => { + const result = await runScenario(traceLifecycle, options, requestHeaders); + + if (expectedSampled) { + assertEquals(baggageToDsc(result.outgoingHeaders.get('baggage')).sampled, expectedSampled); + } + assertDscForIncomingTraceWithoutBaggage(result, expectedSampleRate); + }); + } +} diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/instrument.mjs b/dev-packages/node-integration-tests/suites/express/sentry-trace/instrument.mjs index 288936c1eeca..d2e8e0e16b12 100644 --- a/dev-packages/node-integration-tests/suites/express/sentry-trace/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/instrument.mjs @@ -9,5 +9,6 @@ Sentry.init({ // request to `somewhere.not.sentry` gets trace propagation. tracePropagationTargets: [/^(?!.*express).*$/], tracesSampleRate: 1.0, + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/test.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/test.ts index e790bfb7f3ff..fcdcb122b35c 100644 --- a/dev-packages/node-integration-tests/suites/express/sentry-trace/test.ts +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/test.ts @@ -2,6 +2,8 @@ import { extractTraceparentData, parseBaggageHeader, TRACEPARENT_REGEXP } from ' import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createCjsTests } from '../../../utils/runner'; +const LIFECYCLES = ['stream', 'static'] as const; + type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; describe('express sentry-trace', () => { @@ -67,40 +69,58 @@ describe('express sentry-trace', () => { }); }); - test('Should not propagate baggage if sentry-trace header is present in incoming request but no baggage header', async () => { - const runner = createRunner().start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', - }, - }); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - }, - }); - }); + test.each(LIFECYCLES)( + 'Should populate and propagate sentry baggage if sentry-trace header is present in incoming request but no baggage header (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner().withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); - test('Should not propagate baggage and ignore original 3rd party baggage entries if sentry-trace header is present', async () => { - const runner = createRunner().start(); + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + }, + }); + + expect(response?.test_data.host).toBe('somewhere.not.sentry'); + expect(response?.test_data['sentry-trace']).toMatch(/^12312012123120121231201212312012-[a-f0-9]{16}-1$/); + expect(parseBaggageHeader(response?.test_data.baggage)).toStrictEqual({ + 'sentry-environment': 'prod', + 'sentry-release': '1.0', + 'sentry-public_key': 'public', + 'sentry-trace_id': '12312012123120121231201212312012', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-transaction': 'GET /test/express', + }); + }, + ); - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', - baggage: 'foo=bar', - }, - }); + test.each(LIFECYCLES)( + 'Should populate sentry baggage and ignore original 3rd party baggage entries if sentry-trace header is present (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner().withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - }, - }); - }); + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'foo=bar', + }, + }); + + expect(response?.test_data.host).toBe('somewhere.not.sentry'); + expect(response?.test_data['sentry-trace']).toMatch(/^12312012123120121231201212312012-[a-f0-9]{16}-1$/); + expect(parseBaggageHeader(response?.test_data.baggage)).toStrictEqual({ + 'sentry-environment': 'prod', + 'sentry-release': '1.0', + 'sentry-public_key': 'public', + 'sentry-trace_id': '12312012123120121231201212312012', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-transaction': 'GET /test/express', + }); + }, + ); test('Should populate and propagate sentry baggage if sentry-trace header does not exist', async () => { const runner = createRunner().start(); @@ -312,25 +332,32 @@ describe('express sentry-trace', () => { }); }); - test('Should assign `sentry-trace` header which sets parent trace id of an outgoing request.', async () => { - const runner = createRunner().start(); + test.each(LIFECYCLES)( + 'Should assign `sentry-trace` header which sets parent trace id of an outgoing request. (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner().withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', - }, - }); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - 'sentry-trace': expect.stringContaining('12312012123120121231201212312012-'), - }, - }); + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', + }, + }); - expect(TRACEPARENT_REGEXP.test(response?.test_data['sentry-trace'] || '')).toBe(true); - }); + expect(response?.test_data.host).toBe('somewhere.not.sentry'); + expect(response?.test_data['sentry-trace']).toMatch(/^12312012123120121231201212312012-[a-f0-9]{16}-0$/); + expect(TRACEPARENT_REGEXP.test(response?.test_data['sentry-trace'] || '')).toBe(true); + expect(parseBaggageHeader(response?.test_data.baggage)).toStrictEqual({ + 'sentry-environment': 'prod', + 'sentry-release': '1.0', + 'sentry-public_key': 'public', + 'sentry-trace_id': '12312012123120121231201212312012', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '0', + 'sentry-sampled': 'false', + 'sentry-transaction': 'GET /test/express', + }); + }, + ); test('should attach a `sentry-trace` header to an outgoing request.', async () => { const runner = createRunner().start(); diff --git a/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs index b45f2ba5b2b3..c5ab40f0636a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs @@ -9,16 +9,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', ...(tracesSampleRate !== undefined ? { tracesSampleRate: Number(tracesSampleRate) } : {}), + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', integrations: [], transport: loggingTransport, - // Stash the outgoing propagation data as observed inside the continueTrace callback onto every - // error event, so we can assert on it uniformly across all sampling configs (even when no - // span is emitted). - beforeSend(event) { - event.contexts = { - ...event.contexts, - traceData: { ...Sentry.getTraceData() }, - }; - return event; - }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/continueTrace/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/continueTrace/scenario.mjs index e0f0845f30e7..b08517110414 100644 --- a/dev-packages/node-integration-tests/suites/tracing/continueTrace/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/scenario.mjs @@ -7,6 +7,8 @@ const baggage = process.env.INCOMING_BAGGAGE || undefined; Sentry.continueTrace({ sentryTrace, baggage }, () => { Sentry.startSpan({ name: 'continued-root-span' }, () => { + // Read here, not in `beforeSend`: on Bun, `beforeSend` does not run in the span's async context. + Sentry.getCurrentScope().setContext('traceData', { ...Sentry.getTraceData() }); // Capture while the root span is active so the error carries its trace context. Sentry.captureException(new Error('continued-trace-error')); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/continueTrace/test.ts b/dev-packages/node-integration-tests/suites/tracing/continueTrace/test.ts index aba1bdda43b7..55e94b70bce1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/continueTrace/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/test.ts @@ -1,3 +1,4 @@ +import { parseBaggageHeader } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -27,7 +28,6 @@ interface Variant { parentSpanId?: string; /** Incoming sampling decision: true (`-1`), false (`-0`), or undefined (deferred / none). */ parentSampled?: boolean; - hasIncomingBaggage?: boolean; } const VARIANTS: Variant[] = [ @@ -38,7 +38,6 @@ const VARIANTS: Variant[] = [ traceId: SAMPLED_TRACE_ID, parentSpanId: SAMPLED_SPAN_ID, parentSampled: true, - hasIncomingBaggage: true, }, { key: 'unsampled incoming trace', @@ -79,76 +78,120 @@ function expectsSpan(rate: string | undefined, variant: Variant): boolean { return Number(rate) > 0; // deferred / none -> local rate } +/** + * A trace continued without incoming DSC gets a DSC created from our own segment span. + * See https://develop.sentry.dev/sdk/foundations/trace-propagation/dynamic-sampling-context/#incoming-trace-without-dsc + */ +function expectedBaggageWithoutIncomingDsc(rate: string | undefined, variant: Variant): Record { + const clientDsc = { + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-release': '1.0', + 'sentry-trace_id': variant.traceId, + }; + + if (rate === undefined) { + return clientDsc; + } + + const sampled = expectsSpan(rate, variant); + return { + ...clientDsc, + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': variant.parentSampled === undefined ? rate : String(Number(sampled)), + 'sentry-sampled': String(sampled), + 'sentry-transaction': 'continued-root-span', + }; +} + describe('continueTrace', () => { afterAll(() => { cleanupChildProcesses(); }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - describe.each(CONFIGS)('$name', config => { - test.each(VARIANTS)('continues the $key', async variant => { - const wantsSpan = expectsSpan(config.rate, variant); - - const runner = createRunner().withEnv({ - TRACES_SAMPLE_RATE: config.rate, - INCOMING_SENTRY_TRACE: variant.sentryTrace, - INCOMING_BAGGAGE: variant.baggage, - }); - - let observedErrorTraceId: string | undefined; - let observedSpanTraceId: string | undefined; - - // The error event is delayed by async enrichment (context lines, local variables) while the - // span buffer flushes after span end, so the two envelopes can arrive in either - // order. Match them by type rather than by position. - runner.unordered(); - - runner.expect({ - event: event => { - const trace = event.contexts?.trace; - observedErrorTraceId = trace?.trace_id; + describe.each(['stream', 'static'] as const)('traceLifecycle: %s', lifecycle => { + describe.each(CONFIGS)('$name', config => { + test.each(VARIANTS)('continues the $key', async variant => { + const wantsSpan = expectsSpan(config.rate, variant); + + const runner = createRunner().withEnv({ + TRACE_LIFECYCLE: lifecycle, + TRACES_SAMPLE_RATE: config.rate, + INCOMING_SENTRY_TRACE: variant.sentryTrace, + INCOMING_BAGGAGE: variant.baggage, + }); - if (variant.traceId) { - expect(trace?.trace_id).toBe(variant.traceId); - } else { - expect(trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); - } - expect(trace?.span_id).toMatch(/^[a-f0-9]{16}$/); + let observedErrorTraceId: string | undefined; + let observedSpanTraceId: string | undefined; - // getTraceData() observed inside the callback carries the continued trace id. - const traceData = (event.contexts?.traceData ?? {}) as { 'sentry-trace'?: string; baggage?: string }; - expect(traceData['sentry-trace']).toMatch(new RegExp(`^${trace?.trace_id}-[a-f0-9]{16}`)); - if (variant.hasIncomingBaggage) { - expect(traceData.baggage).toContain(`sentry-trace_id=${variant.traceId}`); - } - }, - }); + // The error event is delayed by async enrichment (context lines, local variables) while the + // span buffer flushes after span end, so the two envelopes can arrive in either + // order. Match them by type rather than by position. + runner.unordered(); - if (wantsSpan) { runner.expect({ - span: container => { - const segment = container.items.find(span => span.is_segment); - observedSpanTraceId = segment?.trace_id; + event: event => { + const trace = event.contexts?.trace; + observedErrorTraceId = trace?.trace_id; if (variant.traceId) { - expect(segment?.trace_id).toBe(variant.traceId); + expect(trace?.trace_id).toBe(variant.traceId); } else { - expect(segment?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); } - if (variant.parentSpanId) { - expect(segment?.parent_span_id).toBe(variant.parentSpanId); + expect(trace?.span_id).toMatch(/^[a-f0-9]{16}$/); + + // getTraceData() observed inside the callback carries the continued trace id. + const traceData = (event.contexts?.traceData ?? {}) as { 'sentry-trace'?: string; baggage?: string }; + expect(traceData['sentry-trace']).toMatch(new RegExp(`^${trace?.trace_id}-[a-f0-9]{16}`)); + if (variant.baggage) { + expect(parseBaggageHeader(traceData.baggage)).toStrictEqual(parseBaggageHeader(variant.baggage)); + } else if (variant.traceId) { + expect(parseBaggageHeader(traceData.baggage)).toStrictEqual( + expectedBaggageWithoutIncomingDsc(config.rate, variant), + ); } - expect(segment?.name).toBe('continued-root-span'); }, }); - } - await runner.start().completed(); + if (wantsSpan) { + const assertSegment = (segment: { trace_id?: string; parent_span_id?: string; name?: string }): void => { + observedSpanTraceId = segment.trace_id; + + if (variant.traceId) { + expect(segment.trace_id).toBe(variant.traceId); + } else { + expect(segment.trace_id).toMatch(/^[a-f0-9]{32}$/); + } + if (variant.parentSpanId) { + expect(segment.parent_span_id).toBe(variant.parentSpanId); + } + expect(segment.name).toBe('continued-root-span'); + }; + + if (lifecycle === 'static') { + runner.expect({ + transaction: transaction => { + assertSegment({ ...transaction.contexts?.trace, name: transaction.transaction }); + }, + }); + } else { + runner.expect({ + span: container => { + assertSegment(container.items.find(span => span.is_segment) ?? {}); + }, + }); + } + } + + await runner.start().completed(); - if (wantsSpan) { - // Error and span share the (continued or freshly generated) trace id. - expect(observedSpanTraceId).toBe(observedErrorTraceId); - } + if (wantsSpan) { + // Error and span share the (continued or freshly generated) trace id. + expect(observedSpanTraceId).toBe(observedErrorTraceId); + } + }); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument-traces-sampler.mjs b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument-traces-sampler.mjs new file mode 100644 index 000000000000..495198a10097 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument-traces-sampler.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@o123.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracesSampler: () => 0.5, + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument.mjs new file mode 100644 index 000000000000..2cdc80de4932 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@o123.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracesSampleRate: Number(process.env.TRACES_SAMPLE_RATE), + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/server.mjs b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/server.mjs new file mode 100644 index 000000000000..c57fa1408f4b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/server.mjs @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http from 'http'; + +const server = http.createServer((request, response) => { + const { pathname } = new URL(request.url, 'http://localhost'); + + if (pathname === '/named') { + Sentry.updateSpanName(Sentry.getRootSpan(Sentry.getActiveSpan()), 'named-segment'); + } + + makeHttpRequest(`${process.env.SERVER_URL}/outgoing`).then(() => { + if (pathname === '/error') { + Sentry.captureException(new Error('test error')); + } + response.end('ok'); + }); +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); + +function makeHttpRequest(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, httpRes => { + httpRes.resume(); + httpRes.on('end', resolve); + }); + request.on('error', reject); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/test.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/test.ts new file mode 100644 index 000000000000..d93e8a3c0b6c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/incoming-trace-without-dsc/test.ts @@ -0,0 +1,169 @@ +import { parseBaggageHeader } from '@sentry/core'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { afterAll, describe, expect, onTestFinished } from 'vitest'; +import { supports } from '../../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +const TRACE_ID = '12345678901234567890123456789012'; +const PARENT_SPAN_ID = '1234567890123456'; + +const CLIENT_DSC = { + trace_id: TRACE_ID, + public_key: 'public', + environment: 'prod', + release: '1.0', + org_id: '123', +}; + +type Lifecycle = 'stream' | 'static'; + +/** + * Starts the server that the app sends its outgoing request to, and records the headers of that request. + */ +async function startOutgoingRequestTarget(): Promise<{ + url: string; + getHeaders: () => Record; +}> { + let headers: Record = {}; + const [url, close] = await createTestServer() + .get('/outgoing', requestHeaders => { + headers = requestHeaders; + }) + .start(); + onTestFinished(close); + return { url, getHeaders: () => headers }; +} + +function toSentryBaggage(dsc: Record): Record { + return Object.fromEntries(Object.entries(dsc).map(([key, value]) => [`sentry-${key}`, value])); +} + +// Bun before 1.4 does not instrument outgoing `node:http` requests. +// See https://github.com/getsentry/sentry-javascript/issues/23881 +describe.runIf(supports({ bunMin: '1.4.0' }))('DSC of an incoming trace without baggage', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe.each(['stream', 'static'])('traceLifecycle: %s', lifecycle => { + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates the DSC from the segment span for an inherited positive decision', async () => { + const expectedDsc = { + ...CLIENT_DSC, + sampled: 'true', + sample_rate: '1', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }; + const outgoing = await startOutgoingRequestTarget(); + + const runner = createRunner() + .withEnv({ SERVER_URL: outgoing.url, TRACE_LIFECYCLE: lifecycle, TRACES_SAMPLE_RATE: '0.5' }) + .expectHeader( + lifecycle === 'stream' ? { span: { trace: expectedDsc } } : { transaction: { trace: expectedDsc } }, + ) + .start(); + await runner.makeRequest('get', '/test', { headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-1` } }); + await runner.completed(); + + const { 'sentry-trace': sentryTrace, baggage } = outgoing.getHeaders(); + expect(sentryTrace).toMatch(new RegExp(`^${TRACE_ID}-[a-f0-9]{16}-1$`)); + expect(parseBaggageHeader(baggage)).toStrictEqual(toSentryBaggage(expectedDsc)); + }); + + test('creates the DSC from the segment span for an inherited negative decision', async () => { + const expectedDsc = { + ...CLIENT_DSC, + sampled: 'false', + sample_rate: '0', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }; + const outgoing = await startOutgoingRequestTarget(); + + const runner = createRunner() + .withEnv({ SERVER_URL: outgoing.url, TRACE_LIFECYCLE: lifecycle, TRACES_SAMPLE_RATE: '1' }) + .expectHeader({ event: { trace: expectedDsc } }) + .start(); + await runner.makeRequest('get', '/error', { headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-0` } }); + await runner.completed(); + + const { 'sentry-trace': sentryTrace, baggage } = outgoing.getHeaders(); + expect(sentryTrace).toMatch(new RegExp(`^${TRACE_ID}-[a-f0-9]{16}-0$`)); + expect(parseBaggageHeader(baggage)).toStrictEqual(toSentryBaggage(expectedDsc)); + }); + + test('creates the DSC from the segment span with the local sample rate for a deferred decision', async () => { + const expectedDsc = { + ...CLIENT_DSC, + sampled: expect.stringMatching(/^(true|false)$/), + sample_rate: '0.5', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }; + const outgoing = await startOutgoingRequestTarget(); + + const runner = createRunner() + .withEnv({ SERVER_URL: outgoing.url, TRACE_LIFECYCLE: lifecycle, TRACES_SAMPLE_RATE: '0.5' }) + .ignore('span', 'transaction') + .expectHeader({ event: { trace: expectedDsc } }) + .start(); + await runner.makeRequest('get', '/error', { headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}` } }); + await runner.completed(); + + const { 'sentry-trace': sentryTrace, baggage } = outgoing.getHeaders(); + const outgoingDsc = parseBaggageHeader(baggage); + const sampled = outgoingDsc?.['sentry-sampled'] === 'true'; + expect(outgoingDsc).toStrictEqual(toSentryBaggage(expectedDsc)); + expect(sentryTrace).toMatch(new RegExp(`^${TRACE_ID}-[a-f0-9]{16}-${sampled ? 1 : 0}$`)); + expect(Number(outgoingDsc?.['sentry-sample_rand']) < 0.5).toBe(sampled); + }); + + test('adds the segment name to the DSC if its source is not `url`', async () => { + const expectedDsc = { + ...CLIENT_DSC, + sampled: 'true', + sample_rate: '1', + sample_rand: expect.stringMatching(/^0\.\d+$/), + transaction: 'named-segment', + }; + const outgoing = await startOutgoingRequestTarget(); + + const runner = createRunner() + .withEnv({ SERVER_URL: outgoing.url, TRACE_LIFECYCLE: lifecycle, TRACES_SAMPLE_RATE: '0.5' }) + .expectHeader( + lifecycle === 'stream' ? { span: { trace: expectedDsc } } : { transaction: { trace: expectedDsc } }, + ) + .start(); + await runner.makeRequest('get', '/named', { headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}-1` } }); + await runner.completed(); + + expect(parseBaggageHeader(outgoing.getHeaders().baggage)).toStrictEqual(toSentryBaggage(expectedDsc)); + }); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-traces-sampler.mjs', (createRunner, test) => { + test('creates the DSC from the segment span with the tracesSampler sample rate', async () => { + const expectedDsc = { + ...CLIENT_DSC, + sampled: expect.stringMatching(/^(true|false)$/), + sample_rate: '0.5', + sample_rand: expect.stringMatching(/^0\.\d+$/), + }; + const outgoing = await startOutgoingRequestTarget(); + + const runner = createRunner() + .withEnv({ SERVER_URL: outgoing.url, TRACE_LIFECYCLE: lifecycle }) + .ignore('span', 'transaction') + .expectHeader({ event: { trace: expectedDsc } }) + .start(); + await runner.makeRequest('get', '/error', { headers: { 'sentry-trace': `${TRACE_ID}-${PARENT_SPAN_ID}` } }); + await runner.completed(); + + const { 'sentry-trace': sentryTrace, baggage } = outgoing.getHeaders(); + const outgoingDsc = parseBaggageHeader(baggage); + const sampled = outgoingDsc?.['sentry-sampled'] === 'true'; + expect(outgoingDsc).toStrictEqual(toSentryBaggage(expectedDsc)); + expect(sentryTrace).toMatch(new RegExp(`^${TRACE_ID}-[a-f0-9]{16}-${sampled ? 1 : 0}$`)); + expect(Number(outgoingDsc?.['sentry-sample_rand']) < 0.5).toBe(sampled); + }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/server.js b/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/server.js index 9203829df912..dd93dd501a37 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/server.js @@ -3,6 +3,7 @@ const Sentry = require('@sentry/node'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', transport: loggingTransport, tracesSampleRate: 0.00000001, // It's important that this is not 1, so that we also check logic for NonRecordingSpans, which is usually the edge-case }); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/test.ts b/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/test.ts index a63efe51adbd..3811940a7297 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rand-propagation/test.ts @@ -1,6 +1,9 @@ +import { parseBaggageHeader } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +const LIFECYCLES = ['stream', 'static'] as const; + describe('sample_rand propagation', () => { afterAll(() => { cleanupChildProcesses(); @@ -31,19 +34,25 @@ describe('sample_rand propagation', () => { }); }); - test('propagates a sample rand when there is an incoming sentry-trace header but no baggage header', async () => { - const runner = createRunner(__dirname, 'server.js').start(); - const response = await runner.makeRequest('get', '/check', { - headers: { - 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', - }, - }); - expect(response).toEqual({ - propagatedData: { - baggage: expect.stringMatching(/sentry-sample_rand=0\.\d+/), - }, - }); - }); + test.each(LIFECYCLES)( + 'propagates a sample rand when there is an incoming sentry-trace header but no baggage header (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + }, + }); + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + }); + }, + ); test('propagates a sample_rand that would lead to a positive sampling decision when there is an incoming positive sampling decision but no sample_rand in the baggage header', async () => { const runner = createRunner(__dirname, 'server.js').start(); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/server.js b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/server.js index 683b646104f4..af9f9a900fd4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/server.js @@ -3,6 +3,8 @@ const Sentry = require('@sentry/node'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/test.ts b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/test.ts index b3040dc0cfa4..1b21bc1e82b1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/test.ts @@ -1,6 +1,9 @@ +import { parseBaggageHeader } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +const LIFECYCLES = ['stream', 'static'] as const; + describe('parentSampleRate propagation with no tracing enabled', () => { afterAll(() => { cleanupChildProcesses(); @@ -18,6 +21,25 @@ describe('parentSampleRate propagation with no tracing enabled', () => { expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); }); + test.each(LIFECYCLES)( + 'propagates a client-only DSC for an incoming trace without baggage (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + }, + }); + + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toStrictEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-release': '1.0', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + }); + }, + ); + test('should not propagate a sample rate for root traces', async () => { const runner = createRunner(__dirname, 'server.js').start(); const response = await runner.makeRequest('get', '/check'); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/server.js b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/server.js index c94638c4491b..c8d01d9feb42 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/server.js @@ -3,6 +3,7 @@ const Sentry = require('@sentry/node'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', transport: loggingTransport, tracesSampleRate: 0, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/test.ts b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/test.ts index 833f04e431a6..b0af86954a15 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/test.ts @@ -1,6 +1,9 @@ +import { parseBaggageHeader } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +const LIFECYCLES = ['stream', 'static'] as const; + describe('parentSampleRate propagation with tracesSampleRate=0', () => { afterAll(() => { cleanupChildProcesses(); @@ -30,6 +33,50 @@ describe('parentSampleRate propagation with tracesSampleRate=0', () => { expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); }); + test.each(LIFECYCLES)( + 'propagates sample rate 1 when inheriting a positive sampling decision without incoming sample rate (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: '', + }, + }); + + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + }); + }, + ); + + test.each(LIFECYCLES)( + 'propagates sample rate 0 when inheriting a negative sampling decision without incoming sample rate (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: '', + }, + }); + + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '0', + 'sentry-sampled': 'false', + }); + }, + ); + test('should propagate configured sample rate when receiving a trace without sampling decision and sample rate', async () => { const runner = createRunner(__dirname, 'server.js').start(); const response = await runner.makeRequest('get', '/check', { diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/server.js b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/server.js index 96f9b84e4ffd..2ffd4914c0bd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/server.js @@ -3,6 +3,7 @@ const Sentry = require('@sentry/node'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', transport: loggingTransport, tracesSampleRate: 0.69, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/test.ts b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/test.ts index 8166c72adcb6..1a916a118d88 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/test.ts @@ -1,6 +1,9 @@ +import { parseBaggageHeader } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +const LIFECYCLES = ['stream', 'static'] as const; + describe('parentSampleRate propagation with tracesSampleRate', () => { afterAll(() => { cleanupChildProcesses(); @@ -30,6 +33,50 @@ describe('parentSampleRate propagation with tracesSampleRate', () => { expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); }); + test.each(LIFECYCLES)( + 'propagates sample rate 1 when inheriting a positive sampling decision without incoming sample rate (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: '', + }, + }); + + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + }); + }, + ); + + test.each(LIFECYCLES)( + 'propagates sample rate 0 when inheriting a negative sampling decision without incoming sample rate (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: '', + }, + }); + + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '0', + 'sentry-sampled': 'false', + }); + }, + ); + test('should propagate configured sample rate when receiving a trace without sampling decision and sample rate', async () => { const runner = createRunner(__dirname, 'server.js').start(); const response = await runner.makeRequest('get', '/check', { diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js index f057a870d0df..2bbf80b5b023 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/server.js @@ -3,6 +3,7 @@ const Sentry = require('@sentry/node'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: process.env.TRACE_LIFECYCLE === 'static' ? 'static' : 'stream', transport: loggingTransport, tracesSampler: ({ inheritOrSampleWith }) => { return inheritOrSampleWith(0.69); diff --git a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts index c50e37c87780..ab2a183aca3b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler/test.ts @@ -1,6 +1,9 @@ +import { parseBaggageHeader } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +const LIFECYCLES = ['stream', 'static'] as const; + describe('parentSampleRate propagation with tracesSampler', () => { afterAll(() => { cleanupChildProcesses(); @@ -12,43 +15,75 @@ describe('parentSampleRate propagation with tracesSampler', () => { expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.69/); }); - test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (1 -> because there is a positive sampling decision and inheritOrSampleWith was used)', async () => { - const runner = createRunner(__dirname, 'server.js').start(); - const response = await runner.makeRequest('get', '/check', { - headers: { - 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', - baggage: '', - }, - }); + test.each(LIFECYCLES)( + 'propagates sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: '', + }, + }); - expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=1/); - }); + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '1', // 1 because there is a positive sampling decision and inheritOrSampleWith was used + 'sentry-sampled': 'true', + }); + }, + ); - test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (0 -> because there is a negative sampling decision and inheritOrSampleWith was used)', async () => { - const runner = createRunner(__dirname, 'server.js').start(); - const response = await runner.makeRequest('get', '/check', { - headers: { - 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', - baggage: '', - }, - }); + test.each(LIFECYCLES)( + 'propagates sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: '', + }, + }); - expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0/); - }); + expect(parseBaggageHeader((response as any).propagatedData.baggage)).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '0', // 0 because there is a negative sampling decision and inheritOrSampleWith was used + 'sentry-sampled': 'false', + }); + }, + ); - test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (the fallback value -> because there is no sampling decision and inheritOrSampleWith was used)', async () => { - const runner = createRunner(__dirname, 'server.js').start(); - const response = await runner.makeRequest('get', '/check', { - headers: { - 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', - baggage: '', - }, - }); + test.each(LIFECYCLES)( + 'propagates sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (the fallback value -> because there is no sampling decision and inheritOrSampleWith was used) (traceLifecycle: %s)', + async lifecycle => { + const runner = createRunner(__dirname, 'server.js').withEnv({ TRACE_LIFECYCLE: lifecycle }).start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', + baggage: '', + }, + }); - expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.69/); - }); + const baggage = parseBaggageHeader((response as any).propagatedData.baggage); + expect(baggage).toMatchObject({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-trace_id': '530699e319cc067ce440315d74acb312', + 'sentry-sample_rand': expect.stringMatching(/^0\.\d+$/), + 'sentry-sample_rate': '0.69', + 'sentry-sampled': expect.stringMatching(/^(true|false)$/), + }); + expect(Number(baggage?.['sentry-sample_rand']) < 0.69).toBe(baggage?.['sentry-sampled'] === 'true'); + }, + ); - test('should propagate sample_rate equivalent to incoming sample_rate (because tracesSampler is configured that way)', async () => { + test('propagates sample_rate equivalent to incoming sample_rate (because tracesSampler is configured that way)', async () => { const runner = createRunner(__dirname, 'server.js').start(); const response = await runner.makeRequest('get', '/check', { headers: { diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 0061ec0dde23..e30cdd59b0f2 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -1415,7 +1415,7 @@ describe('browserTracingIntegration', () => { expect(propagationContext.sampleRand).toBe(0.123); }); - it('puts frozen Dynamic Sampling Context on pageload span if sentry-trace data and only 3rd party baggage is present', () => { + it('creates the Dynamic Sampling Context from the pageload span if sentry-trace data and only 3rd party baggage is present', () => { // make sampled false here, so we can see that it's being used rather than the tracesSampleRate-dictated one document.head.innerHTML = '' + @@ -1444,10 +1444,18 @@ describe('browserTracingIntegration', () => { expect(spanToJSON(idleSpan).parent_span_id).toEqual('1121201211212012'); expect(spanIsSampled(idleSpan)).toBe(false); - expect(dynamicSamplingContext).toBeDefined(); - // Continuing a trace without an incoming Sentry DSC does not populate a new one, but the - // scope's `sample_rand` is still propagated so downstream sampling decisions stay consistent. - expect(dynamicSamplingContext).toStrictEqual({ sample_rand: propagationContext.sampleRand.toString() }); + // Continuing a trace without an incoming Sentry DSC creates the DSC from the pageload span, with + // `sample_rate` reflecting the inherited negative sampling decision. + expect(dynamicSamplingContext).toStrictEqual({ + environment: 'production', + release: undefined, + public_key: expect.any(String), + org_id: undefined, + trace_id: '12312012123120121231201212312012', + sampled: 'false', + sample_rate: '0', + sample_rand: propagationContext.sampleRand.toString(), + }); // Propagation context keeps the meta tag trace data for later events on the same route to add them to the trace expect(propagationContext.traceId).toEqual('12312012123120121231201212312012'); diff --git a/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts b/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts index acca0d540088..bee23dd2c942 100644 --- a/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts +++ b/packages/core/test/lib/tracing/dynamicSamplingContext.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import { + continueTrace, getClient, + getCurrentScope, registerExternalPropagationContext, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, setCurrentClient, @@ -13,8 +15,13 @@ import { SentrySpan, setCapturedScopesOnSpan, } from '../../../src/tracing'; -import { startInactiveSpan } from '../../../src/tracing/trace'; -import { freezeDscOnSpan, getDynamicSamplingContextFromClient } from '../../../src/tracing/dynamicSamplingContext'; +import { startInactiveSpan, startSpan } from '../../../src/tracing/trace'; +import { + freezeDscOnSpan, + getDynamicSamplingContextFromClient, + getDynamicSamplingContextFromScope, +} from '../../../src/tracing/dynamicSamplingContext'; +import type { DynamicSamplingContext } from '../../../src/types/envelope'; import type { Span, SpanContextData } from '../../../src/types/span'; import type { TransactionSource } from '../../../src/types/transaction'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -247,6 +254,116 @@ describe('getDynamicSamplingContextFromSpan', () => { }); }); +describe('getDynamicSamplingContextFromSpan when continuing a trace without an incoming DSC', () => { + const TRACE_ID = '12312012123120121231201212312012'; + const PARENT_SPAN_ID = '1121201211212012'; + + function initClient(options: Parameters[0]): void { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@o123.ingest.sentry.io/42', + release: '1.0.1', + environment: 'myEnv', + ...options, + }), + ); + setCurrentClient(client); + client.init(); + } + + function getDscOfContinuedSpan(sentryTrace: string, baggage?: string): Partial { + return continueTrace({ sentryTrace, baggage }, () => + startSpan({ name: 'GET /users/:id', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'route' } }, span => + getDynamicSamplingContextFromSpan(span), + ), + ); + } + + afterEach(() => { + vi.resetAllMocks(); + }); + + it.each([ + ['no baggage header', undefined], + ['a baggage header without sentry values', 'foo=bar,other=value'], + ])('creates the DSC from the segment span for %s', (_, baggage) => { + initClient({ tracesSampleRate: 0.5 }); + + const dsc = getDscOfContinuedSpan(`${TRACE_ID}-${PARENT_SPAN_ID}-1`, baggage); + + expect(dsc).toStrictEqual({ + trace_id: TRACE_ID, + public_key: 'username', + org_id: '123', + release: '1.0.1', + environment: 'myEnv', + transaction: 'GET /users/:id', + sampled: 'true', + sample_rate: '1', + sample_rand: expect.any(String), + }); + }); + + it('sets sample_rate to 0 for an inherited negative sampling decision', () => { + initClient({ tracesSampleRate: 0.5 }); + + const dsc = getDscOfContinuedSpan(`${TRACE_ID}-${PARENT_SPAN_ID}-0`); + + expect(dsc).toMatchObject({ trace_id: TRACE_ID, sampled: 'false', sample_rate: '0' }); + }); + + it('sets sample_rate to the local tracesSampleRate for a deferred sampling decision', () => { + initClient({ tracesSampleRate: 0.5 }); + + const dsc = getDscOfContinuedSpan(`${TRACE_ID}-${PARENT_SPAN_ID}`); + + expect(dsc).toMatchObject({ trace_id: TRACE_ID, sample_rate: '0.5' }); + expect(Number(dsc.sample_rand) < 0.5).toBe(dsc.sampled === 'true'); + }); + + it('sets sample_rate to the tracesSampler result', () => { + initClient({ tracesSampler: () => 0.25 }); + + const dsc = getDscOfContinuedSpan(`${TRACE_ID}-${PARENT_SPAN_ID}-1`); + + expect(dsc).toMatchObject({ trace_id: TRACE_ID, sample_rate: '0.25' }); + expect(Number(dsc.sample_rand) < 0.25).toBe(dsc.sampled === 'true'); + }); + + it('omits span related values in Tracing without Performance mode', () => { + initClient({}); + + const dsc = continueTrace({ sentryTrace: `${TRACE_ID}-${PARENT_SPAN_ID}-1`, baggage: undefined }, () => + getDynamicSamplingContextFromScope(getClient()!, getCurrentScope()), + ); + + expect(dsc).toStrictEqual({ + trace_id: TRACE_ID, + public_key: 'username', + org_id: '123', + release: '1.0.1', + environment: 'myEnv', + }); + }); + + it('keeps an incoming DSC frozen', () => { + initClient({ tracesSampleRate: 0.5 }); + + const dsc = getDscOfContinuedSpan( + `${TRACE_ID}-${PARENT_SPAN_ID}-1`, + 'sentry-trace_id=12312012123120121231201212312012,sentry-environment=upstream,sentry-sample_rate=0.3,sentry-sampled=true,sentry-sample_rand=0.1', + ); + + expect(dsc).toStrictEqual({ + trace_id: TRACE_ID, + environment: 'upstream', + sample_rate: '0.3', + sampled: 'true', + sample_rand: '0.1', + }); + }); +}); + describe('getDynamicSamplingContextFromClient', () => { const TRACE_ID = '4b25bc58f14243d8b208d1e22a054164'; let client: TestClient; diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index a129c35e6a13..a689b166041c 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -7,6 +7,7 @@ import { getDynamicSamplingContextFromSpan, getIsolationScope, getTraceData, + propagationContextFromHeaders, SentryNonRecordingSpan, SentrySpan, setCurrentClient, @@ -153,23 +154,23 @@ describe('startIdleSpan', () => { expect(data.baggage).toContain('sentry-sampled=true'); }); - it('freezes a continued trace empty DSC as-is when tracing is disabled', () => { + it('creates the DSC from the client for a trace continued without baggage when tracing is disabled', () => { const options = getDefaultTestClientOptions({ dsn }); const client = new TestClient(options); setCurrentClient(client); client.init(); - // A continued `sentry-trace` without baggage yields an empty frozen DSC marker. - getCurrentScope().setPropagationContext({ - traceId: '12345678901234567890123456789012', - sampleRand: 0.42, - dsc: {}, - }); + getCurrentScope().setPropagationContext( + propagationContextFromHeaders('12345678901234567890123456789012-1121201211212012-1', undefined), + ); const idleSpan = startIdleSpan({ name: 'foo' }); - // We are not head of trace: don't fabricate client fields or inject the local transaction. - expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({}); + expect(getDynamicSamplingContextFromSpan(idleSpan)).toEqual({ + environment: 'production', + public_key: '123', + trace_id: '12345678901234567890123456789012', + }); }); it('does not add a url-source span name to the DSC when tracing is disabled', () => { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index f59946d50c10..d90d19ebfc18 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -255,27 +255,23 @@ describe('startSpan', () => { expect(getCapturedScopesOnSpan(span).isolationScope).toBe(getIsolationScope()); }); - it('freezes a continued trace empty DSC as-is when tracing is disabled', () => { + it('creates the DSC from the client for a trace continued without baggage when tracing is disabled', () => { const options = getDefaultTestClientOptions({}); client = new TestClient(options); setCurrentClient(client); client.init(); - // A continued `sentry-trace` without baggage yields an empty frozen DSC marker. - getCurrentScope().setPropagationContext({ - traceId: '12345678901234567890123456789012', - sampleRand: 0.42, - dsc: {}, - }); - - const span = startSpan({ name: 'GET users/[id]' }, span => { - return span; - }); + const span = continueTrace( + { sentryTrace: '12345678901234567890123456789012-1121201211212012-1', baggage: undefined }, + () => startSpan({ name: 'GET users/[id]' }, span => span), + ); expect(span).toBeInstanceOf(SentryNonRecordingSpan); expect(span.spanContext().traceId).toBe('12345678901234567890123456789012'); - // We are not head of trace: don't fabricate client fields or inject the local transaction. - expect(getDynamicSamplingContextFromSpan(span)).toEqual({}); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + environment: 'production', + trace_id: '12345678901234567890123456789012', + }); }); it('does not add a url-source span name to the DSC when tracing is disabled', () => { @@ -1914,7 +1910,7 @@ describe('continueTrace', () => { ); expect(scope.getPropagationContext()).toEqual({ - dsc: {}, // DSC should be an empty object (frozen), because there was an incoming trace + dsc: undefined, // DSC is not frozen, because there was no incoming DSC sampled: false, parentSpanId: '1121201211212012', traceId: '12312012123120121231201212312012', diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index 797660358430..a6819d37dbf2 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -300,7 +300,6 @@ describe('getTraceData', () => { parentSpanId: '1234567890123456', sampleRand: 0.42, sampled: false, - dsc: {}, }); startSpan({ name: 'twp-root' }, () => { @@ -318,7 +317,6 @@ describe('getTraceData', () => { parentSpanId: '1234567890123456', sampleRand: 0.42, sampled: true, - dsc: {}, }); startSpan({ name: 'twp-root' }, () => { @@ -362,7 +360,7 @@ describe('getTraceData', () => { }); }); - it('does not fabricate baggage for a continued empty frozen DSC on nested TwP placeholder spans', () => { + it('creates baggage from the client for a trace continued without baggage on nested TwP placeholder spans', () => { setupClient({ tracesSampleRate: undefined }); getCurrentScope().setPropagationContext({ @@ -370,17 +368,15 @@ describe('getTraceData', () => { parentSpanId: '1234567890123456', sampleRand: 0.42, sampled: true, - dsc: {}, }); startSpan({ name: 'twp-root' }, () => { startSpan({ name: 'twp-child' }, () => { const data = getTraceData(); - // We are not head of trace: a continued `sentry-trace`-only trace must not - // gain locally fabricated client fields at depth either. - expect(data.baggage ?? '').not.toContain('sentry-environment'); - expect(data.baggage ?? '').not.toContain('sentry-public_key'); + expect(data.baggage).toBe( + 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012', + ); }); }); }); diff --git a/packages/core/test/lib/utils/tracing.test.ts b/packages/core/test/lib/utils/tracing.test.ts index c950679a21be..edc02ae81e0c 100644 --- a/packages/core/test/lib/utils/tracing.test.ts +++ b/packages/core/test/lib/utils/tracing.test.ts @@ -39,13 +39,14 @@ describe('propagationContextFromHeaders()', () => { ); }); - it('returns a frozen dynamic sampling context (empty object) when there is an incoming trace but no baggage header', () => { + it('does not freeze a dynamic sampling context when there is an incoming trace but no baggage header', () => { const result = propagationContextFromHeaders(EXAMPLE_SENTRY_TRACE, undefined); - expect(result).toEqual( - expect.objectContaining({ - dsc: {}, - }), - ); + expect(result.dsc).toBeUndefined(); + }); + + it('does not freeze a dynamic sampling context when the incoming baggage has no sentry values', () => { + const result = propagationContextFromHeaders(EXAMPLE_SENTRY_TRACE, 'foo=bar,other=value'); + expect(result.dsc).toBeUndefined(); }); it('returns the correct trace parent data when both sentry-trace and baggage are given', () => { diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 1cfb18963193..487be8d8be83 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -336,9 +336,10 @@ describe('sentryHandle', () => { ); }); - it("starts a span from sentry-trace header but doesn't populate a new DSC", async () => { + it('starts a span from sentry-trace header and creates the DSC from it', async () => { const event = mockEvent({ request: { + method: 'GET', headers: { get: (key: string) => { if (key === 'sentry-trace') { @@ -377,11 +378,17 @@ describe('sentryHandle', () => { expect(_span!.spanContext().traceId).toEqual('1234567890abcdef1234567890abcdef'); expect(spanToJSON(_span!).parent_span_id).toEqual('1234567890abcdef'); expect(spanIsSampled(_span!)).toEqual(true); - // Continuing a trace without incoming baggage does not populate a new DSC, but the scope's - // `sample_rand` is still propagated so downstream sampling decisions stay consistent. Assert it - // matches the scope value rather than just any number, so a freshly-minted rand would fail. + // Assert the scope's `sample_rand` rather than just any number, so a freshly-minted rand would fail. const scopeSampleRand = getCapturedScopesOnSpan(_span!).scope!.getPropagationContext().sampleRand; - expect(envelopeHeaders!.trace).toEqual({ sample_rand: scopeSampleRand.toString() }); + expect(envelopeHeaders!.trace).toEqual({ + environment: 'production', + public_key: 'examplePublicKey', + trace_id: '1234567890abcdef1234567890abcdef', + transaction: 'GET /users/[id]', + sampled: 'true', + sample_rate: '1', + sample_rand: scopeSampleRand.toString(), + }); }); it('creates a transaction with dynamic sampling context from baggage header', async () => {