History explorer — what ran, why, what it touched
rflow already journals every run and step (see reliability). The history explorer turns that durable journal into an operational surface that answers the questions logs can't: which workflows ran in the last hour, which runs sent transactions and what was the final status, what is parked right now, who approved a send, what changed between a good run and a bad one, and can I export a redacted audit trail?
It ships as three surfaces over one query layer, so the CLI, the API and the UI never diverge and every one of them is secret-redacted by default:
- an embedded web UI at
GET /— the run-trace viewer evolved into a full history explorer (workflows overview, runs, run detail timeline, transactions, approvals, waiting, failures, reorgs, replay/test sessions), - a read-only JSON API under
GET /api/history/*— cursor-paginated, stable shapes, auth-gated, - a CLI —
rflow historyandrflow runs diff/rflow runs timeline, useful over SSH with--jsonfor automation.
The shared query layer
There is exactly one read-optimised query module —
rflow_core::history::HistoryReader — and both the CLI and the API call it. It
gives three properties to every result:
- Keyset (cursor) pagination, not just
limit. Pages key on(created_at, id)newest-first and hand back an opaquecursorplushas_more; passing that cursor to the next request continues exactly where the last one stopped — a row is never skipped or returned twice, even as new runs land between pages. - Secret redaction by default. Every rendered input, output, trigger
payload, tx summary, approval message and error is scrubbed of every declared
secret:, every secret-typed config field (signer creds, channel tokens, the server bearer,http_callheaders/hmac,command.env, …) and every${VAR}env value the raw config references, before it leaves the process. The marker is<redacted>. This is the same scrubber the archive exporter uses — one redaction discipline, everywhere. - Filterability. Runs filter by workflow, status, trigger kind, network, contract, relayer, tx hash, block, run id, step id, error kind, approval status, session (live / replay / test) and time range — backed by guarded, idempotent database indexes for the common ones.
The embedded UI — GET /
Open http://localhost:3940/ while rflow start is running. It is a single
dependency-free HTML/CSS/JS file embedded in the binary — no build step, no
external CDN, works offline. Views:
| View | Answers |
|---|---|
| Workflows overview | per workflow: pause state, trigger kind, last run / success / failure, success & failure counts, runs waiting, median / p95 duration, tx count, recent error kind, cursor / head lag |
| Runs history | the full filter/search set above, reflected in the URL hash so a filtered view is shareable |
| Run detail (timeline) | trigger summary, condition results, step timeline with redacted inputs/outputs, retries & attempts, simulation result, gas-cap checks, approval decisions, tx lifecycle, wait/delay parking, reorg linkage, replay/test marker |
| Transactions | every send rflow attempted or would have sent — network, relayer, status lifecycle, tx hash, idempotency key, explorer link where the network resolves |
| Approvals | pending + decided, who decided and why |
| Waiting | runs parked right now (tx / delay / event / approval), with due/expiry |
| Failures & dead-letters | failed and dead-lettered runs |
| Reorg responses | durable on_reorg responses and their steps |
| Replay/test sessions | isolated backtest/dry-run sessions and their run counts |
| Relayers (address book) | every relayer wallet: network, address + copy button, live balance with low-balance flag, funding-plan estimate/shortfall, recent gas spend — backed by GET /api/relayers; a row deep-links to that relayer's runs |
The UI is deliberately restrained (dense tables with durable column widths, copy
buttons on every hash/id/address, clear <redacted> markers, dark + light
themes, loading/error/empty states). Untrusted run data is never rendered as
HTML — event args, errors and HTTP bodies reach the DOM only as text, so a
value containing a <script> tag shows as text and never executes.
The JSON API — GET /api/history/*
Read-only, cursor-paginated, redacted, and gated by the same bearer as the rest
of /api/*. List endpoints return { "items": [...], "cursor": "<opaque|null>", "has_more": <bool> }; pass cursor back as a query param to page.
| Endpoint | Returns |
|---|---|
GET /api/history/runs | runs page (filters via query params: workflow, status, trigger, network, contract, relayer, tx_hash, block, run_id, step_id, error_kind, approval_status, session, since, until, limit, cursor) |
GET /api/history/runs/:id/timeline | one run's chronological timeline: { run, events: [...] } where each event is a trigger, step, approval or reorg_step |
GET /api/history/txs | send attempts (queued/parked/submitted/settled) |
GET /api/history/approvals | approvals (pending + decided) |
GET /api/history/waiting | runs parked right now |
GET /api/history/reorgs | durable on_reorg responses |
GET /api/history/replays | replay/test sessions |
GET /api/history/workflows | the per-workflow overview that powers the landing page |
GET /api/history/export | ?format=json → { runs, steps, sends, approvals, failures } (application/json); ?format=csv → a flat runs CSV (text/csv). Redacted; no raw-payload flag. |
# newest 50 runs of one workflow, then the next page
curl -s -H "Authorization: Bearer $RFLOW_API_TOKEN" \
'localhost:3940/api/history/runs?workflow=treasury-sweep&limit=50' | jq '.cursor'
curl -s -H "Authorization: Bearer $RFLOW_API_TOKEN" \
'localhost:3940/api/history/runs?workflow=treasury-sweep&limit=50&cursor=<cursor>' | jq '.items | length'Bad cursors / malformed run ids answer 400 {"error": ...}; an unknown run id
answers 404. Since the layer is read-only, since accepts a duration (24h,
7d) or an RFC3339 timestamp.
The CLI
Everything the UI shows is reachable over SSH without it — see the
rflow history and rflow runs
references:
rflow history --status failed --since 24h # what failed today
rflow history txs --since 7d # what money moved this week
rflow history waiting # what's parked right now
rflow history approvals --all # every decision, not just pending
rflow history export --workflow treasury-sweep --since 30d --format csv --out audit.csv
rflow runs diff <run-a> <run-b> # a good run vs a bad one
rflow runs timeline <run-id> # one run, chronologicallyEach list command prints a next page: --cursor <c> hint when more rows exist,
and --json emits the same { items, cursor, has_more } shape the API returns.
Authentication
By default rflow's port is unauthenticated (localhost / private-network
operator tool). Set config.server.auth.token
to require a bearer on / and /api/*:
config:
server:
auth:
token: "${{ secrets.RFLOW_API_TOKEN }}" # never a yaml literalWith auth on:
- the UI shell at
GET /stays public — it is a secret-free static login page that must load unauthenticated so the browser can prompt for the token. Every/api/*request (including/api/history/*) then requiresAuthorization: Bearer <token>. - the UI shows a clean login card on any
401, stores the token insessionStorage, sends it on every API call, and returns to login on a later401. The token is never rendered or logged. - behind an auth-terminating proxy (oauth2-proxy, Cloudflare Access, most
tunnels and embedded preview browsers) the
Authorizationheader is often stripped or replaced with the proxy's own bearer. The server therefore also accepts the token via anX-Rflow-Tokenrequest header, and the UI falls back to it automatically when a login401s despite a sent token. Caveat: log/APM pipelines that redactAuthorizationby default usually record custom headers verbatim — the UI only uses the fallback in environments that actually need it, but if your intermediary captures request headers into logs, treat those logs as secret or rotate withrflow token revoke. /healthstays open for k8s probes (unlesshealth_public: false);/metricsfollows auth unlessmetrics_public: true./hooks/*webhook routes keep their own per-trigger HMACauth:and are never covered by the bearer.
Beyond the shared token you can mint named, revocable bearers with
rflow token — any of them authenticates the API and UI.
Explorer links
The transactions and run-detail views build per-network block-explorer links
from a built-in chain map (keyed on the network name / chain id). Networks that
don't resolve to a known explorer simply show the hash with a copy button and no
link. All links open in a new tab with rel="noopener".
Retention
The explorer reads history; it never prunes it. Bounding the journal is the job
of config.retention + rflow retention/rflow archive,
which share the exact same redaction discipline — an exported audit trail never
leaks a secret whether it comes from rflow history export or rflow archive export.