Structured Logging Skill
Two tiers. Tier 1 is non-negotiable. Tier 2 requires judgment. Rule 0 overrides both.
Rule 0 — Follow the project first, always
Before adding any log, check the codebase: existing logger (Winston/Pino,
structlog/loguru, Serilog/zap…)? Existing field naming (
vs
)? Existing redaction/PII-scrubbing utility? Existing audit-log
pipeline (e.g. a dedicated
table/service instead of stdout)?
If the project already has a pattern for something below, use it — it wins
over every default in this skill. Only apply the defaults below on a new
project, or where the project has no established pattern yet. Never rename an
established field (
→
); reuse it.
Preserve the pipeline: never bypass, replace, or wrap existing logging
infrastructure — transports, formatters, tracing integrations, audit
pipelines. Don't
into a logger that already accepts
structured objects, don't introduce a second logger, don't fall back to
/
/
"just this once."
This skill covers backend/service code (APIs, jobs, workers). Client-side/UI
logging is out of scope.
Tier 1 — Mandatory: catch and log every exception
- Every exception that can occur MUST be caught (locally, or at a global
unhandled-exception boundary) and logged. Never let it disappear silently or
be re-thrown without a log.
- Exception log must include: (operation/function name),
(, , ), , and a of the
key variables present at failure time — redacted per the internal-state
rule below (blocklist + auto-mask), not the external-input allowlist. The
goal: explain why it failed, not just that it failed.
- Level: for unhandled/system failures (5xx, DB/queue down, panics).
for expected/recoverable failures (validation errors, 4xx,
failed-then-retried). Never log expected user-input errors as — it
drowns real alerts.
- This tier applies everywhere, always. No judgment call needed.
Tier 2 — Judgment-based: log what could actually cause hard-to-find bugs
Categories below are hints about where risk tends to live, not a checklist
to log every instance of. Before adding a non-error log line, ask: "If
this specific piece of code produces a wrong result, will I need this log
line to figure out why?" Only add it if the honest answer is yes.
Lean toward logging: untrusted/unvalidated input, logic that can silently
produce a wrong result without throwing, non-trivial multi-condition
branching, an external call that can fail/be slow, irreversible or costly side
effects (money, hard-to-undo state changes).
Lean toward not logging: getters/setters/simple mappers, a branch so simple
the code already makes it obvious, per-iteration logging inside a loop.
When you do log, capture decision-relevant values, not the fact code ran
(
branch="loyalty_bonus", discount=15
not
"entering calculateDiscount"
);
for state mutations, capture before → after → trigger.
Loop policy: never log
/
per iteration.
/
per
item IS allowed when that item has its own try/catch and needs independent
investigation (e.g. batch import). Always end the loop with one summary log
(
,
, up to 5
).
Audit vs operational: business actions with legal/compliance weight
(money moved, permission changed, record deleted, admin action) are
audit
events — emit immediately,
/
, never sampled, never truncated
beyond secret-masking. Everything else is operational and may be sampled/
truncated/batched into a wide event as below.
Self-check on log count, not a hard cap: if a function ends up with more
than ~5 log lines, stop and re-justify each one against the question above —
don't cut a legitimately-needed log just to hit a number, and don't keep a
weak one just because you're under budget.
How to log (mechanics)
- Correlation & trace propagation: if OpenTelemetry/APM context already
exists (/), reuse it — don't create a parallel
. Otherwise extract in priority order: W3C →
/ header → generate a UUID. Every entry
point (HTTP middleware, queue consumer, cron/CLI main) sets this once and
passes it through the whole call chain, including outgoing HTTP/DB/queue
calls.
- Structured, not string-concatenated: one JSON object per line, fixed
field names (project convention, default ).
- Emit timing: normal successful flow accumulates into one "wide event" at
exit. Errors, irreversible state changes, audit events, and failed external
calls emit immediately with everything accumulated so far — don't wait,
the process may crash first.
- Streaming / long-lived connections (WebSocket, SSE, gRPC stream):
"wide event at exit" doesn't apply — a connection can live for hours. Emit
at start and at end; WARN/ERROR immediately
per message/segment failure; keepalive = metric, not log. Full pattern in
reference/domain-specific.md
.
- Redaction — hybrid, not one rule for everything:
- External input / request payload / user-facing data → allowlist
(default to not logging; explicitly list safe fields).
- Internal state / exception context / local variables at crash time →
blocklist + auto-mask (
password|token|secret|authorization| credit_card|ssn|api_key|private_key|otp|pin
, case-insensitive) +
truncate strings > ~400 chars with . This is what makes
Tier 1's useful instead of empty.
- Never dump a whole object () — extract fields.
- Collections: log + ≤5 sample ids, never the full array.
- Logger must be injectable (parameter/DI), not a hardcoded global, so
tests can swap in a no-op logger.
- Performance-critical path (marked ): only
+ minimal fields.
- High-frequency INFO (health checks, polling): respect a configurable
sampling rate — 100% of WARN/ERROR/audit, sampled success.
- Metrics vs logging: high-frequency numeric/boolean observations (cache
hit/miss, queue depth, heartbeat, per-request counters) belong in
metrics, not logs — unless folded as one field into a single wide event
already being emitted. Logging these at high frequency is a common way AI
silently 10x's log volume.
- Non-blocking: logging must never block the hot path — serialize/truncate
large before emission.
- High-cardinality: don't use raw URLs with ids, full emails, filenames,
prompts, or UUIDs as index/search labels — see
reference/field-dictionary.md
for the rule and safe alternatives.
- Targeted debug (optional): prefer enabling only for a specific
/ allow-list rather than globally in production.
- Business events: name ();
add a short human-readable field so logs stay skimmable by eye.
- Output: single-line JSON to stdout (stderr for ERROR). Append
ERROR/FATAL to only when (local
non-containerized dev) — never a hard requirement.
- Domain-specific fields (LLM calls, idempotency keys, retry/backoff,
transaction boundaries, feature flags, concurrency ids): see
reference/domain-specific.md
— don't inline these into every function by
default, apply only when that domain is actually in play.
Self-check before finishing a function
Review Mode (auditing existing code / a PR, not generating new code)
Same tiers and checks above apply, plus specifically look for: