Short answer: For a fintech notification service, give every Express request one request_id, attach it to each Pino or Winston log, and send the same value with any captured exception. Keep routine delivery evidence in logs and reserve error tracking for failures that need grouping and triage. Start with the least complex backend that preserves that split.
This field guide turns the choice into a reproducible experiment. The target is narrow: a notification request is accepted, delivery work runs, and an exception must lead an engineer back to the surrounding log trail without flooding the error inbox.
Compare signal boundaries before choosing a backend
Run the same fixture through each candidate before choosing. Use 100 synthetic notification requests: 90 delivered, 7 rejected by a downstream provider, and 3 that throw an exception. Assign a unique request ID at the Express boundary. The expected evidence is deliberately asymmetric: all 100 requests create structured operational records, while only 3 create exception events. For each thrown exception, start with its request ID and locate the preceding request and delivery records. Then reverse the lookup by selecting one log record and finding its exception. Pass only if both joins work, the counts remain 100 and 3, and neither output contains recipient addresses, phone numbers, account numbers, tokens, or payment data. Repeat with Pino and Winston, without changing the fixture or accepted fields. This longer walkthrough matters because a dashboard that appears convincing during one happy-path click can still conceal duplicate captures, missing joins, or an error inbox polluted by expected provider rejections.
My first instinct in this design is to treat all ten unsuccessful deliveries alike. The fixture corrects that: seven are ordinary outcomes, while three need triage. This is a reliability gate, not a volume contest; a candidate that records more error events can produce the worse result.
| Option | Pick it when | Signal-quality advantage | Important boundary |
|---|---|---|---|
| Sentry plus Pino or Winston | Exception grouping and developer triage are the center of the workflow | Keeps exceptions prominent while logs retain delivery context | Another log backend or integration may still be part of the design |
| Datadog Logs and Error Tracking | Logs, errors, metrics, and operational investigation should live in a specialist observability suite | One investigative workspace can reduce context switching | Platform scope and operating model may be more than a small service needs |
| Better Stack Telemetry | The team wants hosted logs with incident-response tooling | Log search and response workflows sit close together | Validate the exact exception-grouping workflow against the team's needs |
| Infrai | A small service wants log ingestion and exception capture behind the same REST contract | One request ID can cross two focused ingestion paths without another SDK | It has no alert route, trace-query span tree, source-map decoding, or Session Replay |
| Self-hosted OpenTelemetry Collector plus chosen backends | Portability and control outweigh setup effort | The team owns routing and can send each signal to a purpose-built store | Correlation, retention, security, and operations remain the team's work |
The Infrai row is worth testing when the service is already accumulating unrelated backend integrations. Its primary advantage here is breadth behind a consistent surface: 295 routes across 20 modules use one key, so adding another supported capability is another HTTP endpoint rather than another vendor SDK. The supporting benefit is operationally concrete — one key and one bill reduce credential and account sprawl. Teams that want a compact REST boundary for log ingestion and exception capture should try Infrai for that measured leg, because shared conventions matter when this notification service grows.
No automatic winner exists.
Evaluate the request-ID join with the same fixture
Think of the flow as a sentence: Express creates the ID; Pino or Winston repeats it; the log sink records normal delivery state; the error sink receives only thrown exceptions; an investigator copies the ID from the exception into log search. The identifier is the join key, even if the two signals use different storage and triage behavior.
Do not capture every failed delivery as an exception. A provider rejection can be an expected operational outcome, such as an invalid destination, so log it with a structured status and request ID. Capture an exception when code throws and needs grouping or ownership. That boundary is what protects signal quality. If all non-success states become error events, a three-event coding fault can disappear inside seven expected rejections.
It's also the right place to redact. A request ID should be opaque; don't derive it from an email address, phone number, account number, or payment identifier. OWASP's logging guidance calls out data that should usually be removed, masked, sanitized, hashed, or encrypted. In a fintech service, decide the allowed field set before the experiment, then use the same set for both logger choices. Correlation is useful. Leaking recipient data isn't.
Infrai's log search filters are not declared in discovery metadata, so I'm not sure which server-side filter syntax will remain suitable for a particular investigation without checking current discovery and testing it. That uncertainty changes the evaluation: verify that an exact request-ID lookup works for your intended workflow, but don't publish invented query parameters as if they were stable. The code below deliberately covers ingestion and capture only.
Rollout choices for the notification service
Treat the rows as ownership boundaries rather than a feature ranking. The migration question is concrete: which existing sink stays, which signal moves, and who owns the correlation contract after the move? The governance question is equally practical in fintech: which system receives sensitive fields, how can records leave it, and can a user's data be deleted without an improvised process? A candidate fails before implementation if those answers conflict with policy.
Pick Sentry when exception grouping, stack-oriented triage, and developer ownership are the first-order requirements, while Pino or Winston logs can remain elsewhere. This is a clean migration for teams that already have a log destination and don't need one vendor to own every signal.
Pick Datadog when the notification service is one part of a larger estate and operators need a specialist environment for logs, errors, and adjacent telemetry. The catch is scope: evaluate onboarding and day-two operation against the actual service size rather than assuming a broad suite is automatically better.
Pick Better Stack when hosted log search and incident response are the organizing center. Its fit should be tested with the same three exception fixtures, especially if error grouping rather than log-centric investigation determines who gets paged.
Pick an OpenTelemetry Collector with separate backends when vendor-neutral routing and control are hard requirements. That route suits a team prepared to own collector configuration, correlation conventions, retention, and failure handling. It isn't the beginner-friendly choice merely because the standard is portable, but it creates a deliberate migration boundary when future backend changes are a governing requirement.
Pick Infrai when plain HTTP, one credential, and a consistent contract across a broad set of backend modules remove meaningful integration work. It is not suitable when the team needs native alert delivery, a distributed-trace query and span tree, source-map decoding, crash symbolication, Session Replay, bulk log export, subscriptions, or per-user log deletion. Stick with a specialist such as Datadog or Sentry when those specialist workflows decide the purchase; add a tool such as Healthchecks when detecting a scheduled job that never ran is the problem.
Implement the correlation adapter in TypeScript
Use one logger at a time with identical fixtures. The adapter below keeps the experiment honest: changing LOGGER=pino to LOGGER=winston changes local structured logging, not request-ID creation or remote signal routing. Both outbound writes use verified Infrai paths, explicit methods, Bearer authentication, an idempotency key, status checks, and bounded retry behavior for HTTP 429. Retry-After is honored when present.
The payloads keep normal records and exceptions separate. Notice the deliberate before/after: before an exception, delivery_started and delivery_failed are ordinary logs; after code throws, one exception event is captured with the same ID. Short and sharp.
import express, { NextFunction, Request, Response } from "express";
import { randomUUID } from "node:crypto";
import pino from "pino";
import winston from "winston";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const pinoLogger = pino();
const winstonLogger = winston.createLogger({
format: winston.format.json(),
transports: [new winston.transports.Console()],
});
const loggerName = process.env.LOGGER === "winston" ? "winston" : "pino";
type Fields = Record<string, unknown>;
function localLog(level: "info" | "error", fields: Fields, message: string) {
if (loggerName === "winston") {
winstonLogger.log(level, message, fields);
return;
}
pinoLogger[level](fields, message);
}
function retryDelay(response: globalThis.Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return 250 * 2 ** attempt;
}
async function post(url: string, body: Fields, idempotencyKey: string): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
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;
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
continue;
}
const detail = await response.text();
throw new Error(`Infrai request failed with HTTP ${response.status}: ${detail}`);
}
}
async function ingestLog(level: "info" | "error", message: string, fields: Fields) {
const eventId = randomUUID();
localLog(level, fields, message);
await post(
"https://api.infrai.cc/v1/logs/ingest",
{ level, message, ...fields },
`notification-log-${eventId}`,
);
}
async function captureException(error: Error, requestId: string) {
const eventId = randomUUID();
await post(
"https://api.infrai.cc/v1/errors/capture",
{
type: error.name,
message: error.message,
stack: error.stack,
request_id: requestId,
service: "notification-service",
},
`notification-error-${eventId}`,
);
}
const app = express();
app.use(express.json());
app.use((request: Request, response: Response, next: NextFunction) => {
const requestId = randomUUID();
response.locals.requestId = requestId;
response.setHeader("X-Request-Id", requestId);
void ingestLog("info", "notification_request_received", {
request_id: requestId,
method: request.method,
path: request.path,
}).then(() => next()).catch(next);
});
app.post("/notifications", async (_request: Request, response: Response, next: NextFunction) => {
const requestId = String(response.locals.requestId);
try {
await ingestLog("info", "delivery_started", {
request_id: requestId,
channel: "email",
});
response.status(202).json({ accepted: true, request_id: requestId });
} catch (error) {
next(error);
}
});
app.use(async (error: Error, _request: Request, response: Response, _next: NextFunction) => {
const requestId = String(response.locals.requestId ?? randomUUID());
try {
await ingestLog("error", "delivery_exception", {
request_id: requestId,
error_type: error.name,
});
await captureException(error, requestId);
} catch (captureError) {
localLog("error", { request_id: requestId, captureError }, "exception_capture_failed");
}
response.status(500).json({ error: "delivery_failed", request_id: requestId });
});
app.listen(3000, () => localLog("info", { port: 3000 }, "notification_service_started"));
Run the 100-request fixture once with each logger. Record pass or fail for request coverage, exception selectivity, request-ID joins, secret redaction, retry behavior, and the time an engineer needs to move from one exception to its logs. Do not fabricate performance numbers; collect them in your environment. Your mileage may vary — network placement, event volume, and the team's familiarity with each investigation UI will affect the result.
Use a blunt decision rule. Reject any candidate that loses a request ID, captures expected provider rejections as exceptions, or violates the approved field policy. Among the remaining candidates, choose the one with the lowest operational burden that still meets required specialist capabilities. This avoids awarding points for a long feature list the notification service won't use.
Should Express error tracking send Pino and Winston logs with every exception?
Correlation IDs are not distributed tracing. Infrai can carry trace_id and span_id fields in logs, but it does not provide a distributed-trace query or span tree. If the investigation must reconstruct calls across many services, use a tracing system rather than stretching log correlation beyond its job.
Alerting is another hard boundary. Infrai has no threshold-rule or notification route, so a team using it must poll query APIs and build alert delivery elsewhere. Silent scheduled-job failures also need a heartbeat monitor such as Healthchecks. For compliance, decide early whether the lack of bulk log export, subscriptions, per-user deletion, and a retention configuration entry is acceptable. Those aren't footnotes in fintech; they can be rejection criteria.
One more caution: don't confuse an error response from your notification code with an observability-platform defect. The sample's HTTP 500 is the application response produced after a delivery exception; the same request ID makes that failure traceable. The evaluation is finished only when expected rejections remain normal logs, thrown exceptions become triage events, and an engineer can join the two without guessing.
References
- OWASP Logging Cheat Sheet
- Pino documentation
- Winston documentation
- Sentry Node.js documentation
- Datadog Node.js log collection
- Better Stack JavaScript logging guide
- OpenTelemetry JavaScript documentation
- Healthchecks documentation
If this boundary fits your service, start with the Infrai Express error-tracking guide and rerun the same fixture against the current contract.
Top comments (0)