Skip to content

Commit 3bb8d17

Browse files
lforstAbhiPrasad
andauthored
feat(nextjs): Improve pageload transaction creation (#5574)
Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent 380f483 commit 3bb8d17

File tree

4 files changed

+316
-38
lines changed

4 files changed

+316
-38
lines changed

packages/nextjs/src/performance/client.ts

+125-21
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,113 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22

3-
import { Primitive, Transaction, TransactionContext } from '@sentry/types';
4-
import { fill, getGlobalObject, stripUrlQueryAndFragment } from '@sentry/utils';
3+
import { getCurrentHub } from '@sentry/hub';
4+
import { Primitive, TraceparentData, Transaction, TransactionContext } from '@sentry/types';
5+
import {
6+
extractTraceparentData,
7+
fill,
8+
getGlobalObject,
9+
logger,
10+
parseBaggageHeader,
11+
stripUrlQueryAndFragment,
12+
} from '@sentry/utils';
13+
import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils';
514
import { default as Router } from 'next/router';
15+
import type { ParsedUrlQuery } from 'querystring';
616

717
const global = getGlobalObject<Window>();
818

919
type StartTransactionCb = (context: TransactionContext) => Transaction | undefined;
1020

21+
/**
22+
* Describes data located in the __NEXT_DATA__ script tag. This tag is present on every page of a Next.js app.
23+
*/
24+
interface SentryEnhancedNextData extends NextData {
25+
// contains props returned by `getInitialProps` - except for `pageProps`, these are the props that got returned by `getServerSideProps` or `getStaticProps`
26+
props: {
27+
_sentryGetInitialPropsTraceData?: string; // trace parent info, if injected by server-side `getInitialProps`
28+
_sentryGetInitialPropsBaggage?: string; // baggage, if injected by server-side `getInitialProps`
29+
pageProps?: {
30+
_sentryGetServerSidePropsTraceData?: string; // trace parent info, if injected by server-side `getServerSideProps`
31+
_sentryGetServerSidePropsBaggage?: string; // baggage, if injected by server-side `getServerSideProps`
32+
33+
// The following two values are only injected in a very special case with the following conditions:
34+
// 1. The page's `getStaticPaths` method must have returned `fallback: 'blocking'`.
35+
// 2. The requested page must be a "miss" in terms of "Incremental Static Regeneration", meaning the requested page has not been generated before.
36+
// In this case, a page is requested and only served when `getStaticProps` is done. There is not even a fallback page or similar.
37+
_sentryGetStaticPropsTraceData?: string; // trace parent info, if injected by server-side `getStaticProps`
38+
_sentryGetStaticPropsBaggage?: string; // baggage, if injected by server-side `getStaticProps`
39+
};
40+
};
41+
}
42+
43+
interface NextDataTagInfo {
44+
route?: string;
45+
traceParentData?: TraceparentData;
46+
baggage?: string;
47+
params?: ParsedUrlQuery;
48+
}
49+
50+
/**
51+
* Every Next.js page (static and dynamic ones) comes with a script tag with the id "__NEXT_DATA__". This script tag
52+
* contains a JSON object with data that was either generated at build time for static pages (`getStaticProps`), or at
53+
* runtime with data fetchers like `getServerSideProps.`.
54+
*
55+
* We can use this information to:
56+
* - Always get the parameterized route we're in when loading a page.
57+
* - Send trace information (trace-id, baggage) from the server to the client.
58+
*
59+
* This function extracts this information.
60+
*/
61+
function extractNextDataTagInformation(): NextDataTagInfo {
62+
let nextData: SentryEnhancedNextData | undefined;
63+
// Let's be on the safe side and actually check first if there is really a __NEXT_DATA__ script tag on the page.
64+
// Theoretically this should always be the case though.
65+
const nextDataTag = global.document.getElementById('__NEXT_DATA__');
66+
if (nextDataTag && nextDataTag.innerHTML) {
67+
try {
68+
nextData = JSON.parse(nextDataTag.innerHTML);
69+
} catch (e) {
70+
__DEBUG_BUILD__ && logger.warn('Could not extract __NEXT_DATA__');
71+
}
72+
}
73+
74+
if (!nextData) {
75+
return {};
76+
}
77+
78+
const nextDataTagInfo: NextDataTagInfo = {};
79+
80+
const { page, query, props } = nextData;
81+
82+
// `nextData.page` always contains the parameterized route
83+
nextDataTagInfo.route = page;
84+
nextDataTagInfo.params = query;
85+
86+
if (props) {
87+
const { pageProps } = props;
88+
89+
const getInitialPropsBaggage = props._sentryGetInitialPropsBaggage;
90+
const getServerSidePropsBaggage = pageProps && pageProps._sentryGetServerSidePropsBaggage;
91+
const getStaticPropsBaggage = pageProps && pageProps._sentryGetStaticPropsBaggage;
92+
// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` or `getStaticProps` so we give it priority.
93+
const baggage = getInitialPropsBaggage || getServerSidePropsBaggage || getStaticPropsBaggage;
94+
if (baggage) {
95+
nextDataTagInfo.baggage = baggage;
96+
}
97+
98+
const getInitialPropsTraceData = props._sentryGetInitialPropsTraceData;
99+
const getServerSidePropsTraceData = pageProps && pageProps._sentryGetServerSidePropsTraceData;
100+
const getStaticPropsTraceData = pageProps && pageProps._sentryGetStaticPropsTraceData;
101+
// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` or `getStaticProps` so we give it priority.
102+
const traceData = getInitialPropsTraceData || getServerSidePropsTraceData || getStaticPropsTraceData;
103+
if (traceData) {
104+
nextDataTagInfo.traceParentData = extractTraceparentData(traceData);
105+
}
106+
}
107+
108+
return nextDataTagInfo;
109+
}
110+
11111
const DEFAULT_TAGS = {
12112
'routing.instrumentation': 'next-router',
13113
} as const;
@@ -16,6 +116,8 @@ let activeTransaction: Transaction | undefined = undefined;
16116
let prevTransactionName: string | undefined = undefined;
17117
let startTransaction: StartTransactionCb | undefined = undefined;
18118

119+
const client = getCurrentHub().getClient();
120+
19121
/**
20122
* Creates routing instrumention for Next Router. Only supported for
21123
* client side routing. Works for Next >= 10.
@@ -30,24 +132,27 @@ export function nextRouterInstrumentation(
30132
startTransactionOnLocationChange: boolean = true,
31133
): void {
32134
startTransaction = startTransactionCb;
33-
Router.ready(() => {
34-
// We can only start the pageload transaction when we have access to the parameterized
35-
// route name. Setting the transaction name after the transaction is started could lead
36-
// to possible race conditions with the router, so this approach was taken.
37-
if (startTransactionOnPageLoad) {
38-
const pathIsRoute = Router.route !== null;
39-
40-
prevTransactionName = pathIsRoute ? stripUrlQueryAndFragment(Router.route) : global.location.pathname;
41-
activeTransaction = startTransactionCb({
42-
name: prevTransactionName,
43-
op: 'pageload',
44-
tags: DEFAULT_TAGS,
45-
metadata: {
46-
source: pathIsRoute ? 'route' : 'url',
47-
},
48-
});
49-
}
50135

136+
if (startTransactionOnPageLoad) {
137+
const { route, traceParentData, baggage, params } = extractNextDataTagInformation();
138+
139+
prevTransactionName = route || global.location.pathname;
140+
const source = route ? 'route' : 'url';
141+
142+
activeTransaction = startTransactionCb({
143+
name: prevTransactionName,
144+
op: 'pageload',
145+
tags: DEFAULT_TAGS,
146+
...(params && client && client.getOptions().sendDefaultPii && { data: params }),
147+
...traceParentData,
148+
metadata: {
149+
source,
150+
...(baggage && { baggage: parseBaggageHeader(baggage) }),
151+
},
152+
});
153+
}
154+
155+
Router.ready(() => {
51156
// Spans that aren't attached to any transaction are lost; so if transactions aren't
52157
// created (besides potentially the onpageload transaction), no need to wrap the router.
53158
if (!startTransactionOnLocationChange) return;
@@ -78,7 +183,7 @@ type WrappedRouterChangeState = RouterChangeState;
78183
* Start a navigation transaction every time the router changes state.
79184
*/
80185
function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): WrappedRouterChangeState {
81-
const wrapper = function (
186+
return function wrapper(
82187
this: any,
83188
method: string,
84189
// The parameterized url, ex. posts/[id]/[comment]
@@ -115,5 +220,4 @@ function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): Wrap
115220
}
116221
return originalChangeStateWrapper.call(this, method, url, as, options, ...args);
117222
};
118-
return wrapper;
119223
}

