# Logging

cronpad writes structured logs to standard output, in JSON (default) or logfmt. Every line about a run automatically carries the job ID, run ID, occurrence ID, attempt, workspace and trace ID, however deep in the code it was written, so you can filter one job's or one run's entire story with a single query. Secrets, tokens and authorization headers are redacted.

## Format

```toml
[logging]
format = "logfmt"   # or "json"
level = "info"      # debug | info | warn | error
add_source = false  # add source=file.go:123
```

The same fields are emitted in both formats, so switching never breaks your queries.

**logfmt:**

```
time=2026-11-10T03:30:00.118Z level=INFO msg="run finished" service=cronpad version=1.0.0 job_id=job_01J9ZK3Q run_id=run_01J9ZM1A occ="job_01J9ZK3Q|1794281400000|0|3|s" attempt=0 trigger=s workspace_id=ws_01J9X sink=webhook outcome=success status_code=200 duration_ms=104 start_lag_ms=6 sla_breached=false trace_id=4bf92f3577b34da6a3ce929d0e0e4736
```

**json:**

```json
{"time":"2026-11-10T03:30:00.118Z","level":"INFO","msg":"run finished","service":"cronpad","version":"1.0.0","job_id":"job_01J9ZK3Q","run_id":"run_01J9ZM1A","occ":"job_01J9ZK3Q|1794281400000|0|3|s","attempt":0,"trigger":"s","workspace_id":"ws_01J9X","sink":"webhook","outcome":"success","status_code":200,"duration_ms":104,"start_lag_ms":6,"sla_breached":false,"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}
```

## Standard fields

| Context | Fields |
|---|---|
| Every line | `time`, `level`, `msg`, `service`, `version`; `trace_id` and `span_id` when inside a traced operation |
| Anything during a run | `job_id`, `run_id`, `occ`, `attempt`, `trigger`, `scheduled_at`, `workspace_id`, `sink` |
| Run finished | `outcome`, `status_code`, `duration_ms`, `start_lag_ms`, `sla_breached`, `error` |
| API requests | `request_id`, `method`, `route` (the route pattern, e.g. `/api/v1/jobs/{id}`), `status`, `duration_ms`, `workspace_id`, `actor` |

`occ` is the [occurrence ID](../concepts/scheduling-engine.md#occurrence-ids): it identifies one fire of one job, including the attempt, which is exactly what you want when tracing a duplicate delivery.

## Useful queries

```sh
# Everything that happened to one run (logfmt + grep)
grep 'run_id=run_01J9ZM1A' cronpad.log

# Loki: failed attempts for one job
{app="cronpad"} | logfmt | job_id="job_01J9ZK3Q" and outcome!="success"

# jq: slowest starts
jq -c 'select(.msg=="run finished" and .start_lag_ms > 1000)' cronpad.json.log
```

## What's logged at each level

| Level | Contents |
|---|---|
| `error` | Things an operator must act on: database or queue failures, panics recovered in workers, failed alert deliveries after all retries |
| `warn` | Degraded but handled: lease lost, queue entry repaired, queue data loss detected, dev login enabled, missing Litestream |
| `info` | One line per finished run, one per API request, startup/shutdown, janitor passes, job state changes |
| `debug` | Claim-loop iterations, retries scheduled, cache misses |

At high volume, `info` is about three lines per run. Set `level = "warn"` to log only problems; run history is still recorded in the database.

## Redaction

Values of fields named like `authorization`, `cookie`, `token`, `secret`, `password`, `api_key` or `signature` are replaced with `[REDACTED]`. Webhook and command configs are never logged whole; only a request summary (`POST https://api.example.com/reports (231 bytes)`). Secret values substituted from `{{secret.NAME}}` never appear in logs.

## Shipping logs elsewhere

Logs go to stdout for your container runtime or journald to collect. To also send them over OTLP to an OpenTelemetry Collector, set `telemetry.otlp_logs = true`; the trace and run fields become OTLP log attributes.