Skip to content

Commit 6376ee0

Browse files
msonnbcodex
andauthored
test(e2e): Migrate Express 5 and MCP to span streaming (#24157)
Exercise Express 5 and MCP v2 instrumentation with span streaming, including low-cardinality span names and database query attributes. part of #23800 Co-authored-by: GPT-6 <codex@openai.com>
1 parent a152971 commit 6376ee0

9 files changed

Lines changed: 417 additions & 352 deletions

File tree

‎dev-packages/e2e-tests/test-applications/node-express-mcp-v2/instrument.mjs‎

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

33
Sentry.init({
4-
traceLifecycle: 'static',
54
environment: 'qa', // dynamic sampling bias to keep transactions
65
dsn: process.env.E2E_TEST_DSN,
76
debug: !!process.env.DEBUG,

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

Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,38 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils';
33
import { Client } from '@modelcontextprotocol/client';
44
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
55

6-
test('records transactions for stable MCP SDK v2 handlers using the register API', async ({ baseURL }) => {
6+
test('records spans for stable MCP SDK v2 handlers using the register API', async ({ baseURL }) => {
77
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));
88

99
const client = new Client({
1010
name: 'test-client-v2',
1111
version: '1.0.0',
1212
});
1313

14-
const initializeTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
15-
return transactionEvent.transaction === 'initialize';
16-
});
14+
const initializeSegmentPromise = waitForStreamedSpan(
15+
'node-express-mcp-v2',
16+
segment => segment.is_segment && segment.name === 'initialize',
17+
);
1718

1819
await client.connect(transport);
1920

2021
await test.step('initialize handshake', async () => {
21-
const initializeTransaction = await initializeTransactionPromise;
22-
expect(initializeTransaction).toBeDefined();
23-
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
24-
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
25-
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-v2');
26-
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-V2');
27-
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
22+
const initializeSegment = await initializeSegmentPromise;
23+
expect(initializeSegment).toBeDefined();
24+
expect(getSpanOp(initializeSegment)).toEqual('mcp.server');
25+
expect(initializeSegment.attributes?.['mcp.method.name']?.value).toEqual('initialize');
26+
expect(initializeSegment.attributes?.['mcp.client.name']?.value).toEqual('test-client-v2');
27+
expect(initializeSegment.attributes?.['mcp.server.name']?.value).toEqual('Echo-V2');
28+
expect(initializeSegment.attributes?.['mcp.transport']?.value).toMatch(/StreamableHTTPServerTransport/);
2829
});
2930

3031
await test.step('registerTool handler', async () => {
31-
const toolTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
32-
return transactionEvent.transaction === 'tools/call echo';
33-
});
32+
const toolSegmentPromise = waitForStreamedSpan(
33+
'node-express-mcp-v2',
34+
segment => segment.is_segment && segment.name === 'tools/call echo',
35+
);
3436

3537
const toolResult = await client.callTool({
3638
name: 'echo',
@@ -48,19 +50,20 @@ test('records transactions for stable MCP SDK v2 handlers using the register API
4850
],
4951
});
5052

51-
const toolTransaction = await toolTransactionPromise;
52-
expect(toolTransaction).toBeDefined();
53-
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
54-
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
55-
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
53+
const toolSegment = await toolSegmentPromise;
54+
expect(toolSegment).toBeDefined();
55+
expect(getSpanOp(toolSegment)).toEqual('mcp.server');
56+
expect(toolSegment.attributes?.['mcp.method.name']?.value).toEqual('tools/call');
57+
expect(toolSegment.attributes?.['mcp.tool.name']?.value).toEqual('echo');
5658
// Proves span was completed with results (span correlation worked end-to-end)
57-
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
59+
expect(toolSegment.attributes?.['mcp.tool.result.content_count']?.value).toEqual(1);
5860
});
5961

6062
await test.step('registerResource handler', async () => {
61-
const resourceTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
62-
return transactionEvent.transaction === 'resources/read echo://foobar';
63-
});
63+
const resourceSegmentPromise = waitForStreamedSpan(
64+
'node-express-mcp-v2',
65+
segment => segment.is_segment && segment.name === 'resources/read',
66+
);
6467

6568
const resourceResult = await client.readResource({
6669
uri: 'echo://foobar',
@@ -70,16 +73,17 @@ test('records transactions for stable MCP SDK v2 handlers using the register API
7073
contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }],
7174
});
7275

