Skip to content

Request Logging

Every completed HTTP request produces a structured RequestLog entry with 23 fields covering timing, identity, routing, security, and performance. Entries are serialized to JSON using a SIMD-accelerated serializer and written through a bounded async channel to one or more output destinations.

Optional fields are omitted from the JSON object when absent — no null noise in your log aggregator.


Add log to any site block to enable access logging to stdout with the default JSON format:

api.example.com {
reverse_proxy localhost:8080
log
}

That is all that is required. Every request to api.example.com will produce one JSON line on stdout.


The logging pipeline is asynchronous and non-blocking. The proxy thread never waits for I/O.

The batch writer runs as a Pingora BackgroundService. It drains the channel in batches of up to 200 entries, flushing either when the batch is full or every 500 ms — whichever comes first. On shutdown, any remaining entries in the batch are flushed before the writer exits.

Backpressure policy: when the channel is full (8 192 pending entries), new entries are dropped and a WARN log is emitted. Proxy latency always wins over logging completeness.


Every request produces the following fields. Fields marked optional are omitted from the JSON output when not present.

JSON keyTypeRequiredDescriptionExample
timestampstring (RFC 3339)yesWhen the request arrived, UTC"2026-04-05T14:23:01.452Z"
request_idstringyesUUID v7 unique to this request (time-sortable)"01924f5c-7e2a-7d00-b3f4-deadbeef1234"
methodstringyesHTTP method"GET"
pathstringyesRequest path, without query string"/api/users"
querystringoptionalRaw query string if present"page=1&limit=20"
hoststringyesHost header, or :authority for HTTP/2"api.example.com"
statusintegeryesHTTP response status code200
response_time_usintegeryesTotal time from request received to response sent, microseconds1234
client_ipstringyesClient IP address from the direct TCP connection — not X-Forwarded-For"192.168.1.100"
user_agentstringoptionalUser-Agent request header"Mozilla/5.0 ..."
refererstringoptionalReferer request header"https://example.com"
bytes_sentintegeryesResponse body size in bytes4096
bytes_receivedintegeryesRequest body size in bytes256
tls_versionstringoptionalTLS version negotiated; absent for plaintext"TLSv1.3"
http_versionstringyesHTTP protocol version"HTTP/2"
is_botbooleanyesWhether the request was classified as a botfalse
countrystringoptionalTwo-letter country code from GeoIP lookup"US"
upstream_addrstringyesBackend address that served this request"127.0.0.1:8080"
upstream_response_time_usintegeryesTime the upstream took to respond, microseconds980
cache_statusstringoptionalCache result if applicable"HIT" or "MISS"
compressionstringoptionalCompression algorithm applied to the response"gzip" or "br"
trace_idstringoptionalW3C trace ID for distributed tracing correlation"4bf92f3577b34da6a3ce929d0e0e4736"
upstream_error_bodystringoptionalFirst 1 KB of the upstream response body on 5xx errors; absent on successful responses"connection refused"

All timing fields use microseconds for sub-millisecond precision. Both response_time_us and upstream_response_time_us are always present; their difference accounts for Dwaar’s processing overhead.


The default. Writes one JSON object per line to stdout. Use this when a process supervisor, container runtime, or log shipper (Fluentd, Vector, Loki) consumes stdout.

log
# equivalent to:
log {
output stdout
}

Writes JSON lines to stderr instead of stdout. Useful when stdout carries application output and you want to route logs separately at the shell level.

log {
output stderr
}

Writes JSON lines to a file. Rotates when the file exceeds max_size. On rotation, the active file is renamed to access.log.1, any existing .1 is shifted to .2, and so on up to the keep limit. The oldest file beyond keep is deleted. Rotation uses atomic POSIX rename — no log lines are lost during the shift.

