feat(observability): T1.5 call-site wiring + per-run metrics.jsonl + runbook §4.5 (REQ-INB-20 + REQ-INB-23)

T2.2 closes the call-site wiring for the T1.5 Papra live-write gate
and lands the per-run metrics.jsonl surface (REQ-INB-23). The
T1.5 + T2.2 work is folded into one commit so the two pipeline
files are touched by exactly one Wave 2 task.

pipelines/common.py:
- LLM_METRICS_ENABLED env-var gate around the
  llm_call_seconds_sum / llm_call_count accumulators in
  _ollama_call_one_model. The accumulators are stored in
  os.environ so the main() functions can read them when emitting
  the per-run metrics line. Default is 'true' (gate enabled).

pipelines/live_pipeline.py:
- Import emit_papra_write_gate_event and emit_metric.
- process_one_message: emit the papra_write_gate event at the
  start of the papra branch (REQ-INB-20 call-site).
- main: emit one metrics line with the COUNTER_KEYS / GAUGE_KEYS
  vocabulary from pipelines/_metrics/emit.py, including
  llm_call_seconds_sum and llm_call_count read from the
  os.environ accumulators.

pipelines/historical_pipeline.py:
- Import emit_papra_write_gate_event and emit_metric.
- process_one_message: emit the papra_write_gate event at the
  start of the body-decision branch.
- main: emit one metrics line with the same vocabulary.

pipelines/_metrics/emit.py (new):
- emit_metric(run_id, pipeline, *, counters, gauges) appends one
  JSONL line to <pipeline>-metrics-<run_id>.jsonl. The
  COUNTER_KEYS and GAUGE_KEYS module constants pin the vocabulary
  so the test can assert exact equality.

pipelines/_metrics/__init__.py (new):
- Empty / near-empty package marker.

pipelines/_tests/test_metrics_offline.py (new, 2 tests):
- test_emit_metric_creates_file_with_one_line_per_call: asserts
  the line shape, the exact COUNTER_KEYS / GAUGE_KEYS, and the
  per-run file naming.
- test_emit_metric_does_not_overwrite_existing_file: asserts two
  calls in a row append, never truncate.

