From a29fce7b6a71c8075b4eba66e875c293578f4284 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Sep 2026 14:45:56 +0200 Subject: [PATCH 1/2] test(e2e): Add Fastify v6 e2e app Co-Authored-By: Claude Fable 5.1 --- .../node-fastify-6/.gitignore | 1 + .../node-fastify-6/package.json | 38 ++ .../node-fastify-6/playwright.config.mjs | 7 + .../node-fastify-6/src/app.ts | 193 ++++++++ .../node-fastify-6/start-event-proxy.mjs | 6 + .../node-fastify-6/tests/errors.test.ts | 106 +++++ .../node-fastify-6/tests/propagation.test.ts | 445 ++++++++++++++++++ .../node-fastify-6/tests/spans.test.ts | 147 ++++++ .../node-fastify-6/tsconfig.json | 10 + .../src/integrations/fastify/index.ts | 2 +- .../integrations/fastify/instrumentation.ts | 2 +- 11 files changed, 955 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/tests/propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/tests/spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-fastify-6/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/.gitignore b/dev-packages/e2e-tests/test-applications/node-fastify-6/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json new file mode 100644 index 000000000000..89290ff82300 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-fastify-6", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "ts-node src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "typecheck": "tsc", + "test:build": "pnpm install && pnpm run typecheck", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@types/node": "^18.19.1", + "fastify": "6.0.0-alpha.3", + "typescript": "5.6.3", + "ts-node": "10.9.2" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "node": "22.22.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true, + "variants": [ + { + "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", + "assert-command": "E2E_TEST_OTEL_SETUP=true pnpm test:assert", + "label": "node-fastify-6 (tracer provider)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-fastify-6/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/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-fastify-6/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-6/src/app.ts new file mode 100644 index 000000000000..d2135c2e8c69 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/src/app.ts @@ -0,0 +1,193 @@ +import type * as S from '@sentry/node'; +const Sentry = require('@sentry/node') as typeof S; + +// Fail the app on any SDK warning +console.warn = new Proxy(console.warn, { + apply: function (target, thisArg, argumentsList) { + const msg = argumentsList[0]; + if (typeof msg === 'string' && msg.startsWith('[Sentry]')) { + console.error(`Sentry warning was triggered: ${msg}`); + process.exit(1); + } + + return target.apply(thisArg, argumentsList); + }, +}); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + integrations: [ + Sentry.fastifyIntegration({ + shouldHandleError: (_error, request, _reply) => { + if (request.routeOptions?.url?.includes('/test-error-not-captured')) { + return false; + } + + if (request.routeOptions?.url?.includes('/test-error-ignored') && _reply.statusCode === 500) { + return false; + } + + return true; + }, + }), + ], + tracesSampleRate: 1, + tunnel: 'http://localhost:3031/', // proxy server + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], + // Only the "(tracer provider)" variant opts in; `undefined` keeps the SDK default + enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, +}); + +import type * as H from 'http'; +import type * as F from 'fastify'; + +// Fastify must load after Sentry.init() +const { fastify } = require('fastify') as typeof F; +const http = require('http') as typeof H; + +const app = fastify(); +const port = 3030; +const port2 = 3040; + +app.get('/test-success', function (_req, res) { + res.send({ version: 'v1' }); +}); + +app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { + const headers = req.headers; + + res.send({ headers, id: req.params.id }); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { + const id = req.params.id; + const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); + + res.send(data); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { + const id = req.params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + + res.send(data); +}); + +app.get('/test-transaction', async function (req, res) { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + + res.send({}); +}); + +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 }); +}); + +// Regression test for https://github.com/fastify/fastify/issues/6409 (fixed in Fastify 5.7.0) +app.register((childApp: F.FastifyInstance, _options: F.FastifyPluginOptions, next: (err?: Error) => void) => { + childApp.setErrorHandler((error: Error, _request: F.FastifyRequest, reply: F.FastifyReply) => { + reply.send({ ok: false }); + }); + + childApp.get('/test-error-ignored', async function () { + throw new Error('This is an error that will not be captured'); + }); + + next(); +}); + +app.get('/test-error-not-captured', async function () { + throw new Error('This is an error that will not be captured'); +}); + +app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-fetch-external-disallowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-http-external-allowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`); + res.send(data); +}); + +app.get('/test-outgoing-http-external-disallowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`); + res.send(data); +}); + +app.post('/test-post', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + +app.get('/flush', async function (_req, res) { + await Sentry.flush(); + res.send({ ok: true }); +}); + +app.listen({ port: port }); + +// External target for propagation tests +const app2 = fastify(); +app2.get('/external-allowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-allowed' }); +}); + +app2.get('/external-disallowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-disallowed' }); +}); + +app2.listen({ port: port2 }); + +function makeHttpRequest(url: string) { + return new Promise(resolve => { + const data: any[] = []; + + http + .request(url, httpRes => { + httpRes.on('data', chunk => { + data.push(chunk); + }); + httpRes.on('error', error => { + resolve({ error: error.message, url }); + }); + httpRes.on('end', () => { + try { + const json = JSON.parse(Buffer.concat(data).toString()); + resolve(json); + } catch { + resolve({ data: Buffer.concat(data).toString(), url }); + } + }); + }) + .end(); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-fastify-6/start-event-proxy.mjs new file mode 100644 index 000000000000..3baa971f4ac3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-fastify-6', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/errors.test.ts new file mode 100644 index 000000000000..716d02b7c4f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/errors.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpan, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-fastify-6', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + const segmentEventPromise = collectStreamedSpansUntilSegment('node-fastify-6', 'GET /test-exception/:id'); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + const segmentEventSpans = await segmentEventPromise; + const segmentEvent = segmentEventSpans.find( + segment => segment.is_segment && segment.name === 'GET /test-exception/:id', + )!; + + 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.function.fastify', + handled: false, + }); + + expect(errorEvent.request).toEqual({ + 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 belongs to the request segment's trace and one of its spans + const segmentTrace = segmentEvent; + expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentTrace?.trace_id); + + const segmentSpanIds = [ + segmentTrace?.span_id, + ...segmentEventSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id) + .map(span => span.span_id), + ]; + expect(segmentSpanIds).toContain(errorEvent.contexts?.trace?.span_id); +}); + +test('Does not send error when shouldHandleError returns false', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError('node-fastify-6', event => { + if (!event.type && event.exception?.values?.[0]?.value === 'This is an error that will not be captured') { + errorEventOccurred = true; + } + return event?.transaction === 'GET /test-error-not-captured'; + }); + + const segmentEventPromise = waitForStreamedSpan( + 'node-fastify-6', + segment => segment.is_segment && segment.name === 'GET /test-error-not-captured', + ); + + const response = await fetch(`${baseURL}/test-error-not-captured`); + + await segmentEventPromise; + + const flushResponse = await fetch(`${baseURL}/flush`); + + expect(response.status).toBe(500); + expect(flushResponse.status).toBe(200); + expect(errorEventOccurred).toBe(false); +}); + +// Regression test for https://github.com/fastify/fastify/issues/6409 (fixed in Fastify 5.7.0) +test('Error in child plugin with rethrown error handler reports correct 500 status', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError('node-fastify-6', event => { + if (!event.type && event.exception?.values?.[0]?.value === 'This is an error that will not be captured') { + errorEventOccurred = true; + } + return event?.transaction === 'GET /test-error-ignored'; + }); + + const segmentEventPromise = waitForStreamedSpan( + 'node-fastify-6', + segment => segment.is_segment && segment.name === 'GET /test-error-ignored', + ); + + const response = await fetch(`${baseURL}/test-error-ignored`); + + await segmentEventPromise; + + const flushResponse = await fetch(`${baseURL}/flush`); + + expect(response.status).toBe(500); + expect(flushResponse.status).toBe(200); + expect(errorEventOccurred).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/propagation.test.ts new file mode 100644 index 000000000000..8132470c8e38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/propagation.test.ts @@ -0,0 +1,445 @@ +import crypto from 'crypto'; +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +test('Propagates trace for outgoing http requests', async ({ baseURL }) => { + const id = crypto.randomUUID(); + + const inboundSegmentPromise = waitForStreamedSpan( + 'node-fastify-6', + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-inbound-headers/${id}`, + ); + + const outboundSegmentPromise = collectStreamedSpansUntilSegment( + 'node-fastify-6', + segment => + getSpanOp(segment) === 'http.server' && segment.attributes?.['url.path']?.value === `/test-outgoing-http/${id}`, + ); + + const response = await fetch(`${baseURL}/test-outgoing-http/${id}`); + const data = await response.json(); + + const inboundSegment = await inboundSegmentPromise; + const outboundSegmentSpans = await outboundSegmentPromise; + const outboundSegment = outboundSegmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-http/${id}`, + )!; + + const traceId = outboundSegment?.trace_id; + const outgoingHttpSpan = outboundSegmentSpans + .filter(span => !span.is_segment) + ?.find(span => getSpanOp(span) === 'http.client'); + + expect(outgoingHttpSpan).toBeDefined(); + + const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + + const outgoingHttpSpanData = outgoingHttpSpan?.attributes || {}; + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + + expect(traceId).toEqual(expect.any(String)); + + // `data` echoes the inbound request headers + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + expect(outboundSegment).toEqual( + expect.objectContaining({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: traceId, + attributes: expect.objectContaining({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.origin': { value: 'auto.http.http_server', type: 'string' }, + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'url.full': { value: `http://localhost:3030/test-outgoing-http/${id}`, type: 'string' }, + 'url.path': { value: `/test-outgoing-http/${id}`, type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.scheme': { value: 'http', type: 'string' }, + 'user_agent.original': { value: 'node', type: 'string' }, + 'client.address': { value: '::1', type: 'string' }, + 'client.port': { value: expect.any(Number), type: 'integer' }, + 'network.transport': { value: 'tcp', type: 'string' }, + 'network.local.address': { value: expect.any(String), type: 'string' }, + 'network.local.port': { value: expect.any(Number), type: 'integer' }, + 'network.peer.address': { value: expect.any(String), type: 'string' }, + 'network.peer.port': { value: expect.any(Number), type: 'integer' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'server.port': { value: 3030, type: 'integer' }, + 'http.response.status_text': { value: 'OK', type: 'string' }, + 'http.route': { value: '/test-outgoing-http/:id', type: 'string' }, + 'http.request.header.accept': { value: '*/*', type: 'string' }, + 'http.request.header.accept_encoding': { value: 'gzip, deflate', type: 'string' }, + 'http.request.header.accept_language': { value: '*', type: 'string' }, + 'http.request.header.connection': { value: 'keep-alive', type: 'string' }, + 'http.request.header.host': { value: 'localhost:3030', type: 'string' }, + 'http.request.header.sec_fetch_mode': { value: 'cors', type: 'string' }, + 'http.request.header.user_agent': { value: 'node', type: 'string' }, + }), + }), + ); + + expect(inboundSegment).toEqual( + expect.objectContaining({ + parent_span_id: outgoingHttpSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: traceId, + attributes: expect.objectContaining({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.origin': { value: 'auto.http.http_server', type: 'string' }, + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'url.full': { value: `http://localhost:3030/test-inbound-headers/${id}`, type: 'string' }, + 'url.path': { value: `/test-inbound-headers/${id}`, type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.scheme': { value: 'http', type: 'string' }, + 'client.address': { value: '::1', type: 'string' }, + 'client.port': { value: expect.any(Number), type: 'integer' }, + 'network.transport': { value: 'tcp', type: 'string' }, + 'network.local.address': { value: expect.any(String), type: 'string' }, + 'network.local.port': { value: expect.any(Number), type: 'integer' }, + 'network.peer.address': { value: expect.any(String), type: 'string' }, + 'network.peer.port': { value: expect.any(Number), type: 'integer' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'server.port': { value: 3030, type: 'integer' }, + 'http.response.status_text': { value: 'OK', type: 'string' }, + 'http.route': { value: '/test-inbound-headers/:id', type: 'string' }, + 'http.request.header.baggage': { value: expect.any(String), type: 'string' }, + 'http.request.header.connection': { value: 'keep-alive', type: 'string' }, + 'http.request.header.host': { value: expect.any(String), type: 'string' }, + 'http.request.header.sentry_trace': { + value: expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + type: 'string', + }, + }), + }), + ); +}); + +test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = crypto.randomUUID(); + + const inboundSegmentPromise = waitForStreamedSpan( + 'node-fastify-6', + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-inbound-headers/${id}`, + ); + + const outboundSegmentPromise = collectStreamedSpansUntilSegment( + 'node-fastify-6', + segment => + getSpanOp(segment) === 'http.server' && segment.attributes?.['url.path']?.value === `/test-outgoing-fetch/${id}`, + ); + + const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); + const data = await response.json(); + + const inboundSegment = await inboundSegmentPromise; + const outboundSegmentSpans = await outboundSegmentPromise; + const outboundSegment = outboundSegmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-fetch/${id}`, + )!; + + const traceId = outboundSegment?.trace_id; + const outgoingHttpSpan = outboundSegmentSpans + .filter(span => !span.is_segment) + ?.find(span => getSpanOp(span) === 'http.client'); + + expect(outgoingHttpSpan).toBeDefined(); + + const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + + const outgoingHttpSpanData = outgoingHttpSpan?.attributes || {}; + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + + expect(traceId).toEqual(expect.any(String)); + + // `data` echoes the inbound request headers + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + expect(outboundSegment).toEqual( + expect.objectContaining({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: traceId, + attributes: expect.objectContaining({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.origin': { value: 'auto.http.http_server', type: 'string' }, + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'url.full': { value: `http://localhost:3030/test-outgoing-fetch/${id}`, type: 'string' }, + 'url.path': { value: `/test-outgoing-fetch/${id}`, type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.scheme': { value: 'http', type: 'string' }, + 'user_agent.original': { value: 'node', type: 'string' }, + 'client.address': { value: '::1', type: 'string' }, + 'client.port': { value: expect.any(Number), type: 'integer' }, + 'network.transport': { value: 'tcp', type: 'string' }, + 'network.local.address': { value: expect.any(String), type: 'string' }, + 'network.local.port': { value: expect.any(Number), type: 'integer' }, + 'network.peer.address': { value: expect.any(String), type: 'string' }, + 'network.peer.port': { value: expect.any(Number), type: 'integer' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'server.port': { value: 3030, type: 'integer' }, + 'http.response.status_text': { value: 'OK', type: 'string' }, + 'http.route': { value: '/test-outgoing-fetch/:id', type: 'string' }, + 'http.request.header.accept': { value: '*/*', type: 'string' }, + 'http.request.header.accept_encoding': { value: 'gzip, deflate', type: 'string' }, + 'http.request.header.accept_language': { value: '*', type: 'string' }, + 'http.request.header.connection': { value: 'keep-alive', type: 'string' }, + 'http.request.header.host': { value: expect.any(String), type: 'string' }, + 'http.request.header.sec_fetch_mode': { value: 'cors', type: 'string' }, + 'http.request.header.user_agent': { value: 'node', type: 'string' }, + }), + }), + ); + + expect(inboundSegment).toEqual( + expect.objectContaining({ + parent_span_id: outgoingHttpSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: traceId, + attributes: expect.objectContaining({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.origin': { value: 'auto.http.http_server', type: 'string' }, + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'url.full': { value: `http://localhost:3030/test-inbound-headers/${id}`, type: 'string' }, + 'url.path': { value: `/test-inbound-headers/${id}`, type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.scheme': { value: 'http', type: 'string' }, + 'client.address': { value: '::1', type: 'string' }, + 'client.port': { value: expect.any(Number), type: 'integer' }, + 'network.transport': { value: 'tcp', type: 'string' }, + 'network.local.address': { value: expect.any(String), type: 'string' }, + 'network.local.port': { value: expect.any(Number), type: 'integer' }, + 'network.peer.address': { value: expect.any(String), type: 'string' }, + 'network.peer.port': { value: expect.any(Number), type: 'integer' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'server.port': { value: 3030, type: 'integer' }, + 'http.response.status_text': { value: 'OK', type: 'string' }, + 'user_agent.original': { value: 'node', type: 'string' }, + 'http.route': { value: '/test-inbound-headers/:id', type: 'string' }, + 'http.request.header.accept': { value: '*/*', type: 'string' }, + 'http.request.header.accept_encoding': { value: 'gzip, deflate', type: 'string' }, + 'http.request.header.accept_language': { value: '*', type: 'string' }, + 'http.request.header.baggage': { value: expect.any(String), type: 'string' }, + 'http.request.header.connection': { value: 'keep-alive', type: 'string' }, + 'http.request.header.host': { value: expect.any(String), type: 'string' }, + 'http.request.header.sec_fetch_mode': { value: 'cors', type: 'string' }, + 'http.request.header.sentry_trace': { + value: expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + type: 'string', + }, + 'http.request.header.user_agent': { value: 'node', type: 'string' }, + }), + }), + ); +}); + +test('Propagates trace for outgoing external http requests', async ({ baseURL }) => { + const inboundSegmentPromise = collectStreamedSpansUntilSegment( + 'node-fastify-6', + segment => + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-http-external-allowed`, + ); + + const response = await fetch(`${baseURL}/test-outgoing-http-external-allowed`); + const data = await response.json(); + + const inboundSegmentSpans = await inboundSegmentPromise; + const inboundSegment = inboundSegmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-http-external-allowed`, + )!; + + const traceId = inboundSegment?.trace_id; + const spanId = inboundSegmentSpans + .filter(span => !span.is_segment) + ?.find(span => getSpanOp(span) === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data).toEqual({ + route: '/external-allowed', + headers: expect.objectContaining({ + 'sentry-trace': `${traceId}-${spanId}-1`, + baggage: expect.any(String), + }), + }); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing http requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundSegmentPromise = collectStreamedSpansUntilSegment( + 'node-fastify-6', + segment => + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-http-external-disallowed`, + ); + + const response = await fetch(`${baseURL}/test-outgoing-http-external-disallowed`); + const data = await response.json(); + + const inboundSegmentSpans = await inboundSegmentPromise; + const inboundSegment = inboundSegmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-http-external-disallowed`, + )!; + + const traceId = inboundSegment?.trace_id; + const spanId = inboundSegmentSpans + .filter(span => !span.is_segment) + ?.find(span => getSpanOp(span) === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data.route).toBe('/external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); + +test('Propagates trace for outgoing external fetch requests', async ({ baseURL }) => { + const inboundSegmentPromise = collectStreamedSpansUntilSegment( + 'node-fastify-6', + segment => + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-fetch-external-allowed`, + ); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); + const data = await response.json(); + + const inboundSegmentSpans = await inboundSegmentPromise; + const inboundSegment = inboundSegmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-fetch-external-allowed`, + )!; + + const traceId = inboundSegment?.trace_id; + const spanId = inboundSegmentSpans + .filter(span => !span.is_segment) + ?.find(span => getSpanOp(span) === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data).toEqual({ + route: '/external-allowed', + headers: expect.objectContaining({ + 'sentry-trace': `${traceId}-${spanId}-1`, + baggage: expect.any(String), + }), + }); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundSegmentPromise = collectStreamedSpansUntilSegment( + 'node-fastify-6', + segment => + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-fetch-external-disallowed`, + ); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); + const data = await response.json(); + + const inboundSegmentSpans = await inboundSegmentPromise; + const inboundSegment = inboundSegmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.attributes?.['url.path']?.value === `/test-outgoing-fetch-external-disallowed`, + )!; + + const traceId = inboundSegment?.trace_id; + const spanId = inboundSegmentSpans + .filter(span => !span.is_segment) + ?.find(span => getSpanOp(span) === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data.route).toBe('/external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/spans.test.ts new file mode 100644 index 000000000000..21283f8ea1a0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/tests/spans.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +test('Sends an API route span', async ({ baseURL }) => { + const pageloadSegmentEventPromise = collectStreamedSpansUntilSegment( + 'node-fastify-6', + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-transaction', + ); + + await fetch(`${baseURL}/test-transaction`); + + const segmentEventSpans = await pageloadSegmentEventPromise; + const segmentEvent = segmentEventSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-transaction', + )!; + + expect(segmentEvent).toEqual( + expect.objectContaining({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + attributes: expect.objectContaining({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.origin': { value: 'auto.http.http_server', type: 'string' }, + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'url.full': { value: 'http://localhost:3030/test-transaction', type: 'string' }, + 'url.path': { value: '/test-transaction', type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.scheme': { value: 'http', type: 'string' }, + 'user_agent.original': { value: 'node', type: 'string' }, + 'client.address': { value: '::1', type: 'string' }, + 'client.port': { value: expect.any(Number), type: 'integer' }, + 'network.transport': { value: 'tcp', type: 'string' }, + 'network.local.address': { value: expect.any(String), type: 'string' }, + 'network.local.port': { value: expect.any(Number), type: 'integer' }, + 'network.peer.address': { value: expect.any(String), type: 'string' }, + 'network.peer.port': { value: expect.any(Number), type: 'integer' }, + 'network.protocol.name': { value: 'http', type: 'string' }, + 'network.protocol.version': { value: '1.1', type: 'string' }, + 'server.port': { value: 3030, type: 'integer' }, + 'http.response.status_text': { value: 'OK', type: 'string' }, + 'http.route': { value: '/test-transaction', type: 'string' }, + 'http.request.header.accept': { value: '*/*', type: 'string' }, + 'http.request.header.accept_encoding': { value: 'gzip, deflate', type: 'string' }, + 'http.request.header.accept_language': { value: '*', type: 'string' }, + 'http.request.header.connection': { value: 'keep-alive', type: 'string' }, + 'http.request.header.host': { value: expect.any(String), type: 'string' }, + 'http.request.header.sec_fetch_mode': { value: 'cors', type: 'string' }, + 'http.request.header.user_agent': { value: 'node', type: 'string' }, + }), + }), + ); + + expect(segmentEvent).toEqual( + expect.objectContaining({ + name: 'GET /test-transaction', + is_segment: true, + attributes: expect.objectContaining({ 'sentry.segment.name.source': { value: 'route', type: 'string' } }), + }), + ); + + const spans = segmentEventSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segmentEvent.span_id, + ); + + expect( + spans.filter(span => span.name === '/test-transaction' && span.attributes['http.request.method']?.value === 'GET'), + ).toEqual([ + expect.objectContaining({ + name: '/test-transaction', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + end_timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.http.fastify', type: 'string' }, + 'sentry.op': { value: 'handler', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.path': { value: '/test-transaction', type: 'string' }, + 'http.route': { value: '/test-transaction', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + }), + }), + ]); + + expect(spans.filter(span => span.name === 'test-span')).toEqual([ + expect.objectContaining({ + name: 'test-span', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + end_timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + attributes: expect.objectContaining({ 'sentry.origin': { value: 'manual', type: 'string' } }), + }), + ]); + + expect(spans.filter(span => span.name === 'child-span')).toEqual([ + expect.objectContaining({ + name: 'child-span', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + end_timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + attributes: expect.objectContaining({ 'sentry.origin': { value: 'manual', type: 'string' } }), + }), + ]); +}); + +test('Captures request metadata', async ({ baseURL }) => { + const segmentEventPromise = waitForStreamedSpan( + 'node-fastify-6', + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'POST /test-post', + ); + + const res = await fetch(`${baseURL}/test-post`, { + method: 'POST', + body: JSON.stringify({ foo: 'bar', other: 1 }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const resBody = await res.json(); + + expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); + + const segmentEvent = await segmentEventPromise; + + expect(segmentEvent.attributes['url.full']?.value).toEqual( + expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + ); + expect(segmentEvent.attributes['http.request.method']?.value).toEqual('POST'); + expect(segmentEvent.attributes['http.request.header.user_agent']?.value).toEqual(expect.stringContaining('')); + expect(segmentEvent.attributes['http.request.header.content_type']?.value).toEqual('application/json'); + + expect(segmentEvent.attributes['http.request.body.data']?.value).toBe(JSON.stringify({ foo: 'bar', other: 1 })); + expect(segmentEvent.attributes['user.ip_address']?.value).toEqual('::1'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-fastify-6/tsconfig.json new file mode 100644 index 000000000000..6b69bfaa593b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "outDir": "dist" + }, + "include": ["./src/*.ts"] +} diff --git a/packages/server-utils/src/integrations/fastify/index.ts b/packages/server-utils/src/integrations/fastify/index.ts index a52a614931d6..bd9b5e467fd1 100644 --- a/packages/server-utils/src/integrations/fastify/index.ts +++ b/packages/server-utils/src/integrations/fastify/index.ts @@ -54,7 +54,7 @@ const _fastifyIntegration = (({ shouldHandleError }: Partial= 3.21.0 < 6) instrumentation by subscribing to the `fastify.initialization` + * Set up the Fastify (>= 3.21.0 < 7) instrumentation by subscribing to the `fastify.initialization` * diagnostics channel and synchronously instrumenting every Fastify instance as it is created. */ export function instrumentFastify(): void { From bb38ddd7606625aaa2cb2a43e70d269e54eed7bd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Sep 2026 15:14:03 +0200 Subject: [PATCH 2/2] test(e2e): Run node-fastify-6 tracer-provider variant in optional CI only Co-Authored-By: Claude Fable 5.1 --- .../e2e-tests/test-applications/node-fastify-6/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json index 89290ff82300..f9c37b7f636c 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json @@ -27,7 +27,7 @@ }, "sentryTest": { "optional": true, - "variants": [ + "optionalVariants": [ { "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", "assert-command": "E2E_TEST_OTEL_SETUP=true pnpm test:assert",