1use super::gauges::Gauges;
23
24#[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
45fn withheld_code(s: Option<&str>) -> u64 {
47 match s {
48 None => 0,
49 Some("parked-412") => 1,
50 Some(_) => 99,
51 }
52}
53
54fn 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
65fn 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
130pub 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
140pub 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
159pub struct MetricsPosture {
160 pub enabled: bool,
161 pub port: u16,
162 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}