Node.js error tracking
Know when your Node.js app breaks, before your users do
ForgeOps connects the errors your Node.js app reports to the deploys, traces, and changes around them, so when production breaks, the incident already says who's affected, what changed, and where to look.
Express and Fastify integrations cover synchronous throws and rejected promises alike. Running on ForgeOps' own dedicated infrastructure, so nothing about a Node.js app's errors or its users' data gets routed through a third-party error-tracking vendor. See an incident investigated →
Install
npm install @forge-ops/tracker
const forgeOpsTracker = require("@forge-ops/tracker");
// ES modules: import * as forgeOpsTracker from "@forge-ops/tracker";
forgeOpsTracker.init({ dsn: "https://<api_key>@getforgeops.net/api/v1/events" });
Express and Fastify integrations cover synchronous throws and rejected promises alike.
Every client reports the same shape
Wherever it comes from, an event arrives with an exception class, a message, and a backtrace, plus whatever environment/release/server context that client can gather on its own. ForgeOps fingerprints and groups on the first three, so a Node.js app's issues list works exactly the same way every other language's does: report the same bug a thousand times and it's still one row, not a thousand.
No agent, no sidecar: it's a plain HTTPS POST to ForgeOps' own ingestion API, so nothing about a Node.js app's traffic gets routed through a third-party pipeline first.
Works with your framework
The Node.js client hooks in at the framework level, not by wrapping individual calls, so nothing about how you already write Node.js code has to change.
Express
Error-handling middleware placed last in the chain catches whatever gets thrown or passed to next().
Fastify
A plugin hooks Fastify's own error lifecycle, including errors from async handlers.
Plain Node/http
Call the client directly from a try/catch if there's no framework in the way at all.
Scripts and crashes
An uncaught exception is sent before the process exits (waiting at most 2 seconds), and an unhandled rejection still crashes the process as it would without the client. Before calling process.exit() yourself, await flush().
What it looks like
The issue list
Every Node.js issue reported into a project shows up here: title, status, assignee, event count, and when it was last seen, the same columns as any other language's project.
The issue detail
Every occurrence keeps its own timestamp, environment, release, and server, plus the backtrace exactly as Node.js reported it, expandable per occurrence rather than flattened into one generic stack.
Alerting
A new Node.js issue, a regression, or a spike past a threshold you set can reach Slack, Microsoft Teams, email, PagerDuty, Opsgenie, or a generic webhook, configured per project, per trigger.
Heartbeats
For the failure mode error tracking can't see on its own: a Node.js cron job or recurring task that's supposed to run but silently stopped. Each heartbeat gets its own ping URL and its own grace period before it alerts.
Background jobs
init() also installs process-level handlers by default, covering an unhandled promise rejection or an uncaught exception that would otherwise crash the process outright, with no wiring needed.
BullMQ (and Bull, and Agenda) never let a failing job reach either handler though: each catches the job's exception itself, to mark it failed and keep the worker alive. BullMQ's own worker.on("failed", ...) event needs wiring instead:
worker.on("failed", (job, err) => {
forgeOpsTracker.captureException(err, {
jobId: job.id, jobName: job.name,
});
});
Release health
Once your app is reporting into ForgeOps, every request through the Express integration also counts as a session: crash-free unless an unhandled exception (or a 5xx response) actually affects it. That gives each release's row on a project's Releases page a real crash-free rate, not just an event count. On by default; counted in-process and flushed as one small aggregate report every 60 seconds, not one network call per request. Requires a plan that includes release health; on a plan that doesn't, the periodic reports are accepted but not recorded (the response says why), so a working setup never looks broken.
forgeOpsTracker.init({
dsn: "...",
trackSessions: false, // opt out entirely
sessionFlushIntervalMs: 30000, // default 60000
});
Performance monitoring
Add the Express middleware below (Fastify isn't supported yet) and every request also times its own duration, 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 every 60 seconds, the same delivery philosophy as release health above. Requires a plan that includes performance monitoring; on a plan that doesn't, the periodic flushes are accepted but not recorded (the response says why), so a working setup never looks broken. Each report also carries a small latency histogram (@forge-ops/tracker 0.9.0 and later), so ForgeOps shows an approximate p50/p95/p99 per transaction, not just an average, accurate to the width of the latency bucket a duration falls into.
import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
forgeOpsTracker.init({
dsn: "...",
trackPerformance: false, // opt out entirely
performanceFlushIntervalMs: 30000, // default 60000
});
Distributed tracing
For one slow request, the Express middleware or the Fastify plugin captures its full nested call tree: the route, plus every Postgres query (pg), Redis command (ioredis, or node-redis 5.12 and later), and outbound http or https call under it. Those libraries are picked up automatically when your app has them installed, with nothing to set up. A Redis span is named after the command alone (Redis GET), never its keys or values. Only a slow request's trace is ever sent: whether it crossed the threshold (one second by default, configurable) is decided entirely inside your process, so a fast request costs nothing over the wire. Postgres and Redis spans need @forge-ops/tracker 0.14.0. Requires a plan that includes distributed tracing.
import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
app.use(forgeOpsTrackerTracingExpressMiddleware);
// or, under Fastify:
import { registerForgeOpsTrackerTracing } from "@forge-ops/tracker/integrations/tracing";
registerForgeOpsTrackerTracing(fastify);
forgeOpsTracker.init({
dsn: "...",
trackTracing: false, // opt out entirely
traceCaptureThresholdMs: 500, // default 1000
});
Wrap your own service-layer code by hand to give it its own span. It works with sync and async callbacks, and does nothing outside a traced request.
await forgeOpsTracker.span("PaymentService.charge", () => chargeCard(order));
Errors now carry the request they happened in: its endpoint (POST /checkout) and its trace id. An issue names its affected endpoint, each occurrence links to its exact trace, and a request that fails is always traced, however fast. The trace follows the request across services in the standard traceparent header, picked up from whatever called your Express or Fastify app and passed on to whatever it calls through Node's http and https modules (the global fetch isn't covered), so once the projects are linked in ForgeOps an error shows the errors another project raised in the same request. Needs @forge-ops/tracker 0.11.0.
forgeOpsTracker.init({
dsn: "...",
propagateTraces: true, // the default
tracePropagationTargets: ["api.example.com"], // only these hosts; null (the default) means all
});
SQL in a request
When the request an issue failed in was traced, the issue opens with its slowest query: how long it took, its share of the request's time, how many queries the request ran, and a likely N+1 warning when the same statement ran five or more times. Queries run through pg are timed for you, each with its SQL; for any other database library, wrap a query in a span with kind: "database" and pass its SQL as statement. Either way the statement is masked before it leaves your process, every string and number replaced with a question mark, and the same SQL shows under that span's bar in the trace's waterfall. Bind values are never sent. Needs @forge-ops/tracker 0.14.0 (0.13.0 for hand-wrapped queries), on a plan that includes distributed tracing.
import pg from "pg";
const pool = new pg.Pool();
// Recorded as a "SELECT orders" span, its SQL sent as
// "... WHERE customer_id = $1 AND status = ?"
const { rows } = await pool.query(
"SELECT id, total FROM orders WHERE customer_id = $1 AND status = 'open'",
[customerId],
);
// Any other library: wrap the query yourself.
import mysql from "mysql2/promise";
import * as forgeOpsTracker from "@forge-ops/tracker";
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const sql = "SELECT id, total FROM orders WHERE customer_id = ? AND status = 'open'";
const [orders] = await forgeOpsTracker.span("Load open orders", () => connection.execute(sql, [customerId]), {
kind: "database",
statement: sql,
dbSystem: "mysql",
});
What changed
A feature flag flip or a config edit can break things with no deploy at all. Record one with recordChange and it shows under What changed on any issue that starts in the next two hours, and on the project's Changes page beside your deploys, labeled potentially relevant, never as the cause. Changes between deploys are detected for you: once per process, init() sends the Node version and each dependency in your package.json, resolved to the version actually installed, on a later event-loop turn so startup never waits on it. Environment variable names (never values) are opt-in. Needs @forge-ops/tracker 0.12.0, on a plan that includes change tracking.
forgeOpsTracker.init({
dsn: process.env.FORGE_OPS_DSN,
detectChanges: true, // the default; false sends no startup snapshot
trackEnvVarNames: true, // default false; names only, never values
});
forgeOpsTracker.recordChange({
kind: "feature_flag",
title: "gateway_retry_v2 turned on for 100% of checkouts",
details: { flag: "gateway_retry_v2", from: "10%", to: "100%" },
actor: "priya@example.com",
});
Custom metrics and infrastructure monitoring
Track a business event you name yourself (a signup, a payment) or a reading from one of your own hosts, with one explicit call each; nothing is automatic. The value defaults to 1, so a bare call is a counter; pass a real amount for anything else (it can be negative, for a refund). A captured value is stored as its own row, so counts and sums you compute later are exact. Calls are buffered and flushed as one batch in the background, so they are safe to make inside a request, and flushMetrics sends whatever is buffered right now. The hostname of an infrastructure reading defaults to the configured server name. Requires a plan that includes custom metrics and infrastructure monitoring.
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
Database errors
When an error comes from a database call, the issue shows which stored procedure, table or view its SQL touched, so you know where to start looking. Sequelize, mysql2 and TypeORM errors carry the statement as a .sql or .query string, so it's read off them automatically. node-postgres, better-sqlite3 and Prisma errors carry none, so attach it where you ran the query with withSql. The names are sent by default and are identifiers, never values. The SQL statement itself is opt-in, with every string and number replaced by a question mark before it leaves your app, and a project setting can stop ForgeOps storing the statement at all.
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 });
Questions
Do I need to change how I already handle errors?
Almost never. Every client hooks the framework's own exception handling directly, the same way the install snippet above shows, so your own logs, error pages, and rescue blocks keep working exactly as they did before, and ForgeOps just also finds out about it. The one common exception is a background-job runner that doesn't forward through that same mechanism on its own; that needs one small hook added instead, not a change to anything already there.
Where does the data actually go?
Straight to ForgeOps' own ingestion API over plain HTTPS. No agent, no sidecar, and no third-party ingestion service in between, so nothing about your app's traffic or its users' data gets routed through anyone else's pipeline first.
What happens if the same bug fires a thousand times?
It's fingerprinted from its exception class, message, and backtrace, so a thousand reports of the same bug still show up as one issue with an event count of 1,000, not a thousand separate rows to dig through. And a runaway loop can't use up your monthly event quota: once one issue repeats faster than your plan's hourly limit (50 an hour on Free), further repeats are still counted, and still count toward spike alerts, but they aren't stored or charged to your quota.
Is anything scrubbed before it's stored?
Yes. Emails, credit card numbers, and known API key/token formats are redacted out of every event before it's even written, on every plan, with room for per-project custom field names on top of the defaults.
Try it with your own Node.js app
Free plan included, no credit card required. New organizations start with 14 days of Business.
Get started freeGet