UNPKG

@honeybadger-io/nextjs

Version:
358 lines (354 loc) 14.4 kB
import Honeybadger from '@honeybadger-io/js'; import * as nextServer from 'next/server'; /** * Edge-safe equivalents of the inbound instrumentation helpers in * `@honeybadger-io/js` (src/server/instrumentation/http_event.ts). They are * duplicated here because this module must also load on the edge runtime where * Node builtins (the `crypto` module, `process.hrtime`) are unavailable. Keep * the header names and the `request_id` / `correlation_id` contract in sync * with that file. * * Both request shapes Next.js uses are supported: the `*RequestEventContext` / * `*RequestEvent` pairs come in a web-`Headers`/`Request` variant (App Router * route handlers and middleware) and a Node-bag variant (Pages Router API * routes, which only ever run on the Node runtime). */ function generateId() { const webCrypto = globalThis.crypto; if (webCrypto && typeof webCrypto.randomUUID === 'function') { try { return webCrypto.randomUUID(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars catch (error) { // fall through to manual generation } } // v4-shaped, not crypto-quality. Acceptable since this is a correlation id, // not a security token. return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { const r = (Math.random() * 16) | 0; const v = ch === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function readHeader(headers, name) { const value = headers.get(name); if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length ? trimmed : undefined; } function readNodeHeader(headers, name) { if (!headers) { return undefined; } const lower = name.toLowerCase(); let value = headers[lower]; if (value === undefined) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === lower) { value = headers[key]; break; } } } if (Array.isArray(value)) { value = value[0]; } if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length ? trimmed : undefined; } // Shared id precedence. Kept in one place (rather than once per request shape) // so the header-name contract documented above is only spelled out once. function seedIds(read) { var _a, _b, _c, _d; const requestId = (_b = (_a = read('x-request-id')) !== null && _a !== void 0 ? _a : read('request-id')) !== null && _b !== void 0 ? _b : generateId(); const correlationId = (_d = (_c = read('x-correlation-id')) !== null && _c !== void 0 ? _c : read('x-amzn-trace-id')) !== null && _d !== void 0 ? _d : requestId; return { request_id: requestId, correlation_id: correlationId }; } // App Router / middleware: headers are a web `Headers` instance. function seedRequestEventContext(headers) { return seedIds((name) => readHeader(headers, name)); } // Pages Router: headers are a Node bag (Pages routes are Node-only, never edge). function seedNodeRequestEventContext(headers) { return seedIds((name) => readNodeHeader(headers, name)); } function now() { return typeof performance !== 'undefined' ? performance.now() : Date.now(); } // Mirrors Util.resolveInsights from @honeybadger-io/core: the master gate and // the per-source flag must both be on. function insightsHttpEnabled() { const insights = Honeybadger.config.insights; return (insights === null || insights === void 0 ? void 0 : insights.enabled) === true && (insights === null || insights === void 0 ? void 0 : insights.http) === true; } // The ids are embedded directly in the payload (instead of relying on the // store's eventContext merge) so the event carries them even on the edge // runtime, where there is no per-request store isolation. On the Node.js // runtime they match the seeded event context, so embedding is a no-op. function emitHandledEvent(method, path, status, start, ids) { const payload = { method, duration: Math.round(now() - start), ...ids, }; if (typeof path === 'string') { payload.path = path; } if (typeof status === 'number') { payload.status = status; } Honeybadger.event('request.handled', payload); } // App Router / middleware: `req.url` is absolute, so parse out the pathname. function emitRequestEvent(req, status, start, ids) { let path; try { path = new URL(req.url).pathname; } // eslint-disable-next-line @typescript-eslint/no-unused-vars catch (error) { // relative or malformed URL — leave path unset } emitHandledEvent(req.method, path, status, start, ids); } // Pages Router: `req.url` is a relative path that may carry a query string. function emitNodeRequestEvent(req, status, start, ids) { const path = typeof req.url === 'string' ? req.url.split('?')[0] : undefined; emitHandledEvent(req.method, path, status, start, ids); } /** * The `waitUntil` primitive the hosting platform injects per request. Next.js * resolves `after()` through this same accessor, and it is the only channel * available in Pages Router API routes, which are invoked as `(req, res)` with * no context argument to read it from. */ function requestContextWaitUntil() { var _a, _b; const context = globalThis[Symbol.for('@next/request-context')]; const waitUntil = (_b = (_a = context === null || context === void 0 ? void 0 : context.get) === null || _a === void 0 ? void 0 : _a.call(context)) === null || _b === void 0 ? void 0 : _b.waitUntil; return typeof waitUntil === 'function' ? waitUntil : undefined; } /** * Middleware receives a `NextFetchEvent` as its second argument. Duck-typed * rather than `instanceof` so the edge bundle needs no runtime import, and * bound because `waitUntil` is a class method that collects into the event. */ function eventWaitUntil(event) { const waitUntil = event === null || event === void 0 ? void 0 : event.waitUntil; return typeof waitUntil === 'function' ? waitUntil.bind(event) : undefined; } /** * Ensure Insights events are delivered before the serverless/edge runtime * freezes, without delaying the response where the runtime lets us avoid it. * * In order of preference: Next.js `after()` (stable in 15.1, App Router only — * it needs App Router request context, so Pages Router must not call it), then * a `waitUntil` from the middleware event or the platform request context, * then a blocking `flushAsync()` when the runtime offers neither. Blocking is * correct in that last case: no `waitUntil` means nothing is going to freeze * the invocation out from under us. * * Delivery failures are logged by the events worker and must not break the handler. */ function scheduleFlush(options = {}) { var _a; const flush = () => Honeybadger.flushAsync().catch(() => { }); if (options.useAfter) { const after = nextServer.after; if (typeof after === 'function') { // Exported but still refusable: `after()` throws outside a supported // context. Fall through to the remaining strategies rather than failing // the request. try { after(flush); return; } // eslint-disable-next-line @typescript-eslint/no-unused-vars catch (error) { // try waitUntil / blocking flush below } } } const waitUntil = (_a = options.waitUntil) !== null && _a !== void 0 ? _a : requestContextWaitUntil(); if (waitUntil) { waitUntil(flush()); return; } return flush(); } function configure(overrides) { var _a; if (((_a = Honeybadger.config.apiKey) === null || _a === void 0 ? void 0 : _a.length) > 0) { return; } let projectRoot = undefined; try { // not available on edge runtime projectRoot = process.cwd(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars catch (error) { // do nothing } Honeybadger .configure({ apiKey: process.env.NEXT_PUBLIC_HONEYBADGER_API_KEY, environment: process.env.NEXT_PUBLIC_VERCEL_ENV || process.env.VERCEL_ENV || process.env.NODE_ENV, revision: process.env.NEXT_PUBLIC_HONEYBADGER_REVISION, projectRoot: 'webpack://_N_E/./', ...overrides, }) .beforeNotify((notice) => { if (!projectRoot) { return; } notice === null || notice === void 0 ? void 0 : notice.backtrace.forEach((line) => { if (line.file) { line.file = line.file.replace(`${projectRoot}/.next/server`, `${process.env.NEXT_PUBLIC_HONEYBADGER_ASSETS_URL}/..`); } return line; }); }); } /** * Next.js uses thrown errors for control flow: `redirect()`, `notFound()`, * `forbidden()` and `unauthorized()` all throw an error carrying a `digest` * string (`NEXT_REDIRECT;...`, `NEXT_NOT_FOUND`, `NEXT_HTTP_ERROR_FALLBACK;...`). * These are not real failures — the framework catches them upstream to produce * the redirect/404/etc. — so we must let them propagate without reporting them, * otherwise every redirect shows up as an error in Honeybadger. * * We match on the `NEXT_` prefix rather than an exhaustive list so that any * present or future framework control-flow digest is covered. This is safe: * genuine errors that React tags with a `digest` use an opaque hash, and other * Next.js bailout signals (e.g. `BAILOUT_TO_CLIENT_SIDE_RENDERING`, * `DYNAMIC_SERVER_USAGE`) are not `NEXT_`-prefixed, so neither is skipped. */ function isNextControlFlowError(error) { const digest = error === null || error === void 0 ? void 0 : error.digest; return typeof digest === 'string' && digest.startsWith('NEXT_'); } /** * Detects a Pages Router API invocation: `(req, res)` where `res` is a Node * `ServerResponse`. We branch on this structurally because — unlike an App * Router route handler — there is no returned `Response` to read the status * from; it lives on `res.statusCode`. */ function isPagesApiInvocation(args) { const req = args[0]; const res = args[1]; return (!!req && typeof req.headers === 'object' && req.headers !== null && !!res && typeof res.statusCode === 'number' && typeof res.end === 'function'); } /** * App Router route handlers and middleware: a web `Request`/`NextRequest` in, a * `Response`/`NextResponse` out. The status comes from the returned response. * * `waitUntil` is present for middleware (from its `NextFetchEvent`); route * handlers get `{ params }` as their second argument and rely on `after()`. */ async function handleAppRouterRequest(call, req, canIsolate, waitUntil) { const ids = seedRequestEventContext(req.headers); if (canIsolate) { Honeybadger.setEventContext(ids); } const start = insightsHttpEnabled() ? now() : null; try { const response = await call(); if (start !== null) { emitRequestEvent(req, response === null || response === void 0 ? void 0 : response.status, start, ids); await scheduleFlush({ useAfter: true, waitUntil }); } return response; } catch (error) { if (isNextControlFlowError(error)) { throw error; } if (start !== null) { emitRequestEvent(req, 500, start, ids); await scheduleFlush({ useAfter: true, waitUntil }); } await Honeybadger.notifyAsync(error); throw error; } } /** * Pages Router API routes: a Node `req`/`res` pair. The handler writes to `res` * and returns nothing meaningful, so the final status is read from * `res.statusCode` once it resolves. */ async function handlePagesApiRequest(call, req, res, canIsolate) { const ids = seedNodeRequestEventContext(req.headers); if (canIsolate) { Honeybadger.setEventContext(ids); } const start = insightsHttpEnabled() ? now() : null; try { const result = await call(); if (start !== null) { emitNodeRequestEvent(req, res.statusCode, start, ids); // No after() here: Pages Router lacks the App Router request context it // needs. scheduleFlush falls through to the platform waitUntil instead. await scheduleFlush({ useAfter: false }); } return result; } catch (error) { if (isNextControlFlowError(error)) { throw error; } if (start !== null) { emitNodeRequestEvent(req, 500, start, ids); await scheduleFlush({ useAfter: false }); } await Honeybadger.notifyAsync(error); throw error; } } /** * Unrecognised invocation shape: still report errors, but emit no insights * event since we can't reliably read the request. */ async function handleUninstrumented(call) { try { return await call(); } catch (error) { if (isNextControlFlowError(error)) { throw error; } await Honeybadger.notifyAsync(error); throw error; } } function withHoneybadger(handler, config) { configure(config); return new Proxy(handler, { apply: (target, thisArg, args) => { const canIsolate = typeof Honeybadger.run === 'function'; const call = () => Reflect.apply(target, thisArg, args); const invoke = () => { // App Router / middleware first: a web Request as the first argument. if (typeof Request !== 'undefined' && args[0] instanceof Request) { return handleAppRouterRequest(call, args[0], canIsolate, eventWaitUntil(args[1])); } // Pages Router API route: a Node req/res pair. if (isPagesApiInvocation(args)) { return handlePagesApiRequest(call, args[0], args[1], canIsolate); } return handleUninstrumented(call); }; return canIsolate ? Honeybadger.run(invoke) : invoke(); }, }); } export { withHoneybadger }; //# sourceMappingURL=honeybadger-nextjs-edge.esm.js.map