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 mode_code(s: &str) -> u64 {
47 match s {
48 "cadence" => 0,
49 "hybrid" => 1,
50 "gated" => 2,
51 _ => 99,
52 }
53}
54
55fn 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
68fn 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
83fn 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
191pub 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
201pub 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
227pub struct MetricsPosture {
228 pub enabled: bool,
229 pub port: u16,
230 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}