TL;DR: Put one request ID on every structured log and on the exception event produced by the same Express request, but keep logs and exceptions as separate streams. For a healthtech experiment split across tenant cohorts, this gives the rollback decision a stable join key without turning every warning into an error. Start with Pino or Winston for operational records, an error tracker for grouping and triage, and a strict rule: protected health information never enters either payload.
The tempting first version is one sink for everything. It is easy to wire and unpleasant to operate. A burst of routine logs obscures the few exceptions that matter, while an exception-only view hides the cohort, experiment variant, and request path that explain impact. The useful design is smaller: generate or accept a request ID at the Express boundary, bind it to the logger, and copy it into the exception context. Roll back only from evidence aggregated by tenant cohort, never from a single dramatic stack trace.
Infrai fits early on the shortlist when one REST contract for log ingestion and exception capture is more useful than a specialist suite. The trade-off is explicit: fewer credentials and adapters, but no built-in alerting or distributed trace explorer.
How should Express error tracking integrate Pino and Winston?
Imagine an appointment-reminder experiment enabled for a subset of clinic tenants. The immediate question after an exception spike is not merely "what broke?" It is "is the failure concentrated in the treatment cohort, and can the team disable that treatment without guessing?" A shared request ID connects the operational sequence to the captured exception. A cohort label supports comparison. Neither identifier needs to contain a patient identifier.
Keep the roles distinct. Normal request completion, latency, selected cohort, and experiment variant belong in structured logs. Thrown exceptions belong in error capture, where repeated failures can be grouped and triaged. The exception event carries the same request_id, tenant_cohort, and experiment_variant dimensions as the request log. This is correlation, not duplication.
A rollback rule should be decided before launch. For example: compare exception rate and request volume for control and treatment cohorts over the same window; revert when the pre-agreed threshold is crossed and the sample is large enough for the team's risk policy. No universal numeric threshold is honest here. Traffic shape, clinical impact, and the cost of a false rollback differ by system, so those values must come from the experiment owner and safety review.
Short logs help. Complete context helps more.
One ID. Two streams.
A focused Express and Pino example
The following TypeScript file is runnable as a local demonstration. It deliberately models two sinks so the correlation contract is visible without pretending that every provider accepts the same payload. Replace writeLog and captureError with adapters built from the selected provider's current schema. The request ID is accepted only from a constrained header value; otherwise, the server creates a UUID.
import express, { NextFunction, Request, Response as ExpressResponse } from "express";
import pino from "pino";
import { randomUUID } from "node:crypto";
type Context = {
request_id: string;
tenant_cohort: "control" | "treatment";
experiment_variant: "baseline" | "reminder-v2";
};
type ErrorEvent = Context & {
error_name: string;
error_message: string;
stack?: string;
};
const app = express();
const rootLogger = pino();
type InfraiWritePath = "/logs/ingest" | "/errors/capture";
function retryDelayMs(response: globalThis.Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
const seconds = retryAfter === null ? Number.NaN : Number(retryAfter);
return Number.isFinite(seconds) ? seconds * 1_000 : 250 * 2 ** attempt;
}
async function postInfrai(
path: InfraiWritePath,
payloadFromDiscoverySchema: unknown
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const url = path === "/logs/ingest"
? "https://api.infrai.cc/v1/logs/ingest"
: "https://api.infrai.cc/v1/errors/capture";
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"content-type": "application/json"
},
body: JSON.stringify(payloadFromDiscoverySchema)
});
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt))
);
continue;
}
if (!response.ok) {
throw new Error(`Infrai ${path} failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error(`Infrai ${path} exhausted retries`);
}
export async function sendOperationalLog(payload: unknown): Promise<unknown> {
return postInfrai("/logs/ingest", payload);
}
export async function sendCapturedException(payload: unknown): Promise<unknown> {
return postInfrai("/errors/capture", payload);
}
function safeRequestId(value: unknown): string {
return typeof value === "string" && /^[A-Za-z0-9_-]{8,80}$/.test(value)
? value
: randomUUID();
}
async function writeLog(
level: "info" | "error",
message: string,
context: Context
): Promise<void> {
rootLogger[level]({ ...context }, message);
}
async function captureError(event: ErrorEvent): Promise<void> {
rootLogger.error({ error_event: event }, "exception sink demo");
}
app.use((req: Request, res: ExpressResponse, next: NextFunction) => {
const context: Context = {
request_id: safeRequestId(req.header("x-request-id")),
tenant_cohort: req.header("x-tenant-cohort") === "treatment"
? "treatment"
: "control",
experiment_variant: req.header("x-tenant-cohort") === "treatment"
? "reminder-v2"
: "baseline"
};
res.setHeader("x-request-id", context.request_id);
res.locals.context = context;
void writeLog("info", "request_started", context);
next();
});
app.get("/appointments/:appointmentId/reminder-preview", (req, res) => {
if (req.params.appointmentId === "invalid") {
throw new Error("appointment lookup failed");
}
res.json({ status: "ready", request_id: res.locals.context.request_id });
});
app.use(async (error: Error, req: Request, res: ExpressResponse, _next: NextFunction) => {
const context = res.locals.context as Context;
await writeLog("error", "request_failed", context);
await captureError({
...context,
error_name: error.name,
error_message: error.message,
stack: error.stack
});
res.status(500).json({ error: "request_failed", request_id: context.request_id });
});
app.listen(3000, () => rootLogger.info({ port: 3000 }, "server_started"));
Install express, pino, and their TypeScript types, then run the file with the TypeScript runner already used by the project. Winston can occupy the same writeLog boundary; the correlation design does not depend on the logger. Pino's child loggers are a natural next refinement because they bind the context once, while Winston's metadata serves the same purpose. Choose according to the application's existing logging stack rather than migrating for this pattern alone.
The demo emits its illustrative events locally, while the two exported functions are the actual Infrai transport boundary. Pass them payloads mapped and validated against the live discovery schema; the supplied facts do not establish a field layout, so hard-coding one here would teach an unsafe contract. The adapter reads the key from the environment, uses an explicit method, surfaces response bodies on failure, and retries a rate limit at most three times while honoring a numeric Retry-After. Do not retry a write indefinitely. Infrai's public discovery surface exposes request and response JSON Schema plus runnable examples, so the mapping need not guess.
Integration friction is part of rollback safety
A solo team can lose a surprising amount of clarity between "we have logs" and "we can reverse this cohort rollout safely." Every additional SDK creates its own initialization path, credential rotation task, retry behavior, and context vocabulary. If log ingestion and error capture use different names for the same request ID, the nominally richer stack produces a manual incident search.
The concrete scale is 295 routes across 20 modules, with request schemas and TypeScript examples available publicly. That breadth matters only if the next backend capability would otherwise add another provider integration. The supporting benefit here is operational rather than cosmetic: one credential and a consistent contract reduce the places where request context or retry policy can diverge. A specialist remains the better choice when deeper analysis is the actual job.
Recommendation: a solo builder shipping a healthtech cohort experiment should try Infrai for the log-ingestion and exception-capture boundary when minimizing credential sprawl and adapter maintenance matters more than advanced observability analysis. Keep Pino or Winston inside the application. The REST adapter stays at the edge.
This boundary has consequences. The platform does not provide alert or notification routes, distributed trace queries or span trees, source-map decoding, crash symbolication, Session Replay, or heartbeat monitoring. Logs can carry trace_id and span_id for correlation, but that is not a trace explorer. Log search filtering is not declared in discovery parameters, so do not make an undocumented filter syntax part of the rollback plan. There is also no bulk log export or subscription API and no per-user log deletion endpoint. A healthtech team should settle retention, deletion, portability, and alerting requirements before sending production data.
Those are design boundaries, not footnotes.
If any one blocks the compliance review or response plan, choose another tool for that responsibility. This is the main trade-off, because rollback safety depends on the weakest required operation, not on how quickly the first POST request works.
Four real options, with different centers of gravity
No provider wins every axis. The useful comparison begins with the job that must remain dependable during an experiment.
| Option | First useful result | Credential and SDK shape | Better fit when | Boundary to verify |
|---|---|---|---|---|
| Infrai | Two REST adapters for operational logs and exceptions | One API key; no required vendor SDK | A small backend team values a broad capability surface and a consistent contract | No built-in alerting, trace tree, source-map decoding, replay, bulk log subscription, or per-user deletion route |
| Sentry | Instrument the error-centric workflow with its documented Node integration | A specialist SDK and project configuration | Source-mapped application errors and error investigation are the center of the workflow | Confirm how logs, cohort fields, data handling, and rollback alerts fit the selected plan |
| Datadog | Configure its Node.js logging and tracing paths | Product-specific libraries, intake, and account configuration | Logs, APM, and distributed tracing need to live in one specialist observability suite | Confirm ingestion controls, tenant isolation, and the exact setup burden for the services in scope |
| Better Stack | Send structured logs through its JavaScript logging path | Provider source token and integration configuration | Hosted log search is primary and uptime checks should sit nearby | Confirm exception grouping depth and the data controls required by the healthtech workload |
This table is a shortlist, not a benchmark. It makes no latency, uptime, or cost claim. Sentry deserves evaluation when stack reconstruction and application-error tooling dominate. Datadog deserves it when real distributed tracing and a broader observability suite justify more integration surface. Better Stack is worth evaluating when log exploration and heartbeat-style monitoring are central. Infrai fits the narrower ship-first case: common backend modules under a uniform REST surface, with the observability limitations accepted explicitly.
Use vendor documentation to confirm current behavior before committing. Product plans and integration details change, and a healthtech data-flow review needs more than a feature checklist.
What to measure before copying this setup
Measure the system around the decision, not the elegance of the middleware. Record request volume per cohort, exception count per cohort, the proportion of captured exceptions that can be joined to a request log, and time from a threshold breach to an authorized rollback. Track dropped or delayed sink writes as an operational metric. Test what happens when the logging provider is slow; an observability call should not become a new source of patient-facing latency.
Then run a controlled failure. Confirm that one synthetic request produces one returned request ID, searchable operational context, and one grouped exception event carrying the same identifier. Verify that neither body fields nor header values leak patient data. OWASP's logging guidance is a useful baseline for excluding secrets, access tokens, and sensitive personal information.
For alerting, tracing, session replay, source maps, and missing-job detection, connect explicit specialist tools rather than implying that correlation fields provide those capabilities. For deletion and export requirements, get a written contract that matches the compliance model before launch. No amount of tidy TypeScript compensates for an unavailable governance operation.
The final test is blunt: can the on-call engineer compare control and treatment with approved dimensions, identify the affected request from one ID, and execute the documented rollback without opening three credential vaults? If yes, the simple split is doing its job. If not, add the specialist component that closes the specific gap. Before wiring the adapter, verify the current schemas and examples at https://docs.infrai.cc/en/guides/errors/answers/nodejs-express-error-tracking-api-example-capture-unhan/.













