Skip to content

Commit a152971

Browse files
msonnbcodex
andauthored
test(e2e): Migrate Express 4 and TSX to span streaming (#24156)
Exercise Express 4 and TSX instrumentation with span streaming, including error correlation, middleware, MCP, and tRPC coverage. part of #23800 --------- Co-authored-by: GPT-6 <[email protected]>
1 parent a53ea7a commit a152971

12 files changed

Lines changed: 943 additions & 836 deletions

File tree

‎dev-packages/e2e-tests/test-applications/node-express/src/app.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ declare global {
99
}
1010

1111
Sentry.init({
12-
traceLifecycle: 'static',
1312
environment: 'qa', // dynamic sampling bias to keep transactions
1413
dsn: process.env.E2E_TEST_DSN,
1514
includeLocalVariables: true,

‎dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts‎

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

44
test('Sends correct error event', async ({ baseURL }) => {
55
const errorEventPromise = waitForError('node-express', event => {
66
return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123';
77
});
88

9-
const transactionEventPromise = waitForTransaction('node-express', event => {
10-
return event.transaction === 'GET /test-exception/:id';
11-
});
9+
const segmentEventPromise = collectStreamedSpansUntilSegment('node-express', 'GET /test-exception/:id');
1210

1311
await fetch(`${baseURL}/test-exception/123`);
1412

1513
const errorEvent = await errorEventPromise;
16-
const transactionEvent = await transactionEventPromise;
14+
const segmentEventSpans = await segmentEventPromise;
15+
const segmentEvent = segmentEventSpans.find(
16+
segment => segment.is_segment && segment.name === 'GET /test-exception/:id',
17+
)!;
1718

1819
expect(errorEvent.exception?.values).toHaveLength(1);
1920
const exception = errorEvent.exception?.values?.[0];
@@ -38,13 +39,17 @@ test('Sends correct error event', async ({ baseURL }) => {
3839
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
3940
});
4041

41-
// The error is attached to the same trace as the request transaction, and to a
42-
// span that belongs to that transaction (its root span or one of its children).
43-
const transactionTrace = transactionEvent.contexts?.trace;
44-
expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionTrace?.trace_id);
42+
// The error is attached to the same trace as the request segment, and to a
43+
// span in that segment.
44+
expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentEvent.trace_id);
4545

46-
const transactionSpanIds = [transactionTrace?.span_id, ...(transactionEvent.spans ?? []).map(span => span.span_id)];
47-
expect(transactionSpanIds).toContain(errorEvent.contexts?.trace?.span_id);
46+
const segmentSpanIds = [
47+
segmentEvent.span_id,
48+
...segmentEventSpans
49+
.filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id)
50+
.map(span => span.span_id),
51+
];
52+
expect(segmentSpanIds).toContain(errorEvent.contexts?.trace?.span_id);
4853
});
4954

