diff --git a/MIGRATION.md b/MIGRATION.md index 1b5c54112e21..94700b9567fe 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -793,6 +793,7 @@ 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 | +| `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`) | | `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`) | @@ -800,6 +801,8 @@ The following span names were adjusted: | `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`) | | `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 | +`navigation.redirect` spans are started through the same code path as navigation spans, so they get the same names. + Resource spans now also carry a `url.domain` attribute holding that domain. The full URL remains available on `url.full`. `http.server` requests that resolve to a route are **unchanged** — those names were already low cardinality. Only requests the SDK cannot parameterize are affected. @@ -816,9 +819,9 @@ Resource URIs are unbounded, so they are no longer part of an `mcp.server` span Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`. -Child spans of a service or root span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references. +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. -`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. +`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. `ignoreSpans` filters matching a URL path no longer apply to them. Another example where filters might need adjustments are `resource.*` spans where their name now only includes the domain the resource was taken from. 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 a8ed2a8ad876..c0ab731ac838 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 @@ -151,7 +151,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow }, 'sentry.segment.name': { type: 'string', - value: '/index.html', + value: 'Navigation', }, 'sentry.segment.name.source': { type: 'string', @@ -177,7 +177,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', @@ -193,11 +195,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); @@ -207,9 +210,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', @@ -232,9 +239,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 d7004ac84aa1..72ce3a0fcf4d 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -33,6 +33,7 @@ import type { Integration, Span } from '@sentry/core'; import { debug, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, parseStringToURLObject, ROUTER_SPAN_NAME_FALLBACK, stripUrlQueryAndFragment, @@ -123,7 +124,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', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 7e6f93ec8736..68feaac0190f 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, @@ -632,7 +633,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', @@ -335,7 +337,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', @@ -423,7 +425,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, }), ); @@ -988,6 +992,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 34ec0384f3b9..7f0977073210 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, @@ -114,7 +115,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'; @@ -122,7 +126,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}`, [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', @@ -134,7 +138,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', @@ -151,8 +155,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( SENTRY_SEGMENT_NAME_SOURCE, parameterizedPathname ? 'route' : 'url', @@ -165,7 +172,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', [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', @@ -269,10 +276,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, [SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url', diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 44badab2e4d0..f61b50f13659 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, @@ -164,7 +165,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 cc4d60f081a4..798ae8efbfd4 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, spanToJSON, @@ -111,7 +113,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: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -154,7 +158,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: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -199,7 +205,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: { [SENTRY_SEGMENT_NAME_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 47562b183b80..2aceae6a5b32 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, spanToJSON, @@ -16,10 +18,10 @@ import { DEBUG_BUILD } from '../common/debug-build'; import { isClientInstrumentationApiUsed } from './createClientInstrumentation'; import { finalizeNavigationSpanFromRouterState, - getParameterizedRoute, normalizePathname, resolveNavigateAbsoluteUrl, resolveNavigateArg, + updateSpanWithParameterizedRoute, } from './utils'; import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; @@ -51,18 +53,13 @@ export function instrumentHydratedRouter(): void { // Matched against `url.path` rather than the span name: with span streaming, the pageload // span is named `Pageload` until a route is resolved, so the name may not hold the pathname. const pageloadPath = spanToJSON(pageloadSpan).attributes[URL_PATH]; - const parameterizePageloadRoute = getParameterizedRoute(router.state); if ( typeof pageloadPath === 'string' && // this event is for the currently active pageload normalizePathname(router.state.location.pathname) === normalizePathname(pageloadPath) ) { - pageloadSpan.updateName(parameterizePageloadRoute); - pageloadSpan.setAttributes({ - [SENTRY_SEGMENT_NAME_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react_router', - [URL_TEMPLATE]: parameterizePageloadRoute, - }); + pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.pageload.react_router'); + updateSpanWithParameterizedRoute(pageloadSpan, router.state); } } @@ -140,7 +137,6 @@ export function instrumentHydratedRouter(): void { } const rootSpanName = rootSpanJson.name; - const parameterizedRoute = getParameterizedRoute(newState); const spanPathname = rootSpanAttributes[URL_PATH] as string | undefined; const destinationPathname = normalizePathname(newState.location.pathname); @@ -151,11 +147,7 @@ export function instrumentHydratedRouter(): void { (destinationPathname === normalizePathname(rootSpanName) || (spanPathname && destinationPathname === normalizePathname(spanPathname))) ) { - rootSpan.updateName(parameterizedRoute); - rootSpan.setAttributes({ - [SENTRY_SEGMENT_NAME_SOURCE]: 'route', - [URL_TEMPLATE]: parameterizedRoute, - }); + updateSpanWithParameterizedRoute(rootSpan, newState); } }); return true; @@ -189,7 +181,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: { [SENTRY_SEGMENT_NAME_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 fab6bf827ad0..94044c096c94 100644 --- a/packages/react-router/src/client/utils.ts +++ b/packages/react-router/src/client/utils.ts @@ -1,6 +1,12 @@ import { getAbsoluteUrl } from '@sentry/browser'; import type { Span } from '@sentry/core'; -import { GLOBAL_OBJ, filterCollectedUrl } from '@sentry/core'; +import { + getClient, + GLOBAL_OBJ, + hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, + filterCollectedUrl, +} from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { DataRouter, RouterState } from 'react-router'; @@ -102,7 +108,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({ [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [URL_PATH]: pathname, @@ -118,9 +126,55 @@ export function normalizePathname(pathname: string): string { return normalized; } -export function getParameterizedRoute(routerState: RouterState): string { - const lastMatch = routerState.matches[routerState.matches.length - 1]; - return normalizePathname(lastMatch?.route.path || routerState.location.pathname); +/** + * The route template for the current match, or `undefined` when nothing matched. + * + * Built from the whole matched chain rather than the leaf alone: nested routes carry paths + * relative to their parent, and index and layout routes carry none at all, so the leaf on its own + * is either a fragment of the route (`edit`) or missing entirely. + */ +function getRouteTemplate(routerState: RouterState): string | undefined { + const { matches } = routerState; + + if (!matches.length) { + return undefined; + } + + let template = ''; + for (const match of matches) { + const routePath = match.route.path; + if (!routePath) { + continue; + } + + template = routePath.startsWith('/') ? routePath : `${template.replace(/\/$/, '')}/${routePath}`; + } + + // A chain without any path only ever matches the root. + return normalizePathname(template); +} + +/** + * Names a root span after the route the router matched. + * + * With span streaming there is nothing low cardinality to fall back to when nothing matched, so + * the span keeps the name it started with rather than taking on the raw URL. + */ +export function updateSpanWithParameterizedRoute(span: Span, routerState: RouterState): void { + const routeTemplate = getRouteTemplate(routerState); + const client = getClient(); + + if (!routeTemplate && client && hasSpanStreamingEnabled(client)) { + return; + } + + const parameterizedRoute = routeTemplate || normalizePathname(routerState.location.pathname); + + span.updateName(parameterizedRoute); + span.setAttributes({ + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [URL_TEMPLATE]: parameterizedRoute, + }); } /** @@ -144,12 +198,7 @@ export function finalizeNavigationSpanFromRouterState(span: Span, routerState: R routerState.navigation?.state === 'idle' && normalizePathname(routerState.location.pathname) === normalizePathname(pathname) ) { - const parameterizedRoute = getParameterizedRoute(routerState); - span.updateName(parameterizedRoute); - span.setAttributes({ - [SENTRY_SEGMENT_NAME_SOURCE]: 'route', - [URL_TEMPLATE]: parameterizedRoute, - }); + updateSpanWithParameterizedRoute(span, routerState); } } diff --git a/packages/react-router/test/client/createClientInstrumentation.test.ts b/packages/react-router/test/client/createClientInstrumentation.test.ts index 9bc1adec80af..5ab217787727 100644 --- a/packages/react-router/test/client/createClientInstrumentation.test.ts +++ b/packages/react-router/test/client/createClientInstrumentation.test.ts @@ -43,6 +43,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(); @@ -90,7 +93,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 = { @@ -114,7 +117,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/about', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.segment.name.source': 'url', 'sentry.op': 'navigation', @@ -130,7 +133,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 = { @@ -151,7 +154,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, expect.objectContaining({ - name: 'settings', + name: 'Navigation', }), { url: 'https://example.com/users/123/settings' }, ); @@ -160,7 +163,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 = { @@ -184,7 +187,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/items/123', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.segment.name.source': 'url', 'sentry.op': 'navigation', @@ -313,7 +316,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', @@ -408,7 +411,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(); @@ -457,7 +460,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); @@ -471,7 +474,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/current-page', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.segment.name.source': 'url', 'sentry.op': 'navigation', @@ -481,7 +484,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.segment.name.source': 'url', 'url.path': destination, @@ -494,7 +498,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 }); @@ -513,7 +517,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); @@ -540,7 +544,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(); @@ -559,7 +563,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; @@ -729,7 +733,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(); @@ -741,7 +745,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/current-page', + name: 'Navigation', attributes: expect.objectContaining({ 'sentry.segment.name.source': 'url', 'sentry.op': 'navigation', @@ -766,7 +770,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(), @@ -800,7 +804,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(); @@ -813,7 +817,7 @@ describe('createSentryClientInstrumentation', () => { expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( mockClient, { - name: '/current-page', + name: 'Navigation', attributes: expect.objectContaining({ 'navigation.type': 'browser.popstate', }), @@ -906,7 +910,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' } }); @@ -931,7 +935,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' } }); @@ -950,7 +954,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 844254dd6ccf..1f697f813101 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.segment.name.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.segment.name.source': 'url', diff --git a/packages/react-router/test/client/utils.test.ts b/packages/react-router/test/client/utils.test.ts index 8486b9c77be2..a91f880b8508 100644 --- a/packages/react-router/test/client/utils.test.ts +++ b/packages/react-router/test/client/utils.test.ts @@ -1,10 +1,20 @@ +import * as core from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { finalizeNavigationSpanFromRouterState, resolveNavigateAbsoluteUrl, updateNavigationSpanUrlFromLocation, + updateSpanWithParameterizedRoute, } from '../../src/client/utils'; +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getClient: vi.fn(), + }; +}); + vi.mock('@sentry/browser', () => ({ getAbsoluteUrl: vi.fn((urlOrPath: string) => { try { @@ -15,6 +25,17 @@ vi.mock('@sentry/browser', () => ({ }), })); +// Span streaming is the default trace lifecycle, and it's what makes span names low cardinality. +const streamingClient = { getOptions: () => ({ traceLifecycle: 'stream' }) } as unknown as core.Client; + +function mockSpan(): { updateName: ReturnType; setAttributes: ReturnType } { + return { updateName: vi.fn(), setAttributes: vi.fn() }; +} + +beforeEach(() => { + vi.mocked(core.getClient).mockReset(); +}); + describe('resolveNavigateAbsoluteUrl', () => { const originalLocation = globalThis.location; @@ -122,6 +143,75 @@ describe('updateNavigationSpanUrlFromLocation', () => { 'url.full': 'https://example.com/foo?bar=1#section', }); }); + + it('falls back to a low cardinality name when span streaming is enabled', () => { + vi.mocked(core.getClient).mockReturnValue(streamingClient); + const span = mockSpan() as any; + + updateNavigationSpanUrlFromLocation(span); + + // The URL stays on the attributes, only the name is low cardinality. + expect(span.updateName).toHaveBeenCalledWith('Navigation'); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'sentry.segment.name.source': 'url', + 'url.path': '/foo', + 'url.full': 'https://example.com/foo?bar=1#section', + }); + }); +}); + +describe('updateSpanWithParameterizedRoute', () => { + it.each([ + // Framework mode: `prefix()` flattens the path onto each child, so the leaf holds the full path. + ['a flat framework route', [{ path: '/' }, { path: 'performance/with/:param' }], '/performance/with/:param'], + ['a flat framework index route', [{ path: '/' }, { path: 'performance', index: true }], '/performance'], + // Library mode: nested route objects carry paths relative to their parent. + ['a nested index route', [{ path: '/' }, { path: 'users/:id' }, { index: true }], '/users/:id'], + ['a nested child route', [{ path: '/' }, { path: 'users/:id' }, { path: 'edit' }], '/users/:id/edit'], + ['a root index route', [{ path: '/' }, { index: true }], '/'], + ['a splat route', [{ path: '/' }, { path: '*' }], '/*'], + ])('names the span after the route template for %s', (_label, routes, expected) => { + const span = mockSpan() as any; + + updateSpanWithParameterizedRoute(span, { + location: { pathname: '/users/123/edit' }, + matches: routes.map(route => ({ route })), + } as any); + + expect(span.updateName).toHaveBeenCalledWith(expected); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'sentry.segment.name.source': 'route', + 'url.template': expected, + }); + }); + + it('keeps the low cardinality name when a streamed navigation matches no route', () => { + vi.mocked(core.getClient).mockReturnValue(streamingClient); + const span = mockSpan() as any; + + updateSpanWithParameterizedRoute(span, { + location: { pathname: '/users/123' }, + matches: [], + } as any); + + expect(span.updateName).not.toHaveBeenCalled(); + expect(span.setAttributes).not.toHaveBeenCalled(); + }); + + it('falls back to the raw pathname without span streaming', () => { + const span = mockSpan() as any; + + updateSpanWithParameterizedRoute(span, { + location: { pathname: '/users/123/' }, + matches: [], + } as any); + + expect(span.updateName).toHaveBeenCalledWith('/users/123'); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'sentry.segment.name.source': 'route', + 'url.template': '/users/123', + }); + }); }); describe('finalizeNavigationSpanFromRouterState', () => { @@ -150,7 +240,7 @@ describe('finalizeNavigationSpanFromRouterState', () => { finalizeNavigationSpanFromRouterState(span, { location: { pathname: '/performance/' }, - matches: [{ route: { path: '' } }], + matches: [{ route: { path: 'performance' } }, { route: { index: true } }], navigation: { state: 'idle' }, } as any); @@ -179,4 +269,23 @@ describe('finalizeNavigationSpanFromRouterState', () => { }); expect(span.setAttributes).toHaveBeenCalledTimes(1); }); + + it('keeps the low cardinality name when a streamed navigation matches no route', () => { + vi.mocked(core.getClient).mockReturnValue(streamingClient); + const span = mockSpan() as any; + + finalizeNavigationSpanFromRouterState(span, { + location: { pathname: '/performance/' }, + matches: [], + navigation: { state: 'idle' }, + } as any); + + expect(span.updateName).toHaveBeenCalledWith('Navigation'); + expect(span.updateName).toHaveBeenCalledTimes(1); + expect(span.setAttributes).toHaveBeenLastCalledWith({ + 'sentry.segment.name.source': 'url', + 'url.path': '/performance/', + 'url.full': 'https://example.com/performance/', + }); + }); }); diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 847ccfe295bd..6ecc0fcf31ee 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, @@ -397,7 +398,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(SENTRY_SEGMENT_NAME_SOURCE, source); if (source === 'route') { activeRootSpan.setAttribute(URL_TEMPLATE, name); @@ -995,8 +999,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(SENTRY_SEGMENT_NAME_SOURCE, source); if (source === 'route') { trackedNav.span.setAttribute(URL_TEMPLATE, name); @@ -1027,7 +1033,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: { [SENTRY_SEGMENT_NAME_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', @@ -1236,11 +1247,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(SENTRY_SEGMENT_NAME_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 e76e76578cc2..a0207725211b 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, @@ -176,7 +177,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 5f44d9df6f2a..2f15cf5f3fcd 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, @@ -87,7 +88,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 0c6c2afa8cf1..12d8cdeea087 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 } from '@sentry/core/browser'; import type { VendoredTanstackRouter, VendoredTanstackRouterRouteMatch } from './vendor/tanstackrouter-types'; import { @@ -137,7 +142,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. @@ -170,7 +178,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 4d93928b2d4d..82b0a4bb575d 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 5c3eac65cd9c..98c4da18df5a 100644 --- a/packages/react/test/reactrouterv4.test.tsx +++ b/packages/react/test/reactrouterv4.test.tsx @@ -128,7 +128,7 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -141,7 +141,7 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/features', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -200,7 +200,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -237,7 +237,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -282,7 +282,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/1234/v1/758', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', @@ -305,7 +305,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/543', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_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 8f43da5efbaf..489735c46d47 100644 --- a/packages/react/test/reactrouterv5.test.tsx +++ b/packages/react/test/reactrouterv5.test.tsx @@ -128,7 +128,7 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/about', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -141,7 +141,7 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/features', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -200,7 +200,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -237,7 +237,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/users/123', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -282,7 +282,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/1234/v1/758', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', @@ -305,7 +305,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/organizations/543', + name: 'Navigation', attributes: { [SENTRY_SEGMENT_NAME_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 c9d2ebc25a97..a38bebe49efb 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, @@ -123,7 +124,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 a2e3542c738f..3fec5dae34e0 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -17,6 +17,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, filterCollectedUrl, @@ -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 c586cd40ec46..b38da224dc7b 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, ROUTER_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -119,7 +120,10 @@ function _instrumentNavigations(client: Client, navigatingStore: Readable { ); }); + 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({ [SENTRY_SEGMENT_NAME_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 a0d2affddf39..2176ac3141bf 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -14,6 +14,7 @@ import { getCurrentScope, getRootSpan, hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, @@ -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 741f92d68ee9..9f916bf3abe0 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -16,6 +16,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 996477315953..ce3859d61121 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 } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, 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', + [SENTRY_SEGMENT_NAME_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