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

@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-web

Configuration

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 covered

React

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 fetch and XMLHttpRequest, automatically, timed under METHOD 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 of fetch/XMLHttpRequest with 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 yourself

timeTransaction 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 now

Each 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