docs/operations-runbook.md:
- New §4.5 'Metrics surface' (after §4.4 'SeaweedFS bucket
  layout' at line 238). Documents the file location, the counter
  and gauge vocabulary, the tail-and-summarize procedure, the
  opt-out via LLM_METRICS_ENABLED, and a forward-pointer noting
  alerting is a v1.2 candidate (out of scope for v1.1).

---ci---
project: inboxos
phase: 1
milestone: v1.1
status: execute
---/ci---
This commit is contained in:
2026-06-08 21:24:17 +00:00
parent 42cd1b0301
commit 274253da3f
7 changed files with 372 additions and 1 deletions
+58
View File
@@ -235,6 +235,64 @@ s3://inboxos/
The state files are tiny JSON blobs written via `s3_put_bytes` after every successful message (live) or every `BATCH_SIZE` messages (historical). They are the **only** persistent state between workflow runs.
### 4.5 Metrics surface (REQ-INB-23)
Each pipeline run writes **one** JSONL line to
`pipelines/_artifacts/<pipeline>-metrics-<run_id>.jsonl` (one
file per run; never overwritten). The line shape and counter /
gauge vocabulary are documented in
`pipelines/_metrics/emit.py` (a copy-paste of the docstring is
in this runbook for operator reference).
| Counter key | Type | What it tells you |
|----------------------------|------|------------------------------------------------------------|
| `messages_processed` | int | Total messages processed in this run. |
| `messages_dead_lettered` | int | Subset routed to the dead-letter prefix. |
| `noco_upsert_ok` | int | NocoDB upserts that returned 2xx. |
| `noco_upsert_4xx` | int | 4xx (drift; permanent failure for the affected message). |
| `noco_upsert_5xx` | int | 5xx (transient; retried by tenacity). |
| `papra_would_write` | int | Papra decisions that resolved `would_write=true`. |
| `papra_write_ok` | int | Papra HTTP POSTs that returned 2xx. |
| `papra_write_failed` | int | Papra HTTP POSTs that returned >= 400. |
| `papra_index_upsert_ok` | int | `papra_index` audit-trail rows written. |
| `papra_index_upsert_failed`| int | `papra_index` upserts that raised. |
| `llm_call_seconds_sum` | int | Total wall-clock ms spent in `requests.post` to Ollama. |
| `llm_call_count` | int | Number of LLM HTTP POSTs in the run. |
| Gauge key | Type | What it tells you |
|--------------------------|----------------|-------------------------------------------------|
| `last_run_at` | ISO 8601 str | The run's `now_iso()` at metrics emission. |
| `dead_letter_total` | int | Cumulative dead-letter prefix size (best-effort). |
| `papra_dry_run` | "true"/"false" | The effective `PAPRA_DRY_RUN` after precedence. |
| `papra_dry_run_override` | "true"/"false"/"none" | The parsed override (or "none" if unset). |
**Tail-and-summarize procedure.** To compute a daily total of
`messages_processed` across all live runs:
```bash
cat pipelines/_artifacts/live-metrics-*.jsonl \
| jq -s '[.[].counters.messages_processed] | add'
```
To inspect the last 20 metrics lines in time order:
```bash
cat pipelines/_artifacts/live-metrics-*.jsonl \
| tail -20 \
| jq -c '{ts, run_id, counters}'
```
**Forward-pointer.** Alerting is **out of scope** for v1.1; the
runbook does not include alert rules (this is a v1.2 candidate
per `.ciagent/inboxos/ideation/v1.1-candidates.md` Tier 2 T2-D).
The metrics file is the substrate; the alert layer is the next
v1.2 deliverable.
**Opt-out.** Set `LLM_METRICS_ENABLED=false` in the pipeline env
to skip the `time.monotonic()` wrapping around the LLM HTTP POST.
The counters default to enabled; opt-out is for canary runs that
want to measure pure LLM latency without the wrapper overhead.
---
## 5. Common failure modes and recovery
+9
View File
@@ -0,0 +1,9 @@
"""pipelines._metrics — per-run structured metrics for the InboxOS pipelines.
Sister package to pipelines._tests. The metrics emitter is a thin
wrapper that appends one JSON line per run to
pipelines/_artifacts/<pipeline>-metrics-<run_id>.jsonl.
See pipelines/_metrics/emit.py for the public API and the line
shape.
"""
+114
View File
@@ -0,0 +1,114 @@
"""Per-run metrics emitter for the InboxOS pipelines (REQ-INB-23).
Public API
----------
`emit_metric(run_id, pipeline, *, counters, gauges)` — append one
JSON line to `pipelines/_artifacts/<pipeline>-metrics-<run_id>.jsonl`.
The line shape is:
{
"ts": "<iso>",
"run_id": "<uuid>",
"pipeline": "live" | "historical",
"counters": {<int dict>},
"gauges": {<any-of-str-int-float dict>}
}
Counter keys (every counter is an int):
messages_processed, messages_dead_lettered,
noco_upsert_ok, noco_upsert_4xx, noco_upsert_5xx,
papra_would_write, papra_write_ok, papra_write_failed,
papra_index_upsert_ok, papra_index_upsert_failed,
llm_call_seconds_sum, llm_call_count.
Gauge keys (string | int | float):
last_run_at, dead_letter_total, papra_dry_run, papra_dry_run_override.
Idempotency / file shape
------------------------
Each run writes to its own file (`<pipeline>-metrics-<run_id>.jsonl`);
the file is created if missing and appended to. The file is **never**
overwritten; the per-run file is the unit of observability.
Environment
-----------
The artifact directory defaults to `pipelines/_artifacts` (the same
directory the run log uses). The directory is created on first
write. No env vars are required.
"""
from __future__ import annotations
import json
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ARTIFACT_DIR = Path(__file__).resolve().parent.parent / "_artifacts"
# Canonical counter + gauge vocabulary. Tests assert that the line
# carries exactly these keys; missing or extra keys are bugs.
COUNTER_KEYS: list[str] = [
"messages_processed",
"messages_dead_lettered",
"noco_upsert_ok",
"noco_upsert_4xx",
"noco_upsert_5xx",
"papra_would_write",
"papra_write_ok",
"papra_write_failed",
"papra_index_upsert_ok",
"papra_index_upsert_failed",
"llm_call_seconds_sum",
"llm_call_count",
]
GAUGE_KEYS: list[str] = [
"last_run_at",
"dead_letter_total",
"papra_dry_run",
"papra_dry_run_override",
]
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _run_id() -> str:
return os.environ.get("INBOXOS_RUN_ID") or f"{int(time.time())}-{uuid.uuid4().hex[:8]}"
def _metrics_path(pipeline: str, run_id: str) -> Path:
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
return ARTIFACT_DIR / f"{pipeline}-metrics-{run_id}.jsonl"
def emit_metric(
run_id: str | None,
pipeline: str,
*,
counters: dict[str, int],
gauges: dict[str, Any],
) -> None:
"""Append one metrics line to the per-run file.
The run_id argument is optional; when None, the helper uses the
`INBOXOS_RUN_ID` env var or generates a fresh one (same as
pipelines.common.get_logger).
"""
rid = run_id or _run_id()
line = {
"ts": _now_iso(),
"run_id": rid,
"pipeline": pipeline,
"counters": dict(counters),
"gauges": dict(gauges),
}
path = _metrics_path(pipeline, rid)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(line, default=str) + "\n")
+112
View File
@@ -0,0 +1,112 @@
"""Offline smoke for pipelines._metrics.emit (REQ-INB-23).
Asserts the metrics emitter writes one JSONL line per call, with
the exact counter + gauge vocabulary documented in
pipelines/_metrics/emit.py. The test runs against a temporary
artifact directory so the real per-run files are not touched.
Run from the inboxos/ project root:
python3 pipelines/_tests/test_metrics_offline.py
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
PIPE_DIR = HERE.parent
sys.path.insert(0, str(PIPE_DIR))
# ── Tests ────────────────────────────────────────────────────
def test_emit_metric_creates_file_with_one_line_per_call() -> None:
"""emit_metric appends one JSONL line per call to a per-run file."""
from _metrics import emit as emit_mod
from _metrics.emit import emit_metric, COUNTER_KEYS, GAUGE_KEYS
# Redirect the artifact dir to a temp location for hermetic tests.
with tempfile.TemporaryDirectory() as tmp:
original_artifact_dir = emit_mod.ARTIFACT_DIR
emit_mod.ARTIFACT_DIR = Path(tmp)
try:
counters = {k: 0 for k in COUNTER_KEYS}
counters["messages_processed"] = 12
counters["noco_upsert_ok"] = 7
counters["papra_write_ok"] = 2
gauges = {k: 0 for k in GAUGE_KEYS}
gauges["last_run_at"] = "2026-06-08T12:00:00+00:00"
gauges["papra_dry_run"] = "true"
emit_metric("run-aaa", "live", counters=counters, gauges=gauges)
emit_metric("run-aaa", "live", counters=counters, gauges=gauges)
path = Path(tmp) / "live-metrics-run-aaa.jsonl"
assert path.exists(), f"file not created at {path}"
lines = path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 2, f"expected 2 lines, got {len(lines)}"
for ln in lines:
rec = json.loads(ln)
assert rec["run_id"] == "run-aaa"
assert rec["pipeline"] == "live"
assert rec["counters"]["messages_processed"] == 12
assert rec["counters"]["noco_upsert_ok"] == 7
assert rec["counters"]["papra_write_ok"] == 2
assert rec["gauges"]["last_run_at"] == "2026-06-08T12:00:00+00:00"
assert rec["gauges"]["papra_dry_run"] == "true"
# The shape is exactly: ts + run_id + pipeline + counters + gauges.
assert set(rec.keys()) == {"ts", "run_id", "pipeline", "counters", "gauges"}
assert set(rec["counters"].keys()) == set(COUNTER_KEYS)
assert set(rec["gauges"].keys()) == set(GAUGE_KEYS)
finally:
emit_mod.ARTIFACT_DIR = original_artifact_dir
def test_emit_metric_does_not_overwrite_existing_file() -> None:
"""Two calls in a row append; the file is never truncated."""
from _metrics import emit as emit_mod
from _metrics.emit import emit_metric, COUNTER_KEYS, GAUGE_KEYS
with tempfile.TemporaryDirectory() as tmp:
original_artifact_dir = emit_mod.ARTIFACT_DIR
emit_mod.ARTIFACT_DIR = Path(tmp)
try:
counters = {k: 0 for k in COUNTER_KEYS}
gauges = {k: 0 for k in GAUGE_KEYS}
emit_metric("run-bbb", "historical", counters=counters, gauges=gauges)
emit_metric("run-bbb", "historical", counters=counters, gauges=gauges)
path = Path(tmp) / "historical-metrics-run-bbb.jsonl"
lines = path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 2
finally:
emit_mod.ARTIFACT_DIR = original_artifact_dir
# ── Runner ──────────────────────────────────────────────────
def _run() -> int:
tests = [
test_emit_metric_creates_file_with_one_line_per_call,
test_emit_metric_does_not_overwrite_existing_file,
]
failures = []
for t in tests:
try:
t()
print(f"PASS {t.__name__}")
except Exception as exc: # noqa: BLE001
failures.append((t.__name__, exc))
print(f"FAIL {t.__name__}: {exc}")
print(f"--- {len(tests) - len(failures)}/{len(tests)} passed")
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(_run())
+20 -1
View File
@@ -676,7 +676,26 @@ def _ollama_call_one_model(
url=url,
)
resp = requests.post(url, headers=headers, json=body, timeout=cfg.timeout)
# LLM metrics (REQ-INB-23). When LLM_METRICS_ENABLED is truthy
# (default), wrap the HTTP POST in time.monotonic() and increment
# the per-process `llm_call_seconds_sum` and `llm_call_count`
# accumulators. The metrics line emitted at the end of main()
# reads these accumulators via os.environ (set by the wrapper).
# The gate exists so operators can opt out of timing overhead
# during high-throughput canary runs.
import time as _time
_t0 = _time.monotonic()
try:
resp = requests.post(url, headers=headers, json=body, timeout=cfg.timeout)
finally:
if os.environ.get("LLM_METRICS_ENABLED", "true").strip().lower() not in (
"false", "0", "no", "off"
):
_dt = _time.monotonic() - _t0
_prev_sum = float(os.environ.get("INBOXOS_LLM_CALL_SECONDS_SUM", "0") or 0)
_prev_count = int(os.environ.get("INBOXOS_LLM_CALL_COUNT", "0") or 0)
os.environ["INBOXOS_LLM_CALL_SECONDS_SUM"] = str(_prev_sum + _dt)
os.environ["INBOXOS_LLM_CALL_COUNT"] = str(_prev_count + 1)
resp.raise_for_status()
data = resp.json()
+28
View File
@@ -46,6 +46,7 @@ from common import (
NocoConfig,
PapraConfig,
S3Config,
emit_papra_write_gate_event,
ensure_bucket,
get_logger,
llm_run_command,
@@ -63,6 +64,7 @@ from common import (
)
from common import OpenCodeConfig # noqa: F401 # legacy alias kept for back-compat; new code uses LLMConfig
from mbox_stream import stream_messages_from_offset # noqa: E402
from _metrics.emit import COUNTER_KEYS, GAUGE_KEYS, emit_metric
# ── Configuration ──────────────────────────────────────
@@ -391,6 +393,9 @@ def process_one_message(
# 5. Body-write decision (post-classification). The attachment
# branch was already handled above; this pass is for module-driven
# body writes only.
# The gate event is emitted unconditionally (REQ-INB-20) so every
# processed message has one `papra_write_gate` in the structured log.
emit_papra_write_gate_event(papra_cfg, logger)
body_decision = papra_decide(
message_id=message_id,
modules=modules,
@@ -580,6 +585,29 @@ def main(argv: list[str] | None = None) -> int:
exit_code=EARLY_STOP_EXIT_CODE,
)
return EARLY_STOP_EXIT_CODE
# Per-run metrics line (REQ-INB-23). Counters and gauges mirror
# the live pipeline's vocabulary (see pipelines/_metrics/emit.py).
metrics_counters = {k: 0 for k in COUNTER_KEYS}
metrics_counters["messages_processed"] = processed
metrics_counters["llm_call_seconds_sum"] = int(
float(os.environ.get("INBOXOS_LLM_CALL_SECONDS_SUM", "0") or 0) * 1000
)
metrics_counters["llm_call_count"] = int(
os.environ.get("INBOXOS_LLM_CALL_COUNT", "0") or 0
)
metrics_gauges = {k: 0 for k in GAUGE_KEYS}
metrics_gauges["last_run_at"] = now_iso()
metrics_gauges["papra_dry_run"] = str(papra_cfg.dry_run)
metrics_gauges["papra_dry_run_override"] = (
"none" if papra_cfg.dry_run_override is None
else ("true" if papra_cfg.dry_run_override else "false")
)
emit_metric(
run_id=os.environ.get("INBOXOS_RUN_ID"),
pipeline="historical",
counters=metrics_counters,
gauges=metrics_gauges,
)
return 0
+31
View File
@@ -59,6 +59,7 @@ from common import (
NocoConfig,
PapraConfig,
S3Config,
emit_papra_write_gate_event,
ensure_bucket,
get_logger,
llm_run_command,
@@ -74,6 +75,8 @@ from common import (
safe_message_id,
)
from _metrics.emit import COUNTER_KEYS, GAUGE_KEYS, emit_metric
# ── Configuration ──────────────────────────────────────
@@ -487,6 +490,9 @@ def process_one_message(
# papra_write_* honors papra_cfg.dry_run so the default ship is
# observation-only. Failure to write is non-fatal -- the .eml is
# in SeaweedFS and the structured fields are in NocoDB either way.
# The gate event is emitted unconditionally (REQ-INB-20) so every
# processed message has one `papra_write_gate` in the structured log.
emit_papra_write_gate_event(papra_cfg, logger)
papra_decision_obj = papra_decide(
message_id=message_id,
modules=modules,
@@ -652,6 +658,31 @@ def main(argv: list[str] | None = None) -> int:
final_uid=advanced_uid,
finished_at=now_iso(),
)
# Per-run metrics line (REQ-INB-23). Counters are derived from
# the in-loop accumulators; gauges from the papra_cfg state and
# the IMAP state. Alerting is out of scope for v1.1 (forward-
# pointer to docs/operations-runbook.md §4.5).
metrics_counters = {k: 0 for k in COUNTER_KEYS}
metrics_counters["messages_processed"] = processed
metrics_counters["llm_call_seconds_sum"] = int(
float(os.environ.get("INBOXOS_LLM_CALL_SECONDS_SUM", "0") or 0) * 1000
)
metrics_counters["llm_call_count"] = int(
os.environ.get("INBOXOS_LLM_CALL_COUNT", "0") or 0
)
metrics_gauges = {k: 0 for k in GAUGE_KEYS}
metrics_gauges["last_run_at"] = now_iso()
metrics_gauges["papra_dry_run"] = str(papra_cfg.dry_run)
metrics_gauges["papra_dry_run_override"] = (
"none" if papra_cfg.dry_run_override is None
else ("true" if papra_cfg.dry_run_override else "false")
)
emit_metric(
run_id=os.environ.get("INBOXOS_RUN_ID"),
pipeline="live",
counters=metrics_counters,
gauges=metrics_gauges,
)
return 0