Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(nextjs): Delay error propagation until withSentry is done#4027
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
92341fe517f73e4702da768aa490ec6822d87efd29da218b3ed6d6245c89c281af445eFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -3,14 +3,17 @@ import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing'; | ||
| import { Transaction } from '@sentry/types'; | ||
| import { addExceptionMechanism, isString, logger, objectify, stripUrlQueryAndFragment } from '@sentry/utils'; | ||
| import * as domain from 'domain'; | ||
| import { NextApiHandler, NextApiResponse } from 'next'; | ||
| import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'; | ||
| const { parseRequest } = Handlers; | ||
| // purely for clarity | ||
| type WrappedNextApiHandler = NextApiHandler; | ||
| // This is the same as the `NextApiHandler` type, except instead of having a return type of `void | Promise<void>`, it's | ||
| // only `Promise<void>`, because wrapped handlers are always async | ||
| export type WrappedNextApiHandler = (req: NextApiRequest, res: NextApiResponse) => Promise<void>; | ||
| type AugmentedResponse = NextApiResponse & { __sentryTransaction?: Transaction }; | ||
| export type AugmentedNextApiResponse = NextApiResponse & { | ||
| __sentryTransaction?: Transaction; | ||
| }; | ||
| // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types | ||
| export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler => { | ||
| @@ -69,7 +72,7 @@ export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler = | ||
| // save a link to the transaction on the response, so that even if there's an error (landing us outside of | ||
| // the domain), we can still finish it (albeit possibly missing some scope data) | ||
| (res as AugmentedResponse).__sentryTransaction = transaction; | ||
| (res as AugmentedNextApiResponse).__sentryTransaction = transaction; | ||
| } | ||
| } | ||
| @@ -148,19 +151,33 @@ export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler = | ||
| captureException(objectifiedErr); | ||
| } | ||
| // Because we're going to finish and send the transaction before passing the error onto nextjs, it won't yet | ||
| // have had a chance to set the status to 500, so unless we do it ourselves now, we'll incorrectly report that | ||
| // the transaction was error-free | ||
| res.statusCode = 500; | ||
| res.statusMessage = 'Internal Server Error'; | ||
| // Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors | ||
| // out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the | ||
| // moment they detect an error, so it's important to get this done before rethrowing the error. Apps not | ||
| // deployed serverlessly will run into this cleanup function again in `res.end(), but it'll just no-op.) | ||
| await finishSentryProcessing(res); | ||
| // We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it | ||
| // would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark | ||
| // the error as already having been captured.) | ||
| throw objectifiedErr; | ||
| } | ||
| }); | ||
| return await boundHandler(); | ||
| // Since API route handlers are all async, nextjs always awaits the return value (meaning it's fine for us to return | ||
| // a promise here rather than a real result, and it saves us the overhead of an `await` call.) | ||
| return boundHandler(); | ||
| }; | ||
| }; | ||
| type ResponseEndMethod = AugmentedResponse['end']; | ||
| type WrappedResponseEndMethod = AugmentedResponse['end']; | ||
| type ResponseEndMethod = AugmentedNextApiResponse['end']; | ||
| type WrappedResponseEndMethod = AugmentedNextApiResponse['end']; | ||
| /** | ||
| * Wrap `res.end()` so that it closes the transaction and flushes events before letting the request finish. | ||
| @@ -173,32 +190,8 @@ type WrappedResponseEndMethod = AugmentedResponse['end']; | ||
| * @returns The wrapped version | ||
| */ | ||
| function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod { | ||
| return async function newEnd(this: AugmentedResponse, ...args: unknown[]) { | ||
| const transaction = this.__sentryTransaction; | ||
| if (transaction) { | ||
| transaction.setHttpStatus(this.statusCode); | ||
| // Push `transaction.finish` to the next event loop so open spans have a better chance of finishing before the | ||
| // transaction closes, and make sure to wait until that's done before flushing events | ||
| const transactionFinished: Promise<void> = new Promise(resolve => { | ||
| setImmediate(() => { | ||
| transaction.finish(); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| await transactionFinished; | ||
| } | ||
| // flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda | ||
| // ends | ||
| try { | ||
| logger.log('Flushing events...'); | ||
| await flush(2000); | ||
| logger.log('Done flushing events'); | ||
| } catch (e) { | ||
| logger.log(`Error while flushing events:\n${e}`); | ||
| } | ||
| return async function newEnd(this: AugmentedNextApiResponse, ...args: unknown[]) { | ||
| await finishSentryProcessing(this); | ||
| // If the request didn't error, we will have temporarily marked the response finished to avoid a nextjs warning | ||
| // message. (See long note above.) Now we need to flip `finished` back to `false` so that the real `res.end()` | ||
| @@ -208,3 +201,36 @@ function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod { | ||
| return origEnd.call(this, ...args); | ||
| }; | ||
| } | ||
| /** | ||
| * Close the open transaction (if any) and flush events to Sentry. | ||
| * | ||
| * @param res The outgoing response for this request, on which the transaction is stored | ||
| */ | ||
| async function finishSentryProcessing(res: AugmentedNextApiResponse): Promise<void> { | ||
lobsterkatie marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const { __sentryTransaction: transaction } = res; | ||
| if (transaction) { | ||
| transaction.setHttpStatus(res.statusCode); | ||
| // Push `transaction.finish` to the next event loop so open spans have a better chance of finishing before the | ||
| // transaction closes, and make sure to wait until that's done before flushing events | ||
| const transactionFinished: Promise<void> = new Promise(resolve => { | ||
| setImmediate(() => { | ||
| transaction.finish(); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| await transactionFinished; | ||
| } | ||
| // Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda | ||
| // ends. If there was an error, rethrow it so that the normal exception-handling mechanisms can apply. | ||
| try { | ||
| logger.log('Flushing events...'); | ||
| await flush(2000); | ||
| logger.log('Done flushing events'); | ||
| } catch (e) { | ||
| logger.log(`Error while flushing events:\n${e}`); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,102 @@ | ||||||
| import * as Sentry from '@sentry/node'; | ||||||
| import * as utils from '@sentry/utils'; | ||||||
| import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'; | ||||||
| import { AugmentedNextApiResponse, withSentry, WrappedNextApiHandler } from '../../src/utils/withSentry'; | ||||||
| const FLUSH_DURATION = 200; | ||||||
| async function sleep(ms: number): Promise<void> { | ||||||
| await new Promise(resolve => setTimeout(resolve, ms)); | ||||||
| } | ||||||
| /** | ||||||
| * Helper to prevent tests from ending before `flush()` has finished its work. | ||||||
| * | ||||||
| * This is necessary because, like its real-life counterpart, our mocked `res.send()` below doesn't await `res.end() | ||||||
| * (which becomes async when we wrap it in `withSentry` in order to give `flush()` time to run). In real life, the | ||||||
| * request/response cycle is held open as subsequent steps wait for `end()` to emit its `prefinished` event. Here in | ||||||
| * tests, without any of that other machinery, we have to hold it open ourselves. | ||||||
| * | ||||||
| * @param wrappedHandler | ||||||
| * @param req | ||||||
| * @param res | ||||||
| */ | ||||||
| async function callWrappedHandler(wrappedHandler: WrappedNextApiHandler, req: NextApiRequest, res: NextApiResponse) { | ||||||
| await wrappedHandler(req, res); | ||||||
| // we know we need to wait at least this long for `flush()` to finish | ||||||
| await sleep(FLUSH_DURATION); | ||||||
| // should be <1 second, just long enough the `flush()` call to return, the original (pre-wrapping) `res.end()` to be | ||||||
| // called, and the response to be marked as done | ||||||
| while (!res.finished) { | ||||||
| await sleep(100); | ||||||
| } | ||||||
lobsterkatie marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||||||
| } | ||||||
| // We mock `captureException` as a no-op because under normal circumstances it is an un-awaited effectively-async | ||||||
| // function which might or might not finish before any given test ends, potentially leading jest to error out. | ||||||
| const captureExceptionSpy = jest.spyOn(Sentry, 'captureException').mockImplementation(jest.fn()); | ||||||
lobsterkatie marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||||||
| const loggerSpy = jest.spyOn(utils.logger, 'log'); | ||||||
lobsterkatie marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||||||
| const flushSpy = jest.spyOn(Sentry, 'flush').mockImplementation(async () => { | ||||||
| // simulate the time it takes time to flush all events | ||||||
Contributor 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
| ||||||
| await sleep(FLUSH_DURATION); | ||||||
| return true; | ||||||
| }); | ||||||
lobsterkatie marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||||||
| describe('withSentry', () => { | ||||||
| let req: NextApiRequest, res: NextApiResponse; | ||||||
| const noShoesError = new Error('Oh, no! Charlie ate the flip-flops! :-('); | ||||||
| const origHandlerNoError: NextApiHandler = async (_req, res) => { | ||||||
| res.send('Good dog, Maisey!'); | ||||||
| }; | ||||||
| const origHandlerWithError: NextApiHandler = async (_req, _res) => { | ||||||
| throw noShoesError; | ||||||
| }; | ||||||
| const wrappedHandlerNoError = withSentry(origHandlerNoError); | ||||||
| const wrappedHandlerWithError = withSentry(origHandlerWithError); | ||||||
| beforeEach(() => { | ||||||
| req = { url: 'http://dogs.are.great' } as NextApiRequest; | ||||||
| res = ({ | ||||||
| send: function(this: AugmentedNextApiResponse) { | ||||||
| this.end(); | ||||||
| }, | ||||||
| end: function(this: AugmentedNextApiResponse) { | ||||||
| this.finished = true; | ||||||
| }, | ||||||
| } as unknown) as AugmentedNextApiResponse; | ||||||
| }); | ||||||
| afterEach(() => { | ||||||
| jest.clearAllMocks(); | ||||||
| }); | ||||||
| describe('flushing', () => { | ||||||
| it('flushes events before rethrowing error', async () => { | ||||||
lobsterkatie marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||||||
| try { | ||||||
| await callWrappedHandler(wrappedHandlerWithError, req, res); | ||||||
| } catch (err) { | ||||||
| expect(err).toBe(noShoesError); | ||||||
| } | ||||||
| expect(captureExceptionSpy).toHaveBeenCalledWith(noShoesError); | ||||||
| expect(flushSpy).toHaveBeenCalled(); | ||||||
| expect(loggerSpy).toHaveBeenCalledWith('Done flushing events'); | ||||||
| // This ensures the expect inside the `catch` block actually ran, i.e., that in the end the wrapped handler | ||||||
| // errored out the same way it would without sentry, meaning the error was indeed rethrown | ||||||
| expect.assertions(4); | ||||||
| }); | ||||||
| it('flushes events before finishing non-erroring response', async () => { | ||||||
lobsterkatie marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||||||
| await callWrappedHandler(wrappedHandlerNoError, req, res); | ||||||
| expect(flushSpy).toHaveBeenCalled(); | ||||||
| expect(loggerSpy).toHaveBeenCalledWith('Done flushing events'); | ||||||
| }); | ||||||
| }); | ||||||
| }); | ||||||
Uh oh!
There was an error while loading. Please reload this page.