Skip to content

Commit 6120667

Browse files
author
ZacLou
committed
feat(observability): ingestion heartbeat and stale-data alerts (#758)
1 parent 7ede0d1 commit 6120667

5 files changed

Lines changed: 307 additions & 0 deletions

File tree

astroml/ingestion/metrics.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,9 @@
6161
"astroml_ingestion_batch_flush_seconds",
6262
"Time spent flushing a batch of models",
6363
)
64+
65+
# Heartbeat / stale-data metric (Issue #758)
66+
INGESTION_LAST_PROCESSED_AT = Gauge(
67+
"astroml_ingestion_last_processed_at_seconds",
68+
"Unix timestamp of the most recently processed ledger",
69+
)

astroml/ingestion/state.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import json
44
import os
55
from dataclasses import dataclass
6+
from datetime import datetime, timezone
7+
8+
from astroml.ingestion.metrics import INGESTION_LAST_PROCESSED_AT
69

710
DEFAULT_STATE_DIR = os.path.join(os.getcwd(), ".astroml_state")
811
DEFAULT_STATE_FILE = os.path.join(DEFAULT_STATE_DIR, "ingestion_state.json")
@@ -12,19 +15,22 @@
1215
class IngestionState:
1316
last_processed_ledger: int | None
1417
processed_ledgers: set[int]
18+
last_processed_at: str | None = None
1519

1620
def to_dict(self) -> dict:
1721
return {
1822
"last_processed_ledger": self.last_processed_ledger,
1923
# store as sorted list for readability
2024
"processed_ledgers": sorted(self.processed_ledgers),
25+
"last_processed_at": self.last_processed_at,
2126
}
2227

2328
@staticmethod
2429
def from_dict(data: dict) -> IngestionState:
2530
return IngestionState(
2631
last_processed_ledger=data.get("last_processed_ledger"),
2732
processed_ledgers=set(data.get("processed_ledgers", [])),
33+
last_processed_at=data.get("last_processed_at"),
2834
)
2935

3036

@@ -60,6 +66,11 @@ def mark_processed(self, ledger_id: int) -> IngestionState:
6066
state.last_processed_ledger = ledger_id
6167
else:
6268
state.last_processed_ledger = max(state.last_processed_ledger, ledger_id)
69+
state.last_processed_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
70+
try:
71+
INGESTION_LAST_PROCESSED_AT.set(datetime.now(timezone.utc).timestamp())
72+
except Exception:
73+
pass # metrics registry may not be initialised in tests/CLI
6374
self.save(state)
6475
return state
6576

