Skip to content

Commit 460bea1

Browse files
author
Luca Forstner
authored
feat(nextjs): Add client routing instrumentation for app router (#9446)
1 parent bf72904 commit 460bea1

File tree

8 files changed

+379
-49
lines changed

8 files changed

+379
-49
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { test, expect } from '@playwright/test';
2+
import { waitForTransaction } from '../event-proxy-server';
3+
4+
test('Creates a pageload transaction for app router routes', async ({ page }) => {
5+
const randomRoute = String(Math.random());
6+
7+
const clientPageloadTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => {
8+
return (
9+
transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` &&
10+
transactionEvent.contexts?.trace?.op === 'pageload'
11+
);
12+
});
13+
14+
await page.goto(`/server-component/parameter/${randomRoute}`);
15+
16+
expect(await clientPageloadTransactionPromise).toBeDefined();
17+
});
18+
19+
test('Creates a navigation transaction for app router routes', async ({ page }) => {
20+
const randomRoute = String(Math.random());
21+
22+
const clientPageloadTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => {
23+
return (
24+
transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` &&
25+
transactionEvent.contexts?.trace?.op === 'pageload'
26+
);
27+
});
28+
29+
await page.goto(`/server-component/parameter/${randomRoute}`);
30+
await clientPageloadTransactionPromise;
31+
await page.getByText('Page (/server-component/parameter/[parameter])').isVisible();
32+
33+
const clientNavigationTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => {
34+
return (
35+
transactionEvent?.transaction === '/server-component/parameter/foo/bar/baz' &&
36+
transactionEvent.contexts?.trace?.op === 'navigation'
37+
);
38+
});
39+
40+
const servercomponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
41+
return (
42+
transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' &&
43+
(await clientNavigationTransactionPromise).contexts?.trace?.trace_id ===
44+
transactionEvent.contexts?.trace?.trace_id
45+
);
46+
});
47+
48+
await page.getByText('/server-component/parameter/foo/bar/baz').click();
49+
50+
expect(await clientNavigationTransactionPromise).toBeDefined();
51+
expect(await servercomponentTransactionPromise).toBeDefined();
52+
});

packages/e2e-tests/test-applications/nextjs-app-dir/tests/trace.test.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

packages/nextjs/src/client/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import { addOrUpdateIntegration } from '@sentry/utils';
1414
import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor';
1515
import { getVercelEnv } from '../common/getVercelEnv';
1616
import { buildMetadata } from '../common/metadata';
17-
import { nextRouterInstrumentation } from './performance';
17+
import { nextRouterInstrumentation } from './routing/nextRoutingInstrumentation';
1818
import { applyTunnelRouteOption } from './tunnelRoute';
1919

2020
export * from '@sentry/react';
21-
export { nextRouterInstrumentation } from './performance';
21+
export { nextRouterInstrumentation } from './routing/nextRoutingInstrumentation';
2222
export { captureUnderscoreErrorException } from '../common/_error';
2323

