A rollback changes code faster than it explains what happened. For a developer-tools API, the useful choice is to create one incident event at the Express boundary, attach one request ID, and send that same structured event to both the application logger and the exception sink. Pino or Winston can own the log path. Neither should invent a second identity for the exception.
TL;DR: normalize first, emit second. Accept a valid inbound request ID or generate one, return it to the caller, and keep it on every log and captured exception. Record release and rollout identifiers so an operator can reconstruct which code handled the request before or after a rollback. Redact before either destination sees the event.
One request, one ID.
How should Express error tracking integrate Pino and Winston?
A stack trace answers where execution failed. It does not, by itself, prove which release, rollout cohort, operation, or request produced the failure. After a rollback, the current deployment state may already differ from the state that served the customer. The evidence has to describe the historical request, not whatever is running when someone opens the dashboard.
Picture the lookup after build api-1842 is replaced by api-1841. A customer supplies the request ID from a failed CLI call. The operator should be able to find the log and exception, see that both name api-1842 and rollout parser-v3, and inspect the same error. If the release exists only as the process's current environment value during a later search, the rollback has already erased the answer. Capture it on the event when the failure occurs.
This changes the design. Treat the correlation envelope as application data with a tiny schema, not as formatter configuration hidden inside two transports. Config bloat is a bad place to keep incident semantics. It is hard to review and even harder to test.
Keep the envelope boring:
-
requestIdjoins the response, logs, and exception event. -
releaseidentifies the deployed build;rolloutidentifies the change being evaluated or reversed. -
operationis a bounded route-level name, not a raw URL containing customer data. -
occurredAtrecords when the application observed the event.
Do not log session identifiers, access tokens, passwords, database connection strings, or encryption keys. OWASP's logging guidance explicitly calls out those categories and recommends sanitizing event data to prevent log injection. That rule belongs before fan-out. One redaction pass is easier to audit than separate filters that slowly diverge.
Build the smallest shared event path
The implementation needs one interface and one error boundary. The adapters below are deliberately thin: an existing Pino or Winston logger can satisfy LogSink, while any exception system can satisfy ExceptionSink. No transport gets to reshape identity fields.
import crypto from "node:crypto";
import type { ErrorRequestHandler, RequestHandler } from "express";
type IncidentEvent = {
requestId: string;
release: string;
rollout: string;
operation: string;
occurredAt: string;
error: { name: string; message: string; stack?: string };
};
type LogSink = { error(event: IncidentEvent, message: string): void };
type ExceptionSink = { capture(error: Error, context: IncidentEvent): void };
const requestIdPattern = /^[A-Za-z0-9._-]{8,128}$/;
export const correlate: RequestHandler = (req, res, next) => {
const candidate = req.header("x-request-id") ?? "";
const requestId = requestIdPattern.test(candidate)
? candidate
: crypto.randomUUID();
res.locals.requestId = requestId;
res.setHeader("x-request-id", requestId);
next();
};
function asError(value: unknown): Error {
return value instanceof Error ? value : new Error("Non-Error thrown");
}
export function incidentBoundary(
log: LogSink,
exceptions: ExceptionSink,
deployment: { release: string; rollout: string },
): ErrorRequestHandler {
return (thrown, req, res, _next) => {
const error = asError(thrown);
const event: IncidentEvent = {
requestId: String(res.locals.requestId),
release: deployment.release,
rollout: deployment.rollout,
operation: `${req.method} ${req.route?.path ?? "unmatched"}`,
occurredAt: new Date().toISOString(),
error: { name: error.name, message: error.message, stack: error.stack },
};
log.error(event, "Request failed");
exceptions.capture(error, event);
res.status(500).json({ error: "internal_error", requestId: event.requestId });
};
}
Mount correlate before routes and the returned error handler after them. For Pino, the adapter's error method can pass the event as its structured object. For Winston, it can pass the same fields as metadata. The important integration is the IncidentEvent contract, not a clever formatter.
There is one intentional duplicate here: the exception appears in two destinations. The identity is not duplicated independently. That distinction prevents the common failure where a log has request A, an exception system generates event B, and the customer only knows the response ID.
No magic.
The sink calls also need an explicit delivery policy. If either performs network I/O, keep it out of the response's critical path with a bounded queue, then define what happens when that queue is full. Silent, unbounded buffering is not evidence retention. It is delayed memory exhaustion.
Test the join, not the pretty output
Formatter snapshots feel productive, but they miss the contract that matters. Feed a known x-request-id through a failing route and assert four things: the response echoes it, the log sink receives it, the exception sink receives it, and both events carry the same release and rollout values. Then repeat with an invalid inbound value and assert that the generated ID is returned and reused.
Also test redaction before fan-out. A payload containing a token-shaped field should reach neither fake sink in clear text. Test a thrown string too; JavaScript permits it, while the exception adapter usually expects an Error.
Benchmark this boundary for allocation count and tail latency because it runs on every failed request. Do not publish invented numbers: run the representative payload, logger mode, and destination behavior from the actual service. Compare synchronous emission, a bounded in-process queue, and the production export path. The decision is whether the added latency and loss behavior fit the incident-retention requirement, not whether a microbenchmark produces an impressive requests-per-second headline.
What changes when the service grows?
At higher volume, move delivery behind a bounded queue and version the event schema. Keep correlation creation inside the process boundary, because a downstream collector cannot recover an ID that the response never exposed. Add trace context only when there is a real distributed trace to join; a request ID and a trace ID solve related but different lookup problems.
Rollback safety also needs a release manifest outside the event stream. Map immutable build identifiers to source revisions and deployment times, then retain that mapping for at least as long as incident evidence. Otherwise release: api-1842 is precise but useless six weeks later.
The custom-sink boundary is an old pattern. Logback calls its output components appenders and documents extension through an appender interface. The useful lesson is architectural, not Java-specific: application code emits a stable event, while an adapter owns delivery. In Node.js, that boundary lets Pino and Winston remain replaceable without rewriting the incident schema.
This design has a clear limitation: dual emission is not suitable when the team cannot define alert ownership or bound the delivery queue. In that case, use one durable event pipeline and derive both searchable logs and exception notifications downstream. The trade-off is slower isolation between the two views, but there is only one delivery path to operate.
There are costs. Two destinations increase storage and can produce duplicate alerts unless alert ownership is explicit. A queue improves request isolation but introduces backpressure and shutdown-flush work. More context improves reconstruction while expanding the sensitive-data surface. Choose the smallest bounded schema that can answer: which request failed, which build served it, which rollout was active, and what error did the application observe?
That is enough to make a rollback explainable. Keep the identifier stable, the deployment fields immutable, and the adapters dull.
Top comments (0)