Skip to content

Commit 516a674

Browse files
chargomeclaude
andauthored
fix(nextjs): Keep router.back/forward navigation type under span streaming (#24074)
Under span streaming, `ignoreSpans` is applied at span start. The placeholder span that `router.back()`/`router.forward()` started in router-patch mode was therefore non-recording, and `popstate` created a second span tagged `browser.popstate` instead of renaming it. `back()`/`forward()` now record the router method and timestamp, and the `popstate` listener starts the navigation span with that `navigation.type` and `startTime`. The placeholder name and its `ignoreSpans` entry are removed, and the two E2E tests skipped in #23905 are re-enabled. Fixes #23909 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c3e0818 commit 516a674

5 files changed

Lines changed: 263 additions & 76 deletions

File tree

dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,7 @@ test('Creates a navigation span for `router.replace()`', async ({ page }) => {
8181
expect(await navigationSpanPromise).toBeDefined();
8282
});
8383

84-
// Skipped rather than relaxed to `browser.popstate`: under span streaming these navigations lose the
85-
// back/forward distinction, which looks like a regression rather than intended behaviour.
86-
// See https://github.com/getsentry/sentry-javascript/issues/23909
87-
test.skip('Creates a navigation span for `router.back()`', async ({ page }) => {
84+
test('Creates a navigation span for `router.back()`', async ({ page }) => {
8885
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
8986
return span.name === `/navigation/:param/router-back` && getSpanOp(span) === 'navigation';
9087
});
@@ -101,10 +98,7 @@ test.skip('Creates a navigation span for `router.back()`', async ({ page }) => {
10198
expect(navigationSpan.attributes['navigation.type']?.value).toMatch(/router\.(back|traverse)/);
10299
});
103100

104-
// Skipped rather than relaxed to `browser.popstate`: under span streaming these navigations lose the
105-
// back/forward distinction, which looks like a regression rather than intended behaviour.
106-
// See https://github.com/getsentry/sentry-javascript/issues/23909
107-
test.skip('Creates a navigation span for `router.forward()`', async ({ page }) => {
101+
test('Creates a navigation span for `router.forward()`', async ({ page }) => {
108102
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
109103
return (
110104
span.name === `/navigation/:param/router-push` &&

packages/nextjs/src/client/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { getClientVercelEnv } from '../common/getVercelEnv';
1111
import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
1212
import { browserTracingIntegration } from './browserTracingIntegration';
1313
import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration';
14-
import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation';
1514
import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing';
1615
import { applyTunnelRouteOption } from './tunnelRoute';
1716

@@ -74,12 +73,8 @@ export function init(options: BrowserOptions): Client | undefined {
7473

7574
opts.ignoreSpans = [
7675
...(opts.ignoreSpans || []),
77-
// we filter out segment spans for /404 pages
76+
// we filter out segment spans for /404 pages (exact match, so a string match isn't safe)
7877
/^\/404$/,
79-
// segment spans where we didn't get a reasonable transaction name
80-
// in this case, constructing a dynamic RegExp is fine because the variable is a constant
81-
// we need to ensure to exact-match, so a string match isn't safe (same for /404 above)
82-
new RegExp(`^${INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME}$`),
8378
];
8479

8580
const client = reactInit(opts);

packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
PAGELOAD_SPAN_NAME_FALLBACK,
77
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
88
filterCollectedUrl,
9+
timestampInSeconds,
910
} from '@sentry/core';
1011
import {
1112
startBrowserTracingNavigationSpan,
@@ -38,7 +39,44 @@ function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath:
3839
});
3940
}
4041

41-
export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction';
42+
/**
43+
* `router.back()` and `router.forward()` carry no destination, so their navigation span can only be
44+
* started once the resulting `popstate` event tells us where we ended up. Until then, this remembers
45+
* which router method triggered the traversal and when, so the span still gets the router's
46+
* navigation type and starts at the router call rather than at the `popstate`.
47+
*/
48+
interface PendingHistoryTraversal {
49+
navigationType: 'router.back' | 'router.forward';
50+
startTime: number;
51+
}
52+
53+
let pendingHistoryTraversal: PendingHistoryTraversal | undefined;
54+
let pendingHistoryTraversalTimeout: ReturnType<typeof setTimeout> | undefined;
55+
56+
/**
57+
* A `back()`/`forward()` without a matching history entry never fires `popstate`. Without an expiry,
58+
* a later unrelated `popstate` (e.g. the browser's back button) would be attributed to that stale
59+
* router call. Browsers dispatch the `popstate` of a same-document traversal within a few
60+
* milliseconds, so anything older than this is not the traversal we are waiting for. A timer rather
61+
* than a timestamp comparison keeps this tolerant of a blocked main thread, which delays the
62+
* `popstate` and the timer alike.
63+
*/
64+
const PENDING_HISTORY_TRAVERSAL_TIMEOUT_MS = 1000;
65+
66+
function setPendingHistoryTraversal(navigationType: PendingHistoryTraversal['navigationType']): void {
67+
clearTimeout(pendingHistoryTraversalTimeout);
68+
pendingHistoryTraversal = { navigationType, startTime: timestampInSeconds() };
69+
pendingHistoryTraversalTimeout = setTimeout(() => {
70+
pendingHistoryTraversal = undefined;
71+
}, PENDING_HISTORY_TRAVERSAL_TIMEOUT_MS);
72+
}
73+
74+
function takePendingHistoryTraversal(): PendingHistoryTraversal | undefined {
75+
clearTimeout(pendingHistoryTraversalTimeout);
76+
const traversal = pendingHistoryTraversal;
77+
pendingHistoryTraversal = undefined;
78+
return traversal;
79+
}
4280

4381
/**
4482
* This mutable keeps track of what router navigation instrumentation mechanism we are using.
@@ -164,7 +202,10 @@ export function appRouterInstrumentNavigation(client: Client): void {
164202
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
165203
const spanName =
166204
parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname);
167-
if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) {
205+
const traversal = takePendingHistoryTraversal();
206+
// A traversal triggered through the router always gets its own span: an open router-patch span
207+
// here would be a `push()`/`replace()` that the user navigated away from again.
208+
if (!traversal && currentRouterPatchingNavigationSpanRef.current?.isRecording()) {
168209
currentRouterPatchingNavigationSpanRef.current.updateName(spanName);
169210
currentRouterPatchingNavigationSpanRef.current.setAttribute(
170211
SENTRY_SEGMENT_NAME_SOURCE,
@@ -179,14 +220,17 @@ export function appRouterInstrumentNavigation(client: Client): void {
179220
client,
180221
{
181222
name: spanName,
223+
startTime: traversal?.startTime,
182224
attributes: {
183225
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
184226
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
185-
'navigation.type': 'browser.popstate',
227+
'navigation.type': traversal?.navigationType ?? 'browser.popstate',
186228
...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
187229
},
188230
},
189-
{ url: getAbsoluteUrl(pathname) },
231+
// The full location rather than just the pathname, so the span's `url.full` keeps the
232+
// (filtered) query string like the update path above does.
233+
{ url: WINDOW.location.href },
190234
);
191235
}
192236
});
@@ -252,56 +296,34 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
252296
return target.apply(thisArg, argArray);
253297
}
254298

255-
let transactionName = INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME;
256-
const transactionAttributes: Record<string, string> = {
257-
[SENTRY_OP]: NAVIGATION,
258-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
259-
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
260-
};
299+
if (routerFunctionName === 'back' || routerFunctionName === 'forward') {
300+
setPendingHistoryTraversal(`router.${routerFunctionName}`);
301+
return target.apply(thisArg, argArray);
302+
}
261303

262304
const href = argArray[0];
263305
const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath;
264306
const normalizedHref =
265307
basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href;
266-
if (routerFunctionName === 'push') {
267-
transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref));
268-
transactionAttributes['navigation.type'] = 'router.push';
269-
} else if (routerFunctionName === 'replace') {
270-
transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref));
271-
transactionAttributes['navigation.type'] = 'router.replace';
272-
} else if (routerFunctionName === 'back') {
273-
transactionAttributes['navigation.type'] = 'router.back';
274-
} else if (routerFunctionName === 'forward') {
275-
transactionAttributes['navigation.type'] = 'router.forward';
276-
}
277-
308+
const transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref));
278309
const parameterizedPathname = maybeParameterizeRoute(transactionName);
279310

280-
const navigationUrl =
281-
routerFunctionName === 'back' || routerFunctionName === 'forward'
282-
? undefined
283-
: getAbsoluteUrl(normalizedHref);
284-
285-
// The incomplete-instrumentation placeholder is a static name, so it is low cardinality
286-
// already, and keeping it is what makes the `ignoreSpans` entry filtering those spans match.
287-
const isPlaceholderName = transactionName === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME;
288-
289311
currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(
290312
client,
291313
{
292314
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
293315
name:
294316
parameterizedPathname ??
295-
(isPlaceholderName || !hasSpanStreamingEnabled(client)
296-
? transactionName
297-
: NAVIGATION_SPAN_NAME_FALLBACK),
317+
(hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : transactionName),
298318
attributes: {
299-
...transactionAttributes,
319+
[SENTRY_OP]: NAVIGATION,
320+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
300321
[SENTRY_SEGMENT_NAME_SOURCE]: parameterizedPathname ? 'route' : 'url',
322+
'navigation.type': `router.${routerFunctionName}`,
301323
...(parameterizedPathname && { [URL_TEMPLATE]: parameterizedPathname }),
302324
},
303325
},
304-
navigationUrl ? { url: navigationUrl } : undefined,
326+
{ url: getAbsoluteUrl(normalizedHref) },
305327
);
306328

307329
return target.apply(thisArg, argArray);

0 commit comments

Comments
 (0)