Pipeline observability — local Prometheus + Grafana via Caddy¶
This repository builds observability in from the ground up. Every stage emits structured events to a JSONL sidecar from its first commit; an exporter tails the sidecars and serves Prometheus metrics; Grafana renders the operator dashboard; Caddy fronts both behind a single http://localhost:8080 URL.
Status: implemented (see docs/plans/p-059-prometheus-exporter.md). bffi-pipeline serve-metrics tails every runs/*/stage-events.jsonl and serves the metric set below on :9100. The emitter is activated by the run-directory check every stage's --output-dir passes through, so any invocation writing into a canonical run directory produces a sidecar.
No outbound telemetry. The whole stack runs in Docker Compose on the operator's machine; no data leaves the box.
Architecture¶
flowchart TD
subgraph Stages["Pipeline stages"]
S2[marc-to-bibframe]
S3[bibframe-to-bffi]
S4[bffi-to-marc]
S5[roundtrip-eval]
end
subgraph Sidecars["Per-run sidecars"]
SJ[stage-events.jsonl]
end
subgraph Exporter["metrics-exporter
host :9100"]
E["/metrics endpoint"]
end
subgraph Docker["Docker Compose"]
PROM[Prometheus
:9090]
GRAF[Grafana
:3000]
end
subgraph Runs["runs/ directory"]
R[per-record artifacts
diff TSVs, review HTML]
end
subgraph Caddy["Caddy reverse proxy
127.0.0.1:8080"]
CADDY[Caddy]
end
S2 & S3 & S4 & S5 --> SJ
SJ -->|tail| E
E -->|scrape| PROM
PROM -->|query| GRAF
PROM & GRAF --> CADDY
CADDY -->|/grafana/| GRAF
CADDY -->|/prometheus/| PROM
CADDY -->|/files/| R
All local. The exporter runs on the host alongside the conversion pipeline (it shares the data dir directly, no volume mapping); Prometheus, Grafana, and Caddy run as Docker Compose services. Operators reach the stack through a single URL; Caddy routes paths to the right backend and serves the runs/ directory as static files so per-record diff artifacts are clickable from dashboard links.
Event sidecar (the contract)¶
Every stage writes JSON-lines to runs/<run_uuid>/stage-events.jsonl. Events are append-only, schema-versioned, and small. Event types:
| Event | When | Required fields | Notes |
|---|---|---|---|
start |
stage begin | stage, run_uuid, ts, entities_total |
entities_total is best-effort for stages where the count is knowable at start. |
progress |
every N entities (default 100) | stage, run_uuid, ts, entities_processed |
Drives throughput + ETA derivations. |
phase_boundary |
sub-phase transition within a stage | stage, run_uuid, ts, phase, entities_total |
Optional; only stages with internal phases emit these. |
end |
stage finish | stage, run_uuid, ts, outcomes: {bucket: count} |
Outcome buckets are stage-specific (see § Metric vocabulary). |
Adding a new event type is a code change; the metric vocabulary table below is the spec the stage side, exporter, and dashboard must agree on.
Metric vocabulary¶
The exporter publishes the canonical metric set below. Every metric maps one-to-one to a sidecar event field.
All metrics are Gauges, including the _total-suffixed ones: the sidecar carries absolute cumulative values, so replaying it into a counter would double-count whenever the exporter restarts and re-reads from offset 0. A gauge holding the last observed absolute value is the honest primitive; the _total names are kept because the dashboard queries them.
| Metric | Type | Labels | Source event | What it means |
|---|---|---|---|---|
bffi_stage_started_timestamp |
Gauge | stage, run_uuid |
start |
Unix ts the stage began. |
bffi_stage_ended_timestamp |
Gauge | stage, run_uuid |
end |
Unix ts the stage finished. |
bffi_stage_entities_total |
Gauge | stage, phase, run_uuid |
start / phase_boundary |
Total entities the stage/phase will process. |
bffi_stage_entities_processed_total |
Gauge | stage, phase, run_uuid |
progress |
Cumulative entities processed so far. |
bffi_stage_outcomes_total |
Gauge | stage, outcome, run_uuid |
end |
One series per key in the stage's end counters — success, failed, shape_flagged, skipped_invalid (records a validation boundary rejected — see docs/validation-strategy.md), and for bibframe2bffi also closed_namespace_residue plus one routing_<name> per discriminator routing. |
bffi_stage_throughput_per_minute |
Gauge | stage, phase, run_uuid |
derived | Rolling-window throughput from the last 5 progress events. |
bffi_stage_eta_seconds |
Gauge | stage, phase, run_uuid |
derived | Linear-extrapolation ETA to phase boundary or stage end. |
bffi_stage_failed |
Gauge | stage, phase, error_type, run_uuid |
failed |
1 when the stage or phase failed terminally. Deliberately not labelled with the failure message — that embeds the record path, which would make cardinality scale with failed-record count. The message stays in the sidecar. |
bffi_stage_errors_total |
Gauge | stage, error_type, run_uuid |
failed |
Accumulated failed-record count per exception class. |
bffi_stage_skipped |
Gauge | stage, reason, run_uuid |
skipped |
1 when the runner explicitly skipped the stage. |
bffi_stage_planned |
Gauge | stage, run_uuid |
plan |
1 for every stage the run intends to execute. |
bffi_stage_phase_planned |
Gauge | stage, phase, run_uuid |
plan |
1 for every planned (stage, phase) pair. |
bffi_run_description |
Gauge | description, run_uuid |
plan |
1, carrying the run's free-text label. |
Label cardinality¶
stage∈ {melinda-sync,marc2bibframe,bibframe2bffi,bffi2marc,roundtrip_eval} — this repository's ingestion + bidirectional-conversion stage set.bffi2marcis the reverse direction (BFFI graph → reconstructed MARCXML);roundtrip_evalis the diff harness that compares reconstructed MARC against the source. Extend as new stages land.phase∈ {_,phase1, …}._is the sentinel for stages without internal phases.outcomeis per-stage but bounded:success/failedfor every stage, plusclosed_namespace_residueand onerouting_<name>per discriminator routing forbibframe2bffi.error_typeis the exception class name the stage caught (XsltprocError,BibframeToBffiError, …) — bounded by the number of failure modes, not by records.run_uuidis one value per pipeline invocation.
Cardinality cap (per run_uuid): ~5 stages × ~3 phases + ~30 outcomes + a handful of error types ≈ 60 series. No label carries per-record data, so the cap holds at 800k records as it does at 300. Multiplied by accumulated runs in the current exporter session — well within Prometheus's comfort zone for any realistic dev / production cadence.
Exporter lifecycle¶
The exporter is a small Python program. Five phases:
| Phase | Trigger | What happens |
|---|---|---|
| Launch | CLI command (e.g. bffi-pipeline serve-metrics) |
Rehydrate every attached sidecar JSONL into the in-memory registry; bind :9100; write <runs-root>/.exporter.pid + .exporter.argv; register the atexit cleanup hook; enter the tail loop. |
| Steady state | tail loop, poll_seconds=1.0 |
Per attached sidecar: read new lines via inode + byte offset (no double-counting). Every glob_rescan_seconds=30s the --watch-glob patterns re-list and auto-attach new matches. |
| Clean shutdown | SIGTERM / SIGINT / interpreter exit | atexit hook removes .exporter.pid + .exporter.argv (best-effort; OSError silently swallowed if the operator already cleaned them). |
| Operator reset | explicit reset command | Reads PID file, SIGTERMs the process, waits up to 10 s for clean exit, then relaunches with the recorded argv. |
| Crash recovery | Next reset after SIGKILL / OOM / container halt | Process-alive check returns False → unlink the stale PID file, log warning, skip. No manual rm needed. |
PID + argv files sit at <runs-root>/.exporter.pid and .exporter.argv — process-global, not per-run, because the exporter is multi-tenant over its sidecars.
Operating modes¶
Mode A — ambient observer (default): one long-lived exporter with --watch-glob 'runs/*/stage-events.jsonl'. Initial walk attaches every existing sidecar; the 30 s rescan picks up new runs as they spawn. The dashboard's active_run dropdown shows new runs within ~30 s — no exporter restart needed.
Mode B — per-run focused bench: single --watch-glob 'runs/<uuid>/stage-events.jsonl'. Useful for an isolated A/B test where you want zero noise from other runs. Operator kills the exporter when done.
Mode A is the durable shape. The exporter never owns the run — it's a passive tail of the run's on-disk JSONL output.
Exporter restarts¶
Restarting the exporter re-attaches every sidecar at offset 0 and replays it. Because every metric is an absolute-value Gauge, the replay lands on exactly the values the previous process had — no reset, no visible discontinuity on the dashboard, and no double-counting. The JSONL on disk is the ground truth; the exporter holds no state the sidecars don't.
The one exception is bffi_stage_errors_total, which accumulates one
increment per failed row. A replay reconstructs it from the same rows,
so the total is stable across restarts too.
Per-run metric isolation¶
Every metric carries an explicit run_uuid label. Dashboards filter every panel by run_uuid="$active_run", where $active_run is a Grafana templating variable populated by label_values(bffi_stage_started_timestamp, run_uuid). The operator picks the run they want to watch; panels re-render against that single run_uuid. Prior runs' data remains queryable in Prometheus under their own values for forensic comparison.
Bundled Grafana dashboard¶
Auto-loaded from config/grafana/dashboards/bffi-pipeline.json at container start (via config/grafana/provisioning/). Read-only in the UI; operators clone-and-edit if they want a custom view.
Panel set:
| Panel | What it shows |
|---|---|
| Run selector | Templated variable $active_run filtered by label_values(bffi_stage_started_timestamp, run_uuid). |
| Started / Ended | Timestamps for when each stage began and finished. |
| Per-stage record counts | ${melinda_total}, ${marc2bibframe_total}, ${bibframe2bffi_total}, ${bffi2marc_total}, ${roundtrip_total} — total entities processed per stage. |
| Errors by stage | Failed-record counts grouped by stage and error type. |
| Run artifacts | Link through Caddy's /files/ mount to per-record diff TSVs and cataloguer-review HTML. |
Extending¶
Adding a new metric is two edits:
- Stage side: extend the relevant event's payload at the
emit_if_activecall. - Exporter side: declare the metric in the exporter's metric set and route the event's payload to it.
- Dashboard (optional): add a panel to the JSON.
The metric vocabulary table above is the spec. Bump the table first; the code follows.
Outstanding¶
bffi_stage_throughput_per_minuteandbffi_stage_eta_secondsderive from a 5-event sliding window, so they stay absent until a stage has emitted twoprogressevents.