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 new file mode 100644 index 000000000000..65cfbab96ee2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); 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 new file mode 100644 index 000000000000..f53e4ef0615f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/full-tracing/test.ts @@ -0,0 +1,149 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipTracingTest, + waitForErrorRequest, + waitForTransactionRequest, +} from '../../../../utils/helpers'; + +const SAMPLED_TRACE_ID = '12345678901234567890123456789012'; +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({}) }); + }); + + // Discard the initial pageload transaction. + await getFirstSentryEnvelopeRequest(page, url, eventAndTraceHeaderRequestParser); + + const transactionPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.trace_id === SAMPLED_TRACE_ID, + ); + + await page.locator('#sampled').click(); + + const req = await transactionPromise; + const transaction = eventAndTraceHeaderRequestParser(req); + const traceContext = transaction[0].contexts?.trace; + + expect(traceContext?.trace_id).toBe(SAMPLED_TRACE_ID); + expect(traceContext?.parent_span_id).toBe(SAMPLED_SPAN_ID); + expect(transaction[0].transaction).toBe('continued-sampled'); + + // The incoming positive sampling decision is honored regardless of local config. + expect(transaction[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 transaction', + 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 getFirstSentryEnvelopeRequest(page, url, eventAndTraceHeaderRequestParser); + + // The captured error carries the continued (unsampled) trace even though no transaction is sent. + const errorPromise = waitForErrorRequest(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$`)); + }, +); + +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 getFirstSentryEnvelopeRequest(page, url, eventAndTraceHeaderRequestParser); + + // With tracesSampleRate=1 the deferred decision resolves to sampled, so a transaction is emitted + // on the continued trace id. + const transactionPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.trace_id === DEFERRED_TRACE_ID, + ); + + await page.locator('#deferred').click(); + + const transaction = eventAndTraceHeaderRequestParser(await transactionPromise); + expect(transaction[0].contexts?.trace?.trace_id).toBe(DEFERRED_TRACE_ID); + expect(transaction[0].transaction).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 }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + }); + + const [pageloadEvent] = await getFirstSentryEnvelopeRequest( + page, + url, + eventAndTraceHeaderRequestParser, + ); + const pageloadTraceId = pageloadEvent.contexts?.trace?.trace_id; + + const transactionPromise = waitForTransactionRequest(page, event => event.transaction === 'continued-noTrace'); + + await page.locator('#noTrace').click(); + + const transaction = eventAndTraceHeaderRequestParser(await transactionPromise); + const traceId = transaction[0].contexts?.trace?.trace_id; + + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + expect(traceId).not.toBe(pageloadTraceId); + expect(transaction[0].contexts?.trace).not.toHaveProperty('parent_span_id'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/subject.js new file mode 100644 index 000000000000..ca258ea51182 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/subject.js @@ -0,0 +1,37 @@ +const TRACES = { + sampled: { + sentryTrace: '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.42', + }, + unsampled: { + sentryTrace: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111111111111111-0', + baggage: undefined, + }, + // No trailing sampling flag -> deferred sampling decision. + deferred: { + sentryTrace: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2222222222222222', + baggage: undefined, + }, + noTrace: { + sentryTrace: undefined, + baggage: undefined, + }, +}; + +function continueAndRun(variant) { + const { sentryTrace, baggage } = TRACES[variant]; + Sentry.continueTrace({ sentryTrace, baggage }, () => { + // Keep the span callback synchronous: the browser ACS is stack-based, so an `await` here would pop + // the continued span/scope before `fetch` and `captureException` run. Firing `fetch` without + // awaiting still attaches the propagation headers (they are read synchronously at call time). + Sentry.startSpan({ op: 'ui.interaction.click', name: `continued-${variant}` }, () => { + fetch('http://sentry-test-site.example'); + Sentry.captureException(new Error(`continued-${variant}-error`)); + }); + }); +} + +for (const variant of ['sampled', 'unsampled', 'deferred', 'noTrace']) { + document.getElementById(variant).addEventListener('click', () => continueAndRun(variant)); +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/template.html b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/template.html new file mode 100644 index 000000000000..a55d5b196a1a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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 new file mode 100644 index 000000000000..06a40a8e55ec --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 0, +}); 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 new file mode 100644 index 000000000000..77956fb134bf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracesSampleRate-zero/test.ts @@ -0,0 +1,106 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + eventAndTraceHeaderRequestParser, + shouldSkipTracingTest, + waitForErrorRequest, + waitForTransactionRequest, +} from '../../../../utils/helpers'; + +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({}) }); + }); + + // With tracesSampleRate=0 there is no pageload transaction, so we only wait for the continued one. + const transactionPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.trace_id === SAMPLED_TRACE_ID, + ); + + await page.goto(url); + await page.locator('#sampled').click(); + + const transaction = eventAndTraceHeaderRequestParser(await transactionPromise); + expect(transaction[0].contexts?.trace?.trace_id).toBe(SAMPLED_TRACE_ID); + expect(transaction[0].contexts?.trace?.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 transaction 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); + + // The captured error carries the continued trace even though no transaction is sent. + const errorPromise = waitForErrorRequest(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$`)); + }, +); + +sentryTest( + 'continueTrace continues an unsampled incoming trace 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); + + 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$`)); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/init.js b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/init.js new file mode 100644 index 000000000000..9eb615a288dd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +// In the browser, "tracing without performance" (TwP) means enabling `browserTracingIntegration` +// but not setting `tracesSampleRate`. +Sentry.init({ + traceLifecycle: 'static', + 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/tracing-without-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/test.ts new file mode 100644 index 000000000000..e9ebd9dcec57 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/continueTrace/tracing-without-performance/test.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + eventAndTraceHeaderRequestParser, + shouldSkipTracingTest, + waitForErrorRequest, +} from '../../../../utils/helpers'; + +const SAMPLED_TRACE_ID = '12345678901234567890123456789012'; +const UNSAMPLED_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +// In TwP mode no transactions 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. +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; + +for (const variant of VARIANTS) { + sentryTest( + `continueTrace continues the ${variant.button} incoming trace in TwP mode`, + 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); + + await page.locator(`#${variant.button}`).click(); + + const [errorEvent] = eventAndTraceHeaderRequestParser(await errorPromise); + expect(errorEvent.contexts?.trace?.trace_id).toBe(variant.traceId); + + const outgoingRequest = await outgoingRequestPromise; + const headers = await outgoingRequest.allHeaders(); + expect(headers['sentry-trace']).toMatch(new RegExp(`^${variant.traceId}-[a-f0-9]{16}${variant.sampledFlag}$`)); + }, + ); +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/subject.js new file mode 100644 index 000000000000..d311b7ed8822 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/subject.js @@ -0,0 +1,15 @@ +const newTraceBtn = document.getElementById('newTrace'); + +newTraceBtn.addEventListener('click', () => { + Sentry.startNewTrace(() => { + // Multiple root spans created within the same `startNewTrace` callback must all belong to the one + // new trace. Each becomes its own transaction when ended. + const span1 = Sentry.startInactiveSpan({ op: 'custom', name: 'new-trace-span-1' }); + span1.end(); + + const span2 = Sentry.startInactiveSpan({ op: 'custom', name: 'new-trace-span-2' }); + span2.end(); + + Sentry.startSpan({ op: 'custom', name: 'new-trace-span-3' }, () => {}); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/template.html new file mode 100644 index 000000000000..ece8760e51fe --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/test.ts new file mode 100644 index 000000000000..cf17b26aee87 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace-multiple-spans/test.ts @@ -0,0 +1,60 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest( + 'every root span created within a single `startNewTrace` callback shares the one new trace', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [pageloadEvent] = await getFirstSentryEnvelopeRequest( + page, + url, + eventAndTraceHeaderRequestParser, + ); + const pageloadTraceId = pageloadEvent.contexts?.trace?.trace_id; + expect(pageloadTraceId).toMatch(/^[\da-f]{32}$/); + + const transactionPromises = getMultipleSentryEnvelopeRequests( + page, + 3, + { envelopeType: 'transaction' }, + eventAndTraceHeaderRequestParser, + ); + + await page.locator('#newTrace').click(); + + const transactions = await transactionPromises; + + const byName = (name: string): EventAndTraceHeader => transactions.find(([event]) => event.transaction === name)!; + + const [span1] = byName('new-trace-span-1'); + const [span2] = byName('new-trace-span-2'); + const [span3] = byName('new-trace-span-3'); + + const newTraceId = span1.contexts?.trace?.trace_id; + expect(newTraceId).toMatch(/^[\da-f]{32}$/); + + // All three root spans share the one new trace id ... + expect(span2.contexts?.trace?.trace_id).toBe(newTraceId); + expect(span3.contexts?.trace?.trace_id).toBe(newTraceId); + + // ... which is a fresh trace, not the pageload trace. + expect(newTraceId).not.toBe(pageloadTraceId); + + // They are independent root spans, not parented to one another. + expect(span1.contexts?.trace).not.toHaveProperty('parent_span_id'); + expect(span2.contexts?.trace).not.toHaveProperty('parent_span_id'); + expect(span3.contexts?.trace).not.toHaveProperty('parent_span_id'); + }, +); diff --git a/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs new file mode 100644 index 000000000000..286cca321ab6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// `TRACES_SAMPLE_RATE` is set per-runner via `.withEnv()`. When it is not set at all, we deliberately +// leave `tracesSampleRate` unset so the SDK runs in "Tracing without Performance" (TwP) mode. +const tracesSampleRate = process.env.TRACES_SAMPLE_RATE; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + ...(tracesSampleRate !== undefined ? { tracesSampleRate: Number(tracesSampleRate) } : {}), + 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 + // transaction 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 new file mode 100644 index 000000000000..1badd4ec54cd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/scenario.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; + +// The incoming trace headers are injected per-runner via `.withEnv()`. Missing env vars become +// `undefined`, which exercises the "no incoming sentry-trace" variant. +const sentryTrace = process.env.INCOMING_SENTRY_TRACE || undefined; +const baggage = process.env.INCOMING_BAGGAGE || undefined; + +Sentry.continueTrace({ sentryTrace, baggage }, () => { + Sentry.startSpan({ name: 'continued-root-span' }, () => { + // Captured while the root span is active. The error is emitted before the span ends, so the + // error envelope always precedes the transaction envelope (ordered assertions rely on this). + 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 new file mode 100644 index 000000000000..81c56c61af14 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/test.ts @@ -0,0 +1,155 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Incoming trace fixtures. +const SAMPLED_TRACE_ID = '12345678901234567890123456789012'; +const SAMPLED_SPAN_ID = '1234567890123456'; +const SAMPLED_SENTRY_TRACE = `${SAMPLED_TRACE_ID}-${SAMPLED_SPAN_ID}-1`; +const SAMPLED_BAGGAGE = + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.42'; + +const UNSAMPLED_TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const UNSAMPLED_SPAN_ID = '1111111111111111'; +const UNSAMPLED_SENTRY_TRACE = `${UNSAMPLED_TRACE_ID}-${UNSAMPLED_SPAN_ID}-0`; + +// Deferred sampling decision: no trailing `-0`/`-1` flag, so `parentSampled` is undefined. +const DEFERRED_TRACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const DEFERRED_SPAN_ID = '2222222222222222'; +const DEFERRED_SENTRY_TRACE = `${DEFERRED_TRACE_ID}-${DEFERRED_SPAN_ID}`; + +interface Variant { + key: string; + sentryTrace?: string; + baggage?: string; + /** Expected continued trace id, or undefined for the "no incoming trace" (freshly generated) variant. */ + traceId?: string; + /** Expected parent span id on the continued root span, if the incoming trace carried one. */ + parentSpanId?: string; + /** Incoming sampling decision: true (`-1`), false (`-0`), or undefined (deferred / none). */ + parentSampled?: boolean; + hasIncomingBaggage?: boolean; +} + +const VARIANTS: Variant[] = [ + { + key: 'sampled incoming trace', + sentryTrace: SAMPLED_SENTRY_TRACE, + baggage: SAMPLED_BAGGAGE, + traceId: SAMPLED_TRACE_ID, + parentSpanId: SAMPLED_SPAN_ID, + parentSampled: true, + hasIncomingBaggage: true, + }, + { + key: 'unsampled incoming trace', + sentryTrace: UNSAMPLED_SENTRY_TRACE, + traceId: UNSAMPLED_TRACE_ID, + parentSpanId: UNSAMPLED_SPAN_ID, + parentSampled: false, + }, + { + key: 'deferred sampling decision', + sentryTrace: DEFERRED_SENTRY_TRACE, + traceId: DEFERRED_TRACE_ID, + parentSpanId: DEFERRED_SPAN_ID, + parentSampled: undefined, + }, + { + key: 'no incoming sentry-trace', + parentSampled: undefined, + }, +]; + +const CONFIGS: { name: string; rate?: string }[] = [ + { name: 'no tracesSampleRate (TwP)', rate: undefined }, + { name: 'tracesSampleRate=1', rate: '1' }, + { name: 'tracesSampleRate=0', rate: '0' }, +]; + +/** + * Sampling precedence (packages/core/src/tracing/sampling.ts): + * - TwP (no rate set) -> never sampled. + * - incoming parentSampled true/false -> overrides local rate. + * - deferred/none -> local tracesSampleRate decides. + */ +function expectsTransaction(rate: string | undefined, variant: Variant): boolean { + if (rate === undefined) return false; // TwP: span recording disabled + if (variant.parentSampled === true) return true; // positive parent decision wins + if (variant.parentSampled === false) return false; // negative parent decision wins + return Number(rate) > 0; // deferred / none -> local rate +} + +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 wantsTransaction = expectsTransaction(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 observedTxTraceId: string | undefined; + + // The error event is delayed by async enrichment (context lines, local variables) while the + // transaction flushes synchronously on 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; + + 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}$/); + + // 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}`); + } + }, + }); + + if (wantsTransaction) { + runner.expect({ + transaction: transaction => { + const trace = transaction.contexts?.trace; + observedTxTraceId = trace?.trace_id; + + if (variant.traceId) { + expect(trace?.trace_id).toBe(variant.traceId); + } else { + expect(trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); + } + if (variant.parentSpanId) { + expect(trace?.parent_span_id).toBe(variant.parentSpanId); + } + expect(transaction.transaction).toBe('continued-root-span'); + }, + }); + } + + await runner.start().completed(); + + if (wantsTransaction) { + // Error and transaction share the (continued or freshly generated) trace id. + expect(observedTxTraceId).toBe(observedErrorTraceId); + } + }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/startNewTrace/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/instrument.mjs new file mode 100644 index 000000000000..bf46e3f7af39 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/instrument.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// `TRACES_SAMPLE_RATE` is set per-runner via `.withEnv()`. When it is not set at all, we deliberately +// leave `tracesSampleRate` unset so the SDK runs in "Tracing without Performance" (TwP) mode. +const tracesSampleRate = process.env.TRACES_SAMPLE_RATE; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + ...(tracesSampleRate !== undefined ? { tracesSampleRate: Number(tracesSampleRate) } : {}), + integrations: [], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/startNewTrace/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/scenario.mjs new file mode 100644 index 000000000000..41ec3be622e0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/scenario.mjs @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; + +// Run inside an ambient active span to prove startNewTrace detaches from any surrounding trace. +Sentry.startSpan({ name: 'outer-ambient-span' }, outerSpan => { + const outerTraceId = outerSpan.spanContext().traceId; + + Sentry.startNewTrace(() => { + // The trace id the API set on the current scope's propagation context. + const newTraceId = Sentry.getCurrentScope().getPropagationContext().traceId; + + // Two independent root inactive spans inside the SAME startNewTrace callback. + // They must all share the same `newTraceId`. + // In TwP / rate=0 these are NonRecordingSpans but still carry a traceId. + const span1 = Sentry.startInactiveSpan({ name: 'new-trace-inactive-1' }); + const span2 = Sentry.startInactiveSpan({ name: 'new-trace-inactive-2' }); + span1.end(); + span2.end(); + + Sentry.startSpan({ name: 'new-trace-active-span' }, activeSpan => { + const traceData = Sentry.getTraceData(); + + Sentry.withScope(scope => { + scope.setContext('startNewTrace', { + outerTraceId, + newTraceId, + span1TraceId: span1.spanContext().traceId, + span2TraceId: span2.spanContext().traceId, + activeSpanTraceId: activeSpan.spanContext().traceId, + sentryTrace: traceData['sentry-trace'], + baggage: traceData.baggage, + }); + Sentry.captureException(new Error('new-trace-error')); + }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/startNewTrace/test.ts b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/test.ts new file mode 100644 index 000000000000..35eaed30c0dc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/test.ts @@ -0,0 +1,51 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +const CONFIGS = [ + ['no tracesSampleRate (TwP)', undefined], + ['tracesSampleRate=1', '1'], + ['tracesSampleRate=0', '0'], +] as const; + +describe('startNewTrace', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test.each(CONFIGS)('starts a fresh trace shared by every root span in the callback [%s]', async (_name, rate) => { + await createRunner() + .withEnv({ TRACES_SAMPLE_RATE: rate }) + // Transactions (if any, in rate=1) are irrelevant here and their ordering vs. the error is + // not deterministic, so we ignore them and rely entirely on the stashed error context. + .ignore('transaction') + .expect({ + event: event => { + const trace = event.contexts?.trace; + const ctx = (event.contexts?.startNewTrace ?? {}) as Record; + + const newTraceId = ctx.newTraceId; + expect(newTraceId).toMatch(/^[a-f0-9]{32}$/); + + // Fresh trace: detached from the ambient/outer span's trace. + expect(ctx.outerTraceId).toMatch(/^[a-f0-9]{32}$/); + expect(newTraceId).not.toBe(ctx.outerTraceId); + + // All root spans created within the callback share the ONE new trace id. + expect(ctx.span1TraceId).toBe(newTraceId); + expect(ctx.span2TraceId).toBe(newTraceId); + expect(ctx.activeSpanTraceId).toBe(newTraceId); + + // The captured error is on the new trace. + expect(trace?.trace_id).toBe(newTraceId); + + // Outgoing propagation carries the new trace id. + expect(ctx.sentryTrace).toMatch(new RegExp(`^${newTraceId}-[a-f0-9]{16}`)); + expect(ctx.baggage).toContain(`sentry-trace_id=${newTraceId}`); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index c3a1979ad92f..b3579005e0b2 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -138,7 +138,7 @@ export function createRunner(...paths: string[]) { // By default, we ignore session & sessions const ignored: Set = new Set(['session', 'sessions', 'client_report']); let unordered = false; - let withEnv: Record = {}; + let withEnv: Record = {}; let withSentryServer = false; let ensureNoErrorOutput = false; // When set, the test using this runner expects `completed()` to reject (e.g. `test.fails` variants @@ -200,7 +200,7 @@ export function createRunner(...paths: string[]) { ignored.delete('metric'); return this; }, - withEnv: function (env: Record) { + withEnv: function (env: Record) { withEnv = { ...withEnv, ...env,