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: `mode="gated"` 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/// `cadence` → 0, `hybrid` → 1, `gated` → 2.
46fn mode_code(s: &str) -> u64 {
47    match s {
48        "cadence" => 0,
49        "hybrid" => 1,
50        "gated" => 2,
51        _ => 99,
52    }
53}
54
55/// `none` → 0, and one code per `Withheld` variant.
56fn withheld_code(s: Option<&str>) -> u64 {
57    match s {
58        None => 0,
59        Some("quiesce-pending") => 1,
60        Some("awaiting-boundary") => 2,
61        Some("parked-412") => 3,
62        Some("cas-conflict") => 4,
63        Some("version-probe-failed") => 5,
64        Some(_) => 99,
65    }
66}
67
68/// One code per citation source (`gauges::LastBoundary::source`).
69fn source_code(s: &str) -> u64 {
70    match s {
71        "sentinel" => 1,
72        "quiescence" => 2,
73        "forced-lag-cap" => 3,
74        "forced-backlog-cap" => 4,
75        "cadence" => 5,
76        "drain" => 6,
77        "recovered" => 7,
78        "repair" => 8,
79        _ => 99,
80    }
81}
82
83/// Every metric this syncer exposes: the metric name, its HELP text,
84/// and how it is read out of the gauges. The table is the contract the
85/// parity test checks — a gauges field with no row here fails the
86/// build's tests rather than silently going unexposed.
87fn series(g: &Gauges) -> Vec<(&'static str, &'static str, u64)> {
88    vec![
89        (
90            "flint_lean_fenced",
91            "1 when this syncer has been deposed and stopped publishing (gauges.state)",
92            u64::from(g.state == "fenced"),
93        ),
94        (
95            "flint_lean_boundary_mode",
96            "citation policy in force: 0=cadence 1=hybrid 2=gated (gauges.boundary_mode)",
97            mode_code(&g.boundary_mode),
98        ),
99        (
100            "flint_lean_rpo_seconds",
101            "seconds since the last DURABLE write. Elapsed time, not exposure: an idle \
102             healthy workspace has nothing at risk and a growing value — pair it with \
103             flint_lean_staged_uncited_objects before alerting",
104            g.rpo_secs,
105        ),
106        (
107            "flint_lean_visibility_lag_seconds",
108            "seconds since the last CITATION. In cadence/hybrid this equals the RPO by \
109             construction; in gated it is the number visibilityLagBoundSecs caps",
110            g.visibility_lag_secs,
111        ),
112        (
113            "flint_lean_staged_uncited_objects",
114            "durable objects no manifest cites yet. This is gated mode's whole exposure: \
115             what a pod replacement would strand for `flint-sync recover-staged`",
116            g.staged_uncited_count,
117        ),
118        (
119            "flint_lean_staged_uncited_bytes",
120            "bytes staged and uncited",
121            g.staged_uncited_bytes,
122        ),
123        (
124            "flint_lean_cited_noncurrent_age_max_seconds",
125            "how long the OLDEST still-cited version has been noncurrent. Gated staging \
126             makes the cited version noncurrent, so the retention backstop runs a clock \
127             against live cited data — noncurrentRetentionDays is the number this must \
128             never reach",
129            g.cited_noncurrent_age_max_secs,
130        ),
131        (
132            "flint_lean_withheld_reason",
133            "why visibility is withheld: 0=none 1=quiesce-pending 2=awaiting-boundary \
134             3=parked-412 4=cas-conflict 5=version-probe-failed",
135            withheld_code(g.withheld_reason.as_deref()),
136        ),
137        (
138            "flint_lean_sentinel_budget_remaining",
139            "work units left in this hour's sentinel budget (D3.1). Zero means honors \
140             defer to the floor — the workspace is running at exactly cadence behavior",
141            g.sentinel_budget_remaining,
142        ),
143        (
144            "flint_lean_forced_citations_total",
145            "citations forced by a cap rather than taken at a declared coherent point. A \
146             workspace that forces every citation has no coherence contract left",
147            g.forced_citation_count,
148        ),
149        (
150            "flint_lean_last_boundary_source",
151            "which coherent point installed the last citation: 1=sentinel 2=quiescence \
152             3=forced-lag-cap 4=forced-backlog-cap 5=cadence 6=drain 7=recovered 8=repair, \
153             0=none yet",
154            g.last_boundary.as_ref().map(|b| source_code(&b.source)).unwrap_or(0),
155        ),
156        (
157            "flint_lean_last_boundary_seq",
158            "the manifest seq the last citation installed",
159            g.last_boundary.as_ref().map(|b| b.seq).unwrap_or(0),
160        ),
161        (
162            "flint_lean_last_boundary_timestamp_seconds",
163            "unix time of the last citation",
164            g.last_boundary.as_ref().map(|b| b.unix).unwrap_or(0),
165        ),
166        (
167            "flint_lean_auth_paused_since_timestamp_seconds",
168            "unix time of the first renewal the store refused with 401/403 since the last \
169             successful one; 0 = not paused. A credential, token or clock fault — never \
170             contention and never a deposal, and retrying does not fix it. It is exported \
171             as a timestamp rather than a duration because a paused holder cannot renew, \
172             and a holder that cannot renew is what a challenger reads as DEAD: alert on \
173             time() - this > the takeover threshold, which is the window in which a live \
174             writer gets deposed (design 6.3)",
175            g.auth_paused_since_unix.unwrap_or(0),
176        ),
177        (
178            "flint_lean_updated_timestamp_seconds",
179            "unix time these gauges were last refreshed. Refreshed on EVERY tick, news or \
180             not, so an idle-but-healthy workspace is distinguishable from a dead one",
181            g.updated_unix,
182        ),
183        (
184            "flint_lean_last_durable_timestamp_seconds",
185            "unix time of the last durable write (gauges.last_durable_unix)",
186            g.last_durable_unix,
187        ),
188    ]
189}
190
191/// Render the exposition. Pure, synchronous, store-free.
192pub fn render(g: &Gauges, labels: &Labels) -> String {
193    let l = labels.render();
194    let mut out = String::new();
195    for (name, help, value) in series(g) {
196        out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n{name}{l} {value}\n"));
197    }
198    out
199}
200
201/// Which gauges field each metric reports, for the parity test. Kept
202/// beside `series` so adding a metric without saying what it covers is
203/// a compile-time-visible omission rather than a silent gap.
204pub const COVERED_FIELDS: &[&str] = &[
205    "state",
206    "boundary_mode",
207    "rpo_secs",
208    "visibility_lag_secs",
209    "staged_uncited_count",
210    "staged_uncited_bytes",
211    "cited_noncurrent_age_max_secs",
212    "withheld_reason",
213    "sentinel_budget_remaining",
214    "forced_citation_count",
215    "last_boundary",
216    "updated_unix",
217    "last_durable_unix",
218    "auth_paused_since_unix",
219];
220
221/// What the syncer recorded about its own exposition attempt. Written
222/// to the state directory at startup and echoed to the operator: a bind
223/// collision has to be VISIBLE, because the design's answer to it is to
224/// keep running, and a degradation nobody can see is indistinguishable
225/// from a feature nobody enabled.
226#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
227pub struct MetricsPosture {
228    pub enabled: bool,
229    pub port: u16,
230    /// False while `enabled` is true ⇒ the port was taken (the agent
231    /// container is the likely occupant) and the listener is not up.
232    pub bound: bool,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub error: Option<String>,
235}
236
237pub const POSTURE_FILE: &str = "metrics.json";
238
239impl super::Syncer {
240    pub fn save_metrics_posture(&self, p: &MetricsPosture) -> super::LeanResult<()> {
241        let bytes = serde_json::to_vec_pretty(p)
242            .map_err(|e| super::LeanError::State(format!("metrics posture: {e}")))?;
243        super::control::write_atomic(&self.cfg.state_dir().join(POSTURE_FILE), &bytes)
244    }
245
246    pub fn load_metrics_posture(&self) -> Option<MetricsPosture> {
247        serde_json::from_slice(&std::fs::read(self.cfg.state_dir().join(POSTURE_FILE)).ok()?).ok()
248    }
249}