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): Connect trace between data-fetching methods and pageload #5655

Merged
merged 13 commits into from
Sep 1, 2022
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { hasTracingEnabled } from '@sentry/tracing';
import { serializeBaggage } from '@sentry/utils';
import { GetServerSideProps } from 'next';

import { isBuild } from '../../utils/isBuild';
import { callTracedServerSideDataFetcher, withErrorInstrumentation } from './wrapperUtils';
import { callTracedServerSideDataFetcher, getTransactionFromRequest, withErrorInstrumentation } from './wrapperUtils';

/**
* Create a wrapped version of the user's exported `getServerSideProps` function
Expand All @@ -28,13 +29,29 @@ export function withSentryGetServerSideProps(
const errorWrappedGetServerSideProps = withErrorInstrumentation(origGetServerSideProps);

if (hasTracingEnabled()) {
return callTracedServerSideDataFetcher(errorWrappedGetServerSideProps, getServerSidePropsArguments, req, res, {
dataFetcherRouteName: parameterizedRoute,
requestedRouteName: parameterizedRoute,
dataFetchingMethodName: 'getServerSideProps',
});
const serverSideProps = await callTracedServerSideDataFetcher(
errorWrappedGetServerSideProps,
getServerSidePropsArguments,
req,
res,
{
dataFetcherRouteName: parameterizedRoute,
requestedRouteName: parameterizedRoute,
dataFetchingMethodName: 'getServerSideProps',
},
);

if ('props' in serverSideProps) {
const requestTransaction = getTransactionFromRequest(req);
if (requestTransaction) {
serverSideProps.props._sentryGetServerSidePropsTraceData = requestTransaction.toTraceparent();
serverSideProps.props._sentryGetServerSidePropsBaggage = serializeBaggage(requestTransaction.getBaggage());
}
}

return serverSideProps;
} else {
return errorWrappedGetServerSideProps(...getServerSidePropsArguments);
return origGetServerSideProps(...getServerSidePropsArguments);
Copy link
Member

Choose a reason for hiding this comment

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

Why remove the error handling here?

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 think I was just messing around and forgot to put it back. Good catch! Changed it back in faaba12

}
};
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { hasTracingEnabled } from '@sentry/tracing';
import { serializeBaggage } from '@sentry/utils';
import App from 'next/app';

import { isBuild } from '../../utils/isBuild';
import { callTracedServerSideDataFetcher, withErrorInstrumentation } from './wrapperUtils';
import { callTracedServerSideDataFetcher, getTransactionFromRequest, withErrorInstrumentation } from './wrapperUtils';

type AppGetInitialProps = typeof App['getInitialProps'];

Expand Down Expand Up @@ -30,12 +31,30 @@ export function withSentryServerSideAppGetInitialProps(origAppGetInitialProps: A
if (hasTracingEnabled()) {
// Since this wrapper is only applied to `getInitialProps` running on the server, we can assert that `req` and
// `res` are always defined: https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return callTracedServerSideDataFetcher(errorWrappedAppGetInitialProps, appGetInitialPropsArguments, req!, res!, {
dataFetcherRouteName: '/_app',
requestedRouteName: context.ctx.pathname,
dataFetchingMethodName: 'getInitialProps',
});
const appGetInitialProps: {
pageProps: {
_sentryGetInitialPropsTraceData?: string;
_sentryGetInitialPropsBaggage?: string;
};
} = await callTracedServerSideDataFetcher(
errorWrappedAppGetInitialProps,
appGetInitialPropsArguments,
req!,
res!,
{
dataFetcherRouteName: '/_app',
requestedRouteName: context.ctx.pathname,
dataFetchingMethodName: 'getInitialProps',
},
);

const requestTransaction = getTransactionFromRequest(req!);
if (requestTransaction) {
appGetInitialProps.pageProps._sentryGetInitialPropsTraceData = requestTransaction.toTraceparent();
appGetInitialProps.pageProps._sentryGetInitialPropsBaggage = serializeBaggage(requestTransaction.getBaggage());
}

return appGetInitialProps;
} else {
return errorWrappedAppGetInitialProps(...appGetInitialPropsArguments);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { hasTracingEnabled } from '@sentry/tracing';
import { serializeBaggage } from '@sentry/utils';
import { NextPageContext } from 'next';
import { ErrorProps } from 'next/error';

import { isBuild } from '../../utils/isBuild';
import { callTracedServerSideDataFetcher, withErrorInstrumentation } from './wrapperUtils';
import { callTracedServerSideDataFetcher, getTransactionFromRequest, withErrorInstrumentation } from './wrapperUtils';

type ErrorGetInitialProps = (context: NextPageContext) => Promise<ErrorProps>;

Expand Down Expand Up @@ -33,12 +34,28 @@ export function withSentryServerSideErrorGetInitialProps(
if (hasTracingEnabled()) {
// Since this wrapper is only applied to `getInitialProps` running on the server, we can assert that `req` and
// `res` are always defined: https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return callTracedServerSideDataFetcher(errorWrappedGetInitialProps, errorGetInitialPropsArguments, req!, res!, {
dataFetcherRouteName: '/_error',
requestedRouteName: context.pathname,
dataFetchingMethodName: 'getInitialProps',
});
const errorGetInitialProps: ErrorProps & {
_sentryGetInitialPropsTraceData?: string;
_sentryGetInitialPropsBaggage?: string;
} = await callTracedServerSideDataFetcher(
errorWrappedGetInitialProps,
errorGetInitialPropsArguments,
req!,
res!,
{
dataFetcherRouteName: '/_error',
requestedRouteName: context.pathname,
dataFetchingMethodName: 'getInitialProps',
},
);

const requestTransaction = getTransactionFromRequest(req!);
if (requestTransaction) {
errorGetInitialProps._sentryGetInitialPropsTraceData = requestTransaction.toTraceparent();
errorGetInitialProps._sentryGetInitialPropsBaggage = serializeBaggage(requestTransaction.getBaggage());
}

return errorGetInitialProps;
} else {
return errorWrappedGetInitialProps(...errorGetInitialPropsArguments);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { hasTracingEnabled } from '@sentry/tracing';
import { serializeBaggage } from '@sentry/utils';
import { NextPage } from 'next';

import { isBuild } from '../../utils/isBuild';
import { callTracedServerSideDataFetcher, withErrorInstrumentation } from './wrapperUtils';
import { callTracedServerSideDataFetcher, getTransactionFromRequest, withErrorInstrumentation } from './wrapperUtils';

type GetInitialProps = Required<NextPage>['getInitialProps'];

Expand All @@ -29,12 +30,22 @@ export function withSentryServerSideGetInitialProps(origGetInitialProps: GetInit
if (hasTracingEnabled()) {
// Since this wrapper is only applied to `getInitialProps` running on the server, we can assert that `req` and
// `res` are always defined: https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return callTracedServerSideDataFetcher(errorWrappedGetInitialProps, getInitialPropsArguments, req!, res!, {
const initialProps: {
_sentryGetInitialPropsTraceData?: string;
_sentryGetInitialPropsBaggage?: string;
} = await callTracedServerSideDataFetcher(errorWrappedGetInitialProps, getInitialPropsArguments, req!, res!, {
dataFetcherRouteName: context.pathname,
requestedRouteName: context.pathname,
dataFetchingMethodName: 'getInitialProps',
});

const requestTransaction = getTransactionFromRequest(req!);
if (requestTransaction) {
initialProps._sentryGetInitialPropsTraceData = requestTransaction.toTraceparent();
initialProps._sentryGetInitialPropsBaggage = serializeBaggage(requestTransaction.getBaggage());
}

return initialProps;
} else {
return errorWrappedGetInitialProps(...getInitialPropsArguments);
}
Expand Down
24 changes: 20 additions & 4 deletions packages/nextjs/src/config/wrappers/wrapperUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { captureException, getCurrentHub, startTransaction } from '@sentry/core'
import { addRequestDataToEvent } from '@sentry/node';
import { getActiveTransaction } from '@sentry/tracing';
import { Transaction } from '@sentry/types';
import { fill } from '@sentry/utils';
import { extractTraceparentData, fill, isString, parseBaggageSetMutability } from '@sentry/utils';
import * as domain from 'domain';
import { IncomingMessage, ServerResponse } from 'http';

Expand All @@ -12,7 +12,14 @@ declare module 'http' {
}
}

function getTransactionFromRequest(req: IncomingMessage): Transaction | undefined {
/**
* Grabs a transaction off a Next.js datafetcher request object, if it was previously put there via
* `setTransactionOnRequest`.
*
* @param req The Next.js datafetcher request object
* @returns the Transaction on the request object if there is one, or `undefined` if the request object didn't have one.
*/
export function getTransactionFromRequest(req: IncomingMessage): Transaction | undefined {
return req._sentryTransaction;
}
Comment on lines +15 to 24
Copy link
Member

