Skip to content

Reading the logs

Posthorn writes structured JSON logs to stdout. Nothing is written to disk by Posthorn itself — every deployment shape catches stdout differently, and your platform handles rotation, retention, and shipping.

This page is the operator-facing companion to the log format reference. If you want to know what Posthorn emits, read that. If you want to know where to find it and how to query it, you’re in the right place.

Terminal window
# Live tail
docker compose logs -f posthorn
# Last 100 lines
docker compose logs --tail 100 posthorn
# Since a wall-clock time
docker compose logs --since 1h posthorn
docker compose logs --since 2026-05-16T18:00:00 posthorn
# Time-stamped output (Docker prefixes each line)
docker compose logs --timestamps posthorn

Posthorn’s lines already carry their own ISO 8601 time field, so --timestamps is only useful when you want Docker’s received time alongside Posthorn’s emitted time.

jq is the simplest universal tool — works against any log source (file tail, docker logs, journalctl -o json, etc.).

Terminal window
# Pretty-print every line as it streams
docker compose logs -f posthorn | jq .
# Only errors
docker compose logs posthorn | jq 'select(.level == "ERROR")'
# Only submission_sent events
docker compose logs posthorn | jq 'select(.msg == "submission_sent")'
# Trace one request across every event
docker compose logs posthorn \
| jq 'select(.submission_id == "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f")'
# Latency P99 over the last 10K events
docker compose logs --tail 10000 posthorn \
| jq -s 'map(select(.msg=="submission_sent")) | sort_by(.latency_ms) | .[-10:] | .[].latency_ms'
# Top 10 client IPs by spam_blocked
docker compose logs posthorn \
| jq -r 'select(.msg=="spam_blocked") | .client_ip' \
| sort | uniq -c | sort -rn | head

Posthorn lines are valid JSON one-per-line (NDJSON), so any tool that speaks NDJSON — jq, gron, fx, ripgrep --null-data, even a small Go/Python script — works without ceremony.

A short catalog of the events you’ll actually grep for. Full schema for each is in the log format reference.

If you want to know…Look for msg=
Did this specific submission go out?submission_sent (with transport_message_id to pivot to the provider)
Why did this submission fail?submission_failed (error, form or form_fields)
Did the retry help?send_retry_scheduledsend_retry_succeeded (or send_retry_failed + submission_failed)
Is the rate limiter firing?rate_limited (with client_ip in form mode)
Is the honeypot catching bots?spam_blocked (with kind: "honeypot" or kind: "origin")
Are any callers misusing their API key?auth_failed (the rejected key is not logged — only that auth failed)
Is the idempotency cache doing its job?idempotent_replay (cached hit) vs idempotent_conflict (collision in flight)
Did the SMTP listener accept this session?smtp_session_opensmtp_tls_establishedsmtp_auth_oksmtp_submission_sent

Terminal failures (502 to the client) store the submission payload in the log line itself — there’s no persistent queue. The submission_failed event with log_failed_submissions = true (default) carries the full form map. To recover:

Terminal window
# Find the failed submission by ID (the API caller has this from the response body... unless the response was eaten)
docker compose logs posthorn \
| jq 'select(.msg=="submission_failed" and .submission_id=="<id>") | .form'
# Or find all failures in the last hour
docker compose logs --since 1h posthorn \
| jq 'select(.msg=="submission_failed") | {submission_id, endpoint, error, form}'

The form data is what would have been templated into the email. You can re-render the message manually or use the provider’s dashboard to send it directly.

docker compose logs and journalctl are fine for ad-hoc investigation; for production observability you want a real backend. Posthorn’s JSON output drops straight into all the common pipelines:

  • Loki + Promtail / Alloy — most natural fit; the json parser stage maps every Posthorn field to a Loki label-able value. Cardinality risk: only label on endpoint, transport, level, msgnot submission_id or client_ip.
  • Elasticsearch + Filebeat / Fluent Bit — same shape; use json codec in the input.
  • Datadog — set the agent’s source: posthorn and service: posthorn; the agent auto-parses JSON.
  • CloudWatch Logs — the Docker JSON-file driver’s lines arrive double-encoded (CW wraps each line in its own JSON envelope). Filter with { $.msg = "submission_sent" } against the inner payload.

A Loki query example for the endpoint label and rate of sends:

sum by (endpoint) (
rate({service="posthorn"} | json | msg="submission_sent" [5m])
)

For Prometheus scraping, don’t parse logs — Posthorn exposes a native /metrics endpoint on the same listener with the same counters. See Docker → Health checks for wiring it into a Docker healthcheck or Prometheus scrape config.

By design:

  • Credentials. Transport API keys, api-mode api_keys, csrf_secret, SMTP smtp_users passwords — never logged in any code path. Tests verify with sentinel-key strings on every CI build.
  • Honeypot field values. Redacted to "<redacted>" in submission_failed.form.
  • Submitter content in /metrics labels. The Prometheus surface is structurally bounded — labels carry only operator-configured names (endpoint paths, transport types, error class enum), never submitter content.

See API keys & secrets for the full guarantee and rotation runbook.