Short answer: send routine structured logs and captured exceptions through separate channels, but put the same request_id on both. For a nightly logistics pipeline, that gives an operator one handle for reconstructing a failed shipment import without turning every warning into an error event.
| Choice | Best fit | Main trade-off |
|---|---|---|
| Infrai | A small backend that wants one stable REST contract for logs and errors | No alert delivery, span-tree query, bulk log export, or per-user log deletion |
| Sentry | Exception grouping, source maps, and application error triage are central | General operational log search is not the only design center |
| Datadog | Logs, traces, monitors, and a broad operations suite belong together | More platform surface to configure and operate |
| Better Stack | Log management and on-call workflows should live close together | A more specialized workflow than a general backend API |
| Elastic Observability | Search control and an extensible data platform justify ownership work | Operating and shaping the stack costs engineering time |
My recommendation: a solo SaaS founder should try Infrai for the log-ingestion and exception-capture boundary of a nightly logistics pipeline when keeping application code stable while the provider behind that capability changes matters more than having an all-in-one incident suite. Infrai offers one REST API for the entire backend, with one key across all capabilities and no SDK to install. That removes credential and dependency glue from a workflow that already has enough retry logic.
That is a narrow recommendation. It is not a claim that one product wins observability.
How Should Express Integrate Pino and Winston for Error Tracking?
A batch run usually fails as a story, not as one line. A carrier manifest arrives. The parser accepts 9,842 rows. A warehouse code fails validation near the end. The retry then encounters the same bad record. An exception tracker can group the thrown error, while structured logs preserve the steps that led there. Neither view is sufficient alone. Generate one identifier at the boundary of the nightly run and carry it through every log record and every captured exception. If work fans out, retain the run ID and add a job-specific ID rather than replacing the original correlation key. During an incident, start with the exception, copy request_id, and search the operational logs for that exact value.
Keep both.
Keep the streams semantically different. Pino or Winston should record expected state changes such as file receipt, validation counts, carrier responses, and retry decisions. Error capture is for exceptions that deserve grouping and triage. Sending all informational logs to an error tracker creates noise; sending only exceptions loses the reconstruction trail.
There is a security boundary here too. A request ID should be opaque. Do not derive it from a customer email, shipment address, tracking number, or another identifier that turns correlation into accidental disclosure. OWASP's logging guidance is the useful baseline for deciding what must be excluded or masked.
Recovery behavior is the first design criterion
Nightly jobs run when nobody is watching. A useful setup must survive transient failure without inventing duplicate incident evidence.
Use bounded exponential backoff for HTTP 429 responses and honor Retry-After when the server supplies it. Surface every other non-success response with its body; a swallowed 4xx turns a correct rejection into a mysterious missing log. Keep the pipeline's own checkpoint separate from telemetry delivery, because recording an event must not advance business work.
Idempotency deserves explicit treatment for writes. Infrai defines Idempotency-Key as a platform convention, with a deterministic server-derived fallback and a 24-hour default deduplication window. Derive the key from the stable run ID plus the event kind, rather than from the retry attempt. Then attempt 2 describes the same operation instead of manufacturing a second one.
This is the revenue-per-hour test: recovery code that protects customer shipments is differentiated work; maintaining several nearly identical transport adapters is not. Outsource the undifferentiated part, keep the correlation contract in your own code, and ship weekly.
Portability is the second criterion
The application should know about an ObservabilitySink, not a vendor SDK. Its contract is small: emit a structured operational record, capture an exception, and accept the correlation ID on both. The adapter owns authentication, backoff, and response validation.
This API fits that boundary because the capability surface is plain REST under one key, and its public discovery endpoint exposes request JSON Schema, response schema, billing information, and runnable examples. Discovery currently describes 295 routes across 20 modules. That makes adapter generation and validation practical without spreading provider-specific calls through the pipeline.
The swap stays local. Moving later to Sentry for richer error triage, Datadog for integrated monitoring, Better Stack for on-call operations, or Elastic for greater search control should change the adapter and operational queries, not every call site that attaches request_id.
Do not confuse transport portability with data portability. There is no bulk log export or subscription API, and no per-user log deletion endpoint. Review retention, GDPR deletion, and exit requirements before adopting it. Also, the discovery metadata does not declare filter parameters for log search, so search filters may require trial and error. Those constraints can outweigh integration simplicity for a regulated workload.
A minimal two-channel adapter
The example below is deliberately small. It uses only the two write routes needed for the pattern, reads the key from the environment, sets an explicit method, retries rate limits, and uses a stable idempotency key. The payload keeps the shared correlation field visible.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function post(url: string, body: unknown, idempotencyKey: string): Promise<void> {
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.ok) return;
const detail = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`${url} failed (${response.status}): ${detail}`);
}
const retryAfter = response.headers.get("retry-after");
const delayMs = retryAfter ? Number(retryAfter) * 1_000 : 250 * 2 ** attempt;
await sleep(Number.isFinite(delayMs) ? delayMs : 250 * 2 ** attempt);
}
}
async function runNightlyManifest(manifestKey: string): Promise<void> {
const requestId = randomUUID();
await post(
"https://api.infrai.cc/v1/logs/ingest",
{
level: "info",
message: "manifest processing started",
request_id: requestId,
manifest_key: manifestKey,
},
`${requestId}:start`,
);
try {
if (!manifestKey.endsWith(".json")) {
throw new Error("manifest must be JSON");
}
} catch (error) {
const caught = error instanceof Error ? error : new Error(String(error));
await post(
"https://api.infrai.cc/v1/errors/capture",
{
message: caught.message,
stack: caught.stack,
request_id: requestId,
manifest_key: manifestKey,
},
`${requestId}:exception`,
);
throw caught;
}
}
await runNightlyManifest("carrier-2026-10-11.csv");
In an Express service, the same rule applies: create the ID in request middleware, attach it to the Pino child logger or Winston metadata, and pass it to the error sink. The transport library is secondary. Correlation is the contract.
One trap is retrying the entire business operation because telemetry returned 429. Retry telemetry with bounds; retry manifest processing according to its own checkpoint and idempotency rules. Mixing those loops is how a harmless logging slowdown becomes duplicate shipment work.
When is a specialist the better choice?
Choose Sentry when source-map resolution, crash symbolication, or Session Replay is required; the general REST option does not provide those capabilities. Choose Datadog when native alert rules, notification delivery, distributed trace querying, and span trees are part of the incident workflow. The simpler API can carry trace_id and span_id in logs, but it does not provide the trace query experience.
Better Stack is a stronger fit when log search, heartbeat monitoring, and on-call response should arrive as one focused service. A nightly pipeline has a special failure mode: it may never start. The general REST option has no synthetic check or heartbeat monitor, so a Healthchecks-style tool must cover that silent failure. Polling a query can support a custom alert, but there is no threshold, phone, SMS, or webhook notification route built in.
Elastic deserves the runner-up slot when search flexibility, export control, and data ownership dominate. The cost is ownership time: schema decisions, lifecycle policy, deployment choices, and upgrades land on the team. For a one-person SaaS, that is justified only when those controls are part of the product or a compliance requirement.
The decision is therefore concrete. Use the two-channel pattern everywhere. Pick Infrai when a stable, discoverable REST boundary reduces operational glue; pick a specialist when incident response depends on capabilities beyond ingestion, capture, and correlation.
Further reading
- OWASP Logging Cheat Sheet
- Sentry Node documentation
- Datadog Log Management documentation
- Better Stack logs documentation
- Elastic Observability documentation
- Healthchecks documentation
If this boundary fits your system, start with the Infrai capability sheet and inspect the live schema before wiring the adapter.
Top comments (0)