5055
test('Should record caught exceptions with local variable', async ({ baseURL }) => {

‎dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts‎

Lines changed: 137 additions & 126 deletions
Large diffs are not rendered by default.
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
3+
4+
test('Sends an API route span', async ({ baseURL }) => {
5+
const segmentEventPromise = collectStreamedSpansUntilSegment(
6+
'node-express',
7+
segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-transaction',
8+
);
9+
10+
await fetch(`${baseURL}/test-transaction`);
11+
12+
const segmentEventSpans = await segmentEventPromise;
13+
const segmentEvent = segmentEventSpans.find(
14+
segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-transaction',
15+
)!;
16+
17+
expect(segmentEvent).toEqual(
18+
expect.objectContaining({
19+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
20+
status: 'ok',
21+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
22+
attributes: expect.objectContaining({
23+
'sentry.segment.name.source': { value: 'route', type: 'string' },
24+
'sentry.origin': { value: 'auto.http.http_server', type: 'string' },
25+
'sentry.op': { value: 'http.server', type: 'string' },
26+
'sentry.sample_rate': { value: 1, type: 'integer' },
27+
'sentry.kind': { value: 'server', type: 'string' },
28+
'http.response.status_code': { value: 200, type: 'integer' },
29+
'url.full': { value: 'http://localhost:3030/test-transaction', type: 'string' },
30+
'url.path': { value: '/test-transaction', type: 'string' },
31+
'server.address': { value: 'localhost', type: 'string' },
32+
'http.request.method': { value: 'GET', type: 'string' },
33+
'url.scheme': { value: 'http', type: 'string' },
34+
'user_agent.original': { value: 'node', type: 'string' },
35+
'client.address': { value: '::1', type: 'string' },
36+
'client.port': { value: expect.any(Number), type: 'integer' },
37+
'network.transport': { value: 'tcp', type: 'string' },
38+
'network.local.address': { value: expect.any(String), type: 'string' },
39+
'network.local.port': { value: expect.any(Number), type: 'integer' },
40+
'network.peer.address': { value: expect.any(String), type: 'string' },
41+
'network.peer.port': { value: expect.any(Number), type: 'integer' },
42+
'network.protocol.name': { value: 'http', type: 'string' },
43+
'network.protocol.version': { value: '1.1', type: 'string' },
44+
'server.port': { value: 3030, type: 'integer' },
45+
'http.response.status_text': { value: 'OK', type: 'string' },
46+
'http.route': { value: '/test-transaction', type: 'string' },
47+
'http.request.header.accept': { value: '*/*', type: 'string' },
48+
'http.request.header.accept_encoding': { value: 'gzip, deflate', type: 'string' },
49+
'http.request.header.accept_language': { value: '*', type: 'string' },
50+
'http.request.header.connection': { value: 'keep-alive', type: 'string' },
51+
'http.request.header.host': { value: expect.any(String), type: 'string' },
52+
'http.request.header.sec_fetch_mode': { value: 'cors', type: 'string' },
53+
'http.request.header.user_agent': { value: 'node', type: 'string' },
54+
}),
55+
}),
56+
);
57+
58+
expect(segmentEvent).toEqual(
59+
expect.objectContaining({
60+
name: 'GET /test-transaction',
61+
is_segment: true,
62+
attributes: expect.objectContaining({ 'sentry.segment.name.source': { value: 'route', type: 'string' } }),
63+
}),
64+
);
65+
66+
const spans = segmentEventSpans.filter(
67+
span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id,
68+
);
69+
70+
// Manually started span
71+
expect(spans.filter(span => span.name === 'test-span')).toEqual([
72+
expect.objectContaining({
73+
name: 'test-span',
74+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
75+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
76+
start_timestamp: expect.any(Number),
77+
status: 'ok',
78+
end_timestamp: expect.any(Number),
79+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
80+
attributes: expect.objectContaining({ 'sentry.origin': { value: 'manual', type: 'string' } }),
81+
}),
82+
]);
83+
84+
// auto instrumented spans
85+
expect(spans.filter(span => span.name === 'query')).toEqual([
86+
expect.objectContaining({
87+
name: 'query',
88+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
89+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
90+
start_timestamp: expect.any(Number),
91+
status: 'ok',
92+
end_timestamp: expect.any(Number),
93+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
94+
attributes: expect.objectContaining({
95+
'sentry.origin': { value: 'auto.http.express', type: 'string' },
96+
'sentry.op': { value: 'middleware', type: 'string' },
97+
'express.name': { value: 'query', type: 'string' },
98+
'express.type': { value: 'middleware', type: 'string' },
99+
}),
100+
}),
101+
]);
102+
103+
expect(spans.filter(span => span.name === 'expressInit')).toEqual([
104+
expect.objectContaining({
105+
name: 'expressInit',
106+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
107+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
108+
start_timestamp: expect.any(Number),
109+
status: 'ok',
110+
end_timestamp: expect.any(Number),
111+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
112+
attributes: expect.objectContaining({
113+
'sentry.origin': { value: 'auto.http.express', type: 'string' },
114+
'sentry.op': { value: 'middleware', type: 'string' },
115+
'express.name': { value: 'expressInit', type: 'string' },
116+
'express.type': { value: 'middleware', type: 'string' },
117+
}),
118+
}),
119+
]);
120+
121+
expect(spans.filter(span => span.name === '/test-transaction')).toEqual([
122+
expect.objectContaining({
123+
name: '/test-transaction',
124+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
125+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
126+
start_timestamp: expect.any(Number),
127+
status: 'ok',
128+
end_timestamp: expect.any(Number),
129+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
130+
attributes: expect.objectContaining({
131+
'sentry.origin': { value: 'auto.http.express', type: 'string' },
132+
'sentry.op': { value: 'handler', type: 'string' },
133+
'http.route': { value: '/test-transaction', type: 'string' },
134+
'express.name': { value: '/test-transaction', type: 'string' },
135+
'express.type': { value: 'request_handler', type: 'string' },
136+
}),
137+
}),
138+
]);
139+
});
140+
141+
test('Sends an API route span for an errored route', async ({ baseURL }) => {
142+
const segmentEventPromise = collectStreamedSpansUntilSegment(
143+
'node-express',
144+
segment =>
145+
getSpanOp(segment) === 'http.server' &&
146+
segment.name === 'GET /test-exception/:id' &&
147+
segment.attributes['url.full']?.value === 'http://localhost:3030/test-exception/777',
148+
);
149+
150+
await fetch(`${baseURL}/test-exception/777`);
151+
152+
const segmentEventSpans = await segmentEventPromise;
153+
const segmentEvent = segmentEventSpans.find(
154+
segment =>
155+
segment.is_segment &&
156+
getSpanOp(segment) === 'http.server' &&
157+
segment.name === 'GET /test-exception/:id' &&
158+
segment.attributes['url.full']?.value === 'http://localhost:3030/test-exception/777',
159+
)!;
160+
161+
expect(getSpanOp(segmentEvent)).toEqual('http.server');
162+
expect(segmentEvent.name).toEqual('GET /test-exception/:id');
163+
expect(segmentEvent?.status).toEqual('error');
164+
expect(segmentEvent.attributes?.['http.response.status_code']?.value).toEqual(500);
165+
166+
const spans = segmentEventSpans.filter(
167+
span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id,
168+
);
169+
170+
expect(spans.filter(span => span.name === 'query')).toEqual([
171+
expect.objectContaining({
172+
name: 'query',
173+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
174+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
175+
start_timestamp: expect.any(Number),
176+
status: 'ok',
177+
end_timestamp: expect.any(Number),
178+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
179+
attributes: expect.objectContaining({
180+
'sentry.origin': { value: 'auto.http.express', type: 'string' },
181+
'sentry.op': { value: 'middleware', type: 'string' },
182+
'express.name': { value: 'query', type: 'string' },
183+
'express.type': { value: 'middleware', type: 'string' },
184+
}),
185+
}),
186+
]);
187+
188+
expect(spans.filter(span => span.name === 'expressInit')).toEqual([
189+
expect.objectContaining({
190+
name: 'expressInit',
191+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
192+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
193+
start_timestamp: expect.any(Number),
194+
status: 'ok',
195+
end_timestamp: expect.any(Number),
196+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
197+
attributes: expect.objectContaining({
198+
'sentry.origin': { value: 'auto.http.express', type: 'string' },
199+
'sentry.op': { value: 'middleware', type: 'string' },
200+
'express.name': { value: 'expressInit', type: 'string' },
201+
'express.type': { value: 'middleware', type: 'string' },
202+
}),
203+
}),
204+
]);
205+
206+
expect(spans.filter(span => span.name === '/test-exception/:id')).toEqual([
207+
expect.objectContaining({
208+
name: '/test-exception/:id',
209+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
210+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
211+
start_timestamp: expect.any(Number),
212+
status: 'error',
213+
end_timestamp: expect.any(Number),
214+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
215+
attributes: expect.objectContaining({
216+
'sentry.origin': { value: 'auto.http.express', type: 'string' },
217+
'sentry.op': { value: 'handler', type: 'string' },
218+
'http.route': { value: '/test-exception/:id', type: 'string' },
219+
'express.name': { value: '/test-exception/:id', type: 'string' },
220+
'express.type': { value: 'request_handler', type: 'string' },
221+
'error.type': { value: 'Error', type: 'string' },
222+
}),
223+
}),
224+
]);
225+
});
226+
227+
test('Outgoing fetch spans include response headers when headersToSpanAttributes is configured', async ({
228+
baseURL,
229+
}) => {
230+
const segmentEventPromise = collectStreamedSpansUntilSegment(
231+
'node-express',
232+
segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-outgoing-fetch',
233+
);
234+
235+
await fetch(`${baseURL}/test-outgoing-fetch`);
236+
237+
const segmentEventSpans = await segmentEventPromise;
238+
const segmentEvent = segmentEventSpans.find(
239+
segment =>
240+
segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-outgoing-fetch',
241+
)!;
242+
243+
const spans = segmentEventSpans.filter(
244+
span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id,
245+
);
246+
247+
// Find the outgoing fetch span (http.client operation from undici instrumentation)
248+
const fetchSpan = spans.find(
249+
span =>
250+
getSpanOp(span) === 'http.client' && span.attributes['url.full']?.value === 'http://localhost:3030/test-success',
251+
);
252+
253+
expect(fetchSpan).toBeDefined();
254+
expect(fetchSpan?.name).toBe('GET localhost');
255+
expect(fetchSpan?.attributes).toEqual(
256+
expect.objectContaining({ 'http.response.header.content-length': { value: [expect.any(String)], type: 'array' } }),
257+
);
258+
});
259+
260+
test('Extracts HTTP request headers as span attributes', async ({ baseURL }) => {
261+
const segmentEventPromise = waitForStreamedSpan(
262+
'node-express',
263+
segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-transaction',
264+
);
265+
266+
await fetch(`${baseURL}/test-transaction`, {
267+
headers: {
268+
'User-Agent': 'Custom-Agent/1.0 (Test)',
269+
'Content-Type': 'application/json',
270+
'X-Custom-Header': 'test-value',
271+
Accept: 'application/json, text/plain',
272+
'X-Request-ID': 'req-123',
273+
},
274+
});
275+
276+
const segmentEvent = await segmentEventPromise;
277+
278+
expect(segmentEvent.attributes).toEqual(
279+
expect.objectContaining({
280+
'http.request.header.user_agent': { value: 'Custom-Agent/1.0 (Test)', type: 'string' },
281+
'http.request.header.content_type': { value: 'application/json', type: 'string' },
282+
'http.request.header.x_custom_header': { value: 'test-value', type: 'string' },
283+
'http.request.header.accept': { value: 'application/json, text/plain', type: 'string' },
284+
'http.request.header.x_request_id': { value: 'req-123', type: 'string' },
285+
}),
286+
);
287+
});

0 commit comments

Comments
 (0)