packages/nextjs/test/index.client.test.ts

+16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as SentryReact from '@sentry/react';
44
import { Integrations as TracingIntegrations } from '@sentry/tracing';
55
import { Integration } from '@sentry/types';
66
import { getGlobalObject, logger } from '@sentry/utils';
7+
import { JSDOM } from 'jsdom';
78

89
import { init, Integrations, nextRouterInstrumentation } from '../src/index.client';
910
import { NextjsOptions } from '../src/utils/nextjsOptions';
@@ -16,6 +17,21 @@ const reactInit = jest.spyOn(SentryReact, 'init');
1617
const captureEvent = jest.spyOn(BaseClient.prototype, 'captureEvent');
1718
const loggerLogSpy = jest.spyOn(logger, 'log');
1819

20+
// We're setting up JSDom here because the Next.js routing instrumentations requires a few things to be present on pageload:
21+
// 1. Access to window.document API for `window.document.getElementById`
22+
// 2. Access to window.location API for `window.location.pathname`
23+
const dom = new JSDOM(undefined, { url: 'https://example.com/' });
24+
Object.defineProperty(global, 'document', { value: dom.window.document, writable: true });
25+
Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true });
26+
27+
const originalGlobalDocument = getGlobalObject<Window>().document;
28+
const originalGlobalLocation = getGlobalObject<Window>().location;
29+
afterAll(() => {
30+
// Clean up JSDom
31+
Object.defineProperty(global, 'document', { value: originalGlobalDocument });
32+
Object.defineProperty(global, 'location', { value: originalGlobalLocation });
33+
});
34+
1935
describe('Client init()', () => {
2036
afterEach(() => {
2137
jest.clearAllMocks();

0 commit comments

Comments
 (0)