UNPKG

@honeybadger-io/nextjs

Version:
287 lines (266 loc) 11.3 kB
import Honeybadger from '@honeybadger-io/js'; import { NextRequest, NextResponse } from 'next/server'; import * as nextServer from 'next/server'; import type { NextApiRequest, NextApiResponse } from 'next'; import { emitNodeRequestEvent, emitRequestEvent, insightsHttpEnabled, now, seedNodeRequestEventContext, seedRequestEventContext, } from './insights-instrumentation'; import type { NodeRequestLike } from './insights-instrumentation'; type WaitUntil = (promise: Promise<unknown>) => void /** * 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(): WaitUntil | undefined { const context = (globalThis as Record<symbol, unknown>)[Symbol.for('@next/request-context')] as | { get?: () => { waitUntil?: WaitUntil } | undefined } | undefined const waitUntil = context?.get?.()?.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: unknown): WaitUntil | undefined { const waitUntil = (event as { waitUntil?: WaitUntil } | undefined)?.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: { useAfter?: boolean; waitUntil?: WaitUntil } = {}): Promise<void> | void { const flush = () => Honeybadger.flushAsync().catch(() => { /* logged by the events worker */ }) if (options.useAfter) { const after = (nextServer as { after?: (cb: () => unknown) => void }).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 = options.waitUntil ?? requestContextWaitUntil() if (waitUntil) { waitUntil(flush()) return } return flush() } function configure(overrides?: Parameters<typeof Honeybadger.configure>[0]) { if (Honeybadger.config.apiKey?.length > 0) { return; } let projectRoot: string | undefined = 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?.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: unknown): boolean { const digest = (error as { digest?: unknown } | null | undefined)?.digest return typeof digest === 'string' && digest.startsWith('NEXT_') } type AppRouterHandler = (req: NextRequest | Request, ...args: unknown[]) => Promise<NextResponse> type PagesApiHandler = (req: NextApiRequest, res: NextApiResponse, ...args: unknown[]) => unknown /** * 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: unknown[]): args is [NodeRequestLike, NextApiResponse] { const req = args[0] as { headers?: unknown } | undefined const res = args[1] as { statusCode?: unknown; end?: unknown } | undefined 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: () => unknown, req: Request, canIsolate: boolean, waitUntil?: WaitUntil): Promise<unknown> { 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 as Response | undefined)?.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 as 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: () => unknown, req: NodeRequestLike, res: NextApiResponse, canIsolate: boolean): Promise<unknown> { 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 as 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: () => unknown): Promise<unknown> { try { return await call() } catch (error) { if (isNextControlFlowError(error)) { throw error } await Honeybadger.notifyAsync(error as Error) throw error } } /** * Wraps a handler function with Honeybadger error reporting. Works with App * Router route handlers, middleware, and Pages Router API routes. * * `request_id` / `correlation_id` are read from the `x-request-id` / * `request-id` and `x-correlation-id` / `x-amzn-trace-id` headers (generated * when absent). When `insights: { enabled: true, http: true }` is configured, * a `request.handled` event carrying the ids plus method, path, status and * duration is emitted per request. * * On the Node.js runtime each invocation additionally runs inside * `Honeybadger.run(...)`, so context is isolated per request and the ids are * seeded onto the event context — merged onto every event emitted during the * request, including programmatic `Honeybadger.event(...)` calls. On the edge * runtime (browser build, single global store) seeding the shared event * context would leak ids between concurrent requests, so programmatic events * there don't inherit them. * * The webpack config-file auto-injection (`honeybadger.*.config.js`) doesn't * reach API routes or edge middleware, so pass `config` to configure * Honeybadger explicitly there. It's ignored if Honeybadger is already * configured (e.g. by the auto-injected file). */ export function withHoneybadger(handler: AppRouterHandler, config?: Parameters<typeof Honeybadger.configure>[0]): AppRouterHandler export function withHoneybadger(handler: PagesApiHandler, config?: Parameters<typeof Honeybadger.configure>[0]): PagesApiHandler export function withHoneybadger(handler: AppRouterHandler | PagesApiHandler, config?: Parameters<typeof Honeybadger.configure>[0]) { 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 = (): unknown => { // 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() }, }); }