"""Prometheus metrics — about the machine, never about the money. The only endpoint that reads the database without a session, so *what* is published matters more than the format. Every series describes the app's own health: when prices were last fetched, whether that worked, whether the database answers. `tests/test_metrics.py` pins the list closed. Off by default, and no `prometheus_client` dependency: decisions.md #85. """ from __future__ import annotations import datetime as dt import time from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models import FxRate, Price # The version matters: Prometheus content-negotiates, and a bare `text/plain` # is accepted but leaves the parser guessing. 0.0.4 is the text exposition # format everything speaks. CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8" # Process-lifetime counters. Prometheus expects counters to reset when a process # restarts and handles it (`increase()`/`rate()` detect the drop), so there is # nothing to persist here — and persisting them would mean a write on every feed # run for a number nobody reads except a graph. _runs = 0 _failures = 0 _last_success: float | None = None def reset() -> None: """Back to a fresh process's state. For tests.""" global _runs, _failures, _last_success _runs = 0 _failures = 0 _last_success = None def record_feed_run(*, ok: bool) -> None: """Called once per price-feed run, whatever the outcome. `_last_success` is only stamped on success on purpose: the question the alert asks is "how long since this last *worked*", and refreshing the timestamp on a failure would answer "just now" forever while every fetch was failing. """ global _runs, _failures, _last_success _runs += 1 if ok: _last_success = time.time() else: _failures += 1 def _midnight_utc(day: dt.date) -> float: """A date as unix seconds. Prices are dated, not timestamped — a close belongs to a trading day, not a moment. UTC midnight is the conventional way to say that in a gauge, and it means an alert's arithmetic is the same everywhere regardless of the install's timezone. """ return dt.datetime(day.year, day.month, day.day, tzinfo=dt.timezone.utc).timestamp() def _render(name: str, help_text: str, kind: str, value: float) -> str: # `:.0f` rather than repr: these are whole seconds, and the default float # formatting turns them into 1.7543e+09, which is legal and unreadable at # 3am when an alert has just fired. return (f"# HELP {name} {help_text}\n" f"# TYPE {name} {kind}\n" f"{name} {value:.0f}\n") def render(session: Session | None) -> str: """The exposition body. `session` is None when the app has no database yet (the first-run wizard has not been finished). That is a legitimate state, not an error: report `stocktake_database_ready 0` and skip the series that would need a query. """ ready = session is not None out = [_render( "stocktake_database_ready", "Whether the app can reach its database (1) or not (0).", "gauge", 1 if ready else 0, )] if session is not None: # Market data is shared across every portfolio, so these read unscoped # by nature — there is no tenancy question to answer here, which is # also why they are safe to publish. last_price = session.execute(select(func.max(Price.date))).scalar() last_fx = session.execute(select(func.max(FxRate.date))).scalar() # Absent, not zero, when there is nothing. A zero would claim prices # were last updated in 1970 and fire the staleness alert on every fresh # install; absent lets a rule use `absent()` if it wants to say # something about that case, and say nothing if it does not. if last_price is not None: out.append(_render( "stocktake_last_price_date_seconds", "Trading day of the most recent close held, as unix seconds (UTC midnight).", "gauge", _midnight_utc(last_price), )) if last_fx is not None: out.append(_render( "stocktake_last_fx_date_seconds", "Date of the most recent FX rate held, as unix seconds (UTC midnight).", "gauge", _midnight_utc(last_fx), )) out.append(_render( "stocktake_feed_runs_total", "Price feed runs since this process started.", "counter", _runs, )) out.append(_render( "stocktake_feed_failures_total", "Price feed runs that raised, since this process started.", "counter", _failures, )) if _last_success is not None: out.append(_render( "stocktake_feed_last_success_seconds", "When the price feed last completed without raising, as unix seconds.", "gauge", _last_success, )) return "".join(out)