Skip to content

feat(nextjs): Add captureRouterTransitionStart hook for capturing navigations #15981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client';

Copy link
Member

Choose a reason for hiding this comment

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

lol i guess that makes sense

import * as Sentry from '@sentry/nextjs';

Sentry.init({
Expand All @@ -9,3 +7,5 @@ Sentry.init({
tracesSampleRate: 1.0,
sendDefaultPii: true,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client';

import * as Sentry from '@sentry/nextjs';

Sentry.init({
Expand All @@ -9,3 +7,5 @@ Sentry.init({
tracesSampleRate: 1.0,
sendDefaultPii: true,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client';

import * as Sentry from '@sentry/nextjs';

Sentry.init({
Expand All @@ -9,3 +7,5 @@ Sentry.init({
tracesSampleRate: 1.0,
sendDefaultPii: true,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client';

import * as Sentry from '@sentry/nextjs';

Sentry.init({
Expand All @@ -9,3 +7,5 @@ Sentry.init({
tracesSampleRate: 1.0,
sendDefaultPii: true,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
/// <reference types="next/navigation-types/compat/navigation" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ test('Creates a navigation transaction for `router.back()`', async ({ page }) =>
contexts: {
trace: {
data: {
'navigation.type': 'router.back',
'navigation.type': expect.stringMatching(/router\.(back|traverse)/), // back is Next.js < 15.3.0, traverse >= 15.3.0
},
},
},
Expand All @@ -118,7 +118,8 @@ test('Creates a navigation transaction for `router.forward()`', async ({ page })
return (
transactionEvent?.transaction === `/navigation/42/router-push` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward'
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' ||
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
);
});

Expand Down Expand Up @@ -169,7 +170,8 @@ test('Creates a navigation transaction for browser-back', async ({ page }) => {
return (
transactionEvent?.transaction === `/navigation/42/browser-back` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate'
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' ||
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
);
});

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ Sentry.init({
tracesSampleRate: 1.0,
sendDefaultPii: true,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
1 change: 1 addition & 0 deletions packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from '@sentry/react';
export * from '../common';
export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error';
export { browserTracingIntegration } from './browserTracingIntegration';
export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrumentation';

let clientIsInitialized = false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadS

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

/**
* This mutable keeps track of what router navigation instrumentation mechanism we are using.
*
* The default one is 'router-patch' which is a way of instrumenting that worked up until Next.js 15.3.0 was released.
* For this method we took the global router instance and simply monkey patched all the router methods like push(), replace(), and so on.
* This worked because Next.js itself called the router methods for things like the <Link /> component.
* 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.
*
* 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.
*
* Now we have the problem of version compatibility.
* For older Next.js versions we cannot use the new hook so we need to always patch the router.
* 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.
* The problem is, that the user may still have registered a hook and then call a patched router method.
* First, the monkey patched router method will be called, starting a navigation span, then the hook will also called.
* 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.
* This is all denoted by this `navigationRoutingMode` variable.
*/
let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-patch';

const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined };

/** Instruments the Next.js app router for pageloads. */
export function appRouterInstrumentPageLoad(client: Client): void {
const origin = browserPerformanceTimeOrigin();
Expand Down Expand Up @@ -61,17 +83,41 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {

/** Instruments the Next.js app router for navigation. */
export function appRouterInstrumentNavigation(client: Client): void {
const currentNavigationSpanRef: NavigationSpanRef = { current: undefined };
routerTransitionHandler = (href, navigationType) => {
const pathname = new URL(href, WINDOW.location.href).pathname;

if (navigationRoutingMode === 'router-patch') {
navigationRoutingMode = 'transition-start-hook';
}

const currentNavigationSpan = currentRouterPatchingNavigationSpanRef.current;
if (currentNavigationSpan) {
currentNavigationSpan.updateName(pathname);
currentNavigationSpan.setAttributes({
'navigation.type': `router.${navigationType}`,
});
currentRouterPatchingNavigationSpanRef.current = undefined;
} else {
startBrowserTracingNavigationSpan(client, {
name: pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
'navigation.type': `router.${navigationType}`,
},
});
}
};

WINDOW.addEventListener('popstate', () => {
if (currentNavigationSpanRef.current?.isRecording()) {
currentNavigationSpanRef.current.updateName(WINDOW.location.pathname);
currentNavigationSpanRef.current.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url');
if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) {
currentRouterPatchingNavigationSpanRef.current.updateName(WINDOW.location.pathname);
currentRouterPatchingNavigationSpanRef.current.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url');
} else {
currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, {
currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, {
name: WINDOW.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
'navigation.type': 'browser.popstate',
Expand All @@ -94,7 +140,7 @@ export function appRouterInstrumentNavigation(client: Client): void {
clearInterval(checkForRouterAvailabilityInterval);
routerPatched = true;

patchRouter(client, router, currentNavigationSpanRef);
patchRouter(client, router, currentRouterPatchingNavigationSpanRef);

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

// @ts-expect-error we cannot possibly type this
Expand Down Expand Up @@ -139,6 +185,10 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
// @ts-expect-error Weird type error related to not knowing how to associate return values with the individual functions - we can just ignore
router[routerFunctionName] = new Proxy(router[routerFunctionName], {
apply(target, thisArg, argArray) {
if (navigationRoutingMode !== 'router-patch') {
return target.apply(thisArg, argArray);
}

let transactionName = INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME;
const transactionAttributes: Record<string, string> = {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
Expand All @@ -148,11 +198,9 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe

if (routerFunctionName === 'push') {
transactionName = transactionNameifyRouterArgument(argArray[0]);
transactionAttributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'url';
transactionAttributes['navigation.type'] = 'router.push';
} else if (routerFunctionName === 'replace') {
transactionName = transactionNameifyRouterArgument(argArray[0]);
transactionAttributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'url';
transactionAttributes['navigation.type'] = 'router.replace';
} else if (routerFunctionName === 'back') {
transactionAttributes['navigation.type'] = 'router.back';
Expand All @@ -171,3 +219,14 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
}
});
}

let routerTransitionHandler: undefined | ((href: string, navigationType: string) => void) = undefined;

/**
* A handler for Next.js' `onRouterTransitionStart` hook in `instrumentation-client.ts` to record navigation spans in Sentry.
*/
export function captureRouterTransitionStart(href: string, navigationType: string): void {
if (routerTransitionHandler) {
routerTransitionHandler(href, navigationType);
}
}
32 changes: 32 additions & 0 deletions packages/nextjs/src/config/withSentryConfig.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
/* eslint-disable complexity */
import { isThenable, parseSemver } from '@sentry/core';

Expand All @@ -12,6 +13,8 @@ import type {
} from './types';
import { constructWebpackConfigFunction } from './webpack';
import { getNextjsVersion } from './util';
import * as fs from 'fs';
import * as path from 'path';

let showedExportModeTunnelWarning = false;

Expand Down Expand Up @@ -155,6 +158,18 @@ function getFinalConfigObject(
}
}

// We wanna check whether the user added a `onRouterTransitionStart` handler to their client instrumentation file.
const instrumentationClientFileContents = getInstrumentationClientFileContents();
if (
instrumentationClientFileContents !== undefined &&
!instrumentationClientFileContents.includes('onRouterTransitionStart')
) {
// eslint-disable-next-line no-console
console.warn(
'[@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.',
);
}

if (nextJsVersion) {
const { major, minor, patch, prerelease } = parseSemver(nextJsVersion);
const isSupportedVersion =
Expand Down Expand Up @@ -343,3 +358,20 @@ function getGitRevision(): string | undefined {
}
return gitRevision;
}

function getInstrumentationClientFileContents(): string | void {
const potentialInstrumentationClientFileLocations = [
['src', 'instrumentation-client.ts'],
['src', 'instrumentation-client.js'],
['instrumentation-client.ts'],
['instrumentation-client.ts'],
];

for (const pathSegments of potentialInstrumentationClientFileLocations) {
try {
return fs.readFileSync(path.join(process.cwd(), ...pathSegments), 'utf-8');
} catch {
// noop
}
}
}
Loading