Skip to content

Commit 3ec962c

Browse files
authored
feat(browser): Emit low cardinality navigation span names (#23550)
Names navigation spans `Navigation` when span streaming is enabled and the SDK has no parameterized route for them. Names in static mode are unchanged. The scope's transaction name keeps the URL, since that's what error events group by. `navigation.redirect` spans share the code path so they get the fallback too, which imo is right. closes #23522
1 parent 106d828 commit 3ec962c

29 files changed

Lines changed: 520 additions & 116 deletions

File tree

MIGRATION.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,13 +809,16 @@ The following span names were adjusted:
809809
| Span op | Before | After |
810810
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
811811
| `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 |
812+
| `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 |
812813
| `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`) |
813814
| `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 |
814815
| `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`) |
815816
| `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 |
816817
| `mcp.server` | The method and its target, including the resource URI (`resources/read file:///docs/api.md`) | The method alone for resource methods (`resources/read`). Tool and prompt names are unchanged (`tools/call get-weather`) |
817818
| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name (`notifications/tools/list_changed`) | The notification method name, or `MCP notification` if the message carries none |
818819

820+
`navigation.redirect` spans are started through the same code path as navigation spans, so they get the same names.
821+
819822
Resource spans now also carry a `url.domain` attribute holding that domain. The full URL remains available on `url.full`.
820823

821824
`http.server` requests that resolve to a route are **unchanged** — those names were already low cardinality. Only requests the SDK cannot parameterize are affected.
@@ -832,9 +835,9 @@ Resource URIs are unbounded, so they are no longer part of an `mcp.server` span
832835