2424
export { Integrations };
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { WINDOW } from '@sentry/react';
2+
import type { HandlerDataFetch, Primitive, Transaction, TransactionContext } from '@sentry/types';
3+
import { addInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils';
4+
5+
type StartTransactionCb = (context: TransactionContext) => Transaction | undefined;
6+
7+
const DEFAULT_TAGS = {
8+
'routing.instrumentation': 'next-app-router',
9+
} as const;
10+
11+
/**
12+
* Instruments the Next.js Clientside App Router.
13+
*/
14+
export function appRouterInstrumentation(
15+
startTransactionCb: StartTransactionCb,
16+
startTransactionOnPageLoad: boolean = true,
17+
startTransactionOnLocationChange: boolean = true,
18+
): void {
19+
// We keep track of the active transaction so we can finish it when we start a navigation transaction.
20+
let activeTransaction: Transaction | undefined = undefined;
21+
22+
// We keep track of the previous location name so we can set the `from` field on navigation transactions.
23+
// This is either a route or a pathname.
24+
let prevLocationName = WINDOW.location.pathname;
25+
26+
if (startTransactionOnPageLoad) {
27+
activeTransaction = startTransactionCb({
28+
name: prevLocationName,
29+
op: 'pageload',
30+
origin: 'auto.pageload.nextjs.app_router_instrumentation',
31+
tags: DEFAULT_TAGS,
32+
// pageload should always start at timeOrigin (and needs to be in s, not ms)
33+
startTimestamp: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined,
34+
metadata: { source: 'url' },
35+
});
36+
}
37+
38+
if (startTransactionOnLocationChange) {
39+
addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch) => {
40+
// The instrumentation handler is invoked twice - once for starting a request and once when the req finishes
41+
// We can use the existence of the end-timestamp to filter out "finishing"-events.
42+
if (handlerData.endTimestamp !== undefined) {
43+
return;
44+
}
45+
46+
// Only GET requests can be navigating RSC requests
47+
if (handlerData.fetchData.method !== 'GET') {
48+
return;
49+
}
50+
51+
const parsedNavigatingRscFetchArgs = parseNavigatingRscFetchArgs(handlerData.args);
52+
53+
if (parsedNavigatingRscFetchArgs === null) {
54+
return;
55+
}
56+
57+
const transactionName = parsedNavigatingRscFetchArgs.targetPathname;
58+
const tags: Record<string, Primitive> = {
59+
...DEFAULT_TAGS,
60+
from: prevLocationName,
61+
};
62+
63+
prevLocationName = transactionName;
64+
65+
if (activeTransaction) {
66+
activeTransaction.finish();
67+
}
68+
69+
startTransactionCb({
70+
name: transactionName,
71+
op: 'navigation',
72+
origin: 'auto.navigation.nextjs.app_router_instrumentation',
73+
tags,
74+
metadata: { source: 'url' },
75+
});
76+
});
77+
}
78+
}
79+
80+
function parseNavigatingRscFetchArgs(fetchArgs: unknown[]): null | {
81+
targetPathname: string;
82+
} {
83+
// Make sure the first arg is a URL object
84+
if (!fetchArgs[0] || typeof fetchArgs[0] !== 'object' || (fetchArgs[0] as URL).searchParams === undefined) {
85+
return null;
86+
}
87+
88+
// Make sure the second argument is some kind of fetch config obj that contains headers
89+
if (!fetchArgs[1] || typeof fetchArgs[1] !== 'object' || !('headers' in fetchArgs[1])) {
90+
return null;
91+
}
92+
93+
try {
94+
const url = fetchArgs[0] as URL;
95+
const headers = fetchArgs[1].headers as Record<string, string>;
96+
97+
// Not an RSC request
98+
if (headers['RSC'] !== '1') {
99+
return null;
100+
}
101+
102+
// Prefetch requests are not navigating RSC requests
103+
if (headers['Next-Router-Prefetch'] === '1') {
104+
return null;
105+
}
106+
107+
return {
108+
targetPathname: url.pathname,
109+
};
110+
} catch {
111+
return null;
112+
}
113+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { WINDOW } from '@sentry/react';
2+
import type { Transaction, TransactionContext } from '@sentry/types';
3+
4+
import { appRouterInstrumentation } from './appRouterRoutingInstrumentation';
5+
import { pagesRouterInstrumentation } from './pagesRouterRoutingInstrumentation';
6+
7+
type StartTransactionCb = (context: TransactionContext) => Transaction | undefined;
8+
9+
/**
10+
* Instruments the Next.js Clientside Router.
11+
*/
12+
export function nextRouterInstrumentation(
13+
startTransactionCb: StartTransactionCb,
14+
startTransactionOnPageLoad: boolean = true,
15+
startTransactionOnLocationChange: boolean = true,
16+
): void {
17+
const isAppRouter = !WINDOW.document.getElementById('__NEXT_DATA__');
18+
if (isAppRouter) {
19+
appRouterInstrumentation(startTransactionCb, startTransactionOnPageLoad, startTransactionOnLocationChange);
20+
} else {
21+
pagesRouterInstrumentation(startTransactionCb, startTransactionOnPageLoad, startTransactionOnLocationChange);
22+
}
23+
}

packages/nextjs/src/client/performance.ts renamed to packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { getCurrentHub } from '@sentry/core';
22
import { WINDOW } from '@sentry/react';
33
import type { Primitive, Transaction, TransactionContext, TransactionSource } from '@sentry/types';
4-
import { logger, stripUrlQueryAndFragment, tracingContextFromHeaders } from '@sentry/utils';
4+
import {
5+
browserPerformanceTimeOrigin,
6+
logger,
7+
stripUrlQueryAndFragment,
8+
tracingContextFromHeaders,
9+
} from '@sentry/utils';
510
import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils';
611
import { default as Router } from 'next/router';
712
import type { ParsedUrlQuery } from 'querystring';
@@ -86,7 +91,7 @@ function extractNextDataTagInformation(): NextDataTagInfo {
8691
}
8792

8893
const DEFAULT_TAGS = {
89-
'routing.instrumentation': 'next-router',
94+
'routing.instrumentation': 'next-pages-router',
9095
} as const;
9196

9297
// We keep track of the active transaction so we can finish it when we start a navigation transaction.
@@ -99,14 +104,14 @@ let prevLocationName: string | undefined = undefined;
99104
const client = getCurrentHub().getClient();
100105

101106
/**
102-
* Creates routing instrumention for Next Router. Only supported for
107+
* Instruments the Next.js pages router. Only supported for
103108
* client side routing. Works for Next >= 10.
104109
*
105110
* Leverages the SingletonRouter from the `next/router` to
106111
* generate pageload/navigation transactions and parameterize
107112
* transaction names.
108113
*/
109-
export function nextRouterInstrumentation(
114+
export function pagesRouterInstrumentation(
110115
startTransactionCb: StartTransactionCb,
111116
startTransactionOnPageLoad: boolean = true,
112117
startTransactionOnLocationChange: boolean = true,
@@ -125,7 +130,10 @@ export function nextRouterInstrumentation(
125130
activeTransaction = startTransactionCb({
126131
name: prevLocationName,
127132
op: 'pageload',
133+
origin: 'auto.pageload.nextjs.pages_router_instrumentation',
128134
tags: DEFAULT_TAGS,
135+
// pageload should always start at timeOrigin (and needs to be in s, not ms)
136+
startTimestamp: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined,
129137
...(params && client && client.getOptions().sendDefaultPii && { data: params }),
130138
...traceparentData,
131139
metadata: {
@@ -165,6 +173,7 @@ export function nextRouterInstrumentation(
165173
const navigationTransaction = startTransactionCb({
166174
name: transactionName,
167175
op: 'navigation',
176+
origin: 'auto.navigation.nextjs.pages_router_instrumentation',
168177
tags,
169178
metadata: { source: transactionSource },
170179
});
@@ -177,8 +186,8 @@ export function nextRouterInstrumentation(
177186
// hooks). Instead, we'll simply let the navigation transaction finish itself (it's an `IdleTransaction`).
178187
const nextRouteChangeSpan = navigationTransaction.startChild({
179188
op: 'ui.nextjs.route-change',
189+
origin: 'auto.ui.nextjs.pages_router_instrumentation',
180190
description: 'Next.js Route Change',
181-
origin: 'auto.navigation.nextjs',
182191
});
183192

184193
const finishRouteChangeSpan = (): void => {

0 commit comments

Comments
 (0)