keelnest
v0.1.2
Published
Error capture for Keelnest. Uncaught errors and unhandled rejections from a browser, a Node server and an edge or Workers runtime. No dependencies.
Maintainers
Readme
keelnest
Error capture for Keelnest. It reports uncaught errors and unhandled rejections from a browser, from a Node server, and from an edge or Workers runtime, so that when a client's application breaks you are told what broke instead of finding out from the client.
No dependencies. About 3 KB over the wire in a browser. Nothing it does can throw into your application, block it, or change how it already handles its own errors.
pnpm add keelnest # npm i / yarn add / bun addYou need a key from Settings → Integrations in Keelnest, and the origin of your Keelnest
(https://app.example.com). There are two kinds of key and the difference matters:
| Key | Where it comes from | What it can write to | | --- | --- | --- | | Application key | Integrations → Server → Get this app's key | that one application, and nothing else | | Workspace key | shown at the top of the Integrations card | whichever application's production URL matches the page the report came from |
A server has no page, so a server must use an application key. A browser may use either.
Next.js
Two files, both optional on their own — wire the one you need, or both.
// instrumentation.ts — the server: rendering, Route Handlers, Server Actions, the proxy
export { register, onRequestError } from "keelnest/next";// instrumentation-client.ts — the browser, before React hydrates
import { init } from "keelnest";
init({
key: process.env.NEXT_PUBLIC_KEELNEST_KEY,
host: process.env.NEXT_PUBLIC_KEELNEST_HOST,
});# .env
KEELNEST_KEY=pk_live_… # the application key; server side, never in the bundle
KEELNEST_HOST=https://app.example.com
NEXT_PUBLIC_KEELNEST_KEY=pk_live_… # the key the browser sends; public by design
NEXT_PUBLIC_KEELNEST_HOST=https://app.example.comregister runs once per server instance, in each runtime Next boots. onRequestError is the part
that matters most: Next catches an error thrown while rendering a Server Component or inside a Route
Handler before it ever reaches uncaughtException, so a process-level handler alone would never see
it. To pass options that cannot come from the environment, call configure first:
import { configure, register, onRequestError } from "keelnest/next";
configure({ maxPerMinute: 10, beforeSend: (e) => (e.message.includes("AbortError") ? null : e) });
export { register, onRequestError };Any browser app — Vite, Astro, CRA, plain JavaScript
import { init } from "keelnest";
init({ key: import.meta.env.VITE_KEELNEST_KEY, host: "https://app.example.com" });Call it once, as early in your entry file as you can: errors thrown before init() are not captured,
because nothing was listening yet.
A script tag, for an app you cannot add a dependency to
Some applications are not yours to add a dependency to: a site a client edits by hand, a builder that
owns package.json, a page inside a CMS. For those, Keelnest serves a script of its own at
<host>/e.js and the Integrations card gives you the tag with your key already in it.
The package ships the same reporter as a single self-configuring file, dist/keelnest.js, for when
you would rather serve it yourself or load it from a CDN:
<script async src="https://unpkg.com/keelnest/dist/keelnest.js"
data-key="pk_live_…" data-host="https://app.example.com"></script>Attributes: data-key (required), data-host (defaults to wherever the script was served from,
which is why it is spelled out above), data-sample (0 to 1, default 1), data-requests ("true"
to also report failed requests). Afterwards window.Keelnest.captureError(e) reports something the
page caught itself.
Node — Express, Fastify, a worker, a cron job
import { init } from "keelnest";
init({ key: process.env.KEELNEST_KEY, host: process.env.KEELNEST_HOST });Cloudflare Workers and other edge runtimes
Edge runtimes do not reliably deliver a global error event, so wrap the handler:
import { init, wrap } from "keelnest";
init({ key: env.KEELNEST_KEY, host: "https://app.example.com" });
export default {
fetch: wrap(async (request: Request, env: Env) => {
// …
}),
};wrap reports what the function throws and then throws it on. Deciding what to do about an error
stays with your application, exactly as it was before you added this dependency.
What it sends
One POST to <host>/api/ingest/errors, batched, at most 20 events per request:
{
"key": "pk_live_…",
"page": "https://client.example.com/checkout",
"events": [
{
"kind": "error",
"message": "Cannot read properties of undefined (reading 'total')",
"source": "https://client.example.com/_next/static/chunks/page-4f2.js:1:9821",
"stack": "TypeError: Cannot read properties of undefined…",
"runtime": "browser",
"at": "2026-09-16T09:41:02.118Z"
}
]
}That is the whole payload. There is no field in it that Keelnest does not use.
It never sends form values, request bodies, request headers, cookies, localStorage,
environment variables, or user identifiers. page is the URL with the query string removed, because
a search term is not ours to store. Messages are clipped to 300 characters and stacks to 4,000 before
they leave, and the ingest route blanks anything token-shaped and any email address again on arrival.
Nothing it sends is trusted. The fingerprint that decides which errors are the same error, and the npm package a stack frame sits in, are both computed on the server from the message and the stack. There is no field this package could fill in to merge its errors into another workspace's group, or to blame a dependency it was not in. That matters because the key is public by design — it ships inside a browser bundle, where anyone can read it. A key is a name, not a secret: an application key can only ever write to the one application it names, and a workspace key can only write to an application whose production URL matches the page that sent the report. If a key is being abused, rotate it in Integrations; sites carrying the old one stop reporting from their next load.
What it will not do to your application
An error reporter that throws, blocks, or floods is worse than no error reporter. So:
- It cannot throw into you. Every entry point is wrapped.
captureErrorin acatchblock returns immediately and never adds a second failure to the first.init()on a runtime it does not recognise, or with no key, produces a reporter that does nothing and says nothing. - It does not block. Sends are fire-and-forget with a 3-second deadline. Timers are
unref'd, so a Node process still exits when it is finished. A page unloading sends withsendBeacon. - It does not flood. At most 30 events per minute per client (the ingest door keeps 60 per application), batches of 20, and one fault repeating inside 10 seconds is one report rather than sixty. A crash loop costs you one event.
- It does not retry forever. A batch that fails is dropped, not queued. Three failures in a row and it pauses, doubling up to half an hour. A 4xx answer — a wrong key, a malformed body — stops it for the life of the process, because sending the same thing again would only be wrong again. Twelve failed sends in total and it gives up. If Keelnest is down, your application does not notice.
- It does not change how your errors already behave. This is the part most reporters get wrong:
- In a browser it adds
errorandunhandledrejectionlisteners, neverwindow.onerror, which has one slot and would evict whatever was in it. It never callspreventDefault(), so the console still logs and your error boundaries still run. - In Node it uses
uncaughtExceptionMonitor, notuncaughtException. Attaching a listener touncaughtExceptionstops Node exiting, which turns a crash into a process that is still running and no longer working. The monitor sees the same errors and changes nothing. - It attaches no signal handlers. A
SIGTERMlistener stops the default termination, and a container that will not stop is a worse bug than the one being reported. unhandledRejectionis the one hook with no observe-only variant, and merely listening to it suppresses Node's default, which is to raise the rejection as an uncaught exception. So when this package is the only listener, it puts that default back on the next tick. PassrestoreDefault: falseif you would rather it did not, and know that you are choosing to let rejections stop crashing your process.- It does not wrap
fetchunless you ask for it (requests: true). Replacing a global is the one thing a dependency can do that you cannot undo, and a wrapper with a bug in it breaks every request in your application rather than losing one report.
- In a browser it adds
One thing it will not promise: the very last error before a process dies may not arrive. When Node is on its way out, this package asks for the send and does not wait for it, because holding a dying process open to file a report is exactly the blocking it refuses to do.
Source maps
Keelnest does not accept source map uploads and does not symbolicate stacks. Frames arrive the
way the runtime produced them. If your browser bundle is minified, they will read like
page-4f2.js:1:9821, and no amount of serving .map files next to the bundle changes that — a
browser applies source maps in DevTools, but error.stack, which is what this package reads, stays
minified.
What actually helps, in order of how much:
- On a server, run Node with
--enable-source-maps. Node rewriteserror.stackitself, so the frames that arrive at Keelnest are your real file names and line numbers. This works today and costs nothing. Next.js enables it for you in development, and you can add it in production withNODE_OPTIONS=--enable-source-maps. - Keep function names in the browser bundle. Minifiers can mangle everything except function
names (esbuild
keepNames, terserkeep_fnames, Viteesbuild.keepNames). The file and line stay meaningless, butat checkoutTotalin the stack is most of the diagnosis. - Nothing at all. Keelnest groups errors on the shape of the message rather than the exact words,
and reads the npm package a frame sits in from the
node_modules/<name>in its path, which still works on a minified bundle that kept its module paths. So a dependency failing still reads as a dependency failing.
If symbolication matters to you, say so — it is not built, and this file will say so until it is.
API
init(options?): ReporterStarts reporting and attaches the handlers for the current runtime. Safe to call twice: the second call returns the reporter the first one made rather than attaching a second set of handlers, even across two copies of this package in one dependency tree.
| Option | Default | |
| --- | --- | --- |
| key | KEELNEST_KEY, then NEXT_PUBLIC_KEELNEST_KEY | the workspace or application key |
| host | KEELNEST_HOST, then NEXT_PUBLIC_KEELNEST_HOST | origin of your Keelnest |
| endpoint | <host>/api/ingest/errors | the full ingest URL, when it is somewhere else |
| attach | "auto" | "browser", "node", "edge", or "none" to wire it yourself |
| runtime | detected | "browser" or "server"; how the error is filed |
| sample | 1 | fraction of clients that report at all, decided once at init |
| maxPerMinute | 30 | events one client may send per minute |
| batchSize | 20 | events per request; 20 is the most the door reads from one body |
| flushAfterMs | 3000 | how long a partial batch waits for company |
| timeoutMs | 3000 | how long one send may take before it is abandoned |
| beforeSend | — | last look at an event; return null to drop it |
| requests | false | browser only: also report requests that fail or answer 5xx |
| restoreDefault | true | Node only: keep Node's crash-on-unhandled-rejection behaviour |
| onDebug | — | where the reporter says what it is doing; silent without it |
captureError(thrown, kind?, extra?): void // report something you caught yourself
wrap(fn, kind?): typeof fn // report what a function throws, then throw it on
flush(): Promise<void> // send what is queued; never rejects
close(): Promise<void> // stop, and remove every handler this package attached
reporter(): Reporter | null // the reporter init() made, or nullkind is one of "error", "rejection", "request", "resource", "unhandled", "uncaught".
Sizes
| | raw | gzipped |
| --- | --- | --- |
| dist/keelnest.js — the script tag, minified here | 8.3 KB | 3.1 KB |
| import { init } from "keelnest" — source, before your bundler minifies it | 18.1 KB | 5.9 KB |
The published ESM and CommonJS builds are deliberately not minified: your bundler does that better than we can, and a readable dependency is one you can step through when it misbehaves. The package is marked side-effect free apart from the script build, so a bundler drops the runtimes you do not use.
Requirements
Node 18 or newer, any modern browser, Cloudflare Workers, Vercel's edge runtime, Deno, Bun. It uses
fetch, AbortController and navigator.sendBeacon where they exist and does without where they do
not.
Licence
MIT.
