Skip to content

Commit

Permalink
ref(nextjs): Improve app router routing instrumentation accuracy (#13695
Browse files Browse the repository at this point in the history
)

Improves the Next.js routing instrumentation by patching the Next.js
router and instrumenting window popstates.

A few details on this PR that might explain weird-looking logic:
- The patching of the router is in a setInterval because Next.js may
take a while to write the router to the window object and we don't have
a cue when that has happened.
- We are using a combination of patching `router.back`/`router.forward`
and the `popstate` event to emit a properly named transaction, because
`router.back` and `router.forward` aren't passed any useful strings we
could use as txn names.
- Because there is a slight delay between the
`router.back`/`router.forward` calls and the `popstate` event, we
temporarily give the navigation span an invalid name that we use as an
indicator to drop if one may leak through.
  • Loading branch information
lforst authored Sep 20, 2024
1 parent cf0152a commit 97974ba
Show file tree
Hide file tree
Showing 14 changed files with 352 additions and 229 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Link from 'next/link';

export const dynamic = 'force-dynamic';

export default function Page() {
return <Link href="/navigation">Go back home</Link>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Link from 'next/link';

export const dynamic = 'force-dynamic';

export default function Page() {
return <Link href="/navigation">Go back home</Link>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Link from 'next/link';

export const dynamic = 'force-dynamic';

export default function Page() {
return <Link href="/navigation">Go back home</Link>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Link from 'next/link';

export const dynamic = 'force-dynamic';

export default function Page() {
return <Link href="/navigation">Go back home</Link>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export default function Page() {
return <p>hello world</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export default function Page() {
return <p>hello world</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import Link from 'next/link';
import { useRouter } from 'next/navigation';

export default function Page() {
const router = useRouter();

return (
<ul>
<li>
<button
onClick={() => {
router.push('/navigation/42/router-push');
}}
>
router.push()
</button>
</li>
<li>
<button
onClick={() => {
router.replace('/navigation/42/router-replace');
}}
>
router.replace()
</button>
</li>
<li>
<button
onClick={() => {
router.forward();
}}
>
router.forward()
</button>
</li>
<li>
<button
onClick={() => {
router.back();
}}
>
router.back()
</button>
</li>
<li>
<Link href="/navigation/42/link">Normal Link</Link>
</li>
<li>
<Link href="/navigation/42/link-replace" replace>
Link Replace
</Link>
</li>
</ul>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,148 @@ test('Creates a navigation transaction for app router routes', async ({ page })
expect(await clientNavigationTransactionPromise).toBeDefined();
expect(await serverComponentTransactionPromise).toBeDefined();
});

test('Creates a navigation transaction for `router.push()`', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/42/router-push` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
);
});

await page.goto('/navigation');
await page.waitForTimeout(3000);
await page.getByText('router.push()').click();

expect(await navigationTransactionPromise).toBeDefined();
});

test('Creates a navigation transaction for `router.replace()`', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/42/router-replace` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace'
);
});

await page.goto('/navigation');
await page.waitForTimeout(3000);
await page.getByText('router.replace()').click();

expect(await navigationTransactionPromise).toBeDefined();
});

test('Creates a navigation transaction for `router.back()`', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/1337/router-back` &&
transactionEvent.contexts?.trace?.op === 'navigation'
);
});

await page.goto('/navigation/1337/router-back');
await page.waitForTimeout(3000);
await page.getByText('Go back home').click();
await page.waitForTimeout(3000);
await page.getByText('router.back()').click();

expect(await navigationTransactionPromise).toMatchObject({
contexts: {
trace: {
data: {
'navigation.type': 'router.back',
},
},
},
});
});

test('Creates a navigation transaction for `router.forward()`', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/42/router-push` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward'
);
});

await page.goto('/navigation');
await page.waitForTimeout(3000);
await page.getByText('router.push()').click();
await page.waitForTimeout(3000);
await page.goBack();
await page.waitForTimeout(3000);
await page.getByText('router.forward()').click();

expect(await navigationTransactionPromise).toBeDefined();
});

test('Creates a navigation transaction for `<Link />`', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/42/link` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
);
});

await page.goto('/navigation');
await page.getByText('Normal Link').click();

expect(await navigationTransactionPromise).toBeDefined();
});

test('Creates a navigation transaction for `<Link replace />`', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/42/link-replace` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace'
);
});

await page.goto('/navigation');
await page.waitForTimeout(3000);
await page.getByText('Link Replace').click();

expect(await navigationTransactionPromise).toBeDefined();
});

test('Creates a navigation transaction for browser-back', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/42/browser-back` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate'
);
});

await page.goto('/navigation/42/browser-back');
await page.waitForTimeout(3000);
await page.getByText('Go back home').click();
await page.waitForTimeout(3000);
await page.goBack();

expect(await navigationTransactionPromise).toBeDefined();
});

test('Creates a navigation transaction for browser-forward', async ({ page }) => {
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
return (
transactionEvent?.transaction === `/navigation/42/router-push` &&
transactionEvent.contexts?.trace?.op === 'navigation' &&
transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate'
);
});

await page.goto('/navigation');
await page.getByText('router.push()').click();
await page.waitForTimeout(3000);
await page.goBack();
await page.waitForTimeout(3000);
await page.goForward();

expect(await navigationTransactionPromise).toBeDefined();
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"clean:build": "lerna run clean",
"clean:caches": "yarn rimraf eslintcache .nxcache && yarn jest --clearCache",
"clean:deps": "lerna clean --yes && rm -rf node_modules && yarn",
"clean:tarballs": "rimraf **/*.tgz",
"clean:tarballs": "rimraf -g **/*.tgz",
"clean:all": "run-s clean:build clean:tarballs clean:caches clean:deps",
"fix": "run-s fix:biome fix:prettier fix:lerna",
"fix:lerna": "lerna run fix",
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"@opentelemetry/instrumentation-http": "0.53.0",
"@opentelemetry/semantic-conventions": "^1.27.0",
"@rollup/plugin-commonjs": "26.0.1",
"@sentry-internal/browser-utils": "8.30.0",
"@sentry/core": "8.30.0",
"@sentry/node": "8.30.0",
"@sentry/opentelemetry": "8.30.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolica
import { getVercelEnv } from '../common/getVercelEnv';
import { browserTracingIntegration } from './browserTracingIntegration';
import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration';
import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation';
import { applyTunnelRouteOption } from './tunnelRoute';

export * from '@sentry/react';
Expand Down Expand Up @@ -39,6 +40,13 @@ export function init(options: BrowserOptions): Client | undefined {
filterTransactions.id = 'NextClient404Filter';
addEventProcessor(filterTransactions);

const filterIncompleteNavigationTransactions: EventProcessor = event =>
event.type === 'transaction' && event.transaction === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME
? null
: event;
filterIncompleteNavigationTransactions.id = 'IncompleteTransactionFilter';
addEventProcessor(filterIncompleteNavigationTransactions);

if (process.env.NODE_ENV === 'development') {
addEventProcessor(devErrorSymbolicationEventProcessor);
}
Expand Down
Loading

0 comments on commit 97974ba

Please sign in to comment.