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

v0.10.0

Published

ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to ForgeOps over HTTP.

Downloads

1,239

Readme

@forge-ops/tracker

Node.js error reporting client for ForgeOps. Requires Node 18+ (for global fetch). It captures uncaught exceptions, unhandled promise rejections, and explicitly reported errors, builds a backtrace, scrubs likely PII, and delivers events to ForgeOps over HTTP without blocking the request or process that raised them.

Installation

npm install @forge-ops/tracker

Configuration

Set a DSN (from a project's settings page in ForgeOps), either via the FORGE_OPS_DSN environment variable or explicitly:

import * as forgeOpsTracker from "@forge-ops/tracker";

forgeOpsTracker.init({
  dsn: "https://<api_key>@getforgeops.net/api/v1/events", // or leave unset to read FORGE_OPS_DSN
  release: "...",
  environment: "production",
});

Call init() once at startup, before handling any requests. Any Configuration property can be overridden by passing it in the options object.

Express

import { forgeOpsTrackerBreadcrumbContextExpressMiddleware } from "@forge-ops/tracker/integrations/breadcrumb-context";
import { forgeOpsTrackerSessionTrackingExpressMiddleware } from "@forge-ops/tracker/integrations/session-tracking";
import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
import { forgeOpsTrackerUserContextMiddleware, forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";

app.use(forgeOpsTrackerBreadcrumbContextExpressMiddleware); // first, before everything else below
app.use(forgeOpsTrackerSessionTrackingExpressMiddleware);
app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
app.use(forgeOpsTrackerUserContextMiddleware); // after Passport's own session middleware, if used
// ...routes...
app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes

Requires Express 5+: its automatic forwarding of both synchronous throws and rejected promises from async route handlers to error-handling middleware is what makes this work with zero other wiring (verified directly against a real async handler, not assumed). Express 4 does not do this for async handlers; each route would need its own try/catch there instead.

Fastify

import { registerForgeOpsTrackerBreadcrumbContext } from "@forge-ops/tracker/integrations/breadcrumb-context";
import { registerForgeOpsTracker } from "@forge-ops/tracker/integrations/fastify";

registerForgeOpsTrackerBreadcrumbContext(app); // first, before registerForgeOpsTracker
registerForgeOpsTracker(app); // called directly, not via app.register()

Called directly on your Fastify instance rather than through app.register(), which would create a new encapsulation scope (and require the fastify-plugin package to break out of it): this avoids that entirely.

What gets reported automatically, and what doesn't

An exception that crashes a request needs no further wiring at all. Both integrations above report anything that propagates uncaught out of a route handler, then let the framework handle it exactly as if this client weren't installed.

An exception your own code catches and handles is different: neither integration ever sees it, since it never propagates far enough to reach either hook:

try {
  await chargeCard(order);
} catch (err) {
  logger.warn(`card declined: ${err.message}`);
  // ForgeOps never sees this: caught locally, never reaches the
  // middleware/hook at all.
}

There's no application-wide hook that reports an exception while still letting your own catch block handle it: report it explicitly instead, right at the catch site:

} catch (err) {
  forgeOpsTracker.captureException(err, { orderId: order.id });
  logger.warn(`card declined: ${err.message}`);
}

Outside a web request (scripts, workers, unhandled promise rejections)

init() also installs process event listeners by default (installProcessHandlers: false to opt out) covering two cases with no wiring needed:

  • uncaughtExceptionMonitor, not uncaughtException: deliberately. Registering any listener for uncaughtException suppresses Node's own default crash behavior entirely (the process would no longer exit on its own; Node's docs are explicit that resuming normal operation afterward isn't safe). uncaughtExceptionMonitor exists specifically for observability tools like this one: it fires without changing what Node does afterward, verified directly against a real uncaught throw, not assumed.
  • unhandledRejection: a rejected promise nobody awaited or attached a .catch() to, arguably the most common way a modern async Node app silently fails.

Neither catches a web request's unhandled exception under Express/Fastify: both catch that themselves, long before it would ever reach here.

Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.

Identifying users

captureException(error, {}, { id: user.id, email: user.email });

Or runWithUser(user, callback) to attach it to every captureException() call made anywhere in callback's own async chain, rather than passing it by hand every time, e.g. from your own middleware:

app.use((req, res, next) => runWithUser({ id: req.user?.id, email: req.user?.email }, next));

There's no imperative setUser() the way some other languages in this repo have: Node is single-threaded, so a plain module-level variable would leak across concurrent requests interleaved on the same event loop, exactly the bug the other languages' own thread-local choice avoids for their own concurrency model. AsyncLocalStorage (Node's own built-in mechanism for this) only propagates a value through an async call chain that was explicitly wrapped, so runWithUser's wrapping shape is the correct, idiomatic choice here, not a limitation being worked around. 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.

Express apps get this automatically: add forgeOpsTrackerUserContextMiddleware (see the Express snippet above), after whatever middleware actually sets req.user (Passport's own session middleware, the closest thing Express has to a single dominant auth library, the same role Warden plays for Rails). A no-op when req.user is never set, whether that's because nobody's signed in or Passport isn't installed. Confirmed directly, with a real Express app and a real async route handler, that the AsyncLocalStorage context this sets survives all the way through to forgeOpsTrackerExpressMiddleware later in the same request, including across an await, not just through a synchronous call chain. Composes with the manual API above rather than replacing it: call runWithUser yourself for a route (or a custom auth setup this can't detect) that needs to override what was auto-detected.

Breadcrumbs

A trail of what happened right before an error. With forgeOpsTrackerBreadcrumbContextExpressMiddleware/ registerForgeOpsTrackerBreadcrumbContext installed (see the Express/Fastify snippets above), every request gets its own trail, and the Express performance middleware records a "controller" breadcrumb into it automatically, no further setup needed. Shows up alongside the error on an issue's own detail page.

forgeOpsTracker.init({
  dsn: "...",
  trackBreadcrumbs: false, // opt out of the automatic sources entirely
  maxBreadcrumbs: 30, // oldest entry dropped once this many have accumulated in one request
});

Add your own by hand, regardless of whether the automatic sources are on:

forgeOpsTracker.addBreadcrumb("charged card", { category: "billing", data: { orderId: order.id } });

category defaults to "custom", level to "info" ("debug"/"info"/"warning"/"error" are the four levels the automatic sources themselves use too), and data to {}. Works outside a request entirely too (a plain script, a worker with no breadcrumb-context middleware/hook installed): the buffer it adds to is created lazily wherever it's first called, the same "works standalone, no specific setup required" shape the manual API in every other SDK in this repo already has.

Each request gets its own fresh, bounded trail (a ring buffer capped at maxBreadcrumbs, oldest entry dropped once full), scoped with a second AsyncLocalStorage instance, the same mechanism runWithUser()/the user-context middleware already use for the affected user: Node is single-threaded, so a plain module-level variable would leak one request's trail into another's interleaved on the same event loop, exactly the bug AsyncLocalStorage avoids here. Confirmed directly, with a real Express app and a real Fastify app, each running two genuinely concurrent, interleaved requests, that one request's trail never bleeds into the other's, and that a breadcrumb recorded deep inside an awaited route handler (or the performance middleware's own res.on("finish") listener, which fires later still) is still visible to captureException() at the end of that same request. Unlike the affected user above, a breadcrumb's message/data is scrubbed for likely PII: console-style/query/request trail entries are exactly the kind of free text (a bind parameter showing up in a message, a URL with a token in it) the scrubber exists to catch, not a deliberately-structured field the way user is.

There's currently no query-level automatic breadcrumb source: this client has no ORM/DB driver integration to hang one off of yet, the same reason there's no query-level performance instrumentation either. Fastify also gets no automatic "controller" breadcrumb today, since there's no Fastify performance/timing integration in this client for one to ride alongside (see "Performance monitoring" below); registerForgeOpsTrackerBreadcrumbContext still gives Fastify requests their own trail, so addBreadcrumb() calls from inside a Fastify route handler work correctly and show up on that request's error.

Delivery: an async loop, not a thread

DeliveryQueue here isn't a background thread: Node is single-threaded. But a Node process is long-running across many requests, so an async processing loop on the event loop is the natural substitute: push() returns immediately, and delivery happens via non-blocking fetch() calls without ever blocking the request that pushed it. The loop starts lazily, on first push, not at import time: Node's cluster module can fork worker processes after the application has already loaded, and an eagerly-started loop would be left dead in every forked child; starting fresh on first push means each forked worker gets its own live loop regardless of when it was forked relative to import.

in_app backtrace frames

Node runs interpreted directly from real .js files on disk, so file-path matching against Configuration#appRoot is a straightforward prefix comparison against those on-disk paths. Defaults to the current working directory; set it explicitly if that doesn't match your app's actual layout. node_modules frames are never marked in_app, regardless of appRoot; Node's own internal modules (the node: scheme) never match a real appRoot prefix either, so they don't need special-casing.

Backtrace parsing is a regex over Error#stack, a plain string in V8 rather than a structured object: verified directly against real captured stack traces, both synchronous and async, named and anonymous frames, before relying on it. One quirk worth knowing: V8 captures an Error's stack at construction time, not at throw time, so there's no "empty backtrace" case for an exception that's constructed but never thrown.

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 (password, apiKey, ssn, and similar), and redacted before the payload ever leaves this process. 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 runWithUser above is a deliberate exception: it's never scrubbed, since redacting it would defeat the whole point of identifying users in the first place. Breadcrumbs (see "Breadcrumbs" above) are not exempt, unlike the user: they're scrubbed the same as the message/backtrace/context.

To disable it:

forgeOpsTracker.init({ dsn: "...", scrubPii: false });

Source context

By default, each in-app backtrace frame (never a node_modules dependency) is captured along with the 5 lines of source on either side of the culprit line, read straight off disk at raise-time, so an issue's detail page can show the actual code that broke, not just a file:line:method reference. This never applies to a frame outside your configured appRoot, and it fails silently (no context, not a thrown error) for any file that can't be read for whatever reason.

This is a real, deliberate exception to "off by default is safer": literal source code is being transmitted, not just a reference to it, and the real protection here is not this flag. Every project on ForgeOps has its own setting (on by default, off durably and immediately once an org owner turns it off, regardless of what any individual app's own captureSourceContext is still set to) that governs whether the server will ever actually store what a client sends. Set this to false if you'd rather this client never even attempt the disk read in the first place:

forgeOpsTracker.init({ dsn: "...", captureSourceContext: false });

Session tracking (release health)

By default, every request through the Express integration is counted as a session: crash-free unless an unhandled exception (or a 5xx response) actually affects it, giving ForgeOps a crash-free rate per release to show alongside the errors themselves, not just the errors on their own. Counted in-process and flushed as a small periodic aggregate on a setInterval timer (never one network call per request), the same delivery philosophy as everything else in this client: a broken or unreachable tracker never affects the host app either way.

forgeOpsTracker.init({
  dsn: "...",
  trackSessions: false, // opt out entirely
  sessionFlushIntervalMs: 30000, // default 60000
});

Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic flushes are simply rejected server-side and dropped, exactly like any other delivery failure.

One deliberate gap: unlike the Ruby gem's at_exit, this client does not hook SIGTERM/ SIGINT to force a final flush on shutdown. Registering a listener for either signal overrides Node's own default disposition (the process no longer exits on its own unless something calls process.exit()), which risks racing (or outright cutting off) a host app's own graceful shutdown if this tracker's own listener won that race. The accepted trade-off: up to one sessionFlushIntervalMs window of session data can be lost on a hard process exit, the same way it would be if the interval simply hadn't ticked yet: never a behavior change for whatever app this is installed into. See src/sessionFlusher.js's own comment for the full reasoning.

Performance monitoring

By default, the Express integration times every request (performance.now() before the route runs, diffed once the response finishes) so a dashboard widget on ForgeOps can show which parts of your app are actually slow, not just which ones raise. Bucketed by transaction ("GET /users/:id", the matched route pattern rather than the literal URL, so a distinct user id doesn't explode into its own separate transaction) and flushed as a small periodic aggregate per transaction on the same kind of setInterval timer session tracking above uses. The same middleware also records a "controller" breadcrumb (see "Breadcrumbs" above), gated on trackBreadcrumbs independently of trackPerformance: turning either one off doesn't affect the other.

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.

forgeOpsTracker.init({
  dsn: "...",
  trackPerformance: false, // opt out entirely
  performanceFlushIntervalMs: 30000, // default 60000
});

Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the periodic flushes are simply rejected server-side and dropped, exactly like any other delivery failure. Same deliberate no-flush-on-SIGTERM/SIGINT gap as session tracking above, and for the identical reason; see src/performanceFlusher.js's own comment.

Fastify isn't supported yet: unlike session tracking, there's no existing request-lifecycle hook to build this on for Fastify today, so it's a separate piece of work rather than something this version already covers.

Custom metrics and infrastructure monitoring

Two explicit calls (nothing is automatic, so there is no track* flag): a business event you name yourself, and a reading from one of your own hosts.

forgeOpsTracker.captureMetric("signup"); // value defaults to 1: a bare counter
forgeOpsTracker.captureMetric("payment", 49); // a real magnitude; it may be negative (a refund)

forgeOpsTracker.captureInfrastructureMetric("cpu", 0.42); // hostname defaults to serverName
forgeOpsTracker.captureInfrastructureMetric("disk", 0.81, { hostname: "db-1" });
await forgeOpsTracker.flushMetrics(); // optional: send right now

Each capture is buffered and flushed as one batch every metricFlushIntervalMs / infrastructureMetricFlushIntervalMs (60000 by default) on an unref'd setInterval timer, and once more when the event loop drains (beforeExit, which does not change how the process exits the way a SIGTERM listener would), so a short-lived cron script that captures a few readings and ends needs nothing more. Call await flushMetrics() if it exits another way (process.exit()). 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 the client isn't enabled for the environment.

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). The buffer holds at most 1000 entries per kind 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 process lives. A NaN or infinite value is dropped at capture: JSON.stringify turns it into null and the server would reject the whole batch behind it. Requires a ForgeOps plan that includes custom metrics / infrastructure monitoring.

Distributed tracing

For one slow request, forgeOpsTrackerTracingExpressMiddleware (Express) and registerForgeOpsTrackerTracing() (Fastify, ./integrations/tracing) capture its full nested call tree: the route span, plus every outbound HTTP call and manually-wrapped span nested under it, so ForgeOps can render a waterfall for that one request.

import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";

app.use(forgeOpsTrackerTracingExpressMiddleware);
import { registerForgeOpsTrackerTracing } from "@forge-ops/tracker/integrations/tracing";

registerForgeOpsTrackerTracing(fastify);

This is the whole point of the feature, so it's worth being explicit about: a request's own trace is only ever built, let alone sent, once its own root span's duration crosses a threshold, decided entirely client-side before a single byte goes over the wire. A normal, fast request costs nothing extra.

forgeOpsTracker.init({
  dsn: "...",
  trackTracing: false, // opt out entirely
  traceCaptureThresholdMs: 500, // default 1000
});

Outbound HTTP calls made via Node's built-in http/https modules (and so anything built on top of them, like axios or node-fetch) nest in automatically, no extra setup: init() always installs this hook, the same way gems/forge_ops_tracker's own Net::HTTP.prepend always applies regardless of config, checking trackTracing fresh on every actual call rather than at install time. Every span name ("GET api.stripe.com") is low-cardinality by design, the same as every transaction name elsewhere in this SDK: never the raw URL path or query string, since either can carry a customer's own id or a secret.

There's no way to auto-detect "this is a logically distinct service layer" the way an outbound HTTP call already has a real hook to extend, so wrap your own service-layer code by hand to have it show up as its own span:

await forgeOpsTracker.span("PaymentService.charge", () => chargeCard(order));

Works with both synchronous and async callbacks (the returned promise, if any, is awaited before the span is recorded), and nests correctly even when two spans are awaited concurrently in the same request (Promise.all), since span nesting is tracked per async execution branch via AsyncLocalStorage, not a single shared stack. kind accepts "controller", "service" (the default), "database", "redis", "http", "job", or "other", and takes an options object: forgeOpsTracker.span("PaymentService.charge", fn, { kind: "service", data: {} }). A no-op outside of a request currently being traced (a plain script, a request that already finished) or with trackTracing off: it just runs the callback and records nothing, never throwing.

Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, a captured trace is simply rejected server-side and dropped, exactly like any other delivery failure.

Known gaps: no database span capture (this SDK has no existing query/ORM instrumentation hook of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists here either); a database call or Redis call inside a traced request just won't show up as its own span for now.

Database errors

When an error carries the SQL behind a failed database call, the event includes the names of the stored procedure, table and view that SQL touched, so the issue tells you where to start looking. This is on by default and sends identifiers only, never values. The statement is read from a .sql or .query string on the error or anything it wraps (cause, and Sequelize's parent/original), which covers Sequelize, mysql2 and TypeORM. node-postgres, better-sqlite3 and Prisma errors carry none, so attach it where you ran the query with withSql.

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";

try {
  await pool.query(sql, params);
} catch (error) {
  throw withSql(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/node
npm install
npm test
npm run lint