From 97991d206deafb0f8dcf531589ac5673d13639b6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 24 Aug 2026 14:44:47 -0400 Subject: [PATCH] feat(browser): Emit low cardinality navigation span names Names navigation spans `Navigation` when span streaming is enabled and the SDK has no parameterized route for them. Names in static mode are unchanged. --- MIGRATION.md | 15 +++-- .../navigation-streamed/test.ts | 23 +++++-- packages/angular/src/tracing.ts | 6 +- .../src/tracing/browserTracingIntegration.ts | 15 ++++- .../tracing/browserTracingIntegration.test.ts | 28 ++++++++- .../appRouterRoutingInstrumentation.ts | 28 +++++++-- .../pagesRouterRoutingInstrumentation.ts | 4 +- .../src/client/createClientInstrumentation.ts | 14 ++++- .../react-router/src/client/hydratedRouter.ts | 6 +- packages/react-router/src/client/utils.ts | 13 +++- .../createClientInstrumentation.test.ts | 50 ++++++++------- .../test/client/hydratedRouter.test.ts | 13 ++-- .../instrumentation.tsx | 27 +++++--- packages/react/src/reactrouter.tsx | 4 +- packages/react/src/reactrouterv3.ts | 5 +- packages/react/src/tanstackrouter.ts | 21 ++++++- .../instrumentation.test.tsx | 5 +- packages/react/test/reactrouterv4.test.tsx | 12 ++-- packages/react/test/reactrouterv5.test.tsx | 12 ++-- packages/remix/src/client/performance.tsx | 4 +- packages/solid/src/solidrouter.ts | 7 ++- packages/solid/src/tanstackrouter.ts | 15 ++++- .../src/client/svelte4BrowserTracing.ts | 6 +- .../src/client/svelte5BrowserTracing.ts | 6 +- .../client/browserTracingIntegration.test.ts | 34 +++++++++++ packages/vue/src/router.ts | 9 ++- packages/vue/src/tanstackrouter.ts | 15 ++++- packages/vue/test/router.test.ts | 61 ++++++++++++++++++- 28 files changed, 362 insertions(+), 96 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 6edbb8ad61b1..a71ab4784b68 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -617,20 +617,23 @@ Affected SDKs: All SDKs running in the browser. With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/). -In v11, this only affects `pageload` spans. Further ops will follow in future releases. +In v11, this only affects `pageload` and `navigation` spans. Further ops will follow in future releases. If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged. The following span names were adjusted: -| Span op | Before | After | -| ---------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| `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 | +| Span op | Before | After | +| ------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `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 | + +`navigation.redirect` spans are started through the same code path as navigation spans, so they get the same names. Some consequences to be aware of: -Child spans of a pageload 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. +Child spans of a pageload or navigation 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. -`ignoreSpans` is evaluated when a span **starts**, at which point a pageload span without a resolved route is already named `'Pageload'`, so filters matching a URL path no longer apply to it. Match on attributes instead: +`ignoreSpans` is evaluated when a span **starts**, at which point a pageload or navigation span without a resolved route is already named `'Pageload'`/`'Navigation'`, so filters matching a URL path no longer apply to it. Match on attributes instead: ```js Sentry.init({ diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts index 0c6ac7b569a0..d9931a387011 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -152,7 +152,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow }, 'sentry.segment.name': { type: 'string', - value: '/index.html', + value: 'Navigation', }, 'sentry.source': { type: 'string', @@ -182,7 +182,9 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow trace_id: pageloadTraceId, }, ], - name: '/index.html', + // The raw URL stays in `url.path`/`url.full`: with span streaming, a navigation span name is + // low cardinality and falls back to 'Navigation' when there is no parameterized route. + name: 'Navigation', span_id: navigationSpan.span_id, start_timestamp: expect.any(Number), status: 'ok', @@ -198,11 +200,12 @@ sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); const navigationSpan1Promise = waitForStreamedSpan( page, - span => getSpanOp(span) === 'navigation' && span.name === '/sub-page', + // Matched on `url.path` rather than the span name, which is low cardinality. + span => getSpanOp(span) === 'navigation' && span.attributes?.[URL_PATH]?.value === '/sub-page', ); const navigationSpan2Promise = waitForStreamedSpan( page, - span => getSpanOp(span) === 'navigation' && span.name === '/sub-page-2', + span => getSpanOp(span) === 'navigation' && span.attributes?.[URL_PATH]?.value === '/sub-page-2', ); await page.goto(url); @@ -212,9 +215,13 @@ sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) const navigationSpan1 = await navigationSpan1Promise; - expect(navigationSpan1.name).toEqual('/sub-page'); + expect(navigationSpan1.name).toEqual('Navigation'); expect(navigationSpan1.attributes).toMatchObject({ + [URL_PATH]: { + type: 'string', + value: '/sub-page', + }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.navigation.browser', @@ -237,9 +244,13 @@ sentryTest('handles pushState with full URL', async ({ getLocalTestUrl, page }) const navigationSpan2 = await navigationSpan2Promise; - expect(navigationSpan2.name).toEqual('/sub-page-2'); + expect(navigationSpan2.name).toEqual('Navigation'); expect(navigationSpan2.attributes).toMatchObject({ + [URL_PATH]: { + type: 'string', + value: '/sub-page-2', + }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.navigation.browser', diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index c45c3d7f4185..7b8ba3dd504f 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -26,6 +26,8 @@ import { FUNCTION } from '@sentry/conventions/op'; import type { Integration, Span } from '@sentry/core'; import { debug, + hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, parseStringToURLObject, stripUrlQueryAndFragment, timestampInSeconds, @@ -115,7 +117,9 @@ export class TraceService implements OnDestroy { startBrowserTracingNavigationSpan( client, { - name: strippedUrl, + // With span streaming, span names have to be low cardinality. The parameterized route + // is only known on `ResolveEnd`, which updates the span name then. + name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : strippedUrl, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.angular', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a6afcbd0b0ae..0c5c83c3bb35 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -22,6 +22,7 @@ import { GLOBAL_OBJ, hasSpansEnabled, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, isURLObjectRelative, parseStringToURLObject, @@ -633,7 +634,11 @@ export const browserTracingIntegration = ((options: Partial { expect(spanIsSampled(span2)).toBe(true); expect(span2.isRecording()).toBe(true); expect(spanToJSON(span2)).toEqual({ - name: '/test', + // The raw URL stays in `url.path`/`url.full`: with span streaming, a navigation span name is + // low cardinality and falls back to 'Navigation' when there is no parameterized route. + name: 'Navigation', status: 'ok', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -336,7 +338,7 @@ describe('browserTracingIntegration', () => { expect(spanIsSampled(span3)).toBe(true); expect(span3.isRecording()).toBe(true); expect(spanToJSON(span3)).toEqual({ - name: '/test2', + name: 'Navigation', status: 'ok', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -425,7 +427,9 @@ describe('browserTracingIntegration', () => { [URL_FULL]: 'https://example.com/test', [URL_PATH]: '/test', }, - name: '/test', + // Redirect spans are started through the same path as navigation spans, so they get the + // low-cardinality fallback name too. + name: 'Navigation', parent_span_id: span.spanContext().spanId, }), ); @@ -990,6 +994,24 @@ describe('browserTracingIntegration', () => { expect(getCurrentScope().getScopeData().transactionName).toBe('test navigation span'); }); + it("never sets the low-cardinality 'Navigation' span name on `scope.transactionName`", () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + startBrowserTracingNavigationSpan(client, { name: 'Navigation' }, { url: 'https://example.com/users/123?q=1' }); + + // The span name is low cardinality with span streaming enabled, but errors have to stay + // grouped by the actual page, so the scope keeps the destination path. + expect(spanToJSON(getActiveSpan()!).name).toBe('Navigation'); + expect(getCurrentScope().getScopeData().transactionName).toBe('/users/123'); + }); + it("updates the scopes' propagationContexts on a navigation", () => { const client = new BrowserClient( getDefaultBrowserClientOptions({ diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 8ab4865e4e06..5f677899bb49 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -2,6 +2,7 @@ import type { Client, Span } from '@sentry/core'; import { GLOBAL_OBJ, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -115,7 +116,10 @@ export function appRouterInstrumentNavigation(client: Client): void { const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href; const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname); const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); - const pathname = parameterizedPathname ?? unparameterizedPathname; + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const spanName = + parameterizedPathname ?? + (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : unparameterizedPathname); if (navigationRoutingMode === 'router-patch') { navigationRoutingMode = 'transition-start-hook'; @@ -123,7 +127,7 @@ export function appRouterInstrumentNavigation(client: Client): void { const currentNavigationSpan = currentRouterPatchingNavigationSpanRef.current; if (currentNavigationSpan) { - currentNavigationSpan.updateName(pathname); + currentNavigationSpan.updateName(spanName); currentNavigationSpan.setAttributes({ 'navigation.type': `router.${navigationType}`, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', @@ -135,7 +139,7 @@ export function appRouterInstrumentNavigation(client: Client): void { startBrowserTracingNavigationSpan( client, { - name: pathname, + name: spanName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', @@ -152,8 +156,11 @@ export function appRouterInstrumentNavigation(client: Client): void { WINDOW.addEventListener('popstate', () => { const pathname = stripTrailingSlash(WINDOW.location.pathname); const parameterizedPathname = maybeParameterizeRoute(pathname); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const spanName = + parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname); if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) { - currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? pathname); + currentRouterPatchingNavigationSpanRef.current.updateName(spanName); currentRouterPatchingNavigationSpanRef.current.setAttribute( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, parameterizedPathname ? 'route' : 'url', @@ -166,7 +173,7 @@ export function appRouterInstrumentNavigation(client: Client): void { currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan( client, { - name: parameterizedPathname ?? pathname, + name: spanName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', @@ -270,10 +277,19 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe ? undefined : getAbsoluteUrl(normalizedHref); + // The incomplete-instrumentation placeholder is a static name, so it is low cardinality + // already, and keeping it is what makes the `ignoreSpans` entry filtering those spans match. + const isPlaceholderName = transactionName === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME; + currentNavigationSpanRef.current = startBrowserTracingNavigationSpan( client, { - name: parameterizedPathname ?? transactionName, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: + parameterizedPathname ?? + (isPlaceholderName || !hasSpanStreamingEnabled(client) + ? transactionName + : NAVIGATION_SPAN_NAME_FALLBACK), attributes: { ...transactionAttributes, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 824bf2dfa9c0..4d3cd291b4c1 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -2,6 +2,7 @@ import type { Client, TransactionSource } from '@sentry/core'; import { debug, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, parseBaggageHeader, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -165,7 +166,8 @@ export function pagesRouterInstrumentNavigation(client: Client): void { startBrowserTracingNavigationSpan( client, { - name: newLocation, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: spanSource === 'route' || !hasSpanStreamingEnabled(client) ? newLocation : NAVIGATION_SPAN_NAME_FALLBACK, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.pages_router_instrumentation', diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index 7d17d62e74f8..2bd055789db1 100644 --- a/packages/react-router/src/client/createClientInstrumentation.ts +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -7,6 +7,8 @@ import { getClient, getRootSpan, GLOBAL_OBJ, + hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -106,7 +108,9 @@ export function createSentryClientInstrumentation( startBrowserTracingNavigationSpan( client, { - name: pathname, + // With span streaming, span names have to be low cardinality, so we can't fall back to + // the URL. The route hooks parameterize the span once they resolve. + name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -149,7 +153,9 @@ export function createSentryClientInstrumentation( navigationSpan = startBrowserTracingNavigationSpan( client, { - name: currentPathname, + // With span streaming, span names have to be low cardinality, so we can't fall back + // to the URL. The route is resolved once the navigation settles. + name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : currentPathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -194,7 +200,9 @@ export function createSentryClientInstrumentation( navigationSpan = startBrowserTracingNavigationSpan( client, { - name: toPath, + // With span streaming, span names have to be low cardinality, so we can't fall back to + // the URL. The route hooks parameterize the span once they resolve. + name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : toPath, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts index f8e517c500ef..14024906f80d 100644 --- a/packages/react-router/src/client/hydratedRouter.ts +++ b/packages/react-router/src/client/hydratedRouter.ts @@ -6,7 +6,9 @@ import { getClient, getRootSpan, GLOBAL_OBJ, + hasSpanStreamingEnabled, isThenable, + NAVIGATION_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -190,7 +192,9 @@ function maybeCreateNavigationTransaction(name: string, url: string, source: 'ur return startBrowserTracingNavigationSpan( client, { - name, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // The route is resolved once the router settles, which updates the span name then. + name: source === 'route' || !hasSpanStreamingEnabled(client) ? name : NAVIGATION_SPAN_NAME_FALLBACK, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', diff --git a/packages/react-router/src/client/utils.ts b/packages/react-router/src/client/utils.ts index c8e522b182b2..9782e6e3b320 100644 --- a/packages/react-router/src/client/utils.ts +++ b/packages/react-router/src/client/utils.ts @@ -1,6 +1,13 @@ import { getAbsoluteUrl } from '@sentry/browser'; import type { Span } from '@sentry/core'; -import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, filterCollectedUrl } from '@sentry/core'; +import { + getClient, + GLOBAL_OBJ, + hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + filterCollectedUrl, +} from '@sentry/core'; import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { DataRouter, RouterState } from 'react-router'; @@ -102,7 +109,9 @@ export function updateNavigationSpanUrlFromLocation(span: Span): void { const { pathname, search = '', hash = '' } = WINDOW.location; const destinationUrl = getAbsoluteUrl(`${pathname}${search}${hash}`); - span.updateName(pathname); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const client = getClient(); + span.updateName(client && hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname); span.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [URL_PATH]: pathname, diff --git a/packages/react-router/test/client/createClientInstrumentation.test.ts b/packages/react-router/test/client/createClientInstrumentation.test.ts index 8a5b697a7567..825aa6310730 100644 --- a/packages/react-router/test/client/createClientInstrumentation.test.ts +++ b/packages/react-router/test/client/createClientInstrumentation.test.ts @@ -44,6 +44,9 @@ vi.mock('@sentry/browser', () => ({ }), })); +// Span streaming is the default trace lifecycle, and it's what makes span names low cardinality. +const mockStreamingClient = { getOptions: () => ({ traceLifecycle: 'stream' }) }; + describe('createSentryClientInstrumentation', () => { beforeEach(() => { vi.clearAllMocks(); @@ -91,7 +94,7 @@ describe('createSentryClientInstrumentation', () => { it('should instrument router navigate with browser tracing span', async () => { const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); const mockInstrument = vi.fn(); - const mockClient = {}; + const mockClient = mockStreamingClient; (core.getClient as any).mockReturnValue(mockClient); (globalThis as any).location = { @@ -115,7 +118,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/about', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.source': 'url', 'sentry.op': 'navigation', @@ -131,7 +134,7 @@ describe('createSentryClientInstrumentation', () => { it('should resolve relative navigate targets against the current URL', async () => { const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); const mockInstrument = vi.fn(); - const mockClient = {}; + const mockClient = mockStreamingClient; (core.getClient as any).mockReturnValue(mockClient); (globalThis as any).location = { @@ -152,7 +155,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, expect.objectContaining({ - name: 'settings', + name: 'Navigation', }), { url: 'https://example.com/users/123/settings' }, ); @@ -161,7 +164,7 @@ describe('createSentryClientInstrumentation', () => { it('should create navigation span with correct name when `to` is an object', async () => { const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); const mockInstrument = vi.fn(); - const mockClient = {}; + const mockClient = mockStreamingClient; (core.getClient as any).mockReturnValue(mockClient); (globalThis as any).location = { @@ -185,7 +188,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/items/123', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.source': 'url', 'sentry.op': 'navigation', @@ -314,7 +317,7 @@ describe('createSentryClientInstrumentation', () => { const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); const mockInstrument = vi.fn(); - (core.getClient as any).mockReturnValue({}); + (core.getClient as any).mockReturnValue(mockStreamingClient); (globalThis as any).location = { href: 'https://example.com/home', origin: 'https://example.com', @@ -409,7 +412,7 @@ describe('createSentryClientInstrumentation', () => { const mockInstrument = vi.fn(); const mockNavigationSpan = { setStatus: vi.fn() }; - (core.getClient as any).mockReturnValue({}); + (core.getClient as any).mockReturnValue(mockStreamingClient); (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan); const instrumentation = createSentryClientInstrumentation(); @@ -458,7 +461,7 @@ describe('createSentryClientInstrumentation', () => { }); const mockInstrument = vi.fn(); const mockNavigationSpan = { setStatus: vi.fn(), updateName: vi.fn(), setAttributes: vi.fn() }; - const mockClient = {}; + const mockClient = mockStreamingClient; (core.getClient as any).mockReturnValue(mockClient); (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan); @@ -472,7 +475,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/current-page', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.source': 'url', 'sentry.op': 'navigation', @@ -482,7 +485,8 @@ describe('createSentryClientInstrumentation', () => { }, { url: 'https://example.com/current-page' }, ); - expect(mockNavigationSpan.updateName).toHaveBeenCalledWith(destination); + // The destination stays on the URL attributes, the span name is low cardinality. + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('Navigation'); expect(mockNavigationSpan.setAttributes).toHaveBeenCalledWith({ 'sentry.source': 'url', 'url.path': destination, @@ -495,7 +499,7 @@ describe('createSentryClientInstrumentation', () => { const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); const mockInstrument = vi.fn(); - (core.getClient as any).mockReturnValue({}); + (core.getClient as any).mockReturnValue(mockStreamingClient); const instrumentation = createSentryClientInstrumentation(); instrumentation.router?.({ instrument: mockInstrument }); @@ -514,7 +518,7 @@ describe('createSentryClientInstrumentation', () => { }); const mockInstrument = vi.fn(); const mockNavigationSpan = { setStatus: vi.fn(), updateName: vi.fn(), setAttributes: vi.fn() }; - const mockClient = {}; + const mockClient = mockStreamingClient; (core.getClient as any).mockReturnValue(mockClient); (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan); @@ -541,7 +545,7 @@ describe('createSentryClientInstrumentation', () => { const mockInstrument = vi.fn(); const mockNavigationSpan = { setStatus: vi.fn(), updateName: vi.fn(), setAttributes: vi.fn() }; - (core.getClient as any).mockReturnValue({}); + (core.getClient as any).mockReturnValue(mockStreamingClient); (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan); const instrumentation = createSentryClientInstrumentation(); @@ -560,7 +564,7 @@ describe('createSentryClientInstrumentation', () => { const mockInstrument = vi.fn(); const mockNavigationSpan = { setStatus: vi.fn(), updateName: vi.fn(), setAttributes: vi.fn() }; - (core.getClient as any).mockReturnValue({}); + (core.getClient as any).mockReturnValue(mockStreamingClient); (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan); delete (globalThis as any).__sentryReactRouterNavigateHookInvoked; @@ -730,7 +734,7 @@ describe('createSentryClientInstrumentation', () => { }); it('should create navigation span with browser.popstate type on popstate event', () => { - const mockClient = {}; + const mockClient = mockStreamingClient; (core.getClient as any).mockReturnValue(mockClient); const mockInstrument = vi.fn(); @@ -742,7 +746,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/current-page', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.source': 'url', 'sentry.op': 'navigation', @@ -767,7 +771,7 @@ describe('createSentryClientInstrumentation', () => { }); it('should update existing numeric navigation span on popstate instead of creating duplicate', async () => { - const mockClient = {}; + const mockClient = mockStreamingClient; const mockNavigationSpan = { setStatus: vi.fn(), updateName: vi.fn(), @@ -801,7 +805,7 @@ describe('createSentryClientInstrumentation', () => { }); it('should create new span on popstate when no numeric navigation is in progress', () => { - const mockClient = {}; + const mockClient = mockStreamingClient; (core.getClient as any).mockReturnValue(mockClient); const mockInstrument = vi.fn(); @@ -814,7 +818,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/current-page', + name: 'Navigation', attributes: expect.objectContaining({ 'navigation.type': 'browser.popstate', }), @@ -907,7 +911,7 @@ describe('navigation root parameterization', () => { it('renames the active navigation/pageload root span with the route pattern from the loader hook', async () => { const mockRootSpan = { setAttributes: vi.fn() }; - (core.getActiveSpan as any).mockReturnValue({}); + (core.getActiveSpan as any).mockReturnValue(mockStreamingClient); (core.getRootSpan as any).mockReturnValue(mockRootSpan); (core.spanToJSON as any).mockReturnValue({ attributes: { 'sentry.op': 'navigation' } }); @@ -929,7 +933,7 @@ describe('navigation root parameterization', () => { it('does not rename the root span when the route has no pattern', async () => { const mockRootSpan = { setAttributes: vi.fn() }; - (core.getActiveSpan as any).mockReturnValue({}); + (core.getActiveSpan as any).mockReturnValue(mockStreamingClient); (core.getRootSpan as any).mockReturnValue(mockRootSpan); (core.spanToJSON as any).mockReturnValue({ attributes: { 'sentry.op': 'navigation' } }); @@ -948,7 +952,7 @@ describe('navigation root parameterization', () => { }); it('does not rename root spans that are not pageload/navigation', async () => { - (core.getActiveSpan as any).mockReturnValue({}); + (core.getActiveSpan as any).mockReturnValue(mockStreamingClient); (core.getRootSpan as any).mockReturnValue({ setAttribute: vi.fn() }); (core.spanToJSON as any).mockReturnValue({ attributes: { 'sentry.op': 'http.server' } }); diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts index 81dcfc7a046b..e23cb0444aab 100644 --- a/packages/react-router/test/client/hydratedRouter.test.ts +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -70,7 +70,7 @@ describe('instrumentHydratedRouter', () => { 'url.path': '/foo/bar', }, })); - (core.getClient as any).mockReturnValue({}); + (core.getClient as any).mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) }); (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan); }); @@ -208,7 +208,7 @@ describe('instrumentHydratedRouter', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - name: '/items/123', + name: 'Navigation', }), // the destination URL keeps the query string, even though the span name doesn't { url: 'https://example.com/items/123?foo=bar' }, @@ -221,7 +221,7 @@ describe('instrumentHydratedRouter', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - name: 'settings', + name: 'Navigation', }), { url: 'https://example.com/foo/bar/settings' }, ); @@ -260,7 +260,7 @@ describe('instrumentHydratedRouter', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - name: '/foo/bar', + name: 'Navigation', }), { url: 'https://example.com/foo/bar' }, ); @@ -283,7 +283,8 @@ describe('instrumentHydratedRouter', () => { await navigateResult; - expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/foo'); + // The destination stays on the URL attributes, the span name is low cardinality. + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('Navigation'); expect(mockNavigationSpan.setAttributes).toHaveBeenCalledWith({ 'sentry.source': 'url', 'url.path': '/foo', @@ -334,7 +335,7 @@ describe('instrumentHydratedRouter', () => { instrumentHydratedRouter(); mockRouter.navigate(-1); - expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/foo'); + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('Navigation'); expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(1); expect(mockNavigationSpan.setAttributes).toHaveBeenCalledWith({ 'sentry.source': 'url', diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 3ee8e246216b..b605abc6a57b 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -15,6 +15,7 @@ import { getClient, getCurrentScope, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -398,7 +399,10 @@ export function updateNavigationSpan( (currentSource !== 'route' && source === 'route') || // URL → route upgrade (currentSource === 'route' && source === 'route' && currentNameHasWildcard)); // Route → better route (only if current has wildcard) if (isImprovement) { - activeRootSpan.updateName(name); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const client = getClient(); + const isUnparameterizedStreamedNavigation = source !== 'route' && !!client && hasSpanStreamingEnabled(client); + activeRootSpan.updateName(isUnparameterizedStreamedNavigation ? NAVIGATION_SPAN_NAME_FALLBACK : name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); if (source === 'route') { activeRootSpan.setAttribute(URL_TEMPLATE, name); @@ -996,8 +1000,10 @@ export function handleNavigation(opts: { `[Tracing] Updated placeholder navigation name from "${oldName}" to "${name}" (will apply to real span)`, ); } else { - // Update existing real span from wildcard to parameterized route name - trackedNav.span.updateName(name); + // Update existing real span from wildcard to parameterized route name. + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const isUnparameterizedStreamedNavigation = source !== 'route' && hasSpanStreamingEnabled(client); + trackedNav.span.updateName(isUnparameterizedStreamedNavigation ? NAVIGATION_SPAN_NAME_FALLBACK : name); trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); if (source === 'route') { trackedNav.span.setAttribute(URL_TEMPLATE, name); @@ -1028,7 +1034,12 @@ export function handleNavigation(opts: { let navigationSpan: Span | undefined; try { navigationSpan = startBrowserTracingNavigationSpan(client, { - name: placeholderEntry.routeName, // Use placeholder's routeName in case it was updated + // Use placeholder's routeName in case it was updated. With span streaming, span names have to + // be low cardinality, so we can't fall back to the URL. + name: + source === 'route' || !hasSpanStreamingEnabled(client) + ? placeholderEntry.routeName + : NAVIGATION_SPAN_NAME_FALLBACK, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -1237,11 +1248,11 @@ function tryUpdateSpanNameBeforeEnd( const spanNotEnded = spanType === 'pageload' || !spanJson.end_timestamp; if (isImprovement && spanNotEnded) { - // With span streaming, a pageload span name has to be low cardinality, so we can't fall back to the URL. + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const client = getClient(); - const isUnparameterizedStreamedPageload = - spanType === 'pageload' && source !== 'route' && !!client && hasSpanStreamingEnabled(client); - span.updateName(isUnparameterizedStreamedPageload ? PAGELOAD_SPAN_NAME_FALLBACK : name); + const isUnparameterizedStreamedSpan = source !== 'route' && !!client && hasSpanStreamingEnabled(client); + const fallbackName = spanType === 'pageload' ? PAGELOAD_SPAN_NAME_FALLBACK : NAVIGATION_SPAN_NAME_FALLBACK; + span.updateName(isUnparameterizedStreamedSpan ? fallbackName : name); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); if (source === 'route') { span.setAttribute(URL_TEMPLATE, name); diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouter.tsx index 42b25a866788..33bd698c9902 100644 --- a/packages/react/src/reactrouter.tsx +++ b/packages/react/src/reactrouter.tsx @@ -10,6 +10,7 @@ import { getCurrentScope, getRootSpan, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -177,7 +178,8 @@ function instrumentReactRouter( if (action && (action === 'PUSH' || action === 'POP')) { const [name, source] = normalizeTransactionName(location.pathname); startBrowserTracingNavigationSpan(client, { - name, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: source === 'route' || !hasSpanStreamingEnabled(client) ? name : NAVIGATION_SPAN_NAME_FALLBACK, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.${instrumentationName}`, diff --git a/packages/react/src/reactrouterv3.ts b/packages/react/src/reactrouterv3.ts index e9a6208a1a58..fed36c0989c4 100644 --- a/packages/react/src/reactrouterv3.ts +++ b/packages/react/src/reactrouterv3.ts @@ -7,6 +7,7 @@ import { import type { Integration, TransactionSource } from '@sentry/core/browser'; import { hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -88,7 +89,9 @@ export function reactRouterV3BrowserTracingIntegration( match, (localName: string, source: TransactionSource = 'url') => { startBrowserTracingNavigationSpan(client, { - name: localName, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: + source === 'route' || !hasSpanStreamingEnabled(client) ? localName : NAVIGATION_SPAN_NAME_FALLBACK, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index f79592599f74..4333a45eb268 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -6,7 +6,12 @@ import { WINDOW, } from '@sentry/browser'; import type { Integration } from '@sentry/core/browser'; -import { filterCollectedUrl, hasSpanStreamingEnabled, PAGELOAD_SPAN_NAME_FALLBACK } from '@sentry/core'; +import { + filterCollectedUrl, + hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, + PAGELOAD_SPAN_NAME_FALLBACK, +} from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -140,7 +145,10 @@ export function tanstackRouterBrowserTracingIntegration( } const routeMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); - const fallbackName = WINDOW.location?.pathname || toLocation.pathname; + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const fallbackName = hasSpanStreamingEnabled(client) + ? NAVIGATION_SPAN_NAME_FALLBACK + : WINDOW.location?.pathname || toLocation.pathname; if (inFlightNavigationSpan) { // Redirect continuation within the same navigation: keep the span, update the target. @@ -173,7 +181,14 @@ export function tanstackRouterBrowserTracingIntegration( const { toLocation } = onResolvedArgs; const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); if (resolvedMatch) { - applyRouteMatch(span, resolvedMatch, toLocation, WINDOW.location?.pathname || toLocation.pathname); + applyRouteMatch( + span, + resolvedMatch, + toLocation, + hasSpanStreamingEnabled(client) + ? NAVIGATION_SPAN_NAME_FALLBACK + : WINDOW.location?.pathname || toLocation.pathname, + ); } }); } diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index ff0d59de3cf9..6f5cacd161e8 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -22,7 +22,10 @@ import type { Location, RouteObject } from '../../src/types'; const mockUpdateName = vi.fn(); const mockSetAttribute = vi.fn(); const mockSpan = { updateName: mockUpdateName, setAttribute: mockSetAttribute } as unknown as Span; -const mockClient = { addIntegration: vi.fn() } as unknown as Client; +const mockClient = { + addIntegration: vi.fn(), + getOptions: () => ({ traceLifecycle: 'stream' }), +} as unknown as Client; vi.mock('@sentry/core', async requireActual => { const actual = await requireActual(); diff --git a/packages/react/test/reactrouterv4.test.tsx b/packages/react/test/reactrouterv4.test.tsx index eb479fc115ef..cb0a7f3cb942 100644 --- a/packages/react/test/reactrouterv4.test.tsx +++ b/packages/react/test/reactrouterv4.test.tsx @@ -129,7 +129,7 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -142,7 +142,7 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/features', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -201,7 +201,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -238,7 +238,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -283,7 +283,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/1234/v1/758', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -306,7 +306,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/543', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', diff --git a/packages/react/test/reactrouterv5.test.tsx b/packages/react/test/reactrouterv5.test.tsx index 5d6e065481fa..7c59f18278c9 100644 --- a/packages/react/test/reactrouterv5.test.tsx +++ b/packages/react/test/reactrouterv5.test.tsx @@ -129,7 +129,7 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -142,7 +142,7 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/features', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -201,7 +201,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -238,7 +238,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -283,7 +283,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/1234/v1/758', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -306,7 +306,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/543', + name: 'Navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', diff --git a/packages/remix/src/client/performance.tsx b/packages/remix/src/client/performance.tsx index 4890eb31773b..d09c72b5edda 100644 --- a/packages/remix/src/client/performance.tsx +++ b/packages/remix/src/client/performance.tsx @@ -5,6 +5,7 @@ import { getCurrentScope, getRootSpan, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, isNodeEnv, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -124,7 +125,8 @@ function startNavigationSpan(matches: RouteMatch[], location: ReturnType const { name, source } = getTransactionNameAndSource(location.pathname, lastMatch.id); const spanContext: StartSpanOptions = { - name, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: source === 'route' || !hasSpanStreamingEnabled(client) ? name : NAVIGATION_SPAN_NAME_FALLBACK, op: 'navigation', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.remix', diff --git a/packages/solid/src/solidrouter.ts b/packages/solid/src/solidrouter.ts index 564e1e9b70ac..fdeed1b8c6c8 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -16,6 +16,8 @@ import { import type { Client, Integration, Span } from '@sentry/core'; import { getClient, + hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -63,7 +65,8 @@ function handleNavigation(location: string): void { startBrowserTracingNavigationSpan( client, { - name: location, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : location, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.${framework}.solidrouter`, @@ -146,6 +149,8 @@ function withSentryRouterRoot(Root: Component): Component( } const routeMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); - const fallbackName = WINDOW.location?.pathname || toLocation.pathname; + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const fallbackName = hasSpanStreamingEnabled(client) + ? NAVIGATION_SPAN_NAME_FALLBACK + : WINDOW.location?.pathname || toLocation.pathname; if (inFlightNavigationSpan) { // Redirect continuation within the same navigation: keep the span, update the target. @@ -170,7 +174,14 @@ export function tanstackRouterBrowserTracingIntegration( const { toLocation } = onResolvedArgs; const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); if (resolvedMatch) { - applyRouteMatch(span, resolvedMatch, toLocation, WINDOW.location?.pathname || toLocation.pathname); + applyRouteMatch( + span, + resolvedMatch, + toLocation, + hasSpanStreamingEnabled(client) + ? NAVIGATION_SPAN_NAME_FALLBACK + : WINDOW.location?.pathname || toLocation.pathname, + ); } }); } diff --git a/packages/sveltekit/src/client/svelte4BrowserTracing.ts b/packages/sveltekit/src/client/svelte4BrowserTracing.ts index 818a8ffe2f61..a039845c12a6 100644 --- a/packages/sveltekit/src/client/svelte4BrowserTracing.ts +++ b/packages/sveltekit/src/client/svelte4BrowserTracing.ts @@ -1,6 +1,7 @@ import type { Client, Span } from '@sentry/core'; import { hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -119,7 +120,10 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable { expect(routingSpanEndSpy).toHaveBeenCalledTimes(1); }); + it('falls back to a low cardinality navigation span name when span streaming is enabled', async () => { + const streamingClient = { + ...fakeClient, + addIntegration: () => {}, + getOptions: () => ({ traceLifecycle: 'stream' }), + }; + + const integration = browserTracingIntegration({ + instrumentPageLoad: false, + }); + // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine + integration.afterAllSetup(streamingClient); + await vi.dynamicImportSettled(); + + // TODO(v11): switch to `navigating` from `$app/state` + // @ts-expect-error - navigating is a writable but the types say it's just readable + // eslint-disable-next-line typescript/no-deprecated + navigating.set({ + from: { route: {}, url: { pathname: '/users' } }, + to: { route: {}, url: { pathname: '/users/7762', href: 'https://sentry-test.io/users/7762' } }, + type: 'link', + }); + + // The destination URL stays on the span options, only the name is low cardinality. + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledWith( + streamingClient, + expect.objectContaining({ + name: 'Navigation', + attributes: expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }), + }), + { url: 'https://sentry-test.io/users/7762' }, + ); + }); + describe('handling same origin and destination navigations', () => { it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => { const integration = browserTracingIntegration({ diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index 7c01ea2b2d59..d3660f6977d2 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -13,6 +13,7 @@ import { getCurrentScope, getRootSpan, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -139,9 +140,15 @@ export function instrumentVueRouter( } if (options.instrumentNavigation && !activePageLoadSpan) { + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // A route name (`custom`) or matched route path (`route`) is low cardinality, a raw path is not. + const client = getClient(); + const isUnparameterizedStreamedNavigation = + transactionSource === 'url' && !!client && hasSpanStreamingEnabled(client); + startNavigationSpanFn( { - name: spanName, + name: isUnparameterizedStreamedNavigation ? NAVIGATION_SPAN_NAME_FALLBACK : spanName, op: 'navigation', attributes: { ...attributes, diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index 4aa4bd1880e9..1d97d942e306 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -15,6 +15,7 @@ import { import type { Integration } from '@sentry/core'; import { hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -144,7 +145,10 @@ export function tanstackRouterBrowserTracingIntegration( const routeMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); // In SSR/non-browser contexts, WINDOW.location may be undefined, so fall back to the router's location. - const fallbackName = WINDOW.location?.pathname || toLocation.pathname; + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + const fallbackName = hasSpanStreamingEnabled(client) + ? NAVIGATION_SPAN_NAME_FALLBACK + : WINDOW.location?.pathname || toLocation.pathname; if (inFlightNavigationSpan) { // Redirect continuation within the same navigation: keep the span, update the target. @@ -177,7 +181,14 @@ export function tanstackRouterBrowserTracingIntegration( } const { toLocation } = onResolvedArgs as TanstackRouterSubscribeArgs; const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); - applyRouteMatch(span, resolvedMatch, toLocation, WINDOW.location?.pathname || toLocation.pathname); + applyRouteMatch( + span, + resolvedMatch, + toLocation, + hasSpanStreamingEnabled(client) + ? NAVIGATION_SPAN_NAME_FALLBACK + : WINDOW.location?.pathname || toLocation.pathname, + ); }); } }, diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 6bec267f904d..91f58b01b31e 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -3,7 +3,7 @@ import type { Span, SpanAttributes } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import { NAVIGATION_ROUTE_ID, URL_TEMPLATE } from '@sentry/conventions/attributes'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Route } from '../src/router'; import { instrumentVueRouter } from '../src/router'; @@ -19,6 +19,7 @@ vi.mock('@sentry/core', async () => { getActiveSpan: vi.fn().mockReturnValue({ spanContext: () => ({ traceId: '1234', spanId: '5678' }), }), + getClient: vi.fn(), }; }); @@ -440,6 +441,64 @@ describe('instrumentVueRouter()', () => { expect(mockNext).not.toHaveBeenCalled(); }); + + describe('with span streaming enabled', () => { + beforeEach(() => { + vi.mocked(SentryCore.getClient).mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as SentryCore.Client); + }); + + afterEach(() => { + vi.mocked(SentryCore.getClient).mockReturnValue(undefined); + }); + + it('falls back to a low cardinality name when the route is not parameterized', () => { + const mockStartSpan = vi.fn().mockReturnValue(MOCK_SPAN); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'path', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0]![0]!; + const to = testRoutes.unmatchedRoute!; + beforeEachCallback(to, testRoutes['initialPageloadRoute']!); // fake initial pageload + beforeEachCallback(to, testRoutes.normalRoute1!); + + expect(mockStartSpan).toHaveBeenLastCalledWith( + { + name: 'Navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + ...getAttributesForRoute(to), + }, + op: 'navigation', + }, + expect.any(String), + ); + }); + + it('keeps a route name, which is already low cardinality', () => { + const mockStartSpan = vi.fn().mockReturnValue(MOCK_SPAN); + instrumentVueRouter( + mockVueRouter, + { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true }, + mockStartSpan, + ); + + const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0]![0]!; + const to = testRoutes.namedRoute!; + beforeEachCallback(to, testRoutes['initialPageloadRoute']!); // fake initial pageload + beforeEachCallback(to, testRoutes.normalRoute1!); + + expect(mockStartSpan).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'login-screen' }), + expect.any(String), + ); + }); + }); }); // Small helper function to get flattened attributes for test comparison