Skip to content

Conversation

@onurtemizkan
Copy link
Collaborator

@onurtemizkan onurtemizkan commented Nov 11, 2025

Building on top of #17962

Added a few more checks to make sure non-resolved (wildcard) routes are not reported in lazy route pageloads / navigations.

  • Improved patchSpanEnd with a user-configurable wait timeout for potentially slow route resolution. Named this option as maxLazyRouteWaitMs and it's defaulted as idleTimeout * 3. It may conditionally delay reporting (if the route resolution is still not done by the end of the timeout), but will prevent prematurely sent lazy-route transactions inside that window.
  • Added extra checks on updateNavigationSpan and handleNavigation for whether any wildcard still exists in a lazy-route, so they are still marked as open to full resolution. We keep track of pending lazy-route resolutions inside pendingLazyRouteLoads
  • Added a final attempt to update the transaction name with fully-resolved route when the pending resolution is done.

Any of these should not affect the behaviour of non-lazy route usage

@onurtemizkan onurtemizkan force-pushed the onur/more-guarding-against-wildcard-transactions branch from 7a6e14e to 46df23c Compare November 11, 2025 09:59
@github-actions
Copy link
Contributor

github-actions bot commented Nov 11, 2025

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

Scenario Requests/s % of Baseline Prev. Requests/s Change %
GET Baseline 9,126 - 8,506 +7%
GET With Sentry 1,407 15% 1,283 +10%
GET With Sentry (error only) 6,238 68% 5,918 +5%
POST Baseline 1,215 - 1,171 +4%
POST With Sentry 519 43% 497 +4%
POST With Sentry (error only) 1,064 88% 1,014 +5%
MYSQL Baseline 3,378 - 3,215 +5%
MYSQL With Sentry 503 15% 421 +19%
MYSQL With Sentry (error only) 2,770 82% 2,603 +6%

View base workflow run

@github-actions
Copy link
Contributor

github-actions bot commented Nov 11, 2025

size-limit report 📦

Path Size % Change Change
@sentry/browser 24.6 kB - -
@sentry/browser - with treeshaking flags 23.09 kB - -
@sentry/browser (incl. Tracing) 41.26 kB - -
@sentry/browser (incl. Tracing, Profiling) 45.53 kB - -
@sentry/browser (incl. Tracing, Replay) 79.73 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 69.4 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 84.42 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 96.58 kB - -
@sentry/browser (incl. Feedback) 41.27 kB - -
@sentry/browser (incl. sendFeedback) 29.27 kB - -
@sentry/browser (incl. FeedbackAsync) 34.2 kB - -
@sentry/react 26.29 kB - -
⛔️ @sentry/react (incl. Tracing) (max: 43.3 kB) 43.36 kB +0.32% +135 B 🔺
@sentry/vue 29.09 kB - -
@sentry/vue (incl. Tracing) 43.03 kB - -
@sentry/svelte 24.61 kB - -
CDN Bundle 26.9 kB - -
CDN Bundle (incl. Tracing) 41.81 kB - -
CDN Bundle (incl. Tracing, Replay) 78.33 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 83.81 kB - -
CDN Bundle - uncompressed 78.84 kB - -
CDN Bundle (incl. Tracing) - uncompressed 124 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 240.03 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 252.79 kB - -
@sentry/nextjs (client) 45.34 kB - -
@sentry/sveltekit (client) 41.64 kB - -
@sentry/node-core 50.86 kB - -
@sentry/node 158.04 kB - -
@sentry/node - without tracing 92.73 kB - -
@sentry/aws-serverless 106.5 kB +0.01% +2 B 🔺

View base workflow run