log {
output file /var/log/dwaar/access.log {
max_size 100mb
keep 5
}
}
OptionDefaultDescription
max_sizenone (unbounded)Rotate when the active file reaches this size. Accepts kb, mb, gb suffixes (case-insensitive) or a plain byte count.
keepnoneNumber of rotated files to retain (.1 through .N). Files beyond this count are deleted.

After a rotation cycle with keep 3, you will see:

access.log ← current, just started
access.log.1 ← previous rotation
access.log.2 ← one before that
access.log.3 ← oldest retained

Writes JSON lines to a SOCK_STREAM Unix domain socket. This is the preferred transport when a Permanu agent or any structured-log consumer (Vector, Fluent Bit) runs on the same host.

log {
output unix /run/dwaar/log.sock
}

Disconnect resilience: when the socket is unavailable, incoming log lines are buffered in memory up to 1 000 lines. On the next successful connection, buffered lines are flushed before new entries are written. If the buffer fills before the socket recovers, the oldest lines are evicted — proxy latency still wins. Reconnect attempts are rate-limited to once per second.


DirectiveContextDescription
logsite blockEnable access logging with defaults (stdout, JSON, INFO)
log { ... }site blockEnable logging with explicit options
skip_logsite block, route blockSuppress the access log entry for matching requests
log_append { ... }site blockInject additional fields into every log entry for this site
log_name <name>site blockAssign a named logger for per-site log routing
log {
output <destination> # stdout | stderr | discard | file <path> { ... } | unix <path>
format <format> # json (default) | console
level <level> # INFO (default) | DEBUG | WARN | ERROR
}
OptionValuesDefaultDescription
outputstdout, stderr, discard, file <path>, unix <path>stdoutWhere to send log entries
formatjson, consolejsonjson emits one JSON object per line; console emits a human-readable text line
levelDEBUG, INFO, WARN, ERRORINFOMinimum log level to emit

Use skip_log inside a route block to silence health-check endpoints or other high-frequency, low-value paths:

api.example.com {
reverse_proxy localhost:8080
log
route /health {
skip_log
respond "ok" 200
}
}

Use log_append to inject request-scoped fields into every log entry for a site. Values are evaluated as templates at request time.

app.example.com {
reverse_proxy localhost:3000
log
log_append {
tenant {http.request.header.X-Tenant-ID}
region eu-west-1
}
}

Each site block configures its own logger independently. Sites with no log directive produce no access logs. Sites with different outputs write to their respective destinations simultaneously.

api.example.com {
reverse_proxy localhost:8080
log {
output unix /run/dwaar/api.sock
level DEBUG
}
}
static.example.com {
file_server /var/www
log {
output file /var/log/dwaar/static.log {
max_size 50mb
keep 3
}
}
}
internal.example.com {
reverse_proxy localhost:9000
# No log directive — no access log for this site.
}

A realistic multi-site Dwaarfile with distinct log configurations per domain:

{
admin localhost:2019
}
# Public API — structured logs to a Unix socket consumed by Vector
api.example.com {
reverse_proxy localhost:8080 localhost:8081 {
lb_policy round_robin
health_uri /health
}
log {
output unix /run/dwaar/api-logs.sock
format json
level INFO
}
log_append {
service api-gateway
env production
}
route /health {
skip_log
respond "ok" 200
}
}
# Marketing site — file logs with rotation, human-readable format for tailing
www.example.com {
file_server /var/www/html
log {
output file /var/log/dwaar/www.log {
max_size 100mb
keep 7
}
format console
level INFO
}
}
# Admin panel — verbose debug logs to stdout, captured by journald
admin.example.com {
reverse_proxy localhost:4000
tls {
client_auth require
}
log {
output stdout
format json
level DEBUG
}
}
# Metrics scrape endpoint — no logging to avoid noise
metrics.internal {
metrics /metrics
skip_log
}

  • Analytics — aggregated traffic metrics built on top of request logs
  • Prometheus — per-route request counters and latency histograms
  • Tracing — distributed trace propagation and trace_id correlation