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("unreadable") => 2,
51 Some(_) => 99,
52 }
53}
54
55fn source_code(s: &str) -> u64 {
57 match s {
58 "sentinel" => 1,
59 "sentinel-deferred" => 2,
60 "cadence" => 3,
61 "drain" => 4,
62 _ => 99,
63 }
64}
65
66fn series(g: &Gauges) -> Vec<(&'static str, &'static str, u64)> {
71 vec![
72 (
73 "flint_lean_rpo_seconds",
74 "seconds since the last boundary was installed. Elapsed time, not exposure: an \
75 idle healthy workspace has nothing at risk and a growing value — pair it with \
76 flint_lean_withheld_reason before alerting",
77 g.rpo_secs,
78 ),
79 (
80 "flint_lean_withheld_reason",
81 "why visibility is withheld: 0=none 1=parked-412 2=unreadable",
82 withheld_code(g.withheld_reason.as_deref()),
83 ),
84 (
85 "flint_lean_sentinel_budget_remaining",
86 "work units left in this hour's sentinel budget (D3.1). Zero means honors \
87 defer to the floor — the workspace is running at exactly cadence behavior",
88 g.sentinel_budget_remaining,
89 ),
90 (
91 "flint_lean_last_boundary_source",
92 "which clock installed the last boundary: 1=sentinel 2=sentinel-deferred \
93 3=cadence 4=drain, 0=none yet",
94 g.last_boundary.as_ref().map(|b| source_code(&b.source)).unwrap_or(0),
95 ),
96 (
97 "flint_lean_last_boundary_seq",
98 "the manifest seq the last boundary installed",
99 g.last_boundary.as_ref().map(|b| b.seq).unwrap_or(0),
100 ),
101 (
102 "flint_lean_last_boundary_timestamp_seconds",
103 "unix time of the last boundary",
104 g.last_boundary.as_ref().map(|b| b.unix).unwrap_or(0),
105 ),
106 (
107 "flint_lean_auth_paused_since_timestamp_seconds",
108 "unix time of the first renewal the store refused with 401/403 since the last \
109 successful one; 0 = not paused. A credential, token or clock fault — never \
110 contention and never a deposal, and retrying does not fix it. It is exported \
111 as a timestamp rather than a duration because a paused holder cannot renew, \
112 and a holder that cannot renew is what a challenger reads as DEAD: alert on \
113 time() - this > the takeover threshold, which is the window in which a live \
114 writer gets deposed (design 6.3)",
115 g.auth_paused_since_unix.unwrap_or(0),
116 ),
117 (
118 "flint_lean_updated_timestamp_seconds",
119 "unix time these gauges were last refreshed. Refreshed on EVERY tick, news or \
120 not, so an idle-but-healthy workspace is distinguishable from a dead one",
121 g.updated_unix,
122 ),
123 (
124 "flint_lean_last_durable_timestamp_seconds",
125 "unix time of the last durable write (gauges.last_durable_unix)",
126 g.last_durable_unix,
127 ),
128 ]
129}
130
131pub fn render(g: &Gauges, labels: &Labels) -> String {
133 let l = labels.render();
134 let mut out = String::new();
135 for (name, help, value) in series(g) {
136 out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n{name}{l} {value}\n"));
137 }
138 out
139}
140
141pub const COVERED_FIELDS: &[&str] = &[
145 "rpo_secs",
146 "withheld_reason",
147 "sentinel_budget_remaining",
148 "last_boundary",
149 "updated_unix",
150 "last_durable_unix",
151 "auth_paused_since_unix",
152];
153
154#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
160pub struct MetricsPosture {
161 pub enabled: bool,
162 pub port: u16,
163 pub bound: bool,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub error: Option<String>,
168}
169
170pub const POSTURE_FILE: &str = "metrics.json";
171
172impl super::Syncer {
173 pub fn save_metrics_posture(&self, p: &MetricsPosture) -> super::LeanResult<()> {
174 let bytes = serde_json::to_vec_pretty(p)
175 .map_err(|e| super::LeanError::State(format!("metrics posture: {e}")))?;
176 super::control::write_atomic(&self.cfg.state_dir().join(POSTURE_FILE), &bytes)
177 }
178
179 pub fn load_metrics_posture(&self) -> Option<MetricsPosture> {
180 serde_json::from_slice(&std::fs::read(self.cfg.state_dir().join(POSTURE_FILE)).ok()?).ok()
181 }
182}