Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as Sentry from '@sentry/node';

Sentry.init({
traceLifecycle: 'static',
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.E2E_TEST_DSN,
debug: !!process.env.DEBUG,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

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

const client = new Client({
name: 'test-client-v2',
version: '1.0.0',
});

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

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-v2');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-V2');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
const initializeSegment = await initializeSegmentPromise;
expect(initializeSegment).toBeDefined();
expect(getSpanOp(initializeSegment)).toEqual('mcp.server');
expect(initializeSegment.attributes?.['mcp.method.name']?.value).toEqual('initialize');
expect(initializeSegment.attributes?.['mcp.client.name']?.value).toEqual('test-client-v2');
expect(initializeSegment.attributes?.['mcp.server.name']?.value).toEqual('Echo-V2');
expect(initializeSegment.attributes?.['mcp.transport']?.value).toMatch(/StreamableHTTPServerTransport/);
});

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

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

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

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

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

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
const resourceSegment = await resourceSegmentPromise;
expect(resourceSegment).toBeDefined();
expect(getSpanOp(resourceSegment)).toEqual('mcp.server');
expect(resourceSegment.attributes?.['mcp.method.name']?.value).toEqual('resources/read');
});

await test.step('registerPrompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
return transactionEvent.transaction === 'prompts/get echo';
});
const promptSegmentPromise = waitForStreamedSpan(
'node-express-mcp-v2',
segment => segment.is_segment && segment.name === 'prompts/get echo',
);

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

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
const promptSegment = await promptSegmentPromise;
expect(promptSegment).toBeDefined();
expect(getSpanOp(promptSegment)).toEqual('mcp.server');
expect(promptSegment.attributes?.['mcp.method.name']?.value).toEqual('prompts/get');
});

await test.step('error tool sets span status to internal_error', async () => {
const toolTransactionPromise = waitForTransaction('node-express-mcp-v2', transactionEvent => {
return transactionEvent.transaction === 'tools/call always-error';
});
await test.step('error tool sets span status to error', async () => {
const toolSegmentPromise = waitForStreamedSpan(
'node-express-mcp-v2',
segment => segment.is_segment && segment.name === 'tools/call always-error',
);

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

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.status).toEqual('internal_error');
const toolSegment = await toolSegmentPromise;
expect(toolSegment).toBeDefined();
expect(getSpanOp(toolSegment)).toEqual('mcp.server');
expect(toolSegment?.status).toEqual('error');
});

await client.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ declare global {
}

Sentry.init({
traceLifecycle: 'static',
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.E2E_TEST_DSN,
includeLocalVariables: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
import { waitForError, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';

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

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

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

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

expect(errorEvent.exception?.values).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123');
Expand All @@ -33,13 +34,18 @@ test('Sends correct error event', async ({ baseURL }) => {
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
});

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

const transactionSpanIds = [transactionTrace?.span_id, ...(transactionEvent.spans ?? []).map(span => span.span_id)];
expect(transactionSpanIds).toContain(errorEvent.contexts?.trace?.span_id);
const segmentSpanIds = [
segmentTrace?.span_id,
...segmentEventSpans
.filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id)
.map(span => span.span_id),
];
expect(segmentSpanIds).toContain(errorEvent.contexts?.trace?.span_id);
});

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