astroml/observability/ingestion.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Ingestion heartbeat / stale-data alerts (Issue #758).
2+
3+
Exposes a health check that reports when no new ledger has been ingested
4+
for longer than a configurable threshold. The check is transport-agnostic
5+
so it can be reused by the FastAPI health router, CLI, or periodic probes.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import time
11+
from datetime import datetime, timedelta, timezone
12+
from typing import Final
13+
14+
from astroml.ingestion.state import StateStore
15+
from astroml.observability.health import CheckResult, HealthStatus
16+
17+
#: Default threshold at which ingestion is considered stale (seconds).
18+
DEFAULT_STALE_THRESHOLD_SECONDS: Final[int] = 300
19+
20+
#: Threshold at which ingestion is considered critically stale (seconds).
21+
#: Used when the caller does not supply an explicit fail threshold.
22+
DEFAULT_FAIL_THRESHOLD_SECONDS: Final[int] = 600
23+
24+
25+
def _parse_timestamp(value: str | None) -> datetime | None:
26+
"""Parse an ISO-8601 timestamp produced by ``StateStore``.
27+
28+
Args:
29+
value: ISO-8601 string, e.g. ``2024-01-01T00:00:00Z``.
30+
31+
Returns:
32+
A timezone-aware UTC datetime, or ``None`` if parsing fails.
33+
"""
34+
if not value:
35+
return None
36+
try:
37+
# StateStore writes ``datetime.utcnow().isoformat() + "Z"``.
38+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
39+
except ValueError:
40+
return None
41+
42+
43+
def check_ingestion_heartbeat(
44+
state_store: StateStore,
45+
*,
46+
stale_threshold_seconds: float = DEFAULT_STALE_THRESHOLD_SECONDS,
47+
fail_threshold_seconds: float | None = None,
48+
now: datetime | None = None,
49+
) -> CheckResult:
50+
"""Check whether the ingestion pipeline has processed a ledger recently.
51+
52+
Args:
53+
state_store: Store that tracks ``last_processed_at``.
54+
stale_threshold_seconds: Seconds without a new ledger before the
55+
check becomes ``DEGRADED``.
56+
fail_threshold_seconds: Seconds without a new ledger before the
57+
check becomes ``FAIL``. Defaults to twice the stale threshold.
58+
now: Optional reference time for tests. Defaults to UTC now.
59+
60+
Returns:
61+
A :class:`CheckResult` named ``"ingestion_heartbeat"``.
62+
"""
63+
started = time.perf_counter()
64+
fail_threshold = fail_threshold_seconds or stale_threshold_seconds * 2
65+
now = now or datetime.now(timezone.utc)
66+
67+
try:
68+
state = state_store.load()
69+
except OSError as exc:
70+
return CheckResult(
71+
name="ingestion_heartbeat",
72+
status=HealthStatus.FAIL,
73+
details={"state_path": state_store.path, "error": str(exc)},
74+
remediation=(
75+
"Cannot read ingestion state. Verify the state file path "
76+
"and that the process user has read access."
77+
),
78+
duration_ms=(time.perf_counter() - started) * 1000,
79+
)
80+
81+
last_processed_at = _parse_timestamp(state.last_processed_at)
82+
83+
if last_processed_at is None:
84+
return CheckResult(
85+
name="ingestion_heartbeat",
86+
status=HealthStatus.DEGRADED,
87+
details={
88+
"last_processed_ledger": state.last_processed_ledger,
89+
"last_processed_at": state.last_processed_at,
90+
"stale_threshold_seconds": stale_threshold_seconds,
91+
"fail_threshold_seconds": fail_threshold,
92+
},
93+
remediation=(
94+
"No ingestion timestamp has been recorded yet. "
95+
"Run an ingestion batch or verify the state store is being updated."
96+
),
97+
duration_ms=(time.perf_counter() - started) * 1000,
98+
)
99+
100+
# Ensure comparison is timezone-aware.
101+
if last_processed_at.tzinfo is None:
102+
last_processed_at = last_processed_at.replace(tzinfo=timezone.utc)
103+
104+
elapsed_seconds = (now - last_processed_at).total_seconds()
105+
106+
if elapsed_seconds >= fail_threshold:
107+
status = HealthStatus.FAIL
108+
remediation = (
109+
f"No ledger ingested for {elapsed_seconds:.0f}s "
110+
f"(fail threshold {fail_threshold:.0f}s). "
111+
"Investigate the ingestion worker, Horizon stream, and network connectivity."
112+
)
113+
elif elapsed_seconds >= stale_threshold_seconds:
114+
status = HealthStatus.DEGRADED
115+
remediation = (
116+
f"No ledger ingested for {elapsed_seconds:.0f}s "
117+
f"(stale threshold {stale_threshold_seconds:.0f}s). "
118+
"Check the ingestion worker logs for stalls or rate-limiting."
119+
)
120+
else:
121+
status = HealthStatus.OK
122+
remediation = ""
123+
124+
return CheckResult(
125+
name="ingestion_heartbeat",
126+
status=status,
127+
details={
128+
"last_processed_ledger": state.last_processed_ledger,
129+
"last_processed_at": state.last_processed_at,
130+
"elapsed_seconds": round(elapsed_seconds, 2),
131+
"stale_threshold_seconds": stale_threshold_seconds,
132+
"fail_threshold_seconds": fail_threshold,
133+
},
134+
remediation=remediation,
135+
duration_ms=(time.perf_counter() - started) * 1000,
136+
)

docs/ingestion-monitoring.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Ingestion monitoring
2+
3+
The ingestion pipeline exposes a heartbeat check so operators can detect when
4+
no new ledgers have been processed for a configurable period.
5+
6+
## Heartbeat check
7+
8+
`astroml.observability.ingestion.check_ingestion_heartbeat` compares the
9+
current time to the `last_processed_at` timestamp recorded in the ingestion
10+
state store and returns a `CheckResult`:
11+
12+
- `OK` — a ledger was processed within `stale_threshold_seconds`.
13+
- `DEGRADED` — no ledger for `stale_threshold_seconds` (default 300s).
14+
- `FAIL` — no ledger for `fail_threshold_seconds` (default 2 × stale threshold).
15+
16+
Example:
17+
18+
```python
19+
from astroml.ingestion.state import StateStore
20+
from astroml.observability.ingestion import check_ingestion_heartbeat
21+
22+
store = StateStore()
23+
result = check_ingestion_heartbeat(store, stale_threshold_seconds=300)
24+
print(result.status, result.remediation)
25+
```
26+
27+
## Prometheus metric
28+
29+
`astroml.ingestion.metrics.INGESTION_LAST_PROCESSED_AT` is a Gauge that
30+
records the Unix timestamp of the most recently processed ledger. Use it to
31+
build alerts such as:
32+
33+
```promql
34+
(time() - astroml_ingestion_last_processed_at_seconds) > 300
35+
```
36+
37+
## State store timestamp
38+
39+
`StateStore.mark_processed` records `last_processed_at` as an ISO-8601 UTC
40+
timestamp whenever a ledger is processed. Existing state files without the
41+
field are treated as having no recorded ingestion time and report
42+
`DEGRADED` until the next successful ingestion.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Tests for ingestion heartbeat / stale-data alerts (Issue #758)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from datetime import datetime, timedelta, timezone
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from astroml.ingestion.state import StateStore
12+
from astroml.observability.health import HealthStatus
13+
from astroml.observability.ingestion import check_ingestion_heartbeat
14+
15+
16+
class TestCheckIngestionHeartbeat:
17+
def test_ok_when_recent_ingestion(self, tmp_path: Path) -> None:
18+
state_path = tmp_path / "ingestion_state.json"
19+
state_path.write_text(
20+
json.dumps(
21+
{
22+
"last_processed_ledger": 1000,
23+
"processed_ledgers": [1000],
24+
"last_processed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
25+
}
26+
),
27+
encoding="utf-8",
28+
)
29+
store = StateStore(str(state_path))
30+
31+
result = check_ingestion_heartbeat(store, stale_threshold_seconds=300)
32+
33+
assert result.status is HealthStatus.OK
34+
assert result.details["last_processed_ledger"] == 1000
35+
assert result.remediation == ""
36+
37+
def test_degraded_when_stale(self, tmp_path: Path) -> None:
38+
state_path = tmp_path / "ingestion_state.json"
39+
stale_at = datetime.now(timezone.utc) - timedelta(seconds=400)
40+
state_path.write_text(
41+
json.dumps(
42+
{
43+
"last_processed_ledger": 1000,
44+
"processed_ledgers": [1000],
45+
"last_processed_at": stale_at.isoformat().replace("+00:00", "Z"),
46+
}
47+
),
48+
encoding="utf-8",
49+
)
50+
store = StateStore(str(state_path))
51+
52+
result = check_ingestion_heartbeat(store, stale_threshold_seconds=300)
53+
54+
assert result.status is HealthStatus.DEGRADED
55+
assert "stale threshold" in result.remediation
56+
57+
def test_fail_when_critically_stale(self, tmp_path: Path) -> None:
58+
state_path = tmp_path / "ingestion_state.json"
59+
stale_at = datetime.now(timezone.utc) - timedelta(seconds=700)
60+
state_path.write_text(
61+
json.dumps(
62+
{
63+
"last_processed_ledger": 1000,
64+
"processed_ledgers": [1000],
65+
"last_processed_at": stale_at.isoformat().replace("+00:00", "Z"),
66+
}
67+
),
68+
encoding="utf-8",
69+
)
70+
store = StateStore(str(state_path))
71+
72+
result = check_ingestion_heartbeat(store, stale_threshold_seconds=300)
73+
74+
assert result.status is HealthStatus.FAIL
75+
assert "fail threshold" in result.remediation
76+
77+
def test_degraded_when_no_timestamp_recorded(self, tmp_path: Path) -> None:
78+
state_path = tmp_path / "ingestion_state.json"
79+
state_path.write_text(
80+
json.dumps(
81+
{
82+
"last_processed_ledger": 1000,
83+
"processed_ledgers": [1000],
84+
}
85+
),
86+
encoding="utf-8",
87+
)
88+
store = StateStore(str(state_path))
89+
90+
result = check_ingestion_heartbeat(store)
91+
92+
assert result.status is HealthStatus.DEGRADED
93+
assert "No ingestion timestamp" in result.remediation
94+
95+
def test_state_store_records_timestamp_on_mark_processed(self, tmp_path: Path) -> None:
96+
state_path = tmp_path / "ingestion_state.json"
97+
store = StateStore(str(state_path))
98+
99+
store.mark_processed(1000)
100+
state = store.load()
101+
102+
assert state.last_processed_at is not None
103+
assert state.last_processed_at.endswith("Z")
104+
105+
def test_state_store_round_trip_preserves_timestamp(self, tmp_path: Path) -> None:
106+
state_path = tmp_path / "ingestion_state.json"
107+
store = StateStore(str(state_path))
108+
store.mark_processed(1000)
109+
110+
reloaded = StateStore(str(state_path)).load()
111+
112+
assert reloaded.last_processed_at == store.load().last_processed_at

0 commit comments

Comments
 (0)