Gateway logging
apps/gateway/crates/obol-gateway/src/telemetry.rs installs a JSON tracing subscriber filtered by RUST_LOG, defaulting to info. Initialization is idempotent.
Every request produces exactly one access-log line on the target obol.access. A RequestLog is created by middleware, absorbs handler-supplied context, and emits on Drop — so a panicking or cancelled request still logs.
Distributed tracing
SetOTEL_EXPORTER_OTLP_ENDPOINT to a non-empty value and the gateway adds an OTLP span exporter over tonic with a batch processor, registered as the tracer obol-gateway. The value is trimmed, and an empty or whitespace-only string means “no collector” rather than an empty URI — Docker Compose always sets the variable, defaulting it to the empty string, so this distinction matters in practice.
Metrics
GET /metrics returns Prometheus text exposition (text/plain; version=0.0.4).
Two of these deserve standing alerts.
obol_meter_dropped_total counts usage events the gateway could not enqueue or emit — a full 4096-slot queue, or two consecutive emit failures. This is the one usage-loss mode that happens before the Redis transport, so control’s loss latch cannot see it (Metering). Any sustained increase means billing-grade data is being lost silently. The counter is reconciled from the meter’s internal count on each /metrics scrape, so scrape it regularly.
obol_snapshot_loaded dropping toward zero means the gateway is losing its compiled policy and workspace state. It is also the second readiness condition below.
Health endpoints
Gateway
The three readiness reasons are
redis unreachable, no policy snapshot loaded, and draining. /startupz reports only redis unreachable and draining; a zero-snapshot install is a successful start, not a failed one. This is ADR-0006 made operational: the gateway degrades gracefully from cache during a control or Postgres blip, but it refuses to declare itself ready for customer traffic without Redis and a snapshot.
Which probe a deploy target uses is the other half of that distinction:
/healthz, /readyz, /startupz, and /metrics are served on the main listener (OBOL_LISTEN_ADDR, default 0.0.0.0:8080). If OBOL_METRICS_ADDR is non-empty (default 0.0.0.0:9091), a second listener additionally serves /metrics and /healthz. A metrics listener that fails to bind logs a warning and the gateway continues serving.
The container image is distroless and has no curl, so the binary carries its own probe: obol-gateway healthcheck <url> exits 0 only on an HTTP 200 within two seconds. Compose uses it against /startupz.
Control
Control exposesGET /healthz, returning {"ok": true}. There is no separate readiness endpoint; schema readiness is handled by ordering instead — the Compose control and control-worker services wait for the one-shot migrate service (alembic upgrade head) to complete successfully before they start.
Control’s internal API is service-authenticated with a service JWT, not open telemetry, but its failure mode is operationally relevant:
GET /internal/v1/workspaces/{workspace_id}/snapshotreturns503 snapshot_unavailablewhen the committed workspace and policy pair cannot be rebuilt. Shedding is the point: answering200with a body the gateway cannot load would take the tenant dark, and the gateway keeps serving its pinned snapshot and retries.GET /internal/v1/keys/{key_hash}is the cache-miss path for key resolution.
snapshot_unavailable means gateways are pinned to increasingly stale snapshots — worth alerting on from control’s own logs.
Worker cadences
The arq worker (apps/control/app/workers.py) is where every off-request-path job runs. Knowing the cadences tells you how stale each read model can be:
The cadences are staggered on purpose, and
refresh_expiring_credentials is the one job with run_at_startup=False — a rolling restart must not stampede every vendor’s token endpoint at once. It is also unique=True with a deployment-shared jitter second, so two replicas schedule the same job id rather than double-refreshing a rotating refresh token.
If the worker is down, the gateway keeps serving. What stops is every read model: usage stops advancing, receipts stop appearing, credentials stop refreshing (and eventually expire), and snapshot publications stop retrying. Worker liveness is not optional for long.
Draining on deploy
apps/gateway/crates/obol-gateway/src/drain.rs runs on SIGTERM or Ctrl-C. The sequence, bounded by one total deadline:
1
Fail readiness first
readyz and startupz start answering 503 with reason draining, so the load balancer stops sending new traffic while the process is still serving what it has. /healthz stays 200.2
Stop accepting, finish in flight
The shutdown watch fires;
axum::serve stops accepting connections and awaits in-flight ones. Background loops wind down.3
Settle reservations
Meter::wait_for_reconciliations waits for every outstanding spend reconciliation, so a shutdown does not leave pessimistic reservations charged.4
Drain the meter queue
The meter’s shutdown signal fires last and the flusher drains whatever is still queued to
stream:usage before exiting.OBOL_DRAIN_TIMEOUT_S (default 30), a drain timed out warning is logged and every remaining task is aborted — which can drop queued usage events and increment obol_meter_dropped_total.
The default drain timeout (30s) is shorter than the default upstream timeout (
OBOL_UPSTREAM_TIMEOUT_S, 120s). A long-running streaming request in flight at shutdown can therefore be aborted by the drain deadline. Raise OBOL_DRAIN_TIMEOUT_S toward your upstream timeout if you want deploys to wait out the longest in-flight stream, and give your orchestrator a matching termination grace period.OBOL_IDEMPOTENCY_TTL_S must exceed the combined upstream and drain timeouts, and the process refuses to start otherwise.
Gateway
What the process these signals describe actually does.
Metering
Where dropped meter events and ingestion gaps come from.
Docker Compose
The environment variables named on this page, in context.