From 9652867552a8d18ee494f9f8f0fc834eb8c07288 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 00:30:33 +0000 Subject: [PATCH 1/5] ref(react): Add more guarding against leftover wildcards in lazy route transactions --- .../src/reactrouter-compat-utils/index.ts | 1 + .../instrumentation.tsx | 242 ++++++++++++------ .../src/reactrouter-compat-utils/utils.ts | 5 + .../instrumentation.test.tsx | 40 +++ .../reactrouter-compat-utils/utils.test.ts | 35 +++ yarn.lock | 46 ++++ 6 files changed, 296 insertions(+), 73 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index c2b56ec446fb..bb91ba8d3072 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -25,6 +25,7 @@ export { pathEndsWithWildcard, pathIsWildcardAndHasChildren, getNumberOfUrlSegments, + transactionNameHasWildcard, } from './utils'; // Lazy route exports diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a6e55f1a967c..e950b43d7a5f 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -41,7 +41,7 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { initializeRouterUtils, resolveRouteNameAndSource } from './utils'; +import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; @@ -52,6 +52,9 @@ let _enableAsyncRouteHandlers: boolean = false; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); +/** Delay (ms) for lazy route updates to complete before finalizing span names. */ +const LAZY_ROUTE_UPDATE_DELAY_MS = 50; + /** * Adds resolved routes as children to the parent route. * Prevents duplicate routes by checking if they already exist. @@ -102,6 +105,27 @@ type V6CompatibleVersion = '6' | '7'; // only exported for testing purposes export const allRoutes = new Set(); +/** Tracks pending lazy route loads per span to wait before finalizing span names. */ +const pendingLazyRouteLoads = new WeakMap>>(); + +/** Registers a pending lazy route load promise for a span. */ +function trackLazyRouteLoad(span: Span, promise: Promise): void { + let promises = pendingLazyRouteLoads.get(span); + if (!promises) { + promises = new Set(); + pendingLazyRouteLoads.set(span, promises); + } + promises.add(promise); + + // Clean up when promise resolves/rejects + promise.finally(() => { + const currentPromises = pendingLazyRouteLoads.get(span); + if (currentPromises) { + currentPromises.delete(promise); + } + }); +} + /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. */ @@ -166,12 +190,18 @@ export function updateNavigationSpan( forceUpdate = false, matchRoutes: MatchRoutes, ): void { - // Check if this span has already been named to avoid multiple updates - // But allow updates if this is a forced update (e.g., when lazy routes are loaded) - const hasBeenNamed = - !forceUpdate && (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const spanJson = spanToJSON(activeRootSpan); + const currentName = spanJson.description; - if (!hasBeenNamed) { + // Check if this span has already been named to avoid multiple updates + // But allow updates if: + // 1. This is a forced update (e.g., when lazy routes are loaded) + // 2. The current name has wildcards (incomplete parameterization) + const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName); + const shouldUpdate = forceUpdate || !hasBeenNamed || currentNameHasWildcard; + + if (shouldUpdate && !spanJson.timestamp) { // Get fresh branches for the current location with all loaded routes const currentBranches = matchRoutes(allRoutes, location); const [name, source] = resolveRouteNameAndSource( @@ -182,18 +212,20 @@ export function updateNavigationSpan( '', ); - // Only update if we have a valid name and the span hasn't finished - const spanJson = spanToJSON(activeRootSpan); - if (name && !spanJson.timestamp) { + // Only update if we have a valid name and it's better than what we have + const isImprovement = name && (!currentName || !name.includes('*')); + if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - // Mark this span as having its name set to prevent future updates - addNonEnumerableProperty( - activeRootSpan as { __sentry_navigation_name_set__?: boolean }, - '__sentry_navigation_name_set__', - true, - ); + // Only mark as finalized if the new name doesn't have wildcards + if (!transactionNameHasWildcard(name)) { + addNonEnumerableProperty( + activeRootSpan as { __sentry_navigation_name_set__?: boolean }, + '__sentry_navigation_name_set__', + true, + ); + } } } } @@ -568,6 +600,8 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const targetPath = (args as any)?.path; + const activeRootSpan = getActiveRootSpan(); + // For browser router, wrap the patch function to update span during patching if (!isMemoryRouter) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access @@ -576,10 +610,10 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (args as any).patch = (routeId: string, children: RouteObject[]) => { addRoutesToAllRoutes(children); - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { updateNavigationSpan( - activeRootSpan, + currentActiveRootSpan, { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), true, // forceUpdate = true since we're loading lazy routes @@ -591,33 +625,43 @@ function wrapPatchRoutesOnNavigation( } } - const result = await originalPatchRoutes(args); + // Create and track promise for this lazy load + const lazyLoadPromise = (async () => { + const result = await originalPatchRoutes(args); + + // Update navigation span after routes are patched + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { + // Determine pathname based on router type + let pathname: string | undefined; + if (isMemoryRouter) { + // For memory routers, only use targetPath + pathname = targetPath; + } else { + // For browser routers, use targetPath or fall back to window.location + pathname = targetPath || WINDOW.location?.pathname; + } - // Update navigation span after routes are patched - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { - // Determine pathname based on router type - let pathname: string | undefined; - if (isMemoryRouter) { - // For memory routers, only use targetPath - pathname = targetPath; - } else { - // For browser routers, use targetPath or fall back to window.location - pathname = targetPath || WINDOW.location?.pathname; + if (pathname) { + updateNavigationSpan( + currentActiveRootSpan, + { pathname, search: '', hash: '', state: null, key: 'default' }, + Array.from(allRoutes), + false, // forceUpdate = false since this is after lazy routes are loaded + _matchRoutes, + ); + } } - if (pathname) { - updateNavigationSpan( - activeRootSpan, - { pathname, search: '', hash: '', state: null, key: 'default' }, - Array.from(allRoutes), - false, // forceUpdate = false since this is after lazy routes are loaded - _matchRoutes, - ); - } + return result; + })(); + + // Track the promise if we have an active span + if (activeRootSpan) { + trackLazyRouteLoad(activeRootSpan, lazyLoadPromise); } - return result; + return lazyLoadPromise; }, }; } @@ -658,9 +702,20 @@ export function handleNavigation(opts: { const activeSpan = getActiveSpan(); const spanJson = activeSpan && spanToJSON(activeSpan); const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; + const currentName = spanJson?.description; + + // If we're already in a navigation span, check if we should update its name + if (isAlreadyInNavigationSpan && activeSpan) { + // Only update if the new name is better (doesn't have wildcards or is more complete) + const shouldUpdate = currentName && transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name); - // Cross usage can result in multiple navigation spans being created without this check - if (!isAlreadyInNavigationSpan) { + if (shouldUpdate) { + activeSpan.updateName(name); + activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${currentName}" to "${name}"`); + } + } else if (!isAlreadyInNavigationSpan) { + // Cross usage can result in multiple navigation spans being created without this check const navigationSpan = startBrowserTracingNavigationSpan(client, { name, attributes: { @@ -741,6 +796,50 @@ function updatePageloadTransaction({ } } +/** Updates span name before end using latest route information. */ +function tryUpdateSpanNameBeforeEnd( + span: Span, + spanJson: ReturnType, + currentName: string | undefined, + location: Location, + routes: RouteObject[], + basename: string | undefined, + spanType: 'pageload' | 'navigation', + allRoutes: Set, +): void { + try { + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + const hasWildcard = currentName && transactionNameHasWildcard(currentName); + + // Only attempt update if source is not 'route' or if the name has wildcards + if (currentSource === 'route' && !hasWildcard) { + return; + } + + const currentAllRoutes = Array.from(allRoutes); + const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes; + const branches = _matchRoutes(routesToUse, location, basename) as unknown as RouteMatch[]; + + if (!branches) { + return; + } + + const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); + + // Only update if we have a valid name and it's better than current + const isImprovement = name && (!currentName || hasWildcard); + const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; + + if (isImprovement && spanNotEnded) { + span.updateName(name); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } catch (error) { + // Silently catch errors to ensure span.end() is always called + DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + } +} + /** * Patches the span.end() method to update the transaction name one last time before the span is sent. * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading. @@ -762,42 +861,39 @@ function patchSpanEnd( const originalEnd = span.end.bind(span); + let endCalled = false; + span.end = function patchedEnd(...args) { - try { - // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet) - const spanJson = spanToJSON(span); - const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - if (currentSource !== 'route') { - // Last chance to update the transaction name with the latest route info - // Use the live global allRoutes Set to include any lazy routes loaded after patching - const currentAllRoutes = Array.from(allRoutes); - const branches = _matchRoutes( - currentAllRoutes.length > 0 ? currentAllRoutes : routes, + // Prevent multiple calls to end() + if (endCalled) { + return; + } + endCalled = true; + + const spanJson = spanToJSON(span); + const currentName = spanJson.description; + + // If we have pending lazy route loads and the current name has wildcards, delay the end slightly + const pendingPromises = pendingLazyRouteLoads.get(span); + if (pendingPromises && pendingPromises.size > 0 && currentName && transactionNameHasWildcard(currentName)) { + // Small delay to allow in-flight lazy routes to complete + setTimeout(() => { + tryUpdateSpanNameBeforeEnd( + span, + spanToJSON(span), + spanToJSON(span).description, location, + routes, basename, - ) as unknown as RouteMatch[]; - - if (branches) { - const [name, source] = resolveRouteNameAndSource( - location, - routes, - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - branches, - basename, - ); - - // Only update if we have a valid name - if (name && (spanType === 'pageload' || !spanJson.timestamp)) { - span.updateName(name); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - } - } - } - } catch (error) { - // Silently catch errors to ensure span.end() is always called - DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + spanType, + allRoutes, + ); + originalEnd(...args); + }, LAZY_ROUTE_UPDATE_DELAY_MS); + return; } + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); return originalEnd(...args); }; diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index d6501d0e4dbf..9de0cfe88d38 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -38,6 +38,11 @@ export function pathEndsWithWildcard(path: string): boolean { return path.endsWith('*'); } +/** Checks if transaction name has wildcard (/* or * or ends with *). */ +export function transactionNameHasWildcard(name: string): boolean { + return name.includes('/*') || name === '*' || name.endsWith('*'); +} + /** * Checks if a path is a wildcard and has child routes. */ diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index bad264d3d6b5..e6cf36f8883c 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -49,6 +49,9 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), routeIsDescendant: vi.fn(() => false), + transactionNameHasWildcard: vi.fn((name: string) => { + return name.includes('/*') || name === '*' || name.endsWith('*'); + }), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -370,3 +373,40 @@ describe('addRoutesToAllRoutes', () => { expect(firstCount).toBe(secondCount); }); }); + +describe('updateNavigationSpan with wildcard detection', () => { + const sampleLocation: Location = { + pathname: '/test', + search: '', + hash: '', + state: null, + key: 'default', + }; + + const sampleRoutes: RouteObject[] = [ + { path: '/', element:
Home
}, + { path: '/about', element:
About
}, + ]; + + const mockMatchRoutes = vi.fn(() => []); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call updateName when provided with valid routes', () => { + const testSpan = { ...mockSpan }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes); + + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should handle forced updates', () => { + const testSpan = { ...mockSpan, __sentry_navigation_name_set__: true }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, true, mockMatchRoutes); + + // Should update even though already named because forceUpdate=true + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 9ff48e7450bc..438b026104bd 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -9,6 +9,7 @@ import { prefixWithSlash, rebuildRoutePathFromAllRoutes, resolveRouteNameAndSource, + transactionNameHasWildcard, } from '../../src/reactrouter-compat-utils'; import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types'; @@ -629,4 +630,38 @@ describe('reactrouter-compat-utils/utils', () => { expect(result).toEqual(['/unknown', 'url']); }); }); + + describe('transactionNameHasWildcard', () => { + it('should detect wildcard at the end of path', () => { + expect(transactionNameHasWildcard('/lazy/*')).toBe(true); + expect(transactionNameHasWildcard('/users/:id/*')).toBe(true); + expect(transactionNameHasWildcard('/products/:category/*')).toBe(true); + }); + + it('should detect standalone wildcard', () => { + expect(transactionNameHasWildcard('*')).toBe(true); + }); + + it('should detect wildcard in the middle of path', () => { + expect(transactionNameHasWildcard('/lazy/*/nested')).toBe(true); + expect(transactionNameHasWildcard('/a/*/b/*/c')).toBe(true); + }); + + it('should not detect wildcards in parameterized routes', () => { + expect(transactionNameHasWildcard('/users/:id')).toBe(false); + expect(transactionNameHasWildcard('/products/:category/:id')).toBe(false); + expect(transactionNameHasWildcard('/items/:itemId/details')).toBe(false); + }); + + it('should not detect wildcards in static routes', () => { + expect(transactionNameHasWildcard('/')).toBe(false); + expect(transactionNameHasWildcard('/about')).toBe(false); + expect(transactionNameHasWildcard('/users/profile')).toBe(false); + }); + + it('should handle edge cases', () => { + expect(transactionNameHasWildcard('')).toBe(false); + expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*' + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 99e434f5efff..934e3f2dc2af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6943,6 +6943,20 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" +"@sentry-internal/browser-utils@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.23.0.tgz#738a07ed99168cdf69d0cdb5a152289ed049de81" + integrity sha512-FUak8FH51TnGrx2i31tgqun0VsbDCVQS7dxWnUZHdi+0hpnFoq9+wBHY+qrOQjaInZSz3crIifYv3z7SEzD0Jg== + dependencies: + "@sentry/core" "10.23.0" + +"@sentry-internal/feedback@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.23.0.tgz#4b9ade29f1d96309eea83cc513c4a73e3992c4d7" + integrity sha512-+HWC9VTPICsFX/lIPoBU9GxTaJZVXJcukP+qGxj+j/8q/Dy1w22JHDWcJbZiaW4kWWlz7VbA0KVKS3grD+e9aA== + dependencies: + "@sentry/core" "10.23.0" + "@sentry-internal/node-cpu-profiler@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz#0640d4aebb4d36031658ccff83dc22b76f437ede" @@ -6959,6 +6973,22 @@ detect-libc "^2.0.4" node-abi "^3.73.0" +"@sentry-internal/replay-canvas@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.23.0.tgz#236916fb9d40637d8c9f86c52b2b1619b1170854" + integrity sha512-GLNY8JPcMI6xhQ5FHiYO/W/3flrwZMt4CI/E3jDRNujYWbCrca60MRke6k7Zm1qi9rZ1FuhVWZ6BAFc4vwXnSg== + dependencies: + "@sentry-internal/replay" "10.23.0" + "@sentry/core" "10.23.0" + +"@sentry-internal/replay@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.23.0.tgz#7a6075e2c2e1d0a371764d7c2e5dad578bb7b1fe" + integrity sha512-5yPD7jVO2JY8+JEHXep0Bf/ugp4rmxv5BkHIcSAHQsKSPhziFks2x+KP+6M8hhbF1WydqAaDYlGjrkL2yspHqA== + dependencies: + "@sentry-internal/browser-utils" "10.23.0" + "@sentry/core" "10.23.0" + "@sentry-internal/rrdom@2.34.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.34.0.tgz#fccc9fe211c3995d4200abafbe8d75b671961ee9" @@ -7032,6 +7062,17 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== +"@sentry/browser@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.23.0.tgz#aa85f9c21c9a6c80b8952ee15307997fb34edbb3" + integrity sha512-9hViLfYONxRJykOhJQ3ZHQ758t1wQIsxEC7mTsydbDm+m12LgbBtXbfgcypWHlom5Yvb+wg6W+31bpdGnATglw== + dependencies: + "@sentry-internal/browser-utils" "10.23.0" + "@sentry-internal/feedback" "10.23.0" + "@sentry-internal/replay" "10.23.0" + "@sentry-internal/replay-canvas" "10.23.0" + "@sentry/core" "10.23.0" + "@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" @@ -7106,6 +7147,11 @@ "@sentry/cli-win32-i686" "2.56.0" "@sentry/cli-win32-x64" "2.56.0" +"@sentry/core@10.23.0": + version "10.23.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.23.0.tgz#7d4eb4d2c7b9ecc88872975a916f44e0b9fec78a" + integrity sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g== + "@sentry/rollup-plugin@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.3.0.tgz#d23fe49e48fa68dafa2b0933a8efabcc964b1df9" From 4eeaac91399056b9871c7f2f981ca1893cfa656a Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 11:58:36 +0000 Subject: [PATCH 2/5] Fix lockfile --- yarn.lock | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/yarn.lock b/yarn.lock index 934e3f2dc2af..99e434f5efff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6943,20 +6943,6 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" -"@sentry-internal/browser-utils@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.23.0.tgz#738a07ed99168cdf69d0cdb5a152289ed049de81" - integrity sha512-FUak8FH51TnGrx2i31tgqun0VsbDCVQS7dxWnUZHdi+0hpnFoq9+wBHY+qrOQjaInZSz3crIifYv3z7SEzD0Jg== - dependencies: - "@sentry/core" "10.23.0" - -"@sentry-internal/feedback@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.23.0.tgz#4b9ade29f1d96309eea83cc513c4a73e3992c4d7" - integrity sha512-+HWC9VTPICsFX/lIPoBU9GxTaJZVXJcukP+qGxj+j/8q/Dy1w22JHDWcJbZiaW4kWWlz7VbA0KVKS3grD+e9aA== - dependencies: - "@sentry/core" "10.23.0" - "@sentry-internal/node-cpu-profiler@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz#0640d4aebb4d36031658ccff83dc22b76f437ede" @@ -6973,22 +6959,6 @@ detect-libc "^2.0.4" node-abi "^3.73.0" -"@sentry-internal/replay-canvas@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.23.0.tgz#236916fb9d40637d8c9f86c52b2b1619b1170854" - integrity sha512-GLNY8JPcMI6xhQ5FHiYO/W/3flrwZMt4CI/E3jDRNujYWbCrca60MRke6k7Zm1qi9rZ1FuhVWZ6BAFc4vwXnSg== - dependencies: - "@sentry-internal/replay" "10.23.0" - "@sentry/core" "10.23.0" - -"@sentry-internal/replay@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.23.0.tgz#7a6075e2c2e1d0a371764d7c2e5dad578bb7b1fe" - integrity sha512-5yPD7jVO2JY8+JEHXep0Bf/ugp4rmxv5BkHIcSAHQsKSPhziFks2x+KP+6M8hhbF1WydqAaDYlGjrkL2yspHqA== - dependencies: - "@sentry-internal/browser-utils" "10.23.0" - "@sentry/core" "10.23.0" - "@sentry-internal/rrdom@2.34.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.34.0.tgz#fccc9fe211c3995d4200abafbe8d75b671961ee9" @@ -7062,17 +7032,6 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== -"@sentry/browser@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.23.0.tgz#aa85f9c21c9a6c80b8952ee15307997fb34edbb3" - integrity sha512-9hViLfYONxRJykOhJQ3ZHQ758t1wQIsxEC7mTsydbDm+m12LgbBtXbfgcypWHlom5Yvb+wg6W+31bpdGnATglw== - dependencies: - "@sentry-internal/browser-utils" "10.23.0" - "@sentry-internal/feedback" "10.23.0" - "@sentry-internal/replay" "10.23.0" - "@sentry-internal/replay-canvas" "10.23.0" - "@sentry/core" "10.23.0" - "@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" @@ -7147,11 +7106,6 @@ "@sentry/cli-win32-i686" "2.56.0" "@sentry/cli-win32-x64" "2.56.0" -"@sentry/core@10.23.0": - version "10.23.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.23.0.tgz#7d4eb4d2c7b9ecc88872975a916f44e0b9fec78a" - integrity sha512-4aZwu6VnSHWDplY5eFORcVymhfvS/P6BRfK81TPnG/ReELaeoykKjDwR+wC4lO7S0307Vib9JGpszjsEZw245g== - "@sentry/rollup-plugin@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.3.0.tgz#d23fe49e48fa68dafa2b0933a8efabcc964b1df9" From 4a73986baeaaf66222df35d9ae34a4b4ed18da13 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 16:10:34 +0000 Subject: [PATCH 3/5] Add configurable lazy route timeout and fix url paths --- .../instrumentation.tsx | 99 +++++++++- .../instrumentation.test.tsx | 177 +++++++++++++++++- 2 files changed, 267 insertions(+), 9 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index e950b43d7a5f..2aa538517c8e 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -52,8 +52,8 @@ let _enableAsyncRouteHandlers: boolean = false; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); -/** Delay (ms) for lazy route updates to complete before finalizing span names. */ -const LAZY_ROUTE_UPDATE_DELAY_MS = 50; +// Maximum time (ms) to wait for lazy routes (configured in setup(), default: idleTimeout * 3) +let _maxLazyRouteWaitMs = 3000; /** * Adds resolved routes as children to the parent route. @@ -97,6 +97,15 @@ export interface ReactRouterOptions { * @default false */ enableAsyncRouteHandlers?: boolean; + + /** + * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names. + * + * Defaults to 3× the configured `idleTimeout`. Set to `0` to not wait at all. + * + * Default: idleTimeout * 3 + */ + maxLazyRouteWaitMs?: number; } type V6CompatibleVersion = '6' | '7'; @@ -485,6 +494,7 @@ export function createReactRouterV6CompatibleTracingIntegration( enableAsyncRouteHandlers = false, instrumentPageLoad = true, instrumentNavigation = true, + maxLazyRouteWaitMs, } = options; return { @@ -492,6 +502,35 @@ export function createReactRouterV6CompatibleTracingIntegration( setup(client) { integration.setup(client); + // Get idleTimeout from browserTracingIntegration options (passed through) + // idleTimeout from browserTracingIntegration (default: 1000ms) + // Note: options already contains idleTimeout if user passed it to browserTracingIntegration + const idleTimeout = options.idleTimeout ?? 1000; + + // Calculate default: 3× idleTimeout + const defaultMaxWait = idleTimeout * 3; + + // Allow explicit override, otherwise use calculated default + const configuredMaxWait = maxLazyRouteWaitMs ?? defaultMaxWait; + + // Validate and set + if (Number.isNaN(configuredMaxWait)) { + DEBUG_BUILD && + debug.warn('[React Router] maxLazyRouteWaitMs must be a number, falling back to default:', defaultMaxWait); + _maxLazyRouteWaitMs = defaultMaxWait; + } else if (configuredMaxWait < 0 && configuredMaxWait !== Infinity) { + DEBUG_BUILD && + debug.warn( + '[React Router] maxLazyRouteWaitMs must be non-negative or Infinity, got:', + configuredMaxWait, + 'falling back to:', + defaultMaxWait, + ); + _maxLazyRouteWaitMs = defaultMaxWait; + } else { + _maxLazyRouteWaitMs = configuredMaxWait; + } + _useEffect = useEffect; _useLocation = useLocation; _useNavigationType = useNavigationType; @@ -827,7 +866,11 @@ function tryUpdateSpanNameBeforeEnd( const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); // Only update if we have a valid name and it's better than current - const isImprovement = name && (!currentName || hasWildcard); + // Upgrade conditions: + // 1. No current name exists + // 2. Current name has wildcards (less specific) + // 3. Upgrading from non-route source to route source (e.g., URL -> parameterized route) + const isImprovement = name && (!currentName || hasWildcard || (currentSource !== 'route' && source === 'route')); const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; if (isImprovement && spanNotEnded) { @@ -873,11 +916,13 @@ function patchSpanEnd( const spanJson = spanToJSON(span); const currentName = spanJson.description; - // If we have pending lazy route loads and the current name has wildcards, delay the end slightly + // If we have pending lazy route loads and the current name has wildcards, + // wait for promises to resolve (with timeout) before finalizing span const pendingPromises = pendingLazyRouteLoads.get(span); if (pendingPromises && pendingPromises.size > 0 && currentName && transactionNameHasWildcard(currentName)) { - // Small delay to allow in-flight lazy routes to complete - setTimeout(() => { + // Special case: 0 means don't wait at all (legacy behavior) + if (_maxLazyRouteWaitMs === 0) { + // Don't wait - immediately update and end span tryUpdateSpanNameBeforeEnd( span, spanToJSON(span), @@ -888,8 +933,46 @@ function patchSpanEnd( spanType, allRoutes, ); - originalEnd(...args); - }, LAZY_ROUTE_UPDATE_DELAY_MS); + return originalEnd(...args); + } + + // Take snapshot of current promises to wait for (prevents race conditions with new navigations) + const promiseArray = Array.from(pendingPromises); + + // Wait for all lazy routes to settle (never rejects, safe for all outcomes) + const settledPromise = Promise.allSettled(promiseArray).then(() => {}); + + // Create timeout promise to prevent hanging indefinitely + const timeoutPromise = new Promise(resolve => { + // Handle special case: Infinity means no timeout + if (_maxLazyRouteWaitMs === Infinity) { + // Don't resolve - wait indefinitely (user explicitly opted in) + return; + } + setTimeout(resolve, _maxLazyRouteWaitMs); + }); + + // Race: whichever completes first (routes resolve or timeout) + Promise.race([settledPromise, timeoutPromise]) + .then(() => { + // Try to update span name with (hopefully) resolved routes + tryUpdateSpanNameBeforeEnd( + span, + spanToJSON(span), + spanToJSON(span).description, + location, + routes, + basename, + spanType, + allRoutes, + ); + originalEnd(...args); + }) + .catch((error: unknown) => { + // Defensive: allSettled never rejects, but be safe + DEBUG_BUILD && debug.warn('Error waiting for lazy routes:', error); + originalEnd(...args); + }); return; } diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index e6cf36f8883c..107d499cdf44 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import type { Client, Span } from '@sentry/core'; -import { addNonEnumerableProperty } from '@sentry/core'; +import { addNonEnumerableProperty, spanToJSON } from '@sentry/core'; import * as React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -11,6 +11,7 @@ import { updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; import { addRoutesToAllRoutes, allRoutes } from '../../src/reactrouter-compat-utils/instrumentation'; +import { resolveRouteNameAndSource, transactionNameHasWildcard } from '../../src/reactrouter-compat-utils/utils'; import type { Location, RouteObject } from '../../src/types'; const mockUpdateName = vi.fn(); @@ -410,3 +411,177 @@ describe('updateNavigationSpan with wildcard detection', () => { expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); }); }); + +describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should upgrade from URL source to route source (regression fix)', async () => { + // Setup: Current span has URL source and non-parameterized name + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to route source with parameterized name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + // Simulate patchSpanEnd calling tryUpdateSpanNameBeforeEnd + // by updating the span name during a navigation + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from URL to route source + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should NOT downgrade from route source to URL source', async () => { + // Setup: Current span has route source with parameterized name (no wildcard) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/:id', + data: { 'sentry.source': 'route' }, + } as any); + + // Target: Would resolve to URL source (downgrade attempt) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/456', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark as already named + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/456', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should NOT update because span is already named + // The early return in tryUpdateSpanNameBeforeEnd (line 815) protects against downgrades + // This test verifies that route->url downgrades are blocked + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it('should upgrade wildcard names to specific routes', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection + vi.mocked(transactionNameHasWildcard).mockReturnValue(true); + + // Target: Resolves to specific parameterized route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from wildcard to specific + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should set name when no current name exists', async () => { + // Setup: Current span has no name (undefined) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: undefined, + } as any); + + // Target: Resolves to route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should set initial name + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not update when same source and no improvement', async () => { + // Setup: Current span has URL source + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to same URL source (no improvement) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Note: updateNavigationSpan always updates if not already named + // This test validates that the isImprovement logic works correctly in tryUpdateSpanNameBeforeEnd + // which is called during span.end() patching + expect(mockUpdateName).toHaveBeenCalled(); // Initial set is allowed + }); +}); From c055e02c5e859fe5391537af7ccb4842a6698800 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 11 Nov 2025 16:30:52 +0000 Subject: [PATCH 4/5] Optimize for bundle size --- .../instrumentation.tsx | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 2aa538517c8e..d66eecae9908 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -505,12 +505,8 @@ export function createReactRouterV6CompatibleTracingIntegration( // Get idleTimeout from browserTracingIntegration options (passed through) // idleTimeout from browserTracingIntegration (default: 1000ms) // Note: options already contains idleTimeout if user passed it to browserTracingIntegration - const idleTimeout = options.idleTimeout ?? 1000; - - // Calculate default: 3× idleTimeout - const defaultMaxWait = idleTimeout * 3; - - // Allow explicit override, otherwise use calculated default + // Calculate default: 3× idleTimeout, allow explicit override + const defaultMaxWait = (options.idleTimeout ?? 1000) * 3; const configuredMaxWait = maxLazyRouteWaitMs ?? defaultMaxWait; // Validate and set @@ -940,22 +936,17 @@ function patchSpanEnd( const promiseArray = Array.from(pendingPromises); // Wait for all lazy routes to settle (never rejects, safe for all outcomes) - const settledPromise = Promise.allSettled(promiseArray).then(() => {}); - - // Create timeout promise to prevent hanging indefinitely - const timeoutPromise = new Promise(resolve => { - // Handle special case: Infinity means no timeout - if (_maxLazyRouteWaitMs === Infinity) { - // Don't resolve - wait indefinitely (user explicitly opted in) - return; - } - setTimeout(resolve, _maxLazyRouteWaitMs); - }); + const allSettled = Promise.allSettled(promiseArray).then(() => {}); + + // Race against timeout or wait indefinitely if Infinity + const waitPromise = + _maxLazyRouteWaitMs === Infinity + ? allSettled + : Promise.race([allSettled, new Promise(r => setTimeout(r, _maxLazyRouteWaitMs))]); - // Race: whichever completes first (routes resolve or timeout) - Promise.race([settledPromise, timeoutPromise]) + // Update span name once routes are resolved or timeout expires + waitPromise .then(() => { - // Try to update span name with (hopefully) resolved routes tryUpdateSpanNameBeforeEnd( span, spanToJSON(span), @@ -968,9 +959,8 @@ function patchSpanEnd( ); originalEnd(...args); }) - .catch((error: unknown) => { - // Defensive: allSettled never rejects, but be safe - DEBUG_BUILD && debug.warn('Error waiting for lazy routes:', error); + .catch(() => { + // Defensive: should never happen with allSettled, but satisfy ESLint originalEnd(...args); }); return; From f65ebba27739ff74c618a26169e8c90ab677ef25 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 12 Nov 2025 09:35:22 +0000 Subject: [PATCH 5/5] Do not downgrade from wildcard route to unparameterized route --- .../instrumentation.tsx | 17 +++++-- .../instrumentation.test.tsx | 49 +++++++++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index d66eecae9908..cb87d8394764 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -222,7 +222,14 @@ export function updateNavigationSpan( ); // Only update if we have a valid name and it's better than what we have - const isImprovement = name && (!currentName || !name.includes('*')); + // Never downgrade from route source to url source + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + const isImprovement = + name && + (!hasBeenNamed || // Span not finalized - accept any name + !currentName || // No current name - always set + (currentNameHasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source) + (currentSource !== 'route' && source === 'route')); // URL → route upgrade if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); @@ -864,9 +871,13 @@ function tryUpdateSpanNameBeforeEnd( // Only update if we have a valid name and it's better than current // Upgrade conditions: // 1. No current name exists - // 2. Current name has wildcards (less specific) + // 2. Current name has wildcards and new source is also 'route' (never downgrade route→url) // 3. Upgrading from non-route source to route source (e.g., URL -> parameterized route) - const isImprovement = name && (!currentName || hasWildcard || (currentSource !== 'route' && source === 'route')); + const isImprovement = + name && + (!currentName || // No current name - always set + (hasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source) + (currentSource !== 'route' && source === 'route')); // URL → route upgrade const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; if (isImprovement && spanNotEnded) { diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 107d499cdf44..c1626637daee 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -451,7 +451,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); }); - it('should NOT downgrade from route source to URL source', async () => { + it('should not downgrade from route source to URL source', async () => { // Setup: Current span has route source with parameterized name (no wildcard) vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation', @@ -479,7 +479,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { vi.fn(() => [{ route: { path: '/users/:id' } }]), ); - // Should NOT update because span is already named + // Should not update because span is already named // The early return in tryUpdateSpanNameBeforeEnd (line 815) protects against downgrades // This test verifies that route->url downgrades are blocked expect(mockUpdateName).not.toHaveBeenCalled(); @@ -494,8 +494,10 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { data: { 'sentry.source': 'route' }, } as any); - // Mock wildcard detection - vi.mocked(transactionNameHasWildcard).mockReturnValue(true); + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current name has wildcard + }); // Target: Resolves to specific parameterized route vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); @@ -521,6 +523,45 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); }); + it('should not downgrade from wildcard route to URL', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current wildcard name returns true + }); + + // Target: After timeout, resolves to URL (lazy route didn't finish loading) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark span as already named/finalized + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/*', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/*' } }]), + ); + + // Should not update - keep wildcard route instead of downgrading to URL + // Wildcard routes are better than URLs for aggregation in performance monitoring + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + it('should set name when no current name exists', async () => { // Setup: Current span has no name (undefined) vi.mocked(spanToJSON).mockReturnValue({