From 4e3b0da82b201b9c7e4bf56b77650ab018613aa7 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Mon, 7 Sep 2026 11:21:08 +0200 Subject: [PATCH] test(e2e): Preserve static Express coverage Co-Authored-By: GPT-6 --- .../node-express-static/.gitignore | 1 + .../node-express-static/package.json | 44 +++ .../node-express-static/playwright.config.mjs | 7 + .../node-express-static/src/app.ts | 175 +++++++++ .../node-express-static/src/mcp.ts | 242 ++++++++++++ .../src/mcpCapturePolicyServer.ts | 16 + .../node-express-static/start-event-proxy.mjs | 6 + .../node-express-static/tests/errors.test.ts | 72 ++++ .../node-express-static/tests/logs.test.ts | 16 + .../node-express-static/tests/mcp.test.ts | 356 ++++++++++++++++++ .../node-express-static/tests/misc.test.ts | 15 + .../tests/transactions.test.ts | 278 ++++++++++++++ .../node-express-static/tests/trpc.test.ts | 132 +++++++ .../node-express-static/tsconfig.json | 11 + 14 files changed, 1371 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/src/mcp.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/src/mcpCapturePolicyServer.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/tests/logs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/tests/mcp.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/tests/misc.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/tests/trpc.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-static/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-static/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/package.json b/dev-packages/e2e-tests/test-applications/node-express-static/package.json new file mode 100644 index 000000000000..d6960ac53e42 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/package.json @@ -0,0 +1,44 @@ +{ + "name": "node-express-static-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" + }, + "sentryTest": { + "variants": [ + { + "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", + "assert-command": "E2E_TEST_OTEL_SETUP=true pnpm test:assert", + "label": "node-express (tracer provider)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-static/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/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-static/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-static/src/app.ts new file mode 100644 index 000000000000..ec030f50e638 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/src/app.ts @@ -0,0 +1,175 @@ +import * as Sentry from '@sentry/node'; +// Keep the dedicated MCP server evaluation ahead of initialization without loading Express before its instrumentation. +import './mcpCapturePolicyServer'; + +declare global { + namespace globalThis { + var transactionIds: string[]; + } +} + +Sentry.init({ + traceLifecycle: 'static', + 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, + dataCollection: { + genAI: { + inputs: false, + outputs: false, + }, + }, + // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. + // Leaving it `undefined` otherwise keeps the SDK's default (no provider). + enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, + integrations: [ + 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 }); +}); + +// @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}`); +}); + +Sentry.addEventProcessor(event => { + global.transactionIds = global.transactionIds || []; + + if (event.type === 'transaction') { + const eventId = event.event_id; + + if (eventId) { + global.transactionIds.push(eventId); + } + } + + return event; +}); + +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-static/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-static/src/mcp.ts new file mode 100644 index 000000000000..116e63849adc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/src/mcp.ts @@ -0,0 +1,242 @@ +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'; +import { capturePolicyServer } from './mcpCapturePolicyServer'; + +// 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 = {}; +const capturePolicyTransports: Record = {}; + +mcpRouter.get('/capture-policy/sse', async (_, res) => { + const transport = new SSEServerTransport('/capture-policy/messages', res); + capturePolicyTransports[transport.sessionId] = transport; + res.on('close', () => { + delete capturePolicyTransports[transport.sessionId]; + }); + await capturePolicyServer.connect(transport); +}); + +mcpRouter.post('/capture-policy/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = capturePolicyTransports[sessionId as string]; + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +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-static/src/mcpCapturePolicyServer.ts b/dev-packages/e2e-tests/test-applications/node-express-static/src/mcpCapturePolicyServer.ts new file mode 100644 index 000000000000..4a85031c8bd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/src/mcpCapturePolicyServer.ts @@ -0,0 +1,16 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { wrapMcpServerWithSentry } from '@sentry/node'; +import { z } from 'zod'; + +export const capturePolicyServer = wrapMcpServerWithSentry( + new McpServer({ + name: 'Capture-Policy', + version: '1.0.0', + }), +); + +capturePolicyServer.tool('capture-policy', { message: z.string() }, async ({ message }) => { + return { + content: [{ type: 'text', text: `Capture policy result: ${message}` }], + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-static/start-event-proxy.mjs new file mode 100644 index 000000000000..1b2d1f2d080c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-static/tests/errors.test.ts new file mode 100644 index 000000000000..da0132bfe78d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/tests/errors.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + const transactionEventPromise = waitForTransaction('node-express-static', event => { + return event.transaction === 'GET /test-exception/:id'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionEventPromise; + + 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.http.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}/), + 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); + + const transactionSpanIds = [transactionTrace?.span_id, ...(transactionEvent.spans ?? []).map(span => span.span_id)]; + expect(transactionSpanIds).toContain(errorEvent.contexts?.trace?.span_id); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-static', 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); //Just to double-check, TBS +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/node-express-static/tests/logs.test.ts new file mode 100644 index 000000000000..389bcd5633d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/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-static', 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-static/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-static/tests/mcp.test.ts new file mode 100644 index 000000000000..599500b3072a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/tests/mcp.test.ts @@ -0,0 +1,356 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } 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'; + +test('Should record transactions 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 initializeTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === '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'); + expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo'); + }); + + await test.step('tool handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo'; + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); + + 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'); + // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction + }); + + await test.step('registerTool handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo-register'; + }); + + const toolResult = await client.callTool({ + name: 'echo-register', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'registerTool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); + + 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-register'); + }); + + await test.step('resource handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const resourceTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'resources/read echo://foobar'; + }); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); + + 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'); + // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction + }); + + await test.step('prompt handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const promptTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'prompts/get echo'; + }); + + 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 postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); + + 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'); + // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction + }); + + await test.step('error tool sets span status to internal_error', async () => { + const toolTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === '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'); + }); +}); + +test('resolves capture policy when the MCP server is wrapped before Sentry.init', async ({ baseURL }) => { + const transport = new SSEClientTransport(new URL(`${baseURL}/capture-policy/sse`)); + const client = new Client({ + name: 'capture-policy-client', + version: '1.0.0', + }); + await client.connect(transport); + + const toolTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return transactionEvent.transaction === 'tools/call capture-policy'; + }); + const privateMessage = 'node-v1-private-capture-policy-message'; + + const toolResult = await client.callTool({ + name: 'capture-policy', + arguments: { + message: privateMessage, + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: `Capture policy result: ${privateMessage}`, + type: 'text', + }, + ], + }); + + const toolTransaction = await toolTransactionPromise; + const traceData = toolTransaction.contexts?.trace?.data; + + expect(traceData?.['mcp.method.name']).toBe('tools/call'); + expect(traceData?.['mcp.tool.name']).toBe('capture-policy'); + expect(traceData?.['mcp.tool.result.content_count']).toBe(1); + expect(traceData?.['mcp.tool.result.content_type']).toBe('text'); + expect(traceData?.['mcp.request.argument.message']).toBeUndefined(); + expect(traceData?.['mcp.tool.result.content']).toBeUndefined(); + expect(JSON.stringify(traceData)).not.toContain(privateMessage); + + await client.close(); +}); + +/** + * Tests for StreamableHTTPServerTransport (wrapper transport pattern) + * + * StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters. + * This causes different `this` values in onmessage vs send, which was breaking span correlation. + * + * The fix uses sessionId as the correlation key instead of transport object reference. + * This test verifies that spans are correctly recorded when using the wrapper transport. + * + * @see https://github.com/getsentry/sentry-mcp/issues/767 + */ +test('Should record transactions 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 initializeTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent.transaction === 'initialize' && + transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable' + ); + }); + + 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-streamable'); + expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable'); + // Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment) + expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/); + }); + + await test.step('tool handler (tests wrapper transport correlation)', async () => { + // This is the critical test - without the sessionId fix, the span would not be completed + // because onmessage and send see different transport instances (wrapper vs inner) + const toolTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined; + return transactionEvent.transaction === 'tools/call echo' && transport?.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 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'); + // This attribute proves the span was completed with results (sessionId correlation worked) + expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1); + }); + + await test.step('resource handler', async () => { + const resourceTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined; + return ( + transactionEvent.transaction === 'resources/read echo://streamable-test' && + transport?.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 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'); + }); + + await test.step('prompt handler', async () => { + const promptTransactionPromise = waitForTransaction('node-express-static', transactionEvent => { + const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined; + return ( + transactionEvent.transaction === 'prompts/get echo' && transport?.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 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'); + }); + + // Clean up - close the client connection + await client.close(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/tests/misc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-static/tests/misc.test.ts new file mode 100644 index 000000000000..e90c89ae5495 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/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-static', () => 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-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-static/tests/transactions.test.ts new file mode 100644 index 000000000000..ebbc1c9cb648 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/tests/transactions.test.ts @@ -0,0 +1,278 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.segment.name.source': 'route', + 'sentry.origin': 'auto.http.http_server', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + 'sentry.kind': 'server', + 'http.response.status_code': 200, + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', + 'server.address': 'localhost', + 'http.request.method': 'GET', + 'url.scheme': 'http', + 'user_agent.original': 'node', + 'client.address': '::1', + 'client.port': expect.any(Number), + 'network.transport': 'tcp', + 'network.local.address': expect.any(String), + 'network.local.port': expect.any(Number), + 'network.peer.address': expect.any(String), + 'network.peer.port': expect.any(Number), + 'network.protocol.name': 'http', + 'network.protocol.version': '1.1', + 'server.port': 3030, + 'http.response.status_text': 'OK', + 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.http_server', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented spans + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'middleware', + 'express.name': 'query', + 'express.type': 'middleware', + }, + description: 'query', + op: 'middleware', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'middleware', + 'express.name': 'expressInit', + 'express.type': 'middleware', + }, + description: 'expressInit', + op: 'middleware', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'handler', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'handler', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.response.status_code']).toEqual(500); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'middleware', + 'express.name': 'query', + 'express.type': 'middleware', + }, + description: 'query', + op: 'middleware', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'middleware', + 'express.name': 'expressInit', + 'express.type': 'middleware', + }, + description: 'expressInit', + op: 'middleware', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'handler', + 'http.route': '/test-exception/:id', + 'express.name': '/test-exception/:id', + 'express.type': 'request_handler', + 'error.type': 'Error', + }, + description: '/test-exception/:id', + op: 'handler', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'internal_error', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Outgoing fetch spans include response headers when headersToSpanAttributes is configured', async ({ + baseURL, +}) => { + const transactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-outgoing-fetch' + ); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + // Find the outgoing fetch span (http.client operation from undici instrumentation) + const fetchSpan = spans.find( + span => span.op === 'http.client' && span.description?.includes('localhost:3030/test-success'), + ); + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan?.data).toEqual( + expect.objectContaining({ + 'http.response.header.content-length': [expect.any(String)], + }), + ); +}); + +test('Extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + 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 transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-Agent/1.0 (Test)', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_custom_header': 'test-value', + 'http.request.header.accept': 'application/json, text/plain', + 'http.request.header.x_request_id': 'req-123', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-static/tests/trpc.test.ts new file mode 100644 index 000000000000..6ffd40d5a2d7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/tests/trpc.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '../src/app'; + +test('Should record span for trpc query', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/getSomething') + ); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.getSomething.query('foobar'); + + await expect(transactionEventPromise).resolves.toBeDefined(); + const transaction = await transactionEventPromise; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.rpc.trpc', + }), + description: `trpc/getSomething`, + }), + ); +}); + +test('Should record transaction for trpc mutation', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/createSomething') + ); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.createSomething.mutate(); + + await expect(transactionEventPromise).resolves.toBeDefined(); + const transaction = await transactionEventPromise; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.rpc.trpc', + }), + description: `trpc/createSomething`, + }), + ); +}); + +test('Should record transaction and error for a crashing trpc handler', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/crashSomething') + ); + }); + + const errorEventPromise = waitForError('node-express-static', 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(transactionEventPromise).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'); + + // Should record nested context + expect((await errorEventPromise).contexts?.trpc?.['input']).toEqual({ + nested: { + nested: { + nested: 'foobar', + }, + }, + }); +}); + +test('Should record transaction and error for a trpc handler that returns a status code', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-static', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/badRequest') + ); + }); + + const errorEventPromise = waitForError('node-express-static', 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(transactionEventPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-static/tsconfig.json new file mode 100644 index 000000000000..0060abd94682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-static/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}