npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.py has 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 in tests/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.py does 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-install

Handled 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-deps

Node.js

One-liner

npx @watchforge/browser -i node --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID"

What the wizard does

  1. Installs @watchforge/browser
  2. Writes watchforge.config.js / .ts + watchforge-node.js / .ts
  3. Patches your server entry and/or package.json start script so register() runs on boot
  4. 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 node

What 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 1

Automatic: 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 you

Vite

One-liner (auto errors)

npx @watchforge/browser -i vite --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
  --replays-on-error 1

Automatic: 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 1

Automatic: 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 1

Automatic: 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 1

What 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.tsx when missing
  • Optionally wraps next.config for 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 node
import "@watchforge/browser/auto";   // first line of a browser or server entry; reads WATCHFORGE_DSN / PUBLIC_ / VITE_ / NEXT_PUBLIC_ ... variables

Node 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) (or captureMessage).
  • React render errors get no component stack without <ErrorBoundary> (a render error that nothing catches still reaches window.onerror). Vue errors reach the console in production and are therefore reported as console.error issues without component info; use attachVueErrorHandler(app, { captureException }) for the component name. Angular needs the ErrorHandler provider from installAngularAutoCapture. 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/server helpers or instrumentation.ts. Frameworks without a dedicated hook (see Every framework and runtime) are seen through console.error and the 5xx safety net: an error the framework turns into a 5xx and never logs shows up as HTTP 500 on GET /route without a stack trace. Cloudflare Workers module syntax needs the one withWatchForge(...) 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 before pagehide. Call await 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 instrument

Span/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() returns false with 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/json

Only 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 from register()), 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[-_]?key become [Filtered] (string values; numeric attributes such as gen_ai.usage.output_tokens are kept); query strings and credentials are removed from url.full / http.url / http.target; url.query is filtered; SQL literals in db.statement are replaced by ?.
  • Correlation: with the bridge on, errors and logs captured while no WatchForge transaction is active take trace_id / span_id from 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.

  1. Set release in register({ release: "[email protected]" }) (the same string for step 3).
  2. Build with source maps enabled (build.sourcemap: true in Vite, devtool: "hidden-source-map" in webpack, productionBrowserSourceMaps: true in Next.js).
  3. 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

  1. Restart the app after the wizard.
  2. Throw from application code (not only the browser DevTools console).
  3. Confirm the issue in WatchForge (stack + breadcrumbs).
  4. 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 — use register({ 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.