Logs are the evidence trail. The bar: from a single log line, plus its siblings sharing the same trace-id, a reader should be able to reconstruct what happened without re-running the code. That's the whole game. Everything else in this skill is in service of that.
Pairs with errors — the error is designed over there; here it gets emitted. Neither works without the other.
Example prompts
- "Add logging to the checkout flow"
- "Why isn't there a log for this failure?"
- "Set up structured logging for a new Fastify service"
- "Review this handler for observability"
Core principles
Structured, always. Key/value pairs. Never string-concat ("user " + id + " failed"). Never printf. A log entry is a JSON object; the message is one field among many.
One log per outcome, not per line. Log at the entry and exit of interesting operations. Do not log every branch — that's what DEBUG is for, in local dev, off in prod.
Every log carries the same context. At minimum: trace_id (per request), user_id (if authenticated), operation (the business event, e.g. checkout.complete). Set these once via a request-scoped logger (Fastify: req.log; Node: AsyncLocalStorage; Go: context.Context). Never pass them by hand into every call.
Errors log the full chain. The errors skill defines the error shape. Logging it means: code, message, context, and the recursive cause chain. Not just err.message.
Level discipline (see the table below). Wrong level = alerts that don't fire, or dashboards that drown.
Never log secrets, PII, tokens, cookies, session data. Not even in DEBUG. Not "we'll strip them later." Never write them in the first place — the redaction step will be forgotten.
The log entry shape
Every entry, minimum:
| Field |
Purpose |
level |
debug info warn error fatal |
msg |
Short, human. "checkout complete" not "the checkout has been completed by user" |
time |
ISO 8601 or epoch (logger default) |
trace_id |
The request's trace-id. Correlates all logs for one request. |
operation |
Business event name. checkout.complete, auth.login, user.create. |
user_id |
Authenticated user id (if any). Not the email, not the name. |
On error entries, add:
| Field |
Purpose |
err.code |
The stable code from the errors skill. |
err.message |
Error message. |
err.context |
The structured context from the thrown error. |
err.cause |
The wrapped underlying error (walked recursively — one field per level). |
err.stack |
Stack trace. In prod, keep it in logs, not in HTTP responses. |
On external calls, add:
| Field |
Purpose |
duration_ms |
How long the call took |
status |
HTTP status (or equivalent) |
target |
What was called (postgres.users, stripe.charge) |
Level discipline
| Level |
Use for |
Prod default |
Example |
| DEBUG |
Granular flow, dev-only |
off |
"cache lookup for key x" |
| INFO |
Business events, outcomes |
on |
"checkout complete", "user signed up" |
| WARN |
Recoverable issues, degraded but working |
on |
"retry succeeded on attempt 3", "rate-limited caller" |
| ERROR |
Needs attention. Unexpected failure. |
on |
"unhandled exception", "downstream 500" |
| FATAL |
Process dying (crash, unrecoverable init) |
on |
"database connection lost, shutting down" |
Cardinal sins:
- Logging a caught-and-handled expected error at ERROR. It fires alerts for a normal outcome.
- Logging every successful DB call at INFO. Drowns real signal.
- Using
console.log in prod. Not structured, no level, no context. Firing offense.
Where to log
- Once at request entry.
req.log.info({ operation, params }, "request start"). Optional; usually the framework's access log covers it.
- At every business outcome.
req.log.info({ operation: "checkout.complete", order_id, amount }, "checkout complete").
- On every external call boundary.
req.log.info({ target, duration_ms, status }, "external call"). INFO for success, WARN for retry, ERROR for failure.
- In the global error handler. Exactly one log per uncaught error, at the level determined by error type (WARN for expected, ERROR for unexpected). See
errors.
Where NOT to log:
- Inside a tight loop, unless at DEBUG.
- Every step of a happy-path flow. Log the outcome, not the journey.
- The same event at two levels (once in the handler that catches, once in the global handler). Log once, at the boundary.
Setup — the once-per-service work
Node / Fastify:
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.token"],
base: { service: "checkout-api" },
});
fastify.register(require("@fastify/http-proxy"), { logger });
// req.log is a child logger with req_id + trace_id already bound
Anywhere: propagate the trace-id via an x-request-id (or traceparent for OpenTelemetry) header on every outgoing call, so downstream services correlate.
The story with errors
- Request enters → framework binds a
trace_id to req.log.
- Business logic runs → INFO logs at outcomes with
operation names.
- External call → INFO with
duration_ms + status + target.
- Error thrown (from the
errors skill) → propagates to the global error handler.
- Global handler logs at the right level: WARN for expected, ERROR for unexpected. Entry includes
err.code, err.context, err.cause chain, trace_id.
- HTTP response goes back with
{ code, message, trace_id } — the same trace_id that's in the logs.
- Operator sees the log in the dashboard → filters by
trace_id → sees all sibling logs from the same request → greps the code for err.code → finds where it was thrown → reproduces with the captured context.
If any of steps 1–6 is missing, step 7 fails. That's when debugging costs hours.
Anti-patterns
- Unstructured strings:
log.info("user " + id + " logged in"). Do: log.info({ user_id: id, operation: "auth.login" }, "user logged in").
- Logging the same event twice. Handler catches, logs, rethrows → global handler catches, logs again. Pick one. Global handler wins.
- Logging then throwing without cause.
log.error("failed"); throw new Error("failed") — the two log entries have no link. Attach the error to the log, or let the global handler log it.
console.log in prod. Bypasses structure, level, redaction. If it slips into a PR, code-review should flag it.
- Redacting after the fact. Don't build a redact list of 40 fields. Structure your logs so secrets never enter them in the first place.
- PII in log context.
user_id is fine. user_email, user_name, user_ip (unless you have a specific compliance-cleared use) is not.
Reviewing for observability
When reviewing (or in code-review), check:
- Every INFO/WARN/ERROR entry has
trace_id + operation bound (via request-scoped logger).
- Every error entry has
err.code + err.context + err.cause (recursive).
- No
console.log. No fmt.Println. No print(...).
- No secrets in any log's key/value pairs. No cookies, no tokens, no PII beyond
user_id.
- External calls have
duration_ms + status.
- No log-then-rethrow. Log once, at the boundary.
1---2name: logging3description: Emit logs that are structured, contextual, and let you debug from them without rerunning the code. Every log carries trace-id, user-id (if authed), and operation name. Errors are logged with the full cause chain from the `errors` skill. Level discipline (DEBUG dev-only, INFO business events, WARN recoverable, ERROR needs-attention). Never log secrets, PII, tokens, cookies. Use when adding a log line, setting up a logger, reviewing a service for observability, or debugging a "why isn't there a log for this" gap.4---56Logs are the evidence trail. The bar: from a single log line, plus its siblings sharing the same trace-id, a reader should be able to reconstruct what happened without re-running the code. That's the whole game. Everything else in this skill is in service of that.78Pairs with `errors` — the error is *designed* over there; here it gets *emitted*. Neither works without the other.910## Example prompts1112- "Add logging to the checkout flow"13- "Why isn't there a log for this failure?"14- "Set up structured logging for a new Fastify service"15- "Review this handler for observability"1617## Core principles18191. **Structured, always.** Key/value pairs. Never string-concat (`"user " + id + " failed"`). Never printf. A log entry is a JSON object; the message is one field among many.20212. **One log per outcome, not per line.** Log at the entry and exit of interesting operations. Do not log every branch — that's what DEBUG is for, in local dev, off in prod.22233. **Every log carries the same context.** At minimum: `trace_id` (per request), `user_id` (if authenticated), `operation` (the business event, e.g. `checkout.complete`). Set these once via a request-scoped logger (Fastify: `req.log`; Node: AsyncLocalStorage; Go: context.Context). Never pass them by hand into every call.24254. **Errors log the full chain.** The `errors` skill defines the error shape. Logging it means: `code`, `message`, `context`, and the recursive `cause` chain. Not just `err.message`.26275. **Level discipline** (see the table below). Wrong level = alerts that don't fire, or dashboards that drown.28296. **Never log secrets, PII, tokens, cookies, session data.** Not even in DEBUG. Not "we'll strip them later." Never write them in the first place — the redaction step *will* be forgotten.3031## The log entry shape3233Every entry, minimum:3435| Field | Purpose |36| :---------- | :--------------------------------------------------------------------------------- |37| `level` | `debug` `info` `warn` `error` `fatal` |38| `msg` | Short, human. `"checkout complete"` not `"the checkout has been completed by user"` |39| `time` | ISO 8601 or epoch (logger default) |40| `trace_id` | The request's trace-id. Correlates all logs for one request. |41| `operation` | Business event name. `checkout.complete`, `auth.login`, `user.create`. |42| `user_id` | Authenticated user id (if any). Not the email, not the name. |4344On error entries, add:4546| Field | Purpose |47| :------------- | :------------------------------------------------------------------------- |48| `err.code` | The stable code from the `errors` skill. |49| `err.message` | Error message. |50| `err.context` | The structured context from the thrown error. |51| `err.cause` | The wrapped underlying error (walked recursively — one field per level). |52| `err.stack` | Stack trace. In prod, keep it in logs, not in HTTP responses. |5354On external calls, add:5556| Field | Purpose |57| :------------ | :------------------------------------------------- |58| `duration_ms` | How long the call took |59| `status` | HTTP status (or equivalent) |60| `target` | What was called (`postgres.users`, `stripe.charge`) |6162## Level discipline6364| Level | Use for | Prod default | Example |65| :----- | :--------------------------------------------- | :----------- | :---------------------------------------- |66| DEBUG | Granular flow, dev-only | off | "cache lookup for key `x`" |67| INFO | Business events, outcomes | on | "checkout complete", "user signed up" |68| WARN | Recoverable issues, degraded but working | on | "retry succeeded on attempt 3", "rate-limited caller" |69| ERROR | Needs attention. Unexpected failure. | on | "unhandled exception", "downstream 500" |70| FATAL | Process dying (crash, unrecoverable init) | on | "database connection lost, shutting down" |7172Cardinal sins:7374- Logging a caught-and-handled expected error at ERROR. It fires alerts for a normal outcome.75- Logging every successful DB call at INFO. Drowns real signal.76- Using `console.log` in prod. Not structured, no level, no context. Firing offense.7778## Where to log7980- **Once at request entry.** `req.log.info({ operation, params }, "request start")`. Optional; usually the framework's access log covers it.81- **At every business outcome.** `req.log.info({ operation: "checkout.complete", order_id, amount }, "checkout complete")`.82- **On every external call boundary.** `req.log.info({ target, duration_ms, status }, "external call")`. INFO for success, WARN for retry, ERROR for failure.83- **In the global error handler.** Exactly one log per uncaught error, at the level determined by error type (WARN for expected, ERROR for unexpected). See `errors`.8485Where NOT to log:8687- Inside a tight loop, unless at DEBUG.88- Every step of a happy-path flow. Log the outcome, not the journey.89- The same event at two levels (once in the handler that catches, once in the global handler). Log once, at the boundary.9091## Setup — the once-per-service work9293**Node / Fastify:**9495```ts96import pino from "pino";97const logger = pino({98 level: process.env.LOG_LEVEL ?? "info",99 redact: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.token"],100 base: { service: "checkout-api" },101});102fastify.register(require("@fastify/http-proxy"), { logger });103// req.log is a child logger with req_id + trace_id already bound104```105106**Anywhere:** propagate the trace-id via an `x-request-id` (or `traceparent` for OpenTelemetry) header on every outgoing call, so downstream services correlate.107108## The story with errors1091101. Request enters → framework binds a `trace_id` to `req.log`.1112. Business logic runs → INFO logs at outcomes with `operation` names.1123. External call → INFO with `duration_ms` + `status` + `target`.1134. Error thrown (from the `errors` skill) → propagates to the global error handler.1145. Global handler logs at the right level: WARN for expected, ERROR for unexpected. Entry includes `err.code`, `err.context`, `err.cause` chain, `trace_id`.1156. HTTP response goes back with `{ code, message, trace_id }` — the same `trace_id` that's in the logs.1167. Operator sees the log in the dashboard → filters by `trace_id` → sees all sibling logs from the same request → greps the code for `err.code` → finds where it was thrown → reproduces with the captured `context`.117118If any of steps 1–6 is missing, step 7 fails. That's when debugging costs hours.119120## Anti-patterns121122- **Unstructured strings**: `log.info("user " + id + " logged in")`. Do: `log.info({ user_id: id, operation: "auth.login" }, "user logged in")`.123- **Logging the same event twice.** Handler catches, logs, rethrows → global handler catches, logs again. Pick one. Global handler wins.124- **Logging then throwing without cause.** `log.error("failed"); throw new Error("failed")` — the two log entries have no link. Attach the error to the log, or let the global handler log it.125- **`console.log` in prod.** Bypasses structure, level, redaction. If it slips into a PR, `code-review` should flag it.126- **Redacting after the fact.** Don't build a redact list of 40 fields. Structure your logs so secrets never enter them in the first place.127- **PII in log context.** `user_id` is fine. `user_email`, `user_name`, `user_ip` (unless you have a specific compliance-cleared use) is not.128129## Reviewing for observability130131When reviewing (or in `code-review`), check:132133- Every INFO/WARN/ERROR entry has `trace_id` + `operation` bound (via request-scoped logger).134- Every error entry has `err.code` + `err.context` + `err.cause` (recursive).135- No `console.log`. No `fmt.Println`. No `print(...)`.136- No secrets in any log's key/value pairs. No cookies, no tokens, no PII beyond `user_id`.137- External calls have `duration_ms` + `status`.138- No log-then-rethrow. Log once, at the boundary.