Skip to main content

hoike_server/
obs.rs

1//! Observability facade: Prometheus metrics + structured audit log.
2//!
3//! Every recording function here is a thin wrapper that compiles to a no-op
4//! unless the `metrics` feature is enabled. This keeps instrumentation call
5//! sites in the OCSP hot path free of `#[cfg]` noise and imposes zero cost on
6//! the default build. The audit log (`audit!`) is always active — it is a
7//! `tracing` event on the dedicated `audit` target, which existing subscribers
8//! can route to their own sink.
9//!
10//! Design reference: `hoike-design.md` §9 (metric surface).
11
12use hoike_core::router::ScopeDetail;
13
14/// Emit a structured audit event on the `audit` tracing target.
15///
16/// Audit events are operational-security records — bundle loads, epoch
17/// transitions, request rejections, and signer generations — kept distinct
18/// from ordinary diagnostic logging so operators can route them to a separate,
19/// longer-retention sink.
20#[macro_export]
21macro_rules! audit {
22    ($($arg:tt)*) => {
23        ::tracing::info!(target: "audit", $($arg)*);
24    };
25}
26// Re-export so `crate::obs::audit!` (internal) and `hoike_server::obs::audit!`
27// (from the CLI crate) both resolve to the crate-root macro that `#[macro_export]`
28// creates.
29pub use crate::audit;
30
31/// Classify a bundle load/reload error into a stable `reason` label for the
32/// `hoike_bundle_load_failures_total` counter. Anti-rollback and continuity
33/// violations are the operationally interesting cases; everything else collapses
34/// to coarse buckets. Always available (no metrics dep) so call sites can
35/// classify even in the default build.
36pub 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// ---------------------------------------------------------------------------
49// Feature-on implementation
50// ---------------------------------------------------------------------------
51#[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    /// Install the global Prometheus recorder. Idempotent: a second call while a
60    /// recorder is already installed returns `false` and changes nothing.
61    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    /// Render the current metric registry in Prometheus text exposition format.
78    /// Returns `None` if the recorder was never installed.
79    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    /// Set the fleet-membership gauge from a scrape-time census of SWIM states.
174    /// Values are absolute counts per state; a state with zero members is still
175    /// emitted so the series never silently disappears from a scrape.
176    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    /// Refresh the bundle freshness gauges from the currently loaded scopes.
183    /// Called on each `/metrics` scrape (collect-on-scrape), so the age and
184    /// next-update figures are computed against the scrape-time clock.
185    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// ---------------------------------------------------------------------------
202// Feature-off implementation (all no-ops)
203// ---------------------------------------------------------------------------
204#[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        // Anti-rollback / continuity — the operationally interesting cases.
242        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        // Coarse buckets.
267        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        // Everything else collapses to "other".
282        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    // The recorder is a process-global singleton (`install_recorder`), so this
290    // single test drives the whole feature-on surface: install once, record one
291    // sample of every series, then assert the rendered exposition names them.
292    #[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        // Spot-check a computed gauge value: age = now(2000) - produced_at(1000).
339        assert!(
340            body.contains("hoike_bundle_age_seconds") && body.contains("1000"),
341            "age gauge should be now - produced_at = 1000"
342        );
343    }
344}