From 3a981d24f107bcdd730b790547da56cb2513e332 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 15:29:28 +0200 Subject: [PATCH 1/3] test(node): Add node-express-streaming E2E test app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clone of node-express with span streaming enabled (traceLifecycle: 'stream' + spanStreamingIntegration()). Tests use waitForStreamedSpan/waitForStreamedSpans helpers instead of waitForTransaction. Covers HTTP server spans, error handling, outgoing fetch, request header attributes, tRPC instrumentation, and MCP via StreamableHTTP transport. SSE MCP test is skipped — MCP handler spans are not emitted as streamed spans with SSE transport (only the POST /messages HTTP span arrives). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../node-express-streaming/.gitignore | 1 + .../node-express-streaming/package.json | 35 ++ .../playwright.config.mjs | 7 + .../node-express-streaming/src/app.ts | 149 +++++++++ .../node-express-streaming/src/mcp.ts | 221 +++++++++++++ .../start-event-proxy.mjs | 6 + .../tests/errors.test.ts | 58 ++++ .../node-express-streaming/tests/logs.test.ts | 16 + .../node-express-streaming/tests/mcp.test.ts | 302 ++++++++++++++++++ .../node-express-streaming/tests/misc.test.ts | 15 + .../tests/transactions.test.ts | 137 ++++++++ .../node-express-streaming/tests/trpc.test.ts | 105 ++++++ .../node-express-streaming/tsconfig.json | 11 + 13 files changed, 1063 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json new file mode 100644 index 000000000000..77124040ff6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/package.json @@ -0,0 +1,35 @@ +{ + "name": "node-express-streaming-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0", + "zod": "~3.25.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "resolutions": { + "@types/qs": "6.9.17" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts new file mode 100644 index 000000000000..9bb66ea83f0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts @@ -0,0 +1,149 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + enableLogs: true, + traceLifecycle: 'stream', + integrations: [ + Sentry.spanStreamingIntegration(), + // @ts-expect-error - headersToSpanAttributes type mismatch in packed tarballs + Sentry.nativeNodeFetchIntegration({ + headersToSpanAttributes: { + responseHeaders: ['content-length'], + }, + }), + ], +}); + +import { TRPCError, initTRPC } from '@trpc/server'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import express from 'express'; +import { z } from 'zod'; +import { mcpRouter } from './mcp'; + +const app = express(); +const port = 3030; + +app.use(express.json()); + +app.use(mcpRouter); + +app.get('/crash-in-with-monitor/:id', async (req, res) => { + try { + await Sentry.withMonitor('express-crash', async () => { + throw new Error(`This is an exception withMonitor: ${req.params.id}`); + }); + res.sendStatus(200); + } catch (error: any) { + res.status(500); + res.send({ message: error.message, pid: process.pid }); + } +}); + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-log', function (req, res) { + Sentry.logger.debug('Accessed /test-log route'); + res.send({ message: 'Log sent' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +app.get('/test-outgoing-fetch', async function (_req, res) { + const response = await fetch('http://localhost:3030/test-success'); + const data = await response.json(); + res.send(data); +}); +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-local-variables-uncaught', function (req, res) { + const randomVariableToRecord = Math.random(); + throw new Error(`Uncaught Local Variable Error - ${JSON.stringify({ randomVariableToRecord })}`); +}); + +app.get('/test-local-variables-caught', function (req, res) { + const randomVariableToRecord = Math.random(); + + let exceptionId: string; + try { + throw new Error('Local Variable Error'); + } catch (e) { + exceptionId = Sentry.captureException(e); + } + + res.send({ exceptionId, randomVariableToRecord }); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); + +export const t = initTRPC.context().create(); + +const procedure = t.procedure.use(Sentry.trpcMiddleware({ attachRpcInput: true })); + +export const appRouter = t.router({ + getSomething: procedure.input(z.string()).query(opts => { + return { id: opts.input, name: 'Bilbo' }; + }), + createSomething: procedure.mutation(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + return { success: true }; + }), + crashSomething: procedure + .input(z.object({ nested: z.object({ nested: z.object({ nested: z.string() }) }) })) + .mutation(() => { + throw new Error('I crashed in a trpc handler'); + }), + badRequest: procedure.mutation(() => { + throw new TRPCError({ code: 'BAD_REQUEST', cause: new Error('Bad Request') }); + }), +}); + +export type AppRouter = typeof appRouter; + +const createContext = () => ({ someStaticValue: 'asdf' }); +type Context = Awaited>; + +app.use( + '/trpc', + trpcExpress.createExpressMiddleware({ + router: appRouter, + createContext, + }), +); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts new file mode 100644 index 000000000000..72c4535a3d6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/mcp.ts @@ -0,0 +1,221 @@ +import { randomUUID } from 'node:crypto'; +import express from 'express'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +// Helper to check if request is an initialize request (compatible with all MCP SDK versions) +function isInitializeRequest(body: unknown): boolean { + return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize'; +} + +const mcpRouter = express.Router(); + +const server = wrapMcpServerWithSentry( + new McpServer({ + name: 'Echo', + version: '1.0.0', + }), +); + +server.resource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], +})); + +server.tool('echo', { message: z.string() }, async ({ message }, rest) => { + return { + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }; +}); + +server.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + +server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +server.tool('always-error', {}, async () => { + throw new Error('intentional error for span status testing'); +}); + +const transports: Record = {}; + +mcpRouter.get('/sse', async (_, res) => { + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on('close', () => { + delete transports[transport.sessionId]; + }); + await server.connect(transport); +}); + +mcpRouter.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = transports[sessionId as string]; + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +// ============================================================================= +// Streamable HTTP Transport Endpoints +// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport +// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation +// See: https://github.com/getsentry/sentry-mcp/issues/767 +// ============================================================================= + +// Create a separate wrapped server for streamable HTTP (to test independent of SSE) +const streamableServer = wrapMcpServerWithSentry( + new McpServer({ + name: 'Echo-Streamable', + version: '1.0.0', + }), +); + +// Register the same handlers on the streamable server +streamableServer.resource( + 'echo', + new ResourceTemplate('echo://{message}', { list: undefined }), + async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], + }), +); + +streamableServer.tool('echo', { message: z.string() }, async ({ message }) => { + return { + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }; +}); + +streamableServer.registerTool( + 'echo-register', + { description: 'Echo tool (register API)', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `registerTool echo: ${message}` }], + }), +); + +streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +// Map to store streamable transports by session ID +const streamableTransports: Record = {}; + +// POST endpoint for streamable HTTP (handles both initialization and subsequent requests) +mcpRouter.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && streamableTransports[sessionId]) { + // Reuse existing transport for session + transport = streamableTransports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request - create new transport + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + // Store transport when session is initialized + streamableTransports[sid] = transport; + }, + }); + + // Clean up on close + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && streamableTransports[sid]) { + delete streamableTransports[sid]; + } + }; + + // Connect to server before handling request + await streamableServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: null, + }); + return; + } + + // Handle request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling streamable HTTP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } +}); + +// GET endpoint for SSE streams (server-initiated messages) +mcpRouter.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !streamableTransports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = streamableTransports[sessionId]; + await transport.handleRequest(req, res); +}); + +// DELETE endpoint for session termination +mcpRouter.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !streamableTransports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = streamableTransports[sessionId]; + await transport.handleRequest(req, res); +}); + +export { mcpRouter }; diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..4ae5a5eab608 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts new file mode 100644 index 000000000000..628a48c56456 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is an exception with id 123'); + expect(exception?.mechanism).toEqual({ + type: 'auto.middleware.express', + handled: false, + }); + + expect(errorEvent.request).toMatchObject({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-streaming', event => { + return event.transaction === 'GET /test-local-variables-caught'; + }); + + await fetch(`${baseURL}/test-local-variables-caught`); + + const errorEvent = await errorEventPromise; + + const frames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + expect(frames?.[frames.length - 1]?.vars?.randomVariableToRecord).toBeDefined(); +}); + +test('To not crash app from withMonitor', async ({ baseURL }) => { + const doRequest = async (id: number) => { + const response = await fetch(`${baseURL}/crash-in-with-monitor/${id}`); + return response.json(); + }; + const [response1, response2] = await Promise.all([doRequest(1), doRequest(2)]); + expect(response1.message).toBe('This is an exception withMonitor: 1'); + expect(response2.message).toBe('This is an exception withMonitor: 2'); + expect(response1.pid).toBe(response2.pid); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts new file mode 100644 index 000000000000..fddd80692dd0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +test('should send logs', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-express-streaming', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts new file mode 100644 index 000000000000..ec82de5af455 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/mcp.test.ts @@ -0,0 +1,302 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// TODO: MCP handler spans (tools/call, resources/read, etc.) are not emitted as streamed spans +// with SSE transport — only the POST /messages HTTP server span arrives in the envelope. +// Re-enable once the MCP instrumentation supports span streaming over SSE. +test.skip('Should record streamed spans for mcp handlers', async ({ baseURL }) => { + const transport = new SSEClientTransport(new URL(`${baseURL}/sse`)); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const initializeSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'initialize' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSpan = await initializeSpanPromise; + expect(initializeSpan).toBeDefined(); + expect(getSpanOp(initializeSpan)).toBe('mcp.server'); + expect(initializeSpan.attributes?.['mcp.method.name']?.value).toBe('initialize'); + expect(initializeSpan.attributes?.['mcp.client.name']?.value).toBe('test-client'); + expect(initializeSpan.attributes?.['mcp.server.name']?.value).toBe('Echo'); + }); + + await test.step('tool handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call echo' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + }); + + await test.step('registerTool handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call echo-register' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + expect(toolSpan.attributes?.['mcp.tool.name']?.value).toBe('echo-register'); + }); + + await test.step('resource handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'resources/read echo://foobar' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const resourceSpan = await resourceSpanPromise; + expect(resourceSpan).toBeDefined(); + expect(getSpanOp(resourceSpan)).toBe('mcp.server'); + expect(resourceSpan.attributes?.['mcp.method.name']?.value).toBe('resources/read'); + }); + + await test.step('prompt handler', async () => { + const postSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + const promptSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'prompts/get echo' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: foobar', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const postSpan = await postSpanPromise; + expect(postSpan).toBeDefined(); + expect(getSpanOp(postSpan)).toBe('http.server'); + + const promptSpan = await promptSpanPromise; + expect(promptSpan).toBeDefined(); + expect(getSpanOp(promptSpan)).toBe('mcp.server'); + expect(promptSpan.attributes?.['mcp.method.name']?.value).toBe('prompts/get'); + }); + + await test.step('error tool sets span status to error', async () => { + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'tools/call always-error' && getSpanOp(span) === 'mcp.server' && span.is_segment; + }); + + try { + await client.callTool({ name: 'always-error', arguments: {} }); + } catch { + // Expected: MCP SDK throws when the tool returns a JSON-RPC error + } + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.status).toBe('error'); + }); +}); + +test('Should record streamed spans for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => { + const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); + + const client = new Client({ + name: 'test-client-streamable', + version: '1.0.0', + }); + + const initializeSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'initialize' && + getSpanOp(span) === 'mcp.server' && + span.attributes?.['mcp.server.name']?.value === 'Echo-Streamable' + ); + }); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSpan = await initializeSpanPromise; + expect(initializeSpan).toBeDefined(); + expect(getSpanOp(initializeSpan)).toBe('mcp.server'); + expect(initializeSpan.attributes?.['mcp.method.name']?.value).toBe('initialize'); + expect(initializeSpan.attributes?.['mcp.client.name']?.value).toBe('test-client-streamable'); + expect(initializeSpan.attributes?.['mcp.server.name']?.value).toBe('Echo-Streamable'); + expect(String(initializeSpan.attributes?.['mcp.transport']?.value)).toMatch(/StreamableHTTPServerTransport/); + }); + + await test.step('tool handler (tests wrapper transport correlation)', async () => { + const toolSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'tools/call echo' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'wrapper-transport-test', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: wrapper-transport-test', + type: 'text', + }, + ], + }); + + const toolSpan = await toolSpanPromise; + expect(toolSpan).toBeDefined(); + expect(getSpanOp(toolSpan)).toBe('mcp.server'); + expect(toolSpan.attributes?.['mcp.method.name']?.value).toBe('tools/call'); + expect(toolSpan.attributes?.['mcp.tool.name']?.value).toBe('echo'); + expect(toolSpan.attributes?.['mcp.tool.result.content_count']?.value).toBe(1); + }); + + await test.step('resource handler', async () => { + const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'resources/read echo://streamable-test' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const resourceResult = await client.readResource({ + uri: 'echo://streamable-test', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }], + }); + + const resourceSpan = await resourceSpanPromise; + expect(resourceSpan).toBeDefined(); + expect(getSpanOp(resourceSpan)).toBe('mcp.server'); + expect(resourceSpan.attributes?.['mcp.method.name']?.value).toBe('resources/read'); + }); + + await test.step('prompt handler', async () => { + const promptSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'prompts/get echo' && + getSpanOp(span) === 'mcp.server' && + String(span.attributes?.['mcp.transport']?.value).includes('StreamableHTTPServerTransport') + ); + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'streamable-prompt', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: streamable-prompt', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const promptSpan = await promptSpanPromise; + expect(promptSpan).toBeDefined(); + expect(getSpanOp(promptSpan)).toBe('mcp.server'); + expect(promptSpan.attributes?.['mcp.method.name']?.value).toBe('prompts/get'); + }); + + await client.close(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts new file mode 100644 index 000000000000..b6bc94e19232 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/misc.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/node'; + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('node-express-streaming', () => true); + + await fetch(`${baseURL}/test-exception/123`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/transactions.test.ts new file mode 100644 index 000000000000..38cb1402c623 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/transactions.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +test('Sends streamed spans for an API route', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('node-express-streaming', spans => { + return spans.some( + span => span.name === 'GET /test-transaction' && getSpanOp(span) === 'http.server' && span.is_segment, + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const spans = await spansPromise; + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan).toBeDefined(); + expect(rootSpan!.name).toBe('GET /test-transaction'); + expect(getSpanOp(rootSpan!)).toBe('http.server'); + expect(rootSpan!.status).toBe('ok'); + expect(rootSpan!.trace_id).toMatch(/[a-f0-9]{32}/); + expect(rootSpan!.attributes?.['sentry.source']?.value).toBe('route'); + expect(rootSpan!.attributes?.['sentry.origin']?.value).toBe('auto.http.otel.http'); + expect(rootSpan!.attributes?.['http.response.status_code']?.value).toBe(200); + + const childSpans = spans.filter(span => !span.is_segment); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'test-span', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'query', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: 'expressInit', + is_segment: false, + status: 'ok', + }), + ); + + expect(childSpans).toContainEqual( + expect.objectContaining({ + name: '/test-transaction', + is_segment: false, + status: 'ok', + }), + ); + + // All spans share the same trace_id + for (const span of spans) { + expect(span.trace_id).toBe(rootSpan!.trace_id); + } +}); + +test('Sends streamed spans for an errored route', async ({ baseURL }) => { + const rootSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'GET /test-exception/:id' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + await fetch(`${baseURL}/test-exception/777`); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('GET /test-exception/:id'); + expect(getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan.status).toBe('error'); + expect(rootSpan.attributes?.['http.status_code']?.value).toBe(500); +}); + +test('Outgoing fetch spans are streamed', async ({ baseURL }) => { + const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const fetchSpan = await fetchSpanPromise; + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan.status).toBe('ok'); +}); + +// TODO: headersToSpanAttributes has a pre-existing type error in packed tarballs (also affects the +// non-streaming node-express app). Re-enable once the NodeFetchOptions type is fixed upstream. +test.skip('Outgoing fetch spans include response headers when headersToSpanAttributes is configured', async ({ + baseURL, +}) => { + const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success'); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const fetchSpan = await fetchSpanPromise; + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan.attributes?.['http.response.header.content-length']).toBeDefined(); +}); + +test('Extracts HTTP request headers as streamed span attributes', async ({ baseURL }) => { + const rootSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return ( + span.name === 'GET /test-transaction' && + getSpanOp(span) === 'http.server' && + span.is_segment && + span.attributes?.['http.request.header.user_agent']?.value === 'Custom-Agent/1.0 (Test)' + ); + }); + + await fetch(`${baseURL}/test-transaction`, { + headers: { + 'User-Agent': 'Custom-Agent/1.0 (Test)', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'test-value', + Accept: 'application/json, text/plain', + 'X-Request-ID': 'req-123', + }, + }); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.attributes?.['http.request.header.user_agent']?.value).toBe('Custom-Agent/1.0 (Test)'); + expect(rootSpan.attributes?.['http.request.header.content_type']?.value).toBe('application/json'); + expect(rootSpan.attributes?.['http.request.header.x_custom_header']?.value).toBe('test-value'); + expect(rootSpan.attributes?.['http.request.header.accept']?.value).toBe('application/json, text/plain'); + expect(rootSpan.attributes?.['http.request.header.x_request_id']?.value).toBe('req-123'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts new file mode 100644 index 000000000000..bc0b982adbe9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/trpc.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '../src/app'; + +test('Should record streamed span for trpc query', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/getSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.getSomething.query('foobar'); + + const trpcSpan = await trpcSpanPromise; + expect(trpcSpan).toBeDefined(); + expect(trpcSpan.name).toBe('trpc/getSomething'); + expect(getSpanOp(trpcSpan)).toBe('rpc.server'); + expect(trpcSpan.attributes?.['sentry.origin']?.value).toBe('auto.rpc.trpc'); +}); + +test('Should record streamed span for trpc mutation', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/createSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.createSomething.mutate(); + + const trpcSpan = await trpcSpanPromise; + expect(trpcSpan).toBeDefined(); + expect(trpcSpan.name).toBe('trpc/createSomething'); + expect(getSpanOp(trpcSpan)).toBe('rpc.server'); + expect(trpcSpan.attributes?.['sentry.origin']?.value).toBe('auto.rpc.trpc'); +}); + +test('Should record streamed span and error for a crashing trpc handler', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/crashSomething' && getSpanOp(span) === 'rpc.server'; + }); + + const errorEventPromise = waitForError('node-express-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('I crashed in a trpc handler')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.crashSomething.mutate({ nested: { nested: { nested: 'foobar' } } })).rejects.toBeDefined(); + + await expect(trpcSpanPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); + + expect((await errorEventPromise).contexts?.trpc?.['procedure_type']).toBe('mutation'); + expect((await errorEventPromise).contexts?.trpc?.['procedure_path']).toBe('crashSomething'); + + expect((await errorEventPromise).contexts?.trpc?.['input']).toEqual({ + nested: { + nested: { + nested: 'foobar', + }, + }, + }); +}); + +test('Should record streamed span and error for a trpc handler that returns a status code', async ({ baseURL }) => { + const trpcSpanPromise = waitForStreamedSpan('node-express-streaming', span => { + return span.name === 'trpc/badRequest' && getSpanOp(span) === 'rpc.server'; + }); + + const errorEventPromise = waitForError('node-express-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Bad Request')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.badRequest.mutate()).rejects.toBeDefined(); + + await expect(trpcSpanPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json new file mode 100644 index 000000000000..0060abd94682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} From cedbe4b89cc5278cb641222bc368038545b7ca41 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 15:47:20 +0200 Subject: [PATCH 2/3] fix(e2e): Remove stale @ts-expect-error directive CI builds fresh tarballs where the type is correct, so the directive is unused. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test-applications/node-express-streaming/src/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts index 9bb66ea83f0d..5a0d1afa4141 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts @@ -11,7 +11,6 @@ Sentry.init({ traceLifecycle: 'stream', integrations: [ Sentry.spanStreamingIntegration(), - // @ts-expect-error - headersToSpanAttributes type mismatch in packed tarballs Sentry.nativeNodeFetchIntegration({ headersToSpanAttributes: { responseHeaders: ['content-length'], From 78cb40f2725b328649dbf8b3e2a0798f50710bef Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 16:42:03 +0200 Subject: [PATCH 3/3] refactor(e2e): Rename transactions.test.ts to spans.test.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/{transactions.test.ts => spans.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dev-packages/e2e-tests/test-applications/node-express-streaming/tests/{transactions.test.ts => spans.test.ts} (100%) diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-express-streaming/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/node-express-streaming/tests/spans.test.ts