From 43038b9ac10561cbc59961c46d1493fbf05ebab5 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 9 Oct 2026 11:49:28 +0200 Subject: [PATCH 1/2] test(node): Port trace context tests to span streaming Co-Authored-By: GPT-6 --- .../public-api/bindScopeToEmitter/scenario.ts | 1 - .../public-api/bindScopeToEmitter/test.ts | 59 +++++++++---------- .../tracing/continueTrace/instrument.mjs | 3 +- .../suites/tracing/continueTrace/scenario.mjs | 3 +- .../suites/tracing/continueTrace/test.ts | 30 +++++----- .../tracing/startNewTrace/instrument.mjs | 1 - .../suites/tracing/startNewTrace/test.ts | 4 +- .../instrument.cjs | 1 - .../traceid-recycling-with-spans/server.js | 3 +- .../traceid-recycling-with-spans/test.ts | 50 ++++++++-------- .../tracing/traceid-recycling/server.ts | 1 - 11 files changed, 73 insertions(+), 83 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/scenario.ts index 5258dc9fb85e..a4a256d3171d 100644 --- a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/scenario.ts @@ -4,7 +4,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, diff --git a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts index 165eb6cc5fd8..d59909dfbea4 100644 --- a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts @@ -1,4 +1,4 @@ -import type { TransactionEvent } from '@sentry/core'; +import { SENTRY_SEGMENT_ID } from '@sentry/conventions/attributes'; import { afterAll, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; @@ -7,36 +7,35 @@ afterAll(() => { }); test('bindScopeToEmitter preserves the active span for listeners firing in a different async context', async () => { - // Collect both transactions regardless of the order they are flushed in. - const transactions: Record = {}; - const collect = (event: TransactionEvent): void => { - transactions[event.transaction as string] = event; - }; - await createRunner(__dirname, 'scenario.ts') - .expect({ transaction: collect }) - .expect({ transaction: collect }) + .expect({ + span: container => { + const parent = container.items.find(span => span.name === 'parent'); + const childUnbound = container.items.find(span => span.name === 'child-unbound'); + + expect(parent?.is_segment).toBe(true); + expect(childUnbound?.is_segment).toBe(true); + + const parentTraceId = parent?.trace_id; + const parentSpanId = parent?.span_id; + + // The bound emitter's listener ran inside the parent span context -> nested child span. + const childBound = container.items.find(span => span.name === 'child-bound'); + expect(childBound?.is_segment).toBe(false); + expect(childBound?.parent_span_id).toBe(parentSpanId); + expect(childBound?.trace_id).toBe(parentTraceId); + + // The unbound emitter's listener ran without the parent active -> its own root span, + // not nested under the parent span. It still shares the isolation scope's propagation context + // trace, matching the core SDK behavior for root spans. + expect( + container.items.filter( + span => !span.is_segment && span.attributes[SENTRY_SEGMENT_ID]?.value === childUnbound?.span_id, + ), + ).toEqual([]); + expect(childUnbound?.parent_span_id).toBeUndefined(); + }, + }) .start() .completed(); - - const parent = transactions['parent']; - const childUnbound = transactions['child-unbound']; - - expect(parent).toBeDefined(); - expect(childUnbound).toBeDefined(); - - const parentTraceId = parent?.contexts?.trace?.trace_id; - const parentSpanId = parent?.contexts?.trace?.span_id; - - // The bound emitter's listener ran inside the parent span context -> nested child span. - const childBound = parent?.spans?.find(span => span.description === 'child-bound'); - expect(childBound).toBeDefined(); - expect(childBound?.parent_span_id).toBe(parentSpanId); - expect(childBound?.trace_id).toBe(parentTraceId); - - // The unbound emitter's listener ran without the parent active -> its own root transaction, - // not nested under the parent span. It still shares the isolation scope's propagation context - // trace, matching the core SDK behavior for root spans. - expect(childUnbound?.spans).toEqual([]); - expect(childUnbound?.contexts?.trace?.parent_span_id).toBeUndefined(); }); 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 286cca321ab6..b45f2ba5b2b3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/instrument.mjs @@ -6,7 +6,6 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests'; 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) } : {}), @@ -14,7 +13,7 @@ Sentry.init({ 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). + // span is emitted). beforeSend(event) { event.contexts = { ...event.contexts, 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 1badd4ec54cd..e0f0845f30e7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/continueTrace/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/scenario.mjs @@ -7,8 +7,7 @@ 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). + // 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 81c56c61af14..aba1bdda43b7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/continueTrace/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/continueTrace/test.ts @@ -72,7 +72,7 @@ const CONFIGS: { name: string; rate?: string }[] = [ * - incoming parentSampled true/false -> overrides local rate. * - deferred/none -> local tracesSampleRate decides. */ -function expectsTransaction(rate: string | undefined, variant: Variant): boolean { +function expectsSpan(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 @@ -87,7 +87,7 @@ describe('continueTrace', () => { 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 wantsSpan = expectsSpan(config.rate, variant); const runner = createRunner().withEnv({ TRACES_SAMPLE_RATE: config.rate, @@ -96,10 +96,10 @@ describe('continueTrace', () => { }); let observedErrorTraceId: string | undefined; - let observedTxTraceId: string | undefined; + let observedSpanTraceId: 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 + // 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(); @@ -124,30 +124,30 @@ describe('continueTrace', () => { }, }); - if (wantsTransaction) { + if (wantsSpan) { runner.expect({ - transaction: transaction => { - const trace = transaction.contexts?.trace; - observedTxTraceId = trace?.trace_id; + span: container => { + const segment = container.items.find(span => span.is_segment); + observedSpanTraceId = segment?.trace_id; if (variant.traceId) { - expect(trace?.trace_id).toBe(variant.traceId); + expect(segment?.trace_id).toBe(variant.traceId); } else { - expect(trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(segment?.trace_id).toMatch(/^[a-f0-9]{32}$/); } if (variant.parentSpanId) { - expect(trace?.parent_span_id).toBe(variant.parentSpanId); + expect(segment?.parent_span_id).toBe(variant.parentSpanId); } - expect(transaction.transaction).toBe('continued-root-span'); + expect(segment?.name).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); + 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/startNewTrace/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/instrument.mjs index bf46e3f7af39..51f41678c3b3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/startNewTrace/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/instrument.mjs @@ -6,7 +6,6 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests'; 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) } : {}), diff --git a/dev-packages/node-integration-tests/suites/tracing/startNewTrace/test.ts b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/test.ts index 35eaed30c0dc..303fc28464f0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/startNewTrace/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/startNewTrace/test.ts @@ -16,9 +16,9 @@ describe('startNewTrace', () => { 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 + // Spans (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') + .ignore('span') .expect({ event: event => { const trace = event.contexts?.trace; diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/instrument.cjs b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/instrument.cjs index 8a06ee49c873..1f8467691127 100644 --- a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/instrument.cjs +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/instrument.cjs @@ -2,7 +2,6 @@ const Sentry = require('@sentry/node'); const { loggingTransport } = require('@sentry-internal/node-integration-tests'); Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', transport: loggingTransport, diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js index 3ecc21e50159..6edf701b8680 100644 --- a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js @@ -6,8 +6,7 @@ const app = express(); app.get('/test', async (_req, res) => { Sentry.captureException(new Error('test error')); - // calling Sentry.flush() here to ensure that the order in which we send transaction and errors - // is guaranteed to be 1. error, 2. transaction (repeated 3x in test) + // Flush the error before the request ends and its root span is sent. await Sentry.flush(); res.json({ success: true }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts index 4706ce877361..5b046f933602 100644 --- a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/test.ts @@ -6,9 +6,8 @@ afterAll(() => { cleanupChildProcesses(); }); -test('errors and transactions get a unique traceId per request, when tracing is enabled', async () => { +test('errors and spans get a unique traceId per request, when tracing is enabled', async () => { const eventTraceIds: string[] = []; - const transactionTraceIds: string[] = []; const runner = createRunner(__dirname, 'server.js') .withFlags('--import', join(__dirname, 'instrument.cjs')) @@ -17,43 +16,42 @@ test('errors and transactions get a unique traceId per request, when tracing is eventTraceIds.push(event.contexts?.trace?.trace_id || ''); }, }) - .expect({ - transaction: transaction => { - transactionTraceIds.push(transaction.spans?.[0]?.trace_id || ''); - }, - }) .expect({ event: event => { eventTraceIds.push(event.contexts?.trace?.trace_id || ''); }, }) - .expect({ - transaction: transaction => { - transactionTraceIds.push(transaction.spans?.[0]?.trace_id || ''); - }, - }) .expect({ event: event => { eventTraceIds.push(event.contexts?.trace?.trace_id || ''); }, - }) - .expect({ - transaction: transaction => { - transactionTraceIds.push(transaction.spans?.[0]?.trace_id || ''); - }, - }) - .start(); + }); + + const seenSegmentIds = new Set(); + const spansPromises = Array.from({ length: 3 }, () => + runner.collectStreamedSpansUntilSegment(segment => { + if (seenSegmentIds.has(segment.span_id)) return false; + seenSegmentIds.add(segment.span_id); + return true; + }), + ); + + const started = runner.start(); + + await started.makeRequest('get', '/test'); + await started.makeRequest('get', '/test'); + await started.makeRequest('get', '/test'); - await runner.makeRequest('get', '/test'); - await runner.makeRequest('get', '/test'); - await runner.makeRequest('get', '/test'); + await started.completed(); - await runner.completed(); + const spanTraceIds = (await Promise.all(spansPromises)).map( + spans => spans.find(span => !span.is_segment)?.trace_id || '', + ); - expect(new Set(transactionTraceIds).size).toBe(3); - for (const traceId of transactionTraceIds) { + expect(new Set(spanTraceIds).size).toBe(3); + for (const traceId of spanTraceIds) { expect(traceId).toMatch(/^[a-f\d]{32}$/); } - expect(eventTraceIds.sort()).toEqual(transactionTraceIds.sort()); + expect(eventTraceIds.sort()).toEqual(spanTraceIds.sort()); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts index d9b232f2ccc9..cc1347eeee86 100644 --- a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling/server.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', transport: loggingTransport, From 8b40d13e88a140122c316b9069c1b7c7f082dac8 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 9 Oct 2026 13:41:35 +0200 Subject: [PATCH 2/2] test(node): Preserve trace recycling flush comment wording Co-Authored-By: GPT-6 --- .../suites/tracing/traceid-recycling-with-spans/server.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js index 6edf701b8680..e99b0a1d46cf 100644 --- a/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/traceid-recycling-with-spans/server.js @@ -6,7 +6,8 @@ const app = express(); app.get('/test', async (_req, res) => { Sentry.captureException(new Error('test error')); - // Flush the error before the request ends and its root span is sent. + // calling Sentry.flush() here to ensure that the order in which we send root spans and errors + // is guaranteed to be 1. error, 2. root span (repeated 3x in test) await Sentry.flush(); res.json({ success: true }); });