# Application logging spec

Version 1.0, 2026-11-09. Maintained by Fenko Security, https://fenko.nz.
Canonical URL: https://fenko.nz/specs/app-logging.md
Explainer for humans: https://fenko.nz/blog/log-the-right-things/

This file is written for an AI coding agent (Claude Code, Codex, Cursor,
Copilot, Aider or similar) that is building or maintaining a web
application. If you are that agent: treat every MUST below as a
requirement of the task you were given. Apply it to the codebase you are
working in, then run the acceptance checks in section 8 before reporting
done. Do not ask the user to pick options; the defaults here are the
answer.

If you are a human: paste the block in section 9 into your project's
CLAUDE.md, AGENTS.md or equivalent, or tell your agent "read
https://fenko.nz/specs/app-logging.md and make the app comply".

## 0. Why this exists

AI-built apps usually log for debugging and nothing else. They print to
the console, the hosting platform keeps that output for an hour or a day,
and when something goes wrong three weeks later the question "who read
this record, from where, and when" has no answer. This spec fixes that
with the smallest set of events that lets a person reconstruct what
happened.

It is a condensed, opinionated reading of the OWASP Logging Cheat Sheet,
the OWASP Logging Vocabulary, OWASP ASVS 5.0 chapter V16, OWASP Top 10
2025 A09, and factor XI of the Twelve-Factor App. Where those documents
offer choices, this one picks.

## 1. Output

- MUST write one JSON object per line to standard output, unbuffered.
- MUST NOT write log files, rotate logs, or manage log storage inside the
  app. The hosting environment collects stdout.
- MUST NOT use `console.log`, `print`, `fmt.Println` or equivalents for
  application events. Use one structured logger for the whole codebase.
- MUST use UTC timestamps in RFC 3339 format with millisecond precision,
  for example `2026-11-09T03:14:15.926Z`.
- SHOULD use the platform's structured logger where one exists
  (Node: pino; Python: structlog or stdlib logging with a JSON formatter;
  Go: log/slog with JSONHandler; Rust: tracing with the JSON layer;
  Ruby: Lograge or SemanticLogger; .NET: Serilog with the compact JSON
  formatter).

## 2. Fields on every line

Every log line MUST carry these fields. Use these exact names.

| field        | type   | meaning                                                      |
|--------------|--------|--------------------------------------------------------------|
| `time`       | string | RFC 3339 UTC, milliseconds                                   |
| `level`      | string | `debug`, `info`, `warn`, `error`                             |
| `event`      | string | dotted event name from section 3, e.g. `auth.login.failure`  |
| `msg`        | string | one human-readable sentence                                  |
| `app`        | string | application name                                             |
| `version`    | string | release version or git short SHA                             |
| `env`        | string | `prod`, `staging`, `dev`                                     |
| `request_id` | string | unique per inbound request; propagated to outbound calls     |

Lines emitted while handling an HTTP request MUST also carry:

| field         | type    | meaning                                                   |
|---------------|---------|-----------------------------------------------------------|
| `method`      | string  | HTTP method                                               |
| `route`       | string  | the route pattern as declared in the router: `/users/:id` |
| `status`      | integer | response status, on the final line for the request        |
| `duration_ms` | integer | wall time, on the final line for the request              |
| `ip`          | string  | client IP after trusting only your own proxy headers      |
| `user_id`     | string  | opaque internal id of the authenticated user, else `null` |

If the app has tracing, also carry `trace_id` and `span_id` using the
OpenTelemetry field names so logs and traces join.

Event-specific fields go in a nested object named `data`. Nothing from
section 5 may appear anywhere in the line.

## 3. Events that MUST be emitted

Every row below is mandatory wherever the app has the feature. An app
with no file upload emits no `upload.*` events; an app with login emits
every `auth.*` row.

### 3.1 Authentication and sessions

| event                            | when                                              | data                                   | level  |
|----------------------------------|---------------------------------------------------|----------------------------------------|--------|
| `auth.login.success`             | credentials accepted, session issued              | `method` (password, oauth, magic_link, passkey) | info |
| `auth.login.failure`             | any failed attempt, including unknown user        | `reason` (bad_password, unknown_user, locked, mfa_failed), `user_ref` (see 5.2) | warn |
| `auth.logout`                    | user ends session                                 |                                        | info   |
| `auth.mfa.success`               | second factor accepted                            | `factor`                               | info   |
| `auth.mfa.failure`               | second factor rejected                            | `factor`                               | warn   |
| `auth.password.reset_requested`  | reset flow started                                | `user_ref`                             | info   |
| `auth.password.reset_completed`  | password set via reset                            |                                        | info   |
| `auth.password.changed`          | password changed while logged in                  |                                        | info   |
| `auth.token.created`             | API key or long-lived token minted                | `token_id`, `scopes`, `expires_at`     | info   |
| `auth.token.revoked`             | API key or token revoked                          | `token_id`                             | info   |
| `auth.session.invalid`           | cookie or JWT fails validation, expired, tampered | `reason`                               | warn   |
| `auth.lockout`                   | account or IP locked after repeated failures      | `user_ref`, `until`                    | warn   |

