From b2e0e77a636cb522f4e2d585a456a64bcb9222e0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 5 Aug 2026 15:47:47 +0200 Subject: [PATCH 1/6] fix(core): Apply `dataCollection.urlQueryParams` to `url.full` and `url.query` `urlQueryParams` only applied to `request.query_string` and `requestDataIntegration`. Everywhere else, query strings went to Sentry unfiltered. Spans are filtered in one central place (`captureSpan`) instead of at the ~57 write sites, which span ~18 packages and mostly have no access to the client. The pass runs after the `processSpan` hooks so integration-set attributes are covered, and before `beforeSendSpan` since explicitly user-attached data is not gated by `dataCollection`. Breadcrumbs do not go through the span pipeline, so those are filtered separately at write time. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/add-outgoing-request-breadcrumb.ts | 8 +- .../core/src/tracing/spans/captureSpan.ts | 4 + .../filterUrlSpanAttributes.ts | 56 ++++++++++ .../lib/tracing/spans/captureSpan.test.ts | 100 ++++++++++++++++++ .../node/src/utils/outgoingFetchRequest.ts | 7 +- 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts diff --git a/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts index 19d4ca60567b..bd935d0b28cd 100644 --- a/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts +++ b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts @@ -1,6 +1,8 @@ import { HTTP_METHOD, URL_FRAGMENT, URL_QUERY } from '@sentry/conventions/attributes'; import { addBreadcrumb } from '../../breadcrumbs'; +import { getClient } from '../../currentScopes'; import { getBreadcrumbLogLevelFromHttpStatusCode } from '../../utils/breadcrumb-log-level'; +import { filterQueryParams } from '../../utils/data-collection/filterQueryParams'; import { getSanitizedUrlString, getUrlFragment, getUrlQuery, parseUrl } from '../../utils/url'; import { getRequestUrlFromClientRequest } from './get-request-url'; import type { HttpClientRequest, HttpIncomingMessage } from './types'; @@ -18,6 +20,10 @@ export function addOutgoingRequestBreadcrumb( const statusCode = response?.statusCode; const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + // Breadcrumbs never reach the span pipeline, so this is the only place the query gets filtered. + const query = getUrlQuery(parsedUrl.search); + const urlQueryParams = getClient()?.getDataCollectionOptions().urlQueryParams ?? true; + addBreadcrumb( { category: 'http', @@ -26,7 +32,7 @@ export function addOutgoingRequestBreadcrumb( url: getSanitizedUrlString(parsedUrl), // eslint-disable-next-line typescript/no-deprecated [HTTP_METHOD]: request.method || 'GET', - [URL_QUERY]: getUrlQuery(parsedUrl.search), + [URL_QUERY]: query && filterQueryParams(query, urlQueryParams), [URL_FRAGMENT]: getUrlFragment(parsedUrl.hash), }, type: 'http', diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 16aee9c8bb65..e370f8c37ea8 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -12,6 +12,7 @@ import { SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span'; +import { filterUrlSpanAttributes } from '../../utils/data-collection/filterUrlSpanAttributes'; import { getCombinedScopeData } from '../../utils/scopeData'; import { INTERNAL_getSegmentSpan, @@ -75,6 +76,9 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW // This also invokes the `processSpan` hook of all integrations client.emit('processSpan', spanJSON); + // Runs after the hooks above so that URL attributes set by integrations are filtered too + filterUrlSpanAttributes(spanJSON, client.getDataCollectionOptions().urlQueryParams); + const { beforeSendSpan, traceLifecycle } = client.getOptions(); const processedSpan = // check for traceLifecycle here because in static lifecycle, diff --git a/packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts b/packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts new file mode 100644 index 000000000000..085ab1a553bd --- /dev/null +++ b/packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts @@ -0,0 +1,56 @@ +import { URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; +import { isAttributeObject } from '../../attributes'; +import type { CollectBehavior } from '../../types/datacollection'; +import type { StreamedSpanJSON } from '../../types/span'; +import { filterQueryParams } from './filterQueryParams'; +import { filterUrlQuery } from './filterUrlQuery'; + +/** + * Applies `dataCollection.urlQueryParams` to the URL attributes of a span. + * + * This is the safety net for the ~50 places across the SDKs that set `url.full` / `url.query`: filtering + * centrally means an integration cannot leak a query string by forgetting to gate its own write, and it + * covers attributes set by users too. Instrumentation on hot paths additionally filters at write time, + * which is harmless because filtering is idempotent. + * + * `url.path` and the span name are deliberately untouched — per spec they never carry a query string. + */ +export function filterUrlSpanAttributes(spanJSON: StreamedSpanJSON, behavior: CollectBehavior): void { + const attributes = spanJSON.attributes; + if (!attributes) { + return; + } + + mapStringAttribute(attributes, URL_FULL, value => filterUrlQuery(value, behavior)); + mapStringAttribute(attributes, URL_QUERY, value => filterQueryParams(value, behavior)); +} + +/** + * Applies `map` to a string attribute, which may be stored either as a bare string or wrapped in an + * attribute object (`{ value, type }`) — both shapes are valid on a span. Removes the attribute when + * `map` returns `undefined`. + */ +function mapStringAttribute( + attributes: NonNullable, + key: string, + map: (value: string) => string | undefined, +): void { + const rawValue = attributes[key]; + const isWrapped = isAttributeObject(rawValue); + const value = isWrapped ? rawValue.value : rawValue; + + if (typeof value !== 'string') { + return; + } + + const mapped = map(value); + + if (mapped === undefined) { + // oxlint-disable-next-line typescript/no-dynamic-delete -- the keys passed here are string constants + delete attributes[key]; + } else if (isWrapped) { + attributes[key] = { ...rawValue, value: mapped }; + } else { + attributes[key] = mapped; + } +} diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index eee8a7764a53..29f4d7cb234b 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -794,4 +794,104 @@ describe('applyScopeToSegmentSpan integration', () => { expect(serializedChild?.is_segment).toBe(false); expect(serializedChild?.attributes).not.toHaveProperty('http.response.status_code'); }); + + describe('dataCollection.urlQueryParams', () => { + function captureUrlSpan( + attributes: Record, + dataCollection?: { urlQueryParams?: boolean | { allow: string[] } | { deny: string[] } }, + ): Record | undefined { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + ...(dataCollection ? { dataCollection } : {}), + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + const span = startInactiveSpan({ name: 'my-span', attributes }); + span.end(); + return span; + }); + + return captureSpan(span, client).attributes as Record | undefined; + } + + it('filters sensitive query params in `url.full` and `url.query` by default', () => { + const attributes = captureUrlSpan({ + 'url.full': 'https://example.com/api/users?token=abc123&q=a%20b%26c&page=5', + 'url.query': 'token=abc123&q=a%20b%26c&page=5', + 'url.path': '/api/users', + }); + + expect(attributes?.['url.full']?.value).toBe('https://example.com/api/users?token=[Filtered]&q=a%20b%26c&page=5'); + expect(attributes?.['url.query']?.value).toBe('token=[Filtered]&q=a%20b%26c&page=5'); + }); + + it('leaves `url.path` and the span name untouched', () => { + const attributes = captureUrlSpan({ + 'url.full': 'https://example.com/api/users?token=abc123', + 'url.path': '/api/users', + }); + + expect(attributes?.['url.path']?.value).toBe('/api/users'); + }); + + it('removes query data entirely when collection is off', () => { + const attributes = captureUrlSpan( + { + 'url.full': 'https://example.com/api/users?token=abc123&page=5', + 'url.query': 'token=abc123&page=5', + }, + { urlQueryParams: false }, + ); + + expect(attributes?.['url.full']?.value).toBe('https://example.com/api/users'); + expect(attributes?.['url.query']).toBeUndefined(); + }); + + it('honors allowList mode', () => { + const attributes = captureUrlSpan( + { 'url.query': 'page=1&ref=x&sort=name' }, + { urlQueryParams: { allow: ['page', 'sort'] } }, + ); + + expect(attributes?.['url.query']?.value).toBe('page=1&ref=[Filtered]&sort=name'); + }); + + it('honors extra deny terms', () => { + const attributes = captureUrlSpan( + { 'url.query': 'page=1&utm_source=email' }, + { urlQueryParams: { deny: ['utm'] } }, + ); + + expect(attributes?.['url.query']?.value).toBe('page=1&utm_source=[Filtered]'); + }); + + it('filters attributes set by instrumentation that runs after the span starts', () => { + const attributes = captureUrlSpan({ 'url.full': 'https://example.com/s?session=abc&ok=1' }); + + expect(attributes?.['url.full']?.value).toBe('https://example.com/s?session=[Filtered]&ok=1'); + }); + + it('filters URL attributes stored as attribute objects', () => { + const attributes = captureUrlSpan({ + 'url.full': { value: 'https://example.com/s?token=abc&ok=1', type: 'string' }, + 'url.query': { value: 'token=abc&ok=1', type: 'string' }, + } as unknown as Record); + + expect(attributes?.['url.full']?.value).toBe('https://example.com/s?token=[Filtered]&ok=1'); + expect(attributes?.['url.query']?.value).toBe('token=[Filtered]&ok=1'); + }); + + it('removes an attribute-object `url.query` when collection is off', () => { + const attributes = captureUrlSpan( + { 'url.query': { value: 'token=abc', type: 'string' } } as unknown as Record, + { urlQueryParams: false }, + ); + + expect(attributes?.['url.query']).toBeUndefined(); + }); + }); }); diff --git a/packages/node/src/utils/outgoingFetchRequest.ts b/packages/node/src/utils/outgoingFetchRequest.ts index cb2655294929..6c7958346f9c 100644 --- a/packages/node/src/utils/outgoingFetchRequest.ts +++ b/packages/node/src/utils/outgoingFetchRequest.ts @@ -1,6 +1,7 @@ import { HTTP_METHOD, URL_FRAGMENT, URL_QUERY } from '@sentry/conventions/attributes'; import type { LRUMap, SanitizedRequestData, Span } from '@sentry/core'; import { + _INTERNAL_filterQueryParams, addBreadcrumb, getActiveSpan, getBreadcrumbLogLevelFromHttpStatusCode, @@ -255,11 +256,15 @@ function getBreadcrumbData(request: UndiciRequest): Partial Date: Wed, 5 Aug 2026 16:32:43 +0200 Subject: [PATCH 2/6] test(core): Cover breadcrumb query filtering and tighten span assertions Breadcrumb query filtering had no test coverage, so re-leaking a token would not have failed CI. Adds cases for the default denylist, off mode, allowList and extra deny terms on both outgoing request breadcrumb paths. The node fetch path needs its own file because the existing test module mocks `getClient` without `getDataCollectionOptions`. Also fixes two span tests that claimed more than they asserted: one checks the span name is untouched but never looked at it, and the other claimed to cover attributes set after the span starts while passing them in at creation. The latter now registers a `processSpan` subscriber, mirroring how `requestDataIntegration` sets `url.full`. Co-Authored-By: Claude Opus 5 (1M context) --- .../add-outgoing-request-breadcrumb.test.ts | 43 +++++++++++++ .../lib/tracing/spans/captureSpan.test.ts | 51 ++++++++++++---- .../outgoingFetchRequestBreadcrumb.test.ts | 61 +++++++++++++++++++ 3 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 packages/node/test/utils/outgoingFetchRequestBreadcrumb.test.ts diff --git a/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts b/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts index 44ed16eb8b73..a0032cce6f27 100644 --- a/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts +++ b/packages/core/test/lib/integrations/http/add-outgoing-request-breadcrumb.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as breadcrumbsModule from '../../../../src/breadcrumbs'; +import { withScope } from '../../../../src/currentScopes'; import { addOutgoingRequestBreadcrumb } from '../../../../src/integrations/http/add-outgoing-request-breadcrumb'; import type { HttpClientRequest, HttpIncomingMessage } from '../../../../src/integrations/http/types'; +import type { CollectBehavior } from '../../../../src/types/datacollection'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; function makeMockRequest(overrides: Partial> = {}): HttpClientRequest { return { @@ -164,4 +167,44 @@ describe('addOutgoingRequestBreadcrumb', () => { const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls[0]![0]; expect(callArg.data?.['http.method']).toBe('GET'); }); + + // Breadcrumbs never reach the span pipeline, so this is the only place `urlQueryParams` is applied to them. + describe('dataCollection.urlQueryParams', () => { + function breadcrumbQuery(path: string, urlQueryParams?: CollectBehavior): unknown { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + ...(urlQueryParams !== undefined ? { dataCollection: { urlQueryParams } } : {}), + }), + ); + + return withScope(scope => { + scope.setClient(client); + addOutgoingRequestBreadcrumb(makeMockRequest({ path }), makeMockResponse()); + + const callArg = vi.mocked(breadcrumbsModule.addBreadcrumb).mock.calls.at(-1)![0]; + return callArg.data?.['url.query']; + }); + } + + it('filters sensitive params and preserves encoding by default', () => { + expect(breadcrumbQuery('/api/test?token=abc123&q=a%20b%26c&page=5')).toBe('token=[Filtered]&q=a%20b%26c&page=5'); + }); + + it('omits the query entirely when collection is off', () => { + expect(breadcrumbQuery('/api/test?token=abc123&page=5', false)).toBeUndefined(); + }); + + it('honors allowList mode', () => { + expect(breadcrumbQuery('/api/test?page=1&ref=x&sort=name', { allow: ['page', 'sort'] })).toBe( + 'page=1&ref=[Filtered]&sort=name', + ); + }); + + it('honors extra deny terms', () => { + expect(breadcrumbQuery('/api/test?page=1&utm_source=email', { deny: ['utm'] })).toBe( + 'page=1&utm_source=[Filtered]', + ); + }); + }); }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 29f4d7cb234b..e3f23f6291aa 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -796,10 +796,12 @@ describe('applyScopeToSegmentSpan integration', () => { }); describe('dataCollection.urlQueryParams', () => { + const SPAN_NAME_PATH = '/api/users'; + function captureUrlSpan( attributes: Record, dataCollection?: { urlQueryParams?: boolean | { allow: string[] } | { deny: string[] } }, - ): Record | undefined { + ) { const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', @@ -810,16 +812,22 @@ describe('applyScopeToSegmentSpan integration', () => { const span = withScope(scope => { scope.setClient(client); - const span = startInactiveSpan({ name: 'my-span', attributes }); + const span = startInactiveSpan({ name: `GET ${SPAN_NAME_PATH}`, attributes }); span.end(); return span; }); - return captureSpan(span, client).attributes as Record | undefined; + return captureSpan(span, client); + } + + function captureUrlSpanAttributes( + ...args: Parameters + ): Record | undefined { + return captureUrlSpan(...args).attributes as Record | undefined; } it('filters sensitive query params in `url.full` and `url.query` by default', () => { - const attributes = captureUrlSpan({ + const attributes = captureUrlSpanAttributes({ 'url.full': 'https://example.com/api/users?token=abc123&q=a%20b%26c&page=5', 'url.query': 'token=abc123&q=a%20b%26c&page=5', 'url.path': '/api/users', @@ -830,16 +838,19 @@ describe('applyScopeToSegmentSpan integration', () => { }); it('leaves `url.path` and the span name untouched', () => { - const attributes = captureUrlSpan({ + const serialized = captureUrlSpan({ 'url.full': 'https://example.com/api/users?token=abc123', 'url.path': '/api/users', }); + const attributes = serialized.attributes as Record | undefined; expect(attributes?.['url.path']?.value).toBe('/api/users'); + expect(serialized.name).toBe(`GET ${SPAN_NAME_PATH}`); + expect(attributes?.['sentry.segment.name']?.value).toBe(`GET ${SPAN_NAME_PATH}`); }); it('removes query data entirely when collection is off', () => { - const attributes = captureUrlSpan( + const attributes = captureUrlSpanAttributes( { 'url.full': 'https://example.com/api/users?token=abc123&page=5', 'url.query': 'token=abc123&page=5', @@ -852,7 +863,7 @@ describe('applyScopeToSegmentSpan integration', () => { }); it('honors allowList mode', () => { - const attributes = captureUrlSpan( + const attributes = captureUrlSpanAttributes( { 'url.query': 'page=1&ref=x&sort=name' }, { urlQueryParams: { allow: ['page', 'sort'] } }, ); @@ -861,7 +872,7 @@ describe('applyScopeToSegmentSpan integration', () => { }); it('honors extra deny terms', () => { - const attributes = captureUrlSpan( + const attributes = captureUrlSpanAttributes( { 'url.query': 'page=1&utm_source=email' }, { urlQueryParams: { deny: ['utm'] } }, ); @@ -869,14 +880,30 @@ describe('applyScopeToSegmentSpan integration', () => { expect(attributes?.['url.query']?.value).toBe('page=1&utm_source=[Filtered]'); }); - it('filters attributes set by instrumentation that runs after the span starts', () => { - const attributes = captureUrlSpan({ 'url.full': 'https://example.com/s?session=abc&ok=1' }); + it('filters URL attributes set by a `processSpan` subscriber after the span ended', () => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', tracesSampleRate: 1 }), + ); + + // Mirrors `requestDataIntegration`, which sets `url.full` during `processSegmentSpan` + client.on('processSpan', spanJSON => { + safeSetSpanJSONAttributes(spanJSON, { 'url.full': 'https://example.com/s?session=abc&ok=1' }); + }); + + const span = withScope(scope => { + scope.setClient(client); + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + return span; + }); + + const attributes = captureSpan(span, client).attributes as Record | undefined; expect(attributes?.['url.full']?.value).toBe('https://example.com/s?session=[Filtered]&ok=1'); }); it('filters URL attributes stored as attribute objects', () => { - const attributes = captureUrlSpan({ + const attributes = captureUrlSpanAttributes({ 'url.full': { value: 'https://example.com/s?token=abc&ok=1', type: 'string' }, 'url.query': { value: 'token=abc&ok=1', type: 'string' }, } as unknown as Record); @@ -886,7 +913,7 @@ describe('applyScopeToSegmentSpan integration', () => { }); it('removes an attribute-object `url.query` when collection is off', () => { - const attributes = captureUrlSpan( + const attributes = captureUrlSpanAttributes( { 'url.query': { value: 'token=abc', type: 'string' } } as unknown as Record, { urlQueryParams: false }, ); diff --git a/packages/node/test/utils/outgoingFetchRequestBreadcrumb.test.ts b/packages/node/test/utils/outgoingFetchRequestBreadcrumb.test.ts new file mode 100644 index 000000000000..8466cf3b3b4f --- /dev/null +++ b/packages/node/test/utils/outgoingFetchRequestBreadcrumb.test.ts @@ -0,0 +1,61 @@ +import type { CollectBehavior } from '@sentry/core'; +import { addBreadcrumb, getCurrentScope, withScope } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { UndiciRequest, UndiciResponse } from '../../src/integrations/node-fetch/types'; +import { addFetchRequestBreadcrumb } from '../../src/utils/outgoingFetchRequest'; +import { NodeClient } from '../../src'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; + +vi.mock('@sentry/core', async () => { + const actual = (await vi.importActual('@sentry/core')) as Record; + return { ...actual, addBreadcrumb: vi.fn() }; +}); + +function makeRequest(path: string): UndiciRequest { + return { method: 'GET', origin: 'https://example.com', path, headers: {} } as unknown as UndiciRequest; +} + +const RESPONSE = { statusCode: 200 } as unknown as UndiciResponse; + +/** + * Breadcrumbs never reach the span pipeline, so `addFetchRequestBreadcrumb` is the only place + * `dataCollection.urlQueryParams` is applied to outgoing fetch breadcrumbs. + */ +describe('addFetchRequestBreadcrumb', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function breadcrumbQuery(path: string, urlQueryParams?: CollectBehavior): unknown { + const client = new NodeClient( + getDefaultNodeClientOptions(urlQueryParams !== undefined ? { dataCollection: { urlQueryParams } } : {}), + ); + + return withScope(scope => { + scope.setClient(client); + getCurrentScope().setClient(client); + addFetchRequestBreadcrumb(makeRequest(path), RESPONSE); + + const callArg = vi.mocked(addBreadcrumb).mock.calls.at(-1)![0]; + return callArg.data?.['url.query']; + }); + } + + it('filters sensitive params and preserves encoding by default', () => { + expect(breadcrumbQuery('/api?token=abc123&q=a%20b%26c&page=5')).toBe('token=[Filtered]&q=a%20b%26c&page=5'); + }); + + it('omits the query entirely when collection is off', () => { + expect(breadcrumbQuery('/api?token=abc123&page=5', false)).toBeUndefined(); + }); + + it('honors allowList mode', () => { + expect(breadcrumbQuery('/api?page=1&ref=x&sort=name', { allow: ['page', 'sort'] })).toBe( + 'page=1&ref=[Filtered]&sort=name', + ); + }); + + it('honors extra deny terms', () => { + expect(breadcrumbQuery('/api?page=1&utm_source=email', { deny: ['utm'] })).toBe('page=1&utm_source=[Filtered]'); + }); +}); From 82e22d5cc32a35fac97ebf9bdf89f68ca8c69892 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 6 Aug 2026 11:27:18 +0200 Subject: [PATCH 3/6] pr feedback --- .../filterUrlSpanAttributes.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts b/packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts index 085ab1a553bd..b9af8b8efbfd 100644 --- a/packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts +++ b/packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts @@ -1,4 +1,5 @@ import { URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; +import type { RawAttributes } from '../../attributes'; import { isAttributeObject } from '../../attributes'; import type { CollectBehavior } from '../../types/datacollection'; import type { StreamedSpanJSON } from '../../types/span'; @@ -8,10 +9,12 @@ import { filterUrlQuery } from './filterUrlQuery'; /** * Applies `dataCollection.urlQueryParams` to the URL attributes of a span. * - * This is the safety net for the ~50 places across the SDKs that set `url.full` / `url.query`: filtering - * centrally means an integration cannot leak a query string by forgetting to gate its own write, and it - * covers attributes set by users too. Instrumentation on hot paths additionally filters at write time, - * which is harmless because filtering is idempotent. + * Filtering centrally rather than at each write site means an + * integration cannot leak a query string by forgetting to gate its own write. + * + * The trade-off is that this cannot tell an SDK-set attribute from one a user set themselves, so a + * `url.full` set via `span.setAttribute()` is filtered as well, even though `dataCollection` is only + * meant to gate automatically collected data. * * `url.path` and the span name are deliberately untouched — per spec they never carry a query string. */ @@ -27,11 +30,11 @@ export function filterUrlSpanAttributes(spanJSON: StreamedSpanJSON, behavior: Co /** * Applies `map` to a string attribute, which may be stored either as a bare string or wrapped in an - * attribute object (`{ value, type }`) — both shapes are valid on a span. Removes the attribute when - * `map` returns `undefined`. + * attribute object (`{ value, type }`) — both shapes are valid on a span. Clears the attribute when + * `map` returns `undefined`; serialization drops `undefined` values. */ function mapStringAttribute( - attributes: NonNullable, + attributes: RawAttributes>, key: string, map: (value: string) => string | undefined, ): void { @@ -46,8 +49,7 @@ function mapStringAttribute( const mapped = map(value); if (mapped === undefined) { - // oxlint-disable-next-line typescript/no-dynamic-delete -- the keys passed here are string constants - delete attributes[key]; + attributes[key] = undefined; } else if (isWrapped) { attributes[key] = { ...rawValue, value: mapped }; } else { From 868bc15d3af94e6ab9cdc82df50e6121cbdab5a0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 6 Aug 2026 13:12:54 +0200 Subject: [PATCH 4/6] ref(core): Filter collected URLs at their write sites Replace the central `captureSpan` filtering pass with a `filterCollectedUrl` helper that every instrumentation site calls when it records a URL. The central pass could not tell an SDK-set attribute from one a user set themselves, so a `url.full` passed to `span.setAttribute()` was filtered too. `dataCollection` is only meant to gate automatically collected data. Routing the SDK's own URLs through a helper makes provenance structural: a URL the user attaches never passes through it, so it is left alone. The helper reads `urlQueryParams` from the client itself, so call sites only wrap the value and `getHttpSpanDetailsFromUrlObject` keeps its signature. Attributes copied in from third-party OTel instrumentation are filtered where the SDK copies them, in `inferSpanData`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/angular/src/tracing.ts | 10 +- packages/astro/src/server/middleware.ts | 6 +- .../aws-serverless/src/requestSpanOptions.ts | 4 +- .../src/metrics/browserMetrics.ts | 3 +- .../integrations/fetchStreamPerformance.ts | 3 +- .../browser/src/integrations/httpcontext.ts | 3 +- .../src/tracing/browserTracingIntegration.ts | 3 +- packages/browser/src/tracing/request.ts | 7 +- packages/bun/src/integrations/bunserver.ts | 6 +- packages/core/src/fetch.ts | 7 +- .../http/add-outgoing-request-breadcrumb.ts | 9 +- .../http/get-outgoing-span-data.ts | 3 +- .../integrations/http/server-subscription.ts | 3 +- packages/core/src/shared-exports.ts | 1 + .../core/src/tracing/spans/captureSpan.ts | 4 - .../data-collection/filterCollectedUrl.ts | 32 +++++ .../filterUrlSpanAttributes.ts | 58 --------- packages/core/src/utils/url.ts | 9 +- .../lib/tracing/spans/captureSpan.test.ts | 117 +++--------------- .../filterCollectedUrl.test.ts | 82 ++++++++++++ packages/elysia/src/withElysia.ts | 3 +- .../src/integrations/google-cloud-http.ts | 3 +- .../nestjs/src/integrations/wrap-route.ts | 4 +- .../appRouterRoutingInstrumentation.ts | 3 +- .../http/httpServerSpansIntegration.ts | 6 +- .../node-fetch/undici-instrumentation.ts | 6 +- .../node/src/utils/outgoingFetchRequest.ts | 8 +- .../src/utils/parseSpanDescription.ts | 6 +- .../src/client/createClientInstrumentation.ts | 7 +- packages/react-router/src/client/utils.ts | 4 +- .../src/server/createServerInstrumentation.ts | 5 +- packages/react/src/tanstackrouter.ts | 3 +- packages/remix/src/server/instrumentServer.ts | 5 +- .../server/integrations/tracing-channel.ts | 5 +- packages/solid/src/solidrouter.ts | 3 +- packages/solid/src/tanstackrouter.ts | 3 +- .../sveltekit/src/server-common/handle.ts | 5 +- packages/vue/src/tanstackrouter.ts | 3 +- 38 files changed, 223 insertions(+), 229 deletions(-) create mode 100644 packages/core/src/utils/data-collection/filterCollectedUrl.ts delete mode 100644 packages/core/src/utils/data-collection/filterUrlSpanAttributes.ts create mode 100644 packages/core/test/lib/utils/data-collection/filterCollectedUrl.test.ts diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index f59474b776e6..bfebfb3fae87 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -24,7 +24,13 @@ import { import { CODE_FUNCTION_NAME, SENTRY_OP, URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { GENERAL_FUNCTION_SPAN_OP } from '@sentry/conventions/op'; import type { Integration, Span } from '@sentry/core'; -import { debug, parseStringToURLObject, stripUrlQueryAndFragment, timestampInSeconds } from '@sentry/core'; +import { + debug, + parseStringToURLObject, + stripUrlQueryAndFragment, + timestampInSeconds, + filterCollectedUrl, +} from '@sentry/core'; import type { Observable } from 'rxjs'; import { Subscription } from 'rxjs'; import { filter, tap } from 'rxjs/operators'; @@ -72,7 +78,7 @@ export function _updateSpanAttributesForParametrizedUrl(route: string, url: stri span.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.${op}.angular`, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [URL_FULL]: absoluteUrl, + [URL_FULL]: filterCollectedUrl(absoluteUrl), [URL_PATH]: parseStringToURLObject(absoluteUrl)?.pathname, [URL_TEMPLATE]: route, }); diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 22263a81b42f..60924dd332cb 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -12,6 +12,8 @@ import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, spanToJSON, winterCGRequestToRequestData, + filterCollectedUrl, + filterCollectedUrlQuery, } from '@sentry/core'; import { captureException, @@ -214,7 +216,7 @@ async function instrumentRequestStartHttpServerSpan( [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: method, // This is here for backwards compatibility, we used to set this here before method, - [URL_FULL]: ctx.url.href, + [URL_FULL]: filterCollectedUrl(ctx.url.href), [URL_PATH]: ctx.url.pathname, ...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), client.getDataCollectionOptions()), }; @@ -223,7 +225,7 @@ async function instrumentRequestStartHttpServerSpan( attributes[HTTP_ROUTE] = parametrizedRoute; } - attributes[URL_QUERY] = getUrlQuery(ctx.url.search); + attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(ctx.url.search)); attributes[URL_FRAGMENT] = getUrlFragment(ctx.url.hash); const name = `${method} ${parametrizedRoute || ctx.url.pathname}`; diff --git a/packages/aws-serverless/src/requestSpanOptions.ts b/packages/aws-serverless/src/requestSpanOptions.ts index 8479842657d2..6e7f869668a1 100644 --- a/packages/aws-serverless/src/requestSpanOptions.ts +++ b/packages/aws-serverless/src/requestSpanOptions.ts @@ -28,7 +28,7 @@ import { } from '@sentry/conventions/attributes'; import { FAAS_FUNCTION_AWS_SPAN_OP } from '@sentry/conventions/op'; import type { SpanAttributes, StartSpanOptions } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, filterCollectedUrl } from '@sentry/core'; import type { Context } from 'aws-lambda'; import { ATTR_FAAS_EXECUTION, ATTR_FAAS_ID } from './semconv'; @@ -75,7 +75,7 @@ function extractOtherEventFields(event: unknown): SpanAttributes { const answer: SpanAttributes = {}; const fullUrl = extractFullUrl(event as ApiGatewayLikeEvent); if (fullUrl) { - answer[URL_FULL] = fullUrl; + answer[URL_FULL] = filterCollectedUrl(fullUrl); } return answer; } diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 5427d568e07e..d3639d24c83f 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, spanToJSON, + filterCollectedUrl, } from '@sentry/core'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; @@ -627,7 +628,7 @@ export function _addResourceSpans( attributes['url.same_origin'] = resourceUrl.includes(WINDOW.location.origin); - attributes[URL_FULL] = resourceUrl; + attributes[URL_FULL] = filterCollectedUrl(resourceUrl); _setResourceRequestAttributes(entry, attributes, [ // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus diff --git a/packages/browser/src/integrations/fetchStreamPerformance.ts b/packages/browser/src/integrations/fetchStreamPerformance.ts index dd3342a31cc7..ce8ec59782d1 100644 --- a/packages/browser/src/integrations/fetchStreamPerformance.ts +++ b/packages/browser/src/integrations/fetchStreamPerformance.ts @@ -10,6 +10,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, stripDataUrlContent, + filterCollectedUrl, } from '@sentry/core'; const responseToStreamSpan = new WeakMap(); @@ -81,7 +82,7 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { name: `${method} ${sanitizedUrl}`, startTime: handlerData.endTimestamp, attributes: { - [URL_FULL]: stripDataUrlContent(url), + [URL_FULL]: filterCollectedUrl(stripDataUrlContent(url)), 'http.method': method, type: 'fetch', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.stream', diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 00676f90547c..8c7f249a8a94 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, } from '@sentry/core/browser'; import { getHttpRequestData, WINDOW } from '../helpers'; +import { filterCollectedUrl } from '@sentry/core'; import { URL_FULL } from '@sentry/conventions/attributes'; /** @@ -59,7 +60,7 @@ export const httpContextIntegration = defineIntegration(() => { 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, + [URL_FULL]: spanOp !== 'http.client' ? filterCollectedUrl(reqData.url) : undefined, 'http.request.header.user_agent': headers['User-Agent'], 'http.request.header.referer': headers['Referer'], }); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index b0c67a006185..2e9f448619e1 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -45,6 +45,7 @@ import { startTrackingLongTasks, } from '@sentry/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; +import { filterCollectedUrl } from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; import { fetchStreamPerformanceIntegration } from '../integrations/fetchStreamPerformance'; import { WEB_VITALS_INTEGRATION_NAME, webVitalsIntegration } from '../integrations/webVitals'; @@ -392,7 +393,7 @@ export const browserTracingIntegration = ((options: Partial filterUrlQuery(value, behavior)); - mapStringAttribute(attributes, URL_QUERY, value => filterQueryParams(value, behavior)); -} - -/** - * Applies `map` to a string attribute, which may be stored either as a bare string or wrapped in an - * attribute object (`{ value, type }`) — both shapes are valid on a span. Clears the attribute when - * `map` returns `undefined`; serialization drops `undefined` values. - */ -function mapStringAttribute( - attributes: RawAttributes>, - key: string, - map: (value: string) => string | undefined, -): void { - const rawValue = attributes[key]; - const isWrapped = isAttributeObject(rawValue); - const value = isWrapped ? rawValue.value : rawValue; - - if (typeof value !== 'string') { - return; - } - - const mapped = map(value); - - if (mapped === undefined) { - attributes[key] = undefined; - } else if (isWrapped) { - attributes[key] = { ...rawValue, value: mapped }; - } else { - attributes[key] = mapped; - } -} diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 6f88f390b0f7..21d41bca6014 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -16,6 +16,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../semanticAttributes'; import type { SpanAttributes } from '../types/span'; +import { filterCollectedUrl, filterCollectedUrlQuery } from './data-collection/filterCollectedUrl'; type PartialURL = { host?: string; @@ -208,11 +209,11 @@ export function getHttpSpanDetailsFromUrlObject( if (urlObject) { // Relative URLs have no meaningful `href`, so fall back to the sanitized path. - attributes[URL_FULL] = isURLObjectRelative(urlObject) - ? getSanitizedUrlStringFromUrlObject(urlObject) - : urlObject.href; + attributes[URL_FULL] = filterCollectedUrl( + isURLObjectRelative(urlObject) ? getSanitizedUrlStringFromUrlObject(urlObject) : urlObject.href, + ); - attributes[URL_QUERY] = getUrlQuery(urlObject.search); + attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(urlObject.search)); attributes[URL_FRAGMENT] = getUrlFragment(urlObject.hash); if (urlObject.pathname) { attributes[URL_PATH] = urlObject.pathname; diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index e3f23f6291aa..e639da7ff67b 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -795,13 +795,11 @@ describe('applyScopeToSegmentSpan integration', () => { expect(serializedChild?.attributes).not.toHaveProperty('http.response.status_code'); }); + // `dataCollection` only gates automatically collected data. URL attributes the SDK collects are + // filtered at their write sites (see `filterCollectedUrl`), so anything reaching a span here is + // either already filtered or was set by the user and must be left alone. describe('dataCollection.urlQueryParams', () => { - const SPAN_NAME_PATH = '/api/users'; - - function captureUrlSpan( - attributes: Record, - dataCollection?: { urlQueryParams?: boolean | { allow: string[] } | { deny: string[] } }, - ) { + function captureUserSetUrl(attributeValue: unknown, dataCollection?: object): unknown { const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', @@ -810,115 +808,28 @@ describe('applyScopeToSegmentSpan integration', () => { }), ); - const span = withScope(scope => { - scope.setClient(client); - const span = startInactiveSpan({ name: `GET ${SPAN_NAME_PATH}`, attributes }); - span.end(); - return span; - }); - - return captureSpan(span, client); - } - - function captureUrlSpanAttributes( - ...args: Parameters - ): Record | undefined { - return captureUrlSpan(...args).attributes as Record | undefined; - } - - it('filters sensitive query params in `url.full` and `url.query` by default', () => { - const attributes = captureUrlSpanAttributes({ - 'url.full': 'https://example.com/api/users?token=abc123&q=a%20b%26c&page=5', - 'url.query': 'token=abc123&q=a%20b%26c&page=5', - 'url.path': '/api/users', - }); - - expect(attributes?.['url.full']?.value).toBe('https://example.com/api/users?token=[Filtered]&q=a%20b%26c&page=5'); - expect(attributes?.['url.query']?.value).toBe('token=[Filtered]&q=a%20b%26c&page=5'); - }); - - it('leaves `url.path` and the span name untouched', () => { - const serialized = captureUrlSpan({ - 'url.full': 'https://example.com/api/users?token=abc123', - 'url.path': '/api/users', - }); - const attributes = serialized.attributes as Record | undefined; - - expect(attributes?.['url.path']?.value).toBe('/api/users'); - expect(serialized.name).toBe(`GET ${SPAN_NAME_PATH}`); - expect(attributes?.['sentry.segment.name']?.value).toBe(`GET ${SPAN_NAME_PATH}`); - }); - - it('removes query data entirely when collection is off', () => { - const attributes = captureUrlSpanAttributes( - { - 'url.full': 'https://example.com/api/users?token=abc123&page=5', - 'url.query': 'token=abc123&page=5', - }, - { urlQueryParams: false }, - ); - - expect(attributes?.['url.full']?.value).toBe('https://example.com/api/users'); - expect(attributes?.['url.query']).toBeUndefined(); - }); - - it('honors allowList mode', () => { - const attributes = captureUrlSpanAttributes( - { 'url.query': 'page=1&ref=x&sort=name' }, - { urlQueryParams: { allow: ['page', 'sort'] } }, - ); - - expect(attributes?.['url.query']?.value).toBe('page=1&ref=[Filtered]&sort=name'); - }); - - it('honors extra deny terms', () => { - const attributes = captureUrlSpanAttributes( - { 'url.query': 'page=1&utm_source=email' }, - { urlQueryParams: { deny: ['utm'] } }, - ); - - expect(attributes?.['url.query']?.value).toBe('page=1&utm_source=[Filtered]'); - }); - - it('filters URL attributes set by a `processSpan` subscriber after the span ended', () => { - const client = new TestClient( - getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', tracesSampleRate: 1 }), - ); - - // Mirrors `requestDataIntegration`, which sets `url.full` during `processSegmentSpan` - client.on('processSpan', spanJSON => { - safeSetSpanJSONAttributes(spanJSON, { 'url.full': 'https://example.com/s?session=abc&ok=1' }); - }); - const span = withScope(scope => { scope.setClient(client); const span = startInactiveSpan({ name: 'my-span' }); + span.setAttribute('url.full', attributeValue as string); span.end(); return span; }); const attributes = captureSpan(span, client).attributes as Record | undefined; + return attributes?.['url.full']?.value; + } - expect(attributes?.['url.full']?.value).toBe('https://example.com/s?session=[Filtered]&ok=1'); - }); - - it('filters URL attributes stored as attribute objects', () => { - const attributes = captureUrlSpanAttributes({ - 'url.full': { value: 'https://example.com/s?token=abc&ok=1', type: 'string' }, - 'url.query': { value: 'token=abc&ok=1', type: 'string' }, - } as unknown as Record); - - expect(attributes?.['url.full']?.value).toBe('https://example.com/s?token=[Filtered]&ok=1'); - expect(attributes?.['url.query']?.value).toBe('token=[Filtered]&ok=1'); + it('does not filter a `url.full` the user set themselves', () => { + expect(captureUserSetUrl('https://example.com/api?token=abc123&page=5')).toBe( + 'https://example.com/api?token=abc123&page=5', + ); }); - it('removes an attribute-object `url.query` when collection is off', () => { - const attributes = captureUrlSpanAttributes( - { 'url.query': { value: 'token=abc', type: 'string' } } as unknown as Record, - { urlQueryParams: false }, + it('does not strip a user-set query even when collection is off', () => { + expect(captureUserSetUrl('https://example.com/api?token=abc123', { urlQueryParams: false })).toBe( + 'https://example.com/api?token=abc123', ); - - expect(attributes?.['url.query']).toBeUndefined(); }); }); }); diff --git a/packages/core/test/lib/utils/data-collection/filterCollectedUrl.test.ts b/packages/core/test/lib/utils/data-collection/filterCollectedUrl.test.ts new file mode 100644 index 000000000000..cc71b0714012 --- /dev/null +++ b/packages/core/test/lib/utils/data-collection/filterCollectedUrl.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { withScope } from '../../../../src/currentScopes'; +import type { CollectBehavior } from '../../../../src/types/datacollection'; +import { filterCollectedUrl, filterCollectedUrlQuery } from '../../../../src/utils/data-collection/filterCollectedUrl'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function withUrlQueryParams(urlQueryParams: CollectBehavior | undefined, fn: () => T): T { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + ...(urlQueryParams !== undefined ? { dataCollection: { urlQueryParams } } : {}), + }), + ); + + return withScope(scope => { + scope.setClient(client); + return fn(); + }); +} + +describe('filterCollectedUrl', () => { + it('filters sensitive params and preserves encoding by default', () => { + const result = withUrlQueryParams(undefined, () => + filterCollectedUrl('https://example.com/api/users?token=abc123&q=a%20b%26c&page=5'), + ); + + expect(result).toBe('https://example.com/api/users?token=[Filtered]&q=a%20b%26c&page=5'); + }); + + it('strips the query entirely when collection is off', () => { + const result = withUrlQueryParams(false, () => filterCollectedUrl('https://example.com/api?token=abc&page=5')); + + expect(result).toBe('https://example.com/api'); + }); + + it('honors allowList mode', () => { + const result = withUrlQueryParams({ allow: ['page'] }, () => + filterCollectedUrl('https://example.com/s?page=1&ref=x'), + ); + + expect(result).toBe('https://example.com/s?page=1&ref=[Filtered]'); + }); + + it('leaves a URL without a query untouched', () => { + expect(withUrlQueryParams(undefined, () => filterCollectedUrl('https://example.com/api'))).toBe( + 'https://example.com/api', + ); + }); + + it('passes `undefined` through', () => { + expect(withUrlQueryParams(undefined, () => filterCollectedUrl(undefined))).toBeUndefined(); + }); + + it('falls back to the denylist when no client is set', () => { + expect(filterCollectedUrl('https://example.com/api?token=abc&page=5')).toBe( + 'https://example.com/api?token=[Filtered]&page=5', + ); + }); +}); + +describe('filterCollectedUrlQuery', () => { + it('filters sensitive params by default', () => { + expect(withUrlQueryParams(undefined, () => filterCollectedUrlQuery('token=abc&page=5'))).toBe( + 'token=[Filtered]&page=5', + ); + }); + + it('returns undefined when collection is off', () => { + expect(withUrlQueryParams(false, () => filterCollectedUrlQuery('token=abc'))).toBeUndefined(); + }); + + it('returns undefined for an empty or missing query', () => { + expect(withUrlQueryParams(undefined, () => filterCollectedUrlQuery(''))).toBeUndefined(); + expect(withUrlQueryParams(undefined, () => filterCollectedUrlQuery(undefined))).toBeUndefined(); + }); + + it('honors extra deny terms', () => { + expect(withUrlQueryParams({ deny: ['utm'] }, () => filterCollectedUrlQuery('page=1&utm_source=email'))).toBe( + 'page=1&utm_source=[Filtered]', + ); + }); +}); diff --git a/packages/elysia/src/withElysia.ts b/packages/elysia/src/withElysia.ts index 30253e0caad6..2820e9231b8d 100644 --- a/packages/elysia/src/withElysia.ts +++ b/packages/elysia/src/withElysia.ts @@ -17,6 +17,7 @@ import { updateSpanName, winterCGRequestToRequestData, withIsolationScope, + filterCollectedUrl, } from '@sentry/core'; import type { AnyElysia, Elysia, ErrorContext, TraceHandler, TraceListener } from 'elysia'; @@ -206,7 +207,7 @@ export function withElysia(app: T, options: ElysiaHandlerOp attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [URL_FULL]: request.url, + [URL_FULL]: filterCollectedUrl(request.url), [URL_PATH]: new URL(request.url).pathname, }, }, diff --git a/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts b/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts index 0852a53d540c..3a0ab698a8ee 100644 --- a/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts +++ b/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts @@ -10,6 +10,7 @@ import { parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SentryNonRecordingSpan, + filterCollectedUrl, } from '@sentry/core'; import { startInactiveSpan } from '@sentry/node'; @@ -63,7 +64,7 @@ function wrapRequestFunction(orig: RequestFunction): RequestFunction { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.serverless', [HTTP_REQUEST_METHOD]: httpMethod, [SERVER_ADDRESS]: getServerAddress(this.apiEndpoint), - [URL_FULL]: reqOpts.uri, + [URL_FULL]: filterCollectedUrl(reqOpts.uri), }, }) : new SentryNonRecordingSpan(); diff --git a/packages/nestjs/src/integrations/wrap-route.ts b/packages/nestjs/src/integrations/wrap-route.ts index 47a8d1b4909a..ac1b0938ab51 100644 --- a/packages/nestjs/src/integrations/wrap-route.ts +++ b/packages/nestjs/src/integrations/wrap-route.ts @@ -1,6 +1,6 @@ import { HTTP_METHOD, HTTP_ROUTE, URL_FULL } from '@sentry/conventions/attributes'; import type { SpanAttributes } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, filterCollectedUrl } from '@sentry/core'; import type { AnyFn } from './helpers'; import { copyReflectMetadata, HTTP_ORIGIN, isWrapped, markWrapped } from './helpers'; import { AttributeNames, NestType } from './enums'; @@ -96,7 +96,7 @@ export function wrapRequestContextHandler( [HTTP_ROUTE]: httpRoute || undefined, // oxlint-disable-next-line typescript/no-deprecated [HTTP_METHOD]: req.method || undefined, - [URL_FULL]: req.originalUrl || req.url || undefined, + [URL_FULL]: filterCollectedUrl(req.originalUrl || req.url || undefined), }; return startSpan({ name: spanName, op: `${NestType.REQUEST_CONTEXT}.nestjs`, attributes }, () => handler.apply(this, handlerArgs), diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index f3146b55e868..9b0c8318c7c3 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + filterCollectedUrl, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, @@ -26,7 +27,7 @@ function stripTrailingSlash(pathname: string): string { function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void { span.setAttributes({ [URL_PATH]: urlPath, - [URL_FULL]: getAbsoluteUrl(urlOrPath), + [URL_FULL]: filterCollectedUrl(getAbsoluteUrl(urlOrPath)), }); } diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index b832c133132e..2aa1ef1b6a1b 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -51,6 +51,8 @@ import { withActiveSpan, getUrlFragment, getUrlQuery, + filterCollectedUrl, + filterCollectedUrlQuery, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; @@ -177,9 +179,9 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', [SENTRY_HTTP_PREFETCH]: isKnownPrefetchRequest(request) || undefined, - [URL_FULL]: fullUrl, + [URL_FULL]: filterCollectedUrl(fullUrl), [URL_PATH]: urlObj?.pathname ?? httpTargetWithoutQueryFragment, - [URL_QUERY]: query, + [URL_QUERY]: filterCollectedUrlQuery(query), [URL_FRAGMENT]: fragment, // Old Semantic Conventions attributes - added for compatibility with what `@opentelemetry/instrumentation-http` output before /* eslint-disable typescript/no-deprecated */ diff --git a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts index 353ad0f11a37..708f30e703b3 100644 --- a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts +++ b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts @@ -37,6 +37,8 @@ import { stripDataUrlContent, getUrlFragment, getUrlQuery, + filterCollectedUrl, + filterCollectedUrlQuery, } from '@sentry/core'; import { addFetchRequestBreadcrumb, addTracePropagationHeadersToFetchRequest } from '../../utils/outgoingFetchRequest'; import { @@ -216,9 +218,9 @@ function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage) [SENTRY_KIND]: 'client', [HTTP_REQUEST_METHOD]: requestMethod, [ATTR_HTTP_REQUEST_METHOD_ORIGINAL]: request.method, - [URL_FULL]: requestUrl.toString(), + [URL_FULL]: filterCollectedUrl(requestUrl.toString()), [URL_PATH]: requestUrl.pathname, - [URL_QUERY]: getUrlQuery(requestUrl.search), + [URL_QUERY]: filterCollectedUrlQuery(getUrlQuery(requestUrl.search)), [URL_FRAGMENT]: getUrlFragment(requestUrl.hash), [URL_SCHEME]: urlScheme, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', diff --git a/packages/node/src/utils/outgoingFetchRequest.ts b/packages/node/src/utils/outgoingFetchRequest.ts index 6c7958346f9c..209eaddbd72a 100644 --- a/packages/node/src/utils/outgoingFetchRequest.ts +++ b/packages/node/src/utils/outgoingFetchRequest.ts @@ -1,7 +1,7 @@ import { HTTP_METHOD, URL_FRAGMENT, URL_QUERY } from '@sentry/conventions/attributes'; import type { LRUMap, SanitizedRequestData, Span } from '@sentry/core'; import { - _INTERNAL_filterQueryParams, + filterCollectedUrlQuery, addBreadcrumb, getActiveSpan, getBreadcrumbLogLevelFromHttpStatusCode, @@ -256,15 +256,11 @@ function getBreadcrumbData(request: UndiciRequest): Partial = {}; if (url) { - data[URL_FULL] = url; + data[URL_FULL] = filterCollectedUrl(url); } - const urlQuery = getUrlQuery(query); + const urlQuery = filterCollectedUrlQuery(getUrlQuery(query)); if (urlQuery) { data[URL_QUERY] = urlQuery; } diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index 68244445eb35..e3421cad3d8c 100644 --- a/packages/react-router/src/client/createClientInstrumentation.ts +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -14,6 +14,7 @@ import { SPAN_STATUS_ERROR, startSpan, updateSpanName, + filterCollectedUrl, } from '@sentry/core'; import { DEBUG_BUILD } from '../common/debug-build'; import type { ClientInstrumentation, InstrumentableRoute, InstrumentableRouter } from '../common/types'; @@ -127,7 +128,7 @@ export function createSentryClientInstrumentation( const result = await callNavigate(); if (result.status === 'error' && result.error instanceof Error) { captureInstrumentationError(result, captureErrors, 'react_router.navigate', { - [URL_FULL]: info.currentUrl, + [URL_FULL]: filterCollectedUrl(info.currentUrl), }); } return; @@ -174,7 +175,7 @@ export function createSentryClientInstrumentation( navigationSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } captureInstrumentationError(result, captureErrors, 'react_router.navigate', { - [URL_FULL]: WINDOW.location?.pathname || info.currentUrl, + [URL_FULL]: WINDOW.location?.pathname || filterCollectedUrl(info.currentUrl), }); } } finally { @@ -230,7 +231,7 @@ export function createSentryClientInstrumentation( if (result.status === 'error' && result.error instanceof Error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); captureInstrumentationError(result, captureErrors, 'react_router.fetcher', { - [URL_FULL]: info.href, + [URL_FULL]: filterCollectedUrl(info.href), }); } }, diff --git a/packages/react-router/src/client/utils.ts b/packages/react-router/src/client/utils.ts index 58c367d00c4b..c8e522b182b2 100644 --- a/packages/react-router/src/client/utils.ts +++ b/packages/react-router/src/client/utils.ts @@ -1,6 +1,6 @@ import { getAbsoluteUrl } from '@sentry/browser'; import type { Span } from '@sentry/core'; -import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, filterCollectedUrl } from '@sentry/core'; import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { DataRouter, RouterState } from 'react-router'; @@ -106,7 +106,7 @@ export function updateNavigationSpanUrlFromLocation(span: Span): void { span.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [URL_PATH]: pathname, - [URL_FULL]: destinationUrl, + [URL_FULL]: filterCollectedUrl(destinationUrl), }); } diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts index 47c701ba377f..a12df00c845b 100644 --- a/packages/react-router/src/server/createServerInstrumentation.ts +++ b/packages/react-router/src/server/createServerInstrumentation.ts @@ -11,6 +11,7 @@ import { SPAN_STATUS_ERROR, startSpan, updateSpanName, + filterCollectedUrl, } from '@sentry/core'; import { DEBUG_BUILD } from '../common/debug-build'; import type { InstrumentableRequestHandler, InstrumentableRoute, ServerInstrumentation } from '../common/types'; @@ -64,7 +65,7 @@ export function createSentryServerInstrumentation( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [URL_FULL]: info.request.url, + [URL_FULL]: filterCollectedUrl(info.request.url), [URL_PATH]: pathname, }); @@ -91,7 +92,7 @@ export function createSentryServerInstrumentation( [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [HTTP_REQUEST_METHOD]: info.request.method, [URL_PATH]: pathname, - [URL_FULL]: info.request.url, + [URL_FULL]: filterCollectedUrl(info.request.url), }, }, async span => { diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index ea6e9a3ea1f2..839c880e4526 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -6,6 +6,7 @@ import { WINDOW, } from '@sentry/browser'; import type { Integration } from '@sentry/core/browser'; +import { filterCollectedUrl } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -182,7 +183,7 @@ function locationToSpanUrlAttributes( return { [URL_PATH]: location.pathname, - [URL_FULL]: absoluteUrl, + [URL_FULL]: filterCollectedUrl(absoluteUrl), }; } diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index 53a7fd98307a..f4efa762b73b 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -34,6 +34,7 @@ import { winterCGHeadersToDict, winterCGRequestToRequestData, withIsolationScope, + filterCollectedUrl, } from '@sentry/core'; import { DEBUG_BUILD } from '../utils/debug-build'; import { createRoutes, getTransactionName, isCloudflareEnv } from '../utils/utils'; @@ -133,7 +134,7 @@ function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) { onlyIfParent: true, attributes: { method: request.method, - [URL_FULL]: request.url, + [URL_FULL]: filterCollectedUrl(request.url), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.remix', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.remix.document_request', }, @@ -386,7 +387,7 @@ function wrapRequestHandler ServerBuild | Promise [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [URL_FULL]: url.href, + [URL_FULL]: filterCollectedUrl(url.href), [URL_PATH]: url.pathname, method: request.method, ...(source === 'route' && { diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts index d5cc0bfeadfc..701772c35218 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -11,6 +11,7 @@ import { spanToJSON, startInactiveSpan, waitForTracingChannelBinding, + filterCollectedUrl, } from '@sentry/core'; import { bindTracingChannelToSpan } from '@sentry/server-utils'; import { @@ -74,7 +75,9 @@ function getRequestAttributes(request: unknown): SpanAttributes { } if (typeof url === 'string') { const urlObject = parseStringToURLObject(url); - attributes[URL_FULL] = urlObject && !isURLObjectRelative(urlObject) ? urlObject.href : undefined; + attributes[URL_FULL] = filterCollectedUrl( + urlObject && !isURLObjectRelative(urlObject) ? urlObject.href : undefined, + ); attributes[URL_PATH] = urlObject?.pathname; } return attributes; diff --git a/packages/solid/src/solidrouter.ts b/packages/solid/src/solidrouter.ts index 6041253a2d28..1cbf5ff382a9 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -19,6 +19,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + filterCollectedUrl, } from '@sentry/core'; import type { BeforeLeaveEventArgs, @@ -40,7 +41,7 @@ function locationToSpanUrlAttributes(pathname: string, search: string = '', hash return { [URL_PATH]: pathname, - [URL_FULL]: getAbsoluteUrl(pathWithSearch), + [URL_FULL]: filterCollectedUrl(getAbsoluteUrl(pathWithSearch)), }; } diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index bc03ba841ff6..caee2291d190 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -17,6 +17,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + filterCollectedUrl, } from '@sentry/core'; import type { AnyRouter } from '@tanstack/solid-router'; @@ -177,7 +178,7 @@ function locationToSpanUrlAttributes(router: AnyRouter, location: TanstackRouter return { [URL_PATH]: location.pathname, - [URL_FULL]: absoluteUrl, + [URL_FULL]: filterCollectedUrl(absoluteUrl), }; } diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 1e306dfa9281..ea1c8654d429 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -19,6 +19,7 @@ import { winterCGHeadersToDict, winterCGRequestToRequestData, withIsolationScope, + filterCollectedUrl, } from '@sentry/core'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -181,7 +182,7 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url', 'sveltekit.tracing.original_name': originalName, - [URL_FULL]: kitRootSpanAttributes[URL_FULL] ?? event.url.href, + [URL_FULL]: kitRootSpanAttributes[URL_FULL] ?? filterCollectedUrl(event.url.href), [URL_PATH]: kitRootSpanAttributes[URL_PATH] ?? event.url.pathname, ...(routeName && { [HTTP_ROUTE]: routeName, @@ -214,7 +215,7 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', 'http.method': event.request.method, - [URL_FULL]: event.url.href, + [URL_FULL]: filterCollectedUrl(event.url.href), [URL_PATH]: event.url.pathname, ...(routeId && { [HTTP_ROUTE]: routeId, diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index e2508f2754df..841a625695f6 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -17,6 +17,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + filterCollectedUrl, } from '@sentry/core'; import type { AnyRouter } from '@tanstack/vue-router'; @@ -186,7 +187,7 @@ function locationToSpanUrlAttributes( return { [URL_PATH]: location.pathname, - [URL_FULL]: absoluteUrl, + [URL_FULL]: filterCollectedUrl(absoluteUrl), }; } From ee7cbb052ce2288c6153ad211ff0a4501813a895 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 6 Aug 2026 13:35:29 +0200 Subject: [PATCH 5/6] fix(core): Filter query params in `http.target` `http.target` is the deprecated alias of `url.full` and carries the same path and query string, so it bypassed `dataCollection.urlQueryParams` and sent sensitive query params even though `url.full` was filtered. Three sites were affected: both incoming server span integrations build it as `pathname + search`, and the outgoing client span uses `request.path`, which Node populates with the query string included. Co-Authored-By: Claude Opus 5 (1M context) --- .../http/get-outgoing-span-data.ts | 2 +- .../integrations/http/server-subscription.ts | 4 +++- .../http/server-subscription.test.ts | 19 +++++++++++++++++++ .../http/httpServerSpansIntegration.ts | 4 +++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/core/src/integrations/http/get-outgoing-span-data.ts b/packages/core/src/integrations/http/get-outgoing-span-data.ts index 3167816a7b5e..96a6d8812919 100644 --- a/packages/core/src/integrations/http/get-outgoing-span-data.ts +++ b/packages/core/src/integrations/http/get-outgoing-span-data.ts @@ -40,7 +40,7 @@ export function getOutgoingRequestSpanData(request: HttpClientRequest): StartSpa [URL_FULL]: filterCollectedUrl(url), /* eslint-disable typescript/no-deprecated */ [HTTP_METHOD]: request.method, - [HTTP_TARGET]: request.path || '/', + [HTTP_TARGET]: filterCollectedUrl(request.path || '/'), [NET_PEER_NAME]: request.host, [HTTP_HOST]: request.getHeader('host') as string | undefined, /* eslint-enable typescript/no-deprecated */ diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index 7ffb3d982eab..7b32bee4daa9 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -302,7 +302,9 @@ function buildServerSpanWrap( [URL_FULL]: filterCollectedUrl(fullUrl), [URL_PATH]: urlObj?.pathname ?? httpTargetWithoutQueryFragment, 'http.method': method, - 'http.target': urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment, + 'http.target': filterCollectedUrl( + urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment, + ), 'http.host': host, 'net.host.name': hostname, 'http.client_ip': typeof ips === 'string' ? ips.split(',')[0] : undefined, diff --git a/packages/core/test/lib/integrations/http/server-subscription.test.ts b/packages/core/test/lib/integrations/http/server-subscription.test.ts index eacefaa8df20..153e853ce56b 100644 --- a/packages/core/test/lib/integrations/http/server-subscription.test.ts +++ b/packages/core/test/lib/integrations/http/server-subscription.test.ts @@ -114,6 +114,25 @@ describe('getHttpServerSubscriptions', () => { ); }); + // `http.target` is the deprecated alias of `url.full` and carries the same query string, so it has to + // respect `dataCollection.urlQueryParams` too. + it('filters sensitive query params in `http.target` and `url.full`', async () => { + server = http.createServer((_req, res) => res.end('ok')); + await new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve())); + instrument(true); + + await makeRequest('/users/42?token=abc123&foo=bar'); + const transaction = await waitForTransaction(); + + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.target': '/users/42?token=[Filtered]&foo=bar', + [URL_FULL]: expect.stringMatching(/\/users\/42\?token=\[Filtered\]&foo=bar$/), + [URL_PATH]: '/users/42', + }), + ); + }); + it('reports a 500 status with internal_error span status', async () => { server = http.createServer((_req, res) => { res.statusCode = 500; diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index 2aa1ef1b6a1b..a45bb745c06b 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -186,7 +186,9 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions // Old Semantic Conventions attributes - added for compatibility with what `@opentelemetry/instrumentation-http` output before /* eslint-disable typescript/no-deprecated */ [HTTP_METHOD]: normalizedRequest.method, - [HTTP_TARGET]: urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment, + [HTTP_TARGET]: filterCollectedUrl( + urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment, + ), [HTTP_HOST]: host, [NET_HOST_NAME]: hostname, [HTTP_CLIENT_IP]: typeof ips === 'string' ? ips.split(',')[0] : undefined, From 3ebcc0f96969f46bbb22e4923bd7d282834ef4fe Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 6 Aug 2026 15:14:51 +0200 Subject: [PATCH 6/6] fix gcp names --- .../src/integrations/google-cloud-http.ts | 4 +++- .../integrations/google-cloud-http.test.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts b/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts index 3a0ab698a8ee..26c14d56153d 100644 --- a/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts +++ b/packages/google-cloud-serverless/src/integrations/google-cloud-http.ts @@ -11,6 +11,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SentryNonRecordingSpan, filterCollectedUrl, + stripUrlQueryAndFragment, } from '@sentry/core'; import { startInactiveSpan } from '@sentry/node'; @@ -57,7 +58,8 @@ function wrapRequestFunction(orig: RequestFunction): RequestFunction { const httpMethod = reqOpts.method || 'GET'; const span = SETUP_CLIENTS.has(getClient() as Client) ? startInactiveSpan({ - name: `${httpMethod} ${reqOpts.uri}`, + // Span names must not contain a query string, and callers can pass any URI they want. + name: `${httpMethod} ${stripUrlQueryAndFragment(reqOpts.uri)}`, onlyIfParent: true, attributes: { [SENTRY_OP]: WEB_SERVER_HTTP_CLIENT_SPAN_OP, diff --git a/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts b/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts index 5f2e78905b47..97a569958266 100644 --- a/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts +++ b/packages/google-cloud-serverless/test/integrations/google-cloud-http.test.ts @@ -100,5 +100,25 @@ describe('GoogleCloudHttp tracing', () => { }, }); }); + + // Span names follow `METHOD scheme://host/path`, so a query string must never reach the name, + // whatever the caller passes as `uri`. + test('strips the query string from the span name', async () => { + nock('https://bigquery.googleapis.com') + .get('/bigquery/v2/projects/project-id/datasets') + .query(true) + .reply(200, '{}'); + + await new Promise((resolve, reject) => { + (bigquery as unknown as { request: (o: unknown, cb: (e: unknown) => void) => void }).request( + { uri: '/datasets?key=SECRET_TOKEN_VALUE&alt=json', method: 'GET' }, + (err: unknown) => (err ? reject(err) : resolve()), + ); + }); + + expect(mockStartInactiveSpan).toBeCalledWith(expect.objectContaining({ name: 'GET /datasets' })); + const names = mockStartInactiveSpan.mock.calls.map(([args]) => (args as { name: string }).name); + expect(names.join('\n')).not.toContain('SECRET_TOKEN_VALUE'); + }); }); });