-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(nextjs): Delay error propagation until
withSentry
is done (#4027)
In our nextjs API route wrapper `withSentry`, we capture any errors thrown by the original handler, and then once we've captured them, we rethrow them, so that our capturing of them doesn't interfere with whatever other error handling might go on. Until recently, that was fine, as nextjs didn't actually propagate the error any farther, and so it didn't interfere with our processing pipeline and didn't prevent `res.end()` (on which we rely for finishing the transaction and flushing events to Sentry) from running. However, Vercel released a change[1] which caused said errors to begin propagating if the API route is running on Vercel. (Technically, it's if the server is running in minimal mode, but all API handlers on vercel do.) A side effect of this change is that when there's an error, `res.end()` is no longer called. As a result, the SDK's work is cut short, and neither errors in API route handlers nor transactions tracing such routes make it to Sentry. This fixes that, by moving the work of finishing the transaction and flushing events into its own function and calling it not only in `res.end()` but also before we rethrow the error. (Note: In the cases where there is an error and the server is not running in minimal mode, this means that function will be hit twice, but that's okay, since the second time around it will just no-op, since `transaction.finish()` bails immediately if the transaction is already finished, and `flush()` returns immediately if there's nothing to flush.) H/t to @jmurty for his work in #4044, which helped me fix some problems in my first approach to solving this problem. Fixes #3917. [1] vercel/next.js#26875
- Loading branch information
1 parent
a4108bf
commit fb66a78
Showing
2 changed files
with
162 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
|
||
// 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()); | ||
const loggerSpy = jest.spyOn(utils.logger, 'log'); | ||
const flushSpy = jest.spyOn(Sentry, 'flush').mockImplementation(async () => { | ||
// simulate the time it takes time to flush all events | ||
await sleep(FLUSH_DURATION); | ||
return true; | ||
}); | ||
|
||
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 () => { | ||
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 () => { | ||
await callWrappedHandler(wrappedHandlerNoError, req, res); | ||
|
||
expect(flushSpy).toHaveBeenCalled(); | ||
expect(loggerSpy).toHaveBeenCalledWith('Done flushing events'); | ||
}); | ||
}); | ||
}); |