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> = {}): 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/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/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index eee8a7764a53..e639da7ff67b 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -794,4 +794,42 @@ describe('applyScopeToSegmentSpan integration', () => { expect(serializedChild?.is_segment).toBe(false); 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', () => { + function captureUserSetUrl(attributeValue: unknown, dataCollection?: object): unknown { + 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' }); + 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; + } + + 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('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', + ); + }); + }); }); 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..26c14d56153d 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,8 @@ import { parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SentryNonRecordingSpan, + filterCollectedUrl, + stripUrlQueryAndFragment, } from '@sentry/core'; import { startInactiveSpan } from '@sentry/node'; @@ -56,14 +58,15 @@ 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, [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/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'); + }); }); }); 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..a45bb745c06b 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,14 +179,16 @@ 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 */ [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, 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 cb2655294929..209eaddbd72a 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 { + filterCollectedUrlQuery, addBreadcrumb, getActiveSpan, getBreadcrumbLogLevelFromHttpStatusCode, @@ -259,7 +260,7 @@ function getBreadcrumbData(request: UndiciRequest): Partial { + 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]'); + }); +}); diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts index 9cc6c64385f1..a7860b25581e 100644 --- a/packages/opentelemetry/src/utils/parseSpanDescription.ts +++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts @@ -34,6 +34,8 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, stripUrlQueryAndFragment, + filterCollectedUrl, + filterCollectedUrlQuery, } from '@sentry/core'; interface SpanDescription { @@ -189,9 +191,9 @@ export function descriptionForHttpMethod(attributes: Attributes): SpanDescriptio const data: Record = {}; 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), }; }