From f56b80df49bee037b2359b9b5af21bc01cd5354c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 27 Aug 2026 14:26:48 +0200 Subject: [PATCH 1/2] feat: Emit low-cardinality http.client span names for fetch and XHR With span streaming, `http.client` spans are named `{method} {url.domain}` instead of `{method} {sanitized-url}`, falling back to the method alone when there is no domain. Covers `instrumentFetchRequest` in `@sentry/core`, browser XHR, and `http.client.stream`. Co-Authored-By: Claude Opus 5 (1M context) --- MIGRATION.md | 7 ++ .../http-timings-streamed/test.ts | 5 +- .../tracing/http-client-span-streamed/test.ts | 2 +- .../test.ts | 5 +- .../tracing/request/fetch-streamed/test.ts | 3 +- .../tracing/request/xhr-streamed/test.ts | 3 +- .../integrations/fetchStreamPerformance.ts | 14 +++- .../browser/src/integrations/graphqlClient.ts | 8 ++- packages/browser/src/tracing/request.ts | 7 +- .../fetchStreamPerformance.test.ts | 60 ++++++++++++++++ .../test/integrations/graphqlClient.test.ts | 20 ++++++ packages/browser/test/tracing/request.test.ts | 68 ++++++++++++++++++- packages/core/src/fetch.ts | 11 ++- packages/core/test/lib/fetch.test.ts | 48 ++++++++++++- 14 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 packages/browser/test/integrations/fetchStreamPerformance.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 7ddc981d29af..87baf1d5bf94 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -834,6 +834,7 @@ The following span names were adjusted: | `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | | `navigation` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Navigation` if the SDK has none | | `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one (`GET /users/123`) | `GET /users/:id` when a route is known, otherwise just the request method (`GET`) | +| `http.client`, `http.client.stream` | The request method and sanitized URL (`GET https://api.example.com/users/123`) | The request method and the domain (`GET api.example.com`), or just the method if there is no domain (`GET`) | | `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none | | `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) | | `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none | @@ -846,6 +847,10 @@ Resource spans now also carry a `url.domain` attribute holding that domain. The `http.server` requests that resolve to a route are **unchanged** — those names were already low cardinality. Only requests the SDK cannot parameterize are affected. +Outgoing requests never resolve to a route, so **every** `http.client` name changes: the path, query and fragment are dropped and only the domain is kept. The full URL remains available on `url.full`, and outgoing request spans now also carry a `url.domain` attribute holding that domain. + +A request with no domain to fall back on — a data URL, or a relative URL that the SDK cannot resolve against a page origin — is named after the method alone. + Some consequences to be aware of: The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes. @@ -856,6 +861,8 @@ For the same reason, `useOperationNameForRootSpan` no longer renames the enclosi Resource URIs are unbounded, so they are no longer part of an `mcp.server` span name. The URI remains available on the `mcp.resource.uri` attribute. +Because the URL path is gone from `http.client` names, `graphqlClientIntegration` no longer appends the operation to the outgoing request span name (`POST https://api.example.com/graphql (query GetUser)` becomes `POST api.example.com`). The operation stays on the request breadcrumb's `graphql.operation` data. + Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`. Child spans of a service or root span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references. The same applies to `ui.action.click` spans, which are named after the current route. diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts index 30e32621edbd..c88915e2dabd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts @@ -32,9 +32,10 @@ sentryTest( expect(pageloadSpan).toBeDefined(); expect(requestSpans).toHaveLength(3); - requestSpans?.forEach((span, index) => + requestSpans?.forEach(span => expect(span).toMatchObject({ - name: `GET http://sentry-test-site.example/${index}`, + // The URL path is high cardinality, so a streamed span name keeps only the domain. + name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts index 3072734d2b79..83849a576668 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -24,7 +24,7 @@ sentryTest( const span = await spanPromise; - expect(span.name).toMatch(/^GET /); + expect(span.name).toBe('GET sentry-test-site.example'); expect(span.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser' }); expect(span.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts index 8e332aa18a0b..f8babfecf43c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts @@ -45,12 +45,13 @@ sentryTest( const [requestSpan, streamSpan] = await Promise.all([httpSpanPromise, streamSpanPromise]); expect(requestSpan).toMatchObject({ - name: 'GET http://sentry-test-site.example/delayed', + name: 'GET sentry-test-site.example', status: 'ok', }); + // `http.client.stream` follows the same name rules as `http.client`, so the path is dropped here too. expect(streamSpan).toMatchObject({ - name: 'GET http://sentry-test-site.example/delayed', + name: 'GET sentry-test-site.example', attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: 'http://sentry-test-site.example/delayed' }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts index 9702763ce3f1..eb81d940bcb8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts @@ -29,7 +29,8 @@ sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) requestSpans.forEach((span, index) => expect(span).toMatchObject({ - name: `GET http://sentry-test-site.example/${index}`, + // The URL path is high cardinality, so a streamed span name keeps only the domain. + name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts index 7fb689dafc1e..ea869b33abfd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts @@ -29,7 +29,8 @@ sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) = requestSpans.forEach((span, index) => expect(span).toMatchObject({ - name: `GET http://sentry-test-site.example/${index}`, + // The URL path is high cardinality, so a streamed span name keeps only the domain. + name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), start_timestamp: expect.any(Number), diff --git a/packages/browser/src/integrations/fetchStreamPerformance.ts b/packages/browser/src/integrations/fetchStreamPerformance.ts index 96b2930c512c..2a5d959e1623 100644 --- a/packages/browser/src/integrations/fetchStreamPerformance.ts +++ b/packages/browser/src/integrations/fetchStreamPerformance.ts @@ -1,10 +1,13 @@ -import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes'; import type { IntegrationFn, Span } from '@sentry/core'; import { addFetchEndInstrumentationHandler, addFetchInstrumentationHandler, defineIntegration, + getClient, getSanitizedUrlStringFromUrlObject, + hasSpanStreamingEnabled, + isURLObjectRelative, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -78,11 +81,18 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { ? getSanitizedUrlStringFromUrlObject(parsedUrl) : url; + // `http.client.stream` follows the same name rules as `http.client`: with span streaming the + // URL path is dropped and only the domain is kept. Relative URLs have no domain, and an + // outgoing request has no route to parameterize. + const client = getClient(); + const domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined; + const streamedName = domain ? `${method} ${domain}` : method; const streamSpan = startInactiveSpan({ - name: `${method} ${sanitizedUrl}`, + name: !!client && hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`, startTime: handlerData.endTimestamp, attributes: { [URL_FULL]: filterCollectedUrl(stripDataUrlContent(url)), + [URL_DOMAIN]: domain, [HTTP_REQUEST_METHOD]: method, type: 'fetch', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.stream', diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 4399c41c0424..9b4920cfcfdf 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -1,6 +1,7 @@ import type { Client, IntegrationFn } from '@sentry/core/browser'; import { defineIntegration, + hasSpanStreamingEnabled, isObjectLike, isString, SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, @@ -83,8 +84,11 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const graphqlBody = getGraphQLRequestPayload(payload); if (graphqlBody) { - const operationInfo = _getGraphQLOperation(graphqlBody); - span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); + // With span streaming the span already carries a low-cardinality name, so it must not be + // renamed back to something containing the URL. + if (!hasSpanStreamingEnabled(client)) { + span.updateName(`${httpMethod} ${httpUrl} (${_getGraphQLOperation(graphqlBody)})`); + } // Handle standard requests - capture the query document when enabled via dataCollection (default true) if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 5ab075ca0620..84407f16deca 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -369,10 +369,15 @@ function xhrCallback( // With span streaming, we always emit http.client spans, even without a parent span const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); + // With span streaming, span names have to be low cardinality, so the URL path is dropped and only the + // domain is kept. `getFullURL` resolves relative URLs against the page origin, so one is almost always + // known here. Outgoing requests have no route to parameterize. + const streamedName = parsedUrl?.host ? `${method} ${parsedUrl.host}` : method; + const span = shouldCreateSpanResult && shouldEmitSpan ? startInactiveSpan({ - name: `${method} ${urlForSpanName}`, + name: !!client && hasSpanStreamingEnabled(client) ? streamedName : `${method} ${urlForSpanName}`, attributes: { type: 'xhr', // eslint-disable-next-line typescript/no-deprecated diff --git a/packages/browser/test/integrations/fetchStreamPerformance.test.ts b/packages/browser/test/integrations/fetchStreamPerformance.test.ts new file mode 100644 index 000000000000..4985097d438e --- /dev/null +++ b/packages/browser/test/integrations/fetchStreamPerformance.test.ts @@ -0,0 +1,60 @@ +import type { Client, HandlerDataFetch } from '@sentry/core/browser'; +import * as utils from '@sentry/core/browser'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchStreamPerformanceIntegration } from '../../src/integrations/fetchStreamPerformance'; + +describe('fetchStreamPerformanceIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** Runs the integration's fetch handler for a streamed response and returns the `startInactiveSpan` spy. */ + function trackStreamedFetch(traceLifecycle: 'static' | 'stream', url: string) { + let fetchHandler: ((data: HandlerDataFetch) => void) | undefined; + vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + return () => {}; + }); + vi.spyOn(utils, 'addFetchEndInstrumentationHandler').mockImplementation(() => () => {}); + vi.spyOn(utils, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + } as unknown as Client); + const startInactiveSpanSpy = vi + .spyOn(utils, 'startInactiveSpan') + .mockReturnValue(new utils.SentryNonRecordingSpan()); + + fetchStreamPerformanceIntegration().setup?.({} as Client); + + // A streamed response is detected by a streaming content type and a missing content-length. + fetchHandler?.({ + fetchData: { url, method: 'GET' }, + args: [url], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1, + response: { headers: new Headers({ 'content-type': 'text/event-stream' }) }, + } as unknown as HandlerDataFetch); + + return startInactiveSpanSpy; + } + + it('drops the URL path but keeps the domain with span streaming enabled', () => { + expect(trackStreamedFetch('stream', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( + expect.objectContaining({ name: 'GET api.example.com' }), + ); + }); + + it('falls back to the request method for a relative URL, which has no domain', () => { + expect(trackStreamedFetch('stream', '/v1/chat')).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET' })); + }); + + it('keeps the sanitized URL with `traceLifecycle: "static"`', () => { + expect(trackStreamedFetch('static', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( + expect.objectContaining({ name: 'GET https://api.example.com/v1/chat' }), + ); + }); +}); diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 8ac6ba3f8bce..7b47d154ae93 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -317,6 +317,7 @@ describe('GraphqlClient', () => { function setupHandler( endpoints: Array, graphQLDocument = true, + traceLifecycle: 'static' | 'stream' = 'static', ): (span: SentrySpan, hint: FetchHint | XhrHint) => void { let capturedListener: ((span: SentrySpan, hint: FetchHint | XhrHint) => void) | undefined; const mockClient = { @@ -325,6 +326,7 @@ describe('GraphqlClient', () => { capturedListener = cb; } }, + getOptions: () => ({ traceLifecycle }), getDataCollectionOptions: () => ({ graphQL: { document: graphQLDocument, variables: true } }), } as unknown as Client; @@ -372,6 +374,24 @@ describe('GraphqlClient', () => { expect(json.attributes['graphql.document']).toBe(requestBody.query); }); + test('keeps the low-cardinality span name with span streaming enabled', () => { + const handler = setupHandler([/\/graphql$/], true, 'stream'); + const span = new SentrySpan({ + name: 'POST localhost:4000', + op: 'http.client', + attributes: { + 'http.method': 'POST', + [URL_FULL]: 'http://localhost:4000/graphql', + }, + }); + + handler(span, makeFetchHint('http://localhost:4000/graphql', requestBody)); + + const json = spanToJSON(span); + expect(json.name).toBe('POST localhost:4000'); + expect(json.attributes['graphql.document']).toBe(requestBody.query); + }); + test('enriches http.client span when only url.full is present', () => { const handler = setupHandler([/\/graphql$/]); const span = new SentrySpan({ diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index f3cd82b86aea..d1bb23641398 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -85,7 +85,7 @@ describe('instrumentOutgoingRequests', () => { expect(fetchHandler).toBeDefined(); expect(requestSpan).toBeDefined(); const requestSpanJson = utils.spanToJSON(requestSpan!); - expect(requestSpanJson.name).toBe('QUERY https://example.com/rest/v1/users'); + expect(requestSpanJson.name).toBe('QUERY example.com'); expect(requestSpanJson.attributes[HTTP_REQUEST_METHOD]).toBe('QUERY'); }); @@ -122,10 +122,74 @@ describe('instrumentOutgoingRequests', () => { expect(xhrHandler).toBeDefined(); expect(requestSpan).toBeDefined(); const requestSpanJson = utils.spanToJSON(requestSpan!); - expect(requestSpanJson.name).toBe('QUERY https://example.com/rest/v1/users'); + expect(requestSpanJson.name).toBe('QUERY example.com'); expect(requestSpanJson.attributes[HTTP_REQUEST_METHOD]).toBe('QUERY'); }); + it('keeps the sanitized URL in the fetch span name with `traceLifecycle: "static"`', () => { + let fetchHandler: ((data: utils.HandlerDataFetch) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + }); + const tracingClient = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, traceLifecycle: 'static' }), + ); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceXHR: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + fetchHandler?.({ + fetchData: { method: 'QUERY', url: 'https://example.com/rest/v1/users?select=id' }, + args: ['https://example.com/rest/v1/users?select=id'], + startTimestamp: Date.now(), + }); + + expect(utils.spanToJSON(requestSpan!).name).toBe('QUERY https://example.com/rest/v1/users'); + }); + + it('keeps the sanitized URL in the XHR span name with `traceLifecycle: "static"`', () => { + let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => { + xhrHandler = handler; + }); + const tracingClient = new BrowserClient( + getDefaultBrowserClientOptions({ tracesSampleRate: 1, traceLifecycle: 'static' }), + ); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceFetch: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + xhrHandler?.({ + xhr: { + [browserUtils.SENTRY_XHR_DATA_KEY]: { + method: 'QUERY', + url: 'https://example.com/rest/v1/users?select=id', + request_headers: {}, + }, + setRequestHeader: vi.fn(), + }, + startTimestamp: Date.now(), + } as utils.HandlerDataXhr); + + expect(utils.spanToJSON(requestSpan!).name).toBe('QUERY https://example.com/rest/v1/users'); + }); + describe('XHR trace header span', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 86f5ed517236..a4ad14735d04 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -4,6 +4,7 @@ import { HTTP_RESPONSE_BODY_SIZE, SERVER_ADDRESS, SERVER_PORT, + URL_DOMAIN, URL_FRAGMENT, URL_FULL, URL_QUERY, @@ -336,6 +337,10 @@ function getSpanStartOptions( spanOrigin: SpanOrigin, client: Client | undefined, ): Parameters[0] { + // With span streaming, span names have to be low cardinality, so the URL path is dropped and only the + // domain is kept. Outgoing requests have no route to parameterize, and relative URLs have no domain. + const isStreamed = !!client && hasSpanStreamingEnabled(client); + // Data URLs need special handling because parseStringToURLObject treats them as "relative" // (no "://"), causing getSanitizedUrlStringFromUrlObject to return just the pathname // without the "data:" prefix, making later stripDataUrlContent calls ineffective. @@ -343,15 +348,16 @@ function getSpanStartOptions( if (url.startsWith('data:')) { const sanitizedUrl = stripDataUrlContent(url); return { - name: `${method} ${sanitizedUrl}`, + name: isStreamed ? method : `${method} ${sanitizedUrl}`, attributes: getFetchSpanAttributes(url, undefined, method, spanOrigin, client), }; } const parsedUrl = parseStringToURLObject(url); const sanitizedUrl = parsedUrl ? getSanitizedUrlStringFromUrlObject(parsedUrl) : url; + const domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined; return { - name: `${method} ${sanitizedUrl}`, + name: isStreamed ? (domain ? `${method} ${domain}` : method) : `${method} ${sanitizedUrl}`, attributes: getFetchSpanAttributes(url, parsedUrl, method, spanOrigin, client), }; } @@ -375,6 +381,7 @@ function getFetchSpanAttributes( if (!isURLObjectRelative(parsedUrl)) { attributes[URL_FULL] = filterCollectedUrl(stripDataUrlContent(parsedUrl.href), client); attributes[SERVER_ADDRESS] = parsedUrl.hostname; + attributes[URL_DOMAIN] = parsedUrl.hostname; attributes[SERVER_PORT] = parsedUrl.port ? Number(parsedUrl.port) : undefined; } attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(parsedUrl.search), client); diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts index ac6a8ab66041..476bce0ac3f8 100644 --- a/packages/core/test/lib/fetch.test.ts +++ b/packages/core/test/lib/fetch.test.ts @@ -1,10 +1,11 @@ import { URL_FULL } from '@sentry/conventions/attributes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { HandlerDataFetch } from '../../src'; +import type { Client, HandlerDataFetch } from '../../src'; import { _INTERNAL_getTracingHeadersForFetchRequest, instrumentFetchRequest } from '../../src/fetch'; import { SentryNonRecordingSpan } from '../../src/tracing/sentryNonRecordingSpan'; import type { Span } from '../../src/types/span'; import * as tracing from '../../src/tracing/trace'; +import * as currentScopes from '../../src/currentScopes'; import * as spanUtils from '../../src/utils/spanUtils'; import * as traceData from '../../src/utils/traceData'; @@ -494,6 +495,7 @@ describe('instrumentFetchRequest', () => { 'sentry.op': 'http.client', [URL_FULL]: url, 'server.address': 'api.example.com', + 'url.domain': 'api.example.com', 'url.query': 'include=profile', 'url.fragment': 'bio', }, @@ -501,6 +503,50 @@ describe('instrumentFetchRequest', () => { }); }); + describe('span name', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function startFetchSpan( + traceLifecycle: 'static' | 'stream', + url = 'https://api.example.com/users/42?include=profile', + ): ReturnType { + hasSpansEnabled.mockReturnValue(true); + vi.spyOn(spanUtils, 'getActiveSpan').mockReturnValue(new SentryNonRecordingSpan()); + vi.spyOn(currentScopes, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + emit: () => {}, + } as unknown as Client); + const startInactiveSpanSpy = vi.spyOn(tracing, 'startInactiveSpan').mockReturnValue(new SentryNonRecordingSpan()); + + instrumentFetchRequest( + { fetchData: { url, method: 'GET' }, args: [url], startTimestamp: Date.now() }, + () => true, + () => false, + {}, + { spanOrigin: 'auto.http.fetch' }, + ); + + return startInactiveSpanSpy; + } + + it('drops the URL path but keeps the domain with span streaming enabled', () => { + expect(startFetchSpan('stream')).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET api.example.com' })); + }); + + it('falls back to the request method for a relative URL, which has no domain', () => { + expect(startFetchSpan('stream', '/users/42')).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET' })); + }); + + it('keeps the sanitized URL with `traceLifecycle: "static"`', () => { + expect(startFetchSpan('static')).toHaveBeenCalledWith( + expect.objectContaining({ name: 'GET https://api.example.com/users/42' }), + ); + }); + }); + describe('trace header span', () => { beforeEach(() => { vi.clearAllMocks(); From bdcfbb59d3328450626e266716d5f209b606e579 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 27 Aug 2026 17:26:15 +0200 Subject: [PATCH 2/2] fix: Strip userinfo from browser outgoing request names and attributes `parseUrl` returns the raw authority, so `user:pass@host:port` could reach the XHR span name and `server.address`. Strip it, and set `url.domain` on browser fetch and XHR spans so the value in the streamed name is filterable. `fetchStreamPerformance` now uses the client it receives in `setup` instead of `getClient()`. Co-Authored-By: Claude Opus 5 (1M context) --- .../tracing/http-client-span-streamed/test.ts | 1 + .../test.ts | 1 + .../tracing/request/fetch-streamed/test.ts | 1 + .../tracing/request/xhr-streamed/test.ts | 1 + .../nextjs-pages-dir/tests/middleware.test.ts | 1 + .../integrations/fetchStreamPerformance.ts | 6 ++-- packages/browser/src/tracing/request.ts | 27 +++++++++++--- .../fetchStreamPerformance.test.ts | 9 +++-- packages/browser/test/tracing/request.test.ts | 36 +++++++++++++++++++ 9 files changed, 70 insertions(+), 13 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts index 83849a576668..81d41ada7ccc 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/http-client-span-streamed/test.ts @@ -27,5 +27,6 @@ sentryTest( expect(span.name).toBe('GET sentry-test-site.example'); expect(span.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser' }); expect(span.attributes['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); + expect(span.attributes['url.domain']).toEqual({ type: 'string', value: 'sentry-test-site.example' }); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts index f8babfecf43c..b015ac60fec5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts @@ -55,6 +55,7 @@ sentryTest( attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: 'http://sentry-test-site.example/delayed' }, + 'url.domain': { type: 'string', value: 'sentry-test-site.example' }, type: { type: 'string', value: 'fetch' }, }), }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts index eb81d940bcb8..92bf496af069 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed/test.ts @@ -39,6 +39,7 @@ sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test-site.example' }, 'server.address': { type: 'string', value: 'sentry-test-site.example' }, type: { type: 'string', value: 'fetch' }, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts index ea869b33abfd..8bb57870becb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-streamed/test.ts @@ -39,6 +39,7 @@ sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) = attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: `http://sentry-test-site.example/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test-site.example' }, 'server.address': { type: 'string', value: 'sentry-test-site.example' }, type: { type: 'string', value: 'xhr' }, }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index 694368cac2ec..c7d55186fe86 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -74,6 +74,7 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru 'http.response.status_code': 200, type: 'fetch', 'url.full': 'http://localhost:3030/', + 'url.domain': 'localhost', 'server.address': 'localhost', 'server.port': 3030, 'sentry.op': 'http.client', diff --git a/packages/browser/src/integrations/fetchStreamPerformance.ts b/packages/browser/src/integrations/fetchStreamPerformance.ts index 2a5d959e1623..4bfaa8a79015 100644 --- a/packages/browser/src/integrations/fetchStreamPerformance.ts +++ b/packages/browser/src/integrations/fetchStreamPerformance.ts @@ -4,7 +4,6 @@ import { addFetchEndInstrumentationHandler, addFetchInstrumentationHandler, defineIntegration, - getClient, getSanitizedUrlStringFromUrlObject, hasSpanStreamingEnabled, isURLObjectRelative, @@ -40,7 +39,7 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { return { name: 'FetchStreamPerformance' as const, - setup() { + setup(client) { // End the stream span when the response body finishes resolving addFetchEndInstrumentationHandler(handlerData => { if (handlerData.response) { @@ -84,11 +83,10 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { // `http.client.stream` follows the same name rules as `http.client`: with span streaming the // URL path is dropped and only the domain is kept. Relative URLs have no domain, and an // outgoing request has no route to parameterize. - const client = getClient(); const domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined; const streamedName = domain ? `${method} ${domain}` : method; const streamSpan = startInactiveSpan({ - name: !!client && hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`, + name: hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`, startTime: handlerData.endTimestamp, attributes: { [URL_FULL]: filterCollectedUrl(stripDataUrlContent(url)), diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 84407f16deca..af8b89b76277 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -43,7 +43,14 @@ import { } from '@sentry/browser-utils'; import type { BrowserClient } from '../client'; import { baggageHeaderHasSentryValues, createHeadersSafely, getFullURL, isPerformanceResourceTiming } from './utils'; -import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_FRAGMENT, URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; +import { + HTTP_REQUEST_METHOD, + SERVER_ADDRESS, + URL_DOMAIN, + URL_FRAGMENT, + URL_FULL, + URL_QUERY, +} from '@sentry/conventions/attributes'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { @@ -159,11 +166,16 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { return () => {}; }); vi.spyOn(utils, 'addFetchEndInstrumentationHandler').mockImplementation(() => () => {}); - vi.spyOn(utils, 'getClient').mockReturnValue({ - getOptions: () => ({ traceLifecycle }), - getDataCollectionOptions: () => ({ urlQueryParams: true }), - } as unknown as Client); const startInactiveSpanSpy = vi .spyOn(utils, 'startInactiveSpan') .mockReturnValue(new utils.SentryNonRecordingSpan()); - fetchStreamPerformanceIntegration().setup?.({} as Client); + fetchStreamPerformanceIntegration().setup?.({ + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + } as unknown as Client); // A streamed response is detected by a streaming content type and a missing content-length. fetchHandler?.({ diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index d1bb23641398..66e50a86327f 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -190,6 +190,42 @@ describe('instrumentOutgoingRequests', () => { expect(utils.spanToJSON(requestSpan!).name).toBe('QUERY https://example.com/rest/v1/users'); }); + it('strips userinfo and the port from the streamed XHR span name and `url.domain`', () => { + let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => { + xhrHandler = handler; + }); + const tracingClient = new BrowserClient(getDefaultBrowserClientOptions({ tracesSampleRate: 1 })); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceFetch: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + xhrHandler?.({ + xhr: { + [browserUtils.SENTRY_XHR_DATA_KEY]: { + method: 'GET', + url: 'https://user:pass@example.com:8443/rest/v1/users', + request_headers: {}, + }, + setRequestHeader: vi.fn(), + }, + startTimestamp: Date.now(), + } as utils.HandlerDataXhr); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET example.com'); + expect(requestSpanJson.attributes['url.domain']).toBe('example.com'); + expect(requestSpanJson.attributes['server.address']).toBe('example.com:8443'); + }); + describe('XHR trace header span', () => { afterEach(() => { vi.restoreAllMocks();