@fixback/node
v0.7.0
Published
The Fixback backend error SDK for Node servers — one-call init, batched secret-key capture, process handlers, and Express + NestJS adapters.
Maintainers
Readme
@fixback/node
The Fixback backend error SDK for Node servers. Capture unhandled crashes and manually-reported errors and file them into the same Fixback pipeline your frontend uses — one queue for your whole product (ADR-0061/0062/0028, spec #224).
Backend errors authenticate with a secret key, so they arrive Internal tier,
tagged platform: node, and filed under the environment that key belongs to.
Fleet-wide crash-loops collapse into a single Issue with a rising occurrence count
instead of flooding your queue.
📖 Full documentation: docs.fixback.dev/sdk/node — guides, concepts, and the complete API reference.
Install
npm install @fixback/nodeThe Express and NestJS adapters are optional — install the framework you use.
Quick start
Call init as early as possible in your process (before your app boots), so the
process handlers are installed before anything can throw:
// instrument.ts — imported first in your entry file
import { init } from "@fixback/node";
init({
secretKey: process.env.FIXBACK_SECRET_KEY!, // sk_… (never your publishable key)
release: process.env.GIT_SHA, // symbolicated against your uploaded sourcemaps
serviceName: "checkout-api", // or FIXBACK_SERVICE_NAME — which service this is
enabled: process.env.NODE_ENV === "production", // gate auto-capture off in local dev
});release falls back to the FIXBACK_RELEASE environment variable when you pass none,
so a deploy that sets it needs no release option. Use the release your sourcemaps
were uploaded under: frames then map back to your source, from CommonJS or ES-module
builds, on POSIX or Windows.
The secret key selects the environment. Each environment in the Fixback dashboard —
Production, Staging, … — has its own keys (and its own Gate), and every error is
filed under the environment of the secret key that sent it. So each deploy sets
FIXBACK_SECRET_KEY to its own environment's secret key, and there is no
environment option to keep in step with it — one passed to init is ignored, with
a one-time warning. A new deploy target starts in the dashboard: create its
environment and deploy that environment's key.
That single call wires:
- a batched secret-key transport that keeps an error until Fixback has filed it —
retrying, honouring
Retry-After, and flushing on shutdown (see Delivery, below) — and sends a flush too large for one request in several. Each error carries the instant it was captured (occurredAt), so a batch flushed later still records when every error happened; - process-level
uncaughtException/unhandledRejectioncapture that leaves your process to do what Node would (below) —autoCapture: falseturns both off (orcaptureUncaughtException/captureUnhandledRejectionindividually); - where it ran, on every batch: the SDK's name and version (the Issue's SDK row),
your
serviceName, and the host name, Node.js version and OS, read once atinit— so several services or instances filing into one environment can be told apart; - breadcrumbs — each request's
consolelines and outgoing HTTP calls, which its errors carry as their Trace (below); - and the manual capture API below.
apiUrl (or FIXBACK_API_URL) defaults to https://api.fixback.dev — the
hosted Fixback API. Set it only when your secret key was minted elsewhere: your
own origin for a self-hosted or local deployment. An explicit apiUrl or release
option wins over its environment variable.
Crashes and unhandled rejections
Listening for uncaughtException or unhandledRejection switches off what Node itself
does with one, so the SDK does it instead, after capturing (handled: false):
- An uncaught exception is flushed, printed, and exits the process with code 1, as Node would.
- An unhandled rejection follows your
--unhandled-rejectionsmode, read fromNODE_OPTIONSand the command line. In Node's defaultthrowmode the capture is flushed (within a short timeout) and the rejection is raised as an uncaught exception: the process prints it and exits 1, or your ownuncaughtExceptionhandler receives it.warn-with-error-codeprints Node's warning and sets exit code 1;warnandnonechange nothing.
When your app has its own listener for the event, the SDK only captures and your
listener decides what happens. Before 0.7 the SDK kept a process running after an
unhandled rejection that Node would have crashed on; to keep that behaviour, handle the
rejection, add your own unhandledRejection listener, or run Node with
--unhandled-rejections=warn.
Delivery
Errors buffer in memory and go to Fixback in batches — when 100 are buffered, every 5
seconds, on flush(), and on close(). An error leaves the buffer only once Fixback has
filed it:
- Retried. A batch that fails — a
5xx, a timeout, Fixback unreachable — is kept and sent again after 10 seconds, then 20, 40, … up to 5 minutes between tries. An error is given up after 6 failed sends, about five minutes of an unreachable Fixback. - Rate limits honoured. When your environment's error budget is spent, Fixback files
what it can and answers the rest
rate_limited, or the whole batch429, with aRetry-After. The SDK sends nothing until that wait is over, then sends those errors again; each wait counts as one of the 6 tries. - Nothing lost to a pause. Errors captured while the SDK waits keep buffering, and a
repeat of one already buffered folds into it — so occurrence counts still add up. The
buffer holds
maxQueueSizedistinct errors (1024): past that the oldest is dropped. - Never resent when it can't pass. An error Fixback could not read
(
invalid_error_item), or a batch refused outright (a4xxother than429), is dropped rather than retried. - Flushed on shutdown. When your process's event loop runs dry (
beforeExit), what was captured since the last flush is sent. OnSIGTERMorSIGINT, if your app has no handler of its own for the signal, the SDK sends what is buffered (waiting at most 2 seconds), then re-raises the signal — so the process still ends exactly as it would have. If your app handles the signal itself, the SDK registers nothing for it: callawait close()in your handler before you exit. A serverless handler shouldawait flush()before it returns.
A secret key Fixback refuses (401: revoked, its environment deleted, or a publishable
key) or a suspended workspace (403) is said once on standard error, so a misconfigured
deploy is never silent. debug: true says more: why the SDK is inert, what every send
answered, and what is retried or given up.
Manual capture
import { captureException, captureMessage } from "@fixback/node";
try {
await risky();
} catch (err) {
captureException(err); // handled: true
}
captureMessage("cache miss storm", "warning");A message's level — fatal, error, warning, info (the default) or debug — is sent
with it and shows on the Issue.
Chained errors
An error you capture is often a wrapper: fetch failed around the AggregateError of
every address the connect tried, or your own error thrown around the database error you
caught. The SDK sends the errors behind it too — its cause, followed five deep, and an
AggregateError's errors, ten of one aggregate — at most ten in all, each with its own
name, message and stack frames, scrubbed like the error itself:
try {
await db.insert(order);
} catch (err) {
throw new Error("could not save the order", { cause: err });
}The Issue shows each one under the error as a Caused by section, and the Brief and
the hand-off to your coding agent include them. The outer error leads, and the types
behind it join its fingerprint. beforeSend sees them as event.errorCauses, and can
redact or drop them. When one error alone outgrows a request, the causes' frames are
dropped before the error's own.
Grouping
Repeats of one error fold into one Issue with a rising count, across every instance and
deploy. An error's fingerprint is its type with the types behind it, and your own top
frames — never node_modules or node: internals — by function and file, never by line.
An anonymous callback is known by the named function it is written in. The message counts
only when the frames cannot name the throw site, with every number collapsed: User 42 not
found and User 43 not found from one throw site are one Issue.
Group your own way with fingerprint, on a capture or in beforeSend. "{{ default }}"
stands for the fingerprint the SDK computed:
captureException(err, { fingerprint: ["{{ default }}", tenantId] }); // split by tenant
captureMessage("Payment provider down", "error", { fingerprint: ["payment-provider-down"] });
init({
secretKey,
beforeSend(event) {
if (event.type === "AbortError") event.fingerprint = ["aborted-request"];
return event;
},
});event.errorSignature is the computed fingerprint; fingerprint wins over it. Fingerprints
changed in version 2 of the rules. An Issue filed before you upgrade keeps folding: the SDK
also sends the old key, and Fixback moves the Issue onto the new one the first time it sees
both.
Express
import express from "express";
import { fixbackRequestContext, fixbackErrorHandler } from "@fixback/node/express";
const app = express();
app.use(fixbackRequestContext()); // opens per-request context (before your routes)
// … your routes …
app.use(fixbackErrorHandler()); // captures errors that reach next(err) (after your routes)A route in a mounted router is recorded by its full pattern: a router at
app.use("/orgs/:org", posts) with posts.get("/posts/:postId") records
/orgs/:org/posts/:postId, from the error handler and from a captureException inside
the route alike, on Express 4 and 5. If a mount's pattern can't be worked out for
certain, the route is left out instead. That happens with a parameter that shares its
segment with text (/report-:year), a wildcard, a RegExp mount, or an error from a
mounted sub-app that reaches the parent app's error handler.
NestJS
import { Module } from "@nestjs/common";
import { FixbackModule } from "@fixback/node/nestjs";
@Module({ imports: [FixbackModule.forRoot()] })
export class AppModule {}FixbackModule.forRoot() registers the request-context middleware and a global
exception filter. The filter captures what a request throws, then lets Nest's own
exception filter produce the response, so your error handling is unchanged. An
exception from a microservice (RPC) or GraphQL call is captured too, then left to
Nest's default handling for that context. Global filters don't reach WebSocket
gateways. A gateway that applies the filter with @UseFilters(FixbackExceptionFilter)
has its exceptions captured, and the client still gets Nest's usual exception event.
Attaching a user and tags
The SDK never scrapes user identity. Attach an app-supplied reference and it rides along with the errors captured in its scope, and shows on the Issue:
import { setTag, setUser } from "@fixback/node";
setUser(currentUser.id); // an opaque ref you choose — not a name/email unless you send one
setTag("tenant", currentTenant.slug); // a key/value pair, shown on the Issue as a chipInside a request, both belong to that request. Outside one — a queue consumer, a cron job,
startup — they set process-wide values that every error carries, and that a request
without its own value carries too (a request's value wins for a key both set).
setUser(null) clears the scope's user; setTag(key, null) removes a tag.
Tags are bounded as ingest keeps them: a key is up to 32 letters, digits, _, ., :
or -; a value is a string, number or boolean of up to 200 characters on one line; an
error carries at most 50. A user ref is up to 256 characters. Anything outside those
bounds is ignored.
Breadcrumbs
An error carries the lead-up of its own request — what it logged and the calls it made out — and the Issue's Trace tab lists them in order above the failing request:
- every
consolecall (log,info,warn,error,debug, a failingassert): its level and its message, formatted as the console prints it and cut to 300 characters; - every outgoing request through
http/httpsorfetch: its method, its URL without the query string, its status, how long it took, and how it ended (ok,http-5xx,network-error, …). Never a header and never a body. The SDK's own batches to Fixback are not recorded.
Each request records into its own ring, so an error never carries another request's
breadcrumbs; work outside a request (a job, a timer) records into one process-wide ring,
which only an error outside a request carries. A ring holds the newest maxBreadcrumbs
(50 by default, at most 100; 0 records none) for at most five minutes. Requests are read
from Node's diagnostics_channel — nothing is patched — and the console wrapper always
runs your original method.
init({
secretKey: process.env.FIXBACK_SECRET_KEY!,
maxBreadcrumbs: 30,
capture: { console: true, network: true }, // turn either off with false
// See each breadcrumb as it is recorded: edit it, or drop it with null.
beforeBreadcrumb: (crumb) => (crumb.url?.includes("/health") ? null : crumb),
});Breadcrumbs pass the default scrub like every Trace: URLs lose their query strings and
PII in the path, and messages their emails, bearer tokens and long digit runs. Your
beforeSend sees them as event.breadcrumbs — edit or empty the list to change what is
sent.
The Correlation id
The request-context middleware gives every request one id, from the first source that
has it: the trace id of an inbound W3C traceparent header, then x-request-id (or
your requestIdHeader), else a freshly minted one. A malformed traceparent is ignored,
never partly used. Errors captured during the request carry the id, so when the page runs
the Fixback browser SDK — which sends traceparent on its same-origin requests and to the
origins you list in its propagateTo — the browser Issue for a failed request and this
server's Issue show the same id. The browser SDK sends the sampled flag (01) by
default, so a parent-based OpenTelemetry sampler on this server records those requests
rather than dropping them; its traceparentSampled: false sends 00 instead. This SDK
reads the id, never the flag, so the link holds either way.
Privacy
Private-by-default. The SDK captures the route pattern (never the concrete path
with values: a route whose pattern it can't work out is left out), method, status, a
correlation id, the app-supplied user ref and tags, where the process runs (service,
host name, Node.js version, OS), and the request's breadcrumbs — console messages and
outgoing requests' metadata, scrubbed. It never sends Authorization/Cookie
headers, environment variables, or query-string values. Request/response bodies are an explicit opt-in, off by default:
with captureBodies: true the failing request's bodies ride on its server context —
serialized, PII-redacted at the scrub choke point, and dropped (never truncated) when
larger than maxBodyBytes (16384 by default, at most 65536) or when they would make their
error too large to send in one request. A beforeSend(event) hook
lets you redact further or drop an event entirely (return null).
Fails quiet
If Fixback is unreachable or misconfigured, your server keeps running normally —
observability never becomes an outage. A capture never throws, the buffer never grows past
maxQueueSize, and the SDK's timers never keep your process alive. Quiet is not silent:
a refused key is said once on standard error, and debug: true says the rest.
