---
name: forge-logging
description: Structured logging discipline. JSON-first, correlation IDs, level meaning, redaction at source, sampling, never-log-secrets, audit logs separated from application logs. Contains pino/structlog/slog setup, redaction config, request-id middleware. Use when adding logging to a service or auditing existing logging output.
license: MIT
---

# forge-logging

You are writing logs that will be read by oncall at 3am when they cannot reproduce the failure. Default agent-written logging is `console.log('user created')` with no structure, no IDs, no context, and frequently the secret value of the thing that just happened. This skill exists to fix all of that.

The mental model: logs are a queryable index of what happened. Every entry has a schema. Every entry can be filtered, grouped, alerted on. Prose logs in plain text are not logs; they are noise that costs storage.

## Quick reference (the things you must never ship)

1. `console.log(...)` anywhere in service code.
2. Template-string log messages: `logger.info(\`user \${id} created\`)`.
3. A log line that contains a raw `Authorization` header value or `Bearer` token.
4. `logger.info(err.message)` - loses stack and cause chain.
5. `logger.error(...)` for a 4xx response (pollutes alerting).
6. Logging at every step of the happy path ("entering function", "calling Y", "got response").
7. Audit events mixed into the same stream as application logs.
8. `LOG_LEVEL` hardcoded instead of read from env.
9. Pretty-printed logs in production (hostile to log aggregators).
10. A log line without a `request_id` or `trace_id` on a request-handling path.

## Hard rules

### Structure

**1. Logs are JSON in production.** No exceptions. Every line is a parseable object. Plain-text logs are grep fodder, not logs.

**2. `console.log` is banned in service code.** Use pino (Node), structlog or loguru (Python), zap or slog (Go).

**3. Every log line has at minimum: `timestamp`, `level`, `message`, `service`.** Most loggers add these automatically; confirm yours does.

**4. The `message` is a stable string. Context goes in fields.**

```ts
// BAD - one unique message per call, ungroupable
logger.info(`order ${order.id} created for user ${user.id} amount $${total / 100}`);

// GOOD - stable message, structured fields, groups perfectly
logger.info(
  { order_id: order.id, user_id: user.id, total_cents: total, currency: order.currency },
  "order.created",
);
```

### Context propagation

**5. Every request carries a correlation ID through every log line.** Generated at the edge (or accepted from `X-Request-Id`), attached to the logger via middleware.

```ts
// reference: Hono middleware
import { uuidv7 } from "uuidv7";

app.use("*", async (c, next) => {
  const incoming = c.req.header("x-request-id");
  const id = incoming && /^[\w-]{8,128}$/.test(incoming) ? incoming : uuidv7();
  c.set("requestId", id);
  c.header("x-request-id", id);
  await next();
});

// elsewhere
const log = logger.child({ request_id: c.get("requestId") });
log.info({ user_id: userId }, "user.viewed");
```

**6. Distributed traces have a `trace_id` and `span_id`.** OpenTelemetry-style. Allows correlating across services.

**7. Background jobs get a `job_id`; cron tasks get a `run_id`.** Anything that is not an HTTP request still needs an identifier to group its logs.

### Levels (the table)

**8. Standard levels mean specific things:**

| Level | Meaning | Example |
| --- | --- | --- |
| `trace` | Extreme detail, off in prod | `trace.method.entered` |
| `debug` | Developer-only, off in prod by default | `db.query.executed` |
| `info` | Notable success-path events | `order.created`, `request.received` |
| `warn` | Degraded but functioning | `cache.miss`, `slow_query` |
| `error` | Failure that needs attention | `charge.failed`, `db.query.error` |
| `fatal` | Process is about to die | `unhandled_rejection` |

**9. Logging at the wrong level is a bug.** `info` for an error pollutes the dashboards. `error` for an expected 404 pages someone for nothing.

**10. Set production level via env.** `LOG_LEVEL=info` in prod, `LOG_LEVEL=debug` in dev. Never hardcode.

### Redaction at source

**11. Never log secrets. Configure the logger to redact known-secret keys.**

```ts
// pino redaction - reference config
import pino from "pino";

const REDACT_KEYS = [
  "password", "passwd", "secret", "token", "api_key", "apiKey",
  "authorization", "cookie", "set-cookie", "private_key", "privateKey",
  "refresh_token", "access_token", "session_secret",
];

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  redact: {
    paths: REDACT_KEYS.flatMap((k) => [k, `*.${k}`, `*.*.${k}`, `req.headers.${k}`]),
    censor: "[REDACTED]",
  },
});
```

**12. Redact in transit, not just at rest.** Logs flow through aggregators, search indices, backups. Redaction at the logger is the only point under your control.

**13. PII per jurisdiction.** Emails, phone numbers, full names - log only when necessary, redact otherwise, document retention.

### What to log

**14. Log inputs of operations that mutate state.** With parameters (after redaction). The success log is separate.

```ts
log.info({ order_id, items: input.items.length, total_cents }, "order.create.requested");
const order = await createOrder(input);
log.info({ order_id: order.id }, "order.create.succeeded");
```

**15. Log the failure of every external call.** With response status + body excerpt.

**16. Read operations at `debug`, not `info`.** A read-heavy service drowns in `info`.

### What NOT to log

**17. Do not log request/response bodies in full.** Especially auth headers, payment payloads, document contents. Sample or hash if you need correlation.

**18. Do not log every step of the happy path.** "Validated request," "loaded user," "queried orders," "returning response" is 4 lines for one event. The boundary log is enough.

