Skip to content
roxy

Flow log and capture

Flow log

The flow log is one JSON object per line, written to stdout or to log.flow.path. Every exchange produces a request event:

{"ts":"2026-10-03T10:12:00.123Z","event":"request","flow":"01J9…","conn":"01J9…",
 "listener":"proxy","client":{"ip":"10.0.0.7","peer":{"ip":"10.0.0.7","port":51234}},
 "tls":{"sni":"api.github.com","alpn":"h2","version":"1.3"},
 "req":{"method":"POST","host":"api.github.com","port":443,"path":"/repos/x/y/issues",
        "query":null,"headers_bytes":812,"body_bytes":1032,
        "body_sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
        "content_type":"application/json"},
 "res":{"status":201,"headers_bytes":1420,"body_bytes":5120,
        "body_sha256":"60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752"},
 "decision":"allow","rules":["github-writes"],"tags":["billing"],
 "mutations":["set_header:authorization"],"addons":["redact"],
 "timing":{"total_ms":412,"upstream_connect_ms":38,"upstream_ttfb_ms":350},
 "terminal_rule":"github-writes","stage":"head"}
fieldmeaning
client.ipthe client, as the rules see it
client.peerthe TCP peer the request came from. It differs from client.ip behind a load balancer (HTTP)
req, resthe request as the client sent it, and the response it got
body_bytesbody bytes forwarded in each direction
body_sha256a SHA-256 of the body, only when a rule's digest action asked for it (actions)
timing.upstream_connect_mstime to open the upstream connection; null if an existing one was reused
timing.upstream_ttfb_mstime from forwarding the request to the response head
decisionallow, deny, or answered when an addon answered the request itself
rulesthe rules that matched
terminal_rulewhat decided: a rule id, or one of _default, _fail_closed, _address_policy, _expired, _quarantined, _sign, _websocket or layer:<name>
stagehead, or where a rule stopped the exchange: request_body, response_head, response_body or websocket
addonsthe addons that ran, in order
reasonwhy the exchange was refused or failed, when it was

Common reasons, by terminal_rule:

terminal_rulereason
_fail_closedmetric_unavailable, metric_table_full, address_list_unavailable, secret_missing, secret_invalid, body_too_large_to_inspect, body_decode_failed, unsupported_content_encoding, buffer_budget_exhausted, missing_value, state_unavailable, sign_conflict, effect_invalid
_address_policyaddress_policy
_expiredpolicy_expired (lease)
_quarantinedquarantined (quarantine)
_signsign_body_too_large, sign_header_invalid (signing AWS requests)
_websocketws_bad_handshake (WebSockets)
layer:<name>layer_error
none, the upstream failedthe upstream_error reason (upstream)
none, the exchange did not finishclient_gone (the client left), client_stalled (it stopped reading), upstream_body_timeout (timeouts), aborted (roxy stopped)
none, an addon gave up part of the exchangelayer_aborted: it ended the request it was passing on or the response it was giving early, or stopped reading the response from upstream

Events

eventwhen
requestevery exchange
response_errorthe response could not be delivered after the request was allowed, for example because the client left or the upstream stalled
parse_errorthe client sent a malformed request; reason says how
upstream_errorthe upstream could not be reached (upstream)
upstream_deniedthe address floor refused an upstream address (address floor)
policy_input_unavailable, metric_table_fullan exchange was denied because a policy input was unavailable
policy_expiredthe policy's valid_until passed (lease)
quarantinedroxy was quarantined, or started quarantined (restored: true), with the incident id, since and cause (quarantine)
quarantine_releasedthe control plane lifted the quarantine (node mode)
upgrade_strippeda WebSocket upgrade was not allowed, so the request went upstream as plain HTTP
ws_open, ws_closea WebSocket opened or closed, with byte counts
ws_messagea WebSocket message a rule denied, or one sampled by log.flow.ws_message_every (WebSockets)
loga rule's log action
layer_erroran addon failed; kind says how (failure)
layer_recordan addon's flow.record (host services)
endpoint_callan addon called an endpoint
observer_laggedan observing addon fell too far behind and stopped getting a copy
connecta CONNECT, when log.flow.connection_events is on
connection_refuseda connection cap was reached (limits)
config_loaded, config_reloaded, config_reload_failedstartup and reload

Redaction

Secret values that roxy injects are removed from everything it logs. The values of authorization, proxy-authorization, cookie, set-cookie and x-api-key are never logged, and log.redact_headers adds more. Query values are redacted.

Writing

The flow log never drops a record while roxy runs (audit backpressure).

  • One thread writes each log file, in batches, so a record is written within a few milliseconds.
  • When more than log.flow.high_water (8 MiB) is waiting to be written, or the disk fails, roxy holds traffic until the log catches up.
  • With max_file_bytes, the file is rotated to <path>.<UTC timestamp>-<seq>, optionally gzipped, and files beyond max_files are deleted. SIGHUP reopens the file, for external rotation.
  • On shutdown roxy writes everything queued, retrying a failing destination for up to 10 seconds. Records still unwritten after that are lost.
  • roxy does not fsync, so a power loss can lose recent records.
log:
  redact_headers: []
  flow:
    path: /var/log/roxy/flow.jsonl  # absent = stdout
    connection_events: false
    ws_message_every: 0             # also log every Nth WebSocket message; 0 = denied only
    high_water: 8mb                 # at least 64kb
    max_file_bytes: 100mb           # absent = never rotate
    max_files: 10                   # absent = keep all
    compress: true

Rotation needs path, since stdout cannot be rotated.

Capture

Capture records whole requests and responses to <capture_dir>/capture.jsonl. It sits between the rules and the address floor. It records the request once the rules allow it, with their changes, and the response as it arrives from the upstream, before any addon changes it. If the address floor or the connection then fails, the capture holds the request head and an end with aborted: true, and no body. It records the exchanges a rule selects with capture: request | response | both, or every exchange with log.capture.all: true. It is written like the flow log, with the same batching, rotation and backpressure.

Secret values are removed from captured headers and URLs. Bodies are captured unredacted.

capture_dir: /var/lib/roxy/capture   # absent = capture off; restart to change
log:
  capture:
    all: false
    high_water: 64mb
    max_file_bytes: 1gb
    max_files: 20
    compress: true
limits:
  max_capture_body_bytes: 16mb       # per direction per exchange

Capture format

One JSON object per line:

{"flow":"01J9…","dir":"request","kind":"head","seq":0,"head":{"headers":[["content-type","application/json"]],"method":"POST","url":"https://api.example.com/v1/x"}}
{"flow":"01J9…","dir":"request","kind":"data","seq":1,"data":"aGVsbG8="}
{"flow":"01J9…","dir":"request","kind":"end","seq":2,"bytes":5}
fieldmeaning
flowthe exchange's id in the flow log
dirrequest or response
kindhead, data (body bytes, base64), truncated (max_capture_body_bytes reached; nothing more is recorded) or end (with the total bytes, and aborted: true if the body did not complete)
seqthe record's position for this flow and direction

A body is its data records joined in seq order. Each data record holds at most 48 KiB of body, so lines stay under 64 KiB plus a little JSON, apart from head lines, which can be up to limits.max_header_bytes.