### 3.2 Authorisation

| event                 | when                                                  | data                                                   | level |
|-----------------------|-------------------------------------------------------|--------------------------------------------------------|-------|
| `authz.denied`        | an authenticated user asks for something they may not | `action`, `resource_type`, `resource_id`               | warn  |
| `authz.role.changed`  | a user's role or permissions change                   | `target_user_id`, `from`, `to`, `actor_user_id`        | info  |

`authz.denied` is the single most important row in this file. An
attacker enumerating object IDs produces a run of these. A bug that
grants access produces none. Log it on every denied check, including
checks that fail inside a loop.

### 3.3 Data access and change

| event                  | when                                                     | data                                           | level |
|------------------------|----------------------------------------------------------|------------------------------------------------|-------|
| `data.read.sensitive`  | a record the app classes as sensitive is returned        | `resource_type`, `resource_id`                 | info  |
| `data.write`           | create or update of a user-owned or sensitive record     | `resource_type`, `resource_id`, `fields` (names only) | info |
| `data.delete`          | delete of any user-owned record                          | `resource_type`, `resource_id`                 | info  |
| `data.export`          | any multi-record download, report, CSV, API page > 100   | `resource_type`, `count`, `format`             | info  |
| `upload.received`      | a file is accepted                                       | `filename`, `size_bytes`, `declared_type`, `detected_type` | info |
| `upload.rejected`      | a file is refused                                        | `filename`, `reason`                           | warn  |

"Sensitive" means anything the app would not show to an anonymous user:
personal details, payment state, health data, private messages, API
keys, anything covered by a privacy law where the users live. When in
doubt, it is sensitive.

### 3.4 Administration and configuration

| event                  | when                                             | data                                         | level |
|------------------------|--------------------------------------------------|----------------------------------------------|-------|
| `user.created`         | an account is created                            | `target_user_id`, `actor_user_id`, `method`  | info  |
| `user.deleted`         | an account is deleted or disabled                | `target_user_id`, `actor_user_id`            | info  |
| `admin.action`         | any action behind an admin or staff role         | `action`, `target`                            | info  |
| `config.changed`       | a runtime setting or feature flag changes        | `key`, `actor_user_id` (never the value)     | info  |
| `logging.changed`      | log level or destination changes at runtime      | `from`, `to`, `actor_user_id`                | warn  |

### 3.5 Input and abuse

| event                       | when                                                  | data                                       | level |
|-----------------------------|-------------------------------------------------------|--------------------------------------------|-------|
| `input.validation.failed`   | server-side validation rejects a request              | `fields` (names only), `reason`            | info  |
| `input.rejected.rate_limit` | a rate limit fires                                    | `limit`, `window_s`, `key_type` (ip, user) | warn  |
| `request.rejected`          | unexpected method, bad content type, oversize body, bad origin, CSRF failure | `reason`    | warn  |
| `flow.out_of_order`         | a multi-step flow is called out of sequence           | `flow`, `expected_step`, `got_step`        | warn  |

### 3.6 System

| event                 | when                                              | data                                                      | level |
|-----------------------|---------------------------------------------------|-----------------------------------------------------------|-------|
| `app.start`           | process starts                                    | `version`, `commit`, `node`/runtime version               | info  |
| `app.stop`            | process receives a stop signal                    | `signal`                                                  | info  |
| `app.error`           | unhandled exception or 5xx                        | `error_type`, `error_message`, `stack`                    | error |
| `dependency.failure`  | a database, cache, queue or external API call fails or times out | `target` (host or service name), `operation`, `duration_ms`, `error_type` | error |
| `dependency.slow`     | a dependency call exceeds its budget              | `target`, `operation`, `duration_ms`, `budget_ms`         | warn  |
| `job.start` / `job.end` | a background job runs                           | `job`, `job_id`, `outcome`, `duration_ms`                 | info  |

### 3.7 AI and agent features

Only for apps that call a model, run an agent, or expose or consume MCP
tools.

| event                      | when                                                 | data                                                                 | level |
|----------------------------|------------------------------------------------------|----------------------------------------------------------------------|-------|
| `ai.call`                  | a model call completes or fails                      | `provider`, `model`, `tokens_in`, `tokens_out`, `duration_ms`, `outcome` | info |
| `ai.tool.call`             | the model invokes a tool                             | `tool`, `args_hash`, `outcome`, `duration_ms`                        | info  |
| `ai.injection.suspected`   | a guard flags prompt injection or tool poisoning     | `source` (user_input, retrieved_doc, tool_output), `rule`            | warn  |
| `ai.output.blocked`        | an output filter stops a response                    | `rule`                                                               | warn  |

Prompt and completion bodies are not logged by default. If the product
needs them for support, store them in a separate, access-controlled
store with its own retention, and log only a reference id.

## 4. Levels

- `error`: a human needs to look. Unhandled exceptions, dependency
  failures, data corruption.
