Production log management for an Express API should make JSON requests and errors searchable without forcing a one-person SaaS to inspect every event. Collecting everything only makes the useful evidence harder to find.
Short answer: for an Express API running AI agent loops, choose log management by its ability to reconstruct one slow, expensive, or failed run from structured JSON. Emit one completion event per meaningful step, correlate it with the request, and retain typed latency, token usage, outcome, and error fields. Search and dashboards come second.
This is a signal-quality decision. Raw prompts, response chunks, and routine framework chatter add noise, cost, and privacy exposure without reliably answering why a run failed.
Start there.
What should Express API production log management answer?
Start with the incident question: "Why did this agent run take longer or consume more tokens than expected, and which step caused it?" That question determines the schema. It also prevents a common mistake: treating every line written by the framework as equally valuable.
An agent request has two clocks. The outer clock measures the HTTP request. Inner clocks measure model calls, tool calls, retries, and queue time. A request-duration field alone cannot identify the slow step. Logging each streamed token goes too far in the other direction.
The useful identifiers are request_id, run_id, and step_id. Useful measurements include elapsed milliseconds plus input and output token counts returned by the model interface. Add outcome, a stable operation name, and an error class. Keep raw prompts, authorization headers, and full tool payloads out of the default event.
Three IDs are enough for this first boundary.
Be ruthless here. A field earns its place when it shortens a decision: retry, fix, contact a user, or leave the system alone. An unqueried field is storage and review work. Remove it.
The smallest implementation I would ship
The application should own the schema. The transport can stay boring: newline-delimited JSON on standard output, collected by the runtime and forwarded to an indexed store. This boundary keeps a storage backend out of request handling.
The following TypeScript wrapper records a single completion event. Token counts come from the component that receives them; the code does not estimate them or record content.
type StepResult<T> = {
value: T;
inputTokens?: number;
outputTokens?: number;
};
type StepEvent = {
timestamp: string;
level: "info" | "error";
event: "agent.step.completed";
request_id: string;
run_id: string;
step_id: string;
operation: "model_call" | "tool_call";
duration_ms: number;
input_tokens?: number;
output_tokens?: number;
outcome: "ok" | "error";
error_class?: string;
};
function emit(event: StepEvent): void {
process.stdout.write(`${JSON.stringify(event)}\n`);
}
async function observeStep<T>(params: {
requestId: string;
runId: string;
stepId: string;
operation: StepEvent["operation"];
execute: () => Promise<StepResult<T>>;
}): Promise<T> {
const started = performance.now();
try {
const result = await params.execute();
emit({
timestamp: new Date().toISOString(),
level: "info",
event: "agent.step.completed",
request_id: params.requestId,
run_id: params.runId,
step_id: params.stepId,
operation: params.operation,
duration_ms: Math.round(performance.now() - started),
input_tokens: result.inputTokens,
output_tokens: result.outputTokens,
outcome: "ok"
});
return result.value;
} catch (error) {
emit({
timestamp: new Date().toISOString(),
level: "error",
event: "agent.step.completed",
request_id: params.requestId,
run_id: params.runId,
step_id: params.stepId,
operation: params.operation,
duration_ms: Math.round(performance.now() - started),
outcome: "error",
error_class: error instanceof Error ? error.name : "UnknownError"
});
throw error;
}
}
The HTTP middleware can emit a separate request-completion event with the same IDs. Keep identifiers as strings and durations as numbers. Type drift makes filters and aggregations unreliable precisely when they are needed.
Error logging needs restraint. Record a safe error class and controlled code, but do not serialize arbitrary thrown objects; they may contain response data or credentials. Rethrow the original exception so observation does not alter retry or HTTP behavior. Logs must remain off the critical path.
I would ship this schema with the next weekly product release, then inspect real queries before adding fields. Designing a universal taxonomy in advance has a poor revenue-per-hour return for a solo operation.
How do you test a management layer?
Use an acceptance test, not a feature checklist. Feed each candidate a synthetic run containing a successful model call, a failed tool call, and a retry. Verify that a beginner can find the run by run_id, filter numeric duration, group errors by error_class, and move from a latency chart to the matching events.
Walk through one concrete reconstruction before scoring anything. Search for the outer request_id, identify its run_id, sort the matching step events by timestamp, and compare their numeric durations. The failed tool event should retain its error class while the retry appears as a distinct step, so the sequence can be understood without reading a prompt or guessing from message text. Then begin at the dashboard: select the same latency interval and confirm that the chart leads back to those exact events. Finally, export the sample, delete it through the documented retention path, and confirm that both operations preserve the expected field types. This is intentionally a small test, but it crosses ingestion, search, correlation, visualization, export, and deletion. A candidate that fails one of those transitions creates manual incident work even if each screen looks capable in isolation.
No feature-count spreadsheet can replace that walk-through.
| Capability | Pass condition | Failure signal |
|---|---|---|
| JSON ingestion | Fields stay typed and independently searchable | The event becomes one text blob |
| Correlation | One query reconstructs a run in step order | Each service needs a manual search |
| Error search | Error class and operation have exact filters | Search relies on message wording |
| Dashboard | A chart links to matching evidence | Charts and events are disconnected |
| Retention | Event classes can follow explicit rules | Everything has one global period |
| Export | Data leaves in a documented, usable form | History depends on one interface |
Test the deployment you will operate, with enough representative fields to expose mapping problems. A polished demo with tiny sample data cannot establish indexing delay, field limits, access control, or export behavior. Verify those boundaries directly.
Metrics and logs do different jobs. OpenTelemetry defines metrics as runtime measurements captured at intervals. Use a metric to notice that latency moved, then use correlated events to explain which run and step moved it. IDs belong in logs rather than metric attributes; grouping a chart by nearly unique run IDs produces noise, not clarity.
Noise and privacy change the choice
The first volume control is omission. Exclude health checks, static asset requests, successful framework chatter, and per-chunk events unless a demonstrated debugging need justifies them. Keep errors and completion summaries. Sample repetitive successful detail only under an explicit policy, because sampling can remove the evidence needed for reconstruction.
Cardinality is the subtler trap. operation=model_call is a useful grouping field. run_id is useful for lookup but poor for grouping because almost every value is unique. Apply that distinction to indexes, dashboards, and metric attributes.
Privacy belongs in the architecture. Prompts and tool payloads may contain user data, so excluding them when the event is created is stronger than hoping a later processor catches every variation. Use opaque identifiers where practical and maintain a documented path to relevant stored records. GDPR Article 17 establishes a right to erasure under its stated conditions, making deletion and retention behavior part of the acceptance test.
Short, controlled events also reduce unnecessary content exposure to people with log access. Development convenience should not silently define production visibility.
What changes when the system grows?
With several services or higher traffic, I would add trace context, adopt open telemetry conventions, and put a collector between applications and storage. The collector can batch and route data while applications retain a stable event schema. Material schema changes should be versioned and tested with fixed representative events before deployment.
I would not start there for a small SaaS. A collector and multi-signal pipeline create operating work of their own. Outsource undifferentiated transport after the event model proves useful, while retaining ownership of field names, correlation rules, retention classes, and export tests.
More infrastructure may improve routing and cross-service context. It also adds configuration and failure boundaries. This approach is not suitable when a regulated workload requires content capture under a formal evidence policy, when operators need distributed trace causality across many services, or when the runtime cannot provide reliable standard-output collection. In those cases, choose a purpose-built audit pipeline, a trace pipeline, or a durable local buffer according to the missing guarantee. The trade-off is more operational machinery in exchange for evidence the small event stream deliberately omits. Move when missing context consumes more engineering time than the pipeline would. Until then, a typed event, a repeatable reconstruction test, and a dashboard that leads back to evidence are enough.
The best production log management choice is the one that passes that test with the least ongoing attention. Keep the schema portable, measure what changes a decision, and ship the smaller loop first.
Top comments (0)