Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions packages/browser/src/integrations/httpcontext.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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
Expand All @@ -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'],
});
},
};
Expand Down
108 changes: 107 additions & 1 deletion packages/browser/test/integrations/httpcontext.test.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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<StreamedSpanJSON> = {
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<StreamedSpanJSON> = {
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,
});
});
});
});
Loading