|
| 1 | +import { captureException, flush, getCurrentHub, startTransaction } from '@sentry/node'; |
| 2 | +import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing'; |
| 3 | +import { |
| 4 | + addExceptionMechanism, |
| 5 | + baggageHeaderToDynamicSamplingContext, |
| 6 | + isString, |
| 7 | + logger, |
| 8 | + objectify, |
| 9 | + stripUrlQueryAndFragment, |
| 10 | +} from '@sentry/utils'; |
| 11 | +import * as domain from 'domain'; |
| 12 | + |
1 | 13 | import { formatAsCode, nextLogger } from '../../utils/nextLogger';
|
2 |
| -// We import these types from `withSentry` rather than directly from `next` because our version can work simultaneously |
3 |
| -// with multiple versions of next. See note in `withSentry` for more. |
4 |
| -import type { NextApiHandler, WrappedNextApiHandler } from '../../utils/withSentry'; |
5 |
| -import { withSentry } from '../../utils/withSentry'; |
| 14 | +import type { |
| 15 | + AugmentedNextApiRequest, |
| 16 | + AugmentedNextApiResponse, |
| 17 | + NextApiHandler, |
| 18 | + ResponseEndMethod, |
| 19 | + WrappedNextApiHandler, |
| 20 | + WrappedResponseEndMethod, |
| 21 | +} from './types'; |
6 | 22 |
|
7 | 23 | /**
|
8 | 24 | * Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only
|
@@ -37,3 +53,207 @@ export function withSentryAPI(
|
37 | 53 |
|
38 | 54 | return withSentry(maybeWrappedHandler, parameterizedRoute);
|
39 | 55 | }
|
| 56 | + |
| 57 | +/** |
| 58 | + * Legacy function for manually wrapping API route handlers, now used as the innards of `withSentryAPI`. |
| 59 | + * |
| 60 | + * @param origHandler The user's original API route handler |
| 61 | + * @param parameterizedRoute The route whose handler is being wrapped. Meant for internal use only. |
| 62 | + * @returns A wrapped version of the handler |
| 63 | + */ |
| 64 | +export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: string): WrappedNextApiHandler { |
| 65 | + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types |
| 66 | + return async function sentryWrappedHandler(req: AugmentedNextApiRequest, res: AugmentedNextApiResponse) { |
| 67 | + // We're now auto-wrapping API route handlers using `withSentryAPI` (which uses `withSentry` under the hood), but |
| 68 | + // users still may have their routes manually wrapped with `withSentry`. This check makes `sentryWrappedHandler` |
| 69 | + // idempotent so that those cases don't break anything. |
| 70 | + if (req.__withSentry_applied__) { |
| 71 | + return origHandler(req, res); |
| 72 | + } |
| 73 | + req.__withSentry_applied__ = true; |
| 74 | + |
| 75 | + // first order of business: monkeypatch `res.end()` so that it will wait for us to send events to sentry before it |
| 76 | + // fires (if we don't do this, the lambda will close too early and events will be either delayed or lost) |
| 77 | + // eslint-disable-next-line @typescript-eslint/unbound-method |
| 78 | + res.end = wrapEndMethod(res.end); |
| 79 | + |
| 80 | + // use a domain in order to prevent scope bleed between requests |
| 81 | + const local = domain.create(); |
| 82 | + local.add(req); |
| 83 | + local.add(res); |
| 84 | + |
| 85 | + // `local.bind` causes everything to run inside a domain, just like `local.run` does, but it also lets the callback |
| 86 | + // return a value. In our case, all any of the codepaths return is a promise of `void`, but nextjs still counts on |
| 87 | + // getting that before it will finish the response. |
| 88 | + const boundHandler = local.bind(async () => { |
| 89 | + const currentScope = getCurrentHub().getScope(); |
| 90 | + |
| 91 | + if (currentScope) { |
| 92 | + currentScope.setSDKProcessingMetadata({ request: req }); |
| 93 | + |
| 94 | + if (hasTracingEnabled()) { |
| 95 | + // If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision) |
| 96 | + let traceparentData; |
| 97 | + if (req.headers && isString(req.headers['sentry-trace'])) { |
| 98 | + traceparentData = extractTraceparentData(req.headers['sentry-trace']); |
| 99 | + __DEBUG_BUILD__ && logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`); |
| 100 | + } |
| 101 | + |
| 102 | + const baggageHeader = req.headers && req.headers.baggage; |
| 103 | + const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggageHeader); |
| 104 | + |
| 105 | + // prefer the parameterized route, if we have it (which we will if we've auto-wrapped the route handler) |
| 106 | + let reqPath = parameterizedRoute; |
| 107 | + |
| 108 | + // If not, fake it by just replacing parameter values with their names, hoping that none of them match either |
| 109 | + // each other or any hard-coded parts of the path |
| 110 | + if (!reqPath) { |
| 111 | + const url = `${req.url}`; |
| 112 | + // pull off query string, if any |
| 113 | + reqPath = stripUrlQueryAndFragment(url); |
| 114 | + // Replace with placeholder |
| 115 | + if (req.query) { |
| 116 | + for (const [key, value] of Object.entries(req.query)) { |
| 117 | + reqPath = reqPath.replace(`${value}`, `[${key}]`); |
| 118 | + } |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + const reqMethod = `${(req.method || 'GET').toUpperCase()} `; |
| 123 | + |
| 124 | + const transaction = startTransaction( |
| 125 | + { |
| 126 | + name: `${reqMethod}${reqPath}`, |
| 127 | + op: 'http.server', |
| 128 | + ...traceparentData, |
| 129 | + metadata: { |
| 130 | + dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, |
| 131 | + source: 'route', |
| 132 | + request: req, |
| 133 | + }, |
| 134 | + }, |
| 135 | + // extra context passed to the `tracesSampler` |
| 136 | + { request: req }, |
| 137 | + ); |
| 138 | + currentScope.setSpan(transaction); |
| 139 | + |
| 140 | + // save a link to the transaction on the response, so that even if there's an error (landing us outside of |
| 141 | + // the domain), we can still finish it (albeit possibly missing some scope data) |
| 142 | + res.__sentryTransaction = transaction; |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + try { |
| 147 | + const handlerResult = await origHandler(req, res); |
| 148 | + |
| 149 | + if (process.env.NODE_ENV === 'development' && !process.env.SENTRY_IGNORE_API_RESOLUTION_ERROR) { |
| 150 | + // eslint-disable-next-line no-console |
| 151 | + console.warn( |
| 152 | + `[sentry] If Next.js logs a warning "API resolved without sending a response", it's a false positive, which we're working to rectify. |
| 153 | + In the meantime, to suppress this warning, set \`SENTRY_IGNORE_API_RESOLUTION_ERROR\` to 1 in your env. |
| 154 | + To suppress the nextjs warning, use the \`externalResolver\` API route option (see https://nextjs.org/docs/api-routes/api-middlewares#custom-config for details).`, |
| 155 | + ); |
| 156 | + } |
| 157 | + |
| 158 | + return handlerResult; |
| 159 | + } catch (e) { |
| 160 | + // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can |
| 161 | + // store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced |
| 162 | + // to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a |
| 163 | + // way to prevent it from actually being reported twice.) |
| 164 | + const objectifiedErr = objectify(e); |
| 165 | + |
| 166 | + if (currentScope) { |
| 167 | + currentScope.addEventProcessor(event => { |
| 168 | + addExceptionMechanism(event, { |
| 169 | + type: 'instrument', |
| 170 | + handled: true, |
| 171 | + data: { |
| 172 | + wrapped_handler: origHandler.name, |
| 173 | + function: 'withSentry', |
| 174 | + }, |
| 175 | + }); |
| 176 | + return event; |
| 177 | + }); |
| 178 | + |
| 179 | + captureException(objectifiedErr); |
| 180 | + } |
| 181 | + |
| 182 | + // Because we're going to finish and send the transaction before passing the error onto nextjs, it won't yet |
| 183 | + // have had a chance to set the status to 500, so unless we do it ourselves now, we'll incorrectly report that |
| 184 | + // the transaction was error-free |
| 185 | + res.statusCode = 500; |
| 186 | + res.statusMessage = 'Internal Server Error'; |
| 187 | + |
| 188 | + // Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors |
| 189 | + // out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the |
| 190 | + // moment they detect an error, so it's important to get this done before rethrowing the error. Apps not |
| 191 | + // deployed serverlessly will run into this cleanup function again in `res.end(), but it'll just no-op.) |
| 192 | + await finishSentryProcessing(res); |
| 193 | + |
| 194 | + // We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it |
| 195 | + // would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark |
| 196 | + // the error as already having been captured.) |
| 197 | + throw objectifiedErr; |
| 198 | + } |
| 199 | + }); |
| 200 | + |
| 201 | + // Since API route handlers are all async, nextjs always awaits the return value (meaning it's fine for us to return |
| 202 | + // a promise here rather than a real result, and it saves us the overhead of an `await` call.) |
| 203 | + return boundHandler(); |
| 204 | + }; |
| 205 | +} |
| 206 | + |
| 207 | +/** |
| 208 | + * Wrap `res.end()` so that it closes the transaction and flushes events before letting the request finish. |
| 209 | + * |
| 210 | + * Note: This wraps a sync method with an async method. While in general that's not a great idea in terms of keeping |
| 211 | + * things in the right order, in this case it's safe, because the native `.end()` actually *is* async, and its run |
| 212 | + * actually *is* awaited, just manually so (which reflects the fact that the core of the request/response code in Node |
| 213 | + * by far predates the introduction of `async`/`await`). When `.end()` is done, it emits the `prefinish` event, and |
| 214 | + * only once that fires does request processing continue. See |
| 215 | + * https://github.com/nodejs/node/commit/7c9b607048f13741173d397795bac37707405ba7. |
| 216 | + * |
| 217 | + * @param origEnd The original `res.end()` method |
| 218 | + * @returns The wrapped version |
| 219 | + */ |
| 220 | +function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod { |
| 221 | + return async function newEnd(this: AugmentedNextApiResponse, ...args: unknown[]) { |
| 222 | + await finishSentryProcessing(this); |
| 223 | + |
| 224 | + return origEnd.call(this, ...args); |
| 225 | + }; |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * Close the open transaction (if any) and flush events to Sentry. |
| 230 | + * |
| 231 | + * @param res The outgoing response for this request, on which the transaction is stored |
| 232 | + */ |
| 233 | +async function finishSentryProcessing(res: AugmentedNextApiResponse): Promise<void> { |
| 234 | + const { __sentryTransaction: transaction } = res; |
| 235 | + |
| 236 | + if (transaction) { |
| 237 | + transaction.setHttpStatus(res.statusCode); |
| 238 | + |
| 239 | + // Push `transaction.finish` to the next event loop so open spans have a better chance of finishing before the |
| 240 | + // transaction closes, and make sure to wait until that's done before flushing events |
| 241 | + const transactionFinished: Promise<void> = new Promise(resolve => { |
| 242 | + setImmediate(() => { |
| 243 | + transaction.finish(); |
| 244 | + resolve(); |
| 245 | + }); |
| 246 | + }); |
| 247 | + await transactionFinished; |
| 248 | + } |
| 249 | + |
| 250 | + // Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda |
| 251 | + // ends. If there was an error, rethrow it so that the normal exception-handling mechanisms can apply. |
| 252 | + try { |
| 253 | + __DEBUG_BUILD__ && logger.log('Flushing events...'); |
| 254 | + await flush(2000); |
| 255 | + __DEBUG_BUILD__ && logger.log('Done flushing events'); |
| 256 | + } catch (e) { |
| 257 | + __DEBUG_BUILD__ && logger.log('Error while flushing events:\n', e); |
| 258 | + } |
| 259 | +} |
0 commit comments