Skip to main content

flint_lean/
metrics.rs

1//! `/metrics` (D15, §8 Q5) — Prometheus exposition rendered from the
2//! SAME struct `gauges.json` is written from.
3//!
4//! One renderer over one struct cannot drift; two computations would,
5//! and the drift would be invisible until an operator made a decision
6//! on the number that was wrong. That is why [`render`] takes a
7//! [`Gauges`] and nothing else — it cannot consult the store, the
8//! stage, or the clock, so a scrape costs exactly zero bucket requests
9//! by construction rather than by care (leg B8's oracle counts them).
10//!
11//! **The label set is fixed at `{workspace, namespace}` and that is a
12//! rule, not a default.** A per-path or per-file metric would multiply
13//! series by the workspace's inventory — 250,000 files is the shipped
14//! cap — and the fleet is 3,000 workspaces. The parity test enforces
15//! the label keys exactly.
16//!
17//! **String-valued gauges are rendered as numeric enums**, not as
18//! labels, for the same reason: `reason="parked-412"` would add a label key
19//! and open the door to the next one. The mapping is in each metric's
20//! HELP line, where a human reading the exposition can see it.
21
22use super::gauges::Gauges;
23
24/// The only labels any series carries.
25#[derive(Debug, Clone)]
26pub struct Labels {
27    pub workspace: String,
28    pub namespace: String,
29}
30
31impl Labels {
32    fn render(&self) -> String {
33        format!(
34            "{{workspace=\"{}\",namespace=\"{}\"}}",
35            escape(&self.workspace),
36            escape(&self.namespace)
37        )
38    }
39}
40
41fn escape(s: &str) -> String {
42    s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', " ")
43}
44
45/// `none` → 0, and one code per `Withheld` variant.
46fn withheld_code(s: Option<&str>) -> u64 {
47    match s {
48        None => 0,
49        Some("parked-412") => 1,
50        Some(_) => 99,
51    }
52}
53
54/// One code per boundary source (`gauges::LastBoundary::source`).
55fn source_code(s: &str) -> u64 {
56    match s {
57        "sentinel" => 1,
58        "sentinel-deferred" => 2,
59        "cadence" => 3,
60        "drain" => 4,
61        _ => 99,
62    }
63}
64
65/// Every metric this syncer exposes: the metric name, its HELP text,
66/// and how it is read out of the gauges. The table is the contract the
67/// parity test checks — a gauges field with no row here fails the
68/// build's tests rather than silently going unexposed.
69fn series(g: &Gauges) -> Vec<(&'static str, &'static str, u64)> {
70    vec![
71        (
72            "flint_lean_rpo_seconds",
73            "seconds since the last boundary was installed. Elapsed time, not exposure: an \
74             idle healthy workspace has nothing at risk and a growing value — pair it with \
75             flint_lean_withheld_reason before alerting",
76            g.rpo_secs,
77        ),
78        (
79            "flint_lean_withheld_reason",
80            "why visibility is withheld: 0=none 1=parked-412",
81            withheld_code(g.withheld_reason.as_deref()),
82        ),
83        (
84            "flint_lean_sentinel_budget_remaining",
85            "work units left in this hour's sentinel budget (D3.1). Zero means honors \
86             defer to the floor — the workspace is running at exactly cadence behavior",
87            g.sentinel_budget_remaining,
88        ),
89        (
90            "flint_lean_last_boundary_source",
91            "which clock installed the last boundary: 1=sentinel 2=sentinel-deferred \
92             3=cadence 4=drain, 0=none yet",
93            g.last_boundary.as_ref().map(|b| source_code(&b.source)).unwrap_or(0),
94        ),
95        (
96            "flint_lean_last_boundary_seq",
97            "the manifest seq the last boundary installed",
98            g.last_boundary.as_ref().map(|b| b.seq).unwrap_or(0),
99        ),
100        (
101            "flint_lean_last_boundary_timestamp_seconds",
102            "unix time of the last boundary",
103            g.last_boundary.as_ref().map(|b| b.unix).unwrap_or(0),
104        ),
105        (
106            "flint_lean_auth_paused_since_timestamp_seconds",
107            "unix time of the first renewal the store refused with 401/403 since the last \
108             successful one; 0 = not paused. A credential, token or clock fault — never \
109             contention and never a deposal, and retrying does not fix it. It is exported \
110             as a timestamp rather than a duration because a paused holder cannot renew, \
111             and a holder that cannot renew is what a challenger reads as DEAD: alert on \
112             time() - this > the takeover threshold, which is the window in which a live \
113             writer gets deposed (design 6.3)",
114            g.auth_paused_since_unix.unwrap_or(0),
115        ),
116        (
117            "flint_lean_updated_timestamp_seconds",
118            "unix time these gauges were last refreshed. Refreshed on EVERY tick, news or \
119             not, so an idle-but-healthy workspace is distinguishable from a dead one",
120            g.updated_unix,
121        ),
122        (
123            "flint_lean_last_durable_timestamp_seconds",
124            "unix time of the last durable write (gauges.last_durable_unix)",
125            g.last_durable_unix,
126        ),
127    ]
128}
129
130/// Render the exposition. Pure, synchronous, store-free.
131pub fn render(g: &Gauges, labels: &Labels) -> String {
132    let l = labels.render();
133    let mut out = String::new();
134    for (name, help, value) in series(g) {
135        out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n{name}{l} {value}\n"));
136    }
137    out
138}
139
140/// Which gauges field each metric reports, for the parity test. Kept
141/// beside `series` so adding a metric without saying what it covers is
142/// a compile-time-visible omission rather than a silent gap.
143pub const COVERED_FIELDS: &[&str] = &[
144    "rpo_secs",
145    "withheld_reason",
146    "sentinel_budget_remaining",
147    "last_boundary",
148    "updated_unix",
149    "last_durable_unix",
150    "auth_paused_since_unix",
151];
152
153/// What the syncer recorded about its own exposition attempt. Written
154/// to the state directory at startup and echoed to the operator: a bind
155/// collision has to be VISIBLE, because the design's answer to it is to
156/// keep running, and a degradation nobody can see is indistinguishable
157/// from a feature nobody enabled.
158#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
159pub struct MetricsPosture {
160    pub enabled: bool,
161    pub port: u16,
162    /// False while `enabled` is true ⇒ the port was taken (the agent
163    /// container is the likely occupant) and the listener is not up.
164    pub bound: bool,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub error: Option<String>,
167}
168
169pub const POSTURE_FILE: &str = "metrics.json";
170
171impl super::Syncer {
172    pub fn save_metrics_posture(&self, p: &MetricsPosture) -> super::LeanResult<()> {
173        let bytes = serde_json::to_vec_pretty(p)
174            .map_err(|e| super::LeanError::State(format!("metrics posture: {e}")))?;
175        super::control::write_atomic(&self.cfg.state_dir().join(POSTURE_FILE), &bytes)
176    }
177
178    pub fn load_metrics_posture(&self) -> Option<MetricsPosture> {
179        serde_json::from_slice(&std::fs::read(self.cfg.state_dir().join(POSTURE_FILE)).ok()?).ok()
180    }
181}