Skip to content

feat(tracing): Wrap Expo Router push, replace, navigate, back, dismiss in addition to prefetch#6221

Draft
alwx wants to merge 2 commits into
mainfrom
alwx/feature/wrap-expo-router-methods
Draft

feat(tracing): Wrap Expo Router push, replace, navigate, back, dismiss in addition to prefetch#6221
alwx wants to merge 2 commits into
mainfrom
alwx/feature/wrap-expo-router-methods

Conversation

@alwx
Copy link
Copy Markdown
Contributor

@alwx alwx commented May 27, 2026

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Extends the Expo Router instrumentation beyond prefetch so that programmatic navigation calls produce breadcrumbs and spans, and the resulting idle navigation transaction can be attributed back to the call that triggered it.

What changed:

  • wrapExpoRouter now wraps push, replace, navigate, back, and dismiss in addition to prefetch.
  • Each wrapped call:
    • emits a navigation breadcrumb (category: 'navigation', data.method, data.pathname),
    • opens a short-lived navigation.<method> span tagged with sentry.origin = auto.navigation.expo_router, navigation.method, and route.name,
    • stores a pending hand-off (pendingExpoRouterNavigation) that the next React Navigation idle navigation span consumes to set its own navigation.method attribute, attributing the transaction back to the originating call.
  • A single __sentryWrapped flag guards against double-wrapping if wrapExpoRouter is called multiple times on the same router.
  • PII handling mirrors reactnavigation.ts: raw href and route params (which can encode user identifiers like /users/123 or { id: '123' }) are only attached to breadcrumbs and span attributes when sendDefaultPii is enabled. method, pathname (the route template), and route.name remain unconditional so navigations stay visible and groupable.

💡 Motivation and Context

Fixes #6158.

Previously only prefetch was instrumented, so any programmatic router.push(...) / router.replace(...) / router.back() etc. was invisible in Sentry — no breadcrumb trail, no span, and the resulting React Navigation idle transaction had no way to know which call site initiated it. This PR closes that gap while keeping the existing prefetch behavior intact and aligning PII handling with the rest of the navigation tracing.

💚 How did you test it?

  • Added unit tests in packages/core/test/tracing/expoRouter.test.ts covering:
    • prefetch (string href, object href, object without pathname, sync + async success, sync + async errors),
    • push / replace / navigate via describe.each — span and breadcrumb shape with string and object hrefs, error reporting, and the pending-navigation hand-off,
    • back() and dismiss(count) with no/optional args,
    • double-wrap idempotency,
    • PII gating: default-off assertions (no href, no params) and dedicated sendDefaultPii: true cases that verify href and params reappear on both the breadcrumb and the span.
  • Full JS test suite: yarn jest — 113 suites / 1481 tests passing.
  • Linters: yarn lint:lerna clean.
  • API report: yarn api-report:check up to date.

📝 Checklist

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPii is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

