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 @@ -909,6 +909,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`) |
| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing (`chat unknown`) | `{operation} {model}`, or `{operation}` if the model is missing (`chat`) |
Expand All @@ -930,6 +931,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 @@ -940,6 +945,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`). 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`.

Messaging span names now read `<operation type> <destination>` in every integration. The amqplib, kafkajs and NestJS BullMQ integrations used their own word order or verb, so their names change: `my-queue process` became `process my-queue`, amqplib's `publish` became `send`, and the kafkajs batch span's `poll` became `receive`. Cloudflare Queues and the kafkajs producer already matched the conventions, so their names are the same in both trace lifecycles. The operation name an integration reports upstream stays on `messaging.operation.name`.
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}`,
// 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}/),
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
@@ -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()],
tracesSampleRate: 1,
autoSessionTracking: false,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fetch('/test-req/0').then(fetch('/test-req/1').then(fetch('/test-req/2')));
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 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),
);

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',
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' },
}),
}),
);
});
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`.
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}`,
// Streamed span names drop the high-cardinality URL path.
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
@@ -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()],
tracesSampleRate: 1,
autoSessionTracking: false,
});
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 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),
);

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',
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' },
}),
}),
);
});
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}`,
// 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}/),
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
13 changes: 10 additions & 3 deletions packages/browser/src/integrations/fetchStreamPerformance.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { HTTP_REQUEST_METHOD, SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes';
import { HTTP_REQUEST_METHOD, SENTRY_OP, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes';
import { HTTP_CLIENT_STREAM } from '@sentry/conventions/op';
import type { IntegrationFn, Span } from '@sentry/core';
import {
addFetchEndInstrumentationHandler,
addFetchInstrumentationHandler,
defineIntegration,
getSanitizedUrlStringFromUrlObject,
getUrlDomain,
hasSpanStreamingEnabled,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
stripDataUrlContent,
filterCollectedUrl,
startInactiveSpan,
} from '@sentry/core/browser';
import { WINDOW } from '../helpers';

const responseToStreamSpan = new WeakMap<object, Span>();
const responseToFallbackTimeout = new WeakMap<object, ReturnType<typeof setTimeout>>();
Expand All @@ -37,7 +40,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 +81,15 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => {
? getSanitizedUrlStringFromUrlObject(parsedUrl)
: url;

// `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: `${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',
[SENTRY_OP]: HTTP_CLIENT_STREAM,
Expand Down
45 changes: 38 additions & 7 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 All @@ -9,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<string | RegExp>;
Expand Down Expand Up @@ -83,8 +91,15 @@ 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 one containing the URL. The operation stays reachable as an attribute.
if (!hasSpanStreamingEnabled(client)) {
span.updateName(`${httpMethod} ${httpUrl} (${_getGraphQLOperation(graphqlBody)})`);
}

Comment thread
sentry[bot] marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -138,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'
Expand All @@ -150,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
Expand Down
Loading
Loading