Skip to content

Commit 9e477cd

Browse files
authored
feat(nextjs): Add captureRouterTransitionStart hook for capturing navigations (#15981)
We relied on Next.js internals for navigation spans and that inevitably broke so Next.js added a `onRouterTransitionStart` hook in `instrumentation-client.ts` (vercel/next.js#77791) for us to use. This PR exposes a handler called `captureRouterTransitionStart` for that hook so that we can actually instrument navigations again.
1 parent c922bcc commit 9e477cd

File tree

10 files changed

+120
-23
lines changed

10 files changed

+120
-23
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use client';
2-
31
import * as Sentry from '@sentry/nextjs';
42

53
Sentry.init({
@@ -9,3 +7,5 @@ Sentry.init({
97
tracesSampleRate: 1.0,
108
sendDefaultPii: true,
119
});
10+
11+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use client';
2-
31
import * as Sentry from '@sentry/nextjs';
42

53
Sentry.init({
@@ -9,3 +7,5 @@ Sentry.init({
97
tracesSampleRate: 1.0,
108
sendDefaultPii: true,
119
});
10+
11+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use client';
2-
31
import * as Sentry from '@sentry/nextjs';
42

53
Sentry.init({
@@ -9,3 +7,5 @@ Sentry.init({
97
tracesSampleRate: 1.0,
108
sendDefaultPii: true,
119
});
10+
11+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use client';
2-
31
import * as Sentry from '@sentry/nextjs';
42

53
Sentry.init({
@@ -9,3 +7,5 @@ Sentry.init({
97
tracesSampleRate: 1.0,
108
sendDefaultPii: true,
119
});
10+
11+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

dev-packages/e2e-tests/test-applications/nextjs-app-dir/next-env.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
/// <reference types="next/navigation-types/compat/navigation" />
44

55
// NOTE: This file should not be edited
6-
// see https://nextjs.org/docs/basic-features/typescript for more information.
6+
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

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

+7-4
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ test('Creates a navigation transaction for `router.back()`', async ({ page }) =>
106106
contexts: {
107107
trace: {
108108
data: {
109-
'navigation.type': 'router.back',
109+
'navigation.type': expect.stringMatching(/router\.(back|traverse)/), // back is Next.js < 15.3.0, traverse >= 15.3.0
110110
},
111111
},
112112
},
@@ -118,7 +118,8 @@ test('Creates a navigation transaction for `router.forward()`', async ({ page })
118118
return (
119119
transactionEvent?.transaction === `/navigation/42/router-push` &&
120120
transactionEvent.contexts?.trace?.op === 'navigation' &&
121-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward'
121+
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' ||
122+
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
122123
);
123124
});
124125

@@ -169,7 +170,8 @@ test('Creates a navigation transaction for browser-back', async ({ page }) => {
169170
return (
170171
transactionEvent?.transaction === `/navigation/42/browser-back` &&
171172
transactionEvent.contexts?.trace?.op === 'navigation' &&
172-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate'
173+
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' ||
174+
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
173175
);
174176
});
175177

@@ -187,7 +189,8 @@ test('Creates a navigation transaction for browser-forward', async ({ page }) =>
187189
return (
188190
transactionEvent?.transaction === `/navigation/42/router-push` &&
189191
transactionEvent.contexts?.trace?.op === 'navigation' &&
190-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate'
192+
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' ||
193+
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
191194
);
192195
});
193196

dev-packages/e2e-tests/test-applications/nextjs-turbo/instrumentation-client.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ Sentry.init({
77
tracesSampleRate: 1.0,
88
sendDefaultPii: true,
99
});
10+
11+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

packages/nextjs/src/client/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export * from '@sentry/react';
1616
export * from '../common';
1717
export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error';
1818
export { browserTracingIntegration } from './browserTracingIntegration';
19+
export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrumentation';
1920

2021
let clientIsInitialized = false;
2122

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

+69-10
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@ import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadS
99

1010
export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction';
1111

12+
/**
13+
* This mutable keeps track of what router navigation instrumentation mechanism we are using.
14+
*
15+
* The default one is 'router-patch' which is a way of instrumenting that worked up until Next.js 15.3.0 was released.
16+
* For this method we took the global router instance and simply monkey patched all the router methods like push(), replace(), and so on.
17+
* This worked because Next.js itself called the router methods for things like the <Link /> component.
18+
* Vercel decided that it is not good to call these public API methods from within the framework so they switched to an internal system that completely bypasses our monkey patching. This happened in 15.3.0.
19+
*
20+
* We raised with Vercel that this breaks our SDK so together with them we came up with an API for `instrumentation-client.ts` called `onRouterTransitionStart` that is called whenever a navigation is kicked off.
21+
*
22+
* Now we have the problem of version compatibility.
23+
* For older Next.js versions we cannot use the new hook so we need to always patch the router.
24+
* For newer Next.js versions we cannot know whether the user actually registered our handler for the `onRouterTransitionStart` hook, so we need to wait until it was called at least once before switching the instrumentation mechanism.
25+
* The problem is, that the user may still have registered a hook and then call a patched router method.
26+
* First, the monkey patched router method will be called, starting a navigation span, then the hook will also called.
27+
* We need to handle this case and not create two separate navigation spans but instead update the current navigation span and then switch to the new instrumentation mode.
28+
* This is all denoted by this `navigationRoutingMode` variable.
29+
*/
30+
let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-patch';
31+
32+
const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined };
33+
1234
/** Instruments the Next.js app router for pageloads. */
1335
export function appRouterInstrumentPageLoad(client: Client): void {
1436
const origin = browserPerformanceTimeOrigin();
@@ -61,17 +83,41 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
6183

6284
/** Instruments the Next.js app router for navigation. */
6385
export function appRouterInstrumentNavigation(client: Client): void {
64-
const currentNavigationSpanRef: NavigationSpanRef = { current: undefined };
86+
routerTransitionHandler = (href, navigationType) => {
87+
const pathname = new URL(href, WINDOW.location.href).pathname;
88+
89+
if (navigationRoutingMode === 'router-patch') {
90+
navigationRoutingMode = 'transition-start-hook';
91+
}
92+
93+
const currentNavigationSpan = currentRouterPatchingNavigationSpanRef.current;
94+
if (currentNavigationSpan) {
95+
currentNavigationSpan.updateName(pathname);
96+
currentNavigationSpan.setAttributes({
97+
'navigation.type': `router.${navigationType}`,
98+
});
99+
currentRouterPatchingNavigationSpanRef.current = undefined;
100+
} else {
101+
startBrowserTracingNavigationSpan(client, {
102+
name: pathname,
103+
attributes: {
104+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
105+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
106+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
107+
'navigation.type': `router.${navigationType}`,
108+
},
109+
});
110+
}
111+
};
65112

66113
WINDOW.addEventListener('popstate', () => {
67-
if (currentNavigationSpanRef.current?.isRecording()) {
68-
currentNavigationSpanRef.current.updateName(WINDOW.location.pathname);
69-
currentNavigationSpanRef.current.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url');
114+
if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) {
115+
currentRouterPatchingNavigationSpanRef.current.updateName(WINDOW.location.pathname);
116+
currentRouterPatchingNavigationSpanRef.current.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url');
70117
} else {
71-
currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, {
118+
currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, {
72119
name: WINDOW.location.pathname,
73120
attributes: {
74-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
75121
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
76122
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
77123
'navigation.type': 'browser.popstate',
@@ -94,7 +140,7 @@ export function appRouterInstrumentNavigation(client: Client): void {
94140
clearInterval(checkForRouterAvailabilityInterval);
95141
routerPatched = true;
96142

97-
patchRouter(client, router, currentNavigationSpanRef);
143+
patchRouter(client, router, currentRouterPatchingNavigationSpanRef);
98144

99145
// If the router at any point gets overridden - patch again
100146
(['nd', 'next'] as const).forEach(globalValueName => {
@@ -103,7 +149,7 @@ export function appRouterInstrumentNavigation(client: Client): void {
103149
GLOBAL_OBJ_WITH_NEXT_ROUTER[globalValueName] = new Proxy(globalValue, {
104150
set(target, p, newValue) {
105151
if (p === 'router' && typeof newValue === 'object' && newValue !== null) {
106-
patchRouter(client, newValue, currentNavigationSpanRef);
152+
patchRouter(client, newValue, currentRouterPatchingNavigationSpanRef);
107153
}
108154

109155
// @ts-expect-error we cannot possibly type this
@@ -139,6 +185,10 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
139185
// @ts-expect-error Weird type error related to not knowing how to associate return values with the individual functions - we can just ignore
140186
router[routerFunctionName] = new Proxy(router[routerFunctionName], {
141187
apply(target, thisArg, argArray) {
188+
if (navigationRoutingMode !== 'router-patch') {
189+
return target.apply(thisArg, argArray);
190+
}
191+
142192
let transactionName = INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME;
143193
const transactionAttributes: Record<string, string> = {
144194
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
@@ -148,11 +198,9 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
148198

149199
if (routerFunctionName === 'push') {
150200
transactionName = transactionNameifyRouterArgument(argArray[0]);
151-
transactionAttributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'url';
152201
transactionAttributes['navigation.type'] = 'router.push';
153202
} else if (routerFunctionName === 'replace') {
154203
transactionName = transactionNameifyRouterArgument(argArray[0]);
155-
transactionAttributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'url';
156204
transactionAttributes['navigation.type'] = 'router.replace';
157205
} else if (routerFunctionName === 'back') {
158206
transactionAttributes['navigation.type'] = 'router.back';
@@ -171,3 +219,14 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
171219
}
172220
});
173221
}
222+
223+
let routerTransitionHandler: undefined | ((href: string, navigationType: string) => void) = undefined;
224+
225+
/**
226+
* A handler for Next.js' `onRouterTransitionStart` hook in `instrumentation-client.ts` to record navigation spans in Sentry.
227+
*/
228+
export function captureRouterTransitionStart(href: string, navigationType: string): void {
229+
if (routerTransitionHandler) {
230+
routerTransitionHandler(href, navigationType);
231+
}
232+
}

packages/nextjs/src/config/withSentryConfig.ts

+32
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
/* eslint-disable complexity */
23
import { isThenable, parseSemver } from '@sentry/core';
34

@@ -12,6 +13,8 @@ import type {
1213
} from './types';
1314
import { constructWebpackConfigFunction } from './webpack';
1415
import { getNextjsVersion } from './util';
16+
import * as fs from 'fs';
17+
import * as path from 'path';
1518

1619
let showedExportModeTunnelWarning = false;
1720

@@ -155,6 +158,18 @@ function getFinalConfigObject(
155158
}
156159
}
157160

161+
// We wanna check whether the user added a `onRouterTransitionStart` handler to their client instrumentation file.
162+
const instrumentationClientFileContents = getInstrumentationClientFileContents();
163+
if (
164+
instrumentationClientFileContents !== undefined &&
165+
!instrumentationClientFileContents.includes('onRouterTransitionStart')
166+
) {
167+
// eslint-disable-next-line no-console
168+
console.warn(
169+
'[@sentry/nextjs] ACTION REQUIRED: To instrument navigations, the Sentry SDK requires you to export an `onRouterTransitionStart` hook from your `instrumentation-client.(js|ts)` file. You can do so by adding `export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;` to the file.',
170+
);
171+
}
172+
158173
if (nextJsVersion) {
159174
const { major, minor, patch, prerelease } = parseSemver(nextJsVersion);
160175
const isSupportedVersion =
@@ -343,3 +358,20 @@ function getGitRevision(): string | undefined {
343358
}
344359
return gitRevision;
345360
}
361+
362+
function getInstrumentationClientFileContents(): string | void {
363+
const potentialInstrumentationClientFileLocations = [
364+
['src', 'instrumentation-client.ts'],
365+
['src', 'instrumentation-client.js'],
366+
['instrumentation-client.ts'],
367+
['instrumentation-client.ts'],
368+
];
369+
370+
for (const pathSegments of potentialInstrumentationClientFileLocations) {
371+
try {
372+
return fs.readFileSync(path.join(process.cwd(), ...pathSegments), 'utf-8');
373+
} catch {
374+
// noop
375+
}
376+
}
377+
}

0 commit comments

Comments
 (0)