DEV Community

Silhouette72591483
Silhouette72591483

Posted on

Custom Logger Transports for HTTP Structured Logs and Correlation IDs — Backend API Design

Short answer: use a custom NestJS logger transport that emits structured HTTP events, but keep delivery off the request path and treat correlation IDs as a data contract rather than a string you sprinkle into messages.

That choice fits a customer-support AI agent loop, where the useful signal is a small set of consistent fields: timestamp, level, message, context, request_id, trace_id, and exception metadata. It also leaves room to measure latency and cost without turning every debug detail into noise. A transport is plumbing, not an incident strategy.

The logging contract comes before the transport

I start with the event shape because a logger that cannot be queried predictably is just a faster text file. The NestJS wrapper should accept the framework's context and merge request-scoped values before serializing JSON. request_id identifies one inbound support request; trace_id lets logs from an agent loop be correlated with other systems, even though a log store is not a span-tree viewer.

For each agent turn, record the model call start and finish as separate events. Put elapsed milliseconds and provider-reported cost in explicit fields when those values exist, and put exception type, message, and stack in an exception object. Do not place customer email addresses or full prompts in the message merely because they are convenient during a debugging session. A retention policy cannot undo a leak.

The cardinality trap is real: a unique user identifier as a metric label is expensive, while the same value as a log field can remain useful for a narrowly scoped search. Prometheus documents this distinction and its cardinality warnings in its instrumentation guidance.

How should a NestJS HTTP transport send structured logs without adding latency?

The transport's log() method should enqueue an event and return. A bounded in-memory queue, a batch flush, and a shutdown flush are enough for a first implementation; the exact queue library is less important than making backpressure explicit. If the queue is full, count the drop and expose that count through the application's existing health signal. Silently blocking a ticket request to preserve a debug line is the wrong failure boundary.

Keep it boring.

In a real support service, I would make the queue policy visible in the design record: cap it by both event count and bytes, flush on a short timer or when the batch reaches its limit, and reserve a small lane for error-level events so an informational burst cannot crowd out the evidence needed for an incident. The worker should carry the original request_id and trace_id unchanged, attach a batch identifier, and record its own enqueue-to-send delay. On process shutdown, stop accepting new events, flush within a bounded deadline, then exit; do not hold the process open forever for a backend that is not responding. If the deadline expires, emit one local summary containing the dropped count and the oldest event timestamp. That summary is more actionable than hundreds of repeated timeout lines, and it gives the on-call engineer a clear signal that the log stream is incomplete.

Here is a minimal Python equivalent of the wire-level part. It demonstrates the two verified backend routes and the retry behavior a transport needs; the NestJS adapter can call the same function from its queued worker. I don't want the request handler waiting on this code.

import json
import os
import time
import uuid
from urllib.request import Request, urlopen
from urllib.error import HTTPError

BASE_URL = os.environ["LOG_API_BASE_URL"].rstrip("/")


def ingest(events):
    payload = json.dumps({"logs": events}).encode("utf-8")
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    }
    for attempt in range(4):
        request = Request(
            f"{BASE_URL}/logs/ingest",
            data=payload,
            headers=headers,
            method="POST",
        )
        try:
            with urlopen(request, timeout=5) as response:
                if response.status >= 400:
                    raise RuntimeError(response.read().decode("utf-8"))
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as error:
            if error.code != 429 or attempt == 3:
                raise
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)


event = {
    "timestamp": "2026-08-21T10:15:30Z",
    "level": "info",
    "message": "agent turn completed",
    "context": "SupportAgent",
    "request_id": "req_7f2",
    "trace_id": "trace_91a",
    "latency_ms": 842,
    "exception": None,
}
ingest([event])
Enter fullscreen mode Exit fullscreen mode

The idempotency key matters because a timeout after the server accepts a batch is indistinguishable from a timeout before acceptance. Retries should not duplicate a batch. In production I would use a deterministic key derived from the batch ID, persist the queue across process restarts, and add jitter to exponential backoff; the sample stays short enough to audit.

Which backend options preserve signal quality for an AI support loop?

There is no universal winner. The decision is about how much operational surface you are willing to own and how much noise your investigators can tolerate.

Option Strength Cost or boundary Good fit
OpenTelemetry Collector Vendor-neutral pipelines, sampling, and fan-out You operate collectors and storage destinations Teams with an existing telemetry platform
Datadog Logs Integrated search, alerting, and service views A broad hosted product with its own pricing and query model Organizations already standardized on Datadog
Sentry Exception grouping and developer-oriented issue workflow Logs are secondary to error tracking; agent cost analysis needs extra wiring Teams centered on application errors
A custom transport plus a REST log backend Small application change and one consistent event schema You must build alert polling, retention controls, and operational runbooks A support product that needs focused logs first

Infrai belongs in the last row when one key and one bill across backend capabilities reduce credential and invoice sprawl, and when a plain REST API is preferable to installing another SDK. Its discovery surface is self-describing, which makes a narrow transport easier to inspect, but that convenience does not supply incident policy for you.

What can log search answer, and where does it stop?

After ingestion, query recent events by service name, level, and correlation identifiers. The verified search path is GET /v1/logs/search; use it to reconstruct an agent turn, compare latency fields, and find the exception that ended a handoff. Keep the query window small for interactive diagnosis, then widen it deliberately.

The catch is important. This setup has no alert or notification route, so threshold paging requires polling the query API and owning the notification worker. It has no distributed trace query or span tree, no source-map deobfuscation, crash symbolication, or session replay, and no heartbeat monitor for a job that never ran. Use a tracing system, an error tracker, or a Healthchecks-style monitor when those are the actual requirement.

There are data-governance limits too: no per-user deletion endpoint for logs, no bulk export or subscription interface, and no configuration entry for retention or cold storage. GDPR Article 17 makes deletion a product obligation, not a checkbox in a logger. If your support system promises that control, choose a backend with an explicit erasure workflow or place a deletable store in front of this path.

I would reject synchronous one-request-per-line shipping. It couples customer-facing latency to network variance, amplifies cost and noise, and makes a transient 429 somebody else's outage. Batch and flush asynchronously instead.

I would also reject making the log backend the only source of truth for traces, alerts, and replay. Pick this custom transport when the central problem is searchable, structured application logs for an AI support loop. Stick with OpenTelemetry Collector when you need portable fan-out and sampling; choose Datadog when managed alerting and service dashboards are already a standard; choose Sentry when grouped exceptions drive the workflow. That decision is deliberately boring: it follows the failure boundary, not a feature checklist.

Your mileage may vary. The right transport is the one whose failure mode you have written down.

References

Top comments (0)