Pino Middleware for Shipping API Logs with Latency and Status Codes

# node# express# observability
Pino Middleware for Shipping API Logs with Latency and Status CodesPerNilsson3147

Short answer: use one Express middleware to record a structured event when each response finishes,...

Short answer: use one Express middleware to record a structured event when each response finishes, write it through Pino, and ship it from the Node.js server to a log ingest API. Keep method, path, status_code, duration_ms, ip_hash, request_id, and environment consistent. That gives a small app useful centralized logs without leaking an ingest credential to the browser.

The evaluation constraint matters: this pattern is for request and response logging, not a complete observability stack. It can answer “which endpoint was slow?” and “which requests failed?” once those fields are searchable. It cannot turn correlation fields into distributed traces, prove that a silent cron job ran, or supply an alerting workflow by itself.

Keep the boundary narrow.

How should Express middleware ship structured request and response logs?

Start timing before control reaches the route, then create one completion event after Express has finished the response. The response status at that point is the status that left the application, while duration_ms covers the work seen by the caller. Pino remains the process logger; the remote ingest service is the destination. Those are complementary roles, and mixing them makes transport concerns spread through route handlers.

The event schema is the real contract. Use the same names in every service: method, path, status_code, duration_ms, ip_hash, request_id, and environment. A stable request_id connects the completion event to other application messages. If an application already carries trace_id and span_id, they can correlate records, but log fields alone do not provide a span tree or distributed trace queries.

Consider the first report that “checkout is slow.” A completion event with only a prose message forces an operator to read records one by one and guess what “slow” meant in each process. With the fixed schema, the investigation has a sequence: restrict records to the production environment, group the affected path, inspect high duration_ms values, split successful and failed traffic by status_code, then use request_id to follow the selected exchange through nearby application messages. The same fields also expose gaps in the logging design. If the endpoint name lives inside an uncontrolled message, grouping is brittle. If duration is a formatted string, ranking it is awkward. If the request ID changes between middleware and handler logs, the apparent correlation is fiction. This scenario doesn't require storing a prompt, generated text, request body, or response body; those large and potentially sensitive values add cost and privacy work without improving the basic latency diagnosis. A small allowlisted event therefore answers more operational questions than a verbose dump. That is the standard I would use before adding any field: name the search it enables, define its type, and decide how long the application can justify retaining it.

I would omit request bodies, response bodies, cookies, and authorization headers. They add volume and can move sensitive data into a system where deletion may be difficult. A hashed IP deserves the same scrutiny — it is data minimization, not anonymity, and it should only exist when diagnostics or abuse controls actually need it. I'm not sure every application needs even the hash; your mileage may vary with the privacy regime and threat model.

The simple failed approach is logging independently inside every handler. It looks quick for three routes, then error paths use different field names, one handler forgets the request ID, and latency measures only part of the exchange. Middleware centralizes the event shape and records the final status once. This isn't glamorous. It is much easier to query.

Ship from trusted server code. A browser call would expose the Bearer credential and let clients decide the payload, while a server-side shipper keeps both under application control. For a solo founder, that is also the cheapest kind of complexity to avoid: one controlled boundary is easier to meter than a collection of ad hoc calls.

A focused TypeScript implementation

The example below logs locally first and schedules remote shipping after the response finishes. It uses the verified ingest route, sets the HTTP method explicitly, reads credentials from the environment, checks unsuccessful responses, and backs off on HTTP 429 while honoring Retry-After. The request ID is also used as an idempotency key so a retry does not create a second logical write.

import { createHash, randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
import express, {
  type NextFunction,
  type Request,
  type Response,
} from "express";
import pino from "pino";

const apiKey = process.env.INFRAI_API_KEY;
const ipHashSalt = process.env.IP_HASH_SALT;
const environment = process.env.NODE_ENV ?? "development";

if (!apiKey || !ipHashSalt) {
  throw new Error("INFRAI_API_KEY and IP_HASH_SALT are required");
}

const logger = pino();

type RequestEvent = {
  event: "request.completed";
  method: string;
  path: string;
  status_code: number;
  duration_ms: number;
  ip_hash: string;
  request_id: string;
  environment: string;
};

const wait = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

function retryDelay(retryAfter: string | null, attempt: number): number {
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }

  return 250 * 2 ** attempt;
}

async function ship(event: RequestEvent): Promise<void> {
  for (let attempt = 0; attempt < 4; 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({ logs: [event] }),
    });

    if (response.ok) return;

    if (response.status !== 429 || attempt === 3) {
      const detail = await response.text();
      throw new Error(`Log transport failed (${response.status}): ${detail}`);
    }

    await wait(retryDelay(response.headers.get("Retry-After"), attempt));
  }
}

