-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(nextjs): Create transactions in getInitialProps
and getServerSideProps
#5593
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
Changes from all commits
f311209
e189fcb
9f4a2b1
848f03f
fe67cdb
4205f31
74edafb
cf22932
afbe522
59bb0d8
29ee43f
dc9988c
a561697
0e0d912
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,3 +1,3 @@ | ||
export { withSentryGetStaticProps } from './withSentryGetStaticProps'; | ||
export { withSentryGetInitialProps } from './withSentryGetInitialProps'; | ||
export { withSentryGetServerSideProps } from './withSentryGetServerSideProps'; | ||
export { withSentryServerSideGetInitialProps } from './withSentryServerSideGetInitialProps'; |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import { hasTracingEnabled } from '@sentry/tracing'; | ||
import { NextPage } from 'next'; | ||
|
||
import { isBuild } from '../../utils/isBuild'; | ||
import { callTracedServerSideDataFetcher, withErrorInstrumentation } from './wrapperUtils'; | ||
|
||
type GetInitialProps = Required<NextPage>['getInitialProps']; | ||
|
||
/** | ||
* Create a wrapped version of the user's exported `getInitialProps` function | ||
* | ||
* @param origGetInitialProps The user's `getInitialProps` function | ||
* @param parameterizedRoute The page's parameterized route | ||
* @returns A wrapped version of the function | ||
*/ | ||
export function withSentryServerSideGetInitialProps( | ||
origGetInitialProps: GetInitialProps, | ||
parameterizedRoute: string, | ||
): GetInitialProps { | ||
return async function ( | ||
...getInitialPropsArguments: Parameters<GetInitialProps> | ||
): Promise<ReturnType<GetInitialProps>> { | ||
if (isBuild()) { | ||
return origGetInitialProps(...getInitialPropsArguments); | ||
} | ||
|
||
const [context] = getInitialPropsArguments; | ||
const { req, res } = context; | ||
|
||
const errorWrappedGetInitialProps = withErrorInstrumentation(origGetInitialProps); | ||
|
||
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!, { | ||
parameterizedRoute, | ||
functionName: 'getInitialProps', | ||
}); | ||
} else { | ||
return errorWrappedGetInitialProps(...getInitialPropsArguments); | ||
} | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,129 @@ | ||
import { captureException } from '@sentry/core'; | ||
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 * as domain from 'domain'; | ||
import { IncomingMessage, ServerResponse } from 'http'; | ||
|
||
declare module 'http' { | ||
interface IncomingMessage { | ||
_sentryTransaction?: Transaction; | ||
} | ||
} | ||
|
||
function getTransactionFromRequest(req: IncomingMessage): Transaction | undefined { | ||
return req._sentryTransaction; | ||
} | ||
|
||
function setTransactionOnRequest(transaction: Transaction, req: IncomingMessage): void { | ||
req._sentryTransaction = transaction; | ||
} | ||
|
||
function autoEndTransactionOnResponseEnd(transaction: Transaction, res: ServerResponse): void { | ||
fill(res, 'end', (originalEnd: ServerResponse['end']) => { | ||
return function (this: unknown, ...endArguments: Parameters<ServerResponse['end']>) { | ||
transaction.finish(); | ||
return originalEnd.call(this, ...endArguments); | ||
}; | ||
}); | ||
} | ||
|
||
/** | ||
* Wraps a function that potentially throws. If it does, the error is passed to `captureException` and rethrown. | ||
*/ | ||
export function withErrorInstrumentation<F extends (...args: any[]) => any>( | ||
origFunction: F, | ||
): (...params: Parameters<F>) => ReturnType<F> { | ||
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); | ||
}); | ||
return potentialPromiseResult; | ||
} catch (e) { | ||
// TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that. | ||
captureException(e); | ||
throw e; | ||
} | ||
}; | ||
} | ||
|
||
/** | ||
* Calls a server-side data fetching function (that takes a `req` and `res` object in its context) with tracing | ||
* instrumentation. A transaction will be created for the incoming request (if it doesn't already exist) in addition to | ||
* a span for the wrapped data fetching function. | ||
* | ||
* All of the above happens in an isolated domain, meaning all thrown errors will be associated with the correct span. | ||
* | ||
* @param origFunction The data fetching method to call. | ||
* @param origFunctionArguments The arguments to call the data fetching method with. | ||
* @param req The data fetching function's request object. | ||
* @param res The data fetching function's response object. | ||
* @param options Options providing details for the created transaction and span. | ||
* @returns what the data fetching method call returned. | ||
*/ | ||
export function callTracedServerSideDataFetcher<F extends (...args: any[]) => Promise<any> | any>( | ||
origFunction: F, | ||
origFunctionArguments: Parameters<F>, | ||
req: IncomingMessage, | ||
res: ServerResponse, | ||
options: { | ||
parameterizedRoute: string; | ||
functionName: string; | ||
}, | ||
): Promise<ReturnType<F>> { | ||
return domain.create().bind(async () => { | ||
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 newTransaction = startTransaction({ | ||
op: 'nextjs.data', | ||
name: options.parameterizedRoute, | ||
metadata: { | ||
source: 'route', | ||
}, | ||
}); | ||
|
||
requestTransaction = newTransaction; | ||
autoEndTransactionOnResponseEnd(newTransaction, res); | ||
setTransactionOnRequest(newTransaction, req); | ||
} | ||
|
||
const dataFetcherSpan = requestTransaction.startChild({ | ||
op: 'nextjs.data', | ||
description: `${options.functionName} (${options.parameterizedRoute})`, | ||
}); | ||
|
||
const currentScope = getCurrentHub().getScope(); | ||
if (currentScope) { | ||
currentScope.setSpan(dataFetcherSpan); | ||
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. Hmmm... What happens if multiple data-fetching functions for the same request are running at the same time? (Not a current concern, but it will be one eventually, and I figure we should future-proof ourselves as much as we can.) 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 think we're good in this situation. Since we have an individual domain for each of the data fetchers, they also have their own hub/cloned scope, so setting the span should "just work". Let me know if I misunderstand something. 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.
Yeah, okay, I see what you're saying. I think I'm stuck in the mindset which applies in our other framework integrations, where it's one domain/request. I'm trying to think if there's any reason we'd need to have all of a request's data fetchers live in the same domain. I guess the question is, is there anything which happens to the scope in one which we'd want to be persisted to another? Thoughts:
So I think the answer is no, because we don't know the order in which they'll run, so even if they were in the same domain, we couldn't count on one setting data that the next one could use. The one exception here is the event processor to add request data. That's gotta be attached to whatever scope is active when we call 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 spent the night thinking about if there is a really good reason for only having one domain across all data fetchers, since having only one domain has its drawbacks with error association. So far I've not come up with anything. I also believe users in any case already have the mental model that data fetchers are more or less isolated and do not share data between them. If bad comes to worst we can explain this behavior in the docs. As for the request data processor, it turned out to be even simpler. |
||
currentScope.addEventProcessor(event => | ||
addRequestDataToEvent(event, req, { | ||
include: { | ||
// When the `transaction` option is set to true, it tries to extract a transaction name from the request | ||
// object. We don't want this since we already have a high-quality transaction name with a parameterized | ||
// route. Setting `transaction` to `true` will clobber that transaction name. | ||
transaction: false, | ||
}, | ||
}), | ||
); | ||
} | ||
|
||
try { | ||
// TODO: Inject trace data into returned props | ||
return await origFunction(...origFunctionArguments); | ||
} finally { | ||
dataFetcherSpan.finish(); | ||
} | ||
})(); | ||
} | ||
|
||
/** | ||
* Call a data fetcher and trace it. Only traces the function if there is an active transaction on the scope. | ||
|
Uh oh!
There was an error while loading. Please reload this page.