@forge-ops/tracker-web
v0.8.0
Published
ForgeOps error tracking client for browser/frontend apps: captures unhandled exceptions (window.onerror, unhandledrejection, a React error boundary, and a Next.js instrumentation hook), plus explicit capture anywhere else, and delivers them to ForgeOps ov
Readme
@forge-ops/tracker-web
Browser/frontend error reporting client for ForgeOps, written in TypeScript. It runs in whatever executes in the browser (a React SPA, a Next.js app's client-side rendering, or a plain page with no framework at all), capturing uncaught errors, unhandled promise rejections, and explicitly reported exceptions, then delivers events to ForgeOps over HTTP.
Installation
npm install @forge-ops/tracker-webConfiguration
Set a DSN (from a project's settings page in ForgeOps) explicitly at startup:
import * as forgeOpsTracker from "@forge-ops/tracker-web";
forgeOpsTracker.init({
dsn: "https://<api_key>@getforgeops.net/api/v1/events",
release: "...",
environment: "production",
});There's no environment-variable auto-detection here (FORGE_OPS_DSN): a browser bundle has no
single, bundler-agnostic way to read an environment variable at runtime (Vite's import.meta.env,
webpack's DefinePlugin, and a plain <script> tag all differ), so the DSN always has to be
passed to init() explicitly, typically already resolved by whatever your own build tooling does
for other public config values.
Call init() once, as early as possible in the page's lifecycle (ideally before any of your own
application code runs, so a crash during your own startup is still caught).
Plain pages (no framework)
init() installs window listeners by default (installGlobalHandlers: false to opt out)
covering the two cases that would otherwise vanish silently:
error: a synchronous exception that escaped every try/catch on the page.unhandledrejection: a rejected promise nobody attached a.catch()to.
forgeOpsTracker.init({ dsn: "..." }); // that's it: both are now coveredReact
import { ForgeOpsErrorBoundary } from "@forge-ops/tracker-web/integrations/react";
<ForgeOpsErrorBoundary fallback={<p>Something went wrong.</p>}>
<App />
</ForgeOpsErrorBoundary>;Reports via componentDidCatch, after React has already committed the error boundary's fallback
state: a slow or failed report can never affect how fast the fallback UI appears.
fallback can also be a function receiving the caught Error, for a fallback that shows something
about what broke. Requires React 18+ (a peer dependency, not bundled).
React error boundaries only catch errors thrown during rendering, in lifecycle methods, and in
constructors of the tree below them: never in event handlers or async callbacks. An exception
your own onClick handler or a fetch().catch() callback catches (or doesn't) never reaches a
boundary at all; report those explicitly with captureException() at the catch site instead.
Next.js
// instrumentation.ts
export { forgeOpsOnRequestError as onRequestError } from "@forge-ops/tracker-web/integrations/nextjs";Next.js (App Router and Pages Router both, stable since Next.js 15) calls its own onRequestError
instrumentation hook for any error that reaches its top-level server-side handling: across
render, route handlers, server actions, and middleware. This covers all of those automatically,
with no further wiring needed once the instrumentation file is in place.
This only covers server-side Next.js errors. A client-side rendering error still needs
ForgeOpsErrorBoundary above, and Next.js's own app/error.tsx/app/global-error.tsx
conventions should call captureException() directly, for the same reason no framework
integration here can see an exception that never propagates as far as its own hook.
Delivery: an async loop, plus keepalive for page teardown
DeliveryQueue here is an async processing loop, not a background thread: push() returns
immediately, and delivery happens over non-blocking fetch() calls without ever blocking the code
that raised the error. One browser-specific addition: every delivery request sets fetch's
keepalive: true, so a request started right before the page navigates away or closes is still
given a chance to complete rather than being cancelled outright by browser navigation.
navigator.sendBeacon was deliberately not used for this instead: it has no way
to set a request header, and the ingestion API only ever authenticates via the Authorization
header (see app/controllers/api/base_controller.rb), so a beacon request would simply arrive
unauthenticated and get rejected. keepalive: true on a real fetch() call is the actual
mechanism that both survives teardown and still authenticates correctly.
Identifying users
forgeOpsTracker.captureException(error, {}, { id: user.id, email: user.email });Or setUser() to attach it to every subsequently reported error until changed or cleared,
rather than passing it to every captureException() call by hand:
forgeOpsTracker.setUser({ id: user.id, email: user.email });
// on logout:
forgeOpsTracker.setUser();A plain module-level variable, not the thread-local/AsyncLocalStorage mechanisms the server-side
clients in this repo need: a browser tab is inherently single-user, so there's no concurrent-
request isolation problem here for a simple mutable variable to cause. id/email/username are
all independently optional. Shows up on an issue's own detail page, and as its own affected-users
count alongside the regular event count.
in_app backtrace frames
Compared against Configuration#appOrigin (defaults to window.location.origin): a frame whose
URL doesn't start with appOrigin, or that includes a /node_modules/ path segment even if it
does (a bundled dependency's source map can still carry that segment), is never marked in_app.
Backtrace parsing tries two known stack-trace shapes per line: V8's (at name (url:line:col),
used by Chrome/Edge) and SpiderMonkey/JavaScriptCore's (name@url:line:col, used by Firefox/
Safari): both verified directly against real captured stack traces in real browsers before
relying on them, not assumed from documentation. A line matching neither is simply skipped rather
than raising or dropping the whole backtrace.
PII scrubbing
By default, the message, backtrace, and any
context/tags you attach are scanned for likely personal data (email addresses, formatted
SSNs/credit cards, known API key/token formats, and anything under a suspiciously-named key) and
redacted before the payload ever leaves the browser. ForgeOps itself scrubs again on arrival
regardless, so this is a second, earlier layer, not the only one. The user attached via
captureException's third argument or setUser() above is a deliberate exception: it's never
scrubbed, since redacting it would defeat the whole point of identifying users in the first place.
To disable it:
forgeOpsTracker.init({ dsn: "...", scrubPii: false });Breadcrumbs
By default, init() also installs a bounded, in-order trail of whatever just happened in the
page: console output, navigation (including a client-side router's own pushState/replaceState
calls), clicks, and outgoing fetch/XHR requests, all captured automatically. Whatever's currently
in the trail is attached to the next reported error, with no wiring needed.
forgeOpsTracker.init({
dsn: "...",
breadcrumbs: false, // opt out of the automatic sources entirely
maxBreadcrumbs: 50, // default 30
});Add your own alongside the automatic ones, for anything specific to the app that none of them would know to record:
forgeOpsTracker.addBreadcrumb({ category: "auth", message: "user logged in", level: "info" });A ring buffer, not an unbounded log: the oldest entry is dropped once maxBreadcrumbs is
reached, so a long-lived single-page app session never grows this without bound. This client's own
delivery requests are never breadcrumbed, so a reported error never ends up leaving a breadcrumb
about itself.
Performance monitoring
Times work and reports one small aggregate per transaction (how many times it ran, total and maximum
duration) every performanceFlushIntervalMs (60s by default), for the Performance page's
per-transaction table. Not one network call per timed call. Separate from Web Vitals (below),
which measures how one page load felt and is sent once per page; this measures how long named work
took. On by default; turn it all off with trackPerformance: false. Two sources:
Each aggregate also carries a small latency histogram (a count per fixed latency bucket: 50, 100, 250, 500, 1000, 2500, 5000 and 10000ms, plus an overflow bucket), so ForgeOps can show an approximate p50/p95/p99 per transaction, not just an average. Percentiles are accurate to the width of whichever bucket a duration falls into; the SDK never stores the individual durations.
- Every
fetchandXMLHttpRequest, automatically, timed underMETHOD host(e.g.GET api.example.com), never the full URL: a path with an id in it would give every distinct id its own row, and a host is the granularity that is always low-cardinality. A request that rejects (offline, DNS failure) is timed too: how long it took to fail is still a real duration. This client's own delivery requests (events, web vitals, session replays, performance samples) are never timed, and it shares one patch offetch/XMLHttpRequestwith the breadcrumb network source rather than wrapping them twice. - Your own, for anything else you want on the Performance page, e.g. a route change or a heavy
render. Keep names low-cardinality (
"route:/checkout", not one per item id):
const results = await forgeOpsTracker.timeTransaction("search", () => api.search(query));
forgeOpsTracker.recordPerformance("render:dashboard", elapsedMs); // or a duration you measured yourselftimeTransaction returns whatever the function returned; if that is a promise it times until the
promise settles (fulfilled or rejected). It records even if the function throws, and the error
propagates unchanged.
The timer is a setTimeout chain, started on the first recorded duration and re-armed only while
there is something left to send, so it never idles. A tab that is hidden or closing may never get
another chance, so the buffered tallies are also flushed when the page is hidden (visibilitychange,
with pagehide as the fallback Web Vitals also uses), and the client's keepalive fetch lets that
delivery survive the page going away. forgeOpsTracker.flushPerformance() sends them right now.
A failed delivery keeps every tally, so the next flush's window just grows. What a flush delivered
is subtracted from the tallies afterward, never the whole map cleared: a record that lands while
the network call is in flight would otherwise be silently discarded, a real bug sdks/go had and
fixed and that gems/forge_ops_tracker's reference implementation still has. A deterministic test
pins this.
Distributed tracing
One flow's own call tree (a checkout, a page's data fetching, a click handler and what it
triggered), shown as a span tree on ForgeOps. A trace is sent only when the whole flow took at least
traceCaptureThresholdMs (1000 by default), so fast flows cost nothing on the wire. On by default;
turn it off with trackTracing: false. Traces are per app; nothing is propagated across services.
import { trace, startTrace, flushSpans } from "@forge-ops/tracker-web";
const order = await trace("checkout", async (t) => {
const cart = await t.span("load cart", () => api.cart(), { kind: "http" });
return t.span("charge", () => api.charge(cart), { data: { items: cart.length } });
});
// Or hold the trace across the flow and finish it when it ends:
const t = startTrace("checkout");
t.recordSpan("render", { kind: "service", startedAt: started, durationMs: elapsed });
t.finish();Automatic: every fetch and XMLHttpRequest made while exactly one trace is open is recorded
into it as an http span named METHOD host (never the path or query), through the same network
patch breadcrumbs and performance timing share. With two or more traces open at once there is no way
to know which one a request belongs to, so it is left out rather than guessed; this client's own
requests are never recorded. Everything else is a span you add by hand.
A browser has no async-context storage (unlike sdks/node's AsyncLocalStorage), so nesting is
explicit: a span's callback receives the scope to nest children under, which stays correct across
awaits and for children started in parallel. span returns what its callback returned (a promise
if it returned one), records even when the callback throws or rejects, and trace finishes the same
way. kind is one of controller, service, database, redis, http, job, other (anything
else is sent as other, since the server rejects a whole trace over one unknown kind). A trace holds
at most 500 spans. When tracing is off or reporting isn't enabled, startTrace returns a disabled
trace on which everything is a no-op (the callback still runs), so callers never check. A trace you
start yourself must be finished, or it stays open and makes automatic attribution ambiguous.
Delivery is async and in-memory, like the error queue; a request over the browser's ~64KB keepalive
limit (a large trace) is sent without keepalive rather than rejected, so it will not survive the
page unloading. await flushSpans() before a flow that ends on unload.
Custom metrics and infrastructure monitoring
Two explicit calls (nothing is automatic, so there is no trackMetrics flag): a business event you
name yourself, and a reading from one of your own hosts.
import { captureMetric, captureInfrastructureMetric, flushMetrics } from "@forge-ops/tracker-web";
captureMetric("signup"); // value defaults to 1: a bare counter
captureMetric("payment", 49); // a real magnitude; it may be negative (a refund)
captureInfrastructureMetric("queue_depth", 12, { hostname: "worker-1" });
await flushMetrics(); // send right nowEach capture is buffered and flushed as one batch every metricFlushIntervalMs /
infrastructureMetricFlushIntervalMs (60,000 by default) on a setTimeout chain. A page can be torn
down at any moment, so a flow that ends on unload should await flushMetrics() first. Every entry is
stored as it was captured (a signup is a row, not a running total), so a count or sum you compute later
is exact. Both are a no-op when reporting isn't enabled for the environment.
Infrastructure readings need a hostname, and a browser has none: pass { hostname } or set
serverName in init (it is null by default, and a reading without one is dropped with a log line rather than sent).
A failed delivery keeps every entry for the next flush, and an entry captured while a delivery is in
flight is kept too (the Ruby gem's own buffer loses it; a test pins this with a gated delivery: a flush
awaits the network, so captures really do interleave with it). Each buffer holds at most 1000 entries
and drops further ones until a flush succeeds, since a plan without the feature rejects every flush and
would otherwise grow it for as long as the page lives. A NaN or infinite value is dropped at capture:
JSON.stringify turns it into null. Requires a ForgeOps plan that includes custom metrics /
infrastructure monitoring.
Web Vitals
By default, init() also captures Core Web Vitals: LCP, CLS, INP, FCP, and TTFB, sent as one
measurement once the page becomes hidden (a tab switch, navigating away, or closing the tab), with
whatever subset of those actually finished measuring by then. No wiring needed beyond init()
itself; see it under a project's own Web Vitals page once reported.
forgeOpsTracker.init({
dsn: "...",
webVitals: false, // opt out entirely
});CLS uses the real session-window algorithm (shifts within 1s of each other, and within 5s of the window's first one, accumulate together; the largest window wins), not a plain running total. INP is a deliberate simplification: the single worst interaction observed, not the closer-to-98th- percentile figure the metric is formally defined as, stated plainly rather than pretending to the full algorithm. Requires a ForgeOps plan that includes Web Vitals tracking; on a plan that doesn't, measurements are rejected server-side and dropped, exactly like any other delivery failure.
Session Replay
Off by default, unlike breadcrumbs/Web Vitals above; turn it on explicitly once your plan includes it:
forgeOpsTracker.init({
dsn: "...",
sessionReplay: true,
});Once on, this records a snapshot of the page plus every DOM mutation, click, scroll, and resize
after it, always in memory, never uploaded on its own. A recording is only ever uploaded the
moment an error is actually reported, attached to that specific error; a session that never
errors never uploads a single byte. Every text node and every input/textarea's own typed value is
masked before it's ever recorded, always, with no way to turn that off globally: a static, genuinely
safe element can opt out one at a time with data-forgeops-unmask="true" on it (or an ancestor of
it), never a project-wide switch. Requires a ForgeOps plan that includes session replay
(Enterprise); on a plan that doesn't, recordings are rejected server-side and dropped, exactly like
any other delivery failure.
Database errors
Browsers rarely run SQL, but an error from server-rendered code or an in-browser SQLite build can carry the statement as a .sql or .query string, which is read automatically (including through cause). Where it doesn't, attach it where you ran the query with withSql, and the event includes the names of the tables and views that SQL touched, so the issue tells you where to start looking. Names are identifiers, never values; the raw statement never leaves the process.
To also send the SQL statement itself, opt in. Every string and number is replaced by ? before it
leaves your process (WHERE email = '[email protected]' AND id = 42 is sent as WHERE email = ? AND id = ?),
and ForgeOps masks it again on arrival:
import { withSql } from "@forge-ops/tracker-web";
try {
db.exec(sql);
} catch (error) {
throw withSql(error as Error, sql);
}
// Opt in to also sending the masked statement (default false).
forgeOpsTracker.init({ dsn: "...", captureSqlStatement: true });Each ForgeOps project also has its own "Capture the SQL behind database errors" setting. Turn it off there and the statement is never stored for that project, whatever this flag says; the names are still kept. A view and a table are written the same way in SQL, so both show as tables/views; the database's own error message usually settles which it was.
Running the tests
cd sdks/typescript
npm install
npm run build
npm test
npm run lint