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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ 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' });
expect(span.attributes['url.domain']).toEqual({ type: 'string', value: 'sentry-test-site.example' });
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ 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' },
'url.domain': { type: 'string', value: 'sentry-test-site.example' },
type: { type: 'string', value: 'fetch' },
}),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Comment thread
cursor[bot] marked this conversation as resolved.
parent_span_id: pageloadSpan?.span_id,
span_id: expect.stringMatching(/[a-f\d]{16}/),
start_timestamp: expect.any(Number),
Expand All @@ -38,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' },
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -38,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' },
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 11 additions & 3 deletions packages/browser/src/integrations/fetchStreamPerformance.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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,
getSanitizedUrlStringFromUrlObject,
hasSpanStreamingEnabled,
isURLObjectRelative,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand Down Expand Up @@ -37,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) {
Expand Down Expand Up @@ -78,11 +80,17 @@ 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;
const streamedName = domain ? `${method} ${domain}` : method;
const streamSpan = startInactiveSpan({
name: `${method} ${sanitizedUrl}`,
name: hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`,

@JPeer264 JPeer264 Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: The sanitizedUrl also takes care of "data: URLs", should the streamedName solely be domains?

Edit: seems like the PR description covers that part

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',
Expand Down
8 changes: 6 additions & 2 deletions packages/browser/src/integrations/graphqlClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Client, IntegrationFn } from '@sentry/core/browser';
import {
defineIntegration,
hasSpanStreamingEnabled,
isObjectLike,
isString,
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
Expand Down Expand Up @@ -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)})`);
}

Comment on lines +89 to 92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When span streaming is enabled, the GraphQL operation name is not added as an attribute to the span, losing valuable information for tracing and observability.
Severity: HIGH

Suggested Fix

In _updateSpanWithGraphQLData, when hasSpanStreamingEnabled(client) is true, the GraphQL operation information should be set as an attribute on the span. After getting the operationInfo, add a call like span.setAttribute('graphql.operation.name', operationInfo) to ensure the data is captured on the span, aligning the implementation with the documented behavior.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/browser/src/integrations/graphqlClient.ts#L89-L92

Potential issue: When span streaming is enabled, the `_updateSpanWithGraphQLData`
function correctly avoids updating the span name to maintain low cardinality. However,
it fails to capture the GraphQL operation information (e.g., "query GetUser") as a span
attribute. The code calculates `operationInfo` but never attaches it to the span when
`hasSpanStreamingEnabled(client)` is true. This contradicts the migration documentation,
which states this information should be available on the `graphql.operation.name`
attribute. As a result, users lose the ability to differentiate between GraphQL
operations in their traces, impacting observability.

Did we get this right? 👍 / 👎 to inform future reviews.

// Handle standard requests - capture the query document when enabled via dataCollection (default true)
if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) {
Expand Down
32 changes: 28 additions & 4 deletions packages/browser/src/tracing/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -159,11 +166,16 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
// so we extend this in here
if (createdSpan) {
const fullUrl = getFullURL(handlerData.fetchData.url);
const host = fullUrl ? parseUrl(fullUrl).host : undefined;
// `parseUrl` returns the raw authority, which can carry userinfo. Credentials must never reach
// a span name or attribute.
const host = fullUrl ? parseUrl(fullUrl).host?.replace(/^.*@/, '') : undefined;
const sanitizedFullUrl = fullUrl ? stripDataUrlContent(fullUrl) : undefined;
createdSpan.setAttributes({
[URL_FULL]: filterCollectedUrl(sanitizedFullUrl),
[SERVER_ADDRESS]: host,
// Unlike `server.address`, `url.domain` excludes the port. `getSpanStartOptions` cannot set it
// for relative URLs, which only resolve to an origin once `getFullURL` has run.
[URL_DOMAIN]: host?.replace(/:\d+$/, ''),
});

if (enableHTTPTimings) {
Expand Down Expand Up @@ -369,16 +381,28 @@ function xhrCallback(
// With span streaming, we always emit http.client spans, even without a parent span
const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client));

// `parseUrl` returns the raw authority, which can carry userinfo. Credentials must never reach a span
// name or attribute.
const host = parsedUrl?.host?.replace(/^.*@/, '');
// Unlike `server.address`, `url.domain` excludes the port.
const domain = host?.replace(/:\d+$/, '');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: This works differently than in the fetchStreamPerformance integration, is that intended?

The other code I mean:

const domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined;


// 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 = domain ? `${method} ${domain}` : method;

const span =
shouldCreateSpanResult && shouldEmitSpan
? startInactiveSpan({
name: `${method} ${urlForSpanName}`,
name: !!client && hasSpanStreamingEnabled(client) ? streamedName : `${method} ${urlForSpanName}`,
Comment thread
cursor[bot] marked this conversation as resolved.
attributes: {
type: 'xhr',
// eslint-disable-next-line typescript/no-deprecated
[HTTP_REQUEST_METHOD]: method,
[URL_FULL]: filterCollectedUrl(sanitizedFullUrl),
[SERVER_ADDRESS]: parsedUrl?.host,
[SERVER_ADDRESS]: host,
[URL_DOMAIN]: domain,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
[URL_QUERY]: filterCollectedUrlQuery(getUrlQuery(parsedUrl?.search)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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(() => () => {});
const startInactiveSpanSpy = vi
.spyOn(utils, 'startInactiveSpan')
.mockReturnValue(new utils.SentryNonRecordingSpan());

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?.({
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' }),
);
});
});
20 changes: 20 additions & 0 deletions packages/browser/test/integrations/graphqlClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ describe('GraphqlClient', () => {
function setupHandler(
endpoints: Array<string | RegExp>,
graphQLDocument = true,
traceLifecycle: 'static' | 'stream' = 'static',
): (span: SentrySpan, hint: FetchHint | XhrHint) => void {
let capturedListener: ((span: SentrySpan, hint: FetchHint | XhrHint) => void) | undefined;
const mockClient = {
Expand All @@ -325,6 +326,7 @@ describe('GraphqlClient', () => {
capturedListener = cb;
}
},
getOptions: () => ({ traceLifecycle }),
getDataCollectionOptions: () => ({ graphQL: { document: graphQLDocument, variables: true } }),
} as unknown as Client;

Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading