Skip to content

Logs, metrics and notifications

Paduraksa writes two log files, counts every request, shows traffic in the dashboard and can tell you when something needs attention.

Where What
Overview Live traffic charts, the hosts and upstreams that need attention, the latest changes
Security overview (Security) What protects each host and how much work each protection has done: see Access control and WAF
Analytics (Insight) Visitors, content, clients and bots
Logs, Live tail (Insight) The log files, and requests as they end
/metrics Prometheus
Notifications (Settings) Alerts to ntfy, Gotify, Discord, a webhook or email
Audit (Insight) and History Who changed what, and every revision

Logs

The process writes two plaintext logs into /data/logs/: error.log (its own entries, Warning and above by default) and access.log (one line per request, per host). Both rotate by size and by UTC day; a rotated file is named after the moment (error-20261109T120000Z.log), gzip-compressed in the background (....log.gz), and trimmed by count, age and total size. A request never waits for a disk, and a log write that fails is retried and reported to the console, never to the error log itself. The directory is created 0700 and the files 0600, because the access log holds client IPs. It appears with the first log line, so a fresh install that has logged nothing has no logs/ directory.

Browsing the logs

Browse, tail and download the files from Logs in the dashboard (Insight), or through GET /api/v1/logs, GET /api/v1/logs/{name}/tail and GET /api/v1/logs/{name}. All of it needs logs:read, which Owner, Admin and Auditor hold (and a token can be scoped to it). Reading a log is not an audited event, like every other read. A tail is bounded: at most 1000 lines (200 unless asked otherwise), read from the last 1 MiB of a plain file or, for a compressed one, from what 8 Mi characters of decompression yields, with each line cut at 4096 characters; downloading gives the whole file.

Requests as they end are in Live tail (Insight, logs:read), and GET /api/v1/logs/tail streams them as Server-Sent Events (curl -N -H "Authorization: Bearer ..." "https://.../api/v1/logs/tail?statusClass=5xx&backlog=50"). The tail shows what access.log would, redacted and capped the same way, and a host with its access log off is in neither. It keeps the newest Paduraksa__AccessLog__TailSize requests in memory; see the API description for the filters and the events.

The first-run setup token's banner is logged at Warning, so on a fresh instance it is also written to error.log (the one exception documented in PLAN section 2). Until an Owner exists it can therefore be found in the logs, rotated .gz files included; it is single-use and worthless once an Owner exists, and the process deletes the token file when setup completes.

The rotation size and the three retention limits are server settings: Settings, Server changes them live, and a value set here wins over the dashboard's. The directory and the error level stay here.

Setting Default What
Paduraksa__Logs__Directory empty (/data/logs) Where the logs live; a relative path resolves against the data directory
Paduraksa__Logs__ErrorLevel Warning Lowest level error.log keeps (Information, Warning, Error, Critical); independent of Logging__LogLevel, which is the console's
Paduraksa__Logs__MaxFileSize 67108864 (64 MiB) Rotate when the next line would pass this (1 KiB to 16 GiB)
Paduraksa__Logs__RetainedFiles 30 Rotated files kept per log, newest first (1 to 10 000)
Paduraksa__Logs__MaxAge 30.00:00:00 Delete rotated files older than this (1 minute to 10 years)
Paduraksa__Logs__MaxTotalSize 1073741824 (1 GiB) Delete the oldest rotated files until each log's rotated files total at most this (1 MiB to 1 TiB)

The logs are inside the data volume, so a backup of it carries them, and they are not part of the proxy's state: deleting them loses history, nothing else. If you would rather keep them elsewhere (a bigger disk), mount that directory too and point Paduraksa__Logs__Directory at it; it must be writable by uid 10001.

Access log and request IDs

Every request gets one line in the access log, whatever answered it (a forwarded request, a 403, the HTTPS redirect, the 404 for an unknown name). A request carries an X-Request-Id: Paduraksa generates one, or keeps the one a trusted proxy sent, and sends it upstream and back to the client depending on the host (Settings → a host, a profile or the global defaults → Request ID). A client that isn't a trusted proxy can't choose its own ID.