🔮 Next steps

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • feat(tracing): Wrap Expo Router push, replace, navigate, back, dismiss in addition to prefetch by alwx in #6221
  • chore(deps): update Android SDK to v8.43.0 by github-actions in #6224
  • chore(deps): update Cocoa SDK to v9.15.0 by github-actions in #6223
  • fix(ci): Remove @sentry/types from JS SDK updater script by antonis in #6219
  • feat(tracing): Add breadcrumbs for dispatched React Navigation events by antonis in #6218
  • feat(tracing): Add Sentry.NavigationContainer wrapper for React Navigation by antonis in #6199
  • chore(e2e): Update Expo sample to SDK 56 by antonis in #6216
  • feat(ios): opt-in consumption of sentry-cocoa via Swift Package Manager by alwx in #6182
  • chore(deps): bump getsentry/craft from 2.26.5 to 2.26.6 by dependabot in #6213
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.26.5 to 2.26.6 by dependabot in #6214
  • chore(deps): bump github/codeql-action from 4.35.5 to 4.36.0 by dependabot in #6215
  • fix(ios): Return NO from requiresMainQueueSetup by antonis in #6202
  • fix(tracing): Bound TTID/TTFD to prevent inflated transactions by antonis in #6210
  • feat(core): Add disableAutoUpload option to Expo plugin by antonis in #6195
  • chore(deps): Remove unused @sentry/types dependency by antonis in #6207
  • Correct route and dynamic param extraction for Expo Router (Correct route and dynamic param extraction for Expo Router #6157) by alwx in #6197
  • chore(deps): update CLI to v3.4.3 by github-actions in #6205
  • chore(deps): update Cocoa SDK to v9.14.0 by github-actions in #6204
  • chore(deps): update Maestro to v2.6.0 by github-actions in #6198
  • chore(deps): bump js-cookie from 3.0.5 to 3.0.7 by dependabot in #6203
  • chore(deps): bump @tootallnate/once from 2.0.0 to 2.0.1 by dependabot in #6206
  • chore(deps): update Sentry Android Gradle Plugin to v6.8.1 by github-actions in #6196
  • feat(core): Expose pauseAppHangTracking and resumeAppHangTracking APIs by antonis in #6192

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

Fails
🚫 Pull request is not ready for merge, please add the "ready-to-merge" label to the pull request
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 6d90951

Comment thread packages/core/src/js/tracing/expoRouter.ts
@alwx alwx changed the title Expo Router: wrap push, replace, navigate, back, dismiss in addition to prefetch WIP: Expo Router: wrap push, replace, navigate, back, dismiss in addition to prefetch May 28, 2026
…nd sendDefaultPii

Expo Router `push`/`replace`/`navigate`/`back`/`dismiss` (and `prefetch`)
wrappers previously emitted the raw `href` and route `params` on both
the navigation breadcrumb and span attributes regardless of the client
options. Dynamic segments like `/users/123` and parameter values such
as `{ id: '123' }` can carry user identifiers and other PII, so the
existing `reactnavigation.ts` integration already gates them behind
`sendDefaultPii`. The new Expo Router wrappers should follow the same
contract.

This change:

- Reads `sendDefaultPii` from the client options and only attaches
  `href` (raw string or stringified object form) and `params` to the
  breadcrumb `data` and span attributes when it is enabled.
- Keeps the non-PII fields — `method`, `pathname` (route template),
  and `route.name` — unconditional so navigations remain visible and
  groupable in Sentry without leaking user data.
- Applies the same guard to the `prefetch` span's `route.href`
  attribute, which was newly added in this PR.
- Adds dedicated `sendDefaultPii` test cases and updates the existing
  default-off assertions for all wrapped methods.
- Adds a changelog entry for #6221.
@alwx alwx changed the title WIP: Expo Router: wrap push, replace, navigate, back, dismiss in addition to prefetch feat(tracing): Wrap Expo Router push, replace, navigate, back, dismiss in addition to prefetch May 28, 2026
Comment on lines +60 to +74
router.push = wrapNavigationMethod(router, 'push', router.push.bind(router));
}
if (router.replace) {
router.replace = wrapNavigationMethod(router, 'replace', router.replace.bind(router));
}
if (router.navigate) {
router.navigate = wrapNavigationMethod(router, 'navigate', router.navigate.bind(router));
}
if (router.back) {
router.back = wrapNavigationMethod(router, 'back', router.back.bind(router)) as NonNullable<T['back']>;
}
if (router.dismiss) {
const originalDismiss = router.dismiss.bind(router) as (...args: unknown[]) => unknown;
router.dismiss = wrapNavigationMethod(router, 'dismiss', originalDismiss) as NonNullable<T['dismiss']>;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Navigation pathname leaks PII to Sentry regardless of sendDefaultPii setting

In wrapNavigationMethod, parsed.pathname is unconditionally included in breadcrumb messages, breadcrumb data.pathname, span names, and route.name attributes — but for string hrefs (e.g. router.push('/users/42')), parseHref sets pathname === href, so the full resolved path is always sent to Sentry even when sendDefaultPii is false, defeating the guard that protects href and params.

Evidence
  • parseHref(href: string) returns { href, routeName: href, pathname: href } — all three fields hold the identical value (e.g. /users/42).
  • wrapNavigationMethod guards parsed.href and parsed.params behind isSendDefaultPiiEnabled(), but parsed.pathname (same value for string hrefs) is written to the breadcrumb message (to ${parsed.pathname}), data.pathname, span name, and route.name with no guard at all.
  • The code comment explicitly labels /users/42 as PII and states it should be gated, yet pathname — which equals that value — bypasses the gate entirely.
  • wrapPrefetch has the same issue: route.name is always set to routeName which equals the full string href.

Identified by Warden security-review · VL9-XWJ

Comment on lines +150 to +175
setPendingExpoRouterNavigation({
method,
href: parsed.href,
pathname: parsed.pathname,
params: parsed.params,
});

const span = startInactiveSpan({
op: `navigation.${method}`,
name: `Navigation ${method}${parsed.pathname ? ` to ${parsed.pathname}` : ''}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION,
'navigation.method': method,
...(parsed.routeName ? { 'route.name': parsed.routeName } : undefined),
...(sendPii && parsed.href !== undefined ? { 'route.href': serializeHref(parsed.href) } : undefined),
},
});

try {
const result = original.apply(router, args);
span?.setStatus({ code: SPAN_STATUS_OK });
span?.end();
return result;
} catch (error) {
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
span?.end();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale pending navigation set before original.apply() can corrupt next navigation's span attributes

If original.apply(router, args) throws synchronously (e.g. invalid route, navigation not ready), setPendingExpoRouterNavigation has already stored the failed call's data. The catch block does not clear it, so the next successful navigation will have consumePendingExpoRouterNavigation() return this stale value and incorrectly tag that span with the failed method's navigation.method and route info.

Evidence
  • setPendingExpoRouterNavigation is called unconditionally at line 150, before the try block at line 168.
  • original.apply(router, args) at line 169 can throw synchronously (e.g. bad route arg).
  • The catch block at lines 173–177 only records the span error and re-throws; it never calls clearPendingExpoRouterNavigation() or otherwise resets the pending value.
  • In reactnavigation.ts line 456, consumePendingExpoRouterNavigation() is called on the next navigation state change, where it would dequeue the stale value and wrongly attribute the new span to the failed navigation.
  • The error-path test in expoRouter.test.ts does not assert that the pending value is cleared after a throw, confirming this is unguarded.
Also found at 2 additional locations
  • packages/core/src/js/tracing/expoRouter.ts:59-74
  • packages/core/src/js/tracing/reactnavigation.ts:437-440

Identified by Warden code-review · X3Y-GQX

Comment on lines +145 to +164
...(sendPii && parsed.href !== undefined ? { href: serializeHref(parsed.href) } : undefined),
...(sendPii && parsed.params ? { params: parsed.params } : undefined),
},
});

setPendingExpoRouterNavigation({
method,
href: parsed.href,
pathname: parsed.pathname,
params: parsed.params,
});

const span = startInactiveSpan({
op: `navigation.${method}`,
name: `Navigation ${method}${parsed.pathname ? ` to ${parsed.pathname}` : ''}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION,
'navigation.method': method,
...(parsed.routeName ? { 'route.name': parsed.routeName } : undefined),
...(sendPii && parsed.href !== undefined ? { 'route.href': serializeHref(parsed.href) } : undefined),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

JSON.stringify in serializeHref can throw and abort navigation before the original method is called

When sendDefaultPii is enabled and href.params contains a non-serializable value (e.g. BigInt, Symbol, or a circular reference), serializeHref throws a TypeError from JSON.stringify. Both call sites (inside addBreadcrumb and startInactiveSpan) are executed before the try { original.apply(router, args) } block, so the exception propagates uncaught and the original navigation method is never invoked — silently dropping the navigation.

Evidence
  • params in ExpoRouterHref and ParsedHref is typed Record<string, unknown>, allowing any value including non-serializable types.
  • serializeHref (line 204) calls JSON.stringify(href) with no try/catch; BigInt/Symbol/circular refs all throw TypeError.
  • Lines 145 and 164 call serializeHref inside addBreadcrumb and startInactiveSpan argument objects, both located before the try block at line 170 that wraps original.apply(router, args).
  • If either throws, execution exits the wrapper without ever reaching original.apply, silently killing the navigation.
  • No existing test covers a non-serializable param value with sendDefaultPii enabled.

Identified by Warden find-bugs · 9U9-FWQ

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.

Wrap push, replace, navigate, back, dismiss in addition to prefetch

1 participant