From 976507cea9caf0414e55a1fccaa026bec58b92ba Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 4 Aug 2026 16:40:31 +0200 Subject: [PATCH] fix(browser): Respect `dataCollection.httpHeaders.request` in `httpContextIntegration` `httpContextIntegration` attached `Referer` and `User-Agent` without looking at `dataCollection`, so `httpHeaders: { request: false }` worked on the server but did nothing in the browser. Both hooks now filter through `httpHeaders.request`. Filtering goes by header name rather than by what this integration collects, because `browserTracingIntegration` puts the same two headers on transaction events before this hook runs. Headers the user sets themselves are left alone, matching the spec and the server. Fixes #21272 Co-Authored-By: Claude Opus 5 (1M context) --- .../browser/src/integrations/httpcontext.ts | 39 +++++-- .../test/integrations/httpcontext.test.ts | 108 +++++++++++++++++- 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 92234b2e9631..00676f90547c 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,4 +1,9 @@ -import { defineIntegration, safeSetSpanJSONAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core/browser'; +import { + _INTERNAL_filterKeyValueData, + defineIntegration, + safeSetSpanJSONAttributes, + SEMANTIC_ATTRIBUTE_SENTRY_OP, +} from '@sentry/core/browser'; import { getHttpRequestData, WINDOW } from '../helpers'; import { URL_FULL } from '@sentry/conventions/attributes'; @@ -9,25 +14,32 @@ import { URL_FULL } from '@sentry/conventions/attributes'; export const httpContextIntegration = defineIntegration(() => { return { name: 'HttpContext' as const, - preprocessEvent(event) { + preprocessEvent(event, _hint, client) { // if none of the information we want exists, don't bother if (!WINDOW.navigator && !WINDOW.location && !WINDOW.document) { return; } - const reqData = getHttpRequestData(); + const { url, headers: collectedHeaders } = getHttpRequestData(); + + // We only filter the headers we collected ourselves, so headers the user set are left alone. Going by key + // means we also catch these headers when `browserTracingIntegration` already put them on the event. + const behavior = client.getDataCollectionOptions().httpHeaders.request; + const userHeaders = Object.entries(event.request?.headers ?? {}).filter(([key]) => !(key in collectedHeaders)); + const headers = { - ...reqData.headers, - ...event.request?.headers, + ..._INTERNAL_filterKeyValueData(collectedHeaders, behavior), + ...Object.fromEntries(userHeaders), }; event.request = { - ...reqData, + // The URL isn't gated by `dataCollection`, same as on the server. + url, ...event.request, - headers, + ...(Object.keys(headers).length > 0 ? { headers } : { headers: undefined }), }; }, - processSegmentSpan(span) { + processSegmentSpan(span, client) { const spanOp = span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; // if none of the information we want exists, don't bother @@ -37,12 +49,19 @@ export const httpContextIntegration = defineIntegration(() => { const reqData = getHttpRequestData(); + // `httpHeadersToSpanAttributes` would also work here, but its cookie and array handling never runs for these + // two headers and costs every browser bundle ~400B gzip. + const headers = _INTERNAL_filterKeyValueData( + reqData.headers, + client.getDataCollectionOptions().httpHeaders.request, + ); + safeSetSpanJSONAttributes(span, { // Coerce empty string to undefined so the helper's nullish check drops it, // rather than writing an empty `url.full` attribute onto the span. [URL_FULL]: spanOp !== 'http.client' ? reqData.url : undefined, - 'http.request.header.user_agent': reqData.headers['User-Agent'], - 'http.request.header.referer': reqData.headers['Referer'], + 'http.request.header.user_agent': headers['User-Agent'], + 'http.request.header.referer': headers['Referer'], }); }, }; diff --git a/packages/browser/test/integrations/httpcontext.test.ts b/packages/browser/test/integrations/httpcontext.test.ts index ea000bb781fc..b5af940f5839 100644 --- a/packages/browser/test/integrations/httpcontext.test.ts +++ b/packages/browser/test/integrations/httpcontext.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { httpContextIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../../src/exports'; -import type { StreamedSpanJSON } from '@sentry/core'; +import type { Event, StreamedSpanJSON } from '@sentry/core'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; import { BrowserClient } from '../../src/client'; +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + describe('httpContextIntegration', () => { globalThis.navigator = { userAgent: @@ -63,4 +66,107 @@ describe('httpContextIntegration', () => { 'url.full': 'https://example.com', }); }); + + describe('dataCollection', () => { + it('attaches headers to events by default', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions()); + const event: Event = {}; + + httpContextIntegration().preprocessEvent!(event, {}, client); + + expect(event.request).toEqual({ + url: 'https://example.com', + headers: { Referer: 'https://example.com', 'User-Agent': USER_AGENT }, + }); + }); + + it('does not attach headers to events when `httpHeaders.request` is disabled', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ dataCollection: { httpHeaders: { request: false } } }), + ); + const event: Event = {}; + + httpContextIntegration().preprocessEvent!(event, {}, client); + + expect(event.request).toEqual({ url: 'https://example.com' }); + }); + + it('drops headers already on the event, which `browserTracingIntegration` attaches unfiltered', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ dataCollection: { httpHeaders: { request: false } } }), + ); + const event: Event = { + type: 'transaction', + request: { url: 'https://example.com', headers: { Referer: 'https://example.com', 'User-Agent': USER_AGENT } }, + }; + + httpContextIntegration().preprocessEvent!(event, {}, client); + + expect(event.request).toEqual({ url: 'https://example.com' }); + }); + + it('leaves user-supplied headers untouched, since `dataCollection` only governs instrumented data', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ dataCollection: { httpHeaders: { request: false } } }), + ); + const event: Event = { + request: { headers: { Referer: 'https://example.com', 'X-My-Own-Header': 'user-set-this' } }, + }; + + httpContextIntegration().preprocessEvent!(event, {}, client); + + expect(event.request).toEqual({ + url: 'https://example.com', + headers: { 'X-My-Own-Header': 'user-set-this' }, + }); + }); + + it('applies an allowlist to event headers', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ dataCollection: { httpHeaders: { request: { allow: ['user-agent'] } } } }), + ); + const event: Event = {}; + + httpContextIntegration().preprocessEvent!(event, {}, client); + + expect(event.request).toEqual({ + url: 'https://example.com', + headers: { Referer: '[Filtered]', 'User-Agent': USER_AGENT }, + }); + }); + + it('does not attach header attributes to segment spans when `httpHeaders.request` is disabled', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ dataCollection: { httpHeaders: { request: false } } }), + ); + const span: Partial = { + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload' }, + }; + + httpContextIntegration().processSegmentSpan!(span as StreamedSpanJSON, client); + + expect(span.attributes).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + 'url.full': 'https://example.com', + }); + }); + + it('applies a denylist to segment span header attributes', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ dataCollection: { httpHeaders: { request: { deny: ['referer'] } } } }), + ); + const span: Partial = { + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload' }, + }; + + httpContextIntegration().processSegmentSpan!(span as StreamedSpanJSON, client); + + expect(span.attributes).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + 'url.full': 'https://example.com', + 'http.request.header.referer': '[Filtered]', + 'http.request.header.user_agent': USER_AGENT, + }); + }); + }); });