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
[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:
{"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: it identifies one fire of one job, including the attempt, which is exactly what you want when tracing a duplicate delivery.
Useful queries
# 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.