Skip to content
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

Merged
merged 13 commits into from
Aug 16, 2022
121 changes: 100 additions & 21 deletions packages/nextjs/src/performance/client.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,90 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { Primitive, Transaction, TransactionContext } from '@sentry/types';
import { fill, getGlobalObject, stripUrlQueryAndFragment } from '@sentry/utils';
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 {
Copy link
Member

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?

Copy link
Member Author

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.

// contains props returned by `getInitialProps` - except for `pageProps`, these are the props that got returned by `getServerSideProps` or `getStaticProps`
props: {
_sentryGetInitialPropsTraceData?: string; // trace id, if injected by server-side `getInitialProps`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not just trace id - let's refer to it as trace parent info since it has sampled flag and parent span id as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 22a0f83

_sentryGetInitialPropsBaggage?: string; // baggage, if injected by server-side `getInitialProps`
pageProps?: {
_sentryGetServerSidePropsTraceData?: string; // trace id, if injected by server-side `getServerSideProps`
_sentryGetServerSidePropsBaggage?: string; // baggage, if injected by server-side `getServerSideProps`
};
};
}

/**
* 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that getStaticProps runs an arbitrary amount of time before the page is served (both when it's run at build time and when it's run at runtime as part of revalidation), it doesn't make sense to send trace data from it, only the parameterized route. Do we want to note that here somehow? Could be in the docstring here or could be below where you're dealing with the other two, to explain why it's not all three.

Copy link
Member Author

Choose a reason for hiding this comment

The 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 getStaticProps and I went ahead and added logic and an explanation for it: 652d3eb

*
* 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(): {
route: string | undefined;
traceParentData: TraceparentData | undefined;
baggage: string | undefined;
params: ParsedUrlQuery | undefined;
} {
let nextData: SentryEnhancedNextData | undefined;
Copy link
Member

Choose a reason for hiding this comment

The 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) {
    // ...
  }
}

Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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__');
}
}

// `nextData.page` always contains the parameterized route
const route = (nextData || {}).page;
const params = (nextData || {}).query;

const getInitialPropsBaggage = ((nextData || {}).props || {})._sentryGetInitialPropsBaggage;
const getServerSidePropsBaggage = (((nextData || {}).props || {}).pageProps || {})._sentryGetServerSidePropsBaggage;

const getInitialPropsTraceData = ((nextData || {}).props || {})._sentryGetInitialPropsTraceData;
const getServerSidePropsTraceData = (((nextData || {}).props || {}).pageProps || {})
._sentryGetServerSidePropsTraceData;

// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` so we give it priority.
const baggage = getInitialPropsBaggage || getServerSidePropsBaggage;
const traceData = getInitialPropsTraceData || getServerSidePropsTraceData;

return {
route,
params,
traceParentData: traceData ? extractTraceparentData(traceData) : undefined,
baggage,
};
}

const DEFAULT_TAGS = {
'routing.instrumentation': 'next-router',
} as const;
Expand All @@ -30,24 +107,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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { route, traceParentData, baggage, params } = extractNextDataTagInformation();
// Pull data from the `__NEXT_DATA__` script tag automatically injected serverside by nextjs
const { route, traceParentData, baggage, params } = extractNextDataTagInformation();


prevTransactionName = route || global.document.location.pathname;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the change to global.document.location? Let's not change for the sake for change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was just easier to test. I generally do not change stuff for the sake of changing them. -> 651e8e0

const source = route ? 'route' : 'url';

activeTransaction = startTransactionCb({
name: prevTransactionName,
op: 'pageload',
tags: DEFAULT_TAGS,
...(params && { data: params }),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc setting data does nothing for transactions - can we throw it into some other part of the event? Like a context?

Does this have PII data concerns? Perhaps we guard this with sendDefaultPII.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally found a use for that flag! Yup there are potential pii issues. Added the flag but removed tests around data because that's just not worth the effort of mocking the hub/client/options. 0ba0d6e

Btw, this data shows up in the trace details when you open a transaction:

Screen Shot 2022-08-12 at 18 02 39

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, this data shows up in the trace details when you open a transaction:

Ah yeah - that means it'll show up on linked errors as well (since we copy over the entire trace context). I guess since we don't index this we can leave it for now.

...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;
Expand Down Expand Up @@ -78,7 +158,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]
Expand Down Expand Up @@ -115,5 +195,4 @@ function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): Wrap
}
return originalChangeStateWrapper.call(this, method, url, as, options, ...args);
};
return wrapper;
}
13 changes: 13 additions & 0 deletions packages/nextjs/test/index.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +17,18 @@ 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 });

const originalGlobalDocument = getGlobalObject<Window>().document;
afterAll(() => {
// Clean up JSDom
Object.defineProperty(global, 'document', {
value: originalGlobalDocument,
});
});

describe('Client init()', () => {
afterEach(() => {
jest.clearAllMocks();
Expand Down
156 changes: 143 additions & 13 deletions packages/nextjs/test/performance/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getGlobalObject } from '@sentry/utils';
import { JSDOM } from 'jsdom';
import { default as Router } from 'next/router';

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

describe('nextRouterInstrumentation', () => {
const originalGlobalDocument = getGlobalObject<Window>().document;

function setUpNextPage(pageProperties: {
url: string;
route: string;
query: any;
props: any;
hasNextData: boolean;
}) {
const dom = new JSDOM(
// Just some example what a __NEXT_DATA__ tag might look like
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Just some example what a __NEXT_DATA__ tag might look like
// Just an example of what a __NEXT_DATA__ tag might look like

pageProperties.hasNextData
? `<body>
<script id="__NEXT_DATA__" type="application/json">
{
"props": ${JSON.stringify(pageProperties.props)},
"page": "${pageProperties.route}",
"query": ${JSON.stringify(pageProperties.query)},
"buildId": "y76hvndNJBAithejdVGLW",
"isFallback": false,
"gssp": true,
"appGip": true,
"scriptLoader": []
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Prefer if we create an object and stringify it for readability + we can type with the type exported from nextjs.

Copy link
Member Author

@lforst lforst Aug 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 bec58e0

</script>
</body>`
: '<body><h1>No next data :(</h1></body>',
{ url: pageProperties.url },
);

Object.defineProperty(global, 'document', { value: dom.window.document, writable: true });
}

afterEach(() => {
// Clean up JSDom
Object.defineProperty(global, 'document', { value: originalGlobalDocument });
});

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',
},
data: {
user: 'lforst',
id: '1337',
q: '42',
},
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',
},
data: {},
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',
},
data: {},
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) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(url, route, query, props, hasNextData, expectedStartTransactionCall) => {
(url, route, query, props, hasNextData, expectedStartTransactionArguments) => {

It might be nice to put this list in a comment at the top of the it.each, and/or stick a blank line between test cases - it's pretty hard to tell what you're looking at given that it's half a dozen items per test case, and each test case goes on for a variable number of lines.

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();
Expand Down