feat(logging): ~2-month container rotation, ISO timestamps, level names
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 41s

Operator asked for bounded container logs (~2 months of history), human-
readable timestamps, and clarity on levels. Levels already existed (LOG_LEVEL
env → pino, default info; warn+ teed into app_logs, queryable at /setup/logs)
— the "level":30 / epoch-ms "time" in docker logs were pino defaults.

- server.ts logger: stamp ISO-8601 UTC time (timestamp fn) and level NAMES
  (formatters.level) so `docker logs` reads human.
- log-service.ts pinoDbStream: accept BOTH level encodings (name + numeric) —
  the label switch would otherwise have silently stopped warn+ persistence
  into app_logs. New log-service-stream.test.ts pins both encodings, the
  info-stays-stdout-only rule, and the never-throws fallback.
- docker-compose.prod.yml: json-file caps resized from 10m×3 (≈30 MB — days,
  not months) to ≈2 months by volume: server 20m×30, vision 20m×10, proxy
  10m×5. json-file rotates by SIZE; time-based isn't a driver feature —
  comment says to revisit if `docker logs` holds under ~60 days.
- app_logs retention default aligned 30→60 days (LOG_RETENTION_DAYS still
  overrides).

Wiki: app-logs.md gains the container-log store section (rotation, format,
LOG_LEVEL knob) + retention update; log.md entry.

Suite 282 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-04 19:47:53 +02:00
parent 43c1f45e29
commit c21babf293
6 changed files with 120 additions and 15 deletions
+15 -6
View File
@@ -33,7 +33,10 @@ export interface LogRetention {
}
export const DEFAULT_RETENTION: LogRetention = {
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
// 60 days (~2 months) — the operator's chosen diagnostic window (2026-07-04),
// matched by the container-log rotation caps in docker-compose.prod.yml. The row
// cap below still bounds a burst regardless of age.
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 60),
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
};
@@ -192,9 +195,10 @@ export class LogService {
/**
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
* Pino writes one JSON object per line to this stream; we parse, map the numeric level
* to a name, and persist. Returned as `{ write }` so it can be passed as pino's stream.
* stdout still receives the same line (we tee), so console logging is unchanged.
* Pino writes one JSON object per line to this stream; we parse, resolve the level
* (name or numeric encoding), and persist. Returned as `{ write }` so it can be passed
* as pino's stream. stdout still receives the same line (we tee), so console logging is
* unchanged.
*/
export function pinoDbStream(
service: LogService,
@@ -218,12 +222,17 @@ export function pinoDbStream(
}
try {
const obj = JSON.parse(line) as {
level?: number;
level?: number | string;
msg?: string;
err?: { stack?: string; message?: string };
[k: string]: unknown;
};
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
// The logger emits level NAMES (formatters.level in server.ts, for human-
// readable container logs); a default pino config emits numbers. Accept both.
const level: LogLevel =
typeof obj.level === "string" && obj.level in LOG_LEVEL_ORDER
? (obj.level as LogLevel)
: NUM_TO_LEVEL[typeof obj.level === "number" ? obj.level : 30] ?? "info";
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
// Strip pino's noisy standard fields from the persisted context.
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;