From bd77d5aec64bed8157e6ee2868b91b186a974f7b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Mar 2026 14:53:28 +0100 Subject: [PATCH 1/6] feat(bun): Set http response header attributes instead of response context headers --- packages/bun/src/integrations/bunserver.ts | 11 +++--- packages/core/src/utils/request.ts | 34 ++++++++++++------ packages/core/test/lib/utils/request.test.ts | 36 ++++++++++++++++++++ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 83e7f5ff4967..11c12da37218 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -207,10 +207,9 @@ function wrapRequestHandler( routeName = route; } - Object.assign( - attributes, - httpHeadersToSpanAttributes(request.headers.toJSON(), getClient()?.getOptions().sendDefaultPii ?? false), - ); + const sendDefaultPii = getClient()?.getOptions().sendDefaultPii ?? false; + + Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), sendDefaultPii)); isolationScope.setSDKProcessingMetadata({ normalizedRequest: { @@ -238,10 +237,12 @@ function wrapRequestHandler( const response = (await target.apply(thisArg, args)) as Response | undefined; if (response?.status) { setHttpStatus(span, response.status); + isolationScope.setContext('response', { - headers: response.headers.toJSON(), status_code: response.status, }); + + span.setAttributes(httpHeadersToSpanAttributes(response.headers.toJSON(), sendDefaultPii, 'response')); } return response; } catch (e) { diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index d328a16e05d9..6aaceb8fc201 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -152,15 +152,22 @@ const SENSITIVE_HEADER_SNIPPETS = [ const PII_HEADER_SNIPPETS = ['x-forwarded-', '-user']; /** - * Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions. - * Header names are converted to the format: http.request.header. + * Converts incoming HTTP request or response headers to OpenTelemetry span attributes following semantic conventions. + * Header names are converted to the format: http..header. * where is the header name in lowercase with dashes converted to underscores. * + * @param lifecycle - The lifecycle of the headers, either 'request' or 'response' + * * @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/http/#http-request-header + * @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/http/#http-response-header + * + * @see https://getsentry.github.io/sentry-conventions/attributes/http/#http-request-header-key + * @see https://getsentry.github.io/sentry-conventions/attributes/http/#http-response-header-key */ export function httpHeadersToSpanAttributes( headers: Record, sendDefaultPii: boolean = false, + lifecycle: 'request' | 'response' = 'request', ): Record { const spanAttributes: Record = {}; @@ -189,10 +196,17 @@ export function httpHeadersToSpanAttributes( const lowerCasedCookieKey = cookieKey.toLowerCase(); - addSpanAttribute(spanAttributes, lowerCasedHeaderKey, lowerCasedCookieKey, cookieValue, sendDefaultPii); + addSpanAttribute( + spanAttributes, + lowerCasedHeaderKey, + lowerCasedCookieKey, + cookieValue, + sendDefaultPii, + lifecycle, + ); } } else { - addSpanAttribute(spanAttributes, lowerCasedHeaderKey, '', value, sendDefaultPii); + addSpanAttribute(spanAttributes, lowerCasedHeaderKey, '', value, sendDefaultPii, lifecycle); } }); } catch { @@ -212,15 +226,15 @@ function addSpanAttribute( cookieKey: string, value: string | string[] | undefined, sendPii: boolean, + lifecycle: 'request' | 'response', ): void { - const normalizedKey = cookieKey - ? `http.request.header.${normalizeAttributeKey(headerKey)}.${normalizeAttributeKey(cookieKey)}` - : `http.request.header.${normalizeAttributeKey(headerKey)}`; - const headerValue = handleHttpHeader(cookieKey || headerKey, value, sendPii); - if (headerValue !== undefined) { - spanAttributes[normalizedKey] = headerValue; + if (headerValue == null) { + return; } + + const normalizedKey = `http.${lifecycle}.header.${normalizeAttributeKey(headerKey)}${cookieKey ? `.${normalizeAttributeKey(cookieKey)}` : ''}`; + spanAttributes[normalizedKey] = headerValue; } function handleHttpHeader( diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index c17c25802599..4d0be66c7f6b 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -780,6 +780,42 @@ describe('request utils', () => { 'http.request.header.x_saml_token': '[Filtered]', }); }); + + it('returns response header attributes if `lifecycle` is "response"', () => { + 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', + Authorization: '[Filtered]', + 'x-bearer-token': 'bearer', + 'x-sso-token': 'sso', + 'x-saml-token': 'saml', + }; + + const result = httpHeadersToSpanAttributes(headers, false, 'response'); + + expect(result).toEqual({ + 'http.response.header.host': 'example.com', + 'http.response.header.user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'http.response.header.accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'http.response.header.accept_language': 'en-US,en;q=0.5', + 'http.response.header.accept_encoding': 'gzip, deflate', + 'http.response.header.connection': 'keep-alive', + 'http.response.header.upgrade_insecure_requests': '1', + 'http.response.header.cache_control': 'no-cache', + 'http.response.header.x_forwarded_for': '[Filtered]', + 'http.response.header.authorization': '[Filtered]', + 'http.response.header.x_bearer_token': '[Filtered]', + 'http.response.header.x_saml_token': '[Filtered]', + 'http.response.header.x_sso_token': '[Filtered]', + }); + }); }); }); }); From 9189e28371cb0776b55c30eca952cbac96229114 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Mar 2026 15:20:15 +0100 Subject: [PATCH 2/6] bun test, unit tests --- packages/bun/test/integrations/bunserver.test.ts | 15 +++++++++++++-- packages/core/test/lib/utils/request.test.ts | 4 ++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 9792c59c2691..38ad982b8144 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,10 +1,15 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import { instrumentBunServe } from '../../src/integrations/bunserver'; +import type { Span } from '@sentry/core'; describe('Bun Serve Integration', () => { + const mockSpan = SentryCore.startInactiveSpan({ name: 'test span' }); + const setAttributesSpy = spyOn(mockSpan, 'setAttributes'); const continueTraceSpy = spyOn(SentryCore, 'continueTrace'); - const startSpanSpy = spyOn(SentryCore, 'startSpan'); + const startSpanSpy = spyOn(SentryCore, 'startSpan').mockImplementation((_opts, cb) => { + return cb(mockSpan as unknown as Span); + }); beforeAll(() => { instrumentBunServe(); @@ -13,6 +18,7 @@ describe('Bun Serve Integration', () => { beforeEach(() => { startSpanSpy.mockClear(); continueTraceSpy.mockClear(); + setAttributesSpy.mockClear(); }); // Fun fact: Bun = 2 21 14 :) @@ -27,7 +33,7 @@ describe('Bun Serve Integration', () => { test('generates a transaction around a request', async () => { const server = Bun.serve({ async fetch(_req) { - return new Response('Bun!'); + return new Response('Bun!', { headers: new Headers({ 'x-custom': 'value' }) }); }, port, }); @@ -52,12 +58,17 @@ describe('Bun Serve Integration', () => { 'http.request.header.connection': 'keep-alive', 'http.request.header.host': expect.any(String), 'http.request.header.user_agent': expect.stringContaining('Bun'), + // 'http.response.header.x_bearer_token': '[Filtered]', }, op: 'http.server', name: 'GET /users', }, expect.any(Function), ); + + expect(setAttributesSpy).toHaveBeenCalledWith({ + 'http.response.header.x_custom': 'value', + }); }); test('generates a post transaction', async () => { diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 4d0be66c7f6b..73a19c2bfa45 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -796,6 +796,8 @@ describe('request utils', () => { 'x-bearer-token': 'bearer', 'x-sso-token': 'sso', 'x-saml-token': 'saml', + 'Set-Cookie': 'session=456', + Cookie: 'session=abc123', }; const result = httpHeadersToSpanAttributes(headers, false, 'response'); @@ -814,6 +816,8 @@ describe('request utils', () => { 'http.response.header.x_bearer_token': '[Filtered]', 'http.response.header.x_saml_token': '[Filtered]', 'http.response.header.x_sso_token': '[Filtered]', + 'http.response.header.set_cookie.session': '[Filtered]', + 'http.response.header.cookie.session': '[Filtered]', }); }); }); From 73b8f350b8cf4878d83f29ed15bbf0ebc32e57cb Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Mar 2026 14:55:31 +0100 Subject: [PATCH 3/6] feat(deno): Set http response header attributes instead of response context headers --- packages/deno/src/wrap-deno-request-handler.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 886b5a6d67ed..1421e2b081dc 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -95,9 +95,15 @@ export const wrapDenoRequestHandler = ( res = await handler(); setHttpStatus(span, res.status); isolationScope.setContext('response', { - headers: Object.fromEntries(res.headers), status_code: res.status, }); + span.setAttributes( + httpHeadersToSpanAttributes( + Object.fromEntries(res.headers), + client.getOptions().sendDefaultPii, + 'response', + ), + ); } catch (e) { span.end(); captureException(e, { From a1144ba590f1a4f3b62714a22196275833b67464 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Mar 2026 15:33:43 +0100 Subject: [PATCH 4/6] adjust denot test --- packages/deno/test/deno-serve.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index 9c2283e365b4..6f76ce2781ab 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -317,9 +317,8 @@ Deno.test('Deno.serve should capture request headers and set response context', // Check response context assertEquals(transaction?.contexts?.response?.status_code, 201); - assertExists(transaction?.contexts?.response?.headers); - assertEquals(transaction?.contexts?.response?.headers?.['content-type'], 'text/plain'); - assertEquals(transaction?.contexts?.response?.headers?.['x-custom-header'], 'test'); + assertEquals(transaction?.contexts?.trace?.data?.['http.response.header.content_type'], 'text/plain'); + assertEquals(transaction?.contexts?.trace?.data?.['http.response.header.x_custom_header'], 'test'); }); Deno.test('Deno.serve should support distributed tracing with sentry-trace header', async () => { From e6111a045dbc381a6a45c854ded3244a998633c7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Mar 2026 16:43:28 +0100 Subject: [PATCH 5/6] cleanup sendDefaultPii retrieval in deno --- packages/deno/src/wrap-deno-request-handler.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 1421e2b081dc..510b903dc722 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -73,10 +73,7 @@ export const wrapDenoRequestHandler = ( assignIfSet(attributes, 'client.port', (info?.remoteAddr as Deno.NetAddr)?.port); } - Object.assign( - attributes, - httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), client.getOptions().sendDefaultPii ?? false), - ); + Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), sendDefaultPii)); attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(request), @@ -98,11 +95,7 @@ export const wrapDenoRequestHandler = ( status_code: res.status, }); span.setAttributes( - httpHeadersToSpanAttributes( - Object.fromEntries(res.headers), - client.getOptions().sendDefaultPii, - 'response', - ), + httpHeadersToSpanAttributes(Object.fromEntries(res.headers), sendDefaultPii, 'response'), ); } catch (e) { span.end(); From a22f893b4ec5eeefc974caa98ff213a1ce9d8282 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 17 Mar 2026 13:54:00 +0100 Subject: [PATCH 6/6] Apply suggestion from @Lms24 --- packages/bun/test/integrations/bunserver.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 38ad982b8144..1605d7c0be90 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -58,7 +58,6 @@ describe('Bun Serve Integration', () => { 'http.request.header.connection': 'keep-alive', 'http.request.header.host': expect.any(String), 'http.request.header.user_agent': expect.stringContaining('Bun'), - // 'http.response.header.x_bearer_token': '[Filtered]', }, op: 'http.server', name: 'GET /users',