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/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 9792c59c2691..1605d7c0be90 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, }); @@ -58,6 +64,10 @@ describe('Bun Serve Integration', () => { }, expect.any(Function), ); + + expect(setAttributesSpy).toHaveBeenCalledWith({ + 'http.response.header.x_custom': 'value', + }); }); test('generates a post transaction', async () => { 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..73a19c2bfa45 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -780,6 +780,46 @@ 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', + 'Set-Cookie': 'session=456', + Cookie: 'session=abc123', + }; + + 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]', + 'http.response.header.set_cookie.session': '[Filtered]', + 'http.response.header.cookie.session': '[Filtered]', + }); + }); }); }); });