1use hoike_core::router::ScopeDetail;
13
14#[macro_export]
21macro_rules! audit {
22 ($($arg:tt)*) => {
23 ::tracing::info!(target: "audit", $($arg)*);
24 };
25}
26pub use crate::audit;
30
31pub fn load_failure_reason(err: &hoike_core::CoreError) -> &'static str {
37 use hoike_core::CoreError::*;
38 match err {
39 EpochRollback { .. } | EpochJumpTooLarge { .. } => "rollback",
40 ForkDetected { .. } => "fork",
41 Bundle(_) => "bundle",
42 StateStore(_) => "state",
43 Io(_) => "io",
44 _ => "other",
45 }
46}
47
48#[cfg(feature = "metrics")]
52mod imp {
53 use super::ScopeDetail;
54 use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
55 use std::sync::OnceLock;
56
57 static HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
58
59 pub fn install() -> bool {
62 if HANDLE.get().is_some() {
63 return false;
64 }
65 match PrometheusBuilder::new().install_recorder() {
66 Ok(handle) => {
67 describe();
68 HANDLE.set(handle).is_ok()
69 }
70 Err(e) => {
71 tracing::warn!(error = %e, "failed to install Prometheus recorder");
72 false
73 }
74 }
75 }
76
77 pub fn render() -> Option<String> {
80 HANDLE.get().map(|h| h.render())
81 }
82
83 fn describe() {
84 use metrics::{describe_counter, describe_gauge, describe_histogram};
85 describe_counter!(
86 "hoike_requests_total",
87 "OCSP requests processed, by CA, HTTP method, and response status"
88 );
89 describe_histogram!(
90 "hoike_request_duration_seconds",
91 "OCSP request processing latency in seconds, by CA"
92 );
93 describe_counter!(
94 "hoike_certid_hash_alg_total",
95 "CertID hashAlgorithm OIDs seen in requests, by CA"
96 );
97 describe_counter!(
98 "hoike_nonce_requests_total",
99 "Nonce handling outcomes, by CA, policy, and outcome"
100 );
101 describe_gauge!("hoike_bundle_epoch", "Current epoch per CA scope");
102 describe_gauge!("hoike_bundle_entries", "Entry count per CA scope");
103 describe_gauge!(
104 "hoike_bundle_age_seconds",
105 "Seconds since the serving bundle was produced, per CA scope"
106 );
107 describe_gauge!(
108 "hoike_bundle_next_update_seconds",
109 "Seconds until the serving bundle's nextUpdate, per CA scope"
110 );
111 describe_counter!(
112 "hoike_bundle_load_failures_total",
113 "Bundle load/reload failures, by CA and reason"
114 );
115 describe_histogram!(
116 "hoike_signer_generation_duration_seconds",
117 "Signer bundle-generation latency in seconds, by CA"
118 );
119 describe_gauge!(
120 "hoike_gossip_members",
121 "Known gossip fleet members by SWIM state (alive/suspect/down)"
122 );
123 }
124
125 pub fn record_request(ca: &str, method: &'static str, status: &'static str) {
126 metrics::counter!(
127 "hoike_requests_total",
128 "ca" => ca.to_string(),
129 "method" => method,
130 "status" => status,
131 )
132 .increment(1);
133 }
134
135 pub fn record_request_duration(ca: &str, seconds: f64) {
136 metrics::histogram!("hoike_request_duration_seconds", "ca" => ca.to_string())
137 .record(seconds);
138 }
139
140 pub fn record_certid_alg(ca: &str, alg: &str) {
141 metrics::counter!(
142 "hoike_certid_hash_alg_total",
143 "ca" => ca.to_string(),
144 "alg" => alg.to_string(),
145 )
146 .increment(1);
147 }
148
149 pub fn record_nonce(ca: &str, policy: &str, outcome: &'static str) {
150 metrics::counter!(
151 "hoike_nonce_requests_total",
152 "ca" => ca.to_string(),
153 "policy" => policy.to_string(),
154 "outcome" => outcome,
155 )
156 .increment(1);
157 }
158
159 pub fn record_bundle_load_failure(ca: &str, reason: &'static str) {
160 metrics::counter!(
161 "hoike_bundle_load_failures_total",
162 "ca" => ca.to_string(),
163 "reason" => reason,
164 )
165 .increment(1);
166 }
167
168 pub fn record_signer_generation(ca: &str, seconds: f64) {
169 metrics::histogram!("hoike_signer_generation_duration_seconds", "ca" => ca.to_string())
170 .record(seconds);
171 }
172
173 pub fn record_gossip_members(alive: u64, suspect: u64, down: u64) {
177 metrics::gauge!("hoike_gossip_members", "state" => "alive").set(alive as f64);
178 metrics::gauge!("hoike_gossip_members", "state" => "suspect").set(suspect as f64);
179 metrics::gauge!("hoike_gossip_members", "state" => "down").set(down as f64);
180 }
181
182 pub fn update_bundle_gauges(scopes: &[ScopeDetail], now: u64) {
186 for s in scopes {
187 let labels = [
188 ("ca", s.ca_label.clone()),
189 ("producer", s.producer_id.clone()),
190 ];
191 metrics::gauge!("hoike_bundle_epoch", &labels).set(s.epoch as f64);
192 metrics::gauge!("hoike_bundle_entries", &labels).set(s.entry_count as f64);
193 let age = now.saturating_sub(s.window.produced_at);
194 metrics::gauge!("hoike_bundle_age_seconds", &labels).set(age as f64);
195 let ttl = s.window.next_update_min.saturating_sub(now);
196 metrics::gauge!("hoike_bundle_next_update_seconds", &labels).set(ttl as f64);
197 }
198 }
199}
200
201#[cfg(not(feature = "metrics"))]
205mod imp {
206 use super::ScopeDetail;
207
208 pub fn install() -> bool {
209 false
210 }
211 pub fn render() -> Option<String> {
212 None
213 }
214 #[inline]
215 pub fn record_request(_ca: &str, _method: &'static str, _status: &'static str) {}
216 #[inline]
217 pub fn record_request_duration(_ca: &str, _seconds: f64) {}
218 #[inline]
219 pub fn record_certid_alg(_ca: &str, _alg: &str) {}
220 #[inline]
221 pub fn record_nonce(_ca: &str, _policy: &str, _outcome: &'static str) {}
222 #[inline]
223 pub fn record_bundle_load_failure(_ca: &str, _reason: &'static str) {}
224 #[inline]
225 pub fn record_signer_generation(_ca: &str, _seconds: f64) {}
226 #[inline]
227 pub fn record_gossip_members(_alive: u64, _suspect: u64, _down: u64) {}
228 #[inline]
229 pub fn update_bundle_gauges(_scopes: &[ScopeDetail], _now: u64) {}
230}
231
232pub use imp::*;
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use hoike_core::CoreError;
238
239 #[test]
240 fn load_failure_reason_classifies_each_variant() {
241 assert_eq!(
243 load_failure_reason(&CoreError::EpochRollback {
244 scope: "test".into(),
245 epoch: 1,
246 high_water: 2,
247 }),
248 "rollback"
249 );
250 assert_eq!(
251 load_failure_reason(&CoreError::EpochJumpTooLarge {
252 scope: "test".into(),
253 epoch: 100,
254 high_water: 1,
255 jump: 99,
256 max_jump: 24,
257 }),
258 "rollback"
259 );
260 assert_eq!(
261 load_failure_reason(&CoreError::ForkDetected {
262 scope: "test".into(),
263 }),
264 "fork"
265 );
266 assert_eq!(
268 load_failure_reason(&CoreError::Bundle(ahu::AhuError::BadMagic {
269 found: *b"XXXX",
270 })),
271 "bundle"
272 );
273 assert_eq!(
274 load_failure_reason(&CoreError::StateStore("sled boom".into())),
275 "state"
276 );
277 assert_eq!(
278 load_failure_reason(&CoreError::Io(std::io::Error::other("disk gone"))),
279 "io"
280 );
281 assert_eq!(load_failure_reason(&CoreError::NoMatchingScope), "other");
283 assert_eq!(
284 load_failure_reason(&CoreError::Config("bad toml".into())),
285 "other"
286 );
287 }
288
289 #[cfg(feature = "metrics")]
293 #[test]
294 fn metrics_registry_snapshot_contains_all_series() {
295 assert!(install(), "recorder should install on first call");
296 assert!(!install(), "second install is a no-op");
297
298 record_request("ca-a", "get", "good");
299 record_request_duration("ca-a", 0.001);
300 record_certid_alg("ca-a", "2.16.840.1.101.3.4.2.1");
301 record_nonce("ca-a", "ignore", "ignored");
302 record_bundle_load_failure("ca-a", "rollback");
303 record_signer_generation("ca-a", 0.05);
304
305 let scopes = [ScopeDetail {
306 ca_label: "ca-a".into(),
307 producer_id: "hoike-combined".into(),
308 epoch: 3,
309 completeness: "authoritative-complete".into(),
310 entry_count: 7,
311 window: ahu::Window {
312 produced_at: 1000,
313 this_update_min: 1000,
314 next_update_min: 5000,
315 next_update_max: 5000,
316 },
317 }];
318 update_bundle_gauges(&scopes, 2000);
319
320 let body = render().expect("recorder installed, render must succeed");
321 for series in [
322 "hoike_requests_total",
323 "hoike_request_duration_seconds",
324 "hoike_certid_hash_alg_total",
325 "hoike_nonce_requests_total",
326 "hoike_bundle_load_failures_total",
327 "hoike_signer_generation_duration_seconds",
328 "hoike_bundle_epoch",
329 "hoike_bundle_entries",
330 "hoike_bundle_age_seconds",
331 "hoike_bundle_next_update_seconds",
332 ] {
333 assert!(
334 body.contains(series),
335 "exposition must contain {series}; got:\n{body}"
336 );
337 }
338 assert!(
340 body.contains("hoike_bundle_age_seconds") && body.contains("1000"),
341 "age gauge should be now - produced_at = 1000"
342 );
343 }
344}