@onurtemizkan onurtemizkan force-pushed the onur/more-guarding-against-wildcard-transactions branch from 8468d79 to 179f040 Compare November 11, 2025 17:35
@onurtemizkan onurtemizkan force-pushed the onur/more-guarding-against-wildcard-transactions branch from 179f040 to c055e02 Compare November 12, 2025 02:10
@onurtemizkan onurtemizkan marked this pull request as ready for review November 12, 2025 02:10
@s1gr1d s1gr1d requested review from Lms24 and Copilot and removed request for Lms24 November 12, 2025 11:47
Copilot finished reviewing on behalf of s1gr1d November 12, 2025 12:32
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds additional guards against wildcard routes in lazy route transactions for React Router integrations. The main goal is to prevent premature reporting of transactions with unresolved (wildcard) route names by introducing a configurable wait timeout for lazy route resolution.

Key Changes:

  • Added transactionNameHasWildcard() utility function to detect wildcard characters in route names
  • Introduced maxLazyRouteWaitMs configuration option (defaults to idleTimeout * 3) to delay span finalization until lazy routes resolve
  • Enhanced updateNavigationSpan, handleNavigation, and patchSpanEnd with wildcard detection and route upgrade logic

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
packages/react/src/reactrouter-compat-utils/utils.ts Added transactionNameHasWildcard() utility function to detect wildcards in transaction names
packages/react/src/reactrouter-compat-utils/instrumentation.tsx Core implementation changes including wildcard checking, timeout handling, lazy route tracking, and span name upgrade logic
packages/react/src/reactrouter-compat-utils/index.ts Exported new transactionNameHasWildcard utility function
packages/react/test/reactrouter-compat-utils/utils.test.ts Added comprehensive test coverage for transactionNameHasWildcard() function
packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx Added tests for wildcard detection and source upgrade/downgrade scenarios in navigation spans

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 104 to 106
* Defaults to 3× the configured `idleTimeout`. Set to `0` to not wait at all.
*
* Default: idleTimeout * 3
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states "Defaults to 3× the configured idleTimeout. Set to 0 to not wait at all." However, setting it to 0 has special behavior (line 931), but the validation logic (line 524) would allow negative values like -1 through to be set if they're not exactly Infinity.

The documentation should clarify:

  • What happens with negative values (they fall back to default)
  • That Infinity means "wait forever"
  • That 0 means "don't wait"

Example:

/**
 * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names.
 *
 * - Set to `0` to not wait at all (immediate finalization)
 * - Set to `Infinity` to wait indefinitely
 * - Negative values will fall back to the default
 * 
 * Defaults to 3× the configured `idleTimeout` (default: 3000ms).
 */
Suggested change
* Defaults to 3× the configured `idleTimeout`. Set to `0` to not wait at all.
*
* Default: idleTimeout * 3
* - Set to `0` to not wait at all (immediate finalization)
* - Set to `Infinity` to wait indefinitely
* - Negative values will fall back to the default
*
* Defaults to 3× the configured `idleTimeout` (default: 3000ms).

Copilot uses AI. Check for mistakes.
Comment on lines 933 to 942
tryUpdateSpanNameBeforeEnd(
span,
spanToJSON(span),
spanToJSON(span).description,
location,
routes,
basename,
) as unknown as RouteMatch[];
spanType,
allRoutes,
);
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential issue where spanToJSON(span) is called three times in quick succession (lines 935-936, and then again at 964). This is inefficient and could lead to inconsistent data if the span changes between calls.

Consider capturing the result once and reusing it:

const currentSpanJson = spanToJSON(span);
tryUpdateSpanNameBeforeEnd(
  span,
  currentSpanJson,
  currentSpanJson.description,
  location,
  routes,
  basename,
  spanType,
  allRoutes,
);

Copilot uses AI. Check for mistakes.
// 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;
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The condition for determining if an update should occur is complex and might allow unintended updates. Specifically, on line 211, shouldUpdate will be true if currentNameHasWildcard is true, regardless of whether hasBeenNamed is true or forceUpdate is false.

This could cause issues because even if a span has been marked as finalized (hasBeenNamed = true) and forceUpdate = false, the function will still attempt updates if the name has wildcards. While this may be intentional for wildcard resolution, the logic on line 213 checks !spanJson.timestamp to prevent updates on ended spans, but doesn't check hasBeenNamed again in the context where currentNameHasWildcard triggered the update.

Consider refactoring for clarity:

const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard;

