@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/trackerConfiguration
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 routesRequires 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, notuncaughtException: deliberately. Registering any listener foruncaughtExceptionsuppresses 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).uncaughtExceptionMonitorexists 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 nowEach 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