Agents debug production by querying logs with SQL (betterstack-logs), and a query only works when every service writes the same fields under the same names. A log line is also a copy of data that leaves the app, so a careless line is a leak.
Implementation: Node APIs, workers and NestJS in references/pino-node.md, which also owns the tested versions; Next.js and serverless functions in references/nextjs.md. Where logs are shipped, sources, and reading logs belong to betterstack-logs.
1. Field contract
Every line is one JSON object with these fields, as nested objects, never dotted keys (http.status_code is {"http": {"status_code": 404}}).
| Field | Type | Required | Example |
|---|---|---|---|
message | string, a short human sentence with no variable data | always | "invoice paid" |
timestamp | string, ISO 8601 UTC with milliseconds | always | "2026-10-19T07:43:47.967Z" |
time | string, same value as timestamp | pino only | "2026-10-19T07:43:47.967Z" |
level | number in pino, label once in Better Stack | always | 30 in the process, "info" in Better Stack |
service | string, the service key in .agents/environments.json; without a registry, the deployable's short name, later its registry key | always | "api" |
env | string, the registry environment name, from APP_ENV; local when unset | always | "production" |
version | string, the commit SHA the version endpoint reports, from GIT_SHA; unknown when unset | always | "3f2a9c1e0b7d4a6f8e2c5b1d9a0f7e6c4b3a2d1e" |
trace_id | string, 32 lowercase hex or a validated inbound id | in requests and jobs | "4bf92f3577b34da6a3ce929d0e0e4736" |
request_id | string | in requests, and jobs a request enqueued | "0199c7c8-5d42-4c4a-9414-325d2de609e4" |
tenant_id | string | once the tenant is known | "org_42" |
user_id | string, the internal id only | once the caller is known | "usr_7" |
event | string, section 2 | on every line an alert, chart or count uses | "invoice.paid" |
error | {type, message, stack, cause} and nothing else, cause the same shape | when an error is logged | {"type": "Error", "message": "charge failed", "cause": {...}} |
http | {method, route, status_code, duration_ms} | request completion lines | {"method": "GET", "route": "/invoices/:id", "status_code": 200, "duration_ms": 3} |
job | {name, id, attempt}, attempt 1-based | inside a job | {"name": "invoice.send", "id": "job_1", "attempt": 1} |
duration_ms | number | lines that close a non-HTTP operation | 412 |
sample_rate | number | sampled lines (section 6) | 0.1 |
localorunknownon a line from a deployed environment is a deploy bug: setAPP_ENVandGIT_SHAthere.- Domain ids go at the top level in snake_case (
invoice_id), as ids, never as objects. - No second name for a contract field: not
msg,err,reqId,correlationId,svc, ornamefor the service. - The one allowed alias: a project may keep an existing tenant field name (Westmont's
organization_id) if its agent setup block fromproject-initdeclares it in one line, e.g.Logging: tenant field is organization_id (logging-standard alias). levelstays numeric in pino because pino's multi-target transports require it,@logtail/pinomaps numbers to labels on ship (0.5.8 turns any string level intofatal), and existing scripts grep numeric levels. Better Stack stores the label; a Vector or collector path must map the number to the label before shipping (betterstack-logs).timeexists because@logtail/pinoturns it into Better Stack'sdtand version 0.5.8 throws without it.
2. Levels and events
| Level | Use for |
|---|---|
fatal | The process cannot continue; logged once, right before exit |
error | An operation failed and someone must act: a 5xx, a job's final failure, a failed payment webhook |
warn | Handled but abnormal: a 4xx, a job attempt that will retry, a fallback taken, a limit nearly reached |
info | Business events and the one completion line per request or job |
debug | Detail for development; off in production |
- Production runs at
infofromLOG_LEVEL; raise one service todebugonly for a bounded investigation and set it back. - One completion line per HTTP request: 2xx and 3xx
info, 4xxwarn, 5xxerror; no separate "request received" line abovedebug. - An expected 4xx (validation failed, not found, permission denied) is that one line: the handler sets its
eventand fields on the completion line (setRequestOutcomeinpino-node.md,scope.outcomeinnextjs.md) and logs no second line. Every completion line carrieshttp, so queries select completion lines byhttp, not by event. - Health, liveness and readiness probes are never logged.
- An event name is
<domain>.<entity>.<past-tense verb>or<domain>.<past-tense verb>: lowercase, dot-separated, words in snake_case (invoice.paid,auth.login.failed,job.attempt_failed). - Never put ids, counts, or user input in an event name or in
message; they go in fields. - Each service keeps its event names as constants in one file, so adding or renaming one is a visible diff.
- Renaming an event breaks the alerts and saved queries that use it: treat it as a contract change and update them in the same change (
betterstack-logs). - Reserved names, emitted by the reference code:
http.request.completed,http.request.failed,job.completed,job.attempt_failed,job.failed,app.started,app.stopping,app.crashed.
3. Correlation
- Inbound, in this order: a valid W3C
traceparentwith a non-zero trace id givestrace_id; else a validx-correlation-id; else a new 32-hex id. A validx-request-idgivesrequest_id; else a new UUID. - Inbound ids are attacker-controlled: accept only
^[A-Za-z0-9._:-]{1,128}$(and the exacttraceparentformat), replace anything else, and never log the rejected value. The same check applies to any id read from a path, query or header before it goes on a line. - Return
x-request-idon every response, so a user's bug report or a browser trace leads to the lines. - Calls to the project's own services send
x-correlation-id: <trace_id>; with OpenTelemetry installed, let OTel propagatetraceparentand injecttrace_id, and do not generate a second id. - The ids live in an AsyncLocalStorage context that the logger reads on every line; auth sets
tenant_idanduser_idinto it once, from the authenticated caller, never from a header or path the caller controls, and code never passes them by hand. An app with no auth yet may settenant_idfrom the route's tenant parameter after thesafeIdcheck, with aTODO(auth)comment at that line, and leavesuser_idunset. - Enqueuing a job copies
trace_id,request_id,tenant_idanduser_idinto the job payload; the worker re-enters that context and addsjob. A scheduled job with no request starts a newtrace_idper run.
4. Errors
- Log an error once, at the boundary that handles it: the request's completion line, the job wrapper,
onRequestErrorin Next.js, a script's top-level catch, or the catch that recovers (then atwarn). - Never log and rethrow. "The extra context helps" is not an exception: wrap the error with
new Error('charge failed', { cause: err })and let the boundary log the chain. "The caller might not log it" is not an exception either: then the caller is missing its boundary; add it there. - The job wrapper is the one place that logs and rethrows, and only because the queue library needs the throw to schedule a retry and logs nothing itself. Work with no queue to retry it ends that rethrow with an empty catch, since the wrapper already logged it.
- Pass the Error object (
{ error }or{ err }), nevererr.messageorString(err), sotype,stackand thecausechain survive. - Expected outcomes (validation failed, not found, permission denied) are
warnorinfo, nevererror: inside a request they name the completion line (section 2), elsewhere they are one line with anevent. - Error messages can carry personal data (a database message quoting the duplicate email); messages you write never include it, and redaction cannot scrub it (section 5).
5. Redaction and PII
- Log ids, not objects:
user_id, not the user;invoice_id, not the invoice; never request or response bodies, header bags, or full URLs (query strings carry tokens; log the route template). - Never log an error's own extra properties: a body parser's error carries the raw body, an HTTP client's carries the request headers. A 4xx caused by the request (malformed or oversized body) is logged by its completion line alone, without the error.
- The logger's redaction list is the safety net, not the plan; the defaults in each reference cover credentials, emails, phones and card fields, and a project adds paths without removing defaults.
- Censor with
[REDACTED]by default, so a reader and the test can see the guard fired; remove the key instead (pinoremove: true) only when its presence alone tells something sensitive or the value is large. - Redaction matches keys, never text: tokens, passwords, emails, phone numbers, card data and addresses never go into
messageor into error messages you construct. - IP addresses and user agents are personal data under GDPR; log them only where a feature needs them, and say so in review.
- A secret found in shipped logs: rotate the secret first, then fix the code, then tell the user, who decides about deleting the logs in Better Stack.
6. Volume and cost
- A line stays under 100 KiB, Better Stack's recommended maximum; the reference loggers replace a larger line with a summary marked
truncated: true. - No bodies, no lists of records, no whole database rows: log counts and ids (
item_count,invoice_idsonly when short). - In loops, log once per batch with counts, not once per item.
- Sample only high-volume
infosuccess events (cache hits, polling), withsample_rateon the line so counts can be scaled. Never samplewarnand above, audit events, or events an alert counts. debugoff and probes silent in production (section 2).
7. Tests
Every service that logs has these tests, built on the project's real logger factory, http options, and exception filter classes, never copies of them; a structural test then pins that main.ts and the root module register those same pieces. A framework that shapes error handling (Nest's exception filter, its body parser) is tested through the framework itself, an app built with NestFactory and nestjs-pino, never a hand-written stand-in: stand-ins hid both a missing message and a doubled error line in a real migration.
- Contract and redaction: capture lines from a destination stream while a real request runs through the app's middleware and a handler logs an object holding a password and an authorization header. Assert the secret strings appear in no line,
[REDACTED]appears, the health probe left no line, and the completion line has every always-required field, the right numeric level, the fourhttpkeys, and noreqdump. - Error once: a route that throws an error with a cause and an extra property produces exactly one line at
erroror above, with amessage,error.cause, and no keys beyond the four; an SDK error carrying a 4xx status keeps its error and stack. - Malformed body: invalid JSON holding a card token and an email gives a 400 and one
warnline that contains neither. - Job correlation: a job enqueued inside a request logs that request's
request_idandtenant_id. - Proof the guards are mounted: break each guard once and watch its test fail, then restore; the list of breaks is in
references/pino-node.md(Tests), including deleting the filter registration frommain.ts, which the structural test must catch.
Templates: the Tests section of each reference.