From ac2f64346a490011ff4341e0e88c93046b907705 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 4 Sep 2026 15:25:40 +0200 Subject: [PATCH 1/7] test(e2e): Port the nestjs-with-submodules 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-with-submodules/src/instrument.ts | 1 - .../tests/errors.test.ts | 58 +-- .../tests/transactions.test.ts | 329 +++++++----------- 3 files changed, 146 insertions(+), 242 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/instrument.ts index aa4c76f13ee5..4f16ebb36d11 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/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-with-submodules/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts index eb3a3d809417..ee01919a38e7 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/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-with-submodules'; + +/** + * 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 unexpected exception to Sentry if thrown in module with global filter', async ({ baseURL }) => { - const errorEventPromise = waitForError('nestjs-with-submodules', event => { + const errorEventPromise = waitForError(APP_NAME, event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an uncaught exception!'; }); @@ -36,7 +46,7 @@ test('Sends unexpected exception to Sentry if thrown in module with global filte }); test('Sends unexpected exception to Sentry if thrown in module with local filter', async ({ baseURL }) => { - const errorEventPromise = waitForError('nestjs-with-submodules', event => { + const errorEventPromise = waitForError(APP_NAME, event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an uncaught exception!'; }); @@ -72,7 +82,7 @@ test('Sends unexpected exception to Sentry if thrown in module with local filter test('Sends unexpected exception to Sentry if thrown in module that was registered before Sentry', async ({ baseURL, }) => { - const errorEventPromise = waitForError('nestjs-with-submodules', event => { + const errorEventPromise = waitForError(APP_NAME, event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an uncaught exception!'; }); @@ -110,7 +120,7 @@ test('Does not send exception to Sentry if user-defined global exception filter }) => { let errorEventOccurred = false; - waitForError('nestjs-with-submodules', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'Something went wrong in the example module!') { errorEventOccurred = true; } @@ -118,14 +128,12 @@ test('Does not send exception to Sentry if user-defined global exception filter return event?.transaction === 'GET /example-module/expected-exception'; }); - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return transactionEvent?.transaction === 'GET /example-module/expected-exception'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /example-module/expected-exception'); const response = await fetch(`${baseURL}/example-module/expected-exception`); expect(response.status).toBe(400); - await transactionEventPromise; + await segmentSpanPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -137,7 +145,7 @@ test('Does not send exception to Sentry if user-defined local exception filter a }) => { let errorEventOccurred = false; - waitForError('nestjs-with-submodules', event => { + waitForError(APP_NAME, event => { if ( !event.type && event.exception?.values?.[0]?.value === 'Something went wrong in the example module with local filter!' @@ -148,14 +156,12 @@ test('Does not send exception to Sentry if user-defined local exception filter a return event?.transaction === 'GET /example-module-local-filter/expected-exception'; }); - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return transactionEvent?.transaction === 'GET /example-module-local-filter/expected-exception'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /example-module-local-filter/expected-exception'); const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`); expect(response.status).toBe(400); - await transactionEventPromise; + await segmentSpanPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -167,7 +173,7 @@ test('Does not send expected exception to Sentry if exception is thrown in modul }) => { let errorEventOccurred = false; - waitForError('nestjs-with-submodules', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0].value === 'Something went wrong in the example module!') { errorEventOccurred = true; } @@ -175,14 +181,12 @@ test('Does not send expected exception to Sentry if exception is thrown in modul return event?.transaction === 'GET /example-module-registered-first/expected-exception'; }); - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return transactionEvent?.transaction === 'GET /example-module-registered-first/expected-exception'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /example-module-registered-first/expected-exception'); const response = await fetch(`${baseURL}/example-module-registered-first/expected-exception`); expect(response.status).toBe(400); - await transactionEventPromise; + await segmentSpanPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -194,7 +198,7 @@ test('Global specific exception filter registered in main module is applied and }) => { let errorEventOccurred = false; - waitForError('nestjs-with-submodules', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by specific filter!') { errorEventOccurred = true; } @@ -202,9 +206,7 @@ test('Global specific exception filter registered in main module is applied and return event?.transaction === 'GET /example-exception-specific-filter'; }); - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return transactionEvent?.transaction === 'GET /example-exception-specific-filter'; - }); + const segmentSpanPromise = waitForSegmentSpan('GET /example-exception-specific-filter'); const response = await fetch(`${baseURL}/example-exception-specific-filter`); const responseBody = await response.json(); @@ -217,7 +219,7 @@ test('Global specific exception filter registered in main module is applied and message: 'Example exception was handled by specific filter!', }); - await transactionEventPromise; + await segmentSpanPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -229,7 +231,7 @@ test('Local specific exception filter registered in main module is applied and e }) => { let errorEventOccurred = false; - waitForError('nestjs-with-submodules', event => { + waitForError(APP_NAME, event => { if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by local filter!') { errorEventOccurred = true; } @@ -237,9 +239,7 @@ test('Local specific exception filter registered in main module is applied and e return event?.transaction === 'GET /example-exception-local-filter'; }); - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', 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(); @@ -252,7 +252,7 @@ test('Local specific exception filter registered in main module is applied and e 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-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts index c5829a9425bc..c9ced0535ae5 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts @@ -1,248 +1,153 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Sends an API route transaction from module', async ({ baseURL }) => { - const pageloadTransactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /example-module/transaction' - ); - }); +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +const APP_NAME = 'nestjs-with-submodules'; + +function findSpan(spans: SerializedStreamedSpan[], name: string): SerializedStreamedSpan | undefined { + return spans.find(span => span.name === name); +} + +test('Sends streamed spans for an API route from module', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module/transaction'); await fetch(`${baseURL}/example-module/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/example-module/transaction', - 'url.path': '/example-module/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': '/example-module/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 /example-module/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: '/example-module/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/example-module/transaction' }, + 'url.path': { type: 'string', value: '/example-module/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': '/example-module/transaction', - 'express.type': 'request_handler', - 'http.route': '/example-module/transaction', - 'sentry.origin': 'auto.http.express', - 'sentry.op': 'handler', - }, - op: 'handler', - description: '/example-module/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 /example-module/transaction', - type: 'transaction', - transaction_info: { - source: 'route', - }, + expect(findSpan(spans, '/example-module/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: '/example-module/transaction' }, + 'express.type': { type: 'string', value: 'request_handler' }, + 'http.route': { type: 'string', value: '/example-module/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 exception filter span for global filter in module registered after Sentry', async ({ +test('API route trace includes exception filter span for global filter in module registered after Sentry', async ({ baseURL, }) => { - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /example-module/expected-exception' && - transactionEvent?.request?.url?.includes('/example-module/expected-exception') - ); - }); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module/expected-exception'); const response = await fetch(`${baseURL}/example-module/expected-exception`); 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.exception_filter', - }, - description: 'ExampleExceptionFilter', - 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.exception_filter', - }, - ]), + const spans = await spansPromise; + + expect(findSpan(spans, 'ExampleExceptionFilter')).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.exception_filter' }, }), - ); + }); }); -test('API route transaction includes exception filter span for local filter in module registered after Sentry', async ({ +test('API route trace includes exception filter span for local filter in module registered after Sentry', async ({ baseURL, }) => { - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /example-module-local-filter/expected-exception' && - transactionEvent?.request?.url?.includes('/example-module-local-filter/expected-exception') - ); - }); + const spansPromise = collectStreamedSpansUntilSegment( + APP_NAME, + 'GET /example-module-local-filter/expected-exception', + ); const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`); 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.exception_filter', - }, - description: 'LocalExampleExceptionFilter', - 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.exception_filter', - }, - ]), + const spans = await spansPromise; + + expect(findSpan(spans, 'LocalExampleExceptionFilter')).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.exception_filter' }, }), - ); + }); }); -test('API route transaction includes exception filter span for global filter in module registered before Sentry', async ({ +test('API route trace includes exception filter span for global filter in module registered before Sentry', async ({ baseURL, }) => { - const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /example-module-registered-first/expected-exception' && - transactionEvent?.request?.url?.includes('/example-module-registered-first/expected-exception') - ); - }); + const spansPromise = collectStreamedSpansUntilSegment( + APP_NAME, + 'GET /example-module-registered-first/expected-exception', + ); const response = await fetch(`${baseURL}/example-module-registered-first/expected-exception`); 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.exception_filter', - }, - description: 'ExampleExceptionFilterRegisteredFirst', - 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.exception_filter', - }, - ]), + const spans = await spansPromise; + + expect(findSpan(spans, 'ExampleExceptionFilterRegisteredFirst')).toMatchObject({ + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.exception_filter' }, }), - ); + }); }); From 6fdbf8666be11317d4518260588edac0987aadf7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 10:05:47 +0200 Subject: [PATCH 2/7] 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 --- .../tests/errors.test.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts index ee01919a38e7..4fdc7aaf1649 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/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-with-submodules'; -/** - * 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 unexpected exception to Sentry if thrown in module with global filter', async ({ baseURL }) => { const errorEventPromise = waitForError(APP_NAME, event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an uncaught exception!'; @@ -128,12 +120,14 @@ test('Does not send exception to Sentry if user-defined global exception filter return event?.transaction === 'GET /example-module/expected-exception'; }); - const segmentSpanPromise = waitForSegmentSpan('GET /example-module/expected-exception'); + // 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 spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module/expected-exception'); const response = await fetch(`${baseURL}/example-module/expected-exception`); expect(response.status).toBe(400); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -156,12 +150,12 @@ test('Does not send exception to Sentry if user-defined local exception filter a return event?.transaction === 'GET /example-module-local-filter/expected-exception'; }); - const segmentSpanPromise = waitForSegmentSpan('GET /example-module-local-filter/expected-exception'); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module-local-filter/expected-exception'); const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`); expect(response.status).toBe(400); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -181,12 +175,12 @@ test('Does not send expected exception to Sentry if exception is thrown in modul return event?.transaction === 'GET /example-module-registered-first/expected-exception'; }); - const segmentSpanPromise = waitForSegmentSpan('GET /example-module-registered-first/expected-exception'); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module-registered-first/expected-exception'); const response = await fetch(`${baseURL}/example-module-registered-first/expected-exception`); expect(response.status).toBe(400); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -206,7 +200,7 @@ test('Global specific exception filter registered in main module is applied and return event?.transaction === 'GET /example-exception-specific-filter'; }); - const segmentSpanPromise = waitForSegmentSpan('GET /example-exception-specific-filter'); + const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-exception-specific-filter'); const response = await fetch(`${baseURL}/example-exception-specific-filter`); const responseBody = await response.json(); @@ -219,7 +213,7 @@ test('Global specific exception filter registered in main module is applied and message: 'Example exception was handled by specific filter!', }); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); @@ -239,7 +233,7 @@ test('Local specific exception filter registered in main module is applied and e 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(); @@ -252,7 +246,7 @@ test('Local specific exception filter registered in main module is applied and e message: 'Example exception was handled by local filter!', }); - await segmentSpanPromise; + await spansPromise; (await fetch(`${baseURL}/flush`)).text(); From fbe0c5ca7933cac1b26dd82e79c3ec4891dd1d75 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 10:36:19 +0200 Subject: [PATCH 3/7] test(e2e): Format Co-Authored-By: Claude Opus 5 --- .../nestjs-with-submodules/tests/errors.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts index 4fdc7aaf1649..34a2e9a909e6 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts @@ -150,7 +150,10 @@ test('Does not send exception to Sentry if user-defined local exception filter a return event?.transaction === 'GET /example-module-local-filter/expected-exception'; }); - const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module-local-filter/expected-exception'); + const spansPromise = collectStreamedSpansUntilSegment( + APP_NAME, + 'GET /example-module-local-filter/expected-exception', + ); const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`); expect(response.status).toBe(400); @@ -175,7 +178,10 @@ test('Does not send expected exception to Sentry if exception is thrown in modul return event?.transaction === 'GET /example-module-registered-first/expected-exception'; }); - const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module-registered-first/expected-exception'); + const spansPromise = collectStreamedSpansUntilSegment( + APP_NAME, + 'GET /example-module-registered-first/expected-exception', + ); const response = await fetch(`${baseURL}/example-module-registered-first/expected-exception`); expect(response.status).toBe(400); From cbd46500c8153be9f044a186374a13f3ae0b9c87 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 8 Sep 2026 10:36:31 +0200 Subject: [PATCH 4/7] 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. Co-Authored-By: Claude Opus 5 --- .../tests/transactions.test.ts | 145 ++++++++++++------ 1 file changed, 97 insertions(+), 48 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts index c9ced0535ae5..5e5bffe7ab06 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts @@ -4,10 +4,65 @@ import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; const APP_NAME = 'nestjs-with-submodules'; +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): Record { + return { + name, + span_id: expect.stringMatching(SPAN_ID), + trace_id: segmentSpan.trace_id, + parent_span_id: 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' }, + }, + }; +} + +/** An exception filter span, which the specs below assert on by name. */ +function exceptionFilterSpan(segmentSpan: SerializedStreamedSpan, name: 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: 'auto.middleware.nestjs.exception_filter' }, + }, + }; +} + test('Sends streamed spans for an API route from module', async ({ baseURL }) => { const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-module/transaction'); @@ -16,11 +71,19 @@ test('Sends streamed spans for an API route from module', 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 /example-module/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 +102,51 @@ test('Sends streamed spans for an API route from module', async ({ baseURL }) => }), }); - expect(findSpan(spans, '/example-module/transaction')).toMatchObject({ + expect(findSpan(spans, '/example-module/transaction')).toEqual({ + name: '/example-module/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: '/example-module/transaction' }, 'express.type': { type: 'string', value: 'request_handler' }, 'http.route': { type: 'string', value: '/example-module/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 exception filter span for global filter in module registered after Sentry', async ({ @@ -96,14 +159,8 @@ test('API route trace includes exception filter span for global filter in module const spans = await spansPromise; - expect(findSpan(spans, 'ExampleExceptionFilter')).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.exception_filter' }, - }), - }); + const segmentSpan = spans.find(span => span.is_segment)!; + expect(findSpan(spans, 'ExampleExceptionFilter')).toEqual(exceptionFilterSpan(segmentSpan, 'ExampleExceptionFilter')); }); test('API route trace includes exception filter span for local filter in module registered after Sentry', async ({ @@ -119,14 +176,10 @@ test('API route trace includes exception filter span for local filter in module const spans = await spansPromise; - expect(findSpan(spans, 'LocalExampleExceptionFilter')).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.exception_filter' }, - }), - }); + const segmentSpan = spans.find(span => span.is_segment)!; + expect(findSpan(spans, 'LocalExampleExceptionFilter')).toEqual( + exceptionFilterSpan(segmentSpan, 'LocalExampleExceptionFilter'), + ); }); test('API route trace includes exception filter span for global filter in module registered before Sentry', async ({ @@ -142,12 +195,8 @@ test('API route trace includes exception filter span for global filter in module const spans = await spansPromise; - expect(findSpan(spans, 'ExampleExceptionFilterRegisteredFirst')).toMatchObject({ - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: 'auto.middleware.nestjs.exception_filter' }, - }), - }); + const segmentSpan = spans.find(span => span.is_segment)!; + expect(findSpan(spans, 'ExampleExceptionFilterRegisteredFirst')).toEqual( + exceptionFilterSpan(segmentSpan, 'ExampleExceptionFilterRegisteredFirst'), + ); }); From 84ef692ca53aaed43a8d488bfb2ffd538f5c0858 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 8 Sep 2026 11:22:49 +0200 Subject: [PATCH 5/7] 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 --- .../nestjs-with-submodules/tests/transactions.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts index 5e5bffe7ab06..c3aee02d14d5 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts @@ -23,6 +23,11 @@ function commonAttributes(segmentSpan: SerializedStreamedSpan): Record Date: Tue, 8 Sep 2026 13:04:54 +0200 Subject: [PATCH 6/7] test(e2e): Rename the nestjs-with-submodules specs to spans.test.ts The file asserts on streamed spans, not transaction events, and `spans.test.ts` is what the other streaming apps already call it. Co-Authored-By: Claude Opus 5 --- .../tests/{transactions.test.ts => spans.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/{transactions.test.ts => spans.test.ts} (100%) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/spans.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/spans.test.ts From 6fcd12fc197e61d48980807cd52f3bf84187da23 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 8 Sep 2026 16:53:32 +0200 Subject: [PATCH 7/7] test(e2e): Await the flush request in the nestjs-with-submodules error specs The `.text()` promise was never awaited, and its body was never used - awaiting the request itself is all these specs need before asserting no error was sent. Co-Authored-By: Claude Opus 5 --- .../nestjs-with-submodules/tests/errors.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts index 34a2e9a909e6..41ab1126d706 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts @@ -129,7 +129,7 @@ test('Does not send exception to Sentry if user-defined global exception filter await spansPromise; - (await fetch(`${baseURL}/flush`)).text(); + await fetch(`${baseURL}/flush`); expect(errorEventOccurred).toBe(false); }); @@ -160,7 +160,7 @@ test('Does not send exception to Sentry if user-defined local exception filter a await spansPromise; - (await fetch(`${baseURL}/flush`)).text(); + await fetch(`${baseURL}/flush`); expect(errorEventOccurred).toBe(false); }); @@ -188,7 +188,7 @@ test('Does not send expected exception to Sentry if exception is thrown in modul await spansPromise; - (await fetch(`${baseURL}/flush`)).text(); + await fetch(`${baseURL}/flush`); expect(errorEventOccurred).toBe(false); }); @@ -221,7 +221,7 @@ test('Global specific exception filter registered in main module is applied and await spansPromise; - (await fetch(`${baseURL}/flush`)).text(); + await fetch(`${baseURL}/flush`); expect(errorEventOccurred).toBe(false); }); @@ -254,7 +254,7 @@ test('Local specific exception filter registered in main module is applied and e await spansPromise; - (await fetch(`${baseURL}/flush`)).text(); + await fetch(`${baseURL}/flush`); expect(errorEventOccurred).toBe(false); });