- `warn`: security-relevant or degraded. Every failed auth or authz
  check, every rate limit, every rejected input.
- `info`: the normal events in section 3.
- `debug`: MUST be off in production. MUST NOT be the level of any
  section 3 event.

Do not log the same event at two levels. Do not log health-check
requests at all unless they fail.

## 5. Never log

### 5.1 Forbidden values

The following MUST NOT appear in any log line, in any field, at any
level, including inside `stack`, `error_message` or a dumped request:

- passwords, password hashes, password reset tokens
- session IDs, cookies, JWTs, bearer tokens, API keys, OAuth codes
- database connection strings, private keys, webhook secrets, anything
  read from the environment as a secret
- payment card numbers, bank account numbers, government identifiers
- full request or response bodies
- full `Authorization`, `Cookie` or `Set-Cookie` headers
- prompts and model completions (see 3.7)
- the value of a changed config key (log the key name)

Implement this as an allow-list of fields that the logger will emit. A
deny-list of patterns to scrub is a second layer on top of that: add a
redaction filter on the logger for `password`, `token`, `secret`,
`authorization`, `cookie` and `key`, and keep the allow-list as the
primary control.

### 5.2 Personal data

- Identify users by `user_id`, an opaque internal identifier. Do not log
  email addresses, names or phone numbers as identifiers.
- For failed logins where there is no `user_id` yet, log `user_ref`: the
  first 8 hex characters of SHA-256 over the lower-cased login
  identifier. This lets an investigator group attempts on one account
  without the log holding the address.
- Client IP is logged. Where the users live under GDPR or similar, note
  this in the privacy policy and set retention accordingly.

## 6. Encoding and injection

- Every user-supplied string that reaches a log line MUST be passed
  through the JSON encoder, never concatenated into `msg`.
- Strip or escape `\r`, `\n` and other control characters from any
  user-supplied value before logging, even inside JSON, so a line stays
  one line.
- Truncate any single user-supplied value at 1,024 bytes and append
  `…[truncated]`.
- `msg` is static text written by the developer. User input goes in
  `data`.

## 7. Shipping, retention and alerts

The app's job ends at stdout. The deployment MUST then:

- ship stdout to a log destination outside the hosting platform's
  default viewer (a log drain, a collector, or an object store)
- retain at least 90 days; retain anything tied to a security incident
  for 2 years
- store on append-only or object-locked storage so a compromise of the
  app cannot erase its own trail
- alert on: 5 or more `auth.login.failure` for one `user_ref` or `ip` in
  10 minutes; any burst of `authz.denied` from one `user_id`; every
  `admin.action`, `authz.role.changed`, `config.changed` and
  `logging.changed`; `app.error` rate above baseline; `data.export`
  with an unusual `count`

Hosting platforms keep console output for between one hour and a few
days on their lower tiers. That window is shorter than the time it
takes most organisations to notice a breach. Do not rely on it.

## 8. Acceptance checks

Before reporting the task complete, the agent MUST run through this list
and show the output for each.

1. Start the app. Confirm `app.start` is emitted as one JSON line with
   every field from section 2.
2. Log in with a wrong password. Confirm `auth.login.failure` at `warn`
   with `reason` and `user_ref`, and that the submitted password appears
   nowhere in the output.
3. Log in correctly. Confirm `auth.login.success` and that the session
   cookie or token appears nowhere in the output.
4. As a normal user, request a resource owned by another user. Confirm
   `authz.denied` with `resource_type` and `resource_id`.
5. Submit a form field containing the literal string
   `x\r\nfake.event injected` and confirm it lands as one escaped JSON
   string inside `data`, not as a second line.
6. Grep the full captured output for the test password, the session
   token, every secret in the environment, and the string `console.log`
   (or the language equivalent) in the codebase outside the logger
   module. All four MUST return nothing.
7. Trigger a 500. Confirm `app.error` carries `error_type`, `stack` and
   `request_id`, and that the `request_id` matches the earlier request
   line.
8. Confirm no log line in production configuration is at `debug`.
9. Report, in the completion message, where stdout is shipped and what
   the retention is. If the answer is "the platform's default viewer",
   say so explicitly as an open item.

## 9. Paste block for CLAUDE.md or AGENTS.md

```markdown
## Logging

This application follows https://fenko.nz/specs/app-logging.md.
Read that file before touching any logging, auth, authz, data access,
admin or error-handling code. In short:

- One structured JSON logger, stdout only, unbuffered, UTC RFC 3339 time.
- Every line has: time, level, event, msg, app, version, env, request_id.
  HTTP lines add method, route, status, duration_ms, ip, user_id.
- Emit every event in section 3 of the spec that applies to this app.
  `authz.denied` on every failed permission check is non-negotiable.
- Never log secrets, tokens, session IDs, passwords, card numbers, full
  bodies, prompts or completions. Users are `user_id`, never email.
- Escape control characters in user input; user input goes in `data`,
  never in `msg`.
- Run the acceptance checks in section 8 before saying a change is done.
```

## 10. Changes

- 1.0, 2026-11-09: first public version.