The hot path only writes a struct into a fixed-size ring; a background writer formats the lines and appends them to access.log (the log above: it rotates, compresses and is trimmed like error.log, and the dashboard's Logs page can tail it), so a slow disk never stalls a request. A full ring drops lines and counts them, reported at Warning in the process log.

Setting Default What
Paduraksa__AccessLog__RingSize 16384 Slots between the request path and the writer: a power of two from 1024 to 1048576 (about 3 MiB at the default: each slot is padded to three cache lines)
Paduraksa__AccessLog__TailSize 4096 Entries the dashboard's Live tail and GET /api/v1/logs/tail keep in memory: from 64 to 65536. They are redacted and capped like the file's lines. An entry is typically a few hundred bytes to a KiB (2 to 4 MiB for a full default buffer) and at most about 8 KiB with every field at its cap, so the worst case is about 32 MiB at the default and about 512 MiB at 65536: size it for that if clients you don't trust can reach the proxy. The buffer is not on disk, so a restart empties it.
Paduraksa__AccessLog__LogQueryString true Write the query string. false logs the path only.
Paduraksa__AccessLog__SensitiveQueryKeys the well-known names Query parameters whose value becomes name=REDACTED: token, access_token, refresh_token, id_token, code, password, passwd, pwd, secret, client_secret, key, api_key, apikey, signature, sig, auth, session (ignoring case). Setting it replaces that list.

Cookies and Authorization never reach the log, and a value that would break a line (a CR or LF in a path or user agent) is escaped. One host can turn its access log off (Settings → the host → Access log → Off); its requests still get a request ID, and they appear in neither the file nor the live tail.

Paduraksa__AccessLog__RingSize=65536
Paduraksa__AccessLog__LogQueryString=false
Paduraksa__AccessLog__SensitiveQueryKeys=token,api_key,pin

The line. One request per line, fields separated by one space, text fields in double quotes (\", \\ and \xNN escapes inside), and - for a value that isn't there:

2026-01-31T12:34:56.789Z 198.51.100.7 "app.example" "GET /a/b?token=REDACTED HTTP/2" 200 512 1.234 0.987 "http://10.0.0.2:8080/" "0k3m9x2p-00000000000abc" "8f2a1c3d4e5f6789" "https://example.com/" "Mozilla/5.0" 2.101 1 0.000 0.012 0.000 0.105 0.987
# Field What
1 time When the request ended, UTC, to the millisecond
2 client The client's address (behind a trusted proxy, the one its header carried)
3 host The Host as received
4 request Method, path with its query (sensitive values redacted) and protocol
5 status What the client got; - when the connection was dropped without an answer
6 bytes Response body bytes sent
7 total The whole request, in milliseconds with three decimals
8 upstream The forward to the upstream (connect, send, wait for its headers); - when Paduraksa answered itself
9 upstream address The destination the request went to
10 request ID X-Request-Id
11 CF-Ray Only from a trusted Cloudflare edge or tunnel
12 referer
13 user agent
14 TLS The connection's TLS handshake, on its first request only; - on plain HTTP and on a connection's later requests
15 connection request Which request of its connection this is, from 1: above 1 on TLS, the handshake was reused
16 body From the first to the last request body byte read; 0.000 without a body
17 gate From the top of the request to the gate's hand-off (to the upstream, or to its own answer): access lists, rules, rate limits, auth, proof of work
18 connect The upstream connect (DNS, TCP, TLS) inside field 8; 0.000 on a reused connection, - when nothing was forwarded
19 send From the response's headers to its last byte handed to the client; a slow reader or a streamed answer shows here
20 wait Waiting for the upstream, as browsers' "Waiting for server response" (TTFB): from when the request was sent upstream (its body's last byte, or its headers without a body) to the upstream's response headers; 0.000 when the upstream answered before the body was all sent, - when nothing was forwarded

Durations are milliseconds. The stages overlap (a body is read while the forward runs), so they don't add up to the total. Fields 14 to 19 came after the first 13, and field 20 after them; a reader of the first fields reads them unchanged. Lines written before field 20 existed have 19 fields. The live tail showed a wait then too, but it was field 8 minus field 18, so it included the time the body took to go up; field 20 doesn't. The same figures are in the Live tail's entry as a waterfall, each stage drawn where it started, like a browser's network timing. The proxy's own work between stages (preparing the forward, preparing the response) and any time no stage covers get their own muted rows, so the rows account for the whole request. Hover, focus or tap a row for what happens in that stage.

Metrics

Every request is counted (by host, route, upstream and destination) and timed, whether or not its host writes an access log. The counters cost no allocation and no lock on the request path. The home page of the dashboard draws them live: the last hour from memory and from the rollups the server started from, a day, a week or a month from one-minute rollups kept in paduraksa.db.

Restarts and downtime. The one-hour view survives a restart: at startup the server reads the last hour of rollups, so the chart doesn't begin at the boot. Every rollup minute records how many of its seconds the server was running, and a minute with no record is a minute it wasn't, so a restart, a crash or a day offline is a shaded gap in the charts (a line is never drawn through it, and it isn't drawn as zero requests); a quiet hour with the server up is a line at zero. Rates are over the seconds the server was running, so the minute of a restart isn't diluted. A graceful stop (a docker stop, a SIGTERM) writes the minute in progress; a crash loses it and up to one write interval of finished minutes. Minutes stored by an earlier version had no records for idle time, so quiet stretches from before the upgrade also show as gaps.

Prometheus. The admin port serves the text format at /metrics (the same endpoint is /api/v1/metrics). It needs a bearer API token with the metrics:read scope, which every role holds, so create a token for a viewer and give it only that scope:

scrape_configs:
  - job_name: paduraksa
    scheme: https
    metrics_path: /metrics
    authorization:
      credentials_file: /etc/prometheus/paduraksa-token   # pdk_...
    tls_config:
      ca_file: /etc/prometheus/paduraksa-root.pem          # GET /api/v1/ca/root.pem
    static_configs:
      - targets: ["proxy.lan:9443"]

A series is labelled host (the host's first name) and host_id; upstreams, destinations and routes carry their typed IDs (upstream_id, destination_id, route_id), so a rename doesn't break a series. Requests the proxy answered for a name no host has are the (unmatched) host. The families:

Family Labels What
paduraksa_requests_total host, status_class Requests answered by the class of status (1xx to 5xx, none for a dropped connection)
paduraksa_gate_outcomes_total host, outcome What the gate did: forwarded, answered, refused_access_list, refused_rule_set, refused_rate_limit, refused_proof_of_work, refused_forward_auth, refused_request_limits, refused_connection, no_route, unavailable, dropped, unknown_host, refused_other
paduraksa_response_size_bytes_total, paduraksa_request_size_bytes_total host Body bytes sent to clients and read from them, counted as they passed (a chunked or streamed body counts, an upstream's gzip counts compressed, a WebSocket's frames count when it closes)
paduraksa_request_duration_seconds host Histogram of the whole request
paduraksa_host_upstream_duration_seconds host Histogram of the upstream's share; the difference is the proxy's own time
paduraksa_tls_handshake_duration_seconds host Histogram of client TLS handshakes (TCP and QUIC), each counted once, against the host of its connection's first request
paduraksa_response_send_duration_seconds host Histogram of the time from a response's headers to its last byte written: slow readers and streamed answers
paduraksa_route_* host, route_id, path The same counters per route
paduraksa_upstream_requests_total, _errors_total (kind), paduraksa_upstream_duration_seconds upstream_id Forwards, YARP's failures by kind, and upstream time
paduraksa_upstream_response_size_bytes_total, _request_size_bytes_total upstream_id Body bytes of the requests forwarded to the upstream, as the client sent and got them
paduraksa_destination_requests_total, _errors_total upstream_id, destination_id Per destination
paduraksa_rate_limit_decisions_total, paduraksa_proof_of_work_events_total, paduraksa_access_list_events_total result / event The process totals the Security overview shows
paduraksa_connection_errors_total kind peer_gone: Kestrel errors from a client that vanished mid-write (a dropped phone or a sleeping laptop). They are logged at Debug as one line instead of filling error.log with stacks; a sudden rise is worth a look
paduraksa_config_generation, paduraksa_hosts The live snapshot

Histogram buckets end at 2^e - 1 microseconds for e from 4 to 26 (le="0.000015" to le="67.108863"): an observation of exactly 16 microseconds is in le="0.000031", not below.

OpenTelemetry. The same counters are System.Diagnostics.Metrics instruments on the meter Paduraksa (paduraksa.requests, paduraksa.gate.outcomes, paduraksa.response.size, paduraksa.request.size, paduraksa.upstream.errors, ...), read only when a listener asks. A latency is a sum in seconds (paduraksa.request.duration.sum, and paduraksa.request.upstream.duration.sum for the upstream's share of it); the buckets are the Prometheus endpoint's.

The retention is a server setting (Settings, Server, live); the write interval stays here.

Setting Default What
Paduraksa__Metrics__Retention 90.00:00:00 How long a one-minute rollup is kept (1 hour to 5 years); older rows are deleted hourly
Paduraksa__Metrics__WriteInterval 00:00:15 How often finished minutes are written to the database (1 second to 10 minutes)

Counters start from zero at every start, as Prometheus expects.

The overview API. GET /api/v1/metrics/overview?range=hour|day|week|month (metrics:read) returns the chart as series: every step from the first one the server was running for to the last, oldest first. Each step has start, widthSeconds (the hour's steps from before the last start are a minute wide, the rest ten seconds; a longer range's steps are all bucketWidth), coveredSeconds and the rates over those seconds. A step with coveredSeconds 0 is a time the server wasn't running (all its figures are 0): draw a gap, not a zero. A step with coverage and requestsPerSecond 0 was quiet.

Analytics

The dashboard's Analytics page (Insight) and GET /api/v1/analytics show visitors, content and clients: unique visitors, the top paths, referers and user agents, the slowest paths and the largest responses, browsers, systems and devices, bots by kind, and the bots that ignore a host's robots.txt. They are computed by the same background writer that writes access.log, never on the request path, so a host with its access log off is in neither: turn it off for a host and its traffic is not recorded anywhere.

Under them, the Security section shows what the gate refused over the same range, by reason and over time, and the counters of proof of work, rate limits, access lists, the connection stage and sign-in (see the security overview). Those come from the metrics, not the access log, so they count every host whatever its access-log setting.

Nothing in them is a client address: an address is hashed with a random salt that lives in memory only, goes into a fixed-size counting sketch (HyperLogLog) and is gone, so what is stored can't be turned back into an address, even by someone who holds the database. The price of a salt that dies with the process is that a visitor active across a restart is counted once on each side of it. Query strings are never counted (paths are counted without them), a referer is reduced to its scheme and host, and the user agents are the raw strings the clients sent. Visitors and the tables are approximate by design (the page says so): about 1.6 % for visitors, and every top-table figure has a documented bound (observability.md, Overview).

Closed five-minute buckets are written to the config database every 15 seconds; an hour that has passed is rolled up into one row per host for the day and week ranges, and a day that has passed into one row per host for the month. The five-minute rows are kept two days once rolled up, the hourly rows for the retention.

The retention is a server setting (Settings, Server, live); the write interval stays here.

Setting Default What
Paduraksa__Analytics__Retention 90.00:00:00 How long an hour of analytics is kept (1 hour to 5 years); older rows are deleted hourly
Paduraksa__Analytics__WriteInterval 00:00:15 How often closed buckets are written to the database (1 second to 10 minutes)

A graceful stop writes the bucket in progress; a crash loses it and whatever was not yet written, and the time after it shows as a gap on the charts, not as zeros.

Notifications

The channels (ntfy, Gotify, Discord, a webhook, email) are added in the dashboard under Settings, Notifications; their secrets are in the vault. These five are server settings: set them under Settings, Server, or in the environment, which then wins:

Setting Default What
Paduraksa__Notifications__AdminUrl none The address the dashboard is reached at (https://admin.example.com: http or https, no login, query or fragment). Messages link to the page that shows what happened; empty sends no link, because the process cannot know which name or port you reach it by
Paduraksa__Notifications__HealthHold 00:00:10 How long a destination's new health state must hold before it is reported (1 second to 10 minutes), so one failed probe sends nothing
Paduraksa__Notifications__CoalesceWindow 00:00:03 How long a channel waits after a message for more of the same kind, so ten upstreams going down at once is one digest (0 to 1 minute)
Paduraksa__Notifications__MaxDeliveryAge 01:00:00 A delivery that has been waiting longer than this (its event is that old, at start or when the channel takes it) is marked expired instead of sent, so a long outage of the process or of the database does not end in a burst of stale messages (1 minute to 7 days)
Paduraksa__Notifications__HistoryRetention 14.00:00:00 How long a finished event and its deliveries stay in the notification history (1 hour to 5 years); older ones are deleted hourly

Delivery is durable and at least once. An event is written to the config database, with one delivery per subscribed channel, within about a quarter of a second of happening, and the first message to a quiet channel is sent at once (a burst behind it is digested); a restart, a crash or a channel that is down loses nothing but that quarter second (events still in memory when the process is killed). Retries (15 seconds, 1, 4 and 16 minutes after a transient failure) are kept in the database and carry on after a restart. A crash between the service accepting a message and Paduraksa recording it sends the message again at the next start, so a message can arrive twice. A webhook receiver drops the repeat by the event ID, which is eventId in the JSON body and the X-Paduraksa-Event-Id header (both signed with the rest when the channel has a signing key): the same ID on every resend of the same message, and a digest's own ID when several events go out as one message. The history is under Settings, Notification history, where a failed or expired delivery can be sent again; /metrics shows the queue depth and the deliveries sent, failed, expired and dropped by kind of channel.

A channel's secrets (a webhook URL, a token, an SMTP password) are stored in the vault, never shown again and never in the config, the audit log or a backup of the config alone.

Channels

Add them under Settings, Notifications. Adding one sends it a test message at once. Needs providers:write.

Kind How it sends
ntfy A JSON publish to the server's root, with an optional bearer token
Gotify POST /message, with the token in X-Gotify-Key
Discord An embed, with mentions off
Webhook JSON. With a signing key it sends X-Paduraksa-Timestamp and X-Paduraksa-Signature: sha256=hex(HMAC-SHA-256("<timestamp>." + body))
Email SMTP. STARTTLS can be required, so a server that does not offer it is refused and never sent the password. A login without TLS is refused

A channel's URL or SMTP host may be any address, including loopback and the admin port, because only someone with providers:write can set it. Redirects are refused and responses are never shown, only fixed lines such as "The server refused the credentials (HTTP 401)."

Events

Each channel picks the events it wants:

  • Upstream down and up, and destination down and up. A state must hold for HealthHold (10 seconds) before it is reported, so one failed probe sends nothing. New channels start without the per-destination pair, which a one-destination upstream says twice.
  • Certificate expiry, for every certificate whatever its source: 30, 14, 7 and 1 days before. It is sent once per certificate and threshold, and a renewal starts over.
  • ACME failure and recovery: the first failure of a streak, and the recovery after it.
  • Sign-in anomalies: an account locked by failed sign-ins, and an Owner or Admin signing in from an address with no completed sign-in as that user in the last 30 days.

History

Notification history (Settings) lists every event and delivery. A failed or expired delivery can be sent again. /metrics shows the queue depth and the deliveries sent, failed, expired and dropped, by kind of channel.