Choose a reason for hiding this comment

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

Why did you choose to wrap this line in a function? (Same goes for the setter.)

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 don't know to be honest. It just made sense to me to put this behind a little bit of abstraction to provide more context why this field exists on the req object. Feel free to remove it in the future though! I think the ambient type (declare module 'http' { at the top of the file) provides enough context.


Expand All @@ -38,12 +45,15 @@ export function withErrorInstrumentation<F extends (...args: any[]) => any>(
return function (this: unknown, ...origFunctionArguments: Parameters<F>): ReturnType<F> {
try {
const potentialPromiseResult = origFunction.call(this, ...origFunctionArguments);

// First of all, we need to capture promise rejections so we have the following check, as well as the try-catch block.
// Additionally, we do the following instead of `await`-ing so we do not change the method signature of the passed function from `() => unknown` to `() => Promise<unknown>.
Promise.resolve(potentialPromiseResult).catch(err => {
// TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that.
captureException(err);
throw err;
Copy link
Member Author

Choose a reason for hiding this comment

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

Small bugfix: we need to rethrow the rejected promise here, otherwise it counts as "caught"

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay so this turned out to be problematic. Throwing here caused an uncaught promise rejection to bubble up, which terminates the process in Node 16 and above. I think my approach here was overly clever from the beginning so I changed it to simply await the maybe-promise result. This makes the wrapped function async but as far as I can tell that is fine since everywhere we use it, we're already async.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thinking more about this, if we really want this wrapper function not to be changing the function signature in the future, we can go with that approach, but without the throw.

I think the behaviour stays as expected: awaiting a rejected Promise throws, and you can also catch it via .catch(). The only drawback is, that it will be marked as "caught", not causing an unhandled promise rejection to bubble up. We can maybe circumvent this via queueMicrotask: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises#when_promises_and_tasks_collide. But that again sounds a bit overly clever and is complicated to understand after the fact.

});

return potentialPromiseResult;
} catch (e) {
// TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that.
Expand Down Expand Up @@ -85,13 +95,20 @@ export function callTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
let requestTransaction: Transaction | undefined = getTransactionFromRequest(req);

if (requestTransaction === undefined) {
// TODO: Extract trace data from `req` object (trace and baggage headers) and attach it to transaction
const sentryTraceHeader = req.headers['sentry-trace'];
const rawBaggageString = req.headers && isString(req.headers.baggage) && req.headers.baggage;
const traceparentData =
typeof sentryTraceHeader === 'string' ? extractTraceparentData(sentryTraceHeader) : undefined;

const baggage = parseBaggageSetMutability(rawBaggageString, traceparentData);

const newTransaction = startTransaction({
op: 'nextjs.data.server',
name: options.requestedRouteName,
...traceparentData,
metadata: {
source: 'route',
baggage,
},
});

Expand Down Expand Up @@ -121,7 +138,6 @@ export function callTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
}

try {
// TODO: Inject trace data into returned props
return await origFunction(...origFunctionArguments);
} finally {
dataFetcherSpan.finish();
Expand Down
25 changes: 13 additions & 12 deletions packages/nextjs/src/performance/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ type StartTransactionCb = (context: TransactionContext) => Transaction | undefin
* 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`
Comment on lines -25 to -28
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 got this wrong in a previous PR

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

Expand Down Expand Up @@ -79,25 +78,27 @@ function extractNextDataTagInformation(): NextDataTagInfo {

const { page, query, props } = nextData;

// `nextData.page` always contains the parameterized route
// `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching
// function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the
// parent transaction
nextDataTagInfo.route = page;
nextDataTagInfo.params = query;

if (props) {
const { pageProps } = props;
if (props && props.pageProps) {
const pageProps = props.pageProps;
const getInitialPropsBaggage = pageProps._sentryGetInitialPropsBaggage;
const getServerSidePropsBaggage = pageProps._sentryGetServerSidePropsBaggage;
const getStaticPropsBaggage = pageProps._sentryGetStaticPropsBaggage;

const getInitialPropsBaggage = props._sentryGetInitialPropsBaggage;
const getServerSidePropsBaggage = pageProps && pageProps._sentryGetServerSidePropsBaggage;
const getStaticPropsBaggage = pageProps && pageProps._sentryGetStaticPropsBaggage;
// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` or `getStaticProps` so we give it priority.
const baggage = getInitialPropsBaggage || getServerSidePropsBaggage || getStaticPropsBaggage;
if (baggage) {
nextDataTagInfo.baggage = baggage;
}

const getInitialPropsTraceData = props._sentryGetInitialPropsTraceData;
const getServerSidePropsTraceData = pageProps && pageProps._sentryGetServerSidePropsTraceData;
const getStaticPropsTraceData = pageProps && pageProps._sentryGetStaticPropsTraceData;
const getInitialPropsTraceData = pageProps._sentryGetInitialPropsTraceData;
const getServerSidePropsTraceData = pageProps._sentryGetServerSidePropsTraceData;
const getStaticPropsTraceData = pageProps._sentryGetStaticPropsTraceData;
// Ordering of the following shouldn't matter but `getInitialProps` generally runs before `getServerSideProps` or `getStaticProps` so we give it priority.
const traceData = getInitialPropsTraceData || getServerSidePropsTraceData || getStaticPropsTraceData;
if (traceData) {
Expand Down
8 changes: 5 additions & 3 deletions packages/nextjs/test/performance/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ describe('client', () => {
'/[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',
pageProps: {
_sentryGetInitialPropsTraceData: 'c82b8554881b4d28ad977de04a4fb40a-a755953cd3394d5f-1',
_sentryGetInitialPropsBaggage:
'other=vendor,foo=bar,third=party,last=item,sentry-release=2.1.0,sentry-environment=myEnv',
},
},
true,
{
Expand Down