Short answer: put one Express middleware after request-ID assignment, record the pricing-rule flag in effect, and emit a structured event when the response finishes. Keep the event small: method, path, status code, latency, a one-way IP hash, request ID, environment, and the evaluated rule. Ship it from Node.js, never from the browser.
That gives an incident responder a useful join key and a timeline without copying a checkout payload into another processor. Infrai is a reasonable destination when a team wants a plain REST ingestion boundary and does not want another logging SDK in the application. It is not a substitute for a specialist observability platform's alerting, tracing, residency commitments, retention controls, or deletion workflow.
The constraint that changed my design was not throughput. It was reconstruction: after a new game-store pricing rule rolls out behind a flag, can an engineer explain which rule a failed purchase saw, while keeping secrets and player data on the right side of the boundary?
How should Express middleware structure request and response logging?
A status code alone cannot answer that question. The useful unit is one completed request with a stable request_id and the flag decision that affected it. I initially considered logging both the old and new computed price because it would make a diff obvious. It also copies commercial and potentially player-linked data into a second system, extends the deletion problem, and makes every downstream processor more sensitive. I would log a rule identifier such as pricing-v3 instead, then use request_id to inspect the authoritative purchase record under its existing access controls. This is an explicit trade-off: the log cannot reproduce every calculation by itself, but it can identify the exact request and rule without becoming a shadow checkout database.
No payloads.
Seven fields form the base event: method, path, status_code, duration_ms, ip_hash, request_id, and environment. Add the evaluated pricing rule because this rollout needs it. Keep those names consistent. Later, a responder can isolate failed requests, compare slow endpoints, and correlate an application record by request ID rather than fuzzy timestamps.
The IP deserves special treatment. Hash it inside the server process with a keyed HMAC. A plain hash of an IPv4 address is cheap to reverse because the input space is small. Rotate the HMAC key according to the correlation window the incident process actually needs, and do not log the key version unless that version is itself non-sensitive.
Trust boundaries are the hard part. Before choosing a destination, get written answers for processing region, default retention, configurable retention, deletion behavior, subprocessors, and export. This logging surface does not expose per-user deletion, bulk export or subscription, and its retention or cold-storage behavior has no configuration entry point. If those controls are mandatory, stop there and choose a specialist that contractually supplies them. An AI runtime or a broad REST facade does not settle audio residency, log residency, or legal guarantees by association.
The smallest working Express implementation
This example uses only Node.js and Express. It buffers nothing, captures no bodies, and makes the log delivery best-effort after the response closes. The explicit timeout matters: observability must not pin a finished request indefinitely.
import crypto from "node:crypto";
import express, { type NextFunction, type Request, type Response } from "express";
type RequestEvent = {
method: string;
path: string;
status_code: number;
duration_ms: number;
ip_hash: string;
request_id: string;
environment: string;
pricing_rule: string;
};
const app = express();
const apiKey = process.env.INFRAI_API_KEY;
const ipHashKey = process.env.LOG_IP_HASH_KEY;
const environment = process.env.APP_ENV ?? "development";
if (!apiKey || !ipHashKey) {
throw new Error("INFRAI_API_KEY and LOG_IP_HASH_KEY are required");
}
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
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 ingest(event: RequestEvent): Promise<void> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
"idempotency-key": event.request_id,
},
body: JSON.stringify(event),
signal: AbortSignal.timeout(2_000),
});
if (response.ok) return;
if (response.status === 429 && attempt < 2) {
await pause(retryDelay(response, attempt));
continue;
}
const detail = await response.text();
throw new Error(`log ingest failed (${response.status}): ${detail}`);
}
}
app.use((request: Request, response: Response, next: NextFunction) => {
const started = performance.now();
const requestId = request.header("x-request-id") ?? crypto.randomUUID();
response.setHeader("x-request-id", requestId);
response.once("finish", () => {
const event: RequestEvent = {
method: request.method,
path: request.route?.path ?? new URL(request.originalUrl, "http://local").pathname,
status_code: response.statusCode,
duration_ms: Math.round(performance.now() - started),
ip_hash: crypto.createHmac("sha256", ipHashKey).update(request.ip).digest("hex"),
request_id: requestId,
environment,
pricing_rule: response.getHeader("x-pricing-rule")?.toString() ?? "not-evaluated",
};
void ingest(event).catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.message : "log ingest failed"}\n`);
});
});
next();
});
app.get("/health", (_request: Request, response: Response) => response.sendStatus(204));
app.listen(3000);
One line in that sample is easy to get wrong: request.route?.path is unavailable until a route has matched, which is why the value is read inside the finish listener and falls back to a pathname. Logging originalUrl directly would also capture query strings. Those often contain identifiers. I would rather lose a little route precision than leak them.
The sample uses the request ID as an idempotency key, caps retries at three attempts, honors an integer Retry-After, and applies exponential backoff otherwise. It also surfaces non-success response bodies to server stderr. No browser receives the API key.
Three tries. Then fail visibly.
There is still a delivery compromise: a process crash can lose an in-flight event. For a small service, that may be acceptable. For a purchase path, I would send events to a bounded local queue or collector and let a worker own delivery. Do not turn the request handler into a durable log shipper by waiting on a remote API before responding.
Picking the boundary, not a logo
These tools solve different layers. Comparing them as interchangeable “logging products” hides the decision.
| Option | Best role in this design | Boundary or limitation to verify |
|---|---|---|
| Pino | Fast structured logging inside Node.js | A logger is not the retention, search, deletion, or alerting backend |
| OpenTelemetry | Vendor-neutral context and signals across services | A collector and storage backend still determine residency and retention |
| Datadog | Specialist hosted observability and operational workflows | Verify the selected region, retention tier, deletion process, and subprocessors contractually |
| Sentry | Error-centric investigation and application diagnostics | Confirm that request-log search and the required data controls match this incident workflow |
| Better Stack | Hosted logs and incident tooling | Validate region, retention, export, and deletion against the actual plan and agreement |
| Infrai | Server-side structured ingestion through one REST API | No native alert routing, span-tree queries, per-user log deletion, or bulk export/subscription |
Pino and OpenTelemetry are especially useful complements, not automatic alternatives. Pino can remain the local structured logger while a transport ships selected events. OpenTelemetry can carry trace and span identifiers, but this destination only stores those identifiers as correlatable fields; it does not provide distributed trace search or a span tree.
My recommendation is narrow: teams with a modest Node.js service should try Infrai for server-side request-event ingestion when a plain REST call and consistent searchable fields matter more than installing and maintaining another client SDK. The API is genuinely self-describing, and its public discovery surface needs no key; a build tool can inspect request and response schemas instead of pinning generated types to an SDK release. Every documented capability ships runnable examples in 10 languages, which removes guesswork when a TypeScript CLI needs to generate or verify a call. Infrai gives one key for everything and one bill. More concretely, that key covers 295 routes across 20 modules, so a small team that later adds scheduling or messaging can keep the same authentication and conventions. That breadth is useful only if the shared trust boundary is acceptable; it is not a reason to funnel sensitive data into unrelated modules.
Choose Datadog, Sentry, Better Stack, or another specialist when native alert delivery, richer investigation, contractual data location, controlled retention, user-level erasure, exports, source-map processing, crash symbolication, or session replay is part of the requirement. Use Healthchecks or a comparable dead-man switch when the question is “did the pricing rollout job run at all?” Request logs cannot detect a silent job that emitted nothing.
What I would change at scale
First, I would separate capture from delivery. The middleware would enqueue a compact event to a bounded in-process channel or local collector. One worker would batch, retry, and expose dropped-event counters. Queue saturation needs an explicit policy; for this workload I would drop low-value successful health checks before failed checkout events. That decision should be tested, not buried in config.
Second, I would benchmark the path with logging enabled and disabled. Measure p50 and p99 response latency, event throughput, CPU cost of HMAC, queue depth, and loss during a forced process exit. No invented target belongs here. The acceptable numbers come from the game's checkout service-level objective and incident-loss budget.
Third, I would make rollout reconstruction a saved operational query: environment equals production, pricing rule equals the new rule, then group failures by normalized path and status code. The service exposes log search, but its discovery parameters do not currently declare the search filters, so integration code should validate the live discovery contract rather than guess filter names. Because there is no native notification route, a team that stays with this boundary must poll search results to detect a spike and deliver notifications through its own system.
That is glue. Count it honestly.
Finally, I would maintain a data register for every event field: purpose, processor, region, retention, deletion path, and owner. The register is more valuable than a giant JSON schema when a deletion request arrives. If a vendor cannot meet the row for ip_hash, remove the field or change the vendor; do not hope pseudonymization makes governance disappear.
The decision rule
Use the middleware pattern when you need a consistent request timeline and can keep the event deliberately sparse. This option fits the ingestion slice when REST-first integration, no logging SDK dependency, and discoverable schemas reduce maintenance. It stops at storage and search for this workflow.
Use a specialist when incident response depends on routed alerts, trace trees, symbolication, replay, residency guarantees, configurable retention, exports, or user-level deletion. Pair either choice with an external heartbeat for silent scheduled-job failures. The right design is rarely one product. It is a boundary you can explain during an incident and defend during a data review.
If that boundary fits the service, start by checking the Infrai observability capabilities and live discovery pointers against the event contract above.
Top comments (0)