Skip to content

Structured logging

Posthorn emits structured JSON logs for every operationally meaningful event. Each log line carries a UUIDv4 submission ID that threads through the lifecycle of a single submission, so you can grep one ID to see every decision point for that request.

Default format is JSON. Every log entry is a single line of JSON with these standard fields:

{
"time": "2026-05-14T20:01:23.456Z",
"level": "INFO",
"msg": "submission_sent",
"submission_id": "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f",
"endpoint": "/api/contact",
"transport": "postmark",
"latency_ms": 312
}
FieldAlways presentDescription
timeyesISO 8601 timestamp, UTC, millisecond precision
levelyesINFO, WARN, ERROR (Posthorn does not currently emit DEBUG)
msgyesEvent name (see event catalog below)
submission_idHTTP request-scoped eventsUUIDv4 generated at request receipt
session_idSMTP session-scoped eventsUUIDv4 generated when the SMTP connection is accepted
endpointHTTP request-scoped eventsPath from config
transportHTTP request-scoped events"postmark", "resend", etc.
latency_msterminal eventsTotal duration in milliseconds

Every HTTP request-pipeline event Posthorn emits. See the log format reference for full field schemas per event.

EventLevelWhen
submission_receivedINFOAll defenses passed; request entering the send pipeline
auth_failedINFOAPI-mode Authorization: Bearer missing or unknown key — 401
auth_rate_limitedINFOPer-IP failed-auth budget exhausted (brute-force defense) — 429
idempotent_replayINFOAPI-mode Idempotency-Key matched a cached prior response — replayed verbatim
idempotent_conflictINFOAPI-mode Idempotency-Key matches an in-flight request — 409
spam_blockedINFOHoneypot fired or Origin/Referer rejected — 403 (origin) or silent 200 (honeypot)
body_too_largeINFORequest body exceeded the cap — 413
rate_limitedINFOToken bucket empty — 429
csrf_rejectedINFOCross-Site Request Forgery (CSRF) token missing, expired, or invalid signature — 403 (form-mode only)
validation_failedINFORequired field missing or email malformed — 422
template_render_failedERRORSubject or body template errored — 500
submission_dry_runINFOEndpoint dry_run = true short-circuited — 200 with prepared message
send_retry_scheduledINFOTransport returned 5xx/transient or 429; waiting delay before retry
send_retry_succeededINFOThe retry returned success
send_retry_failedINFOThe retry also failed; terminal
submission_sentINFOTransport accepted the message — 200, includes transport_message_id
submission_failedERRORTerminal failure (no retry, or retry exhausted) — 502

SMTP-ingress events (when [smtp_listener] is configured):

EventLevelWhen
smtp_session_openINFOTCP connection accepted
smtp_tls_establishedINFOAfter successful STARTTLS
smtp_tls_handshake_failedINFOSTARTTLS error
smtp_auth_ok / smtp_auth_failedINFOAUTH PLAIN result (password never logged)
smtp_sender_rejectedINFOMAIL FROM: not in allowed_senders
smtp_recipient_rejectedINFORCPT TO: exceeded session cap or recipient block
smtp_submission_sentINFOOutbound transport accepted
smtp_submission_failedERROROutbound transport rejected
smtp_session_closeINFOSession ended (reason: "quit" for clean QUIT)

Process-lifecycle events (one-time, not per-request):

EventLevelWhen
posthorn startingINFOProcess starting; carries version, listen, config, endpoints count
endpoint registeredINFOPer-endpoint at startup; carries path, transport, recipients count
smtp_listener registeredINFOWhen [smtp_listener] block is configured
http ingress listening / smtp ingress listeningINFOPer-ingress when accept loop starts
shutdown signal receivedINFOSIGTERM/SIGINT received; 15s drain begins
second signal received, forcing exitWARNA second signal during drain; process exits immediately
posthorn stoppedINFOAfter all ingresses stopped

When log_failed_submissions = true (the default), terminal failures log the full form payload at ERROR level under the form field:

{
"time": "2026-05-16T20:01:23.456Z",
"level": "ERROR",
"msg": "submission_failed",
"submission_id": "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f",
"endpoint": "/api/contact",
"transport": "postmark",
"error": "postmark: 422 The 'From' address is not a valid Sender Signature.",
"form": {
"name": "Alice Smith",
"email": "alice@example.com",
"message": "I would like to discuss..."
},
"latency_ms": 287
}

The rationale: when a submission has failed terminally, the operator’s primary recovery path is reading the form contents out of the logs and replying manually. Without the payload, the data is gone.

The honeypot field’s value is redacted to "<redacted>" in the form map even when present — spam payloads don’t immortalize in operator logs.

For GDPR-sensitive contexts where you can’t log form contents, flip the switch per endpoint:

[[endpoints]]
path = "/api/contact"
log_failed_submissions = true # log full form payloads here
[[endpoints]]
path = "/api/medical-intake"
log_failed_submissions = false # log only field NAMES here

When log_failed_submissions = false, the metadata still logs (event, error, latency, IDs) and form_fields lists just the key names (no values).

LevelUse
INFOStandard events: submissions received, sent, blocked, rate-limited, retries
WARNOne specific event — a second SIGTERM/SIGINT during graceful shutdown
ERRORtemplate_render_failed, submission_failed, smtp_submission_failed

Posthorn does not currently emit DEBUG. Configure the global minimum:

[logging]
level = "info"
[logging]
format = "json"

"json" is the only supported value (the config loader rejects anything else). Posthorn writes to stdout; structure-parsing pipelines (Loki, Elasticsearch, Datadog, CloudWatch, journald with a JSON parser) ingest the lines directly.

By design:

  • API keys. Configured transport API keys, api-mode api_keys, csrf_secret, and SMTP smtp_users passwords — never logged in any code path. Tests verify by triggering failures with sentinel-key strings and asserting the string does not appear in captured log output.
  • Honeypot field values. Redacted to "<redacted>" if present in a submission_failed form payload.
  • Submitter content in /metrics labels. The Prometheus exposition keeps cardinality bounded — labels carry only operator-configured names (endpoint paths, transport types, error class enum), never submitter content.

See API keys for the full guarantee.

The submission_id is returned to the client in the 200 JSON response:

{ "status": "ok", "submission_id": "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f" }

If the client wants to surface that ID to the end user (an “Your message has been received, ID: 7f2c84d6…” confirmation), they can. Then a support request that quotes the ID maps directly to log lines in Posthorn.

The submission_sent event also carries transport_message_id — the upstream provider’s identifier (Postmark’s MessageID, Resend’s id, SES’s Message ID, etc.) — so you can pivot from Posthorn logs straight to the provider’s UI for any submission.