73-
const resourceTransaction = await resourceTransactionPromise;
74-
expect(resourceTransaction).toBeDefined();
75-
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
76-
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
76+
const resourceSegment = await resourceSegmentPromise;
77+
expect(resourceSegment).toBeDefined();
78+
expect(getSpanOp(resourceSegment)).toEqual('mcp.server');
79+
expect(resourceSegment.attributes?.['mcp.method.name']?.value).toEqual('resources/read');
7780
});
7881

7982
await test.step('registerPrompt handler', async () => {
80-
const promptTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
81-
return transactionEvent.transaction === 'prompts/get echo';
82-
});
83+
const promptSegmentPromise = waitForStreamedSpan(
84+
'node-express-mcp-v2',
85+
segment => segment.is_segment && segment.name === 'prompts/get echo',
86+
);
8387

8488
const promptResult = await client.getPrompt({
8589
name: 'echo',
@@ -100,27 +104,28 @@ test('records transactions for stable MCP SDK v2 handlers using the register API
100104
],
101105
});
102106

103-
const promptTransaction = await promptTransactionPromise;
104-
expect(promptTransaction).toBeDefined();
105-
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
106-
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
107+
const promptSegment = await promptSegmentPromise;
108+
expect(promptSegment).toBeDefined();
109+
expect(getSpanOp(promptSegment)).toEqual('mcp.server');
110+
expect(promptSegment.attributes?.['mcp.method.name']?.value).toEqual('prompts/get');
107111
});
108112

109-
await test.step('error tool sets span status to internal_error', async () => {
110-
const toolTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
111-
return transactionEvent.transaction === 'tools/call always-error';
112-
});
113+
await test.step('error tool sets span status to error', async () => {
114+
const toolSegmentPromise = waitForStreamedSpan(
115+
'node-express-mcp-v2',
116+
segment => segment.is_segment && segment.name === 'tools/call always-error',
117+
);
113118

114119
try {
115120
await client.callTool({ name: 'always-error', arguments: {} });
116121
} catch {
117122
// Expected: MCP SDK throws when the tool returns a JSON-RPC error
118123
}
119124

120-
const toolTransaction = await toolTransactionPromise;
121-
expect(toolTransaction).toBeDefined();
122-
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
123-
expect(toolTransaction.contexts?.trace?.status).toEqual('internal_error');
125+
const toolSegment = await toolSegmentPromise;
126+
expect(toolSegment).toBeDefined();
127+
expect(getSpanOp(toolSegment)).toEqual('mcp.server');
128+
expect(toolSegment?.status).toEqual('error');
124129
});
125130

126131
await client.close();

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

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

99
Sentry.init({
10-
traceLifecycle: 'static',
1110
environment: 'qa', // dynamic sampling bias to keep transactions
1211
dsn: process.env.E2E_TEST_DSN,
1312
includeLocalVariables: true,

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

Lines changed: 17 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-v5', 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-v5', event => {
10-
return event.transaction === 'GET /test-exception/:id';
11-
});
9+
const segmentEventPromise = collectStreamedSpansUntilSegment('node-express-v5', '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
expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123');
@@ -33,13 +34,18 @@ test('Sends correct error event', async ({ baseURL }) => {
3334
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
3435
});
3536

36-
// The error is attached to the same trace as the request transaction, and to a
37-
// span that belongs to that transaction (its root span or one of its children).
38-
const transactionTrace = transactionEvent.contexts?.trace;
39-
expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionTrace?.trace_id);
37+
// The error is attached to the same trace as the request segment, and to a
38+
// span in that segment.
39+
const segmentTrace = segmentEvent;
40+
expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentTrace?.trace_id);
4041

41-
const transactionSpanIds = [transactionTrace?.span_id, ...(transactionEvent.spans ?? []).map(span => span.span_id)];
42-
expect(transactionSpanIds).toContain(errorEvent.contexts?.trace?.span_id);
42+
const segmentSpanIds = [
43+
segmentTrace?.span_id,
44+
...segmentEventSpans
45+
.filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id)
46+
.map(span => span.span_id),
47+
];
48+
expect(segmentSpanIds).toContain(errorEvent.contexts?.trace?.span_id);
4349
});
4450

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

0 commit comments

Comments
 (0)