Skip to content

Commit 7a6e14e

Browse files
committed
ref(react): Add more guarding against leftover wildcards in lazy route transactions
1 parent 02625ef commit 7a6e14e

File tree

6 files changed

+296
-73
lines changed

6 files changed

+296
-73
lines changed

packages/react/src/reactrouter-compat-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {
2525
pathEndsWithWildcard,
2626
pathIsWildcardAndHasChildren,
2727
getNumberOfUrlSegments,
28+
transactionNameHasWildcard,
2829
} from './utils';
2930

3031
// Lazy route exports

packages/react/src/reactrouter-compat-utils/instrumentation.tsx

Lines changed: 169 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import type {
4141
UseRoutes,
4242
} from '../types';
4343
import { checkRouteForAsyncHandler } from './lazy-routes';
44-
import { initializeRouterUtils, resolveRouteNameAndSource } from './utils';
44+
import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils';
4545

4646
let _useEffect: UseEffect;
4747
let _useLocation: UseLocation;
@@ -52,6 +52,9 @@ let _enableAsyncRouteHandlers: boolean = false;
5252

5353
const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet<Client>();
5454

55+
/** Delay (ms) for lazy route updates to complete before finalizing span names. */
56+
const LAZY_ROUTE_UPDATE_DELAY_MS = 50;
57+
5558
/**
5659
* Adds resolved routes as children to the parent route.
5760
* Prevents duplicate routes by checking if they already exist.
@@ -102,6 +105,27 @@ type V6CompatibleVersion = '6' | '7';
102105
// only exported for testing purposes
103106
export const allRoutes = new Set<RouteObject>();
104107

108+
/** Tracks pending lazy route loads per span to wait before finalizing span names. */
109+
const pendingLazyRouteLoads = new WeakMap<Span, Set<Promise<unknown>>>();
110+
111+
/** Registers a pending lazy route load promise for a span. */
112+
function trackLazyRouteLoad(span: Span, promise: Promise<unknown>): void {
113+
let promises = pendingLazyRouteLoads.get(span);
114+
if (!promises) {
115+
promises = new Set();
116+
pendingLazyRouteLoads.set(span, promises);
117+
}
118+
promises.add(promise);
119+
120+
// Clean up when promise resolves/rejects
121+
promise.finally(() => {
122+
const currentPromises = pendingLazyRouteLoads.get(span);
123+
if (currentPromises) {
124+
currentPromises.delete(promise);
125+
}
126+
});
127+
}
128+
105129
/**
106130
* Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
107131
*/
@@ -166,12 +190,18 @@ export function updateNavigationSpan(
166190
forceUpdate = false,
167191
matchRoutes: MatchRoutes,
168192
): void {
169-
// Check if this span has already been named to avoid multiple updates
170-
// But allow updates if this is a forced update (e.g., when lazy routes are loaded)
171-
const hasBeenNamed =
172-
!forceUpdate && (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__;
193+
const spanJson = spanToJSON(activeRootSpan);
194+
const currentName = spanJson.description;
173195

174-
if (!hasBeenNamed) {
196+
// Check if this span has already been named to avoid multiple updates
197+
// But allow updates if:
198+
// 1. This is a forced update (e.g., when lazy routes are loaded)
199+
// 2. The current name has wildcards (incomplete parameterization)
200+
const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__;
201+
const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName);
202+
const shouldUpdate = forceUpdate || !hasBeenNamed || currentNameHasWildcard;
203+
204+
if (shouldUpdate && !spanJson.timestamp) {
175205
// Get fresh branches for the current location with all loaded routes
176206
const currentBranches = matchRoutes(allRoutes, location);
177207
const [name, source] = resolveRouteNameAndSource(
@@ -182,18 +212,20 @@ export function updateNavigationSpan(
182212
'',
183213
);
184214

185-
// Only update if we have a valid name and the span hasn't finished
186-
const spanJson = spanToJSON(activeRootSpan);
187-
if (name && !spanJson.timestamp) {
215+
// Only update if we have a valid name and it's better than what we have
216+
const isImprovement = name && (!currentName || !name.includes('*'));
217+
if (isImprovement) {
188218
activeRootSpan.updateName(name);
189219
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
190220

191-
// Mark this span as having its name set to prevent future updates
192-
addNonEnumerableProperty(
193-
activeRootSpan as { __sentry_navigation_name_set__?: boolean },
194-
'__sentry_navigation_name_set__',
195-
true,
196-
);
221+
// Only mark as finalized if the new name doesn't have wildcards
222+
if (!transactionNameHasWildcard(name)) {
223+
addNonEnumerableProperty(
224+
activeRootSpan as { __sentry_navigation_name_set__?: boolean },
225+
'__sentry_navigation_name_set__',
226+
true,
227+
);
228+
}
197229
}
198230
}
199231
}
@@ -568,6 +600,8 @@ function wrapPatchRoutesOnNavigation(
568600
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
569601
const targetPath = (args as any)?.path;
570602

603+
const activeRootSpan = getActiveRootSpan();
604+
571605
// For browser router, wrap the patch function to update span during patching
572606
if (!isMemoryRouter) {
573607
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
@@ -576,10 +610,10 @@ function wrapPatchRoutesOnNavigation(
576610
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
577611
(args as any).patch = (routeId: string, children: RouteObject[]) => {
578612
addRoutesToAllRoutes(children);
579-
const activeRootSpan = getActiveRootSpan();
580-
if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') {
613+
const currentActiveRootSpan = getActiveRootSpan();
614+
if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
581615
updateNavigationSpan(
582-
activeRootSpan,
616+
currentActiveRootSpan,
583617
{ pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
584618
Array.from(allRoutes),
585619
true, // forceUpdate = true since we're loading lazy routes
@@ -591,33 +625,43 @@ function wrapPatchRoutesOnNavigation(
591625
}
592626
}
593627

594-
const result = await originalPatchRoutes(args);
628+
// Create and track promise for this lazy load
629+
const lazyLoadPromise = (async () => {
630+
const result = await originalPatchRoutes(args);
631+
632+
// Update navigation span after routes are patched
633+
const currentActiveRootSpan = getActiveRootSpan();
634+
if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
635+
// Determine pathname based on router type
636+
let pathname: string | undefined;
637+
if (isMemoryRouter) {
638+
// For memory routers, only use targetPath
639+
pathname = targetPath;
640+
} else {
641+
// For browser routers, use targetPath or fall back to window.location
642+
pathname = targetPath || WINDOW.location?.pathname;
643+
}
595644

596-
// Update navigation span after routes are patched
597-
const activeRootSpan = getActiveRootSpan();
598-
if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') {
599-
// Determine pathname based on router type
600-
let pathname: string | undefined;
601-
if (isMemoryRouter) {
602-
// For memory routers, only use targetPath
603-
pathname = targetPath;
604-
} else {
605-
// For browser routers, use targetPath or fall back to window.location
606-
pathname = targetPath || WINDOW.location?.pathname;
645+
if (pathname) {
646+
updateNavigationSpan(
647+
currentActiveRootSpan,
648+
{ pathname, search: '', hash: '', state: null, key: 'default' },
649+
Array.from(allRoutes),
650+
false, // forceUpdate = false since this is after lazy routes are loaded
651+
_matchRoutes,
652+
);
653+
}
607654
}
608655

609-
if (pathname) {
610-
updateNavigationSpan(
611-
activeRootSpan,
612-
{ pathname, search: '', hash: '', state: null, key: 'default' },
613-
Array.from(allRoutes),
614-
false, // forceUpdate = false since this is after lazy routes are loaded
615-
_matchRoutes,
616-
);
617-
}
656+
return result;
657+
})();
658+
659+
// Track the promise if we have an active span
660+
if (activeRootSpan) {
661+
trackLazyRouteLoad(activeRootSpan, lazyLoadPromise);
618662
}
619663

620-
return result;
664+
return lazyLoadPromise;
621665
},
622666
};
623667
}
@@ -658,9 +702,20 @@ export function handleNavigation(opts: {
658702
const activeSpan = getActiveSpan();
659703
const spanJson = activeSpan && spanToJSON(activeSpan);
660704
const isAlreadyInNavigationSpan = spanJson?.op === 'navigation';
705+
const currentName = spanJson?.description;
706+
707+
// If we're already in a navigation span, check if we should update its name
708+
if (isAlreadyInNavigationSpan && activeSpan) {
709+
// Only update if the new name is better (doesn't have wildcards or is more complete)
710+
const shouldUpdate = currentName && transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name);
661711

662-
// Cross usage can result in multiple navigation spans being created without this check
663-
if (!isAlreadyInNavigationSpan) {
712+
if (shouldUpdate) {
713+
activeSpan.updateName(name);
714+
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom');
715+
DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${currentName}" to "${name}"`);
716+
}
717+
} else if (!isAlreadyInNavigationSpan) {
718+
// Cross usage can result in multiple navigation spans being created without this check
664719
const navigationSpan = startBrowserTracingNavigationSpan(client, {
665720
name,
666721
attributes: {
@@ -741,6 +796,50 @@ function updatePageloadTransaction({
741796
}
742797
}
743798

799+
/** Updates span name before end using latest route information. */
800+
function tryUpdateSpanNameBeforeEnd(
801+
span: Span,
802+
spanJson: ReturnType<typeof spanToJSON>,
803+
currentName: string | undefined,
804+
location: Location,
805+
routes: RouteObject[],
806+
basename: string | undefined,
807+
spanType: 'pageload' | 'navigation',
808+
allRoutes: Set<RouteObject>,
809+
): void {
810+
try {
811+
const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
812+
const hasWildcard = currentName && transactionNameHasWildcard(currentName);
813+
814+
// Only attempt update if source is not 'route' or if the name has wildcards
815+
if (currentSource === 'route' && !hasWildcard) {
816+
return;
817+
}
818+
819+
const currentAllRoutes = Array.from(allRoutes);
820+
const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes;
821+
const branches = _matchRoutes(routesToUse, location, basename) as unknown as RouteMatch[];
822+
823+
if (!branches) {
824+
return;
825+
}
826+
827+
const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename);
828+
829+
// Only update if we have a valid name and it's better than current
830+
const isImprovement = name && (!currentName || hasWildcard);
831+
const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp;
832+
833+
if (isImprovement && spanNotEnded) {
834+
span.updateName(name);
835+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
836+
}
837+
} catch (error) {
838+
// Silently catch errors to ensure span.end() is always called
839+
DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`);
840+
}
841+
}
842+
744843
/**
745844
* Patches the span.end() method to update the transaction name one last time before the span is sent.
746845
* 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(
762861

763862
const originalEnd = span.end.bind(span);
764863

864+
let endCalled = false;
865+
765866
span.end = function patchedEnd(...args) {
766-
try {
767-
// Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet)
768-
const spanJson = spanToJSON(span);
769-
const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE];
770-
if (currentSource !== 'route') {
771-
// Last chance to update the transaction name with the latest route info
772-
// Use the live global allRoutes Set to include any lazy routes loaded after patching
773-
const currentAllRoutes = Array.from(allRoutes);
774-
const branches = _matchRoutes(
775-
currentAllRoutes.length > 0 ? currentAllRoutes : routes,
867+
// Prevent multiple calls to end()
868+
if (endCalled) {
869+
return;
870+
}
871+
endCalled = true;
872+
873+
const spanJson = spanToJSON(span);
874+
const currentName = spanJson.description;
875+
876+
// If we have pending lazy route loads and the current name has wildcards, delay the end slightly
877+
const pendingPromises = pendingLazyRouteLoads.get(span);
878+
if (pendingPromises && pendingPromises.size > 0 && currentName && transactionNameHasWildcard(currentName)) {
879+
// Small delay to allow in-flight lazy routes to complete
880+
setTimeout(() => {
881+
tryUpdateSpanNameBeforeEnd(
882+
span,
883+
spanToJSON(span),
884+
spanToJSON(span).description,
776885
location,
886+
routes,
777887
basename,
778-
) as unknown as RouteMatch[];
779-
780-
if (branches) {
781-
const [name, source] = resolveRouteNameAndSource(
782-
location,
783-
routes,
784-
currentAllRoutes.length > 0 ? currentAllRoutes : routes,
785-
branches,
786-
basename,
787-
);
788-
789-
// Only update if we have a valid name
790-
if (name && (spanType === 'pageload' || !spanJson.timestamp)) {
791-
span.updateName(name);
792-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
793-
}
794-
}
795-
}
796-
} catch (error) {
797-
// Silently catch errors to ensure span.end() is always called
798-
DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`);
888+
spanType,
889+
allRoutes,
890+
);
891+
originalEnd(...args);
892+
}, LAZY_ROUTE_UPDATE_DELAY_MS);
893+
return;
799894
}
800895

896+
tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes);
801897
return originalEnd(...args);
802898
};
803899

packages/react/src/reactrouter-compat-utils/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export function pathEndsWithWildcard(path: string): boolean {
3838
return path.endsWith('*');
3939
}
4040

41+
/** Checks if transaction name has wildcard (/* or * or ends with *). */
42+
export function transactionNameHasWildcard(name: string): boolean {
43+
return name.includes('/*') || name === '*' || name.endsWith('*');
44+
}
45+
4146
/**
4247
* Checks if a path is a wildcard and has child routes.
4348
*/

0 commit comments

Comments
 (0)