From f56b80df49bee037b2359b9b5af21bc01cd5354c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 27 Aug 2026 14:26:48 +0200 Subject: [PATCH 1/5] 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/5] 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(); From 138a7513cfb08a0d024d1c05f66c29cc7d1ed488 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 31 Aug 2026 17:08:20 +0200 Subject: [PATCH 3/5] feat: Resolve relative outgoing-request URLs to the page domain Relative URLs previously left `http.client` and `http.client.stream` spans named after the method alone, even in browsers where the page origin is a perfectly good domain to resolve against. `instrumentFetchRequest` now takes a `urlBase` that the browser fills with the page origin, so a relative fetch is named `GET app.example.com` instead of `GET`. XHR and stream spans resolve the same way. Consolidate the three different domain derivations (two regexes over `parseUrl().host`, and an `isURLObjectRelative` check over a URL object) into one `getUrlDomain` helper. `URL.hostname` drops userinfo and the port structurally, so the credential stripping no longer needs a regex. Keep the GraphQL operation on outgoing request spans as `graphql.operation.name` and `graphql.operation.type`. It only ever lived in the span name, so the low-cardinality rename would otherwise have dropped it from the span entirely. Refs #23527 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XvHhCTmsaPtA8vMDuHXSwd --- MIGRATION.md | 2 +- .../http-timings-streamed/test.ts | 2 +- .../fetch-relative-url-streamed/init.js | 10 ++ .../fetch-relative-url-streamed/subject.js | 1 + .../fetch-relative-url-streamed/test.ts | 43 ++++++++ .../test.ts | 2 +- .../tracing/request/fetch-streamed/test.ts | 2 +- .../request/xhr-relative-url-streamed/init.js | 10 ++ .../xhr-relative-url-streamed/subject.js | 11 ++ .../request/xhr-relative-url-streamed/test.ts | 43 ++++++++ .../tracing/request/xhr-streamed/test.ts | 2 +- .../integrations/fetchStreamPerformance.ts | 9 +- .../browser/src/integrations/graphqlClient.ts | 39 +++++-- packages/browser/src/tracing/request.ts | 21 ++-- .../fetchStreamPerformance.test.ts | 30 +++++- .../test/integrations/graphqlClient.test.ts | 29 +++++ packages/browser/test/tracing/request.test.ts | 101 ++++++++++++++++++ packages/core/src/fetch.ts | 25 +++-- packages/core/src/shared-exports.ts | 1 + packages/core/src/utils/url.ts | 14 +++ packages/core/test/lib/fetch.test.ts | 37 ++++++- packages/core/test/lib/utils/url.test.ts | 15 +++ 22 files changed, 405 insertions(+), 44 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 87baf1d5bf94..ab30f03584b8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -861,7 +861,7 @@ 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. +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`). Outgoing GraphQL request spans now carry the operation on the `graphql.operation.name` and `graphql.operation.type` attributes instead, and it also 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`. 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 c88915e2dabd..7c02a63fc41c 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 @@ -34,7 +34,7 @@ sentryTest( requestSpans?.forEach(span => expect(span).toMatchObject({ - // The URL path is high cardinality, so a streamed span name keeps only the domain. + // Streamed span names drop the high-cardinality URL path. name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js new file mode 100644 index 000000000000..763d29410874 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/subject.js new file mode 100644 index 000000000000..143b078692db --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/subject.js @@ -0,0 +1 @@ +fetch('/test-req/0').then(fetch('/test-req/1').then(fetch('/test-req/2'))); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts new file mode 100644 index 000000000000..24c038002fd9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest, TEST_HOST } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('names spans for relative fetch requests after the page domain', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans + .filter(s => getSpanOp(s) === 'http.client') + .sort((a, b) => + (a.attributes!['url.full']!.value as string).localeCompare(b.attributes!['url.full']!.value as string), + ); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + // A relative URL has no domain of its own, so it resolves against the page origin. + name: 'GET sentry-test.io', + parent_span_id: pageloadSpan?.span_id, + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: `${TEST_HOST}/test-req/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test.io' }, + 'server.address': { type: 'string', value: 'sentry-test.io' }, + type: { type: 'string', value: 'fetch' }, + }), + }), + ); +}); 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 b015ac60fec5..04f0261beff4 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 @@ -49,7 +49,7 @@ sentryTest( status: 'ok', }); - // `http.client.stream` follows the same name rules as `http.client`, so the path is dropped here too. + // `http.client.stream` follows the same name rules as `http.client`. expect(streamSpan).toMatchObject({ name: 'GET sentry-test-site.example', attributes: expect.objectContaining({ 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 92bf496af069..484a7151de1f 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,7 @@ sentryTest('creates spans for fetch requests', async ({ getLocalTestUrl, page }) requestSpans.forEach((span, index) => expect(span).toMatchObject({ - // The URL path is high cardinality, so a streamed span name keeps only the domain. + // Streamed span names drop the high-cardinality URL path. name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js new file mode 100644 index 000000000000..763d29410874 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/subject.js new file mode 100644 index 000000000000..75155522ab50 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/subject.js @@ -0,0 +1,11 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', '/test-req/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', '/test-req/1'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', '/test-req/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts new file mode 100644 index 000000000000..8b072bcc917e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { sentryTest, TEST_HOST } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('names spans for relative XHR requests after the page domain', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans( + page, + spans => spans.filter(s => getSpanOp(s) === 'http.client').length >= 3, + ); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpans = allSpans + .filter(s => getSpanOp(s) === 'http.client') + .sort((a, b) => + (a.attributes!['url.full']!.value as string).localeCompare(b.attributes!['url.full']!.value as string), + ); + + expect(requestSpans).toHaveLength(3); + + requestSpans.forEach((span, index) => + expect(span).toMatchObject({ + // A relative URL has no domain of its own, so it resolves against the page origin. + name: 'GET sentry-test.io', + parent_span_id: pageloadSpan?.span_id, + trace_id: pageloadSpan?.trace_id, + attributes: expect.objectContaining({ + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.full': { type: 'string', value: `${TEST_HOST}/test-req/${index}` }, + 'url.domain': { type: 'string', value: 'sentry-test.io' }, + 'server.address': { type: 'string', value: 'sentry-test.io' }, + type: { type: 'string', value: 'xhr' }, + }), + }), + ); +}); 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 8bb57870becb..2fd96eb8af58 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,7 @@ sentryTest('creates spans for XHR requests', async ({ getLocalTestUrl, page }) = requestSpans.forEach((span, index) => expect(span).toMatchObject({ - // The URL path is high cardinality, so a streamed span name keeps only the domain. + // Streamed span names drop the high-cardinality URL path. name: 'GET sentry-test-site.example', parent_span_id: pageloadSpan?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), diff --git a/packages/browser/src/integrations/fetchStreamPerformance.ts b/packages/browser/src/integrations/fetchStreamPerformance.ts index 4bfaa8a79015..b32f54b76024 100644 --- a/packages/browser/src/integrations/fetchStreamPerformance.ts +++ b/packages/browser/src/integrations/fetchStreamPerformance.ts @@ -5,8 +5,8 @@ import { addFetchInstrumentationHandler, defineIntegration, getSanitizedUrlStringFromUrlObject, + getUrlDomain, hasSpanStreamingEnabled, - isURLObjectRelative, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -14,6 +14,7 @@ import { filterCollectedUrl, startInactiveSpan, } from '@sentry/core/browser'; +import { WINDOW } from '../helpers'; const responseToStreamSpan = new WeakMap(); const responseToFallbackTimeout = new WeakMap>(); @@ -80,10 +81,8 @@ 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 domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined; + // `http.client.stream` follows the same name rules as `http.client`. + const domain = getUrlDomain(url, WINDOW.location?.origin); const streamedName = domain ? `${method} ${domain}` : method; const streamSpan = startInactiveSpan({ name: hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`, diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 9b4920cfcfdf..42f64ff02a66 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -10,7 +10,14 @@ import { } from '@sentry/core/browser'; import type { FetchHint, XhrHint } from '@sentry/browser-utils'; import { getBodyString, getFetchRequestArgBody, SENTRY_XHR_DATA_KEY } from '@sentry/browser-utils'; -import { GRAPHQL_DOCUMENT, HTTP_METHOD, SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { + GRAPHQL_DOCUMENT, + GRAPHQL_OPERATION_NAME, + GRAPHQL_OPERATION_TYPE, + HTTP_METHOD, + SENTRY_OP, + URL_FULL, +} from '@sentry/conventions/attributes'; interface GraphQLClientOptions { endpoints: Array; @@ -85,11 +92,15 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (graphqlBody) { // With span streaming the span already carries a low-cardinality name, so it must not be - // renamed back to something containing the URL. + // renamed back to one containing the URL. The operation stays reachable as an attribute. if (!hasSpanStreamingEnabled(client)) { span.updateName(`${httpMethod} ${httpUrl} (${_getGraphQLOperation(graphqlBody)})`); } + const { operationName, operationType } = _getGraphQLOperationDetails(graphqlBody); + span.setAttribute(GRAPHQL_OPERATION_NAME, operationName); + span.setAttribute(GRAPHQL_OPERATION_TYPE, operationType); + // Handle standard requests - capture the query document when enabled via dataCollection (default true) if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { span.setAttribute(GRAPHQL_DOCUMENT, graphqlBody.query); @@ -142,6 +153,24 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient }); } +/** + * The operation name and type of a GraphQL request. Persisted operations carry no query document, so + * their type is unknown. + */ +function _getGraphQLOperationDetails(requestBody: GraphQLRequestPayload): GraphQLOperation { + if (isPersistedRequest(requestBody)) { + return { operationName: requestBody.operationName, operationType: undefined }; + } + + if (isStandardRequest(requestBody)) { + const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; + const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); + return { operationName, operationType }; + } + + return { operationName: undefined, operationType: undefined }; +} + /** * @param requestBody - GraphQL request * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' or 'persisted NAME' @@ -154,10 +183,8 @@ export function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string // Handle standard GraphQL requests if (isStandardRequest(requestBody)) { - const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; - const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); - const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; - return operationInfo; + const { operationName, operationType } = _getGraphQLOperationDetails(requestBody); + return operationName ? `${operationType} ${operationName}` : `${operationType}`; } // Fallback for unknown request types diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index af8b89b76277..7ed8200575b5 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -14,6 +14,7 @@ import { getClient, getLocationHref, getTraceData, + getUrlDomain, getUrlFragment, getUrlQuery, hasSpansEnabled, @@ -42,6 +43,7 @@ import { SENTRY_XHR_DATA_KEY, } from '@sentry/browser-utils'; import type { BrowserClient } from '../client'; +import { WINDOW } from '../helpers'; import { baggageHeaderHasSentryValues, createHeadersSafely, getFullURL, isPerformanceResourceTiming } from './utils'; import { HTTP_REQUEST_METHOD, @@ -159,6 +161,8 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal('location', { origin: 'https://app.example.com' }); }); afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); /** Runs the integration's fetch handler for a streamed response and returns the `startInactiveSpan` spy. */ @@ -43,17 +45,37 @@ describe('fetchStreamPerformanceIntegration', () => { 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' }), + expect.objectContaining({ + name: 'GET api.example.com', + attributes: expect.objectContaining({ 'url.domain': '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('resolves a relative URL against the page origin', () => { + expect(trackStreamedFetch('stream', '/v1/chat')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET app.example.com', + attributes: expect.objectContaining({ 'url.domain': 'app.example.com' }), + }), + ); + }); + + it('falls back to the request method for a data URL, which has no domain', () => { + expect(trackStreamedFetch('stream', 'data:text/event-stream,data: hi')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET', + attributes: expect.objectContaining({ 'url.domain': undefined }), + }), + ); }); 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' }), + expect.objectContaining({ + name: 'GET https://api.example.com/v1/chat', + attributes: expect.objectContaining({ 'url.domain': 'api.example.com' }), + }), ); }); }); diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 7b47d154ae93..97bc06f93449 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -372,6 +372,8 @@ describe('GraphqlClient', () => { const json = spanToJSON(span); expect(json.name).toBe('POST http://localhost:4000/graphql (query GetHello)'); expect(json.attributes['graphql.document']).toBe(requestBody.query); + expect(json.attributes['graphql.operation.name']).toBe('GetHello'); + expect(json.attributes['graphql.operation.type']).toBe('query'); }); test('keeps the low-cardinality span name with span streaming enabled', () => { @@ -390,6 +392,33 @@ describe('GraphqlClient', () => { const json = spanToJSON(span); expect(json.name).toBe('POST localhost:4000'); expect(json.attributes['graphql.document']).toBe(requestBody.query); + expect(json.attributes['graphql.operation.name']).toBe('GetHello'); + expect(json.attributes['graphql.operation.type']).toBe('query'); + }); + + test('records the operation on a persisted request, which has no query document', () => { + 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', { + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { persistedQuery: { version: 1, sha256Hash: 'abc123' } }, + }), + ); + + const json = spanToJSON(span); + expect(json.attributes['graphql.operation.name']).toBe('GetUser'); + expect(json.attributes['graphql.operation.type']).toBeUndefined(); }); test('enriches http.client span when only url.full is present', () => { diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index 66e50a86327f..a1780babfa30 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -226,6 +226,107 @@ describe('instrumentOutgoingRequests', () => { expect(requestSpanJson.attributes['server.address']).toBe('example.com:8443'); }); + it('resolves a relative fetch URL against the page origin for the streamed span name', () => { + vi.stubGlobal('location', { origin: 'https://app.example.com' }); + + 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 })); + 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: 'GET', url: '/rest/v1/users?select=id' }, + args: ['/rest/v1/users?select=id'], + startTimestamp: Date.now(), + }); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET app.example.com'); + expect(requestSpanJson.attributes['url.domain']).toBe('app.example.com'); + + vi.unstubAllGlobals(); + }); + + it('resolves a relative XHR URL against the page origin for the streamed span name', () => { + vi.stubGlobal('location', { origin: 'https://app.example.com' }); + + 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: '/rest/v1/users?select=id', + request_headers: {}, + }, + setRequestHeader: vi.fn(), + }, + startTimestamp: Date.now(), + } as utils.HandlerDataXhr); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET app.example.com'); + expect(requestSpanJson.attributes['url.domain']).toBe('app.example.com'); + + vi.unstubAllGlobals(); + }); + + it('falls back to the request method for a data URL, which has no domain', () => { + 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 })); + 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: 'GET', url: 'data:text/plain,hello' }, + args: ['data:text/plain,hello'], + startTimestamp: Date.now(), + }); + + const requestSpanJson = utils.spanToJSON(requestSpan!); + expect(requestSpanJson.name).toBe('GET'); + expect(requestSpanJson.attributes['url.domain']).toBeUndefined(); + }); + describe('XHR trace header span', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index a4ad14735d04..6dc2ed4f6ea3 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -28,6 +28,7 @@ import { getActiveSpan } from './utils/spanUtils'; import { getTraceData } from './utils/traceData'; import { getSanitizedUrlStringFromUrlObject, + getUrlDomain, getUrlFragment, getUrlQuery, isURLObjectRelative, @@ -49,6 +50,8 @@ interface InstrumentFetchRequestOptions { spanOrigin?: SpanOrigin; propagateTraceparent?: boolean; onRequestSpanEnd?: (span: Span, responseInformation: ResponseHookInfo) => void; + /** Base URL for relative request URLs. Browsers pass the page origin; server runtimes have none. */ + urlBase?: string; } /** @@ -91,7 +94,11 @@ export function instrumentFetchRequest( return undefined; } - const { spanOrigin = 'auto.http.browser', propagateTraceparent = false } = instrumentFetchRequestOptions ?? {}; + const { + spanOrigin = 'auto.http.browser', + propagateTraceparent = false, + urlBase, + } = instrumentFetchRequestOptions ?? {}; const client = getClient(); const hasParent = !!getActiveSpan(); @@ -100,7 +107,7 @@ export function instrumentFetchRequest( const span = shouldCreateSpanResult && shouldEmitSpan - ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin, client)) + ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin, client, urlBase)) : new SentryNonRecordingSpan(); const spanForTraceHeaders = spanIsIgnored(span) && hasParent ? undefined : span; @@ -336,10 +343,12 @@ function getSpanStartOptions( method: string, spanOrigin: SpanOrigin, client: Client | undefined, + urlBase: string | 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. + // With span streaming, span names have to be low cardinality, so only the domain is kept. Outgoing + // requests have no route to fall back on, so one without a domain is named after the method alone. const isStreamed = !!client && hasSpanStreamingEnabled(client); + const domain = getUrlDomain(url, urlBase); // Data URLs need special handling because parseStringToURLObject treats them as "relative" // (no "://"), causing getSanitizedUrlStringFromUrlObject to return just the pathname @@ -349,16 +358,15 @@ function getSpanStartOptions( const sanitizedUrl = stripDataUrlContent(url); return { name: isStreamed ? method : `${method} ${sanitizedUrl}`, - attributes: getFetchSpanAttributes(url, undefined, method, spanOrigin, client), + attributes: getFetchSpanAttributes(url, undefined, method, spanOrigin, client, domain), }; } const parsedUrl = parseStringToURLObject(url); const sanitizedUrl = parsedUrl ? getSanitizedUrlStringFromUrlObject(parsedUrl) : url; - const domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined; return { name: isStreamed ? (domain ? `${method} ${domain}` : method) : `${method} ${sanitizedUrl}`, - attributes: getFetchSpanAttributes(url, parsedUrl, method, spanOrigin, client), + attributes: getFetchSpanAttributes(url, parsedUrl, method, spanOrigin, client, domain), }; } @@ -368,6 +376,7 @@ function getFetchSpanAttributes( method: string, spanOrigin: SpanOrigin, client: Client | undefined, + domain: string | undefined, ): SpanAttributes { const attributes: SpanAttributes = { [URL_FULL]: filterCollectedUrl(stripDataUrlContent(url), client), @@ -376,12 +385,12 @@ function getFetchSpanAttributes( [HTTP_REQUEST_METHOD]: method, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + [URL_DOMAIN]: domain, }; if (parsedUrl) { 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/src/shared-exports.ts b/packages/core/src/shared-exports.ts index aacaa27a4d26..b4817dfb6f45 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -326,6 +326,7 @@ export { stripDataUrlContent, getUrlQuery, getUrlFragment, + getUrlDomain, } from './utils/url'; export { eventFromMessage, diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 46d3b912b694..45be6373bf29 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -148,6 +148,20 @@ export function getUrlFragment(fragment: string | undefined): string | undefined return fragment?.replace(/^#/, '') || undefined; } +/** + * The domain a request goes to, for the `url.domain` attribute and low-cardinality span names. + * + * Relative URLs need a `base` to resolve against — browsers have the page origin, server runtimes do + * not. URLs with no domain at all, such as data URLs, return `undefined`. + */ +export function getUrlDomain(url: string, base?: string): string | undefined { + try { + return new URL(url, base).hostname || undefined; + } catch { + return undefined; + } +} + type PartialRequest = { method?: string; }; diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts index 476bce0ac3f8..57c10d16243b 100644 --- a/packages/core/test/lib/fetch.test.ts +++ b/packages/core/test/lib/fetch.test.ts @@ -511,6 +511,7 @@ describe('instrumentFetchRequest', () => { function startFetchSpan( traceLifecycle: 'static' | 'stream', url = 'https://api.example.com/users/42?include=profile', + urlBase?: string, ): ReturnType { hasSpansEnabled.mockReturnValue(true); vi.spyOn(spanUtils, 'getActiveSpan').mockReturnValue(new SentryNonRecordingSpan()); @@ -526,18 +527,46 @@ describe('instrumentFetchRequest', () => { () => true, () => false, {}, - { spanOrigin: 'auto.http.fetch' }, + { spanOrigin: 'auto.http.fetch', urlBase }, ); 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' })); + expect(startFetchSpan('stream')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET api.example.com', + attributes: expect.objectContaining({ 'url.domain': 'api.example.com' }), + }), + ); + }); + + it('falls back to the request method for a relative URL when there is no base to resolve it against', () => { + expect(startFetchSpan('stream', '/users/42')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET', + attributes: expect.objectContaining({ 'url.domain': undefined }), + }), + ); }); - 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('resolves a relative URL against `urlBase`', () => { + expect(startFetchSpan('stream', '/users/42', 'https://app.example.com')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET app.example.com', + attributes: expect.objectContaining({ 'url.domain': 'app.example.com' }), + }), + ); + }); + + it('falls back to the request method for a data URL, which has no domain', () => { + expect(startFetchSpan('stream', 'data:text/plain,hello', 'https://app.example.com')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET', + attributes: expect.objectContaining({ 'url.domain': undefined }), + }), + ); }); it('keeps the sanitized URL with `traceLifecycle: "static"`', () => { diff --git a/packages/core/test/lib/utils/url.test.ts b/packages/core/test/lib/utils/url.test.ts index ad073e49a393..e045366955fe 100644 --- a/packages/core/test/lib/utils/url.test.ts +++ b/packages/core/test/lib/utils/url.test.ts @@ -3,6 +3,7 @@ import { getHttpSpanDetailsFromUrlObject, getSanitizedUrlString, getSanitizedUrlStringFromUrlObject, + getUrlDomain, getUrlFragment, getUrlQuery, isURLObjectRelative, @@ -308,6 +309,20 @@ describe('getUrlQuery', () => { }); }); +describe('getUrlDomain', () => { + it.each([ + ['https://somedomain.com/path?a=b#c', undefined, 'somedomain.com'], + ['https://user:pass@somedomain.com:8443/path', undefined, 'somedomain.com'], + ['/path/to/happiness', undefined, undefined], + ['/path/to/happiness', 'https://somedomain.com', 'somedomain.com'], + ['https://otherdomain.com/path', 'https://somedomain.com', 'otherdomain.com'], + ['data:text/plain,hello', 'https://somedomain.com', undefined], + ['', undefined, undefined], + ])('resolves %s against %s', (url, base, expected) => { + expect(getUrlDomain(url, base)).toBe(expected); + }); +}); + describe('getUrlFragment', () => { it.each([ ['#section', 'section'], From ad9e54ca56275cd63f1fdac0cd317c1ac9a2850d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 31 Aug 2026 17:38:51 +0200 Subject: [PATCH 4/5] test: Remove pageload-span race from relative-URL streamed tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `waitForStreamedSpans` resolves on the first envelope matching its predicate, and the predicate only waited for the three `http.client` spans. The pageload span often shares that envelope but does not have to, so asserting `parent_span_id` against it failed whenever it landed in a later one — the flakiness check caught this on repeat 10. Parenting is already covered by the `fetch-streamed` and `xhr-streamed` suites, so drop it here and assert only the span name and URL attributes these tests exist for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XvHhCTmsaPtA8vMDuHXSwd --- .../tracing/request/fetch-relative-url-streamed/test.ts | 6 +----- .../tracing/request/xhr-relative-url-streamed/test.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts index 24c038002fd9..820103bac06e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/test.ts @@ -15,9 +15,7 @@ sentryTest('names spans for relative fetch requests after the page domain', asyn await page.goto(url); - const allSpans = await spansPromise; - const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); - const requestSpans = allSpans + const requestSpans = (await spansPromise) .filter(s => getSpanOp(s) === 'http.client') .sort((a, b) => (a.attributes!['url.full']!.value as string).localeCompare(b.attributes!['url.full']!.value as string), @@ -29,8 +27,6 @@ sentryTest('names spans for relative fetch requests after the page domain', asyn expect(span).toMatchObject({ // A relative URL has no domain of its own, so it resolves against the page origin. name: 'GET sentry-test.io', - parent_span_id: pageloadSpan?.span_id, - trace_id: pageloadSpan?.trace_id, attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: `${TEST_HOST}/test-req/${index}` }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts index 8b072bcc917e..31ab73597b88 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/test.ts @@ -15,9 +15,7 @@ sentryTest('names spans for relative XHR requests after the page domain', async await page.goto(url); - const allSpans = await spansPromise; - const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); - const requestSpans = allSpans + const requestSpans = (await spansPromise) .filter(s => getSpanOp(s) === 'http.client') .sort((a, b) => (a.attributes!['url.full']!.value as string).localeCompare(b.attributes!['url.full']!.value as string), @@ -29,8 +27,6 @@ sentryTest('names spans for relative XHR requests after the page domain', async expect(span).toMatchObject({ // A relative URL has no domain of its own, so it resolves against the page origin. name: 'GET sentry-test.io', - parent_span_id: pageloadSpan?.span_id, - trace_id: pageloadSpan?.trace_id, attributes: expect.objectContaining({ 'http.request.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: `${TEST_HOST}/test-req/${index}` }, From 538523830c964e2a5abdea5e55f91fdb68f4df69 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 31 Aug 2026 18:07:44 +0200 Subject: [PATCH 5/5] test: Drop redundant spanStreamingIntegration from new streamed suites Starting a browser span adds the integration on its own, so listing it in `Sentry.init` is no longer needed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XvHhCTmsaPtA8vMDuHXSwd --- .../suites/tracing/request/fetch-relative-url-streamed/init.js | 2 +- .../suites/tracing/request/xhr-relative-url-streamed/init.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js index 763d29410874..5ab240338c8c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-relative-url-streamed/init.js @@ -4,7 +4,7 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, autoSessionTracking: false, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js index 763d29410874..5ab240338c8c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-relative-url-streamed/init.js @@ -4,7 +4,7 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, autoSessionTracking: false, });