This makes it clearer that wildcards allow re-updating even after being named.

Suggested change
const shouldUpdate = forceUpdate || !hasBeenNamed || currentNameHasWildcard;
const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard;

Copilot uses AI. Check for mistakes.
Comment on lines 229 to 232
(!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
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isImprovement logic has a subtle issue on line 229. The condition !hasBeenNamed means "span not finalized", but this doesn't necessarily mean we should accept any name.

The comment says "accept any name" if not finalized, but consider this scenario:

  1. Span starts with name /users/:id (route source, not finalized yet)
  2. New resolution returns /users/123 (url source)
  3. Since !hasBeenNamed is true, it would accept this URL, downgrading from a parameterized route to a URL

This contradicts the comment on line 225 that says "Never downgrade from route source to url source". The condition should verify that we're not downgrading even when the span isn't finalized:

const isImprovement =
  name &&
  (!currentName || // No current name - always set
    (currentNameHasWildcard && source === 'route') || // Wildcard route → better route
    (currentSource !== 'route' && source === 'route') || // URL → route upgrade
    (!hasBeenNamed && currentSource !== 'route')); // Not finalized, but only if not already a route
Suggested change
(!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
(
!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
(!hasBeenNamed && currentSource !== 'route') // Not finalized, but only if not already a route
);

Copilot uses AI. Check for mistakes.
// 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);
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The logic on line 752 only updates the span if the current name has wildcards AND the new name doesn't. However, this misses a valid upgrade scenario:

If currentName = '/users/*' (wildcard) and name = '/users/*/profile/*' (still has wildcards but more specific), this condition would be false and no update occurs, even though the new name provides more specificity.

Consider updating the condition to allow upgrades between wildcard routes if the new route is more specific, or document why such updates are intentionally skipped.

Suggested change
const shouldUpdate = currentName && transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name);
const shouldUpdate =
currentName &&
(
// Upgrade from wildcard to non-wildcard
(transactionNameHasWildcard(currentName) && !transactionNameHasWildcard(name))
// Or upgrade to a more specific route, even if both have wildcards
|| isMoreSpecificRouteName(currentName, name)
);

Copilot uses AI. Check for mistakes.
const isImprovement =
name &&
(!currentName || // No current name - always set
(hasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source)
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Similar to the issue in updateNavigationSpan (lines 227-232), the isImprovement logic here has the same problem. Line 878 with condition !currentName means "always set if no current name", but this could allow a downgrade scenario:

  1. Current span has no description yet
  2. Initial resolution returns URL source /users/123
  3. Since !currentName is true, it accepts the URL
  4. Later, a better route source /users/:id becomes available but might not update

While this specific scenario might be less common, the logic should be consistent and prevent downgrades. Consider:

const isImprovement =
  name &&
  (!currentName || // No current name - always set
    (hasWildcard && source === 'route' && !transactionNameHasWildcard(name)) || // Wildcard → non-wildcard route
    (currentSource !== 'route' && source === 'route')); // URL → route upgrade

This ensures wildcard routes only upgrade to non-wildcard routes, making the improvement criteria more strict and predictable.

Suggested change
(hasWildcard && source === 'route') || // Wildcard route → better route (MUST stay in route source)
(hasWildcard && source === 'route' && !transactionNameHasWildcard(name)) || // Wildcard → non-wildcard route

Copilot uses AI. Check for mistakes.

/** Checks if transaction name has wildcard (/* or * or ends with *). */
export function transactionNameHasWildcard(name: string): boolean {
return name.includes('/*') || name === '*' || name.endsWith('*');
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition name.endsWith('*') is redundant because any string ending with * is already covered by name.includes('/*') (if it's /something*) or name === '*' (if it's just *). However, this logic fails to catch edge cases like /path*something or paths ending with * but not preceded by /.

Consider either:

  1. Removing the redundant name.endsWith('*') check since it's already covered
  2. Or if you want to catch all wildcards more broadly, use: name.includes('*') which will catch any wildcard character anywhere in the path

Based on the test cases, it seems the intent is to detect any wildcard (*) character in paths, so name.includes('*') would be the most accurate implementation.

Suggested change
return name.includes('/*') || name === '*' || name.endsWith('*');
return name.includes('*');

Copilot uses AI. Check for mistakes.
getGlobalPathname: vi.fn(() => '/test'),
routeIsDescendant: vi.fn(() => false),
transactionNameHasWildcard: vi.fn((name: string) => {
return name.includes('/*') || name === '*' || name.endsWith('*');
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The mock implementation duplicates the same potentially problematic logic from the actual implementation. The condition name.includes('/*') || name === '*' || name.endsWith('*') doesn't comprehensively catch all wildcards.

For consistency and maintainability, consider using the actual implementation instead of mocking it, or if mocking is necessary, simplify to: name.includes('*') to match any wildcard character in the path.

Suggested change
return name.includes('/*') || name === '*' || name.endsWith('*');
return name.includes('*');

Copilot uses AI. Check for mistakes.
DEBUG_BUILD &&
debug.warn('[React Router] maxLazyRouteWaitMs must be a number, falling back to default:', defaultMaxWait);
_maxLazyRouteWaitMs = defaultMaxWait;
} else if (configuredMaxWait < 0 && configuredMaxWait !== Infinity) {
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation condition configuredMaxWait < 0 && configuredMaxWait !== Infinity is problematic. This condition will never be true because:

  1. Infinity is positive (not less than 0)
  2. -Infinity would pass this check but should probably be rejected

The correct condition should be: configuredMaxWait < 0 (without the && configuredMaxWait !== Infinity part), since Infinity is never less than 0 anyway.

Suggested change
} else if (configuredMaxWait < 0 && configuredMaxWait !== Infinity) {
} else if (configuredMaxWait < 0) {

Copilot uses AI. Check for mistakes.
Comment on lines 961 to 970
tryUpdateSpanNameBeforeEnd(
span,
spanToJSON(span),
spanToJSON(span).description,
location,
routes,
currentAllRoutes.length > 0 ? currentAllRoutes : routes,
branches,
basename,
spanType,
allRoutes,
);
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the issue at lines 933-942, spanToJSON(span) is called twice here (lines 963 and 964). This is inefficient and could lead to inconsistent data.

Capture the result once and reuse it:

const currentSpanJson = spanToJSON(span);
tryUpdateSpanNameBeforeEnd(
  span,
  currentSpanJson,
  currentSpanJson.description,
  location,
  routes,
  basename,
  spanType,
  allRoutes,
);

Copilot uses AI. Check for mistakes.
*
* Default: idleTimeout * 3
*/
maxLazyRouteWaitMs?: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming suggestions here: lazyRouteTimeoutMs or even just lazyRouteTimeout as the JSDoc says it's milliseconds. That would align with the naming of idleTimeout

Comment on lines +918 to +921
if (endCalled) {
return;
}
endCalled = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just wondering why this might be called multiple times 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we're deferring a manually-triggered span.end when the lazy route resolution promise is resolved (or this new lazy-timeout is fired), if an idle timeout or a trigger like document.hidden triggers this beforehand, this saves us from running matchRoutes etc. again, before trying to run originalEnd which won't work anyway. I updated the comments for it.


if (branches) {
const [name, source] = resolveRouteNameAndSource(
// Take snapshot of current promises to wait for (prevents race conditions with new navigations)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting approach 😅 I guess if it works with existing setups, it's fine but it's a bit hard to grasp

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not that required actually, we're mapping the pending promises by their respective transaction anyway. Removed it 👍

@s1gr1d s1gr1d requested a review from chargome November 12, 2025 12:38
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}"`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Navigation Source Downgrade Violates Principle

In handleNavigation, the wildcard update logic allows downgrading from route source to URL source. When an existing navigation span has a wildcard route name like /users/*, it can be updated to a URL source name like /users/123. This violates the "never downgrade from route source to URL source" principle mentioned in updateNavigationSpan. The condition should check that source === 'route' before allowing the update, similar to the logic in updateNavigationSpan.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants