Skip to main content

chio_metrics_spec/
lib.rs

1//! Workspace-wide metric registry for Chio SRE surfaces.
2//!
3//! New Prometheus metric names must be added here first, then consumed from
4//! constants instead of inlining string literals at emission sites. The
5//! snapshot test in this crate is the CI gate for taxonomy drift.
6
7#![forbid(unsafe_code)]
8
9pub mod runtime;
10
11/// Prometheus metric family kind.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub enum MetricKind {
14    Counter,
15    Gauge,
16    Histogram,
17}
18
19impl MetricKind {
20    #[must_use]
21    pub const fn as_str(self) -> &'static str {
22        match self {
23            Self::Counter => "counter",
24            Self::Gauge => "gauge",
25            Self::Histogram => "histogram",
26        }
27    }
28}
29
30/// One registered metric family.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct MetricDescriptor {
33    pub name: &'static str,
34    pub help: &'static str,
35    pub kind: MetricKind,
36    pub labels: &'static [&'static str],
37    pub buckets: &'static [&'static str],
38}
39
40/// Semantic validation failure for metric descriptors.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum MetricValidationError {
43    InvalidMetricName {
44        name: &'static str,
45    },
46    InvalidLabelName {
47        metric: &'static str,
48        label: &'static str,
49    },
50    DuplicateLabelName {
51        metric: &'static str,
52        label: &'static str,
53    },
54    UnexpectedBuckets {
55        metric: &'static str,
56        kind: MetricKind,
57    },
58    MissingHistogramBuckets {
59        metric: &'static str,
60    },
61    InvalidHistogramBucket {
62        metric: &'static str,
63        bucket: &'static str,
64    },
65    NonIncreasingHistogramBucket {
66        metric: &'static str,
67        previous: &'static str,
68        bucket: &'static str,
69    },
70    DuplicateMetricName {
71        metric: &'static str,
72    },
73    RegistryNotSorted {
74        previous: &'static str,
75        current: &'static str,
76    },
77}
78
79/// Declare a metric descriptor from a const name.
80///
81/// The macro intentionally accepts a single name expression plus literal
82/// metadata. Sites that need a new name add a constant first, which keeps the
83/// grep gate and snapshot aligned.
84#[macro_export]
85macro_rules! describe {
86    (
87        name = $name:expr,
88        help = $help:literal,
89        kind = $kind:ident,
90        labels = [$($label:literal),* $(,)?],
91        buckets = [$($bucket:literal),* $(,)?] $(,)?
92    ) => {
93        $crate::MetricDescriptor {
94            name: $name,
95            help: $help,
96            kind: $crate::MetricKind::$kind,
97            labels: &[$($label),*],
98            buckets: &[$($bucket),*],
99        }
100    };
101    (
102        name = $name:expr,
103        help = $help:literal,
104        kind = $kind:ident,
105        labels = [$($label:literal),* $(,)?] $(,)?
106    ) => {
107        $crate::describe!(
108            name = $name,
109            help = $help,
110            kind = $kind,
111            labels = [$($label),*],
112            buckets = []
113        )
114    };
115}
116
117pub const CHIO_ALERT_DISPATCH_TOTAL: &str = "chio_alert_dispatch_total";
118pub const CHIO_ALERT_DISPATCH_LATENCY_SECONDS: &str = "chio_alert_dispatch_latency_seconds";
119pub const CHIO_AMBIGUOUS_DISPATCH_RETAINED_HOLD_TOTAL: &str =
120    "chio_ambiguous_dispatch_retained_hold_total";
121pub const CHIO_ANCHOR_ROUND_LATENCY_SECONDS: &str = "chio_anchor_round_latency_seconds";
122pub const CHIO_CAPABILITY_REVOCATION_LAG_SECONDS: &str = "chio_capability_revocation_lag_seconds";
123pub const CHIO_DISPATCH_FAILURE_TOTAL: &str = "chio_dispatch_failure_total";
124pub const CHIO_DLQ_DEPTH: &str = "chio_dlq_depth";
125pub const CHIO_FAIL_OPEN_SUSPECTED_TOTAL: &str = "chio_fail_open_suspected_total";
126pub const CHIO_FEDERATION_HOP_LATENCY_SECONDS: &str = "chio_federation_hop_latency_seconds";
127pub const CHIO_FEDERATION_HOP_TOTAL: &str = "chio_federation_hop_total";
128pub const CHIO_FEDERATION_TRANSPORT_ACCEPT_DURATION_SECONDS: &str =
129    "chio_federation_transport_accept_duration_seconds";
130pub const CHIO_FEDERATION_TRANSPORT_ACCEPT_OPEN: &str = "chio_federation_transport_accept_open";
131pub const CHIO_FEDERATION_TRANSPORT_ADMISSION_TOTAL: &str =
132    "chio_federation_transport_admission_total";
133pub const CHIO_FEDERATION_TRANSPORT_CATCHUP_EPOCH_GAP_TOTAL: &str =
134    "chio_federation_transport_catchup_epoch_gap_total";
135pub const CHIO_FEDERATION_TRANSPORT_DIRECTORY_RELOAD_TOTAL: &str =
136    "chio_federation_transport_directory_reload_total";
137pub const CHIO_FEDERATION_TRANSPORT_LANE_TOTAL: &str = "chio_federation_transport_lane_total";
138pub const CHIO_FEDERATION_TRANSPORT_OUTBOX_TOTAL: &str = "chio_federation_transport_outbox_total";
139pub const CHIO_FEDERATION_TRANSPORT_ROUTER_ALIVE: &str = "chio_federation_transport_router_alive";
140pub const CHIO_FEDERATION_TRANSPORT_VERIFY_FAILURES_TOTAL: &str =
141    "chio_federation_transport_verify_failures_total";
142pub const CHIO_GUARD_DENY_TOTAL: &str = "chio_guard_deny_total";
143pub const CHIO_GUARD_EVAL_DURATION_SECONDS: &str = "chio_guard_eval_duration_seconds";
144pub const CHIO_GUARD_EVALUATIONS_TOTAL: &str = "chio_guard_evaluations_total";
145pub const CHIO_GUARD_FUEL_CONSUMED_TOTAL: &str = "chio_guard_fuel_consumed_total";
146pub const CHIO_GUARD_HOST_CALL_DURATION_SECONDS: &str = "chio_guard_host_call_duration_seconds";
147pub const CHIO_GUARD_MODULE_BYTES: &str = "chio_guard_module_bytes";
148pub const CHIO_GUARD_POOL_CHECKOUT_TOTAL: &str = "chio_guard_pool_checkout_total";
149pub const CHIO_GUARD_POOL_EVICT_TOTAL: &str = "chio_guard_pool_evict_total";
150pub const CHIO_GUARD_POOL_WARM_SIZE: &str = "chio_guard_pool_warm_size";
151pub const CHIO_GUARD_RELOAD_TOTAL: &str = "chio_guard_reload_total";
152pub const CHIO_GUARD_VERDICT_TOTAL: &str = "chio_guard_verdict_total";
153pub const CHIO_KERNEL_DECISION_LATENCY_SECONDS: &str = "chio_kernel_decision_latency_seconds";
154pub const CHIO_OTEL_INGRESS_DROP_TOTAL: &str = "chio_otel_ingress_drop_total";
155pub const CHIO_OTEL_SINK_DROP_TOTAL: &str = "chio_otel_sink_drop_total";
156pub const CHIO_PHEROMONE_QUEUE_OVERFLOW_TOTAL: &str = "chio_pheromone_queue_overflow_total";
157pub const CHIO_PHEROMONE_RELAY_CATCHUP_BYTES_SERVED_TOTAL: &str =
158    "chio_pheromone_relay_catchup_bytes_served_total";
159pub const CHIO_PHEROMONE_RELAY_CATCHUP_DENIES_TOTAL: &str =
160    "chio_pheromone_relay_catchup_denies_total";
161pub const CHIO_PHEROMONE_RELAY_DEAD_LETTERS_TOTAL: &str = "chio_pheromone_relay_dead_letters_total";
162pub const CHIO_PHEROMONE_RELAY_DELIVERY_TOTAL: &str = "chio_pheromone_relay_delivery_total";
163pub const CHIO_PHEROMONE_RELAY_ENDPOINT_DENIED_TOTAL: &str =
164    "chio_pheromone_relay_endpoint_denied_total";
165pub const CHIO_PHEROMONE_RELAY_LATENCY_SECONDS: &str = "chio_pheromone_relay_latency_seconds";
166pub const CHIO_PHEROMONE_RELAY_NONCE_REPLAY_CONFLICTS_TOTAL: &str =
167    "chio_pheromone_relay_nonce_replay_conflicts_total";
168pub const CHIO_PHEROMONE_RELAY_OLDEST_PENDING_AGE_SECONDS: &str =
169    "chio_pheromone_relay_oldest_pending_age_seconds";
170pub const CHIO_PHEROMONE_RELAY_QUEUE_DEPTH: &str = "chio_pheromone_relay_queue_depth";
171pub const CHIO_PHEROMONE_RELAY_REJECTIONS_TOTAL: &str = "chio_pheromone_relay_rejections_total";
172pub const CHIO_PHEROMONE_RELAY_STALE_DIRECTORIES_TOTAL: &str =
173    "chio_pheromone_relay_stale_directories_total";
174pub const CHIO_PHEROMONE_RELAY_STALE_LEASES: &str = "chio_pheromone_relay_stale_leases";
175pub const CHIO_PHEROMONE_RECEIVER_DEPOSITS_TOTAL: &str = "chio_pheromone_receiver_deposits_total";
176pub const CHIO_PHEROMONE_RECEIVER_LATENCY_SECONDS: &str = "chio_pheromone_receiver_latency_seconds";
177pub const CHIO_PHEROMONE_RECEIVER_REJECTIONS_TOTAL: &str =
178    "chio_pheromone_receiver_rejections_total";
179pub const CHIO_RECEIPT_SECONDS_SINCE_LAST_CHECKPOINT: &str =
180    "chio_receipt_seconds_since_last_checkpoint";
181pub const CHIO_RECEIPT_UNCHECKPOINTED_SEQ_RANGE: &str = "chio_receipt_uncheckpointed_seq_range";
182pub const CHIO_RECEIPT_WRITE_TOTAL: &str = "chio_receipt_write_total";
183pub const CHIO_RECEIPT_WRITE_LATENCY_SECONDS: &str = "chio_receipt_write_latency_seconds";
184pub const CHIO_SETTLEMENT_UNRESOLVED_TOTAL: &str = "chio_settlement_unresolved_total";
185pub const CHIO_SIDECAR_REQUESTS_TOTAL: &str = "chio_sidecar_requests_total";
186pub const CHIO_SIGNING_QUEUE_BLOCK_TOTAL: &str = "chio_signing_queue_block_total";
187pub const CHIO_SOC_EXPORT_TOTAL: &str = "chio_soc_export_total";
188pub const CHIO_SOC_EXPORT_LAG_SECONDS: &str = "chio_soc_export_lag_seconds";
189pub const CHIO_TRUST_CONTROL_READY: &str = "chio_trust_control_ready";
190
191pub const GUARD_EVAL_DURATION_BUCKETS_SECONDS: &[&str] = &[
192    "0.0001", "0.0005", "0.001", "0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0",
193];
194pub const GUARD_HOST_CALL_DURATION_BUCKETS_SECONDS: &[&str] = &[
195    "0.00001", "0.00005", "0.0001", "0.0005", "0.001", "0.005", "0.01", "0.05", "0.1",
196];
197pub const DECISION_LATENCY_BUCKETS_SECONDS: &[&str] =
198    &["0.025", "0.05", "0.075", "0.1", "0.25", "0.5", "1.0", "2.5"];
199pub const RECEIPT_WRITE_LATENCY_BUCKETS_SECONDS: &[&str] =
200    &["0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"];
201pub const ALERT_DISPATCH_LATENCY_BUCKETS_SECONDS: &[&str] =
202    &["0.25", "0.5", "1.0", "2.5", "5.0", "10.0"];
203pub const EXPORT_LAG_BUCKETS_SECONDS: &[&str] = &["30", "60", "120", "300", "600", "1800"];
204pub const ANCHOR_ROUND_LATENCY_BUCKETS_SECONDS: &[&str] =
205    &["0.1", "0.5", "1.0", "2.5", "5.0", "10.0"];
206pub const FEDERATION_HOP_LATENCY_BUCKETS_SECONDS: &[&str] =
207    &["0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"];
208pub const FEDERATION_TRANSPORT_ACCEPT_DURATION_BUCKETS_SECONDS: &[&str] =
209    &["0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"];
210pub const PHEROMONE_RECEIVER_LATENCY_BUCKETS_SECONDS: &[&str] = &[
211    "0.001", "0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0",
212];
213
214pub const REGISTRY: &[MetricDescriptor] = &[
215    describe!(
216        name = CHIO_ALERT_DISPATCH_LATENCY_SECONDS,
217        help = "PagerDuty or OpsGenie alert dispatch latency in seconds.",
218        kind = Histogram,
219        labels = ["route", "outcome"],
220        buckets = ["0.25", "0.5", "1.0", "2.5", "5.0", "10.0"]
221    ),
222    describe!(
223        name = CHIO_ALERT_DISPATCH_TOTAL,
224        help = "Total PagerDuty or OpsGenie alert dispatch outcomes.",
225        kind = Counter,
226        labels = ["route", "outcome"]
227    ),
228    describe!(
229        name = CHIO_AMBIGUOUS_DISPATCH_RETAINED_HOLD_TOTAL,
230        help = "Total budget or payment holds retained after an ambiguous post-dispatch outcome, labeled by whether durable reconciliation is available.",
231        kind = Counter,
232        labels = ["reconciliation"]
233    ),
234    describe!(
235        name = CHIO_ANCHOR_ROUND_LATENCY_SECONDS,
236        help = "Anchor round latency in seconds.",
237        kind = Histogram,
238        labels = ["witness", "outcome"],
239        buckets = ["0.1", "0.5", "1.0", "2.5", "5.0", "10.0"]
240    ),
241    describe!(
242        name = CHIO_CAPABILITY_REVOCATION_LAG_SECONDS,
243        help = "Capability revocation propagation lag in seconds.",
244        kind = Histogram,
245        labels = ["authority"],
246        buckets = ["1", "5", "15", "30", "60", "120", "300"]
247    ),
248    describe!(
249        name = CHIO_DISPATCH_FAILURE_TOTAL,
250        help = "Total tool-dispatch failures that did not bypass mediation.",
251        kind = Counter,
252        labels = ["surface", "outcome"]
253    ),
254    describe!(
255        name = CHIO_DLQ_DEPTH,
256        help = "Dead-letter queue depth by exporter.",
257        kind = Gauge,
258        labels = ["exporter"]
259    ),
260    describe!(
261        name = CHIO_FAIL_OPEN_SUSPECTED_TOTAL,
262        help = "Total suspected fail-open paths detected by SRE guards.",
263        kind = Counter,
264        labels = ["surface"]
265    ),
266    describe!(
267        name = CHIO_FEDERATION_HOP_LATENCY_SECONDS,
268        help = "Federation hop latency in seconds.",
269        kind = Histogram,
270        labels = ["result"],
271        buckets = ["0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"]
272    ),
273    describe!(
274        name = CHIO_FEDERATION_HOP_TOTAL,
275        help = "Total federation hop outcomes.",
276        kind = Counter,
277        labels = ["result"]
278    ),
279    describe!(
280        name = CHIO_FEDERATION_TRANSPORT_ACCEPT_DURATION_SECONDS,
281        help = "Iroh federation-transport per-lane accept handler duration in seconds.",
282        kind = Histogram,
283        labels = ["lane"],
284        buckets = ["0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"]
285    ),
286    describe!(
287        name = CHIO_FEDERATION_TRANSPORT_ACCEPT_OPEN,
288        help = "Iroh federation-transport in-flight accept handlers by lane (slowloris gauge).",
289        kind = Gauge,
290        labels = ["lane"]
291    ),
292    describe!(
293        name = CHIO_FEDERATION_TRANSPORT_ADMISSION_TOTAL,
294        help = "Total iroh federation-transport admission-gate outcomes at after_handshake.",
295        kind = Counter,
296        labels = ["outcome"]
297    ),
298    describe!(
299        name = CHIO_FEDERATION_TRANSPORT_CATCHUP_EPOCH_GAP_TOTAL,
300        help = "Total iroh federation-transport revocation catch-up epoch gaps detected.",
301        kind = Counter,
302        labels = ["source"]
303    ),
304    describe!(
305        name = CHIO_FEDERATION_TRANSPORT_DIRECTORY_RELOAD_TOTAL,
306        help = "Total iroh federation-transport directory reload outcomes.",
307        kind = Counter,
308        labels = ["outcome"]
309    ),
310    describe!(
311        name = CHIO_FEDERATION_TRANSPORT_LANE_TOTAL,
312        help = "Total iroh federation-transport per-lane accept outcomes.",
313        kind = Counter,
314        labels = ["lane", "outcome"]
315    ),
316    describe!(
317        name = CHIO_FEDERATION_TRANSPORT_OUTBOX_TOTAL,
318        help = "Total iroh federation-transport pheromone outbox drain outcomes.",
319        kind = Counter,
320        labels = ["outcome"]
321    ),
322    describe!(
323        name = CHIO_FEDERATION_TRANSPORT_ROUTER_ALIVE,
324        help = "Iroh federation-transport router liveness (1 alive, 0 the router died).",
325        kind = Gauge,
326        labels = []
327    ),
328    describe!(
329        name = CHIO_FEDERATION_TRANSPORT_VERIFY_FAILURES_TOTAL,
330        help = "Total iroh federation-transport verification failures by seam and bounded reason.",
331        kind = Counter,
332        labels = ["seam", "reason"]
333    ),
334    describe!(
335        name = CHIO_GUARD_DENY_TOTAL,
336        help = "Total WASM guard denies by reason class.",
337        kind = Counter,
338        labels = ["guard_id", "reason_class"]
339    ),
340    describe!(
341        name = CHIO_GUARD_EVAL_DURATION_SECONDS,
342        help = "WASM guard evaluation duration in seconds.",
343        kind = Histogram,
344        labels = ["guard_id", "verdict"],
345        buckets = [
346            "0.0001", "0.0005", "0.001", "0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "0.5",
347            "1.0"
348        ]
349    ),
350    describe!(
351        name = CHIO_GUARD_EVALUATIONS_TOTAL,
352        help = "Total guard evaluation outcomes across native and WASM guards.",
353        kind = Counter,
354        labels = ["guard", "outcome"]
355    ),
356    describe!(
357        name = CHIO_GUARD_FUEL_CONSUMED_TOTAL,
358        help = "Total WASM guard fuel units consumed.",
359        kind = Counter,
360        labels = ["guard_id"]
361    ),
362    describe!(
363        name = CHIO_GUARD_HOST_CALL_DURATION_SECONDS,
364        help = "WASM guard host-call duration in seconds.",
365        kind = Histogram,
366        labels = ["guard_id", "host_fn"],
367        buckets =
368            ["0.00001", "0.00005", "0.0001", "0.0005", "0.001", "0.005", "0.01", "0.05", "0.1"]
369    ),
370    describe!(
371        name = CHIO_GUARD_MODULE_BYTES,
372        help = "Loaded WASM guard module size in bytes.",
373        kind = Gauge,
374        labels = ["guard_id", "epoch"]
375    ),
376    describe!(
377        name = CHIO_GUARD_POOL_CHECKOUT_TOTAL,
378        help = "Total WASM guard pool checkout outcomes by tenant.",
379        kind = Counter,
380        labels = ["guard_id", "tenant_id"]
381    ),
382    describe!(
383        name = CHIO_GUARD_POOL_EVICT_TOTAL,
384        help = "Total WASM guard pool evictions by tenant.",
385        kind = Counter,
386        labels = ["guard_id", "tenant_id"]
387    ),
388    describe!(
389        name = CHIO_GUARD_POOL_WARM_SIZE,
390        help = "Warm WASM guard pool size by tenant.",
391        kind = Gauge,
392        labels = ["guard_id", "tenant_id"]
393    ),
394    describe!(
395        name = CHIO_GUARD_RELOAD_TOTAL,
396        help = "Total WASM guard reload outcomes.",
397        kind = Counter,
398        labels = ["guard_id", "outcome"]
399    ),
400    describe!(
401        name = CHIO_GUARD_VERDICT_TOTAL,
402        help = "Total WASM guard verdicts by guard and verdict.",
403        kind = Counter,
404        labels = ["guard_id", "verdict"]
405    ),
406    describe!(
407        name = CHIO_KERNEL_DECISION_LATENCY_SECONDS,
408        help = "Kernel mediation decision latency in seconds.",
409        kind = Histogram,
410        labels = ["surface", "outcome"],
411        buckets = ["0.025", "0.05", "0.075", "0.1", "0.25", "0.5", "1.0", "2.5"]
412    ),
413    describe!(
414        name = CHIO_OTEL_INGRESS_DROP_TOTAL,
415        help = "Total OTEL ingress batches dropped by bounded queue admission.",
416        kind = Counter,
417        labels = []
418    ),
419    describe!(
420        name = CHIO_OTEL_SINK_DROP_TOTAL,
421        help = "Total OTEL receipt sink batches dropped before append.",
422        kind = Counter,
423        labels = []
424    ),
425    describe!(
426        name = CHIO_PHEROMONE_QUEUE_OVERFLOW_TOTAL,
427        help = "Total local pheromone gossip queue overflows.",
428        kind = Counter,
429        labels = ["treaty", "recipient"]
430    ),
431    describe!(
432        name = CHIO_PHEROMONE_RECEIVER_DEPOSITS_TOTAL,
433        help = "Total local pheromone receiver deposit admission outcomes.",
434        kind = Counter,
435        labels = ["outcome"]
436    ),
437    describe!(
438        name = CHIO_PHEROMONE_RECEIVER_LATENCY_SECONDS,
439        help = "Local pheromone receiver batch verification latency in seconds.",
440        kind = Histogram,
441        labels = ["outcome"],
442        buckets = ["0.001", "0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"]
443    ),
444    describe!(
445        name = CHIO_PHEROMONE_RECEIVER_REJECTIONS_TOTAL,
446        help = "Total local pheromone receiver rejections by bounded reason.",
447        kind = Counter,
448        labels = ["reason"]
449    ),
450    describe!(
451        name = CHIO_PHEROMONE_RELAY_CATCHUP_BYTES_SERVED_TOTAL,
452        help = "Total bounded catch-up bytes served by relay.",
453        kind = Counter,
454        labels = ["responder", "treaty"]
455    ),
456    describe!(
457        name = CHIO_PHEROMONE_RELAY_CATCHUP_DENIES_TOTAL,
458        help = "Total bounded catch-up requests denied by reason.",
459        kind = Counter,
460        labels = ["reason"]
461    ),
462    describe!(
463        name = CHIO_PHEROMONE_RELAY_DEAD_LETTERS_TOTAL,
464        help = "Total relay outbox batches moved to dead letter.",
465        kind = Counter,
466        labels = ["reason"]
467    ),
468    describe!(
469        name = CHIO_PHEROMONE_RELAY_DELIVERY_TOTAL,
470        help = "Total live pheromone relay delivery outcomes.",
471        kind = Counter,
472        labels = ["recipient", "outcome"]
473    ),
474    describe!(
475        name = CHIO_PHEROMONE_RELAY_ENDPOINT_DENIED_TOTAL,
476        help = "Total relay endpoints denied by profile lint or delivery policy.",
477        kind = Counter,
478        labels = ["profile", "reason"]
479    ),
480    describe!(
481        name = CHIO_PHEROMONE_RELAY_LATENCY_SECONDS,
482        help = "Live pheromone relay delivery latency in seconds.",
483        kind = Histogram,
484        labels = ["outcome"],
485        buckets = ["0.001", "0.005", "0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"]
486    ),
487    describe!(
488        name = CHIO_PHEROMONE_RELAY_NONCE_REPLAY_CONFLICTS_TOTAL,
489        help = "Total relay nonce replay conflicts.",
490        kind = Counter,
491        labels = ["peer"]
492    ),
493    describe!(
494        name = CHIO_PHEROMONE_RELAY_OLDEST_PENDING_AGE_SECONDS,
495        help = "Oldest pending relay outbox age in seconds.",
496        kind = Gauge,
497        labels = []
498    ),
499    describe!(
500        name = CHIO_PHEROMONE_RELAY_QUEUE_DEPTH,
501        help = "Relay outbox depth by bounded status.",
502        kind = Gauge,
503        labels = ["status"]
504    ),
505    describe!(
506        name = CHIO_PHEROMONE_RELAY_REJECTIONS_TOTAL,
507        help = "Total live pheromone relay rejections by bounded reason.",
508        kind = Counter,
509        labels = ["reason"]
510    ),
511    describe!(
512        name = CHIO_PHEROMONE_RELAY_STALE_DIRECTORIES_TOTAL,
513        help = "Total stale peer-directory bundles or documents rejected.",
514        kind = Counter,
515        labels = ["profile"]
516    ),
517    describe!(
518        name = CHIO_PHEROMONE_RELAY_STALE_LEASES,
519        help = "Relay scheduler leases past their expiry.",
520        kind = Gauge,
521        labels = []
522    ),
523    describe!(
524        name = CHIO_RECEIPT_SECONDS_SINCE_LAST_CHECKPOINT,
525        help = "Seconds since the receipt-store checkpoint last advanced while data was pending on the local store.",
526        kind = Gauge,
527        labels = []
528    ),
529    describe!(
530        name = CHIO_RECEIPT_UNCHECKPOINTED_SEQ_RANGE,
531        help = "Uncheckpointed receipt entry_seq range (end - start) on the local store.",
532        kind = Gauge,
533        labels = []
534    ),
535    describe!(
536        name = CHIO_RECEIPT_WRITE_LATENCY_SECONDS,
537        help = "Receipt write latency at the local store boundary in seconds.",
538        kind = Histogram,
539        labels = ["store", "outcome"],
540        buckets = ["0.01", "0.025", "0.05", "0.1", "0.25", "0.5", "1.0"]
541    ),
542    describe!(
543        name = CHIO_RECEIPT_WRITE_TOTAL,
544        help = "Total receipt write outcomes after policy or guard evaluation.",
545        kind = Counter,
546        labels = ["outcome"]
547    ),
548    describe!(
549        name = CHIO_SETTLEMENT_UNRESOLVED_TOTAL,
550        help = "Total settlement observer routing invocations with an unresolved outcome.",
551        kind = Counter,
552        labels = []
553    ),
554    describe!(
555        name = CHIO_SIDECAR_REQUESTS_TOTAL,
556        help = "Total sidecar request outcomes at the mediation edge.",
557        kind = Counter,
558        labels = ["outcome"]
559    ),
560    describe!(
561        name = CHIO_SIGNING_QUEUE_BLOCK_TOTAL,
562        help = "Total receipt signing requests blocked by bounded queue capacity or byte budget.",
563        kind = Counter,
564        labels = ["reason"]
565    ),
566    describe!(
567        name = CHIO_SOC_EXPORT_LAG_SECONDS,
568        help = "SOC export lag in seconds from receipt persistence to sink acknowledgement.",
569        kind = Histogram,
570        labels = ["exporter", "severity"],
571        buckets = ["30", "60", "120", "300", "600", "1800"]
572    ),
573    describe!(
574        name = CHIO_SOC_EXPORT_TOTAL,
575        help = "Total SOC export outcomes for audit rows.",
576        kind = Counter,
577        labels = ["exporter", "outcome"]
578    ),
579    describe!(
580        name = CHIO_TRUST_CONTROL_READY,
581        help = "Trust-control readiness state, where 1 is ready and 0 is not ready.",
582        kind = Gauge,
583        labels = []
584    ),
585];
586
587#[must_use]
588pub fn descriptor_for(name: &str) -> Option<&'static MetricDescriptor> {
589    REGISTRY.iter().find(|descriptor| descriptor.name == name)
590}
591
592#[must_use]
593pub fn is_registered_metric(name: &str) -> bool {
594    descriptor_for(name).is_some()
595}
596
597pub fn validate_metric_descriptor(
598    descriptor: &MetricDescriptor,
599) -> Result<(), MetricValidationError> {
600    if !is_prometheus_metric_name(descriptor.name) {
601        return Err(MetricValidationError::InvalidMetricName {
602            name: descriptor.name,
603        });
604    }
605
606    for (index, label) in descriptor.labels.iter().enumerate() {
607        if !is_prometheus_label_name(label) {
608            return Err(MetricValidationError::InvalidLabelName {
609                metric: descriptor.name,
610                label,
611            });
612        }
613        if descriptor.labels[..index].contains(label) {
614            return Err(MetricValidationError::DuplicateLabelName {
615                metric: descriptor.name,
616                label,
617            });
618        }
619    }
620
621    match descriptor.kind {
622        MetricKind::Histogram => validate_histogram_buckets(descriptor),
623        MetricKind::Counter | MetricKind::Gauge => {
624            if descriptor.buckets.is_empty() {
625                Ok(())
626            } else {
627                Err(MetricValidationError::UnexpectedBuckets {
628                    metric: descriptor.name,
629                    kind: descriptor.kind,
630                })
631            }
632        }
633    }
634}
635
636pub fn validate_registry() -> Result<(), MetricValidationError> {
637    let mut previous = None;
638    for descriptor in REGISTRY {
639        validate_metric_descriptor(descriptor)?;
640        if let Some(previous_name) = previous {
641            if descriptor.name == previous_name {
642                return Err(MetricValidationError::DuplicateMetricName {
643                    metric: descriptor.name,
644                });
645            }
646            if descriptor.name < previous_name {
647                return Err(MetricValidationError::RegistryNotSorted {
648                    previous: previous_name,
649                    current: descriptor.name,
650                });
651            }
652        }
653        previous = Some(descriptor.name);
654    }
655    Ok(())
656}
657
658#[must_use]
659pub fn is_prometheus_metric_name(name: &str) -> bool {
660    let mut bytes = name.bytes();
661    let Some(first) = bytes.next() else {
662        return false;
663    };
664    (first.is_ascii_alphabetic() || matches!(first, b'_' | b':'))
665        && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b':'))
666}
667
668#[must_use]
669pub fn is_prometheus_label_name(name: &str) -> bool {
670    if name.starts_with("__") {
671        return false;
672    }
673    let mut bytes = name.bytes();
674    let Some(first) = bytes.next() else {
675        return false;
676    };
677    (first.is_ascii_alphabetic() || first == b'_')
678        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
679}
680
681fn validate_histogram_buckets(descriptor: &MetricDescriptor) -> Result<(), MetricValidationError> {
682    if descriptor.buckets.is_empty() {
683        return Err(MetricValidationError::MissingHistogramBuckets {
684            metric: descriptor.name,
685        });
686    }
687
688    let mut previous_value = None;
689    let mut previous_bucket = "";
690    for bucket in descriptor.buckets {
691        let Ok(value) = bucket.parse::<f64>() else {
692            return Err(MetricValidationError::InvalidHistogramBucket {
693                metric: descriptor.name,
694                bucket,
695            });
696        };
697        if !value.is_finite() {
698            return Err(MetricValidationError::InvalidHistogramBucket {
699                metric: descriptor.name,
700                bucket,
701            });
702        }
703        if let Some(previous) = previous_value {
704            if value <= previous {
705                return Err(MetricValidationError::NonIncreasingHistogramBucket {
706                    metric: descriptor.name,
707                    previous: previous_bucket,
708                    bucket,
709                });
710            }
711        }
712        previous_value = Some(value);
713        previous_bucket = bucket;
714    }
715
716    Ok(())
717}
718
719#[must_use]
720pub fn registry_snapshot() -> String {
721    let mut output = String::new();
722    for descriptor in REGISTRY {
723        output.push_str(descriptor.name);
724        output.push('|');
725        output.push_str(descriptor.kind.as_str());
726        output.push('|');
727        output.push_str(&descriptor.labels.join(","));
728        output.push('|');
729        output.push_str(&descriptor.buckets.join(","));
730        output.push('|');
731        output.push_str(descriptor.help);
732        output.push('\n');
733    }
734    output
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    const REQUIRED_SRE_METRICS: &[&str] = &[
742        CHIO_KERNEL_DECISION_LATENCY_SECONDS,
743        CHIO_RECEIPT_WRITE_TOTAL,
744        CHIO_GUARD_EVALUATIONS_TOTAL,
745        CHIO_CAPABILITY_REVOCATION_LAG_SECONDS,
746        CHIO_ANCHOR_ROUND_LATENCY_SECONDS,
747        CHIO_FEDERATION_HOP_TOTAL,
748        CHIO_DLQ_DEPTH,
749    ];
750
751    #[test]
752    fn golden_snapshot_matches_registry() {
753        assert_eq!(registry_snapshot(), include_str!("../metrics.snapshot"));
754    }
755
756    #[test]
757    fn registry_is_sorted_and_unique() {
758        let mut previous = "";
759        for descriptor in REGISTRY {
760            assert!(
761                descriptor.name > previous,
762                "metric registry must stay sorted and unique: {} after {previous}",
763                descriptor.name
764            );
765            previous = descriptor.name;
766        }
767    }
768
769    #[test]
770    fn registry_metric_and_label_names_are_prometheus_safe() {
771        for descriptor in REGISTRY {
772            assert!(
773                is_prometheus_metric_name(descriptor.name),
774                "invalid metric name {}",
775                descriptor.name
776            );
777            for label in descriptor.labels {
778                assert!(
779                    is_prometheus_label_name(label),
780                    "invalid label name {label} on {}",
781                    descriptor.name
782                );
783            }
784        }
785    }
786
787    #[test]
788    fn registry_descriptors_pass_runtime_validation() {
789        assert_eq!(validate_registry(), Ok(()));
790        for descriptor in REGISTRY {
791            assert_eq!(validate_metric_descriptor(descriptor), Ok(()));
792        }
793    }
794
795    #[test]
796    fn descriptor_validation_rejects_malformed_histogram_buckets() {
797        let unordered = MetricDescriptor {
798            name: "bad_latency_seconds",
799            help: "Bad latency buckets.",
800            kind: MetricKind::Histogram,
801            labels: &[],
802            buckets: &["0.1", "0.05"],
803        };
804        assert_eq!(
805            validate_metric_descriptor(&unordered),
806            Err(MetricValidationError::NonIncreasingHistogramBucket {
807                metric: "bad_latency_seconds",
808                previous: "0.1",
809                bucket: "0.05",
810            })
811        );
812
813        let invalid = MetricDescriptor {
814            name: "bad_latency_seconds",
815            help: "Bad latency buckets.",
816            kind: MetricKind::Histogram,
817            labels: &[],
818            buckets: &["NaN"],
819        };
820        assert_eq!(
821            validate_metric_descriptor(&invalid),
822            Err(MetricValidationError::InvalidHistogramBucket {
823                metric: "bad_latency_seconds",
824                bucket: "NaN",
825            })
826        );
827    }
828
829    #[test]
830    fn descriptor_validation_rejects_bucket_kind_mismatches() {
831        let counter = MetricDescriptor {
832            name: "bad_total",
833            help: "Bad counter buckets.",
834            kind: MetricKind::Counter,
835            labels: &[],
836            buckets: &["1"],
837        };
838        assert_eq!(
839            validate_metric_descriptor(&counter),
840            Err(MetricValidationError::UnexpectedBuckets {
841                metric: "bad_total",
842                kind: MetricKind::Counter,
843            })
844        );
845
846        let histogram = MetricDescriptor {
847            name: "bad_latency_seconds",
848            help: "Missing latency buckets.",
849            kind: MetricKind::Histogram,
850            labels: &[],
851            buckets: &[],
852        };
853        assert_eq!(
854            validate_metric_descriptor(&histogram),
855            Err(MetricValidationError::MissingHistogramBuckets {
856                metric: "bad_latency_seconds",
857            })
858        );
859    }
860
861    #[test]
862    fn prometheus_label_names_reject_reserved_internal_prefix() {
863        assert!(!is_prometheus_label_name("__name__"));
864        assert!(!is_prometheus_label_name("__tenant_id"));
865    }
866
867    #[test]
868    fn required_sre_metrics_are_registered() {
869        for name in REQUIRED_SRE_METRICS {
870            assert!(is_registered_metric(name), "missing required metric {name}");
871        }
872    }
873
874    #[test]
875    fn labelled_metrics_have_expected_labels() {
876        assert_eq!(
877            descriptor_for(CHIO_RECEIPT_WRITE_TOTAL).map(|metric| metric.labels),
878            Some(&["outcome"][..])
879        );
880        assert_eq!(
881            descriptor_for(CHIO_GUARD_EVALUATIONS_TOTAL).map(|metric| metric.labels),
882            Some(&["guard", "outcome"][..])
883        );
884        assert_eq!(
885            descriptor_for(CHIO_FEDERATION_HOP_TOTAL).map(|metric| metric.labels),
886            Some(&["result"][..])
887        );
888        assert_eq!(
889            descriptor_for(CHIO_DLQ_DEPTH).map(|metric| metric.labels),
890            Some(&["exporter"][..])
891        );
892    }
893}