Skip to content

Commit f99d89b

Browse files
author
Luca Forstner
committed
feat(nextjs): Improve pageload transaction creation
1 parent fbb44d5 commit f99d89b

File tree

2 files changed

+222
-33
lines changed

2 files changed

+222
-33
lines changed

packages/nextjs/src/performance/client.ts

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,71 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22

33
import { Primitive, Transaction, TransactionContext } from '@sentry/types';
4-
import { fill, getGlobalObject, stripUrlQueryAndFragment } from '@sentry/utils';
4+
import { fill, getGlobalObject, logger, parseBaggageHeader, stripUrlQueryAndFragment } from '@sentry/utils';
5+
import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils';
56
import { default as Router } from 'next/router';
7+
import type { ParsedUrlQuery } from 'querystring';
68

79
const global = getGlobalObject<Window>();
810

911
type StartTransactionCb = (context: TransactionContext) => Transaction | undefined;
1012

13+
/**
14+
* Describes data located in the __NEXT_DATA__ script tag. This tag is present on every page of a Next.js app.
15+
*/
16+
interface SentryEnhancedNextData extends NextData {
17+
// contains props returned by `getInitialProps` - except for `pageProps`, these are the props that got returned by `getServerSideProps` or `getStaticProps`
18+
props: {
19+
_sentryGetInitialPropsTraceId?: string; // trace id, if injected by server-side `getInitialProps`
20+
_sentryGetInitialPropsBaggage?: string; // baggage, if injected by server-side `getInitialProps`
21+
pageProps?: {
22+
_sentryGetServerSidePropsTraceId?: string; // trace id, if injected by server-side `getServerSideProps`
23+
_sentryGetServerSidePropsBaggage?: string; // baggage, if injected by server-side `getServerSideProps`
24+
};
25+
};
26+
}
27+
28+
// Author's note: It's really not that complicated.
29+
// eslint-disable-next-line complexity
30+
function extractNextDataTagInformation(): {
31+
route: string;
32+
source: 'route' | 'url';
33+
traceId: string | undefined;
34+
baggage: string | undefined;
35+
params: ParsedUrlQuery | undefined;
36+
} {
37+
let nextData: SentryEnhancedNextData | undefined;
38+
39+
const nextDataTag = global.document.getElementById('__NEXT_DATA__');
40+
if (nextDataTag && nextDataTag.innerHTML) {
41+
try {
42+
nextData = JSON.parse(nextDataTag.innerHTML);
43+
} catch (e) {
44+
__DEBUG_BUILD__ && logger.warn('Could not extract __NEXT_DATA__');
45+
}
46+
}
47+
48+
// `nextData.page` always contains the parameterized route
49+
const route = (nextData || {}).page || global.document.location.pathname;
50+
const source = nextData ? 'route' : 'url';
51+
52+
const getServerSidePropsTraceId = (((nextData || {}).props || {}).pageProps || {})._sentryGetServerSidePropsTraceId;
53+
const getInitialPropsTraceId = ((nextData || {}).props || {})._sentryGetInitialPropsTraceId;
54+
const getServerSidePropsBaggage = (((nextData || {}).props || {}).pageProps || {})._sentryGetServerSidePropsBaggage;
55+
const getInitialPropsBaggage = ((nextData || {}).props || {})._sentryGetInitialPropsBaggage;
56+
57+
const params = (nextData || {}).query;
58+
59+
return {
60+
route,
61+
source,
62+
params,
63+
// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` so we give it priority.
64+
traceId: getInitialPropsTraceId || getServerSidePropsTraceId,
65+
baggage: getInitialPropsBaggage || getServerSidePropsBaggage,
66+
};
67+
}
68+
1169
const DEFAULT_TAGS = {
1270
'routing.instrumentation': 'next-router',
1371
} as const;
@@ -30,24 +88,26 @@ export function nextRouterInstrumentation(
3088
startTransactionOnLocationChange: boolean = true,
3189
): void {
3290
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-
}
5091

92+
if (startTransactionOnPageLoad) {
93+
const { route, source, traceId, baggage, params } = extractNextDataTagInformation();
94+
95+
prevTransactionName = route;
96+
97+
activeTransaction = startTransactionCb({
98+
name: prevTransactionName,
99+
traceId,
100+
op: 'pageload',
101+
tags: DEFAULT_TAGS,
102+
...(params && { data: params }),
103+
metadata: {
104+
source,
105+
...(baggage && { baggage: parseBaggageHeader(baggage) }),
106+
},
107+
});
108+
}
109+
110+
Router.ready(() => {
51111
// Spans that aren't attached to any transaction are lost; so if transactions aren't
52112
// created (besides potentially the onpageload transaction), no need to wrap the router.
53113
if (!startTransactionOnLocationChange) return;
@@ -78,7 +138,7 @@ type WrappedRouterChangeState = RouterChangeState;
78138
* Start a navigation transaction every time the router changes state.
79139
*/
80140
function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): WrappedRouterChangeState {
81-
const wrapper = function (
141+
return function wrapper(
82142
this: any,
83143
method: string,
84144
// The parameterized url, ex. posts/[id]/[comment]
@@ -115,5 +175,4 @@ function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): Wrap
115175
}
116176
return originalChangeStateWrapper.call(this, method, url, as, options, ...args);
117177
};
118-
return wrapper;
119178
}

packages/nextjs/test/performance/client.test.ts

Lines changed: 143 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getGlobalObject } from '@sentry/utils';
2+
import { JSDOM } from 'jsdom';
13
import { default as Router } from 'next/router';
24

35
import { nextRouterInstrumentation } from '../../src/performance/client';
@@ -28,28 +30,156 @@ describe('client', () => {
2830
});
2931

