From 5d5c32683cd04f360cf05259a24bcb20674187ac Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 4 Sep 2026 15:25:42 +0200 Subject: [PATCH] test(e2e): Port the nestjs-distributed-tracing E2E app to span streaming Removes the `traceLifecycle: 'static'` pin and rewrites the specs against streamed spans. Streamed spans carry neither scope tags nor breadcrumbs, so the event-handler specs have the app report its isolation scope as span attributes, the same way the Next.js middleware specs do. Ref: #23801 Co-Authored-By: Claude Opus 5 --- .../src/events.controller.ts | 4 + .../src/instrument.ts | 1 - .../src/listeners/test-event.listener.ts | 2 + .../nestjs-distributed-tracing/src/utils.ts | 18 + .../tests/events.test.ts | 89 ++-- .../tests/propagation.test.ts | 411 ++++++------------ 6 files changed, 197 insertions(+), 328 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts index 581ee0b49b09..f6c186cb4db6 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get } from '@nestjs/common'; import { EventsService } from './events.service'; +import { reportIsolationScopeOnSpan } from './utils'; @Controller('events') export class EventsController { @@ -15,12 +16,15 @@ export class EventsController { @Get('emit-multiple') async emitMultipleEvents() { await this.eventsService.emitMultipleEvents(); + reportIsolationScopeOnSpan(); return { message: 'Events emitted' }; } @Get('test-isolation') testIsolation() { + reportIsolationScopeOnSpan(); + return { message: 'ok' }; } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts index 1160869cded2..1cf7b8ee1f76 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/instrument.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nestjs'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts index ddbe3dd13261..9e02fbb84bd8 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import * as Sentry from '@sentry/nestjs'; +import { reportIsolationScopeOnSpan } from '../utils'; @Injectable() export class TestEventListener { @@ -27,6 +28,7 @@ export class TestEventListener { @OnEvent('multiple.second') async handleMultipleEvents(payload: any): Promise { Sentry.setTag(payload.data, true); + reportIsolationScopeOnSpan(); await new Promise(resolve => setTimeout(resolve, 100)); } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/utils.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/utils.ts index 27639ef26349..448638116ba4 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/utils.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/utils.ts @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/nestjs'; import * as http from 'http'; export function makeHttpRequest(url) { @@ -24,3 +25,20 @@ export function makeHttpRequest(url) { .end(); }); } + +/** + * Streamed spans carry no scope data, so the specs read what the isolation scope holds from these + * attributes on the enclosing segment span instead. + */ +export function reportIsolationScopeOnSpan() { + const activeSpan = Sentry.getActiveSpan(); + if (!activeSpan) { + return; + } + + const scopeData = Sentry.getIsolationScope().getScopeData(); + Sentry.getRootSpan(activeSpan).setAttributes({ + 'isolation_scope.tag_keys': Object.keys(scopeData.tags), + 'isolation_scope.breadcrumb_messages': scopeData.breadcrumbs.map(breadcrumb => breadcrumb.message ?? ''), + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts index 419683ee25ad..95afe0b747df 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -1,19 +1,24 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +const APP_NAME = 'nestjs-distributed-tracing'; + +function waitForSegmentSpan(name: string): Promise { + return waitForStreamedSpan(APP_NAME, span => span.is_segment && span.name === name); +} test('Event emitter', async () => { - const eventErrorPromise = waitForError('nestjs-distributed-tracing', errorEvent => { + const eventErrorPromise = waitForError(APP_NAME, errorEvent => { return errorEvent.exception.values[0].value === 'Test error from event handler'; }); - const successEventTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return transactionEvent.transaction === 'event myEvent.pass'; - }); + const successEventSpanPromise = waitForSegmentSpan('event myEvent.pass'); const eventsUrl = `http://localhost:3050/events/emit`; await fetch(eventsUrl); const eventError = await eventErrorPromise; - const successEventTransaction = await successEventTransactionPromise; + const successEventSpan = await successEventSpanPromise; expect(eventError.exception).toEqual({ values: [ @@ -29,18 +34,15 @@ test('Event emitter', async () => { ], }); - expect(successEventTransaction.contexts.trace).toEqual({ - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.segment.name.source': 'custom', - 'sentry.op': 'function', - 'sentry.origin': 'auto.event.nestjs', - }, - origin: 'auto.event.nestjs', - op: 'function', + expect(successEventSpan).toMatchObject({ + is_segment: true, status: 'ok', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'function' }, + 'sentry.origin': { type: 'string', value: 'auto.event.nestjs' }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, + }), }); }); @@ -52,44 +54,47 @@ test('Event handler breadcrumbs do not leak into subsequent HTTP requests', asyn // Wait for at least one setInterval tick to fire and add the breadcrumb await new Promise(resolve => setTimeout(resolve, 3000)); - const transactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return transactionEvent.transaction === 'GET /events/test-isolation'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /events/test-isolation'); await fetch('http://localhost:3050/events/test-isolation'); - const transaction = await transactionPromise; + const segmentSpan = await segmentSpanPromise; - const leakedBreadcrumb = (transaction.breadcrumbs || []).find( - (b: any) => b.message === 'leaked-breadcrumb-from-event-handler', + // Streamed spans carry no breadcrumbs, so the route reports its isolation scope as an attribute + expect(segmentSpan.attributes['isolation_scope.breadcrumb_messages']?.value).not.toContain( + 'leaked-breadcrumb-from-event-handler', ); - expect(leakedBreadcrumb).toBeUndefined(); }); test('Multiple OnEvent decorators', async () => { - const firstTxPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return transactionEvent.transaction === 'event multiple.first|multiple.second'; - }); - const secondTxPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return transactionEvent.transaction === 'event multiple.first|multiple.second'; - }); - const rootPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return transactionEvent.transaction === 'GET /events/emit-multiple'; + // Both handler invocations produce a segment span of the same name in traces of their own, so + // they are accumulated rather than awaited one by one - two `waitFor` calls would both resolve + // with whichever span arrives first. + const streamedSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); + return false; }); + const rootSpanPromise = waitForSegmentSpan('GET /events/emit-multiple'); + const eventsUrl = `http://localhost:3050/events/emit-multiple`; await fetch(eventsUrl); - const firstTx = await firstTxPromise; - const secondTx = await secondTxPromise; - const rootTx = await rootPromise; + const rootSpan = await rootSpanPromise; - expect(firstTx).toBeDefined(); - expect(secondTx).toBeDefined(); + const findHandlerSpans = () => + streamedSpans.filter(span => span.is_segment && span.name === 'event multiple.first|multiple.second'); + await expect.poll(() => findHandlerSpans().length).toBe(2); + + // Streamed spans carry no scope tags, so the app reports its isolation scope as an attribute. + // The tags belong to the event handlers' isolation scopes, not to the root HTTP request's. + const handlerTagKeys = findHandlerSpans().flatMap( + span => (span.attributes['isolation_scope.tag_keys']?.value as string[]) ?? [], + ); + expect(handlerTagKeys).toEqual(expect.arrayContaining(['test-first', 'test-second'])); - // Tags should be on the event handler transactions, not the root HTTP transaction - expect(firstTx.tags?.['test-first'] || firstTx.tags?.['test-second']).toBe(true); - expect(secondTx.tags?.['test-first'] || secondTx.tags?.['test-second']).toBe(true); - expect(rootTx.tags?.['test-first']).toBeUndefined(); - expect(rootTx.tags?.['test-second']).toBeUndefined(); + const rootTagKeys = rootSpan.attributes['isolation_scope.tag_keys']?.value as string[]; + expect(rootTagKeys).not.toContain('test-first'); + expect(rootTagKeys).not.toContain('test-second'); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts index 16a528accdfd..28ca62966a25 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts @@ -1,320 +1,187 @@ import crypto from 'crypto'; import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +const APP_NAME = 'nestjs-distributed-tracing'; + +function isSegmentOf(span: SerializedStreamedSpan, urlPath: string): boolean { + return span.is_segment && span.attributes['url.path']?.value === urlPath; +} + +/** + * Both apps stream into the same trace when propagation works, so the whole request is only in hand + * once every segment span it produced has arrived. + */ +function collectTrace(...urlPaths: string[]): Promise { + return collectStreamedSpans(APP_NAME, spansOfTrace => + urlPaths.every(urlPath => spansOfTrace.some(span => isSegmentOf(span, urlPath))), + ); +} + +function expectBaggage(rawBaggage: string | undefined, traceId: string): void { + expect(rawBaggage).toBeDefined(); + expect((rawBaggage ?? '').split(',')).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +} test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const id = crypto.randomUUID(); - const inboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-inbound-headers/${id}` - ); - }); - - const outboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-outgoing-http/${id}` - ); - }); + const spansPromise = collectTrace(`/test-outgoing-http/${id}`, `/test-inbound-headers/${id}`); const response = await fetch(`${baseURL}/test-outgoing-http/${id}`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - const outboundTransaction = await outboundTransactionPromise; + const spans = await spansPromise; - const traceId = outboundTransaction?.contexts?.trace?.trace_id; - const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client'); + const outboundSegmentSpan = spans.find(span => isSegmentOf(span, `/test-outgoing-http/${id}`))!; + const inboundSegmentSpan = spans.find(span => isSegmentOf(span, `/test-inbound-headers/${id}`))!; + const traceId = outboundSegmentSpan.trace_id; + const outgoingHttpSpan = spans.find(span => getSpanOp(span) === 'http.client'); expect(outgoingHttpSpan).toBeDefined(); - const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + // The outgoing span keeps only the domain in its name, so the request it stands for is spelled + // out in `url.full`. + expect(outgoingHttpSpan!.name).toBe('GET localhost'); + expect(outgoingHttpSpan!.attributes['url.full']?.value).toBe(`http://localhost:3030/test-inbound-headers/${id}`); - const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; // Outgoing span (`http.client`) does not include headers as attributes - expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); - - expect(traceId).toEqual(expect.any(String)); + expect(Object.keys(outgoingHttpSpan!.attributes).some(key => key.startsWith('http.request.header.'))).toBe(false); // data is passed through from the inbound request, to verify we have the correct headers set - const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; - const inboundHeaderBaggage = data.headers?.['baggage']; - - expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); - expect(inboundHeaderBaggage).toBeDefined(); - - const baggage = (inboundHeaderBaggage || '').split(','); - expect(baggage).toEqual( - expect.arrayContaining([ - 'sentry-environment=qa', - `sentry-trace_id=${traceId}`, - expect.stringMatching(/sentry-public_key=/), - ]), - ); + expect(data.headers?.['sentry-trace']).toEqual(`${traceId}-${outgoingHttpSpan!.span_id}-1`); + expectBaggage(data.headers?.['baggage'], traceId); - expect(outboundTransaction.contexts?.trace).toEqual({ - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.http.http_server', - 'sentry.op': 'http.server', - 'sentry.sample_rate': 1, - 'sentry.kind': 'server', - 'http.response.status_code': 200, - 'url.full': `http://localhost:3030/test-outgoing-http/${id}`, - 'url.path': `/test-outgoing-http/${id}`, - 'server.address': 'localhost', - 'http.request.method': 'GET', - 'url.scheme': 'http', - 'user_agent.original': expect.any(String), - 'client.address': '::1', - 'client.port': expect.any(Number), - 'network.transport': 'tcp', - 'network.local.address': expect.any(String), - 'network.local.port': expect.any(Number), - 'network.peer.address': expect.any(String), - 'network.peer.port': expect.any(Number), - 'network.protocol.name': 'http', - 'network.protocol.version': '1.1', - 'server.port': 3030, - 'http.response.status_text': 'OK', - 'http.route': '/test-outgoing-http/:id', - 'http.request.header.accept': '*/*', - 'http.request.header.accept_encoding': 'gzip, deflate', - 'http.request.header.accept_language': '*', - 'http.request.header.connection': 'keep-alive', - 'http.request.header.host': expect.any(String), - 'http.request.header.sec_fetch_mode': 'cors', - 'http.request.header.user_agent': 'node', - }, - op: 'http.server', - span_id: expect.stringMatching(/[a-f0-9]{16}/), + expect(outboundSegmentSpan).toMatchObject({ + name: 'GET /test-outgoing-http/:id', status: 'ok', - trace_id: traceId, - origin: 'auto.http.http_server', + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.sample_rate': { type: 'integer', value: 1 }, + 'sentry.kind': { type: 'string', value: 'server' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'http.route': { type: 'string', value: '/test-outgoing-http/:id' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + 'url.full': { type: 'string', value: `http://localhost:3030/test-outgoing-http/${id}` }, + 'server.address': { type: 'string', value: 'localhost' }, + 'server.port': { type: 'integer', value: 3030 }, + }), }); + expect(outboundSegmentSpan.parent_span_id).toBeUndefined(); - expect(inboundTransaction.contexts?.trace).toEqual({ - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.http.http_server', - 'sentry.op': 'http.server', - 'sentry.kind': 'server', - 'http.response.status_code': 200, - 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, - 'url.path': `/test-inbound-headers/${id}`, - 'server.address': 'localhost', - 'http.request.method': 'GET', - 'url.scheme': 'http', - 'client.address': '::1', - 'client.port': expect.any(Number), - 'network.transport': 'tcp', - 'network.local.address': expect.any(String), - 'network.local.port': expect.any(Number), - 'network.peer.address': expect.any(String), - 'network.peer.port': expect.any(Number), - 'network.protocol.name': 'http', - 'network.protocol.version': '1.1', - 'server.port': 3030, - 'http.response.status_text': 'OK', - 'http.route': '/test-inbound-headers/:id', - 'http.request.header.baggage': expect.any(String), - 'http.request.header.connection': 'keep-alive', - 'http.request.header.host': expect.any(String), - 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), - }, - op: 'http.server', - parent_span_id: outgoingHttpSpanId, - span_id: expect.stringMatching(/[a-f0-9]{16}/), + // The inbound request continues the trace, hanging off the outgoing client span + expect(inboundSegmentSpan).toMatchObject({ + name: 'GET /test-inbound-headers/:id', status: 'ok', - trace_id: traceId, - origin: 'auto.http.http_server', + parent_span_id: outgoingHttpSpan!.span_id, + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'http.route': { type: 'string', value: '/test-inbound-headers/:id' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + 'url.full': { type: 'string', value: `http://localhost:3030/test-inbound-headers/${id}` }, + 'http.request.header.sentry_trace': { + type: 'string', + value: expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + }, + 'http.request.header.baggage': { type: 'string', value: expect.any(String) }, + }), }); }); test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const id = crypto.randomUUID(); - const inboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-inbound-headers/${id}` - ); - }); - - const outboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-outgoing-fetch/${id}` - ); - }); + const spansPromise = collectTrace(`/test-outgoing-fetch/${id}`, `/test-inbound-headers/${id}`); const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - const outboundTransaction = await outboundTransactionPromise; + const spans = await spansPromise; - const traceId = outboundTransaction?.contexts?.trace?.trace_id; - const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client'); + const outboundSegmentSpan = spans.find(span => isSegmentOf(span, `/test-outgoing-fetch/${id}`))!; + const inboundSegmentSpan = spans.find(span => isSegmentOf(span, `/test-inbound-headers/${id}`))!; + const traceId = outboundSegmentSpan.trace_id; - expect(outgoingHttpSpan).toBeDefined(); + const outgoingFetchSpan = spans.find(span => getSpanOp(span) === 'http.client'); + expect(outgoingFetchSpan).toBeDefined(); - const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + expect(outgoingFetchSpan!.name).toBe('GET localhost'); + expect(outgoingFetchSpan!.attributes['url.full']?.value).toBe(`http://localhost:3030/test-inbound-headers/${id}`); - const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; // Outgoing span (`http.client`) does not include headers as attributes - expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); - - expect(traceId).toEqual(expect.any(String)); - - // data is passed through from the inbound request, to verify we have the correct headers set - const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; - const inboundHeaderBaggage = data.headers?.['baggage']; + expect(Object.keys(outgoingFetchSpan!.attributes).some(key => key.startsWith('http.request.header.'))).toBe(false); - expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); - expect(inboundHeaderBaggage).toBeDefined(); + expect(data.headers?.['sentry-trace']).toEqual(`${traceId}-${outgoingFetchSpan!.span_id}-1`); + expectBaggage(data.headers?.['baggage'], traceId); - const baggage = (inboundHeaderBaggage || '').split(','); - expect(baggage).toEqual( - expect.arrayContaining([ - 'sentry-environment=qa', - `sentry-trace_id=${traceId}`, - expect.stringMatching(/sentry-public_key=/), - ]), - ); - - expect(outboundTransaction.contexts?.trace).toEqual({ - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.http.http_server', - 'sentry.op': 'http.server', - 'sentry.sample_rate': 1, - 'sentry.kind': 'server', - 'http.response.status_code': 200, - 'url.full': `http://localhost:3030/test-outgoing-fetch/${id}`, - 'url.path': `/test-outgoing-fetch/${id}`, - 'server.address': 'localhost', - 'http.request.method': 'GET', - 'url.scheme': 'http', - 'user_agent.original': expect.any(String), - 'client.address': '::1', - 'client.port': expect.any(Number), - 'network.transport': 'tcp', - 'network.local.address': expect.any(String), - 'network.local.port': expect.any(Number), - 'network.peer.address': expect.any(String), - 'network.peer.port': expect.any(Number), - 'network.protocol.name': 'http', - 'network.protocol.version': '1.1', - 'server.port': 3030, - 'http.response.status_text': 'OK', - 'http.route': '/test-outgoing-fetch/:id', - 'http.request.header.accept': '*/*', - 'http.request.header.accept_encoding': 'gzip, deflate', - 'http.request.header.accept_language': '*', - 'http.request.header.connection': 'keep-alive', - 'http.request.header.host': expect.any(String), - 'http.request.header.sec_fetch_mode': 'cors', - 'http.request.header.user_agent': 'node', - }, - op: 'http.server', - span_id: expect.stringMatching(/[a-f0-9]{16}/), + expect(outboundSegmentSpan).toMatchObject({ + name: 'GET /test-outgoing-fetch/:id', status: 'ok', - trace_id: traceId, - origin: 'auto.http.http_server', + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, + 'sentry.op': { type: 'string', value: 'http.server' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'http.route': { type: 'string', value: '/test-outgoing-fetch/:id' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + 'url.full': { type: 'string', value: `http://localhost:3030/test-outgoing-fetch/${id}` }, + }), }); - expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.http.http_server', - 'sentry.op': 'http.server', - 'sentry.kind': 'server', - 'http.response.status_code': 200, - 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, - 'url.path': `/test-inbound-headers/${id}`, - 'server.address': 'localhost', - 'http.request.method': 'GET', - 'url.scheme': 'http', - 'client.address': '::1', - 'client.port': expect.any(Number), - 'network.transport': 'tcp', - 'network.local.address': expect.any(String), - 'network.local.port': expect.any(Number), - 'network.peer.address': expect.any(String), - 'network.peer.port': expect.any(Number), - 'network.protocol.name': 'http', - 'network.protocol.version': '1.1', - 'server.port': 3030, - 'http.response.status_text': 'OK', - 'http.route': '/test-inbound-headers/:id', - }), - op: 'http.server', - parent_span_id: outgoingHttpSpanId, - span_id: expect.stringMatching(/[a-f0-9]{16}/), + expect(inboundSegmentSpan).toMatchObject({ + name: 'GET /test-inbound-headers/:id', status: 'ok', - trace_id: traceId, - origin: 'auto.http.http_server', + parent_span_id: outgoingFetchSpan!.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'http.route': { type: 'string', value: '/test-inbound-headers/:id' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + 'url.full': { type: 'string', value: `http://localhost:3030/test-inbound-headers/${id}` }, + }), }); }); test('Propagates trace for outgoing external http requests', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-outgoing-http-external-allowed` - ); - }); + const spansPromise = collectTrace('/test-outgoing-http-external-allowed', '/external-allowed'); const response = await fetch(`${baseURL}/test-outgoing-http-external-allowed`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - - const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; - - expect(traceId).toEqual(expect.any(String)); - expect(spanId).toEqual(expect.any(String)); + const spans = await spansPromise; + const outgoingHttpSpan = spans.find(span => getSpanOp(span) === 'http.client')!; + const traceId = outgoingHttpSpan.trace_id; expect(data).toEqual({ headers: expect.objectContaining({ - 'sentry-trace': `${traceId}-${spanId}-1`, + 'sentry-trace': `${traceId}-${outgoingHttpSpan.span_id}-1`, baggage: expect.any(String), }), route: 'external-allowed', }); - const baggage = (data.headers.baggage || '').split(','); - expect(baggage).toEqual( - expect.arrayContaining([ - 'sentry-environment=qa', - `sentry-trace_id=${traceId}`, - expect.stringMatching(/sentry-public_key=/), - ]), - ); + expectBaggage(data.headers.baggage, traceId); }); test('Does not propagate outgoing http requests not covered by tracePropagationTargets', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-outgoing-http-external-disallowed` - ); - }); + // Without propagation the receiving app opens a trace of its own, so this one ends at the segment + // span of the request under test. + const spansPromise = collectTrace('/test-outgoing-http-external-disallowed'); const response = await fetch(`${baseURL}/test-outgoing-http-external-disallowed`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - - const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; - - expect(traceId).toEqual(expect.any(String)); - expect(spanId).toEqual(expect.any(String)); + const spans = await spansPromise; + expect(spans.find(span => getSpanOp(span) === 'http.client')).toBeDefined(); expect(data.route).toBe('external-disallowed'); expect(data.headers?.['sentry-trace']).toBeUndefined(); @@ -322,60 +189,34 @@ test('Does not propagate outgoing http requests not covered by tracePropagationT }); test('Propagates trace for outgoing external fetch requests', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-outgoing-fetch-external-allowed` - ); - }); + const spansPromise = collectTrace('/test-outgoing-fetch-external-allowed', '/external-allowed'); const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - - const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; - - expect(traceId).toEqual(expect.any(String)); - expect(spanId).toEqual(expect.any(String)); + const spans = await spansPromise; + const outgoingFetchSpan = spans.find(span => getSpanOp(span) === 'http.client')!; + const traceId = outgoingFetchSpan.trace_id; expect(data).toEqual({ headers: expect.objectContaining({ - 'sentry-trace': `${traceId}-${spanId}-1`, + 'sentry-trace': `${traceId}-${outgoingFetchSpan.span_id}-1`, baggage: expect.any(String), }), route: 'external-allowed', }); - const baggage = (data.headers.baggage || '').split(','); - expect(baggage).toEqual( - expect.arrayContaining([ - 'sentry-environment=qa', - `sentry-trace_id=${traceId}`, - expect.stringMatching(/sentry-public_key=/), - ]), - ); + expectBaggage(data.headers.baggage, traceId); }); test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { - const inboundTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['url.path'] === `/test-outgoing-fetch-external-disallowed` - ); - }); + const spansPromise = collectTrace('/test-outgoing-fetch-external-disallowed'); const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); const data = await response.json(); - const inboundTransaction = await inboundTransactionPromise; - - const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; - - expect(traceId).toEqual(expect.any(String)); - expect(spanId).toEqual(expect.any(String)); + const spans = await spansPromise; + expect(spans.find(span => getSpanOp(span) === 'http.client')).toBeDefined(); expect(data.route).toBe('external-disallowed'); expect(data.headers?.['sentry-trace']).toBeUndefined();