diff --git a/.size-limit.js b/.size-limit.js index 3819ee2e91f6..36bf0607e840 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -233,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '152 KB', + limit: '154 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts index 07e0467382da..4d3d93fdf81e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts @@ -78,6 +78,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: expect.stringContaining('/test-ssr'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, op: 'http.server', origin: 'auto.http.astro', @@ -223,6 +228,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/myUsername123'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -256,6 +266,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/api/user/myUsername123.json'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -308,6 +323,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/catchAll/hell0/whatever-do'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -360,6 +380,11 @@ test.describe('parametrized vs static paths', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/settings'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts index 9151c13907af..0c69d1f59698 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts @@ -79,6 +79,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: expect.stringContaining('/test-ssr'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, op: 'http.server', origin: 'auto.http.astro', @@ -226,6 +231,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/myUsername123'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -259,6 +269,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/api/user/myUsername123.json'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -311,6 +326,11 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/catchAll/hell0/whatever-do'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, @@ -363,6 +383,11 @@ test.describe('parametrized vs static paths', () => { 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', url: expect.stringContaining('/user-page/settings'), + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'navigate', + 'http.request.header.user_agent': expect.any(String), }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts index c7496d4e6247..354655a4ac7e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts @@ -70,6 +70,11 @@ test.describe('tracing in static routes with server islands', () => { 'sentry.op': 'http.server', 'sentry.origin': 'auto.http.astro', 'sentry.source': 'route', + 'http.request.header.accept': expect.any(String), + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.accept_language': 'en-US', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': expect.any(String), }), op: 'http.server', origin: 'auto.http.astro', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts index 1209eae1ada9..2f314a10817d 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts index be0e03cdbc97..a7d5ed887049 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts index c37eb8da7cc1..143ebcd9a9f0 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts index 78dfe680a453..0a23c1766b38 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + '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}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + '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}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts index ac4c8bdea83e..9730bfd6fa68 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts index 5fb9c98ffc66..77cb616450f9 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction from module', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/example-module/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}/), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts index b95aa1fe4406..63976a559898 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction from module', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/example-module/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}/), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts index 1b3be0840f3f..4f11a03dc6cc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 052dd62697a1..7f9b3e822628 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -6,6 +6,7 @@ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "test:prod": "TEST_ENV=production playwright test", + "test:prod-turbo": "TEST_ENV=prod-turbopack playwright test", "test:dev": "TEST_ENV=development playwright test", "test:dev-turbo": "TEST_ENV=dev-turbopack playwright test", "test:build": "pnpm install && pnpm build", @@ -46,7 +47,7 @@ }, { "build-command": "pnpm test:build-turbo", - "assert-command": "pnpm test:prod && pnpm test:dev-turbo", + "assert-command": "pnpm test:prod-turbo && pnpm test:dev-turbo", "label": "nextjs-15 (turbo)" } ] diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs index e1be6810f4dc..2eaa19f24532 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs @@ -14,7 +14,7 @@ const getStartCommand = () => { return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; } - if (testEnv === 'production') { + if (testEnv === 'production' || testEnv === 'prod-turbopack') { return 'pnpm next start -p 3030'; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts index 3e41c04e2644..3cad4a546508 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts @@ -22,3 +22,38 @@ test('App router transactions should be attached to the pageload request span', expect(pageloadTraceId).toBeTruthy(); expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); }); + +test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { + test.skip( + process.env.TEST_ENV === 'prod-turbopack' || process.env.TEST_ENV === 'dev-turbopack', + 'Incoming fetch request headers are not added as span attributes when Turbopack is enabled (addHeadersAsAttributes)', + ); + + const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; + }); + + await fetch(`${baseURL}/pageload-tracing`, { + headers: { + 'User-Agent': 'Custom-NextJS-Agent/15.0', + 'Content-Type': 'text/html', + 'X-NextJS-Test': 'nextjs-header-value', + Accept: 'text/html, application/xhtml+xml', + 'X-Framework': 'Next.js', + 'X-Request-ID': 'nextjs-789', + }, + }); + + const serverTransaction = await serverTransactionPromise; + + expect(serverTransaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-NextJS-Agent/15.0', + 'http.request.header.content_type': 'text/html', + 'http.request.header.x_nextjs_test': 'nextjs-header-value', + 'http.request.header.accept': 'text/html, application/xhtml+xml', + 'http.request.header.x_framework': 'Next.js', + 'http.request.header.x_request_id': 'nextjs-789', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts index ec02acca77d6..9b06ad052f58 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts @@ -39,6 +39,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts index 86fdffd3b452..048f70a1aba8 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts index b47feebcd728..1ffd9f2e498d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), @@ -208,3 +215,34 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) measurements: {}, }); }); + +test('Extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express', 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-fastify-3/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts index ee097817bafb..1cdfd67a4851 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + '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}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + '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}/), @@ -198,7 +224,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -219,8 +245,18 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'net.peer.port': expect.any(Number), 'http.status_code': 200, 'http.status_text': 'OK', + 'http.user_agent': 'node', 'http.route': '/test-inbound-headers/:id', - }), + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts index d4c10751f4a5..4bf9b00f127d 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts index 3746687b92c1..6e6b20b916e8 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + '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}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + '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}/), @@ -198,7 +224,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -220,7 +246,17 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', - }), + 'http.user_agent': 'node', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts index 1f049b802bca..eadf89abe7ae 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts index 6de3b988c3b5..4e903edf05b5 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts @@ -32,6 +32,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -75,6 +79,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + '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': 'localhost:3030', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -106,6 +117,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -146,6 +161,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -189,6 +208,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + '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}/), @@ -198,7 +224,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -219,8 +245,18 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'net.peer.port': expect.any(Number), 'http.status_code': 200, 'http.status_text': 'OK', + 'http.user_agent': 'node', 'http.route': '/test-inbound-headers/:id', - }), + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.any(String), + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts index e148c8158cd8..3a00e0616f57 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts index 3f332992d0e7..bd6540b088d3 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts @@ -37,6 +37,13 @@ test('Sends successful transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-success', + '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}/), @@ -57,6 +64,10 @@ test('Sends successful transaction', async ({ baseURL }) => { const spans = transactionEvent.spans || []; + spans.forEach(span => { + expect(Object.keys(span.data).some(key => key.startsWith('http.request.header.'))).toBe(false); + }); + expect(spans).toEqual([ { data: { diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts index f7e8639b7ace..592c5a4717f4 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts @@ -31,6 +31,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -74,6 +78,13 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-http/:id', + '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}/), @@ -105,6 +116,10 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', + 'http.request.header.baggage': expect.stringContaining(traceId!), // we already check if traceId is defined + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), }, op: 'http.server', parent_span_id: outgoingHttpSpanId, @@ -145,6 +160,10 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + const outgoingHttpSpanData = outgoingHttpSpan?.data || {}; + // Outgoing span (`http.client`) does not include headers as attributes + expect(Object.keys(outgoingHttpSpanData).some(key => key.startsWith('http.request.header.'))).toBe(false); + expect(traceId).toEqual(expect.any(String)); // data is passed through from the inbound request, to verify we have the correct headers set @@ -188,6 +207,13 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-outgoing-fetch/:id', + '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': 'localhost:3030', + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -197,7 +223,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); expect(inboundTransaction.contexts?.trace).toEqual({ - data: expect.objectContaining({ + data: { 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', @@ -219,7 +245,17 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/test-inbound-headers/:id', - }), + 'http.user_agent': 'node', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.baggage': expect.stringContaining(traceId!), // we already check if traceId is defined + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.sentry_trace': expect.stringMatching(/[a-f0-9]{32}-[a-f0-9]{16}-1/), + 'http.request.header.user_agent': 'node', + }, op: 'http.server', parent_span_id: outgoingHttpSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts index 966dbc5937e3..53803a8882e6 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts index 5ca9077634d2..84d783b1d567 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts @@ -36,6 +36,13 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.status_text': 'OK', 'http.route': '/task', + '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', }, origin: 'auto.http.otel.http', op: 'http.server', diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts index 3e12007c0d75..a586146eece5 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts @@ -50,6 +50,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts index c6abde474439..1f79fd438719 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts @@ -50,6 +50,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts index f1df13a71ab3..e7b6a5ddfc09 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.server.test.ts @@ -43,3 +43,33 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); }); + +test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction.includes('GET /api/test-param/'); + }); + + await fetch(`${baseURL}/api/test-param/headers-test`, { + headers: { + 'User-Agent': 'Custom-Nuxt-Agent/3.0', + 'Content-Type': 'application/json', + 'X-Nuxt-Test': 'nuxt-header-value', + Accept: 'application/json, text/html', + 'X-Framework': 'Nuxt', + 'X-Request-ID': 'nuxt-456', + }, + }); + + const transaction = await transactionPromise; + + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-Nuxt-Agent/3.0', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_nuxt_test': 'nuxt-header-value', + 'http.request.header.accept': 'application/json, text/html', + 'http.request.header.x_framework': 'Nuxt', + 'http.request.header.x_request_id': 'nuxt-456', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts index 4cc3fb5cef9e..9fd87b052374 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts @@ -32,3 +32,33 @@ test('server pageload request span has nested request span for sub request', asy ]), ); }); + +test('extracts HTTP request headers as span attributes', async ({ page, baseURL }) => { + const serverTxnEventPromise = waitForTransaction('sveltekit-2', txnEvent => { + return txnEvent?.transaction === 'GET /api/users'; + }); + + await fetch(`${baseURL}/api/users`, { + headers: { + 'User-Agent': 'Custom-SvelteKit-Agent/1.0', + 'Content-Type': 'application/json', + 'X-Test-Header': 'sveltekit-test-value', + Accept: 'application/json', + 'X-Framework': 'SvelteKit', + 'X-Request-ID': 'sveltekit-123', + }, + }); + + const serverTxnEvent = await serverTxnEventPromise; + + expect(serverTxnEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-SvelteKit-Agent/1.0', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_test_header': 'sveltekit-test-value', + 'http.request.header.accept': 'application/json', + 'http.request.header.x_framework': 'SvelteKit', + 'http.request.header.x_request_id': 'sveltekit-123', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts index 2a0ca499d02c..fca8f1b85528 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts @@ -38,6 +38,13 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'http.status_code': 200, 'http.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}/), diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index d135ca97ccb1..48fab1be2e9d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -2,6 +2,18 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; import { createTestServer } from '../../../utils/server'; +function getCommonHttpRequestHeaders(): Record { + return { + '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', + }; +} + describe('httpIntegration', () => { afterAll(() => { cleanupChildProcesses(); @@ -118,6 +130,7 @@ describe('httpIntegration', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: `http://localhost:${port}/test`, + ...getCommonHttpRequestHeaders(), }); }, }) @@ -159,6 +172,9 @@ describe('httpIntegration', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: `http://localhost:${port}/test`, + 'http.request.header.content_length': '9', + 'http.request.header.content_type': 'text/plain;charset=UTF-8', + ...getCommonHttpRequestHeaders(), }); }, }) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index ce9a1b1fa65a..c91d98725d9b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -59,6 +59,8 @@ export { getSpanStatusFromHttpCode, getTraceData, getTraceMetaTags, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, graphqlIntegration, hapiIntegration, httpIntegration, diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index e80020ba0913..61f7913cf1b1 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -18,10 +18,12 @@ import { getClient, getCurrentScope, getTraceMetaTags, + httpHeadersToSpanAttributes, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setHttpStatus, startSpan, + winterCGHeadersToDict, withIsolationScope, } from '@sentry/node'; import type { APIContext, MiddlewareResponseHandler, RoutePart } from 'astro'; @@ -220,6 +222,10 @@ async function instrumentRequestStartHttpServerSpan( // This is here for backwards compatibility, we used to set this here before method, url: stripUrlQueryAndFragment(ctx.url.href), + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }; if (parametrizedRoute) { diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 13e54d1537cb..03933582c846 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -54,7 +54,7 @@ describe('sentryMiddleware', () => { } as any; }); vi.spyOn(SentryNode, 'getActiveSpan').mockImplementation(getSpanMock); - vi.spyOn(SentryNode, 'getClient').mockImplementation(() => ({}) as Client); + vi.spyOn(SentryNode, 'getClient').mockImplementation(() => ({ getOptions: () => ({}) }) as Client); vi.spyOn(SentryNode, 'getTraceMetaTags').mockImplementation( () => ` diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 7d7455d496bb..a041e0a7231f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -42,6 +42,8 @@ export { close, getSentryRelease, createGetModuleFromFilename, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index ec092bcdbbba..e0ce86b1bd23 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -62,6 +62,8 @@ export { close, getSentryRelease, createGetModuleFromFilename, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index c31aa0e84ebc..9c235b8bc97c 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -3,6 +3,8 @@ import { captureException, continueTrace, defineIntegration, + getClient, + httpHeadersToSpanAttributes, isURLObjectRelative, parseStringToURLObject, SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, @@ -205,6 +207,10 @@ function wrapRequestHandler( routeName = route; } + const client = getClient(); + const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; + Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), sendDefaultPii)); + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { url: request.url, diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 77ac2440915b..9792c59c2691 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -47,6 +47,11 @@ describe('Bun Serve Integration', () => { 'url.port': port.toString(), 'url.scheme': 'http:', 'url.domain': 'localhost', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.user_agent': expect.stringContaining('Bun'), }, op: 'http.server', name: 'GET /users', @@ -81,6 +86,12 @@ describe('Bun Serve Integration', () => { 'url.port': port.toString(), 'url.scheme': 'http:', 'url.domain': 'localhost', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.content_length': '0', + 'http.request.header.host': expect.any(String), + 'http.request.header.user_agent': expect.stringContaining('Bun'), }, op: 'http.server', name: 'POST /', @@ -128,6 +139,59 @@ describe('Bun Serve Integration', () => { expect(startSpanSpy).toHaveBeenCalledTimes(1); }); + test('includes HTTP request headers as span attributes', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Headers test!'); + }, + port, + }); + + // Make request with custom headers + await fetch(`http://localhost:${port}/api/test`, { + method: 'POST', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + Accept: 'application/json, text/plain', + Authorization: 'Bearer token123', + }, + body: JSON.stringify({ test: 'data' }), + }); + + await server.stop(); + + // Verify span was created with header attributes + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'POST', + 'sentry.source': 'url', + 'url.path': '/api/test', + 'url.full': `http://localhost:${port}/api/test`, + 'url.port': port.toString(), + 'url.scheme': 'http:', + 'url.domain': 'localhost', + // HTTP headers as span attributes following OpenTelemetry semantic conventions + 'http.request.header.user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_custom_header': 'custom-value', + 'http.request.header.accept': 'application/json, text/plain', + 'http.request.header.accept_encoding': 'gzip, deflate, br, zstd', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.content_length': '15', + 'http.request.header.host': expect.any(String), + }), + op: 'http.server', + name: 'POST /api/test', + }), + expect.any(Function), + ); + }); + test('skips span creation for OPTIONS and HEAD requests', async () => { const server = Bun.serve({ async fetch(_req) { diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index edc1bccef96f..45fe548696ab 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -4,10 +4,12 @@ import { continueTrace, flush, getHttpSpanDetailsFromUrlObject, + httpHeadersToSpanAttributes, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, setHttpStatus, startSpan, + winterCGHeadersToDict, withIsolationScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; @@ -64,6 +66,9 @@ export function wrapRequestHandler( attributes['user_agent.original'] = userAgentHeader; } + const sendDefaultPii = options.sendDefaultPii ?? false; + Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), sendDefaultPii)); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; addCloudResourceContext(isolationScope); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index eb2989437396..ad323e3c5b5a 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -319,6 +319,7 @@ describe('withSentry', () => { 'sentry.sample_rate': 1, 'http.response.status_code': 200, 'http.request.body.size': 10, + 'http.request.header.content_length': '10', }, op: 'http.server', origin: 'auto.http.cloudflare', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4447eea4dae0..ef61364ab3f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,6 +92,7 @@ export { httpRequestToRequestData, extractQueryParamsFromUrl, headersToDict, + httpHeadersToSpanAttributes, } from './utils/request'; export { DEFAULT_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 04cd1006ba28..ffd60f3e8486 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -128,6 +128,47 @@ function getAbsoluteUrl({ return undefined; } +// "-user" because otherwise it would match "user-agent" +const SENSITIVE_HEADER_SNIPPETS = ['auth', 'token', 'secret', 'cookie', '-user', 'password', 'key']; + +/** + * Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions. + * Header names are converted to the format: http.request.header. + * where is the header name in lowercase with dashes converted to underscores. + * + * @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/http/#http-request-header + */ +export function httpHeadersToSpanAttributes( + headers: Record, + sendDefaultPii: boolean = false, +): Record { + const spanAttributes: Record = {}; + + try { + Object.entries(headers).forEach(([key, value]) => { + if (value !== undefined) { + const lowerCasedKey = key.toLowerCase(); + + if (!sendDefaultPii && SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet))) { + return; + } + + const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`; + + if (Array.isArray(value)) { + spanAttributes[normalizedKey] = value.map(v => (v !== null && v !== undefined ? String(v) : v)).join(';'); + } else if (typeof value === 'string') { + spanAttributes[normalizedKey] = value; + } + } + }); + } catch { + // Return empty object if there's an error + } + + return spanAttributes; +} + /** Extract the query params from an URL. */ export function extractQueryParamsFromUrl(url: string): string | undefined { // url is path and query string diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index fe90578d5392..b37ee860f43f 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { extractQueryParamsFromUrl, headersToDict, + httpHeadersToSpanAttributes, httpRequestToRequestData, winterCGHeadersToDict, winterCGRequestToRequestData, @@ -420,4 +421,288 @@ describe('request utils', () => { expect(extractQueryParamsFromUrl(url)).toEqual(expected); }); }); + + describe('httpHeadersToSpanAttributes', () => { + it('works with empty headers object', () => { + expect(httpHeadersToSpanAttributes({})).toEqual({}); + }); + + it('converts single string header values to strings', () => { + const headers = { + 'Content-Type': 'application/json', + 'user-agent': 'test-agent', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + }); + }); + + it('handles array header values by joining with semicolons', () => { + const headers = { + 'custom-header': ['value1', 'value2'], + accept: ['application/json', 'text/html'], + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.custom_header': 'value1;value2', + 'http.request.header.accept': 'application/json;text/html', + }); + }); + + it('filters undefined values in arrays when joining', () => { + const headers = { + 'undefined-values': [undefined, undefined], + 'valid-header': 'valid-value', + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.valid_header': 'valid-value', + 'http.request.header.undefined_values': ';', + }); + }); + + it('ignores undefined header values', () => { + const headers = { + 'valid-header': 'valid-value', + 'undefined-header': undefined, + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.valid_header': 'valid-value', + }); + }); + + it('adds empty array headers as empty string', () => { + const headers = { + 'empty-header': [], + 'valid-header': 'valid-value', + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.empty_header': '', + 'http.request.header.valid_header': 'valid-value', + }); + }); + + it('converts header names to lowercase and replaces dashes with underscores', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-CUSTOM-HEADER': 'custom-value', + 'user-Agent': 'test-agent', + ACCEPT: 'text/html', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.x_custom_header': 'custom-value', + 'http.request.header.user_agent': 'test-agent', + 'http.request.header.accept': 'text/html', + }); + }); + + it('handles real-world headers', () => { + const headers = { + Host: 'example.com', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Cache-Control': 'no-cache', + 'X-Forwarded-For': '192.168.1.1', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.host': 'example.com', + 'http.request.header.user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'http.request.header.accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'http.request.header.accept_language': 'en-US,en;q=0.5', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.upgrade_insecure_requests': '1', + 'http.request.header.cache_control': 'no-cache', + 'http.request.header.x_forwarded_for': '192.168.1.1', + }); + }); + + it('handles multiple values for the same header by joining with semicolons', () => { + const headers = { + 'x-random-header': ['test=abc123', 'preferences=dark-mode', 'number=three'], + Accept: ['application/json', 'text/html'], + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.x_random_header': 'test=abc123;preferences=dark-mode;number=three', + 'http.request.header.accept': 'application/json;text/html', + }); + }); + + it('handles headers with empty string values', () => { + const headers = { + 'empty-header': '', + 'valid-header': 'valid-value', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.empty_header': '', + 'http.request.header.valid_header': 'valid-value', + }); + }); + + it('returns empty object when processing invalid headers throws error', () => { + // Create a headers object that will throw an error when iterated + const headers = {}; + Object.defineProperty(headers, Symbol.iterator, { + get() { + throw new Error('Test error'); + }, + }); + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({}); + }); + + it('stringifies non-string values (except null) in arrays and joins them', () => { + const headers = { + 'mixed-types': ['string-value', 123, true, null], + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.mixed_types': 'string-value;123;true;', + }); + }); + + it('ignores non-string and non-array header values', () => { + const headers = { + 'string-header': 'valid-value', + 'number-header': 123, + 'boolean-header': true, + 'null-header': null, + 'object-header': { key: 'value' }, + } as any; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.string_header': 'valid-value', + }); + }); + + describe('PII filtering', () => { + it('filters out sensitive headers when sendDefaultPii is false (default)', () => { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent', + Authorization: 'Bearer secret-token', + Cookie: 'session=abc123', + 'X-API-Key': 'api-key-123', + 'X-Auth-Token': 'auth-token-456', + }; + + const result = httpHeadersToSpanAttributes(headers, false); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + // Sensitive headers should be filtered out + }); + }); + + it('includes sensitive headers when sendDefaultPii is true', () => { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent', + Authorization: 'Bearer secret-token', + Cookie: 'session=abc123', + 'X-API-Key': 'api-key-123', + }; + + const result = httpHeadersToSpanAttributes(headers, true); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + 'http.request.header.authorization': 'Bearer secret-token', + 'http.request.header.cookie': 'session=abc123', + 'http.request.header.x_api_key': 'api-key-123', + }); + }); + + it('filters sensitive headers case-insensitively', () => { + const headers = { + AUTHORIZATION: 'Bearer secret-token', + Cookie: 'session=abc123', + 'x-api-key': 'key-123', + 'Content-Type': 'application/json', + }; + + const result = httpHeadersToSpanAttributes(headers, false); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + }); + }); + + it('filters comprehensive list of sensitive headers', () => { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'test-agent', + Accept: 'application/json', + Host: 'example.com', + + // Should be filtered + Authorization: 'Bearer token', + Cookie: 'session=123', + 'Set-Cookie': 'session=456', + 'X-API-Key': 'key', + 'X-Auth-Token': 'token', + 'X-Secret': 'secret', + 'x-secret-key': 'another-secret', + 'WWW-Authenticate': 'Basic', + 'Proxy-Authorization': 'Basic auth', + 'X-Access-Token': 'access', + 'X-CSRF-Token': 'csrf', + 'X-XSRF-Token': 'xsrf', + 'X-Session-Token': 'session', + 'X-Password': 'password', + 'X-Private-Key': 'private', + 'X-Forwarded-user': 'user', + 'X-Forwarded-authorization': 'auth', + }; + + const result = httpHeadersToSpanAttributes(headers, false); + + expect(result).toEqual({ + 'http.request.header.content_type': 'application/json', + 'http.request.header.user_agent': 'test-agent', + 'http.request.header.accept': 'application/json', + 'http.request.header.host': 'example.com', + }); + }); + }); + }); }); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 0b76f7776772..83292ab11e46 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -42,6 +42,8 @@ export { close, getSentryRelease, createGetModuleFromFilename, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index ba50778d30ad..a0e4112404cd 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -15,6 +15,7 @@ import { } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { AugmentedNextApiResponse, NextApiHandler } from '../types'; +import { addHeadersAsAttributes } from '../utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../utils/responseEnd'; import { dropNextjsRootContext, escapeNextjsTracing } from '../utils/tracingUtils'; @@ -87,6 +88,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', + ...addHeadersAsAttributes(normalizedRequest.headers || {}), }, }, async span => { diff --git a/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts new file mode 100644 index 000000000000..4e8cdb3fe7c9 --- /dev/null +++ b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts @@ -0,0 +1,30 @@ +import type { Span, WebFetchHeaders } from '@sentry/core'; +import { getClient, httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core'; + +/** + * Extracts HTTP request headers as span attributes and optionally applies them to a span. + */ +export function addHeadersAsAttributes( + headers: WebFetchHeaders | Headers | Record | undefined, + span?: Span, +): Record { + if (!headers) { + return {}; + } + + const client = getClient(); + const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; + + const headersDict: Record = + headers instanceof Headers || (typeof headers === 'object' && 'get' in headers) + ? winterCGHeadersToDict(headers as Headers) + : headers; + + const headerAttributes = httpHeadersToSpanAttributes(headersDict, sendDefaultPii); + + if (span) { + span.setAttributes(headerAttributes); + } + + return headerAttributes; +} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 2067ebccc245..304066cc2313 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -22,6 +22,7 @@ import { import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; +import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; @@ -63,6 +64,11 @@ export function wrapGenerationFunctionWithSentry a const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + addHeadersAsAttributes(headers, rootSpan); + } + let data: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 3a9ca786d697..bd84fb4195b7 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -13,6 +13,7 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from '../edge/types'; @@ -59,6 +60,7 @@ export function wrapMiddlewareWithSentry( let spanName: string; let spanSource: TransactionSource; + let headerAttributes: Record = {}; if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ @@ -66,6 +68,8 @@ export function wrapMiddlewareWithSentry( }); spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; spanSource = 'url'; + + headerAttributes = addHeadersAsAttributes(req.headers); } else { spanName = 'middleware'; spanSource = 'component'; @@ -84,6 +88,7 @@ export function wrapMiddlewareWithSentry( const rootSpan = getRootSpan(activeSpan); if (rootSpan) { setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); + rootSpan.setAttributes(headerAttributes); } } @@ -94,6 +99,7 @@ export function wrapMiddlewareWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrapMiddlewareWithSentry', + ...headerAttributes, }, }, () => { diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index e1a2238b05a1..e10d51321a0e 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -19,6 +19,7 @@ import { } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; +import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope } from './utils/tracingUtils'; @@ -39,6 +40,10 @@ export function wrapRouteHandlerWithSentry any>( const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + if (rootSpan && process.env.NEXT_RUNTIME !== 'edge') { + addHeadersAsAttributes(headers, rootSpan); + } + let edgeRuntimeIsolationScopeOverride: Scope | undefined; if (rootSpan && process.env.NEXT_RUNTIME === 'edge') { const isolationScope = commonObjectToIsolationScope(headers); @@ -50,6 +55,7 @@ export function wrapRouteHandlerWithSentry any>( rootSpan.updateName(`${method} ${parameterizedRoute}`); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server'); + addHeadersAsAttributes(headers, rootSpan); } return withIsolationScope( diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 9dd097cb75ae..f0f8e9df8717 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -24,6 +24,7 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/ import type { ServerComponentContext } from '../common/types'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; +import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; @@ -61,6 +62,11 @@ export function wrapServerComponentWithSentry any> const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + addHeadersAsAttributes(context.headers, rootSpan); + } + let params: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 466eb19eb1d1..6002981cfcf4 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -13,6 +13,7 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from './types'; @@ -31,11 +32,15 @@ export function wrapApiHandlerWithSentry( const req: unknown = args[0]; const currentScope = getCurrentScope(); + let headerAttributes: Record = {}; + if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); currentScope.setTransactionName(`${req.method} ${parameterizedRoute}`); + + headerAttributes = addHeadersAsAttributes(req.headers); } else { currentScope.setTransactionName(`handler (${parameterizedRoute})`); } @@ -58,6 +63,7 @@ export function wrapApiHandlerWithSentry( rootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + ...headerAttributes, }); setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); } @@ -74,6 +80,7 @@ export function wrapApiHandlerWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrapApiHandlerWithSentry', + ...headerAttributes, }, }, () => { diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index c96184df51cf..c61c92026f60 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -53,11 +53,14 @@ describe('data-fetching function wrappers should not create manual spans', () => test('wrapped function sets route backfill attribute when called within an active span', async () => { const mockSetAttribute = vi.fn(); + const mockSetAttributes = vi.fn(); const mockGetActiveSpan = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const mockGetRootSpan = vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const origFunction = vi.fn(async () => ({ props: {} })); @@ -72,11 +75,14 @@ describe('data-fetching function wrappers should not create manual spans', () => test('wrapped function does not set route backfill attribute for /_error route', async () => { const mockSetAttribute = vi.fn(); + const mockSetAttributes = vi.fn(); const mockGetActiveSpan = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const mockGetRootSpan = vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({ setAttribute: mockSetAttribute, + setAttributes: mockSetAttributes, } as any); const origFunction = vi.fn(async () => ({ props: {} })); diff --git a/packages/node-core/src/integrations/http/incoming-requests.ts b/packages/node-core/src/integrations/http/incoming-requests.ts index 4fddeefef4b5..57588d0ac16e 100644 --- a/packages/node-core/src/integrations/http/incoming-requests.ts +++ b/packages/node-core/src/integrations/http/incoming-requests.ts @@ -19,6 +19,7 @@ import { getCurrentScope, getIsolationScope, getSpanStatusFromHttpCode, + httpHeadersToSpanAttributes, httpRequestToRequestData, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -191,6 +192,8 @@ export function instrumentServer( const tracer = client.tracer; const scheme = fullUrl.startsWith('https') ? 'https' : 'http'; + const shouldSendDefaultPii = client?.getOptions().sendDefaultPii ?? false; + // We use the plain tracer.startSpan here so we can pass the span kind const span = tracer.startSpan(bestEffortTransactionName, { kind: SpanKind.SERVER, @@ -211,6 +214,7 @@ export function instrumentServer( 'http.flavor': httpVersion, 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp', ...getRequestContentLengthAttribute(request), + ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, shouldSendDefaultPii), }, }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index f5f3865feffa..f510ca733d19 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -92,6 +92,8 @@ export { getIsolationScope, getTraceData, getTraceMetaTags, + httpHeadersToSpanAttributes, + winterCGHeadersToDict, continueTrace, withScope, withIsolationScope, diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index db8322a0c828..109c3e0f3672 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -23,6 +23,7 @@ import { getRootSpan, getTraceData, hasSpansEnabled, + httpHeadersToSpanAttributes, isNodeEnv, loadModule, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -31,6 +32,7 @@ import { setHttpStatus, spanToJSON, startSpan, + winterCGHeadersToDict, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; @@ -324,6 +326,10 @@ function wrapRequestHandler ServerBuild | Promise [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', method: request.method, + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(request.headers), + clientOptions.sendDefaultPii ?? false, + ), }, }, async span => { diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 3f5797efd211..26872a0f6f24 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -3,10 +3,12 @@ import { continueTrace, debug, flushIfServerless, + getClient, getCurrentScope, getDefaultIsolationScope, getIsolationScope, getTraceMetaTags, + httpHeadersToSpanAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -14,6 +16,7 @@ import { spanToJSON, startSpan, updateSpanName, + winterCGHeadersToDict, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; @@ -176,6 +179,10 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url', 'sveltekit.tracing.original_name': originalName, + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(event.request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }); } @@ -201,6 +208,10 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', 'http.method': event.request.method, + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(event.request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }, name: routeName, },