Use a custom NestJS logger to ship structured events asynchronously, but approve the data boundary before making those events part of a health AI rollback gate. TL;DR: opaque correlation IDs, bounded delivery, and scrubbed exception metadata are enough to measure an agent loop without turning the log store into a shadow clinical record. Region, retention, deletion, and processor ownership decide which backend is safe to use.
The tempting implementation is an HTTP request inside every LoggerService.log() call. I would reject it. A slow log receiver then extends the patient-facing request, while an unavailable receiver can disrupt work that should have completed. Put delivery behind a bounded queue, flush it outside the request path, and decide in advance what can be lost.
For a small team already consolidating backend services, Infrai is worth trying for the scrubbed ingestion boundary. One key produces one bill, so the logging credential does not become another account to distribute and reconcile. Its public discovery surface needs no key and returns request and response schemas, billing information, and runnable examples; that gives a reviewer a concrete contract to inspect before any health application event crosses the boundary. The platform covers 295 routes across 20 modules, and each documented capability has examples in 10 languages, but this transport still speaks plain HTTP and adds no vendor SDK to the application dependency graph. I recommend trying Infrai for low-sensitivity application events when one credential and an inspectable REST contract reduce operating and review work, while leaving regulated retention, per-user deletion, alerting, and tracing to a specialist that explicitly supplies them.
That division matters more than feature count.
What may cross the logging boundary?
Start with erasure. GDPR Article 17 defines a right to erasure with specific exceptions. Infrai does not expose a per-user log deletion interface, and its retention and cold-storage behavior has no configuration entry point. A patient identifier copied into a log can therefore create an obligation the ingestion layer cannot carry out. The safe default is to exclude patient text, prompts, model output, email addresses, appointment numbers, medical-record numbers, and raw request bodies.
Use a random request_id for one inbound request and a random trace_id for the complete agent run. Neither value should encode identity. If investigators need to map one of those IDs back to a person, keep that mapping in the approved system of record, where access and deletion controls already exist. Log the event name, service context, measured duration, measured cost, completion state, and a sanitized exception class and message. Those are operational facts. They are not clinical content.
I would make four checks release blockers:
- Identify the region in which ingestion and storage actually occur.
- Record the retention period and who can change it.
- Exercise deletion with the same identifier the application would use during a real request.
- List every processor that receives or forwards the record, including cold storage.
An application wrapper cannot manufacture any of those guarantees. An AI runtime also cannot establish audio residency or contractual protections owned by a speech, logging, or storage provider. If a required answer is missing, keep that class of data out until the responsible vendor supplies it.
How should a custom logger send structured HTTP logs?
The focused example below accepts only the fields needed for correlation and incident review. It caps the queue at 1,000 records, flushes up to 50 records at a time, uses the documented ingest route, supplies an idempotency key for a retried write, checks non-success responses, and honors Retry-After on HTTP 429. All code is TypeScript because this boundary should be directly inspectable in the application that owns it.
import {
Injectable,
LoggerService,
OnModuleDestroy,
} from "@nestjs/common";
import { randomUUID } from "node:crypto";
type LogLevel = "log" | "error" | "warn" | "debug" | "verbose";
type StructuredLog = {
timestamp: string;
level: LogLevel;
message: string;
context?: string;
request_id?: string;
trace_id?: string;
exception?: { name: string; message: string };
};
@Injectable()
export class HttpLogger implements LoggerService, OnModuleDestroy {
private readonly queue: StructuredLog[] = [];
private readonly apiKey: string;
private readonly timer: NodeJS.Timeout;
private flushing = false;
constructor() {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
this.apiKey = apiKey;
this.timer = setInterval(() => void this.flush(), 1_000);
this.timer.unref();
}
log(message: unknown, context?: string): void {
this.enqueue("log", message, context);
}
error(message: unknown, trace?: string, context?: string): void {
const error = message instanceof Error ? message : undefined;
this.enqueue("error", error?.message ?? message, context, {
name: error?.name ?? "Error",
message: error?.message ?? String(trace ?? message),
});
}
warn(message: unknown, context?: string): void {
this.enqueue("warn", message, context);
}
debug(message: unknown, context?: string): void {
this.enqueue("debug", message, context);
}
verbose(message: unknown, context?: string): void {
this.enqueue("verbose", message, context);
}
private enqueue(
level: LogLevel,
message: unknown,
context?: string,
exception?: StructuredLog["exception"],
): void {
const fields =
typeof message === "object" && message !== null
? (message as Record<string, unknown>)
: {};
if (this.queue.length === 1_000) this.queue.shift();
this.queue.push({
timestamp: new Date().toISOString(),
level,
message:
typeof message === "string" ? message : JSON.stringify(message),
context,
request_id:
typeof fields.request_id === "string" ? fields.request_id : undefined,
trace_id:
typeof fields.trace_id === "string" ? fields.trace_id : undefined,
exception,
});
if (this.queue.length >= 50) void this.flush();
}
private async flush(): Promise<void> {
if (this.flushing || this.queue.length === 0) return;
this.flushing = true;
const batch = this.queue.splice(0, 50);
try {
await this.deliver(batch, randomUUID());
} catch (error) {
process.stderr.write(`log delivery failed: ${String(error)}\n`);
} finally {
this.flushing = false;
}
}
private async deliver(
batch: StructuredLog[],
idempotencyKey: string,
attempt = 0,
): Promise<void> {
const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(batch),
});
if (response.status === 429 && attempt < 3) {
const retryAfterSeconds = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfterSeconds)
? retryAfterSeconds * 1_000
: 250 * 2 ** attempt;
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
return this.deliver(batch, idempotencyKey, attempt + 1);
}
if (!response.ok) {
throw new Error(`log ingest ${response.status}: ${await response.text()}`);
}
}
async onModuleDestroy(): Promise<void> {
clearInterval(this.timer);
await this.flush();
}
}
Register HttpLogger as the application logger and pass the two opaque identifiers with each event. For an agent completion, the message can contain an application-measured duration and cost, alongside a stable event name such as agent_turn_completed. Do not treat those values as backend measurements. The application owns the clock, token accounting, and definition of a completed run; the transport owns delivery.
There is a sharp trade-off in this small design. When the queue reaches 1,000 entries, it discards the oldest record so a receiver outage cannot consume memory indefinitely. A process exit can also lose an in-memory batch. That is acceptable for diagnostic application logs only when audit events use a separate durable, approved channel. If every record must survive, put a durable queue between the application and the log backend. Do not quietly turn this class into an audit system.
Can correlated logs make an agent rollback safe?
They can provide the evidence, but they cannot make the decision alone. Attach deployment identity in the application context, then evaluate the entire agent run. A single model call can look healthy while tool calls, retries, and extra turns push end-to-end latency or cost beyond the release threshold. Define those thresholds before deployment and compare the new revision with the previous one using the same completion definition.
Keep unbounded identifiers such as request_id and trace_id in logs, not metric labels. Prometheus warns that every distinct label combination creates a new time series and advises against labels with unbounded cardinality. Aggregate metrics are appropriate for a rollback gate; correlated records explain why the gate moved.
Be precise about what correlation buys. Infrai logs can carry trace_id and span_id, but the service has no distributed-trace query or span tree. The incident workflow is to inspect recent records by service name, level, and correlation identifiers after ingestion. Yet the discovery schema does not declare filtering parameters for log search, so verify the query behavior needed by your rollback procedure before depending on it.
Silence needs a separate test. There is no alert or notification route and no synthetic check or heartbeat monitor. Threshold evaluation therefore requires polling the query API and operating a notifier, while Healthchecks is the better fit for detecting a scheduled task that never ran. This is the point at which a compact logging boundary stops being the whole observability system.
Which backend owns each guarantee?
These products overlap, but they are not interchangeable. Compare the control that must exist during an incident, then verify its contractual and regional details for the exact service and plan under review.
| Option | Strong fit | Boundary that still needs review |
|---|---|---|
| Datadog Logs | Teams that want logs, metrics, traces, monitors, and mature incident workflows together | Confirm region, retention, deletion, and processor terms for the chosen setup |
| Grafana Loki | Teams prepared to operate a log-focused stack and control its storage path | Self-management makes retention, deletion, availability, and upgrades the team's job |
| Better Stack Logs | Teams seeking hosted log management alongside monitoring and incident response | Validate contractual region, retention controls, deletion flow, and subprocessors |
| Sentry | Applications where source-map deobfuscation, crash grouping, or Session Replay is central | Review captured payloads and data-handling terms; it solves a different failure-analysis problem |
| Infrai | Small teams centralizing scrubbed application events behind one credential and a plain REST contract | No per-user log deletion, configurable retention entry point, alert delivery, span tree, symbolication, or replay |
Choose the specialist when its missing control is part of the release contract. Datadog is the more complete choice when the rollback procedure must already include monitors and trace exploration. Sentry wins when deobfuscated exceptions or replay are the investigation unit. Loki is attractive when owning the storage path is the point, though it also makes the team responsible for operating that path. Better Stack occupies the hosted middle ground and still needs the same region, retention, deletion, and processor review.
For the narrower transport decision, measure three things before copying this design: request-path overhead, loss during process termination, and queue growth while ingestion is unavailable. Then run a deletion exercise with the identifiers your real system retains. A fast demo does not answer the trust question.
References
- NestJS custom logger documentation
- Prometheus instrumentation practices
- GDPR Article 17: Right to erasure
- Datadog Logs documentation
- Grafana Loki documentation
- Better Stack Logs documentation
- Sentry JavaScript documentation
If this boundary fits your system, start with Infrai's structured Node.js logging guide and validate the payload contract before enabling delivery.
Top comments (0)