watchfire
v1.3.0
Published
Self-hosted error tracking for Next.js. A library, not a service.
Maintainers
Readme
Watchfire
Self-hosted error tracking for Next.js. A library, not a service.
Watchfire captures JavaScript errors in your users' browsers, resolves the minified stack traces back to your original source using privately held source maps, gives each error a stable fingerprint for grouping, and hands the result to a callback in your code. Error data stays on your infrastructure, and there's no extra service to run.
npm install watchfireWhy
Hosted error tracking sends stack traces and user context to a third party, which for many teams means a subprocessor agreement and a vendor review. Self-hosting a full platform (Sentry's stack is Kafka, ClickHouse, Redis, and Postgres) is a lot of infrastructure for what is usually a few dozen events a day.
The hard part of error tracking is resolving minified stack traces against source maps without making the maps public. That fits in a library. Storage, alerting, and dashboards are left to the tools you already run: watchfire delivers each error as a structured event, and you decide what to store and when to alert.
There's no UI, no issue workflow, and no analytics.
How it works
browser error ──POST──▶ /api/errors (a route in your app)
│
▼
validate → rate-limit → scrub → parse → resolve → fingerprint
│
▼
onEvent(event) ← your code: a DB insert, a log lineOne package, six subpath exports and a CLI:
| Import | Contents |
| --- | --- |
| watchfire/browser | Client SDK: capture, breadcrumbs, batching, repeat suppression, flush. 5 kB minified, 2.3 kB gzipped |
| watchfire/ingest | The route handler, a standard (Request) => Response function |
| watchfire/next | withWatchfire, wraps next.config to wire source maps and the release build id |
| watchfire/react | reportBoundaryError, for error boundaries |
| watchfire/stack | Stack parsing (Chrome, Safari, and Firefox formats) and fingerprinting |
| watchfire/sourcemaps | Map stores, the runtime resolver, boot-time registration |
The build tooling targets Next.js; the runtime is framework-neutral.
Setup
1. The build
In next.config.ts, wrap your config:
import { withWatchfire } from "watchfire/next";
const nextConfig = { /* your config */ };
export default withWatchfire(nextConfig);The wrapper enables source map generation and returns your release id as Next's build id, so maps are stored under the same name the browser reports. The release comes from NEXT_PUBLIC_RELEASE (pass release to override); without one, Next falls back to its own generated id, so local development needs no configuration. A generateBuildId you define yourself is respected and not overwritten.
In package.json, run watchfire maps after the build:
"scripts": {
"build": "next build && watchfire maps"
}next build writes a .map file beside every chunk. watchfire maps moves the maps into a private directory inside the server output, removes the public pointers, and fails the build if any map remains publicly reachable. Maps are stored under a release id, which defaults to Next's build id.
2. The server route
Create app/api/errors/route.ts:
import { createIngestHandler, filesystemStore, defaultMapsDir } from "watchfire/ingest";
export const POST = createIngestHandler({
maps: filesystemStore(defaultMapsDir()),
onEvent: async (event) => {
await db.insertInto("client_errors").values({
fingerprint: event.fingerprint,
message: event.message,
frames: JSON.stringify(event.frames),
}).execute();
},
});onEvent receives the finished event: parsed, resolved to original source, fingerprinted.
3. The browser
Create instrumentation-client.ts at the project root. Next runs any file with that name in the browser before your app code starts, so nothing needs to import it:
import { init } from "watchfire/browser";
init({ endpoint: "/api/errors", release: process.env.NEXT_PUBLIC_RELEASE });release must match the id the maps were stored under in step 1. withWatchfire reads the same NEXT_PUBLIC_RELEASE variable, so setting one env var at build time covers both. A report with an unknown release is still delivered, but without resolved source positions.
Reports are batched, and the queue is sent when the page is hidden or torn down. If your own code destroys the page, call flush() first, or the report explaining why is the one you lose:
import { captureError, flush } from "watchfire/browser";
captureError(new Error("StaleBundle: reloading"));
flush();
location.reload();Source maps at runtime
Resolution reads maps from local disk, which always matches the release the server is running. This covers errors from browsers on the current bundle.
Browsers on an older bundle (tabs opened before your last deploy) produce stacks that reference the previous build. To resolve those too, configure a shared store and register each release's maps on boot:
import { layeredStore, s3Store, filesystemStore, registerMaps, defaultMapsDir } from "watchfire/sourcemaps";
const shared = s3Store({ bucket: "my-app-maps", client, commands });
void registerMaps({ release, localDir: `${defaultMapsDir()}/${release}`, store: shared });registerMaps only uploads what's missing, so every server can call it on every boot. Without a shared store, stale-bundle errors fall back to function names instead of source positions.
Default filtering
The default ignore list contains only errors that are noise in any application: ResizeObserver loop notices, cross-origin Script error events (which carry no usable information), and errors thrown from browser-extension code, identified by extension URLs in the stack.
Some extension errors arrive with no stack at all, leaving no frame to identify anyone by. A short list of those is matched on the message instead, and the bar for being on it is that the message names an extension API the page cannot reach (chrome.runtime errors, and the Firefox pair produced when an extension hands React a privileged event target). A generic TypeError is never matched this way.
Chunk-load failures, network errors, and aborted requests are reported, since they're often signal: a chunk-load spike measures how many open tabs a deploy broke, and a network-error spike can indicate an outage. To drop or reroute them, use ignoreErrors (substrings or regexes, matched against both message and stack) or classify them in onEvent.
Privacy defaults
- Route changes and clicks: recorded as path patterns and CSS selectors, never text content
- Network breadcrumbs: method, origin, and status;
fetchFullPath: trueadds the path, and query strings are always stripped (callback URLs can contain OAuth codes) - Console breadcrumbs: off by default
- Input values: never captured; there's no option to enable this
Breadcrumbs
Each report carries a short trail of what happened in the tab beforehand: fetch (method, path, status), click (a CSS selector), navigation (route changes), and console if you opt in. capture.limit bounds it, default 30.
Eviction is not first-in-first-out. An app that polls makes requests the overwhelming majority of everything recorded, and dropping the oldest entry would discard the rarer clicks and navigations first, which are the ones that say what the person was doing. So when the buffer is full the oldest entry of the largest kind is dropped. Request chatter evicts request chatter, and a click from a minute ago outlives a hundred polls.
The trade is worth knowing: the trail is the recent history of each kind in chronological order, not strictly the last N events.
Grouping
Every event carries a fingerprint; grouping by it gives an issue list. Two properties are enforced by tests:
- Stable across deploys. The fingerprint is built from resolved source paths and lines, not generated chunk names, so a rebuild doesn't split an issue. Variable parts of the message (ids, numbers, quoted strings, URLs) are normalized out.
- Stable across engines. Only the top application frame contributes. Engines disagree about deeper framework frames (V8 reports frames JSC elides), which would split one bug into several issues.
When a stack yields nothing at all, grouping falls back to the request that failed. Safari reports a dropped fetch as TypeError: Load failed with no stack, so every dropped request in an application would otherwise share one key; the breadcrumb trail still knows which endpoint it was. Only the most recent request counts, and only if it failed.
Watchfire's own frames are removed from the stack before it reaches onEvent. Breadcrumb capture patches window.fetch, so without this the wrapper is frame 0 of every network error, ahead of the code that made the call.
Repeat suppression runs in the browser: at most three reports per distinct error per page load, with the suppressed count attached to the next flush.
Status
v1.3.0. Covered by 195 unit tests and 33 end-to-end tests that run Chromium, WebKit, and Firefox against a Next 16 build. Parser fixtures are captured from the engines rather than written by hand; the source map decoder is tested against real bundler output. Watchfire is in production at Fieldwork.
Out of scope: a hosted UI and Sentry protocol compatibility, both of which would turn the library into a service.
License
MIT
