@watchforge/browser
v0.1.32
Published
WatchForge JavaScript SDK: automatic error, log and performance capture for browsers, Node.js servers (Express, Fastify, Koa, Hapi, NestJS, Restify, Hono), Next.js, Remix, SvelteKit, Astro, Nuxt, Bun, Deno, Cloudflare Workers, AWS Lambda and Electron
Maintainers
Readme
WatchForge JavaScript SDK (@watchforge/browser)
One npm package for the browser, React / Vue / Angular / Svelte / Solid / Preact / Lit, Next.js, Remix / React Router, SvelteKit, Astro, Nuxt, SolidStart, Qwik, Gatsby, Node.js servers (Express, Fastify, Koa, Hapi, NestJS, Restify, Hono, AdonisJS), Bun, Deno, Cloudflare Workers, AWS Lambda and Electron. See Every framework and runtime for what each one needs and how each one was verified.
One register() call = automatic error capture, logs and traces. Nothing else has to be called for uncaught errors, console output, fetch/XHR/HTTP activity or page/request traces; see What is captured automatically after register() for the exact list and Not automatic for what still needs code.
Real-package status (2026-10-07):
smoke-kit/js_frameworks_e2e.pyhas been run once on a Mac (Node 25.2). Express, Fastify (ESM and CommonJS), Koa, Hono, h3, Astro and Nuxt ran against the real packages and passed their issue/transaction/status checks; it found an Express bug (4xx errors reported) and a Hapi bug (late error event dropped), both fixed since and not re-run. Restify, NestJS, SvelteKit and React Router did not run because of problems in the script itself (fixed, not re-run). Bun, Deno and the other runtimes are covered separately in the tables below. Nothing has been verified against the real WatchForge backend; the script uses a local stand-in for the ingest endpoint.
Wizard status: the wizard (
bin/watchforge.js) is covered by fixture tests intests/node/wizard.node-test.mjs(a throw-away project tree per framework; each mode is run twice to prove it is idempotent). It has not been run against fresh projects created by the real framework CLIs (npm create svelte,nuxi init, ...);smoke-kit/js_frameworks_e2e.pydoes that on a machine with network access.
Quick install (wizard)
Replace the DSN with your project DSN from WatchForge settings. Run from your app root, then restart the app.
| Framework | Command | Auto after install |
| --- | --- | --- |
| Node.js | npx @watchforge/browser -i node --dsn "YOUR_DSN" | Uncaught errors, HTTP traces/breadcrumbs, console, Express if present |
| Express | npx @watchforge/browser -i express --dsn "YOUR_DSN" | Same as Node + Express request/error middleware |
| React | npx @watchforge/browser -i react --dsn "YOUR_DSN" --replays-on-error 1 | Browser crashes + React render errors (ErrorBoundary auto-wrapped) |
| Vite | npx @watchforge/browser -i vite --dsn "YOUR_DSN" --replays-on-error 1 | Browser crashes; React/Vue auto-wired if those deps exist |
| Vue | npx @watchforge/browser -i vue --dsn "YOUR_DSN" --replays-on-error 1 | Browser crashes + Vue errorHandler auto-attached |
| Angular | npx @watchforge/browser -i angular --dsn "YOUR_DSN" --replays-on-error 1 | Browser crashes + Angular ErrorHandler auto-provided |
| Next.js | npx @watchforge/browser -i nextjs --dsn "YOUR_DSN" --replays-on-error 1 | Client + SSR/request errors via layout/instrumentation patches |
| Nuxt | npx @watchforge/browser -i nuxt --dsn "YOUR_DSN" | Vue + SSR + Nitro API/route errors (a universal plugin and a Nitro plugin are written) |
| SvelteKit | npx @watchforge/browser -i sveltekit --dsn "YOUR_DSN" | hooks.client / hooks.server import the init file (created when missing) |
| Remix / React Router | npx @watchforge/browser -i remix --dsn "YOUR_DSN" (or -i react-router) | entry.client / entry.server import the init file (if they are the framework defaults, the wizard tells you to reveal them first) |
| Astro | npx @watchforge/browser -i astro --dsn "YOUR_DSN" | Server middleware + a browser <script> in the layout |
| SolidStart / Qwik / Gatsby | -i solidstart, -i qwik, -i gatsby | Client and server entry files import the init file |
| Svelte / Solid / Preact / Lit | -i svelte (or solid, preact, lit, stencil) | Same as Vite: entry import + global handlers + framework tags |
| Fastify / Koa / Hapi / NestJS / Hono / Restify / AdonisJS | -i fastify (or koa, hapi, nestjs, hono, restify, adonis) | Same as Node; the SDK hooks the framework itself |
| Bun / Electron | -i bun, -i electron | Same as Node; Bun.serve is hooked, Electron adds renderer/child-process crash capture |
| AWS Lambda / Cloudflare Workers / Deno | -i lambda, -i cloudflare, -i deno | Prints the exact settings / one line to add; no project file is changed |
Optional flags:
--app-env production|development|staging
--debug
--replays-on-error 1 # browser only
--replays-session 0.1 # browser only
--skip-installHandled try/catch errors you swallow still need captureException(err) (same as Sentry).
Runtime support
| Runtime / Framework | Import | Auto captures |
| --- | --- | --- |
| Browser / Vite / Vue / Angular | @watchforge/browser | Uncaught errors, unhandled rejections, console/click/nav breadcrumbs, page traces, optional session replay |
| React | @watchforge/browser + @watchforge/browser/react | Same as browser + ErrorBoundary component stacks |
| Next.js | @watchforge/browser/next (+ server helpers) | Client + SSR/request errors via wizard patches |
| Node.js | @watchforge/browser/node | Uncaught errors, HTTP traces/breadcrumbs, outbound HTTP, console, Express auto-middleware when present |
| Express | same as Node (auto) or @watchforge/browser/express | Request context + route errors |
Not required: Material UI or any UI kit. React is optional unless you use @watchforge/browser/react.
npm install @watchforge/browser
# if peer conflicts from your app: npm install @watchforge/browser --legacy-peer-depsNode.js
One-liner
npx @watchforge/browser -i node --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID"What the wizard does
- Installs
@watchforge/browser - Writes
watchforge.config.js/.ts+watchforge-node.js/.ts - Patches your server entry and/or
package.jsonstart script soregister()runs on boot - Enables uncaught exceptions, unhandled rejections, HTTP request traces + breadcrumbs, outbound HTTP breadcrumbs, console breadcrumbs, and Express middleware when Express is loaded
Manual setup
import { register } from "@watchforge/browser/node";
register({
dsn: process.env.WATCHFORGE_DSN,
app_env: "production",
release: "[email protected]",
tracesSampleRate: 1.0,
enableLogs: true,
});Call this first in your process entry (index.js, server.js, etc.).
Handled try/catch errors still need captureException(err) (same as Sentry).
Express
One-liner
npx @watchforge/browser -i express --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID"
# same auto path as: -i nodeWhat the wizard does — same as Node, plus Express request/error middleware is injected automatically when register() runs before app.listen().
Manual setup (optional)
import express from "express";
import { register } from "@watchforge/browser/node";
import {
expressRequestMiddleware,
expressMiddleware,
} from "@watchforge/browser/express";
register({
dsn: process.env.WATCHFORGE_DSN,
app_env: "production",
release: "[email protected]",
tracesSampleRate: 1.0,
});
const app = express();
app.use(express.json());
app.use(expressRequestMiddleware()); // before routes
// ... routes ...
app.use(expressMiddleware()); // after routes (error middleware)React
One-liner (auto errors)
npx @watchforge/browser -i react --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
--replays-on-error 1Automatic: installs package, writes config, patches entry, wraps root render in ErrorBoundary, enables browser crashes / breadcrumbs / traces / replay.
Manual (only if you skip the wizard)
import { register } from "@watchforge/browser";
import { ErrorBoundary } from "@watchforge/browser/react";
register({ dsn: "...", app_env: "production", tracesSampleRate: 1.0, replaysOnErrorSampleRate: 1.0 });
// wrap <App /> with <ErrorBoundary> — wizard does this for youVite
One-liner (auto errors)
npx @watchforge/browser -i vite --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
--replays-on-error 1Automatic: patches src/main.*. If the project depends on React or Vue, those framework error hooks are wired automatically too.
Vue
One-liner (auto errors)
npx @watchforge/browser -i vue --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
--replays-on-error 1Automatic: patches entry + attaches Vue errorHandler so render errors are reported.
Angular
One-liner (auto errors)
npx @watchforge/browser -i angular --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
--replays-on-error 1Automatic: patches src/main.ts with a WatchForge ErrorHandler provider + bootstrap .catch.
Next.js
One-liner
npx @watchforge/browser -i nextjs --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
--app-env production \
--replays-on-error 1What the wizard does (all automatic)
- Writes
watchforge.config - Patches App Router
layout+watchforge-init, or Pages_app - Adds
instrumentation.ts/ client instrumentation,error.tsx/global-error.tsxwhen missing - Optionally wraps
next.configfor compiler error capture - Client + server/request errors start reporting after restart — no manual capture calls needed for crashes
Manual (App Router client)
"use client";
import { WatchForgeProvider } from "@watchforge/browser/next";
import { watchforgeConfig } from "../../watchforge.config";
export default function WatchForgeInit() {
return <WatchForgeProvider options={watchforgeConfig} />;
}Render <WatchForgeInit /> once inside <body> in your root layout.
Server route handlers:
import {
register,
withWatchForgeRouteHandler,
} from "@watchforge/browser/next/server";
register({ dsn: process.env.WATCHFORGE_DSN!, app_env: "production" });
export const GET = withWatchForgeRouteHandler(async () => {
throw new Error("Next.js route handler test error");
});Every framework and runtime
Two things make most of this work with no code beyond one init: the SDK hooks node:http itself (so any Node framework gets a request transaction), and every server gets a safety net: a 5xx response that nothing reported (no hook, no console.error, no captureException) becomes one issue named HTTP 500 on GET /route, so a framework with no dedicated hook never goes silent. 4xx responses are never issues. An error object is reported once, however many hooks see it. Switch the net off with captureServerErrorResponses: false.
Zero-code start. Where the application has no place for a register() call, the environment can start the SDK:
WATCHFORGE_DSN=https://KEY@host/ID node --import @watchforge/browser/register server.js # any Node framework
WATCHFORGE_DSN=https://KEY@host/ID bun --preload @watchforge/browser/register server.ts # Bun
NODE_OPTIONS="--import @watchforge/browser/register" npm run dev # CLIs that spawn nodeimport "@watchforge/browser/auto"; // first line of a browser or server entry; reads WATCHFORGE_DSN / PUBLIC_ / VITE_ / NEXT_PUBLIC_ ... variablesNode servers
| Framework | What you do | Error capture | Verified |
| --- | --- | --- | --- |
| Express | -i express (or expressRequestMiddleware / expressMiddleware) | route + error middleware; 4xx errors (err.status / err.statusCode 400-499) are passed on but are not issues | real Express on a Mac (2026-10-07): all checks passed except one real bug (a 404 error was reported), fixed since and covered by a unit test but not re-run |
| Fastify | init only | onRequest / onError hooks, wrapped setErrorHandler, and Fastify's diagnostics channels (works for ESM import) | real Fastify, ESM and CommonJS, plus the wizard on a fresh project (Mac, 2026-10-07): all checks passed |
| Koa | init only | app.emit("error") observed (Koa's own output is untouched) | real Koa (Mac, 2026-10-07): issues, transactions and statuses passed; the log-forwarding check failed only on script timing (script fixed, not re-run) |
| Hapi | init only | onPreResponse (error held while the request is open) + request events (error channel, which real Hapi emits after the response ended) + onRequest route naming | real Hapi (Mac, 2026-10-07) exposed a bug: the late error event was dropped and only HTTP 500 on GET /route was reported. Fixed and covered by stand-ins that now emit in Hapi's real order; not re-run against real Hapi |
| Restify | init only | after event | stand-in package. The Mac run could not start the script's own app (two-argument sync handlers are rejected by real Restify); script fixed, not re-run |
| NestJS | init only | ExceptionsHandler | stand-in package. The Mac run could not compile the script's app (TypeScript 6 TS5011); script fixed (TypeScript 5, rootDir), not re-run |
| Hono, h3 / Nitro, AdonisJS, Elysia, anything else | init only | console.error where the framework logs through it, plus the 5xx safety net | real Hono (@hono/node-server) and real h3 (Mac, 2026-10-07): issues, transactions and statuses passed (log check was script timing, fixed, not re-run). AdonisJS, Elysia and others: safety net tested with plain node:http and with Bun only |
Honest limits: ESM-only frameworks (AdonisJS, Hono on Node) are seen through console.error and the safety net rather than a dedicated hook; Fastify's ESM support needs a Fastify version that publishes its diagnostics channel (older versions are hooked through the CommonJS factory); every "stand-in package" row means the hook was tested against a small imitation of that framework's public API, not the real package.
Meta-frameworks
| Framework | Wizard writes / patches | Client errors | Server errors |
| --- | --- | --- | --- |
| Next.js | see Next.js | yes | yes (instrumentation.ts) |
| Remix / React Router | entry.client, entry.server import | window handlers | console.error where the framework logs through it, else the safety net (+ @watchforge/browser/remix helpers) |
| SvelteKit | hooks.client, hooks.server import | window handlers | console.error where the framework logs through it (SvelteKit's default handleError does), else the safety net |
| Astro | middleware (reports anything thrown through next()) + layout <script> | window handlers | the generated middleware, plus the safety net. If you already have a src/middleware, the wizard only adds the import; add the same try/catch around next() yourself there. |
| Nuxt | universal plugin (vue:error, app:error hooks) + Nitro plugin | yes | console.error where Nitro logs through it, else the safety net |
| SolidStart / Qwik / Gatsby | entry file imports | window handlers | console.error where the framework logs through it, else the safety net (Gatsby: browser only) |
Verification (Mac run with smoke-kit/js_frameworks_e2e.py, 2026-10-07, local stand-in for the ingest endpoint): a real Astro SSR build (@astrojs/node, standalone) and a real Nuxt build (Nitro server output) passed every server-route check. The script does not include the wizard-generated Astro middleware or Nuxt plugins, so those have fixture-test coverage only; Nuxt answers an unknown path with HTTP 200 and its app shell (the script's expectation was wrong and is fixed). The real SvelteKit build failed inside @sveltejs/kit's Vite plugin (the script now prints enough of the error to diagnose it) and the real React Router app could not be started (wrong @react-router/serve path in the script, now fixed): neither framework has been verified. SolidStart, Qwik and Gatsby are not covered by the script.
Browser UI libraries (Svelte, Solid, Preact, Lit, Alpine, Ember, Stencil, htmx, Angular, Vue, React)
Their render and event-handler errors surface through window.onerror / unhandledrejection, which the SDK already captures. The SDK adds a framework tag (and frameworks with every library found on the page) by looking at the page, so issues can be filtered per framework. Alpine, htmx and plain HTML pages: add <script type="module">import { register } from "https://esm.sh/@watchforge/browser"; register({ dsn: "..." });</script>, or bundle it.
Runtimes and shells
| Runtime | What you do | Captured |
| --- | --- | --- |
| Bun | init only (--preload @watchforge/browser/register, or -i bun) | Bun.serve (fetch, routes, error, websocket, and export default { fetch }): transaction per request, thrown errors, 5xx safety net |
| Deno | import "npm:@watchforge/browser/auto" first | Deno.serve (all call forms): same as Bun. Not run against a real Deno binary. |
| Cloudflare Workers / Pages Functions / Vercel Edge | export default withWatchForge({ fetch(request, env, ctx) {...} }) from @watchforge/browser/cloudflare, plus WATCHFORGE_DSN as a variable | thrown errors, 5xx safety net, console.*, scheduled / queue / email handlers, flush via ctx.waitUntil. Needs nodejs_compat for per-request trace isolation. Tested with a simulation (a Node process presenting itself as Workers), not with wrangler. |
| AWS Lambda | Handler node_modules/@watchforge/browser/src/lambda.handler + env WATCHFORGE_LAMBDA_HANDLER=index.handler + WATCHFORGE_DSN | throw / rejection / callback(err), API Gateway / ALB / function-URL 5xx, an invocation about to time out (reported before Lambda kills it), flush before the function freezes. Tested with simulated handlers, not with the Lambda runtime interface emulator. |
| Electron | init in the main process (import "@watchforge/browser/auto") and in the renderer | main process: uncaught errors, console, plus render-process-gone, child-process-gone, unresponsive, preload-error, did-fail-load. Tested with stand-in event emitters. |
| Capacitor / Ionic / Tauri / React Native web views | the browser SDK in the web layer | JavaScript errors in the web view (tagged capacitor / tauri). Native-side crashes are not captured. |
Framework imports (cheat sheet)
import { register, captureException, captureMessage, captureCheckIn } from "@watchforge/browser";
import { WatchForgeProvider } from "@watchforge/browser/next";
import { withWatchForgeRouteHandler } from "@watchforge/browser/next/server";
import { ErrorBoundary } from "@watchforge/browser/react";
import {
expressRequestMiddleware,
expressMiddleware,
} from "@watchforge/browser/express";
import { register as registerNode } from "@watchforge/browser/node";What is captured automatically after register()
Everything in these tables happens after the single register() call, with no wrappers, filters or capture* calls in your code.
Browser (React / Vite / Vue / Angular / Next client / plain JS)
| Kind | What is captured | Option to switch it off |
| --- | --- | --- |
| Errors | window.onerror, unhandled promise rejections, failed <img>/<script>/<link> loads, console.error(...) (as an issue), fetch / XHR network failures (as an issue) | captureResourceErrors, captureConsoleErrors, captureFailedRequests |
| De-duplication / limits | One error object is reported once, however many hooks see it (e.g. a failed fetch that then surfaces as an unhandled rejection is one issue). At most 10 events per distinct type: message and 100 events in total per minute; the rest are dropped client-side. | none |
| Logs | every console.log/info/debug (level info), console.warn (warning), console.error (error), plus one API log per fetch/XHR (HTTP >= 400 is level error). Batched (25 entries or 5 s) and flushed when the page is hidden or unloaded. The SDK's own messages are never forwarded. | enableLogs: false |
| Breadcrumbs | console, clicks, navigation (history/hash), fetch/XHR | none |
| Traces | pageload + navigation transactions with Web Vitals, Navigation/Resource Timing spans and fetch/XHR http.client spans (see Performance monitoring) | tracesSampleRate: 0 |
| Delivery | fetch with keepalive (survives page unload); bodies over 60 KB are sent without keepalive because browsers reject larger keepalive requests | none |
| Replay | only when replaysSessionSampleRate / replaysOnErrorSampleRate > 0 | default off |
Node.js / Express
| Kind | What is captured | Notes |
| --- | --- | --- |
| Errors | uncaughtException, unhandledRejection, console.error(...) (as an issue), Express route errors (error middleware is attached automatically when Express is loaded after register()) | Uncaught errors are flushed (up to 2 s) before the process ends and Node's normal behaviour is kept: exit code 1 and the error on stderr for an uncaught exception, --unhandled-rejections honoured for rejections. If your app has its own uncaughtException / unhandledRejection listener, it stays in charge and the process is not ended by the SDK. |
| De-duplication / limits | same as the browser | |
| Logs | console.log/info/debug (info), console.warn (warning), console.error (error); flushed every 5 s and when the event loop drains. HTTP requests are breadcrumbs and spans on Node, not logs | enableLogs: false |
| Breadcrumbs | console, inbound and outbound HTTP, Node warnings | |
| Traces | http.server transaction per request, outbound http/https/fetch spans, DB spans for installed pg, mysql2, mysql, better-sqlite3, sqlite3, mongodb, cache spans for ioredis / redis | tracesSampleRate, instrumentDatabases, instrumentCaches |
Not automatic
- Errors you catch and swallow: call
captureException(err)(orcaptureMessage). - React render errors get no component stack without
<ErrorBoundary>(a render error that nothing catches still reacheswindow.onerror). Vue errors reach the console in production and are therefore reported asconsole.errorissues without component info; useattachVueErrorHandler(app, { captureException })for the component name. Angular needs theErrorHandlerprovider frominstallAngularAutoCapture.register()also tries to load these framework hooks itself, but that dynamic import only resolves in some setups, so do not rely on it. - Next.js server/route errors need the
next/serverhelpers orinstrumentation.ts. Frameworks without a dedicated hook (see Every framework and runtime) are seen throughconsole.errorand the 5xx safety net: an error the framework turns into a 5xx and never logs shows up asHTTP 500 on GET /routewithout a stack trace. Cloudflare Workers module syntax needs the onewithWatchForge(...)wrapper because the platform reads the exported object directly. - Router-template names for page transactions need the router helpers listed under Performance monitoring; without them the pathname is used with ids normalised.
- Not flushed:
process.exit()while logs are still buffered,SIGKILL/OOM kills, and browser tab kills beforepagehide. Callawait flush()yourself in serverless handlers and short scripts (it waits for pending captures, buffered logs and in-flight requests). - Worker threads / child processes need their own
register(). - Native crashes (segfaults, V8 fatal errors) are not JavaScript errors and are not captured.
You usually do not need captureException for crashes. Use it for handled errors you still want in WatchForge.
Tracing, replay, releases
| Option | Typical value | Notes |
| --- | --- | --- |
| tracesSampleRate | 1.0 | 0.0–1.0 (1.0 = 100% of traces) |
| replaysSessionSampleRate | 0.1 | Browser only |
| replaysOnErrorSampleRate | 1.0 | Browser only |
| release | "[email protected]" | Required for Releases / crash-free views |
| enableLogs | true | Console output (and browser fetch/XHR calls) are forwarded automatically; captureLog(...) adds your own |
Without tracesSampleRate / traces, Crash Free Sessions may stay on “Setup required” even if Issues work.
Performance monitoring
With tracesSampleRate > 0, register() records performance data in the shape the WatchForge Performance pages expect (Queries, Outbound APIs, Endpoints, Page loads & Web Vitals, Caches). You don't need any code for the defaults below. Spans are always children of the active span, unsampled requests record nothing, and bound query parameters and query strings are never sent.
Browser (automatic)
| Data | Details |
| --- | --- |
| pageload transaction | Starts at navigation start and finishes after load plus 1 s with no fetch/XHR in flight (30 s at most). It is sent right away if the page is hidden first (visibilitychange/pagehide, keepalive send). |
| navigation transactions | Started by history.pushState / replaceState / popstate / hash routes when the path changes. Query-only changes and the router's boot-time replaceState don't count. |
| Name | The route template when a router integration reports one (/products/:id). Otherwise the pathname, with numeric, UUID and hash segments replaced by {id}. |
| measurements (pageload) | lcp, fcp, fp, ttfb, inp (ms) and cls (unit ""), using Sentry's { value, unit } shape. INP is the longest interaction seen so far. CLS uses the largest session window. |
| Spans | browser.request, browser.response, browser.domContentLoadedEvent, browser.loadEvent, plus resource.script / resource.css / resource.img / resource.other, with up to 100 resources carrying transfer/encoded/decoded sizes |
| fetch / XHR | http.client spans: "GET https://host/path" (no query string), http.method, url, server.address, http.response.status_code, http.response_content_length. WatchForge's own ingest calls are skipped. |
| traceparent, tracestate, baggage | Sent only to same-origin URLs and to tracePropagationTargets: ["api.example.com", /^https:\/\/partner\./] ([] = never) |
| Long tasks | ui.long-animation-frame (Chrome 123+, with script file/function) or ui.long-task spans (max 50 per transaction) |
| Clicks | ui.action.click spans in the active page transaction, described by a text-free selector (main#app > button.buy); traceUserInteractions: false turns them off |
| Web vitals as attributes | browser.web_vital.<lcp\|cls\|inp\|fcp\|ttfb>.value next to the measurements |
| Context | contexts.browser { name, version }, tags["device.class"] (mobile / desktop) |
Router integrations (they name pageload/navigation transactions after the route template):
// React Router 6.4+/7 data router
import { instrumentReactRouter } from "@watchforge/browser/react";
const router = createBrowserRouter(routes);
instrumentReactRouter(router);
// <BrowserRouter> / any router: pathname + params
import { useWatchForgeRoute } from "@watchforge/browser/react";
useWatchForgeRoute(useLocation().pathname, useParams());
// Next.js App Router (client component), or <WatchForgeProvider pathname={...} params={...}>
import { useWatchForgeNextRoute } from "@watchforge/browser/next";
useWatchForgeNextRoute(usePathname(), useParams());
// Next.js Pages Router (_app)
import Router from "next/router";
import { instrumentNextPagesRouter } from "@watchforge/browser/next";
instrumentNextPagesRouter(Router);
// Remix / React Router framework mode (root.tsx, client side)
import { instrumentRemixRouter } from "@watchforge/browser/remix";
instrumentRemixRouter(); // window.__remixRouter, or pass your router
// SvelteKit (root +layout.svelte)
import { afterNavigate } from "$app/navigation";
import { trackSvelteKitNavigation } from "@watchforge/browser/sveltekit";
afterNavigate(trackSvelteKitNavigation);
// Anything else
import { setRouteName } from "@watchforge/browser";
setRouteName("/products/:id");Node (automatic)
| Data | Details |
| --- | --- |
| http.server transaction per request | op is sent both top-level and in contexts.trace. The name is METHOD + route template, e.g. Express req.baseUrl + req.route.path gives "GET /api/users/:id". Without a template it falls back to the normalised path. Also sets data.request.{method,url}, tags["http.status_code"] and data["http.response.status_code"], with the status mapped from the HTTP code (404 → not_found, 503 → unavailable, a thrown error → internal_error, a client abort → cancelled). Incoming traceparent is continued. |
| DB spans (db.sql.query / db.mongodb.query) | Patched automatically if installed: pg (Client#query, which also covers Pool), mysql2 (query/execute, which also covers pools and the promise API), mysql, better-sqlite3 (Statement#run/get/all, Database#exec), sqlite3 (Database#run/get/all/exec) and mongodb (Collection methods; for find/aggregate, toArray() is timed). Each span has description = the statement text as written (placeholders, no values) and data = db.system, db.name, db.operation, db.statement, server.address. MongoDB statements are the command shape with every value replaced by "?". |
| Outbound HTTP (http.client) | http/https.request/get and global fetch (undici), with the same fields as in the browser, plus traceparent (parent = the client span). Set tracePropagationTargets to restrict where it goes. |
| Cache spans | ioredis / redis: GET/MGET → cache.get (cache.hit, cache.item_size), SET/SETEX/PSETEX/SETNX → cache.put, DEL/UNLINK → cache.remove. Each span has cache.key and network.peer.address. |
Turn off driver patching with register({ instrumentDatabases: false, instrumentCaches: false }). Drivers are resolved from your app (process.cwd()) when register() runs.
Helpers for everything else:
import { instrumentQuery, withPrismaTracing, prismaMiddleware, runWithSpan, getCurrentTransaction } from "@watchforge/browser";
// any driver / query builder: pass the parameterised statement, never values
const rows = await instrumentQuery("postgresql", () => sql`select * from users where id = ${id}`, {
statement: "select * from users where id = $1",
});
// Prisma 4.16+/5+: one span per model operation ("User.findMany")
const prisma = withPrismaTracing(new PrismaClient());
// Prisma < 5
prisma.$use(prismaMiddleware({ system: "postgresql" }));
// group work under a custom span (DB/HTTP spans inside nest under it, across awaits)
const span = getCurrentTransaction()?.startSpan("function", "buildReport");
await runWithSpan(span, () => buildReport());
span?.finish();Framework route names on the server:
| Framework | How |
| --- | --- |
| Express 4/5 | automatic (req.baseUrl + req.route.path) |
| Next.js | withWatchForgeRouteHandler(handler) / withWatchForgeApiHandler(handler) infer /api/users/[id] from params, or pass { route }. onRequestError also applies Next's routePath. withWatchForgeServerAction adds a function.nextjs.server_action span. |
| Remix / React Router 7 | export default wrapHandleRequest(handleRequest) and export const handleDataRequest = wrapHandleDataRequest() in entry.server |
| SvelteKit | export const handle = watchForgeHandle() (or inside sequence(...)) in hooks.server.js, which uses event.route.id |
| Anything else | withServerTransaction({ method, url, headers, route, getStatusCode }, fn) / setTransactionRoute(method, route) |
Distributed tracing, sampling and the span API (contract v2)
Everything here is configured through optional register() options; no other app code is needed.
| Option | Default | What it does |
| --- | --- | --- |
| tracesSampleRate | 1 | Fallback sampling rate for new root transactions |
| tracesSampler(context) | none | Return a number 0..1 or a boolean; null/undefined = no opinion. context = { name, op, source, parentSampled, attributes, transactionContext } (snake_case aliases included) |
| parentBasedSampling | true | Honor the upstream decision (traceparent flags / sentry-trace) when the sampler has no opinion |
| tracePropagationTargets | browser: same origin; Node: every host | Strings (substring of the full URL), RegExps or "*"; [] = never. The WatchForge ingest host never gets trace headers |
| propagateSentryTrace | false | Also send sentry-trace |
| beforeSendTransaction(tx) / beforeSendSpan(span) | none | Return null to drop (children of a dropped span are re-parented); a throwing hook sends the original |
| serverName | host name | server_name on transactions (Node) |
| traceUserInteractions / traceInteractionTransactions | true / false | Browser ui.action.click spans inside the active page transaction / also as their own transaction when none is active |
| instrumentQueues | true | Node bullmq / kafkajs / SQS spans + header injection |
Decision order is tracesSampler -> parent -> tracesSampleRate; the result is stored in contexts.trace.sampled, client_sample_rate and sampling.reason (sampler, parent, sample_rate, forced). Unsampled traces still propagate their trace id with sampled=0 (traceparent flag 00).
Outgoing requests (browser fetch/XHR, Node http/https, global fetch, undici used directly) carry traceparent, the incoming tracestate unchanged and baggage (sentry-* keys; third-party members are preserved). Incoming requests on every Node HTTP framework, Next/Remix/SvelteKit/Nuxt/Deno/Bun/Lambda handlers continue the trace (traceparent, else sentry-trace; baggage; tracestate) and record parent_span_id, parent_sampled, dynamic_sampling_context, tracestate. A server-rendered page can pass its trace to the browser with <meta name="traceparent"> / sentry-trace / baggage tags.
Automatic spans carry kind and origin (auto.<category>.<library>) and OpenTelemetry attributes (http.request.method, url.full, url.path, server.address, db.system, db.operation, db.collection.name, cache.*, messaging.*, rpc.*). SQL statements have every literal replaced by ?. Errors carry contexts.trace {trace_id, span_id}, log lines carry trace_id and span_id.
Optional explicit API (everything above works without it):
import { startSpan, getCurrentSpan, continueTrace, startTransaction, getTraceHeaders } from "@watchforge/browser";
await startSpan("function", "render report", { tenant: "a" }, async (span) => {
span.addEvent("cache.miss", { key: "report" });
span.addLink(otherTraceId, otherSpanId);
try { await work(); } catch (e) { span.recordException(e); throw e; } // adds an "exception" event + status internal_error
});
const txn = startTransaction("job", "nightly import", "task", { continued: continueTrace(message.headers), source: "task" });
fetch(url, { headers: getTraceHeaders() }); // for transports the SDK does not instrumentSpan/Transaction also have setKind, setOrigin, setData, setStatus(status, message); limits are 1000 spans per transaction (the rest counted in data.dropped_spans), 1000 characters for descriptions and attribute values, 128 events/links per span.
Node queue/job/service coverage (patched automatically when the package is installed; Beta, verified only against stand-in classes, not the real libraries):
| Library | Coverage |
| --- | --- |
| bullmq | Queue#add/addBulk -> queue.publish (headers stored in opts.telemetry.metadata), Worker#processJob -> queue.process transaction continuing the producer, with a span link. CommonJS build only (an ESM-only app loads a different copy of the classes) |
| kafkajs | producer.send/sendBatch inject message headers; consumer.run eachMessage/eachBatch run as queue.process |
| @aws-sdk/client-sqs | SendMessage(Batch) adds traceparent/tracestate/baggage message attributes, ReceiveMessage requests them and records a queue.receive span. No automatic per-message consumer (use continueTrace(sqsCarrier(attrs)) yourself, or Lambda) |
| node-cron, node-schedule | each run is a cron transaction (source task); wrapScheduledJob(fn, { name }) for other schedulers |
| express views | app.render -> template.render span (view name only) |
| @grpc/grpc-js | unary client calls -> rpc.client span + trace metadata |
Not covered: GraphQL resolver spans, gRPC servers/streaming, Redis commands other than GET/SET/DEL families, outgoing fetch propagation on Bun/Deno/Workers, mobile-style startup metrics.
OpenTelemetry (OTLP)
Beta (not tested against the real OpenTelemetry packages). If your app already uses OpenTelemetry (instrumentations, the OTel API), send those spans to WatchForge with one init option. WatchForge already ships an OTLP/HTTP receiver; this bridge builds the OTLP JSON itself with fetch, so there is no new hard dependency. @opentelemetry/api and @opentelemetry/sdk-trace-node are optional and loaded lazily.
import { register } from "@watchforge/browser";
await register({
dsn: "https://[email protected]/1",
opentelemetry: true, // default false
// otlpEndpoint: "https://collector.internal/otlp", // optional override of the derived URL
tracesSampleRate: 0, // one trace source per service (see "Duplicates" below)
});With opentelemetry: true (Node), register() attaches a WatchForge span processor (batch: every 5 s or 512 spans, 3 s HTTP timeout, failures swallowed, flushed by flush() and before exit) to the application's global TracerProvider. If none is registered and @opentelemetry/sdk-trace-node is installed, one is created and registered. If OpenTelemetry is not installed the SDK logs one warning line and continues as a no-op. It never throws.
| Name | What it does |
| --- | --- |
| register({ opentelemetry: true, otlpEndpoint }) | Attach to the global TracerProvider (Node). Also WATCHFORGE_OPENTELEMETRY=true / WATCHFORGE_OTLP_ENDPOINT for --import @watchforge/browser/register. |
| setupOpenTelemetry(provider) | Explicit attach to a provider you hold. Synchronous, idempotent, returns true/false. Works in the browser too (pass your WebTracerProvider). Without an argument it uses the global provider and returns a Promise<boolean> (Node only). |
| getOtlpConfig() | Pure, no network, works with the bridge off, without OpenTelemetry and in the browser: { tracesEndpoint, headers, env, protocol: "http/json" }. |
| createWatchForgeSpanExporter(opts?) | A ready OpenTelemetry SpanExporter (export / shutdown / forceFlush). |
| createWatchForgeSpanProcessor(opts?) | The batching processor the bridge uses (no @opentelemetry/sdk-trace-base needed). |
Provider compatibility (SDK 1.x and 2.x)
- 1.x providers have
addSpanProcessor(): used directly. - 2.x providers take processors in the constructor only. WatchForge extends the provider's internal processor list (Beta, relies on a private field of the SDK). If that is not possible
setupOpenTelemetry()returnsfalsewith one warning, and you pass the exporter when you build the provider, which works on every version:
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { register, createWatchForgeSpanExporter } from "@watchforge/browser";
register({ dsn }); // supplies the endpoint and key
new NodeTracerProvider({
spanProcessors: [new BatchSpanProcessor(createWatchForgeSpanExporter())],
}).register();Start order: if your own OpenTelemetry setup (NodeSDK.start(), provider.register()) runs after register({ opentelemetry: true }), WatchForge would have installed its own provider first. Call register() after OpenTelemetry starts, or call setupOpenTelemetry(provider) yourself.
Collector, agent or hand-built exporter
getOtlpConfig() returns what any OTLP/HTTP-JSON sender needs:
const { tracesEndpoint, headers, env } = getOtlpConfig();
// tracesEndpoint: https://ingest.example.com/api/ingestion/otlp/v1/traces
// headers: { Authorization: "DSN PUBLIC_KEY" }
// env: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_HEADERS,
// OTEL_EXPORTER_OTLP_HEADERS ("Authorization=DSN PUBLIC_KEY"),
// OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ("http/json")Without any SDK call the same works through the environment of a Collector or OTel agent:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://<host>/api/ingestion/otlp/v1/traces
OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=DSN <public key>
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/jsonOnly the DSN public key is placed in headers. otlpEndpoint is used as the full traces URL; a bare origin or a path ending in /otlp gets /v1/traces appended.
What is exported and how
- OTLP/HTTP JSON: resource (
service.name,deployment.environment,service.version,telemetry.sdk.language,host.name; missing ones are filled fromregister()), scope, hex ids, kind, unix-nano timestamps as strings, typed attributes, events, links, status and dropped counts. - Only spans OpenTelemetry sampled and recorded; your OTel sampler stays in charge.
- No export loop: the exporter's own request runs with OpenTelemetry instrumentation suppressed, and spans for requests to the WatchForge ingest host are dropped.
- Redaction: values of keys matching
authorization|cookie|password|secret|token|api[-_]?keybecome[Filtered](string values; numeric attributes such asgen_ai.usage.output_tokensare kept); query strings and credentials are removed fromurl.full/http.url/http.target;url.queryis filtered; SQL literals indb.statementare replaced by?. - Correlation: with the bridge on, errors and logs captured while no WatchForge transaction is active take
trace_id/span_idfrom the current OpenTelemetry span, so issues link to the OTel trace.
Duplicates
Native WatchForge tracing and the OpenTelemetry bridge are two trace sources. Use one trace source per service: the OpenTelemetry bridge or native tracing (set tracesSampleRate: 0). Native tracing is not changed by the bridge.
Browser
register({ opentelemetry: true }) does nothing in the browser (no global-provider auto-attach). getOtlpConfig() and setupOpenTelemetry(provider) with your own WebTracerProvider work. The browser sends the Authorization header cross-origin, as the rest of the SDK does.
Source maps (readable production stack traces)
Minified bundles report errors like at c (index-3f9a.js:1:123). Upload your
build's source maps for each release and WatchForge shows the original file,
line and function instead - and groups errors by real code, so a new build
doesn't split an old bug into a new issue.
- Set
releaseinregister({ release: "[email protected]" })(the same string for step 3). - Build with source maps enabled (
build.sourcemap: truein Vite,devtool: "hidden-source-map"in webpack,productionBrowserSourceMaps: truein Next.js). - Create an upload token in Project Settings → Source maps & mapping files, store it as the CI secret
WATCHFORGE_UPLOAD_TOKEN, then after the build:
npx -p @watchforge/browser watchforge-sourcemaps upload ./dist \
--release "[email protected]" \
--url-prefix "~/assets" \
--endpoint https://YOUR_WATCHFORGE_HOST/api/ingestion/artifacts/--url-prefix is the path your pages load the bundles from (~ means any host).
You don't need to deploy the .map files publicly.
Cron monitors (Node)
import { register, captureCheckIn } from "@watchforge/browser/node";
register({ dsn: process.env.WATCHFORGE_DSN, app_env: "production" });
const monitorConfig = {
schedule: { type: "crontab", value: "0 * * * *" },
timezone: "UTC",
check_margin: 5,
max_runtime: 30,
};
const checkInId = captureCheckIn({
monitorSlug: "nightly-job",
status: "in_progress",
monitorConfig,
});
// ... run job ...
captureCheckIn({
monitorSlug: "nightly-job",
status: "ok",
checkInId,
monitorConfig,
});Verify setup
- Restart the app after the wizard.
- Throw from application code (not only the browser DevTools console).
- Confirm the issue in WatchForge (stack + breadcrumbs).
- Open Traces for request/pageload spans; open Replay on an issue if replay sampling is enabled.
import { captureException } from "@watchforge/browser";
captureException(new Error("WatchForge test error"));Advanced
- Full options and troubleshooting:
CONFIGURATION_GUIDE.md init()is deprecated — useregister({ dsn })- Optional: copy
src/into your repo and import locally (same API)
Browser support
Modern browsers with ES modules. For React component stacks, use @watchforge/browser/react.