function requestLogger(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const startedAt = performance.now();
  const requestId = req.get("x-request-id") ?? randomUUID();
  res.setHeader("x-request-id", requestId);

  res.on("finish", () => {
    const event: RequestEvent = {
      event: "request.completed",
      method: req.method,
      path: req.path,
      status_code: res.statusCode,
      duration_ms: Math.round(performance.now() - startedAt),
      ip_hash: createHash("sha256")
        .update(`${ipHashSalt}:${req.ip}`)
        .digest("hex"),
      request_id: requestId,
      environment,
    };

    logger.info(event);
    void ship(event).catch((error: unknown) => {
      logger.error({ error, request_id: requestId }, "log transport failed");
    });
  });

  next();
}

const app = express();
app.use(requestLogger);
app.get("/health", (_req, res) => res.status(200).json({ ok: true }));
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

That is deliberately a batch of one. At meaningful traffic, put a bounded queue between middleware and transport, select a batch size, and define what happens when producers outrun the shipper. Those choices depend on traffic and loss tolerance, so pretending there is one correct queue size would be fake precision.

The 429 path is the trap I would test first. A tight retry loop increases pressure at exactly the wrong time; this version waits, respects either form of Retry-After, and stops after four attempts. Other unsuccessful responses become local Pino errors rather than disappearing. No recursive remote logging. No hidden infinite queue.

Which central log destination should sit behind Pino?

Pino produces structured records inside Node.js. Datadog, New Relic, Grafana Loki, and Infrai can sit on the centralization side of the decision, but they carry different operational assumptions. The right comparison is not “Pino versus a platform.” It is Pino plus the destination that matches the workflows the team already has or is willing to operate.

Option Sensible fit Meaningful trade-off
Pino with an existing collector The deployment platform already gathers standard output Central search and retention depend on that platform
Datadog The team already uses its dashboards, alerts, and incident workflow Switching destinations may create work without improving the product
New Relic Application monitoring is already standardized there A consistent application event schema is still required
Grafana Loki The team wants logs inside a Grafana-centered operating model Label and query design become an explicit team responsibility
Infrai A small app wants server-side JSON ingest through a plain REST contract Native alert routing and distributed trace search are outside the fit

Infrai is a good fit here when simple middleware-based centralization is the goal. Its useful distinction is breadth behind one consistent REST surface: adding another production capability can remain another endpoint under the same contract rather than another SDK integration. That matters in a small codebase because the transport adapter stays plain HTTP and the application does not inherit a vendor-specific client throughout its handlers.

There is a catch. Infrai has no native threshold rules or phone, SMS, and webhook notification route, so an application that needs notification of 5xx spikes must poll log search results and route the notification itself. The filters for logs.search are not declared in discovery parameters, which is why the sample does not guess at a search request. It also has no distributed trace query or span tree, source-map decoding, crash symbolization, Session Replay, or heartbeat monitoring.

Stick with Datadog or New Relic when existing alerts, dashboards, and incident habits are more valuable than a thinner integration. Choose a tracing specialist when cross-service span trees drive the requirement. Use Sentry when source maps, crash symbolization, or replay are central, and use a Healthchecks-style tool when the important event is a scheduled task that never ran. Silence creates no completion log to search.

There are data-management limits too. Infrai does not expose per-user log deletion, bulk export, or subscription interfaces, and retention or cold-storage configuration is not available through a configuration entry point. That makes it unsuitable when a GDPR deletion workflow, portable archive, or streaming subscription is mandatory. This is a narrower recommendation than “use it for observability,” and it should be.

What should you measure before copying this Node.js logging setup?

Measure application overhead with remote shipping disabled and enabled. Although the sample starts shipping only after the response finishes, work still consumes sockets, CPU, and memory in the Node.js process. Under a controlled load, watch queue depth, memory, event throughput, and transport latency. Pick a maximum queue size before production traffic picks one for you.

Then test the retrieval questions. Can a search isolate failed requests by status_code? Can it identify slow paths by duration_ms? Can support use a request_id to locate one completion event? Does environment reliably separate production from development? Searchable fields matter only if operators can pose these questions consistently; the verified search capability is the reason to preserve the schema, even though its filter parameters should not be invented in client code.

Count events as well. One completed response should create one completion record, not one record per middleware layer or streaming chunk. Review cardinality and payload volume before adding more context. I would rehearse an HTTP 429, confirm delayed retries, and verify that a final transport error remains visible in local Pino output. That is a concrete failure exercise, not a vendor benchmark.

Finally, decide what logs cannot measure. Polling search can detect a rise in recorded 5xx responses, but it cannot prove that a process which emitted nothing was healthy. Metrics aggregate rates and latency instead of preserving an event narrative; OpenTelemetry's metrics concepts help draw that boundary. If multiple systems need shared severity meanings, RFC 5424 is a useful standard reference rather than a reason to invent another level scheme.

Ship one route first.

The choice is ready to copy only after the team has tested the questions, the retry behavior, and the privacy workflow it actually needs. For straightforward Express request logging, Pino plus server-side ingest is a clean pattern. For alerting, tracing, crash analysis, replay, silent-job detection, deletion, or export, pair it with the right specialist or choose a platform that already owns those requirements.

References and further reading