@honeybadger-io/nextjs
Version:
Next.js integration for Honeybadger
358 lines (354 loc) • 14.4 kB
JavaScript
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