**19. Do not log code flow.** Use a tracer for that.

### Performance

**20. Logging is hot path. Async logger.** pino is async by default. Synchronous loggers can dominate request latency.

**21. Sample high-volume events.** A debug log inside a tight loop is a denial-of-service against your log pipeline.

**22. Log at the application boundary, not in libraries.** Library code should take a logger as a parameter or use a standard interface.

### Local dev

**23. Pretty-print in dev, JSON in prod.** `pino-pretty`, `structlog.dev.ConsoleRenderer`. Detect via `NODE_ENV`.

**24. Color is fine in dev terminals, never in prod logs.** ANSI codes pollute log aggregators.

### Errors

**25. Log the full error object, not just message.**

```ts
// BAD
logger.error("charge failed: " + err.message);

// GOOD
logger.error({ err, order_id }, "charge.failed");
```

**26. Errors get a fingerprint for grouping.** Sentry-style. Random message values (timestamps, IDs) break grouping; structure them as tags.

### Audit logs are separate

**27. Security-sensitive events go to a separate audit log.** Login, password reset, role change, data export. Different retention, different access controls.

```ts
// keep two streams
const audit = pino({ /* writes to a separate destination, longer retention */ });
audit.info({ actor_id, target_id, action: "password.changed" }, "audit");
```

## Common AI-output patterns to reject

| Pattern | Why wrong | Fix |
| --- | --- | --- |
| `console.log(...)` | No level, no structure | `logger.info({ ... }, "event.name")` |
| `` logger.info(`user ${id} did ${action}`) `` | Templated message ungroupable | Stable message + structured fields |
| `logger.info(err.message)` | Loses stack + cause | `logger.error({ err }, "event.name")` |
| `logger.error(...)` on a 4xx | Pollutes alerting | `info` or `debug` for 4xx |
| `logger.info(req.headers)` | Logs Authorization | Redact via logger config; never log raw headers |
| Logging every step of happy path | Noise dominates signal | One boundary log per request |
| `LOG_LEVEL = 'info'` hardcoded | Cannot tune for incident | `process.env.LOG_LEVEL` |
| Pretty-print in production | Aggregator chokes | JSON in prod |
| No `request_id` on request logs | Cannot correlate | Middleware that attaches an id to a child logger |
| Same stream for audit + app | Compliance hazard, retention mismatch | Two streams |

## Worked example: end-to-end logger setup

```ts
// src/logger.ts
import pino from "pino";

const REDACT_KEYS = [
  "password", "secret", "token", "api_key", "apiKey", "authorization",
  "cookie", "set-cookie", "private_key", "refresh_token", "access_token",
];

const prettyTransport =
  process.env.NODE_ENV === "development"
    ? {
        transport: {
          target: "pino-pretty" as const,
          options: { colorize: true, translateTime: "HH:MM:ss.l" },
        },
      }
    : {};

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  redact: {
    paths: REDACT_KEYS.flatMap((k) => [k, `*.${k}`, `*.*.${k}`, `req.headers.${k}`]),
    censor: "[REDACTED]",
  },
  formatters: {
    level: (label) => ({ level: label }),
  },
  ...prettyTransport,
});

export function withRequest(requestId: string) {
  return logger.child({ request_id: requestId });
}

// src/server.ts (middleware)
app.use("*", async (c, next) => {
  const id = c.req.header("x-request-id") ?? uuidv7();
  c.set("requestId", id);
  c.header("x-request-id", id);

  const start = performance.now();
  await next();
  const elapsed_ms = Math.round(performance.now() - start);

  withRequest(id).info(
    { method: c.req.method, path: c.req.path, status: c.res.status, elapsed_ms },
    "http.request",
  );
});

// src/handlers/orders.ts (usage)
app.post("/v1/orders", async (c) => {
  const log = withRequest(c.get("requestId") as string);
  const input = await c.req.json();
  log.info({ customer_id: input.customer_id, items: input.items.length }, "order.create.requested");
  const order = await createOrder(input);
  log.info({ order_id: order.id }, "order.create.succeeded");
  return c.json({ data: order }, 201);
});
```

What this shows: redaction config at source; pretty-in-dev / JSON-in-prod transport switch; request-id middleware; child logger per request; stable event names (`order.create.requested`, `order.create.succeeded`); access log via middleware (not in every handler).

## Workflow

1. **Pick the logger library once, project-wide.**
2. **Add request-id middleware first.** Every subsequent log inherits the id.
3. **Configure JSON output, level from env, redaction list.**
4. **Decide which boundaries log: HTTP, queue, scheduled, CLI.**
5. **Walk through happy and sad paths.** What does the boundary failure log? What context does it need?
6. **Test that secrets are actually redacted.** Send a request with an `Authorization` header and grep the logs for the token. If it appears, the redaction is broken.

## Verification

```bash
bash skills/backend/forge-logging/verify/check_logging.sh path/to/file.ts
```

Flags: `console.log`/`console.info`/etc in service code; templated log messages with `${}`; secret-shaped substrings in log calls.

## When to skip this skill

- CLI tools where stdout IS the product.
- One-off scripts.
- Pure library code (let the caller bring the logger).

## Related skills

- [`forge-error-handling`](../forge-error-handling/SKILL.md) - what to log, log-once-at-boundary discipline.
- [`forge-secrets`](../../security/forge-secrets/SKILL.md) - redaction list, what counts as a secret.
- [`forge-api-design`](../forge-api-design/SKILL.md) - `request_id` correlation across response + log.
