-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
feat(nextjs): Improve pageload transaction creation #5574
Changes from 9 commits
f99d89b
936b42e
227cd4d
56179ed
22a0f83
651e8e0
bec58e0
0ba0d6e
1bde09a
652d3eb
49f51d7
0a52912
aa4d876
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -1,13 +1,104 @@ | ||||||||
/* eslint-disable @typescript-eslint/no-explicit-any */ | ||||||||
|
||||||||
import { Primitive, Transaction, TransactionContext } from '@sentry/types'; | ||||||||
import { fill, getGlobalObject, stripUrlQueryAndFragment } from '@sentry/utils'; | ||||||||
import { getCurrentHub } from '@sentry/hub'; | ||||||||
import { Primitive, TraceparentData, Transaction, TransactionContext } from '@sentry/types'; | ||||||||
import { | ||||||||
extractTraceparentData, | ||||||||
fill, | ||||||||
getGlobalObject, | ||||||||
logger, | ||||||||
parseBaggageHeader, | ||||||||
stripUrlQueryAndFragment, | ||||||||
} from '@sentry/utils'; | ||||||||
import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils'; | ||||||||
import { default as Router } from 'next/router'; | ||||||||
import type { ParsedUrlQuery } from 'querystring'; | ||||||||
|
||||||||
const global = getGlobalObject<Window>(); | ||||||||
|
||||||||
type StartTransactionCb = (context: TransactionContext) => Transaction | undefined; | ||||||||
|
||||||||
/** | ||||||||
* Describes data located in the __NEXT_DATA__ script tag. This tag is present on every page of a Next.js app. | ||||||||
*/ | ||||||||
interface SentryEnhancedNextData extends NextData { | ||||||||
// contains props returned by `getInitialProps` - except for `pageProps`, these are the props that got returned by `getServerSideProps` or `getStaticProps` | ||||||||
props: { | ||||||||
_sentryGetInitialPropsTraceData?: string; // trace parent info, if injected by server-side `getInitialProps` | ||||||||
_sentryGetInitialPropsBaggage?: string; // baggage, if injected by server-side `getInitialProps` | ||||||||
pageProps?: { | ||||||||
_sentryGetServerSidePropsTraceData?: string; // trace parent info, if injected by server-side `getServerSideProps` | ||||||||
_sentryGetServerSidePropsBaggage?: string; // baggage, if injected by server-side `getServerSideProps` | ||||||||
Comment on lines
+30
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know we're leaving There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See #5574 (comment) |
||||||||
}; | ||||||||
}; | ||||||||
} | ||||||||
|
||||||||
interface NextDataTagInfo { | ||||||||
route?: string; | ||||||||
traceParentData?: TraceparentData; | ||||||||
baggage?: string; | ||||||||
params?: ParsedUrlQuery; | ||||||||
} | ||||||||
|
||||||||
/** | ||||||||
* Every Next.js page (static and dynamic ones) comes with a script tag with the id "__NEXT_DATA__". This script tag | ||||||||
* contains a JSON object with data that was either generated at build time for static pages (`getStaticProps`), or at | ||||||||
* runtime with data fetchers like `getServerSideProps.`. | ||||||||
Comment on lines
+51
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I finally read up on how ISR works and I think I fully understand it now. There is one case where it totally makes sense to trace |
||||||||
* | ||||||||
* We can use this information to: | ||||||||
* - Always get the parameterized route we're in when loading a page. | ||||||||
* - Send trace information (trace-id, baggage) from the server to the client. | ||||||||
* | ||||||||
* This function extracts this information. | ||||||||
*/ | ||||||||
function extractNextDataTagInformation(): NextDataTagInfo { | ||||||||
let nextData: SentryEnhancedNextData | undefined; | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. low: IMO I would like this function to act as a builder, perhaps something like so: const nextDataTagInfo = {};
if (nextData) {
nextDataTagInfo.route = nextData.page;
nextDataTagInfo.params = nextData.query;
if (nextData.props) {
// ...
}
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just tried this. Trust me, it's uglier than it is right now + you'll get problems with typing. I'm leaving it as is. Mutability is the root of all evil. Feel free to push changes to this if you want though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done with 1bde09a, let me know what you think! (feel free to revert if you think it'll cause more bugs than less). |
||||||||
// Let's be on the safe side and actually check first if there is really a __NEXT_DATA__ script tag on the page. | ||||||||
// Theoretically this should always be the case though. | ||||||||
const nextDataTag = global.document.getElementById('__NEXT_DATA__'); | ||||||||
if (nextDataTag && nextDataTag.innerHTML) { | ||||||||
try { | ||||||||
nextData = JSON.parse(nextDataTag.innerHTML); | ||||||||
} catch (e) { | ||||||||
__DEBUG_BUILD__ && logger.warn('Could not extract __NEXT_DATA__'); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
if (!nextData) { | ||||||||
return {}; | ||||||||
} | ||||||||
|
||||||||
const nextDataTagInfo: NextDataTagInfo = {}; | ||||||||
|
||||||||
const { page, query, props } = nextData; | ||||||||
|
||||||||
// `nextData.page` always contains the parameterized route | ||||||||
nextDataTagInfo.route = page; | ||||||||
nextDataTagInfo.params = query; | ||||||||
|
||||||||
if (props) { | ||||||||
const { pageProps } = props; | ||||||||
|
||||||||
const getInitialPropsBaggage = props._sentryGetInitialPropsBaggage; | ||||||||
const getServerSidePropsBaggage = pageProps && pageProps._sentryGetServerSidePropsBaggage; | ||||||||
// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` so we give it priority. | ||||||||
const baggage = getInitialPropsBaggage || getServerSidePropsBaggage; | ||||||||
if (baggage) { | ||||||||
nextDataTagInfo.baggage = baggage; | ||||||||
} | ||||||||
|
||||||||
const getInitialPropsTraceData = props._sentryGetInitialPropsTraceData; | ||||||||
const getServerSidePropsTraceData = pageProps && pageProps._sentryGetServerSidePropsTraceData; | ||||||||
// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` so we give it priority. | ||||||||
const traceData = getInitialPropsTraceData || getServerSidePropsTraceData; | ||||||||
if (traceData) { | ||||||||
nextDataTagInfo.traceParentData = extractTraceparentData(traceData); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
return nextDataTagInfo; | ||||||||
} | ||||||||
|
||||||||
const DEFAULT_TAGS = { | ||||||||
'routing.instrumentation': 'next-router', | ||||||||
} as const; | ||||||||
|
@@ -16,6 +107,8 @@ let activeTransaction: Transaction | undefined = undefined; | |||||||
let prevTransactionName: string | undefined = undefined; | ||||||||
let startTransaction: StartTransactionCb | undefined = undefined; | ||||||||
|
||||||||
const client = getCurrentHub().getClient(); | ||||||||
|
||||||||
/** | ||||||||
* Creates routing instrumention for Next Router. Only supported for | ||||||||
* client side routing. Works for Next >= 10. | ||||||||
|
@@ -30,24 +123,27 @@ export function nextRouterInstrumentation( | |||||||
startTransactionOnLocationChange: boolean = true, | ||||||||
): void { | ||||||||
startTransaction = startTransactionCb; | ||||||||
Router.ready(() => { | ||||||||
// We can only start the pageload transaction when we have access to the parameterized | ||||||||
// route name. Setting the transaction name after the transaction is started could lead | ||||||||
// to possible race conditions with the router, so this approach was taken. | ||||||||
if (startTransactionOnPageLoad) { | ||||||||
const pathIsRoute = Router.route !== null; | ||||||||
|
||||||||
prevTransactionName = pathIsRoute ? stripUrlQueryAndFragment(Router.route) : global.location.pathname; | ||||||||
activeTransaction = startTransactionCb({ | ||||||||
name: prevTransactionName, | ||||||||
op: 'pageload', | ||||||||
tags: DEFAULT_TAGS, | ||||||||
metadata: { | ||||||||
source: pathIsRoute ? 'route' : 'url', | ||||||||
}, | ||||||||
}); | ||||||||
} | ||||||||
|
||||||||
if (startTransactionOnPageLoad) { | ||||||||
const { route, traceParentData, baggage, params } = extractNextDataTagInformation(); | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
|
||||||||
prevTransactionName = route || global.location.pathname; | ||||||||
const source = route ? 'route' : 'url'; | ||||||||
|
||||||||
activeTransaction = startTransactionCb({ | ||||||||
name: prevTransactionName, | ||||||||
op: 'pageload', | ||||||||
tags: DEFAULT_TAGS, | ||||||||
...(params && client && client.getOptions().sendDefaultPii && { data: params }), | ||||||||
...traceParentData, | ||||||||
metadata: { | ||||||||
source, | ||||||||
...(baggage && { baggage: parseBaggageHeader(baggage) }), | ||||||||
}, | ||||||||
}); | ||||||||
} | ||||||||
|
||||||||
Router.ready(() => { | ||||||||
// Spans that aren't attached to any transaction are lost; so if transactions aren't | ||||||||
// created (besides potentially the onpageload transaction), no need to wrap the router. | ||||||||
if (!startTransactionOnLocationChange) return; | ||||||||
|
@@ -78,7 +174,7 @@ type WrappedRouterChangeState = RouterChangeState; | |||||||
* Start a navigation transaction every time the router changes state. | ||||||||
*/ | ||||||||
function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): WrappedRouterChangeState { | ||||||||
const wrapper = function ( | ||||||||
return function wrapper( | ||||||||
this: any, | ||||||||
method: string, | ||||||||
// The parameterized url, ex. posts/[id]/[comment] | ||||||||
|
@@ -115,5 +211,4 @@ function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): Wrap | |||||||
} | ||||||||
return originalChangeStateWrapper.call(this, method, url, as, options, ...args); | ||||||||
}; | ||||||||
return wrapper; | ||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ import * as SentryReact from '@sentry/react'; | |
import { Integrations as TracingIntegrations } from '@sentry/tracing'; | ||
import { Integration } from '@sentry/types'; | ||
import { getGlobalObject, logger, SentryError } from '@sentry/utils'; | ||
import { JSDOM } from 'jsdom'; | ||
|
||
import { init, Integrations, nextRouterInstrumentation } from '../src/index.client'; | ||
import { NextjsOptions } from '../src/utils/nextjsOptions'; | ||
|
@@ -16,6 +17,19 @@ const reactInit = jest.spyOn(SentryReact, 'init'); | |
const captureEvent = jest.spyOn(BaseClient.prototype, 'captureEvent'); | ||
const logWarn = jest.spyOn(logger, 'warn'); | ||
|
||
// Set up JSDom - needed for page load instrumentation | ||
const dom = new JSDOM(undefined, { url: 'https://example.com/' }); | ||
Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); | ||
Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); | ||
|
||
const originalGlobalDocument = getGlobalObject<Window>().document; | ||
const originalGlobalLocation = getGlobalObject<Window>().location; | ||
afterAll(() => { | ||
// Clean up JSDom | ||
Object.defineProperty(global, 'document', { value: originalGlobalDocument }); | ||
Object.defineProperty(global, 'location', { value: originalGlobalLocation }); | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you add a sentence or two to the comment explaining why we need to do this? |
||
|
||
describe('Client init()', () => { | ||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,3 +1,6 @@ | ||||||
import { getGlobalObject } from '@sentry/utils'; | ||||||
import { JSDOM } from 'jsdom'; | ||||||
import { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils'; | ||||||
import { default as Router } from 'next/router'; | ||||||
|
||||||
import { nextRouterInstrumentation } from '../../src/performance/client'; | ||||||
|
@@ -28,28 +31,152 @@ describe('client', () => { | |||||
}); | ||||||
|
||||||
describe('nextRouterInstrumentation', () => { | ||||||
const originalGlobalDocument = getGlobalObject<Window>().document; | ||||||
const originalGlobalLocation = getGlobalObject<Window>().location; | ||||||
|
||||||
function setUpNextPage(pageProperties: { | ||||||
url: string; | ||||||
route: string; | ||||||
query: any; | ||||||
props: any; | ||||||
hasNextData: boolean; | ||||||
}) { | ||||||
const nextDataContent: NextData = { | ||||||
props: pageProperties.props, | ||||||
page: pageProperties.route, | ||||||
query: pageProperties.query, | ||||||
buildId: 'y76hvndNJBAithejdVGLW', | ||||||
isFallback: false, | ||||||
gssp: true, | ||||||
appGip: true, | ||||||
scriptLoader: [], | ||||||
}; | ||||||
|
||||||
const dom = new JSDOM( | ||||||
// Just some example what a __NEXT_DATA__ tag might look like | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
pageProperties.hasNextData | ||||||
? `<body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify( | ||||||
nextDataContent, | ||||||
)}</script></body>` | ||||||
: '<body><h1>No next data :(</h1></body>', | ||||||
{ url: pageProperties.url }, | ||||||
); | ||||||
|
||||||
Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); | ||||||
Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); | ||||||
} | ||||||
|
||||||
afterEach(() => { | ||||||
// Clean up JSDom | ||||||
Object.defineProperty(global, 'document', { value: originalGlobalDocument }); | ||||||
Object.defineProperty(global, 'location', { value: originalGlobalLocation }); | ||||||
}); | ||||||
|
||||||
it('waits for Router.ready()', () => { | ||||||
setUpNextPage({ url: 'https://example.com/', route: '/', query: {}, props: {}, hasNextData: false }); | ||||||
const mockStartTransaction = jest.fn(); | ||||||
expect(readyCalled).toBe(false); | ||||||
nextRouterInstrumentation(mockStartTransaction); | ||||||
expect(readyCalled).toBe(true); | ||||||
}); | ||||||
|
||||||
it('creates a pageload transaction', () => { | ||||||
const mockStartTransaction = jest.fn(); | ||||||
nextRouterInstrumentation(mockStartTransaction); | ||||||
expect(mockStartTransaction).toHaveBeenCalledTimes(1); | ||||||
expect(mockStartTransaction).toHaveBeenLastCalledWith({ | ||||||
name: '/[user]/posts/[id]', | ||||||
op: 'pageload', | ||||||
tags: { | ||||||
'routing.instrumentation': 'next-router', | ||||||
it.each([ | ||||||
[ | ||||||
'https://example.com/lforst/posts/1337?q=42', | ||||||
'/[user]/posts/[id]', | ||||||
{ user: 'lforst', id: '1337', q: '42' }, | ||||||
{ | ||||||
_sentryGetInitialPropsTraceData: 'c82b8554881b4d28ad977de04a4fb40a-a755953cd3394d5f-1', | ||||||
_sentryGetInitialPropsBaggage: | ||||||
'other=vendor,foo=bar,third=party,last=item,sentry-release=2.1.0,sentry-environment=myEnv', | ||||||
}, | ||||||
metadata: { | ||||||
source: 'route', | ||||||
true, | ||||||
{ | ||||||
name: '/[user]/posts/[id]', | ||||||
op: 'pageload', | ||||||
tags: { | ||||||
'routing.instrumentation': 'next-router', | ||||||
}, | ||||||
metadata: { | ||||||
source: 'route', | ||||||
baggage: [{ environment: 'myEnv', release: '2.1.0' }, '', true], | ||||||
}, | ||||||
traceId: 'c82b8554881b4d28ad977de04a4fb40a', | ||||||
parentSpanId: 'a755953cd3394d5f', | ||||||
parentSampled: true, | ||||||
}, | ||||||
}); | ||||||
}); | ||||||
], | ||||||
[ | ||||||
'https://example.com/static', | ||||||
'/static', | ||||||
{}, | ||||||
{ | ||||||
pageProps: { | ||||||
_sentryGetServerSidePropsTraceData: 'c82b8554881b4d28ad977de04a4fb40a-a755953cd3394d5f-1', | ||||||
_sentryGetServerSidePropsBaggage: | ||||||
'other=vendor,foo=bar,third=party,last=item,sentry-release=2.1.0,sentry-environment=myEnv', | ||||||
}, | ||||||
}, | ||||||
true, | ||||||
{ | ||||||
name: '/static', | ||||||
op: 'pageload', | ||||||
tags: { | ||||||
'routing.instrumentation': 'next-router', | ||||||
}, | ||||||
metadata: { | ||||||
source: 'route', | ||||||
baggage: [{ environment: 'myEnv', release: '2.1.0' }, '', true], | ||||||
}, | ||||||
traceId: 'c82b8554881b4d28ad977de04a4fb40a', | ||||||
parentSpanId: 'a755953cd3394d5f', | ||||||
parentSampled: true, | ||||||
}, | ||||||
], | ||||||
[ | ||||||
'https://example.com/', | ||||||
'/', | ||||||
{}, | ||||||
{}, | ||||||
true, | ||||||
{ | ||||||
name: '/', | ||||||
op: 'pageload', | ||||||
tags: { | ||||||
'routing.instrumentation': 'next-router', | ||||||
}, | ||||||
metadata: { | ||||||
source: 'route', | ||||||
}, | ||||||
}, | ||||||
], | ||||||
[ | ||||||
'https://example.com/lforst/posts/1337?q=42', | ||||||
'/', | ||||||
{}, | ||||||
{}, | ||||||
false, // no __NEXT_DATA__ tag | ||||||
{ | ||||||
name: '/lforst/posts/1337', | ||||||
op: 'pageload', | ||||||
tags: { | ||||||
'routing.instrumentation': 'next-router', | ||||||
}, | ||||||
metadata: { | ||||||
source: 'url', | ||||||
}, | ||||||
}, | ||||||
], | ||||||
])( | ||||||
'creates a pageload transaction (#%#)', | ||||||
(url, route, query, props, hasNextData, expectedStartTransactionCall) => { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
It might be nice to put this list in a comment at the top of the |
||||||
const mockStartTransaction = jest.fn(); | ||||||
setUpNextPage({ url, route, query, props, hasNextData }); | ||||||
nextRouterInstrumentation(mockStartTransaction); | ||||||
expect(mockStartTransaction).toHaveBeenCalledTimes(1); | ||||||
expect(mockStartTransaction).toHaveBeenLastCalledWith(expectedStartTransactionCall); | ||||||
}, | ||||||
); | ||||||
|
||||||
it('does not create a pageload transaction if option not given', () => { | ||||||
const mockStartTransaction = jest.fn(); | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -153,16 +153,11 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp | |
tunnel?: string; | ||
|
||
/** | ||
* Important: This option is currently unused and will only work in the next major version of the SDK | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than just deleting this line, I think we should say that it works client-side in the nextjs SDK but not elsewhere. |
||
* | ||
* Controls if potentially sensitive data should be sent to Sentry by default. | ||
* Note that this only applies to data that the SDK is sending by default | ||
* but not data that was explicitly set (e.g. by calling `Sentry.setUser()`). | ||
* Details about the implementation are TBD. | ||
* | ||
* Defaults to `false`. | ||
* | ||
* @ignore | ||
*/ | ||
sendDefaultPii?: boolean; | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there other fields in NextData that we can use? https://github.com/vercel/next.js/blob/0796b6faa991cd58a517b0f160320e9978208066/packages/next/shared/lib/utils.ts#L84-L109 - perhaps create a follow up issue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Poked around in the type. Nothing really sticks out as very useful. I can create an issue but very low prio imo.