From c629143d380b5fae8cd2f1c107c8ff8513d39fc0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 4 Sep 2026 15:25:35 +0200 Subject: [PATCH 1/5] test(e2e): Port the nestjs-12 E2E app to span streaming Removes the `traceLifecycle: 'static'` pin and rewrites the specs against streamed spans. Ref: #23801 Co-Authored-By: Claude Opus 5 --- .../nestjs-12/src/instrument.ts | 1 - .../nestjs-12/tests/errors.test.ts | 55 +- .../nestjs-12/tests/span-decorator.test.ts | 76 +- .../nestjs-12/tests/transactions.test.ts | 840 +++++------------- 4 files changed, 287 insertions(+), 685 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/src/instrument.ts index aa4c76f13ee5..4f16ebb36d11 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/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-12/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/errors.test.ts index 2b891f247d5f..6e20358979a8 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/errors.test.ts @@ -1,8 +1,18 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +const APP_NAME = 'nestjs-12'; + +/** + * Resolves once the request's segment span has been streamed, which is how these specs know the + * request finished and any error it would have produced had its chance to be sent. + */ +function waitForSegmentSpan(name: string): Promise { + return waitForStreamedSpan(APP_NAME, span => span.is_segment && span.name === name); +} test('Sends exception to Sentry', async ({ baseURL }) => { - const errorEventPromise = waitForError('nestjs-12', event => { + const errorEventPromise = waitForError(APP_NAME, event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; }); @@ -37,7 +47,7 @@ test('Sends exception to Sentry', async ({ baseURL }) => { test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { let errorEventOccurred = false; - waitForError('nestjs-12', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected 400 exception with id 123') { errorEventOccurred = true; } @@ -45,7 +55,7 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { return event?.transaction === 'GET /test-expected-400-exception/:id'; }); - waitForError('nestjs-12', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected 500 exception with id 123') { errorEventOccurred = true; } @@ -53,13 +63,8 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { return event?.transaction === 'GET /test-expected-500-exception/:id'; }); - const transactionEventPromise400 = waitForTransaction('nestjs-12', transactionEvent => { - return transactionEvent?.transaction === 'GET /test-expected-400-exception/:id'; - }); - - const transactionEventPromise500 = waitForTransaction('nestjs-12', transactionEvent => { - return transactionEvent?.transaction === 'GET /test-expected-500-exception/:id'; - }); + const segmentSpanPromise400 = waitForSegmentSpan('GET /test-expected-400-exception/:id'); + const segmentSpanPromise500 = waitForSegmentSpan('GET /test-expected-500-exception/:id'); const response400 = await fetch(`${baseURL}/test-expected-400-exception/123`); expect(response400.status).toBe(400); @@ -67,8 +72,8 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { const response500 = await fetch(`${baseURL}/test-expected-500-exception/123`); expect(response500.status).toBe(500); - await transactionEventPromise400; - await transactionEventPromise500; + await segmentSpanPromise400; + await segmentSpanPromise500; (await fetch(`${baseURL}/flush`)).text(); @@ -78,7 +83,7 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { test('Does not send RpcExceptions to Sentry', async ({ baseURL }) => { let errorEventOccurred = false; - waitForError('nestjs-12', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected RPC exception with id 123') { errorEventOccurred = true; } @@ -86,14 +91,12 @@ test('Does not send RpcExceptions to Sentry', async ({ baseURL }) => { return event?.transaction === 'GET /test-expected-rpc-exception/:id'; }); - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return transactionEvent?.transaction === 'GET /test-expected-rpc-exception/:id'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /test-expected-rpc-exception/:id'); const response = await fetch(`${baseURL}/test-expected-rpc-exception/123`); expect(response.status).toBe(500); - await transactionEventPromise; + await segmentSpanPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -105,7 +108,7 @@ test('Global exception filter registered in main module is applied and exception }) => { let errorEventOccurred = false; - waitForError('nestjs-12', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by global filter!') { errorEventOccurred = true; } @@ -113,9 +116,7 @@ test('Global exception filter registered in main module is applied and exception return event?.transaction === 'GET /example-exception-global-filter'; }); - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return transactionEvent?.transaction === 'GET /example-exception-global-filter'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /example-exception-global-filter'); const response = await fetch(`${baseURL}/example-exception-global-filter`); const responseBody = await response.json(); @@ -128,7 +129,7 @@ test('Global exception filter registered in main module is applied and exception message: 'Example exception was handled by global filter!', }); - await transactionEventPromise; + await segmentSpanPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -140,7 +141,7 @@ test('Local exception filter registered in main module is applied and exception }) => { let errorEventOccurred = false; - waitForError('nestjs-12', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by local filter!') { errorEventOccurred = true; } @@ -148,9 +149,7 @@ test('Local exception filter registered in main module is applied and exception return event?.transaction === 'GET /example-exception-local-filter'; }); - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return transactionEvent?.transaction === 'GET /example-exception-local-filter'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /example-exception-local-filter'); const response = await fetch(`${baseURL}/example-exception-local-filter`); const responseBody = await response.json(); @@ -163,7 +162,7 @@ test('Local exception filter registered in main module is applied and exception message: 'Example exception was handled by local filter!', }); - await transactionEventPromise; + await segmentSpanPromise; (await fetch(`${baseURL}/flush`)).text(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts index bc07655f9cca..bb2bd978c832 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts @@ -1,73 +1,51 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; -test('Transaction includes span and correct value for decorated async function', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-span-decorator-async' - ); - }); +const APP_NAME = 'nestjs-12'; + +test('Trace includes span and correct value for decorated async function', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-span-decorator-async'); const response = await fetch(`${baseURL}/test-span-decorator-async`); const body = await response.json(); expect(body.result).toEqual('test'); - const transactionEvent = await transactionEventPromise; + const spans = await spansPromise; - expect(transactionEvent.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.origin': 'auto.function.nestjs.sentry_traced', - 'sentry.op': 'wait and return a string', - }, - description: 'wait', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - op: 'wait and return a string', - origin: 'auto.function.nestjs.sentry_traced', + expect(spans).toContainEqual( + expect.objectContaining({ + name: 'wait', + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'auto.function.nestjs.sentry_traced' }, + 'sentry.op': { type: 'string', value: 'wait and return a string' }, }), - ]), + }), ); }); -test('Transaction includes span and correct value for decorated sync function', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-span-decorator-sync' - ); - }); +test('Trace includes span and correct value for decorated sync function', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-span-decorator-sync'); const response = await fetch(`${baseURL}/test-span-decorator-sync`); const body = await response.json(); expect(body.result).toEqual('test'); - const transactionEvent = await transactionEventPromise; + const spans = await spansPromise; - expect(transactionEvent.spans).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.origin': 'auto.function.nestjs.sentry_traced', - 'sentry.op': 'return a string', - }, - description: 'getString', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - op: 'return a string', - origin: 'auto.function.nestjs.sentry_traced', + expect(spans).toContainEqual( + expect.objectContaining({ + name: 'getString', + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'auto.function.nestjs.sentry_traced' }, + 'sentry.op': { type: 'string', value: 'return a string' }, }), - ]), + }), ); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts index cd10d955538a..2acba0789900 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts @@ -1,719 +1,345 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Sends an API route transaction', async ({ baseURL }) => { - const pageloadTransactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-transaction' - ); - }); +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +const APP_NAME = 'nestjs-12'; + +function findSpan(spans: SerializedStreamedSpan[], name: string): SerializedStreamedSpan | undefined { + return spans.find(span => span.name === name); +} + +test('Sends streamed spans for an API route', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-transaction'); await fetch(`${baseURL}/test-transaction`); - const transactionEvent = await pageloadTransactionEventPromise; - - expect(transactionEvent.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-transaction', - 'url.path': '/test-transaction', - 'server.address': 'localhost', - 'http.request.method': 'GET', - 'url.scheme': 'http', - 'user_agent.original': 'node', - '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-transaction', - '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}/), + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + + expect(segmentSpan).toMatchObject({ + name: 'GET /test-transaction', + is_segment: true, status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - 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-transaction' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + 'http.response.status_text': { type: 'string', value: 'OK' }, + 'url.full': { type: 'string', value: 'http://localhost:3030/test-transaction' }, + 'url.path': { type: 'string', value: '/test-transaction' }, + 'url.scheme': { type: 'string', value: 'http' }, + 'server.address': { type: 'string', value: 'localhost' }, + 'server.port': { type: 'integer', value: 3030 }, + 'user_agent.original': { type: 'string', value: 'node' }, + }), }); - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - data: { - 'express.name': '/test-transaction', - 'express.type': 'request_handler', - 'http.route': '/test-transaction', - 'sentry.origin': 'auto.http.express', - 'sentry.op': 'handler', - }, - op: 'handler', - description: '/test-transaction', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.express', - }, - { - data: { - 'sentry.origin': 'manual', - }, - description: 'test-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', - }, - { - data: { - 'sentry.origin': 'manual', - }, - description: 'child-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', - }, - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.origin': 'auto.http.nestjs', - 'sentry.op': 'handler', - component: '@nestjs/core', - 'nestjs.version': expect.any(String), - 'nestjs.type': 'handler', - 'nestjs.callback': 'testTransaction', - }, - description: 'testTransaction', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'auto.http.nestjs', - op: 'handler', - }, - ]), - transaction: 'GET /test-transaction', - type: 'transaction', - transaction_info: { - source: 'route', - }, + expect(findSpan(spans, '/test-transaction')).toMatchObject({ + is_segment: false, + status: 'ok', + parent_span_id: segmentSpan.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'handler' }, + 'sentry.origin': { type: 'string', value: 'auto.http.express' }, + 'express.name': { type: 'string', value: '/test-transaction' }, + 'express.type': { type: 'string', value: 'request_handler' }, + 'http.route': { type: 'string', value: '/test-transaction' }, }), - ); + }); + + // The Nest handler span carries the callback name as an attribute rather than in its name, which + // stays low cardinality under span streaming. + expect(findSpan(spans, 'Request handler')).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'handler' }, + 'sentry.origin': { type: 'string', value: 'auto.http.nestjs' }, + component: { type: 'string', value: '@nestjs/core' }, + 'nestjs.type': { type: 'string', value: 'handler' }, + 'nestjs.callback': { type: 'string', value: 'testTransaction' }, + 'nestjs.version': { type: 'string', value: expect.any(String) }, + }), + }); + + const testSpan = findSpan(spans, 'test-span'); + expect(testSpan).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ 'sentry.origin': { type: 'string', value: 'manual' } }), + }); + + expect(findSpan(spans, 'child-span')).toMatchObject({ + is_segment: false, + status: 'ok', + parent_span_id: testSpan!.span_id, + attributes: expect.objectContaining({ 'sentry.origin': { type: 'string', value: 'manual' } }), + }); + + for (const span of spans) { + expect(span.trace_id).toBe(segmentSpan.trace_id); + } }); -test('API route transaction includes nest middleware span. Spans created in and after middleware are nested correctly', async ({ +test('API route trace includes nest middleware span. Spans created in and after middleware are nested correctly', async ({ baseURL, }) => { - const pageloadTransactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-middleware-instrumentation' - ); - }); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-middleware-instrumentation'); const response = await fetch(`${baseURL}/test-middleware-instrumentation`); expect(response.status).toBe(200); - const transactionEvent = await pageloadTransactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs', - }, - description: 'ExampleMiddleware', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs', - }, - ]), - }), - ); + const spans = await spansPromise; - const exampleMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'ExampleMiddleware'); - const exampleMiddlewareSpanId = exampleMiddlewareSpan?.span_id; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-controller-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-middleware-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - ]), + const exampleMiddlewareSpan = findSpan(spans, 'ExampleMiddleware'); + expect(exampleMiddlewareSpan).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs' }, }), - ); + }); + + const testMiddlewareSpan = findSpan(spans, 'test-middleware-span'); + const testControllerSpan = findSpan(spans, 'test-controller-span'); - // verify correct span parent-child relationships - const testMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'test-middleware-span'); - const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span'); + expect(testMiddlewareSpan).toMatchObject({ is_segment: false, status: 'ok' }); + expect(testControllerSpan).toMatchObject({ is_segment: false, status: 'ok' }); // 'ExampleMiddleware' is the parent of 'test-middleware-span' - expect(testMiddlewareSpan.parent_span_id).toBe(exampleMiddlewareSpanId); + expect(testMiddlewareSpan!.parent_span_id).toBe(exampleMiddlewareSpan!.span_id); // 'ExampleMiddleware' is NOT the parent of 'test-controller-span' - expect(testControllerSpan.parent_span_id).not.toBe(exampleMiddlewareSpanId); + expect(testControllerSpan!.parent_span_id).not.toBe(exampleMiddlewareSpan!.span_id); }); -test('API route transaction includes nest guard span and span started in guard is nested correctly', async ({ - baseURL, -}) => { - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-guard-instrumentation' - ); - }); +test('API route trace includes nest guard span and span started in guard is nested correctly', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-guard-instrumentation'); const response = await fetch(`${baseURL}/test-guard-instrumentation`); expect(response.status).toBe(200); - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.guard', - }, - description: 'ExampleGuard', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs.guard', - }, - ]), - }), - ); + const spans = await spansPromise; - const exampleGuardSpan = transactionEvent.spans.find(span => span.description === 'ExampleGuard'); - const exampleGuardSpanId = exampleGuardSpan?.span_id; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-guard-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - ]), + const exampleGuardSpan = findSpan(spans, 'ExampleGuard'); + expect(exampleGuardSpan).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.guard' }, }), - ); + }); - // verify correct span parent-child relationships - const testGuardSpan = transactionEvent.spans.find(span => span.description === 'test-guard-span'); + const testGuardSpan = findSpan(spans, 'test-guard-span'); + expect(testGuardSpan).toMatchObject({ is_segment: false, status: 'ok' }); // 'ExampleGuard' is the parent of 'test-guard-span' - expect(testGuardSpan.parent_span_id).toBe(exampleGuardSpanId); + expect(testGuardSpan!.parent_span_id).toBe(exampleGuardSpan!.span_id); }); -test('API route transaction includes nest pipe span for valid request', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-pipe-instrumentation/:id' && - transactionEvent?.request?.url?.includes('/test-pipe-instrumentation/123') - ); - }); +test('API route trace includes nest pipe span for valid request', async ({ baseURL }) => { + // Both pipe specs hit the same route, so the segment name alone does not tell their traces apart. + const spansPromise = collectStreamedSpansUntilSegment( + APP_NAME, + span => + span.name === 'GET /test-pipe-instrumentation/:id' && + span.attributes['url.path']?.value === '/test-pipe-instrumentation/123', + ); const response = await fetch(`${baseURL}/test-pipe-instrumentation/123`); expect(response.status).toBe(200); - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.pipe', - }, - description: 'ParseIntPipe', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs.pipe', - }, - ]), + const spans = await spansPromise; + + expect(findSpan(spans, 'ParseIntPipe')).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.pipe' }, }), - ); + }); }); -test('API route transaction includes nest pipe span for invalid request', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-pipe-instrumentation/:id' && - transactionEvent?.request?.url?.includes('/test-pipe-instrumentation/abc') - ); - }); +test('API route trace includes nest pipe span for invalid request', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment( + APP_NAME, + span => + span.name === 'GET /test-pipe-instrumentation/:id' && + span.attributes['url.path']?.value === '/test-pipe-instrumentation/abc', + ); const response = await fetch(`${baseURL}/test-pipe-instrumentation/abc`); expect(response.status).toBe(400); - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.pipe', - }, - description: 'ParseIntPipe', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'internal_error', - op: 'middleware', - origin: 'auto.middleware.nestjs.pipe', - }, - ]), + const spans = await spansPromise; + + // Streamed spans only distinguish `ok` from `error`; the detailed status lives in + // `sentry.status.message`. + expect(findSpan(spans, 'ParseIntPipe')).toMatchObject({ + is_segment: false, + status: 'error', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.pipe' }, + 'sentry.status.message': { type: 'string', value: 'internal_error' }, }), - ); + }); }); -test('API route transaction includes nest interceptor spans before route execution. Spans created in and after interceptor are nested correctly', async ({ +test('API route trace includes nest interceptor spans before route execution. Spans created in and after interceptor are nested correctly', async ({ baseURL, }) => { - const pageloadTransactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-interceptor-instrumentation' - ); - }); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-interceptor-instrumentation'); const response = await fetch(`${baseURL}/test-interceptor-instrumentation`); expect(response.status).toBe(200); - const transactionEvent = await pageloadTransactionEventPromise; - - // check if interceptor spans before route execution exist - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.interceptor', - }, - description: 'ExampleInterceptor1', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs.interceptor', - }, - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.interceptor', - }, - description: 'ExampleInterceptor2', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs.interceptor', - }, - ]), - }), - ); + const spans = await spansPromise; - // get interceptor spans - const exampleInterceptor1Span = transactionEvent.spans.find(span => span.description === 'ExampleInterceptor1'); - const exampleInterceptor1SpanId = exampleInterceptor1Span?.span_id; - const exampleInterceptor2Span = transactionEvent.spans.find(span => span.description === 'ExampleInterceptor2'); - const exampleInterceptor2SpanId = exampleInterceptor2Span?.span_id; - - // check if manually started spans exist - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-controller-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-interceptor-span-1', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-interceptor-span-2', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - ]), - }), - ); + const exampleInterceptor1Span = findSpan(spans, 'ExampleInterceptor1'); + const exampleInterceptor2Span = findSpan(spans, 'ExampleInterceptor2'); - // verify correct span parent-child relationships - const testInterceptor1Span = transactionEvent.spans.find(span => span.description === 'test-interceptor-span-1'); - const testInterceptor2Span = transactionEvent.spans.find(span => span.description === 'test-interceptor-span-2'); - const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span'); + for (const interceptorSpan of [exampleInterceptor1Span, exampleInterceptor2Span]) { + expect(interceptorSpan).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, + }), + }); + } + + const testInterceptor1Span = findSpan(spans, 'test-interceptor-span-1'); + const testInterceptor2Span = findSpan(spans, 'test-interceptor-span-2'); + const testControllerSpan = findSpan(spans, 'test-controller-span'); + + for (const manualSpan of [testInterceptor1Span, testInterceptor2Span, testControllerSpan]) { + expect(manualSpan).toMatchObject({ is_segment: false, status: 'ok' }); + } // 'ExampleInterceptor1' is the parent of 'test-interceptor-span-1' - expect(testInterceptor1Span.parent_span_id).toBe(exampleInterceptor1SpanId); + expect(testInterceptor1Span!.parent_span_id).toBe(exampleInterceptor1Span!.span_id); // 'ExampleInterceptor1' is NOT the parent of 'test-controller-span' - expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptor1SpanId); + expect(testControllerSpan!.parent_span_id).not.toBe(exampleInterceptor1Span!.span_id); // 'ExampleInterceptor2' is the parent of 'test-interceptor-span-2' - expect(testInterceptor2Span.parent_span_id).toBe(exampleInterceptor2SpanId); + expect(testInterceptor2Span!.parent_span_id).toBe(exampleInterceptor2Span!.span_id); // 'ExampleInterceptor2' is NOT the parent of 'test-controller-span' - expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptor2SpanId); + expect(testControllerSpan!.parent_span_id).not.toBe(exampleInterceptor2Span!.span_id); }); -test('API route transaction includes exactly one nest interceptor span after route execution. Spans created in controller and in interceptor are nested correctly', async ({ +test('API route trace includes exactly one nest interceptor span after route execution. Spans created in controller and in interceptor are nested correctly', async ({ baseURL, }) => { - const pageloadTransactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-interceptor-instrumentation' - ); - }); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-interceptor-instrumentation'); const response = await fetch(`${baseURL}/test-interceptor-instrumentation`); expect(response.status).toBe(200); - const transactionEvent = await pageloadTransactionEventPromise; - - // check if interceptor spans after route execution exist - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.interceptor', - }, - description: 'Interceptors - After Route', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs.interceptor', - }, - ]), - }), - ); + const spans = await spansPromise; - // check that exactly one after route span is sent - const allInterceptorSpansAfterRoute = transactionEvent.spans.filter( - span => span.description === 'Interceptors - After Route', - ); - expect(allInterceptorSpansAfterRoute.length).toBe(1); + const interceptorSpansAfterRoute = spans.filter(span => span.name === 'Interceptors - After Route'); + expect(interceptorSpansAfterRoute).toHaveLength(1); - // get interceptor span - const exampleInterceptorSpanAfterRoute = transactionEvent.spans.find( - span => span.description === 'Interceptors - After Route', - ); - const exampleInterceptorSpanAfterRouteId = exampleInterceptorSpanAfterRoute?.span_id; - - // check if manually started span in interceptor after route exists - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-interceptor-span-after-route', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - ]), + const interceptorSpanAfterRoute = interceptorSpansAfterRoute[0]!; + expect(interceptorSpanAfterRoute).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, }), - ); + }); - // verify correct span parent-child relationships - const testInterceptorSpanAfterRoute = transactionEvent.spans.find( - span => span.description === 'test-interceptor-span-after-route', - ); - const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span'); + const testInterceptorSpanAfterRoute = findSpan(spans, 'test-interceptor-span-after-route'); + const testControllerSpan = findSpan(spans, 'test-controller-span'); + + expect(testInterceptorSpanAfterRoute).toMatchObject({ is_segment: false, status: 'ok' }); - // 'Interceptor - After Route' is the parent of 'test-interceptor-span-after-route' - expect(testInterceptorSpanAfterRoute.parent_span_id).toBe(exampleInterceptorSpanAfterRouteId); + // 'Interceptors - After Route' is the parent of 'test-interceptor-span-after-route' + expect(testInterceptorSpanAfterRoute!.parent_span_id).toBe(interceptorSpanAfterRoute.span_id); - // 'Interceptor - After Route' is NOT the parent of 'test-controller-span' - expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanAfterRouteId); + // 'Interceptors - After Route' is NOT the parent of 'test-controller-span' + expect(testControllerSpan!.parent_span_id).not.toBe(interceptorSpanAfterRoute.span_id); }); -test('API route transaction includes nest async interceptor spans before route execution. Spans created in and after async interceptor are nested correctly', async ({ +test('API route trace includes nest async interceptor spans before route execution. Spans created in and after async interceptor are nested correctly', async ({ baseURL, }) => { - const pageloadTransactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-async-interceptor-instrumentation' - ); - }); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-async-interceptor-instrumentation'); const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation`); expect(response.status).toBe(200); - const transactionEvent = await pageloadTransactionEventPromise; - - // check if interceptor spans before route execution exist - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.interceptor', - }, - description: 'AsyncInterceptor', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs.interceptor', - }, - ]), - }), - ); + const spans = await spansPromise; - // get interceptor spans - const exampleAsyncInterceptor = transactionEvent.spans.find(span => span.description === 'AsyncInterceptor'); - const exampleAsyncInterceptorSpanId = exampleAsyncInterceptor?.span_id; - - // check if manually started spans exist - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-controller-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-async-interceptor-span', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - ]), + const asyncInterceptorSpan = findSpan(spans, 'AsyncInterceptor'); + expect(asyncInterceptorSpan).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, }), - ); + }); - // verify correct span parent-child relationships - const testAsyncInterceptorSpan = transactionEvent.spans.find( - span => span.description === 'test-async-interceptor-span', - ); - const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span'); + const testAsyncInterceptorSpan = findSpan(spans, 'test-async-interceptor-span'); + const testControllerSpan = findSpan(spans, 'test-controller-span'); + + expect(testAsyncInterceptorSpan).toMatchObject({ is_segment: false, status: 'ok' }); + expect(testControllerSpan).toMatchObject({ is_segment: false, status: 'ok' }); // 'AsyncInterceptor' is the parent of 'test-async-interceptor-span' - expect(testAsyncInterceptorSpan.parent_span_id).toBe(exampleAsyncInterceptorSpanId); + expect(testAsyncInterceptorSpan!.parent_span_id).toBe(asyncInterceptorSpan!.span_id); // 'AsyncInterceptor' is NOT the parent of 'test-controller-span' - expect(testControllerSpan.parent_span_id).not.toBe(exampleAsyncInterceptorSpanId); + expect(testControllerSpan!.parent_span_id).not.toBe(asyncInterceptorSpan!.span_id); }); -test('API route transaction includes exactly one nest async interceptor span after route execution. Spans created in controller and in async interceptor are nested correctly', async ({ +test('API route trace includes exactly one nest async interceptor span after route execution. Spans created in controller and in async interceptor are nested correctly', async ({ baseURL, }) => { - const pageloadTransactionEventPromise = waitForTransaction('nestjs-12', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-async-interceptor-instrumentation' - ); - }); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-async-interceptor-instrumentation'); const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation`); expect(response.status).toBe(200); - const transactionEvent = await pageloadTransactionEventPromise; - - // check if interceptor spans after route execution exist - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.nestjs.interceptor', - }, - description: 'Interceptors - After Route', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - op: 'middleware', - origin: 'auto.middleware.nestjs.interceptor', - }, - ]), - }), - ); + const spans = await spansPromise; - // check that exactly one after route span is sent - const allInterceptorSpansAfterRoute = transactionEvent.spans.filter( - span => span.description === 'Interceptors - After Route', - ); - expect(allInterceptorSpansAfterRoute.length).toBe(1); + const interceptorSpansAfterRoute = spans.filter(span => span.name === 'Interceptors - After Route'); + expect(interceptorSpansAfterRoute).toHaveLength(1); - // get interceptor span - const exampleInterceptorSpanAfterRoute = transactionEvent.spans.find( - span => span.description === 'Interceptors - After Route', - ); - const exampleInterceptorSpanAfterRouteId = exampleInterceptorSpanAfterRoute?.span_id; - - // check if manually started span in interceptor after route exists - expect(transactionEvent).toEqual( - expect.objectContaining({ - spans: expect.arrayContaining([ - { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: expect.any(Object), - description: 'test-async-interceptor-span-after-route', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - status: 'ok', - origin: 'manual', - }, - ]), + const interceptorSpanAfterRoute = interceptorSpansAfterRoute[0]!; + expect(interceptorSpanAfterRoute).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, }), - ); + }); - // verify correct span parent-child relationships - const testInterceptorSpanAfterRoute = transactionEvent.spans.find( - span => span.description === 'test-async-interceptor-span-after-route', - ); - const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span'); + const testInterceptorSpanAfterRoute = findSpan(spans, 'test-async-interceptor-span-after-route'); + const testControllerSpan = findSpan(spans, 'test-controller-span'); + + expect(testInterceptorSpanAfterRoute).toMatchObject({ is_segment: false, status: 'ok' }); - // 'Interceptor - After Route' is the parent of 'test-interceptor-span-after-route' - expect(testInterceptorSpanAfterRoute.parent_span_id).toBe(exampleInterceptorSpanAfterRouteId); + // 'Interceptors - After Route' is the parent of 'test-async-interceptor-span-after-route' + expect(testInterceptorSpanAfterRoute!.parent_span_id).toBe(interceptorSpanAfterRoute.span_id); - // 'Interceptor - After Route' is NOT the parent of 'test-controller-span' - expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanAfterRouteId); + // 'Interceptors - After Route' is NOT the parent of 'test-controller-span' + expect(testControllerSpan!.parent_span_id).not.toBe(interceptorSpanAfterRoute.span_id); }); test('Calling use method on service with Injectable decorator returns 200', async ({ baseURL }) => { From 3dc1fa3ba373348ecbf034cd1bf34b2c1c01d818 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 10:05:46 +0200 Subject: [PATCH 2/5] test(e2e): Use the existing streamed-span helpers instead of a local wrapper The local `waitForSegmentSpan` only re-expressed what `collectStreamedSpansUntilSegment` and `waitForStreamedSpan` already do, and was copied into every ported app. Co-Authored-By: Claude Opus 5 --- .../nestjs-12/tests/errors.test.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/errors.test.ts index 6e20358979a8..04f2401ae268 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/errors.test.ts @@ -1,16 +1,8 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment, waitForError } from '@sentry-internal/test-utils'; const APP_NAME = 'nestjs-12'; -/** - * Resolves once the request's segment span has been streamed, which is how these specs know the - * request finished and any error it would have produced had its chance to be sent. - */ -function waitForSegmentSpan(name: string): Promise { - return waitForStreamedSpan(APP_NAME, span => span.is_segment && span.name === name); -} - test('Sends exception to Sentry', async ({ baseURL }) => { const errorEventPromise = waitForError(APP_NAME, event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; @@ -63,8 +55,10 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { return event?.transaction === 'GET /test-expected-500-exception/:id'; }); - const segmentSpanPromise400 = waitForSegmentSpan('GET /test-expected-400-exception/:id'); - const segmentSpanPromise500 = waitForSegmentSpan('GET /test-expected-500-exception/:id'); + // Waiting for each request's segment span is how this spec knows the request finished and + // any error it would have produced had its chance to be sent. + const spansPromise400 = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-expected-400-exception/:id'); + const spansPromise500 = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-expected-500-exception/:id'); const response400 = await fetch(`${baseURL}/test-expected-400-exception/123`); expect(response400.status).toBe(400); @@ -72,8 +66,8 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { const response500 = await fetch(`${baseURL}/test-expected-500-exception/123`); expect(response500.status).toBe(500); - await segmentSpanPromise400; - await segmentSpanPromise500; + await spansPromise400; + await spansPromise500; (await fetch(`${baseURL}/flush`)).text(); @@ -91,12 +85,12 @@ test('Does not send RpcExceptions to Sentry', async ({ baseURL }) => { return event?.transaction === 'GET /test-expected-rpc-exception/:id'; }); - const segmentSpanPromise = waitForSegmentSpan('GET /test-expected-rpc-exception/:id'); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-expected-rpc-exception/:id'); const response = await fetch(`${baseURL}/test-expected-rpc-exception/123`); expect(response.status).toBe(500); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -116,7 +110,7 @@ test('Global exception filter registered in main module is applied and exception return event?.transaction === 'GET /example-exception-global-filter'; }); - const segmentSpanPromise = waitForSegmentSpan('GET /example-exception-global-filter'); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-exception-global-filter'); const response = await fetch(`${baseURL}/example-exception-global-filter`); const responseBody = await response.json(); @@ -129,7 +123,7 @@ test('Global exception filter registered in main module is applied and exception message: 'Example exception was handled by global filter!', }); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -149,7 +143,7 @@ test('Local exception filter registered in main module is applied and exception return event?.transaction === 'GET /example-exception-local-filter'; }); - const segmentSpanPromise = waitForSegmentSpan('GET /example-exception-local-filter'); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-exception-local-filter'); const response = await fetch(`${baseURL}/example-exception-local-filter`); const responseBody = await response.json(); @@ -162,7 +156,7 @@ test('Local exception filter registered in main module is applied and exception message: 'Example exception was handled by local filter!', }); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); From 682d00844ec26834639482822de8b207dbdbcbe7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 8 Sep 2026 10:35:02 +0200 Subject: [PATCH 3/5] test(e2e): Assert streamed spans exhaustively with toEqual Switches the span assertions from `toMatchObject` to `toEqual` so an unexpected field or attribute fails the test, as the transaction-based specs did. Child spans carry a bounded attribute set, so they are matched exhaustively. The segment span also carries the scope contexts, SDK integrations and user IP, which vary by machine, so it keeps `objectContaining`. Co-Authored-By: Claude Opus 5 --- .../nestjs-12/tests/span-decorator.test.ts | 53 ++-- .../nestjs-12/tests/transactions.test.ts | 299 ++++++++++-------- 2 files changed, 198 insertions(+), 154 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts index bb2bd978c832..f4f89ee2c810 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts @@ -1,8 +1,35 @@ import { expect, test } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; const APP_NAME = 'nestjs-12'; +const SPAN_ID = /^[a-f0-9]{16}$/; + +/** The full shape of a `@SentryTraced` span, so `toEqual` catches anything unexpected. */ +function tracedSpan(segmentSpan: SerializedStreamedSpan, name: string, op: string): Record { + return { + name, + span_id: expect.stringMatching(SPAN_ID), + trace_id: segmentSpan.trace_id, + parent_span_id: expect.stringMatching(SPAN_ID), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + is_segment: false, + status: 'ok', + attributes: { + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + 'sentry.segment.name': { type: 'string', value: segmentSpan.name }, + 'sentry.segment.id': { type: 'string', value: segmentSpan.span_id }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.environment': { type: 'string', value: 'qa' }, + 'sentry.origin': { type: 'string', value: 'auto.function.nestjs.sentry_traced' }, + 'sentry.op': { type: 'string', value: op }, + }, + }; +} + test('Trace includes span and correct value for decorated async function', async ({ baseURL }) => { const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-span-decorator-async'); @@ -13,17 +40,8 @@ test('Trace includes span and correct value for decorated async function', async const spans = await spansPromise; - expect(spans).toContainEqual( - expect.objectContaining({ - name: 'wait', - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.origin': { type: 'string', value: 'auto.function.nestjs.sentry_traced' }, - 'sentry.op': { type: 'string', value: 'wait and return a string' }, - }), - }), - ); + const segmentSpan = spans.find(span => span.is_segment)!; + expect(spans.find(span => span.name === 'wait')).toEqual(tracedSpan(segmentSpan, 'wait', 'wait and return a string')); }); test('Trace includes span and correct value for decorated sync function', async ({ baseURL }) => { @@ -36,16 +54,9 @@ test('Trace includes span and correct value for decorated sync function', async const spans = await spansPromise; - expect(spans).toContainEqual( - expect.objectContaining({ - name: 'getString', - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.origin': { type: 'string', value: 'auto.function.nestjs.sentry_traced' }, - 'sentry.op': { type: 'string', value: 'return a string' }, - }), - }), + const segmentSpan = spans.find(span => span.is_segment)!; + expect(spans.find(span => span.name === 'getString')).toEqual( + tracedSpan(segmentSpan, 'getString', 'return a string'), ); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts index 2acba0789900..6e1176d498b7 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts @@ -4,10 +4,73 @@ import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; const APP_NAME = 'nestjs-12'; +const SPAN_ID = /^[a-f0-9]{16}$/; +const TRACE_ID = /^[a-f0-9]{32}$/; + function findSpan(spans: SerializedStreamedSpan[], name: string): SerializedStreamedSpan | undefined { return spans.find(span => span.name === name); } +/** + * The attributes span streaming puts on every span of a trace. Spelling them out is what lets the + * child span assertions below use `toEqual`, so an unexpected attribute fails the test. + */ +function commonAttributes(segmentSpan: SerializedStreamedSpan): Record { + return { + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + 'sentry.segment.name': { type: 'string', value: segmentSpan.name }, + 'sentry.segment.id': { type: 'string', value: segmentSpan.span_id }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.environment': { type: 'string', value: 'qa' }, + }; +} + +/** A manually started span, which carries nothing beyond the common attributes. */ +function manualSpan( + segmentSpan: SerializedStreamedSpan, + name: string, + parentSpanId: string | RegExp, +): Record { + return { + name, + span_id: expect.stringMatching(SPAN_ID), + trace_id: segmentSpan.trace_id, + parent_span_id: typeof parentSpanId === 'string' ? parentSpanId : expect.stringMatching(parentSpanId), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + is_segment: false, + status: 'ok', + attributes: { + ...commonAttributes(segmentSpan), + 'sentry.origin': { type: 'string', value: 'manual' }, + }, + }; +} + +/** A Nest middleware-family span (middleware, guard, pipe, interceptor, exception filter). */ +function nestMiddlewareSpan( + segmentSpan: SerializedStreamedSpan, + name: string, + origin: string, +): Record { + return { + name, + span_id: expect.stringMatching(SPAN_ID), + trace_id: segmentSpan.trace_id, + parent_span_id: expect.stringMatching(SPAN_ID), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + is_segment: false, + status: 'ok', + attributes: { + ...commonAttributes(segmentSpan), + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: origin }, + }, + }; +} + test('Sends streamed spans for an API route', async ({ baseURL }) => { const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-transaction'); @@ -16,11 +79,19 @@ test('Sends streamed spans for an API route', async ({ baseURL }) => { const spans = await spansPromise; const segmentSpan = spans.find(span => span.is_segment)!; - expect(segmentSpan).toMatchObject({ + // The segment span additionally carries the scope's contexts (os, device, runtime, culture), the + // SDK's integration list and the user's IP, all of which vary by machine, so only the + // request-specific attributes are pinned here. The child spans below are matched exhaustively. + expect(segmentSpan).toEqual({ name: 'GET /test-transaction', + span_id: expect.stringMatching(SPAN_ID), + trace_id: expect.stringMatching(TRACE_ID), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), is_segment: true, status: 'ok', attributes: expect.objectContaining({ + ...commonAttributes(segmentSpan), 'sentry.origin': { type: 'string', value: 'auto.http.http_server' }, 'sentry.op': { type: 'string', value: 'http.server' }, 'sentry.segment.name.source': { type: 'string', value: 'route' }, @@ -39,51 +110,51 @@ test('Sends streamed spans for an API route', async ({ baseURL }) => { }), }); - expect(findSpan(spans, '/test-transaction')).toMatchObject({ + expect(findSpan(spans, '/test-transaction')).toEqual({ + name: '/test-transaction', + span_id: expect.stringMatching(SPAN_ID), + trace_id: segmentSpan.trace_id, + parent_span_id: segmentSpan.span_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), is_segment: false, status: 'ok', - parent_span_id: segmentSpan.span_id, - attributes: expect.objectContaining({ + attributes: { + ...commonAttributes(segmentSpan), 'sentry.op': { type: 'string', value: 'handler' }, 'sentry.origin': { type: 'string', value: 'auto.http.express' }, 'express.name': { type: 'string', value: '/test-transaction' }, 'express.type': { type: 'string', value: 'request_handler' }, 'http.route': { type: 'string', value: '/test-transaction' }, - }), + }, }); // The Nest handler span carries the callback name as an attribute rather than in its name, which // stays low cardinality under span streaming. - expect(findSpan(spans, 'Request handler')).toMatchObject({ + const nestHandlerSpan = findSpan(spans, 'Request handler'); + expect(nestHandlerSpan).toEqual({ + name: 'Request handler', + span_id: expect.stringMatching(SPAN_ID), + trace_id: segmentSpan.trace_id, + parent_span_id: expect.stringMatching(SPAN_ID), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), is_segment: false, status: 'ok', - attributes: expect.objectContaining({ + attributes: { + ...commonAttributes(segmentSpan), 'sentry.op': { type: 'string', value: 'handler' }, 'sentry.origin': { type: 'string', value: 'auto.http.nestjs' }, component: { type: 'string', value: '@nestjs/core' }, 'nestjs.type': { type: 'string', value: 'handler' }, 'nestjs.callback': { type: 'string', value: 'testTransaction' }, 'nestjs.version': { type: 'string', value: expect.any(String) }, - }), + }, }); const testSpan = findSpan(spans, 'test-span'); - expect(testSpan).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ 'sentry.origin': { type: 'string', value: 'manual' } }), - }); - - expect(findSpan(spans, 'child-span')).toMatchObject({ - is_segment: false, - status: 'ok', - parent_span_id: testSpan!.span_id, - attributes: expect.objectContaining({ 'sentry.origin': { type: 'string', value: 'manual' } }), - }); - - for (const span of spans) { - expect(span.trace_id).toBe(segmentSpan.trace_id); - } + expect(testSpan).toEqual(manualSpan(segmentSpan, 'test-span', nestHandlerSpan!.span_id)); + expect(findSpan(spans, 'child-span')).toEqual(manualSpan(segmentSpan, 'child-span', testSpan!.span_id)); }); test('API route trace includes nest middleware span. Spans created in and after middleware are nested correctly', async ({ @@ -95,25 +166,18 @@ test('API route trace includes nest middleware span. Spans created in and after expect(response.status).toBe(200); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; const exampleMiddlewareSpan = findSpan(spans, 'ExampleMiddleware'); - expect(exampleMiddlewareSpan).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs' }, - }), - }); - - const testMiddlewareSpan = findSpan(spans, 'test-middleware-span'); - const testControllerSpan = findSpan(spans, 'test-controller-span'); - - expect(testMiddlewareSpan).toMatchObject({ is_segment: false, status: 'ok' }); - expect(testControllerSpan).toMatchObject({ is_segment: false, status: 'ok' }); + expect(exampleMiddlewareSpan).toEqual(nestMiddlewareSpan(segmentSpan, 'ExampleMiddleware', 'auto.middleware.nestjs')); // 'ExampleMiddleware' is the parent of 'test-middleware-span' - expect(testMiddlewareSpan!.parent_span_id).toBe(exampleMiddlewareSpan!.span_id); + expect(findSpan(spans, 'test-middleware-span')).toEqual( + manualSpan(segmentSpan, 'test-middleware-span', exampleMiddlewareSpan!.span_id), + ); + + const testControllerSpan = findSpan(spans, 'test-controller-span'); + expect(testControllerSpan).toEqual(manualSpan(segmentSpan, 'test-controller-span', SPAN_ID)); // 'ExampleMiddleware' is NOT the parent of 'test-controller-span' expect(testControllerSpan!.parent_span_id).not.toBe(exampleMiddlewareSpan!.span_id); @@ -126,22 +190,15 @@ test('API route trace includes nest guard span and span started in guard is nest expect(response.status).toBe(200); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; const exampleGuardSpan = findSpan(spans, 'ExampleGuard'); - expect(exampleGuardSpan).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.guard' }, - }), - }); - - const testGuardSpan = findSpan(spans, 'test-guard-span'); - expect(testGuardSpan).toMatchObject({ is_segment: false, status: 'ok' }); + expect(exampleGuardSpan).toEqual(nestMiddlewareSpan(segmentSpan, 'ExampleGuard', 'auto.middleware.nestjs.guard')); // 'ExampleGuard' is the parent of 'test-guard-span' - expect(testGuardSpan!.parent_span_id).toBe(exampleGuardSpan!.span_id); + expect(findSpan(spans, 'test-guard-span')).toEqual( + manualSpan(segmentSpan, 'test-guard-span', exampleGuardSpan!.span_id), + ); }); test('API route trace includes nest pipe span for valid request', async ({ baseURL }) => { @@ -157,15 +214,11 @@ test('API route trace includes nest pipe span for valid request', async ({ baseU expect(response.status).toBe(200); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; - expect(findSpan(spans, 'ParseIntPipe')).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.pipe' }, - }), - }); + expect(findSpan(spans, 'ParseIntPipe')).toEqual( + nestMiddlewareSpan(segmentSpan, 'ParseIntPipe', 'auto.middleware.nestjs.pipe'), + ); }); test('API route trace includes nest pipe span for invalid request', async ({ baseURL }) => { @@ -180,17 +233,25 @@ test('API route trace includes nest pipe span for invalid request', async ({ bas expect(response.status).toBe(400); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; // Streamed spans only distinguish `ok` from `error`; the detailed status lives in // `sentry.status.message`. - expect(findSpan(spans, 'ParseIntPipe')).toMatchObject({ + expect(findSpan(spans, 'ParseIntPipe')).toEqual({ + name: 'ParseIntPipe', + span_id: expect.stringMatching(SPAN_ID), + trace_id: segmentSpan.trace_id, + parent_span_id: expect.stringMatching(SPAN_ID), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), is_segment: false, status: 'error', - attributes: expect.objectContaining({ + attributes: { + ...commonAttributes(segmentSpan), 'sentry.op': { type: 'string', value: 'middleware' }, 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.pipe' }, 'sentry.status.message': { type: 'string', value: 'internal_error' }, - }), + }, }); }); @@ -203,39 +264,30 @@ test('API route trace includes nest interceptor spans before route execution. Sp expect(response.status).toBe(200); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; const exampleInterceptor1Span = findSpan(spans, 'ExampleInterceptor1'); const exampleInterceptor2Span = findSpan(spans, 'ExampleInterceptor2'); + expect(exampleInterceptor1Span).toEqual( + nestMiddlewareSpan(segmentSpan, 'ExampleInterceptor1', 'auto.middleware.nestjs.interceptor'), + ); + expect(exampleInterceptor2Span).toEqual( + nestMiddlewareSpan(segmentSpan, 'ExampleInterceptor2', 'auto.middleware.nestjs.interceptor'), + ); - for (const interceptorSpan of [exampleInterceptor1Span, exampleInterceptor2Span]) { - expect(interceptorSpan).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, - }), - }); - } - - const testInterceptor1Span = findSpan(spans, 'test-interceptor-span-1'); - const testInterceptor2Span = findSpan(spans, 'test-interceptor-span-2'); - const testControllerSpan = findSpan(spans, 'test-controller-span'); - - for (const manualSpan of [testInterceptor1Span, testInterceptor2Span, testControllerSpan]) { - expect(manualSpan).toMatchObject({ is_segment: false, status: 'ok' }); - } + // Each interceptor is the parent of the span started inside it + expect(findSpan(spans, 'test-interceptor-span-1')).toEqual( + manualSpan(segmentSpan, 'test-interceptor-span-1', exampleInterceptor1Span!.span_id), + ); + expect(findSpan(spans, 'test-interceptor-span-2')).toEqual( + manualSpan(segmentSpan, 'test-interceptor-span-2', exampleInterceptor2Span!.span_id), + ); - // 'ExampleInterceptor1' is the parent of 'test-interceptor-span-1' - expect(testInterceptor1Span!.parent_span_id).toBe(exampleInterceptor1Span!.span_id); + const testControllerSpan = findSpan(spans, 'test-controller-span'); + expect(testControllerSpan).toEqual(manualSpan(segmentSpan, 'test-controller-span', SPAN_ID)); - // 'ExampleInterceptor1' is NOT the parent of 'test-controller-span' + // Neither interceptor is the parent of 'test-controller-span' expect(testControllerSpan!.parent_span_id).not.toBe(exampleInterceptor1Span!.span_id); - - // 'ExampleInterceptor2' is the parent of 'test-interceptor-span-2' - expect(testInterceptor2Span!.parent_span_id).toBe(exampleInterceptor2Span!.span_id); - - // 'ExampleInterceptor2' is NOT the parent of 'test-controller-span' expect(testControllerSpan!.parent_span_id).not.toBe(exampleInterceptor2Span!.span_id); }); @@ -248,30 +300,23 @@ test('API route trace includes exactly one nest interceptor span after route exe expect(response.status).toBe(200); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; const interceptorSpansAfterRoute = spans.filter(span => span.name === 'Interceptors - After Route'); expect(interceptorSpansAfterRoute).toHaveLength(1); const interceptorSpanAfterRoute = interceptorSpansAfterRoute[0]!; - expect(interceptorSpanAfterRoute).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, - }), - }); - - const testInterceptorSpanAfterRoute = findSpan(spans, 'test-interceptor-span-after-route'); - const testControllerSpan = findSpan(spans, 'test-controller-span'); - - expect(testInterceptorSpanAfterRoute).toMatchObject({ is_segment: false, status: 'ok' }); + expect(interceptorSpanAfterRoute).toEqual( + nestMiddlewareSpan(segmentSpan, 'Interceptors - After Route', 'auto.middleware.nestjs.interceptor'), + ); // 'Interceptors - After Route' is the parent of 'test-interceptor-span-after-route' - expect(testInterceptorSpanAfterRoute!.parent_span_id).toBe(interceptorSpanAfterRoute.span_id); + expect(findSpan(spans, 'test-interceptor-span-after-route')).toEqual( + manualSpan(segmentSpan, 'test-interceptor-span-after-route', interceptorSpanAfterRoute.span_id), + ); // 'Interceptors - After Route' is NOT the parent of 'test-controller-span' - expect(testControllerSpan!.parent_span_id).not.toBe(interceptorSpanAfterRoute.span_id); + expect(findSpan(spans, 'test-controller-span')!.parent_span_id).not.toBe(interceptorSpanAfterRoute.span_id); }); test('API route trace includes nest async interceptor spans before route execution. Spans created in and after async interceptor are nested correctly', async ({ @@ -283,25 +328,20 @@ test('API route trace includes nest async interceptor spans before route executi expect(response.status).toBe(200); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; const asyncInterceptorSpan = findSpan(spans, 'AsyncInterceptor'); - expect(asyncInterceptorSpan).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, - }), - }); - - const testAsyncInterceptorSpan = findSpan(spans, 'test-async-interceptor-span'); - const testControllerSpan = findSpan(spans, 'test-controller-span'); - - expect(testAsyncInterceptorSpan).toMatchObject({ is_segment: false, status: 'ok' }); - expect(testControllerSpan).toMatchObject({ is_segment: false, status: 'ok' }); + expect(asyncInterceptorSpan).toEqual( + nestMiddlewareSpan(segmentSpan, 'AsyncInterceptor', 'auto.middleware.nestjs.interceptor'), + ); // 'AsyncInterceptor' is the parent of 'test-async-interceptor-span' - expect(testAsyncInterceptorSpan!.parent_span_id).toBe(asyncInterceptorSpan!.span_id); + expect(findSpan(spans, 'test-async-interceptor-span')).toEqual( + manualSpan(segmentSpan, 'test-async-interceptor-span', asyncInterceptorSpan!.span_id), + ); + + const testControllerSpan = findSpan(spans, 'test-controller-span'); + expect(testControllerSpan).toEqual(manualSpan(segmentSpan, 'test-controller-span', SPAN_ID)); // 'AsyncInterceptor' is NOT the parent of 'test-controller-span' expect(testControllerSpan!.parent_span_id).not.toBe(asyncInterceptorSpan!.span_id); @@ -316,30 +356,23 @@ test('API route trace includes exactly one nest async interceptor span after rou expect(response.status).toBe(200); const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; const interceptorSpansAfterRoute = spans.filter(span => span.name === 'Interceptors - After Route'); expect(interceptorSpansAfterRoute).toHaveLength(1); const interceptorSpanAfterRoute = interceptorSpansAfterRoute[0]!; - expect(interceptorSpanAfterRoute).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.interceptor' }, - }), - }); - - const testInterceptorSpanAfterRoute = findSpan(spans, 'test-async-interceptor-span-after-route'); - const testControllerSpan = findSpan(spans, 'test-controller-span'); - - expect(testInterceptorSpanAfterRoute).toMatchObject({ is_segment: false, status: 'ok' }); + expect(interceptorSpanAfterRoute).toEqual( + nestMiddlewareSpan(segmentSpan, 'Interceptors - After Route', 'auto.middleware.nestjs.interceptor'), + ); // 'Interceptors - After Route' is the parent of 'test-async-interceptor-span-after-route' - expect(testInterceptorSpanAfterRoute!.parent_span_id).toBe(interceptorSpanAfterRoute.span_id); + expect(findSpan(spans, 'test-async-interceptor-span-after-route')).toEqual( + manualSpan(segmentSpan, 'test-async-interceptor-span-after-route', interceptorSpanAfterRoute.span_id), + ); // 'Interceptors - After Route' is NOT the parent of 'test-controller-span' - expect(testControllerSpan!.parent_span_id).not.toBe(interceptorSpanAfterRoute.span_id); + expect(findSpan(spans, 'test-controller-span')!.parent_span_id).not.toBe(interceptorSpanAfterRoute.span_id); }); test('Calling use method on service with Injectable decorator returns 200', async ({ baseURL }) => { From 796df8ee4e159378a12865c0c8515c89f89e2bd8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 8 Sep 2026 11:22:38 +0200 Subject: [PATCH 4/5] test(e2e): Tolerate the release attribute CI sets on streamed spans CI builds the apps with a release, so every span carries `sentry.release` there and none locally, which the exhaustive attribute matching tripped over. Co-Authored-By: Claude Opus 5 --- .../test-applications/nestjs-12/tests/span-decorator.test.ts | 5 +++++ .../test-applications/nestjs-12/tests/transactions.test.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts index f4f89ee2c810..7a15277dce3a 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts @@ -24,6 +24,11 @@ function tracedSpan(segmentSpan: SerializedStreamedSpan, name: string, op: strin 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, 'sentry.environment': { type: 'string', value: 'qa' }, + // CI builds the apps with a release, local runs have none. It comes from the client + // options, so whatever the segment span got, every other span of the trace got too. + ...(segmentSpan.attributes['sentry.release'] + ? { 'sentry.release': { type: 'string', value: expect.any(String) } } + : {}), 'sentry.origin': { type: 'string', value: 'auto.function.nestjs.sentry_traced' }, 'sentry.op': { type: 'string', value: op }, }, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts index 6e1176d498b7..de983d9c7ded 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts @@ -23,6 +23,11 @@ function commonAttributes(segmentSpan: SerializedStreamedSpan): Record Date: Tue, 8 Sep 2026 13:03:55 +0200 Subject: [PATCH 5/5] test(e2e): Rename the nestjs-12 specs to spans.test.ts and mark interceptor requests The file asserts on streamed spans, not transaction events. Both specs of each interceptor route also produced a trace with the same segment name, so each request now carries a `spec` query marker to tell its own trace apart. Co-Authored-By: Claude Opus 5 --- .../{transactions.test.ts => spans.test.ts} | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) rename dev-packages/e2e-tests/test-applications/nestjs-12/tests/{transactions.test.ts => spans.test.ts} (93%) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/spans.test.ts similarity index 93% rename from dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/nestjs-12/tests/spans.test.ts index de983d9c7ded..1285ebc399fe 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/spans.test.ts @@ -76,6 +76,18 @@ function nestMiddlewareSpan( }; } +/** + * Two specs hit each interceptor route, and `collectStreamedSpans` resolves with any trace that + * satisfies the predicate - including one left over from the spec before. Each request therefore + * carries a marker that tells its own trace apart. `url.query` keeps it out of the span name. + */ +function collectSpansOfRequest(segmentName: string, spec: string): Promise { + return collectStreamedSpansUntilSegment( + APP_NAME, + span => span.name === segmentName && span.attributes['url.query']?.value === `spec=${spec}`, + ); +} + test('Sends streamed spans for an API route', async ({ baseURL }) => { const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-transaction'); @@ -263,9 +275,9 @@ test('API route trace includes nest pipe span for invalid request', async ({ bas test('API route trace includes nest interceptor spans before route execution. Spans created in and after interceptor are nested correctly', async ({ baseURL, }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-interceptor-instrumentation'); + const spansPromise = collectSpansOfRequest('GET /test-interceptor-instrumentation', 'interceptor-before-route'); - const response = await fetch(`${baseURL}/test-interceptor-instrumentation`); + const response = await fetch(`${baseURL}/test-interceptor-instrumentation?spec=interceptor-before-route`); expect(response.status).toBe(200); const spans = await spansPromise; @@ -299,9 +311,9 @@ test('API route trace includes nest interceptor spans before route execution. Sp test('API route trace includes exactly one nest interceptor span after route execution. Spans created in controller and in interceptor are nested correctly', async ({ baseURL, }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-interceptor-instrumentation'); + const spansPromise = collectSpansOfRequest('GET /test-interceptor-instrumentation', 'interceptor-after-route'); - const response = await fetch(`${baseURL}/test-interceptor-instrumentation`); + const response = await fetch(`${baseURL}/test-interceptor-instrumentation?spec=interceptor-after-route`); expect(response.status).toBe(200); const spans = await spansPromise; @@ -327,9 +339,12 @@ test('API route trace includes exactly one nest interceptor span after route exe test('API route trace includes nest async interceptor spans before route execution. Spans created in and after async interceptor are nested correctly', async ({ baseURL, }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-async-interceptor-instrumentation'); + const spansPromise = collectSpansOfRequest( + 'GET /test-async-interceptor-instrumentation', + 'async-interceptor-before-route', + ); - const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation`); + const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation?spec=async-interceptor-before-route`); expect(response.status).toBe(200); const spans = await spansPromise; @@ -355,9 +370,12 @@ test('API route trace includes nest async interceptor spans before route executi test('API route trace includes exactly one nest async interceptor span after route execution. Spans created in controller and in async interceptor are nested correctly', async ({ baseURL, }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-async-interceptor-instrumentation'); + const spansPromise = collectSpansOfRequest( + 'GET /test-async-interceptor-instrumentation', + 'async-interceptor-after-route', + ); - const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation`); + const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation?spec=async-interceptor-after-route`); expect(response.status).toBe(200); const spans = await spansPromise;