833836
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`.
834837

835-
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.
838+
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.
836839

837-
`ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later.
840+
`ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name. For example, an unresolved pageload or navigation span is named `'Pageload'`/`'Navigation'` and might receive its final, resolved route name later.
838841
`ignoreSpans` filters matching a URL path no longer apply to them.
839842
Another example where filters might need adjustments are `resource.*` spans where their name now only includes the domain the resource was taken from.
840843

dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow
151151
},
152152
'sentry.segment.name': {
153153
type: 'string',
154-
value: '/index.html',
154+
value: 'Navigation',
155155
},
156156
'sentry.segment.name.source': {
157157
type: 'string',
@@ -177,7 +177,9 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow
177177
trace_id: pageloadTraceId,
178178
},
179179
],
180-
name: '/index.html',
180+
// The raw URL stays in `url.path`/`url.full`: with span streaming, a navigation span name is
181+
// low cardinality and falls back to 'Navigation' when there is no parameterized route.
182+
name: 'Navigation',
181183
span_id: navigationSpan.span_id,
182184
start_timestamp: expect.any(Number),
183185
status: 'ok',
@@ -193,11 +195,12 @@ sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page })
193195
const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');
194196
const navigationSpan1Promise = waitForStreamedSpan(
195197
page,
196-
span => getSpanOp(span) === 'navigation' && span.name === '/sub-page',
198+
// Matched on `url.path` rather than the span name, which is low cardinality.
199+
span => getSpanOp(span) === 'navigation' && span.attributes?.[URL_PATH]?.value === '/sub-page',
197200
);
198201
const navigationSpan2Promise = waitForStreamedSpan(
199202
page,
200-
span => getSpanOp(span) === 'navigation' && span.name === '/sub-page-2',
203+
span => getSpanOp(span) === 'navigation' && span.attributes?.[URL_PATH]?.value === '/sub-page-2',
201204
);
202205

203206
await page.goto(url);
@@ -207,9 +210,13 @@ sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page })
207210

208211
const navigationSpan1 = await navigationSpan1Promise;
209212

210-
expect(navigationSpan1.name).toEqual('/sub-page');
213+
expect(navigationSpan1.name).toEqual('Navigation');
211214

212215
expect(navigationSpan1.attributes).toMatchObject({
216+
[URL_PATH]: {
217+
type: 'string',
218+
value: '/sub-page',
219+
},
213220
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
214221
type: 'string',
215222
value: 'auto.navigation.browser',
@@ -232,9 +239,13 @@ sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page })
232239

233240
const navigationSpan2 = await navigationSpan2Promise;
234241

235-
expect(navigationSpan2.name).toEqual('/sub-page-2');
242+
expect(navigationSpan2.name).toEqual('Navigation');
236243

237244
expect(navigationSpan2.attributes).toMatchObject({
245+
[URL_PATH]: {
246+
type: 'string',
247+
value: '/sub-page-2',
248+
},
238249
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: {
239250
type: 'string',
240251
value: 'auto.navigation.browser',

packages/angular/src/tracing.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type { Integration, Span } from '@sentry/core';
3333
import {
3434
debug,
3535
hasSpanStreamingEnabled,
36+
NAVIGATION_SPAN_NAME_FALLBACK,
3637
parseStringToURLObject,
3738
ROUTER_SPAN_NAME_FALLBACK,
3839
stripUrlQueryAndFragment,
@@ -123,7 +124,9 @@ export class TraceService implements OnDestroy {
123124
startBrowserTracingNavigationSpan(
124125
client,
125126
{
126-
name: strippedUrl,
127+
// With span streaming, span names have to be low cardinality. The parameterized route
128+
// is only known on `ResolveEnd`, which updates the span name then.
129+
name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : strippedUrl,
127130
attributes: {
128131
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.angular',
129132
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',

packages/browser/src/tracing/browserTracingIntegration.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
GLOBAL_OBJ,
2323
hasSpansEnabled,
2424
hasSpanStreamingEnabled,
25+
NAVIGATION_SPAN_NAME_FALLBACK,
2526
PAGELOAD_SPAN_NAME_FALLBACK,
2627
isURLObjectRelative,
2728
parseStringToURLObject,
@@ -632,7 +633,11 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
632633
startBrowserTracingNavigationSpan(
633634
client,
634635
{
635-
name: parsed?.pathname || WINDOW.location.pathname,
636+
// With span streaming, span names have to be low cardinality, and there is no route
637+
// information available here.
638+
name: hasSpanStreamingEnabled(client)
639+
? NAVIGATION_SPAN_NAME_FALLBACK
640+
: parsed?.pathname || WINDOW.location.pathname,
636641
attributes: {
637642
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
638643
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
@@ -713,7 +718,13 @@ export function startBrowserTracingNavigationSpan(
713718
client.emit('startNavigationSpan', spanOptions, { isRedirect, url });
714719

715720
const scope = getCurrentScope();
716-
scope.setTransactionName(spanOptions.name);
721+
// `Navigation` is a low-cardinality span name, not a description of the page. The scope's
722+
// transaction name is what error events are grouped by, so it keeps the URL instead. `url` is the
723+
// destination, while `location` still points at the previous page during a `pushState`.
724+
const isFallbackSpanName = spanOptions.name === NAVIGATION_SPAN_NAME_FALLBACK;
725+
scope.setTransactionName(
726+
isFallbackSpanName ? (url && parseStringToURLObject(url)?.pathname) || WINDOW.location?.pathname : spanOptions.name,
727+
);
717728

718729
// We store the normalized request data on the scope, so we get the request data at time of span creation
719730
// otherwise, the URL etc. may already be of the following navigation, and we'd report the wrong URL

packages/browser/test/tracing/browserTracingIntegration.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,9 @@ describe('browserTracingIntegration', () => {
292292
expect(spanIsSampled(span2)).toBe(true);
293293
expect(span2.isRecording()).toBe(true);
294294
expect(spanToJSON(span2)).toEqual({
295-
name: '/test',
295+
// The raw URL stays in `url.path`/`url.full`: with span streaming, a navigation span name is
296+
// low cardinality and falls back to 'Navigation' when there is no parameterized route.
297+
name: 'Navigation',
296298
status: 'ok',
297299
attributes: {
298300
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
@@ -335,7 +337,7 @@ describe('browserTracingIntegration', () => {
335337
expect(spanIsSampled(span3)).toBe(true);
336338
expect(span3.isRecording()).toBe(true);
337339
expect(spanToJSON(span3)).toEqual({
338-
name: '/test2',
340+
name: 'Navigation',
339341
status: 'ok',
340342
attributes: {
341343
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
@@ -423,7 +425,9 @@ describe('browserTracingIntegration', () => {
423425
[URL_FULL]: 'https://example.com/test',
424426
[URL_PATH]: '/test',
425427
},
426-
name: '/test',
428+
// Redirect spans are started through the same path as navigation spans, so they get the
429+
// low-cardinality fallback name too.
430+
name: 'Navigation',
427431
parent_span_id: span.spanContext().spanId,
428432
}),
429433
);
@@ -988,6 +992,24 @@ describe('browserTracingIntegration', () => {
988992
expect(getCurrentScope().getScopeData().transactionName).toBe('test navigation span');
989993
});
990994

995+
it("never sets the low-cardinality 'Navigation' span name on `scope.transactionName`", () => {
996+
const client = new BrowserClient(
997+
getDefaultBrowserClientOptions({
998+
tracesSampleRate: 1,
999+
integrations: [browserTracingIntegration()],
1000+
}),
1001+
);
1002+
setCurrentClient(client);
1003+
client.init();
1004+
1005+
startBrowserTracingNavigationSpan(client, { name: 'Navigation' }, { url: 'https://example.com/users/123?q=1' });
1006+
1007+
// The span name is low cardinality with span streaming enabled, but errors have to stay
1008+
// grouped by the actual page, so the scope keeps the destination path.
1009+
expect(spanToJSON(getActiveSpan()!).name).toBe('Navigation');
1010+
expect(getCurrentScope().getScopeData().transactionName).toBe('/users/123');
1011+
});
1012+
9911013
it("updates the scopes' propagationContexts on a navigation", () => {
9921014
const client = new BrowserClient(
9931015
getDefaultBrowserClientOptions({

packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core';
22
import {
33
GLOBAL_OBJ,
44
hasSpanStreamingEnabled,
5+
NAVIGATION_SPAN_NAME_FALLBACK,
56
PAGELOAD_SPAN_NAME_FALLBACK,
67
SEMANTIC_ATTRIBUTE_SENTRY_OP,
78
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
@@ -114,15 +115,18 @@ export function appRouterInstrumentNavigation(client: Client): void {
114115
const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href;
115116
const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname);
116117
const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname);
117-
const pathname = parameterizedPathname ?? unparameterizedPathname;
118+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
119+
const spanName =
120+
parameterizedPathname ??
121+
(hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : unparameterizedPathname);
118122

119123
if (navigationRoutingMode === 'router-patch') {
120124
navigationRoutingMode = 'transition-start-hook';
121125
}
122126

123127
const currentNavigationSpan = currentRouterPatchingNavigationSpanRef.current;
124128
if (currentNavigationSpan) {
125-
currentNavigationSpan.updateName(pathname);
129+
currentNavigationSpan.updateName(spanName);
126130
currentNavigationSpan.setAttributes({
127131
'navigation.type': `router.${navigationType}`,
128132
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
@@ -134,7 +138,7 @@ export function appRouterInstrumentNavigation(client: Client): void {
134138
startBrowserTracingNavigationSpan(
135139
client,
136140
{
137-
name: pathname,
141+
name: spanName,
138142
attributes: {
139143
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
140144
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
@@ -151,8 +155,11 @@ export function appRouterInstrumentNavigation(client: Client): void {
151155
WINDOW.addEventListener('popstate', () => {
152156
const pathname = stripTrailingSlash(WINDOW.location.pathname);
153157
const parameterizedPathname = maybeParameterizeRoute(pathname);
158+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
159+
const spanName =
160+
parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname);
154161
if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) {
155-
currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? pathname);
162+
currentRouterPatchingNavigationSpanRef.current.updateName(spanName);
156163
currentRouterPatchingNavigationSpanRef.current.setAttribute(
157164
SENTRY_SEGMENT_NAME_SOURCE,
158165
parameterizedPathname ? 'route' : 'url',
@@ -165,7 +172,7 @@ export function appRouterInstrumentNavigation(client: Client): void {
165172
currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(
166173
client,
167174
{
168-
name: parameterizedPathname ?? pathname,
175+
name: spanName,
169176
attributes: {
170177
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
171178
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
@@ -269,10 +276,19 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
269276
? undefined
270277
: getAbsoluteUrl(normalizedHref);
271278

279+
// The incomplete-instrumentation placeholder is a static name, so it is low cardinality
280+
// already, and keeping it is what makes the `ignoreSpans` entry filtering those spans match.
281+
const isPlaceholderName = transactionName === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME;
282+
272283
currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(
273284
client,
274285
{
275-
name: parameterizedPathname ?? transactionName,
286+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
287+
name:
288+
parameterizedPathname ??
289+
(isPlaceholderName || !hasSpanStreamingEnabled(client)
290+
? transactionName
291+
: NAVIGATION_SPAN_NAME_FALLBACK),
276292
attributes: {
277293
...transactionAttributes,
278294
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',

packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Client, TransactionSource } from '@sentry/core';
22
import {
33
debug,
44
hasSpanStreamingEnabled,
5+
NAVIGATION_SPAN_NAME_FALLBACK,
56
PAGELOAD_SPAN_NAME_FALLBACK,
67
parseBaggageHeader,
78
SEMANTIC_ATTRIBUTE_SENTRY_OP,
@@ -164,7 +165,8 @@ export function pagesRouterInstrumentNavigation(client: Client): void {
164165
startBrowserTracingNavigationSpan(
165166
client,
166167
{
167-
name: newLocation,
168+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
169+
name: spanSource === 'route' || !hasSpanStreamingEnabled(client) ? newLocation : NAVIGATION_SPAN_NAME_FALLBACK,
168170
attributes: {
169171
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
170172
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.pages_router_instrumentation',

0 commit comments

Comments
 (0)