3032
describe('nextRouterInstrumentation', () => {
33+
const originalGlobalDocument = getGlobalObject<Window>().document;
34+
35+
function setUpNextPage(pageProperties: {
36+
url: string;
37+
route: string;
38+
query: any;
39+
props: any;
40+
hasNextData: boolean;
41+
}) {
42+
const dom = new JSDOM(
43+
// Just some example what a __NEXT_DATA__ tag might look like
44+
pageProperties.hasNextData
45+
? `<body>
46+
<script id="__NEXT_DATA__" type="application/json">
47+
{
48+
"props": ${JSON.stringify(pageProperties.props)},
49+
"page": "${pageProperties.route}",
50+
"query": ${JSON.stringify(pageProperties.query)},
51+
"buildId": "y76hvndNJBAithejdVGLW",
52+
"isFallback": false,
53+
"gssp": true,
54+
"appGip": true,
55+
"scriptLoader": []
56+
}
57+
</script>
58+
</body>`
59+
: '<body><h1>No next data :(</h1></body>',
60+
{ url: pageProperties.url },
61+
);
62+
63+
Object.defineProperty(global, 'document', { value: dom.window.document, writable: true, configurable: true });
64+
}
65+
66+
afterEach(() => {
67+
// Clean up JSDom
68+
Object.defineProperty(global, 'document', {
69+
value: originalGlobalDocument,
70+
writable: true,
71+
configurable: true,
72+
});
73+
});
74+
3175
it('waits for Router.ready()', () => {
76+
setUpNextPage({ url: 'https://example.com/', route: '/', query: {}, props: {}, hasNextData: false });
3277
const mockStartTransaction = jest.fn();
3378
expect(readyCalled).toBe(false);
3479
nextRouterInstrumentation(mockStartTransaction);
3580
expect(readyCalled).toBe(true);
3681
});
3782

38-
it('creates a pageload transaction', () => {
39-
const mockStartTransaction = jest.fn();
40-
nextRouterInstrumentation(mockStartTransaction);
41-
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
42-
expect(mockStartTransaction).toHaveBeenLastCalledWith({
43-
name: '/[user]/posts/[id]',
44-
op: 'pageload',
45-
tags: {
46-
'routing.instrumentation': 'next-router',
83+
it.each([
84+
[
85+
'https://example.com/lforst/posts/1337?q=42',
86+
'/[user]/posts/[id]',
87+
{ user: 'lforst', id: '1337', q: '42' },
88+
{
89+
_sentryGetInitialPropsTraceId: 'SOME_TRACE_ID',
90+
_sentryGetInitialPropsBaggage:
91+
'other=vendor,foo=bar,third=party,last=item,sentry-release=2.1.0,sentry-environment=myEnv',
4792
},
48-
metadata: {
49-
source: 'route',
93+
true,
94+
{
95+
name: '/[user]/posts/[id]',
96+
op: 'pageload',
97+
tags: {
98+
'routing.instrumentation': 'next-router',
99+
},
100+
data: {
101+
user: 'lforst',
102+
id: '1337',
103+
q: '42',
104+
},
105+
metadata: {
106+
source: 'route',
107+
baggage: [{ environment: 'myEnv', release: '2.1.0' }, '', true],
108+
},
109+
traceId: 'SOME_TRACE_ID',
50110
},
51-
});
52-
});
111+
],
112+
[
113+
'https://example.com/static',
114+
'/static',
115+
{},
116+
{
117+
pageProps: {
118+
_sentryGetServerSidePropsTraceId: 'SOME_TRACE_ID',
119+
_sentryGetServerSidePropsBaggage:
120+
'other=vendor,foo=bar,third=party,last=item,sentry-release=2.1.0,sentry-environment=myEnv',
121+
},
122+
},
123+
true,
124+
{
125+
name: '/static',
126+
op: 'pageload',
127+
tags: {
128+
'routing.instrumentation': 'next-router',
129+
},
130+
data: {},
131+
metadata: {
132+
source: 'route',
133+
baggage: [{ environment: 'myEnv', release: '2.1.0' }, '', true],
134+
},
135+
traceId: 'SOME_TRACE_ID',
136+
},
137+
],
138+
[
139+
'https://example.com/',
140+
'/',
141+
{},
142+
{},
143+
true,
144+
{
145+
name: '/',
146+
op: 'pageload',
147+
tags: {
148+
'routing.instrumentation': 'next-router',
149+
},
150+
data: {},
151+
metadata: {
152+
source: 'route',
153+
},
154+
},
155+
],
156+
[
157+
'https://example.com/lforst/posts/1337?q=42',
158+
'/',
159+
{},
160+
{},
161+
false, // no __NEXT_DATA__ tag
162+
{
163+
name: '/lforst/posts/1337',
164+
op: 'pageload',
165+
tags: {
166+
'routing.instrumentation': 'next-router',
167+
},
168+
metadata: {
169+
source: 'url',
170+
},
171+
},
172+
],
173+
])(
174+
'creates a pageload transaction (#%#)',
175+
(url, route, query, props, hasNextData, expectedStartTransactionCall) => {
176+
const mockStartTransaction = jest.fn();
177+
setUpNextPage({ url, route, query, props, hasNextData });
178+
nextRouterInstrumentation(mockStartTransaction);
179+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
180+
expect(mockStartTransaction).toHaveBeenLastCalledWith(expectedStartTransactionCall);
181+
},
182+
);
53183

54184
it('does not create a pageload transaction if option not given', () => {
55185
const mockStartTransaction = jest.fn();

0 commit comments

Comments
 (0)