Skip to content

Commit 64fb9d1

Browse files
chargomeclaude
andauthored
test(e2e): Port the nestjs-basic E2E app to span streaming (#24099)
Removes the `traceLifecycle: 'static'` pin and rewrites the specs against streamed spans. Two things did not port mechanically. `waitForStreamedSpan` gains the optional `timestamp` parameter `waitForEnvelopeItem` already has, because the `app_creation` span is emitted at startup, before any spec can start listening. And streamed spans carry no breadcrumbs, so `/test-schedule-isolation` reports whether a scheduled task's breadcrumb leaked into its isolation scope as a span attribute, the same way the Next.js middleware specs do. Ref: #23801 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f52fb3a commit 64fb9d1

8 files changed

Lines changed: 523 additions & 855 deletions

File tree

‎dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Controller, Get, Param, ParseIntPipe, UseFilters, UseGuards, UseInterceptors } from '@nestjs/common';
2-
import { flush } from '@sentry/nestjs';
2+
import { flush, getActiveSpan, getIsolationScope, getRootSpan } from '@sentry/nestjs';
33
import { AppService } from './app.service';
44
import { AsyncInterceptor } from './async-example.interceptor';
55
import { ScheduleService } from './schedule.service';
@@ -103,6 +103,17 @@ export class AppController {
103103

104104
@Get('test-schedule-isolation')
105105
testScheduleIsolation() {
106+
// Streamed spans carry no breadcrumbs, so the test reads from this attribute whether a
107+
// breadcrumb added by a scheduled task leaked into this request's isolation scope
108+
const activeSpan = getActiveSpan();
109+
if (activeSpan) {
110+
const breadcrumbs = getIsolationScope().getScopeData().breadcrumbs;
111+
getRootSpan(activeSpan).setAttribute(
112+
'isolation_scope.has_schedule_breadcrumb',
113+
breadcrumbs.some(breadcrumb => breadcrumb.message === 'leaked-breadcrumb-from-schedule'),
114+
);
115+
}
116+
106117
return { message: 'ok' };
107118
}
108119

‎dev-packages/e2e-tests/test-applications/nestjs-basic/src/instrument.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as Sentry from '@sentry/nestjs';
22

33
Sentry.init({
4-
traceLifecycle: 'static',
54
environment: 'qa', // dynamic sampling bias to keep transactions
65
dsn: process.env.E2E_TEST_DSN,
76
tunnel: `http://localhost:3031/`, // proxy server

‎dev-packages/e2e-tests/test-applications/nestjs-basic/tests/errors.test.ts‎

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
2+
import { collectStreamedSpansUntilSegment, waitForError } from '@sentry-internal/test-utils';
3+
4+
const APP_NAME = 'nestjs-basic';
35

46
test('Sends exception to Sentry', async ({ baseURL }) => {
5-
const errorEventPromise = waitForError('nestjs-basic', event => {
7+
const errorEventPromise = waitForError(APP_NAME, event => {
68
return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123';
79
});
810

@@ -35,7 +37,7 @@ test('Sends exception to Sentry', async ({ baseURL }) => {
3537
});
3638

3739
test('Sends AxiosError to Sentry', async ({ baseURL }) => {
38-
const errorEventPromise = waitForError('nestjs-basic', event => {
40+
const errorEventPromise = waitForError(APP_NAME, event => {
3941
return !event.type && event.exception?.values?.[0]?.value === 'This is an axios error with id 123';
4042
});
4143

@@ -55,38 +57,35 @@ test('Sends AxiosError to Sentry', async ({ baseURL }) => {
5557
test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => {
5658
let errorEventOccurred = false;
5759

58-
waitForError('nestjs-basic', event => {
60+
waitForError(APP_NAME, event => {
5961
if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected 400 exception with id 123') {
6062
errorEventOccurred = true;
6163
}
6264

6365
return event?.transaction === 'GET /test-expected-400-exception/:id';
6466
});
6567

66-
waitForError('nestjs-basic', event => {
68+
waitForError(APP_NAME, event => {
6769
if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected 500 exception with id 123') {
6870
errorEventOccurred = true;
6971
}
7072

7173
return event?.transaction === 'GET /test-expected-500-exception/:id';
7274
});
7375

74-
const transactionEventPromise400 = waitForTransaction('nestjs-basic', transactionEvent => {
75-
return transactionEvent?.transaction === 'GET /test-expected-400-exception/:id';
76-
});
77-
78-
const transactionEventPromise500 = waitForTransaction('nestjs-basic', transactionEvent => {
79-
return transactionEvent?.transaction === 'GET /test-expected-500-exception/:id';
80-
});
76+
// Waiting for each request's segment span is how this spec knows the request finished and
77+
// any error it would have produced had its chance to be sent.
78+
const spansPromise400 = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-expected-400-exception/:id');
79+
const spansPromise500 = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-expected-500-exception/:id');
8180

8281
const response400 = await fetch(`${baseURL}/test-expected-400-exception/123`);
8382
expect(response400.status).toBe(400);
8483

8584
const response500 = await fetch(`${baseURL}/test-expected-500-exception/123`);
8685
expect(response500.status).toBe(500);
8786

88-
await transactionEventPromise400;
89-
await transactionEventPromise500;
87+
await spansPromise400;
88+
await spansPromise500;
9089

9190
(await fetch(`${baseURL}/flush`)).text();
9291

@@ -96,22 +95,20 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => {
9695
test('Does not send RpcExceptions to Sentry', async ({ baseURL }) => {
9796
let errorEventOccurred = false;
9897

99-
waitForError('nestjs-basic', event => {
98+
waitForError(APP_NAME, event => {
10099
if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected RPC exception with id 123') {
101100
errorEventOccurred = true;
102101
}
103102

104103
return event?.transaction === 'GET /test-expected-rpc-exception/:id';
105104
});
106105

107-
const transactionEventPromise = waitForTransaction('nestjs-basic', transactionEvent => {
108-
return transactionEvent?.transaction === 'GET /test-expected-rpc-exception/:id';
109-
});
106+
const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-expected-rpc-exception/:id');
110107

111108
const response = await fetch(`${baseURL}/test-expected-rpc-exception/123`);
112109
expect(response.status).toBe(500);
113110

114-
await transactionEventPromise;
111+
await spansPromise;
115112

116113
(await fetch(`${baseURL}/flush`)).text();
117114

@@ -123,17 +120,15 @@ test('Global exception filter registered in main module is applied and exception
123120
}) => {
124121
let errorEventOccurred = false;
125122

126-
waitForError('nestjs-basic', event => {
123+
waitForError(APP_NAME, event => {
127124
if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by global filter!') {
128125
errorEventOccurred = true;
129126
}
130127

131128
return event?.transaction === 'GET /example-exception-global-filter';
132129
});
133130

134-
const transactionEventPromise = waitForTransaction('nestjs-basic', transactionEvent => {
135-
return transactionEvent?.transaction === 'GET /example-exception-global-filter';
136-
});
131+
const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-exception-global-filter');
137132

138133
const response = await fetch(`${baseURL}/example-exception-global-filter`);
139134
const responseBody = await response.json();
@@ -146,7 +141,7 @@ test('Global exception filter registered in main module is applied and exception
146141
message: 'Example exception was handled by global filter!',
147142
});
148143

149-
await transactionEventPromise;
144+
await spansPromise;
150145

151146
(await fetch(`${baseURL}/flush`)).text();
152147

@@ -158,17 +153,15 @@ test('Local exception filter registered in main module is applied and exception
158153
}) => {
159154
let errorEventOccurred = false;
160155

161-
waitForError('nestjs-basic', event => {
156+
waitForError(APP_NAME, event => {
162157
if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by local filter!') {
163158
errorEventOccurred = true;
164159
}
165160

166161
return event?.transaction === 'GET /example-exception-local-filter';
167162
});
168163

169-
const transactionEventPromise = waitForTransaction('nestjs-basic', transactionEvent => {
170-
return transactionEvent?.transaction === 'GET /example-exception-local-filter';
171-
});
164+
const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /example-exception-local-filter');
172165

173166
const response = await fetch(`${baseURL}/example-exception-local-filter`);
174167
const responseBody = await response.json();
@@ -181,7 +174,7 @@ test('Local exception filter registered in main module is applied and exception
181174
message: 'Example exception was handled by local filter!',
182175
});
183176

184-
await transactionEventPromise;
177+
await spansPromise;
185178

186179
(await fetch(`${baseURL}/flush`)).text();
187180

‎dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
2+
import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ baseURL }) => {
55
const errorEventPromise = waitForError('nestjs-basic', event => {
@@ -75,18 +75,19 @@ test('Scheduled task breadcrumbs do not leak into subsequent HTTP requests', asy
7575
// Wait for at least one interval tick to fire
7676
await new Promise(resolve => setTimeout(resolve, 3000));
7777

78-
const transactionPromise = waitForTransaction('nestjs-basic', transactionEvent => {
79-
return transactionEvent.transaction === 'GET /test-schedule-isolation';
78+
const segmentSpanPromise = waitForStreamedSpan('nestjs-basic', span => {
79+
return span.is_segment && span.name === 'GET /test-schedule-isolation';
8080
});
8181

8282
await fetch(`${baseURL}/test-schedule-isolation`);
8383

84-
const transaction = await transactionPromise;
84+
const segmentSpan = await segmentSpanPromise;
8585

86-
const leakedBreadcrumb = (transaction.breadcrumbs || []).find(
87-
(b: any) => b.message === 'leaked-breadcrumb-from-schedule',
88-
);
89-
expect(leakedBreadcrumb).toBeUndefined();
86+
// Streamed spans carry no breadcrumbs, so the route reports the leak as a span attribute
87+
expect(segmentSpan.attributes['isolation_scope.has_schedule_breadcrumb']).toEqual({
88+
value: false,
89+
type: 'boolean',
90+
});
9091

9192
// kill interval so tests don't get stuck
9293
await fetch(`${baseURL}/kill-test-schedule-interval/test-schedule-isolation`);

‎dev-packages/e2e-tests/test-applications/nestjs-basic/tests/span-decorator.test.ts‎

Lines changed: 46 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,67 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
3+
import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
34

4-
test('Transaction includes span and correct value for decorated async function', async ({ baseURL }) => {
5-
const transactionEventPromise = waitForTransaction('nestjs-basic', transactionEvent => {
6-
return (
7-
transactionEvent?.contexts?.trace?.op === 'http.server' &&
8-
transactionEvent?.transaction === 'GET /test-span-decorator-async'
9-
);
10-
});
5+
const APP_NAME = 'nestjs-basic';
6+
7+
const SPAN_ID = /^[a-f0-9]{16}$/;
8+
9+
/** The full shape of a `@SentryTraced` span, so `toEqual` catches anything unexpected. */
10+
function tracedSpan(segmentSpan: SerializedStreamedSpan, name: string, op: string): Record<string, unknown> {
11+
return {
12+
name,
13+
span_id: expect.stringMatching(SPAN_ID),
14+
trace_id: segmentSpan.trace_id,
15+
parent_span_id: expect.stringMatching(SPAN_ID),
16+
start_timestamp: expect.any(Number),
17+
end_timestamp: expect.any(Number),
18+
is_segment: false,
19+
status: 'ok',
20+
attributes: {
21+
'sentry.trace_lifecycle': { type: 'string', value: 'stream' },
22+
'sentry.segment.name': { type: 'string', value: segmentSpan.name },
23+
'sentry.segment.id': { type: 'string', value: segmentSpan.span_id },
24+
'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' },
25+
'sentry.sdk.version': { type: 'string', value: expect.any(String) },
26+
'sentry.environment': { type: 'string', value: 'qa' },
27+
// CI builds the apps with a release, local runs have none. It comes from the client
28+
// options, so whatever the segment span got, every other span of the trace got too.
29+
...(segmentSpan.attributes['sentry.release']
30+
? { 'sentry.release': { type: 'string', value: expect.any(String) } }
31+
: {}),
32+
'sentry.origin': { type: 'string', value: 'auto.function.nestjs.sentry_traced' },
33+
'sentry.op': { type: 'string', value: op },
34+
},
35+
};
36+
}
37+
38+
test('Trace includes span and correct value for decorated async function', async ({ baseURL }) => {
39+
const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-span-decorator-async');
1140

1241
const response = await fetch(`${baseURL}/test-span-decorator-async`);
1342
const body = await response.json();
1443

1544
expect(body.result).toEqual('test');
1645

17-
const transactionEvent = await transactionEventPromise;
46+
const spans = await spansPromise;
1847

19-
expect(transactionEvent.spans).toEqual(
20-
expect.arrayContaining([
21-
expect.objectContaining({
22-
span_id: expect.stringMatching(/[a-f0-9]{16}/),
23-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
24-
data: {
25-
'sentry.origin': 'auto.function.nestjs.sentry_traced',
26-
'sentry.op': 'wait and return a string',
27-
},
28-
description: 'wait',
29-
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
30-
start_timestamp: expect.any(Number),
31-
status: 'ok',
32-
op: 'wait and return a string',
33-
origin: 'auto.function.nestjs.sentry_traced',
34-
}),
35-
]),
36-
);
48+
const segmentSpan = spans.find(span => span.is_segment)!;
49+
expect(spans.find(span => span.name === 'wait')).toEqual(tracedSpan(segmentSpan, 'wait', 'wait and return a string'));
3750
});
3851

39-
test('Transaction includes span and correct value for decorated sync function', async ({ baseURL }) => {
40-
const transactionEventPromise = waitForTransaction('nestjs-basic', transactionEvent => {
41-
return (
42-
transactionEvent?.contexts?.trace?.op === 'http.server' &&
43-
transactionEvent?.transaction === 'GET /test-span-decorator-sync'
44-
);
45-
});
52+
test('Trace includes span and correct value for decorated sync function', async ({ baseURL }) => {
53+
const spansPromise = collectStreamedSpansUntilSegment(APP_NAME, 'GET /test-span-decorator-sync');
4654

4755
const response = await fetch(`${baseURL}/test-span-decorator-sync`);
4856
const body = await response.json();
4957

5058
expect(body.result).toEqual('test');
5159

52-
const transactionEvent = await transactionEventPromise;
60+
const spans = await spansPromise;
5361

54-
expect(transactionEvent.spans).toEqual(
55-
expect.arrayContaining([
56-
expect.objectContaining({
57-
span_id: expect.stringMatching(/[a-f0-9]{16}/),
58-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
59-
data: {
60-
'sentry.origin': 'auto.function.nestjs.sentry_traced',
61-
'sentry.op': 'return a string',
62-
},
63-
description: 'getString',
64-
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
65-
start_timestamp: expect.any(Number),
66-
status: 'ok',
67-
op: 'return a string',
68-
origin: 'auto.function.nestjs.sentry_traced',
69-
}),
70-
]),
62+
const segmentSpan = spans.find(span => span.is_segment)!;
63+
expect(spans.find(span => span.name === 'getString')).toEqual(
64+
tracedSpan(segmentSpan, 'getString', 'return a string'),
7165
);
7266
});
7367

0 commit comments

Comments
 (0)