Skip to main content

hyperi_rustlib/metrics/
labels.rs

1// Project:   hyperi-rustlib
2// File:      src/metrics/labels.rs
3// Purpose:   Bounded enum types for low-cardinality Prometheus labels
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Bounded label values for Prometheus / OTel metrics.
10//!
11//! Each enum represents a label whose value set is fixed at compile
12//! time. Wrapping label arguments in these types stops free-form
13//! strings from sliding into metric labels and blowing up TSDB
14//! cardinality.
15//!
16//! Variant choice aligns with industry conventions:
17//!
18//! - [`TransportKind`] -- one variant per `transport-*` cargo
19//!   feature; label values match OTel `messaging.system`.
20//! - [`FlushTrigger`] -- standard buffer-flush triggers seen across
21//!   Kafka producers and batch processors.
22//! - [`AuthFailureReason`] -- RFC 6749 OAuth 2.0 + JWT-specific
23//!   failure codes; label values are the RFC strings.
24//! - [`ValidationFailureReason`] -- JSON Schema 2020-12 validator
25//!   error categories.
26//!
27//! No `Other` catch-all: every code path that names a failure must
28//! pick a variant. Add new variants here when a real new failure
29//! mode appears; never widen at the call site.
30
31/// Transport backend kind. One variant per `transport-*` cargo
32/// feature plus `routed`. Aligns with OTel `messaging.system`
33/// attribute values where they overlap (kafka, redis, http).
34#[non_exhaustive]
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum TransportKind {
37    Kafka,
38    Grpc,
39    Memory,
40    File,
41    Pipe,
42    Http,
43    Redis,
44    Routed,
45}
46
47impl TransportKind {
48    /// Snake-case label value suitable for Prometheus / OTel exporters.
49    #[must_use]
50    pub const fn as_label(self) -> &'static str {
51        match self {
52            Self::Kafka => "kafka",
53            Self::Grpc => "grpc",
54            Self::Memory => "memory",
55            Self::File => "file",
56            Self::Pipe => "pipe",
57            Self::Http => "http",
58            Self::Redis => "redis",
59            Self::Routed => "routed",
60        }
61    }
62}
63
64impl std::fmt::Display for TransportKind {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.as_label())
67    }
68}
69
70/// Buffer-flush trigger. Bounded set covering the patterns in every
71/// batch processor I've seen.
72#[non_exhaustive]
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum FlushTrigger {
75    /// Byte threshold crossed.
76    Size,
77    /// Record-count threshold crossed.
78    Records,
79    /// Time / idle threshold crossed.
80    Age,
81    /// Cache eviction forced a flush.
82    Eviction,
83    /// Graceful drain at shutdown.
84    Shutdown,
85    /// Operator / test invoked.
86    Manual,
87}
88
89impl FlushTrigger {
90    #[must_use]
91    pub const fn as_label(self) -> &'static str {
92        match self {
93            Self::Size => "size",
94            Self::Records => "records",
95            Self::Age => "age",
96            Self::Eviction => "eviction",
97            Self::Shutdown => "shutdown",
98            Self::Manual => "manual",
99        }
100    }
101}
102
103impl std::fmt::Display for FlushTrigger {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.write_str(self.as_label())
106    }
107}
108
109/// Authentication failure reason. RFC 6749 / OAuth 2.0 error codes
110/// plus the JWT-specific failure modes most apps observe.
111///
112/// Label values match the RFC strings exactly so OAuth-aware
113/// dashboards and OTel collectors work without translation.
114#[non_exhaustive]
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum AuthFailureReason {
117    /// Token TTL expired (JWT exp / session timeout).
118    Expired,
119    /// JWT / HMAC signature verification failed.
120    InvalidSignature,
121    /// RFC 6749 `invalid_client` — client_id or secret wrong.
122    InvalidClient,
123    /// RFC 6749 `invalid_grant` — grant / code / refresh wrong.
124    InvalidGrant,
125    /// RFC 6749 `invalid_scope` — requested scope rejected.
126    InvalidScope,
127    /// Token couldn't be parsed (structurally malformed).
128    MalformedToken,
129    /// Token explicitly revoked.
130    RevokedToken,
131    /// Too many attempts (operator-policy rate limit).
132    RateLimited,
133    /// RFC 6749 `unauthorized_client`.
134    Unauthorized,
135    /// RFC 6749 `access_denied`.
136    AccessDenied,
137}
138
139impl AuthFailureReason {
140    #[must_use]
141    pub const fn as_label(self) -> &'static str {
142        match self {
143            Self::Expired => "expired",
144            Self::InvalidSignature => "invalid_signature",
145            Self::InvalidClient => "invalid_client",
146            Self::InvalidGrant => "invalid_grant",
147            Self::InvalidScope => "invalid_scope",
148            Self::MalformedToken => "malformed_token",
149            Self::RevokedToken => "revoked_token",
150            Self::RateLimited => "rate_limited",
151            Self::Unauthorized => "unauthorized",
152            Self::AccessDenied => "access_denied",
153        }
154    }
155}
156
157impl std::fmt::Display for AuthFailureReason {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.write_str(self.as_label())
160    }
161}
162
163/// Payload validation failure reason. Categories mirror the
164/// JSON Schema 2020-12 validator error taxonomy (Ajv,
165/// python-jsonschema, et al.).
166#[non_exhaustive]
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168pub enum ValidationFailureReason {
169    /// Combinator failure: `oneOf` / `anyOf` / `allOf` couldn't pick.
170    SchemaInvalid,
171    /// Required property absent (`required` keyword).
172    FieldMissing,
173    /// Wrong JSON type (`type` keyword).
174    TypeMismatch,
175    /// Numeric / length / array bound violated.
176    OutOfRange,
177    /// Regex `pattern` keyword failed.
178    PatternMismatch,
179    /// `format` keyword (email, date, uuid, ...).
180    FormatInvalid,
181    /// `enum` keyword: value not in allowed set.
182    EnumViolation,
183    /// Strict-mode unexpected property.
184    AdditionalProperties,
185    /// `null` where non-null required.
186    NullValue,
187    /// Bytes wouldn't decode (UTF-8 / base64 / hex).
188    EncodingError,
189}
190
191impl ValidationFailureReason {
192    #[must_use]
193    pub const fn as_label(self) -> &'static str {
194        match self {
195            Self::SchemaInvalid => "schema_invalid",
196            Self::FieldMissing => "field_missing",
197            Self::TypeMismatch => "type_mismatch",
198            Self::OutOfRange => "out_of_range",
199            Self::PatternMismatch => "pattern_mismatch",
200            Self::FormatInvalid => "format_invalid",
201            Self::EnumViolation => "enum_violation",
202            Self::AdditionalProperties => "additional_properties",
203            Self::NullValue => "null_value",
204            Self::EncodingError => "encoding_error",
205        }
206    }
207}
208
209impl std::fmt::Display for ValidationFailureReason {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.write_str(self.as_label())
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    /// All label values are snake_case ASCII, no spaces, no upper.
220    /// Cheap belt-and-braces check that the strings are stable
221    /// Prometheus / OTel label material.
222    #[test]
223    fn label_values_are_snake_case_ascii() {
224        let all: &[&str] = &[
225            TransportKind::Kafka.as_label(),
226            TransportKind::Grpc.as_label(),
227            TransportKind::Memory.as_label(),
228            TransportKind::File.as_label(),
229            TransportKind::Pipe.as_label(),
230            TransportKind::Http.as_label(),
231            TransportKind::Redis.as_label(),
232            TransportKind::Routed.as_label(),
233            FlushTrigger::Size.as_label(),
234            FlushTrigger::Records.as_label(),
235            FlushTrigger::Age.as_label(),
236            FlushTrigger::Eviction.as_label(),
237            FlushTrigger::Shutdown.as_label(),
238            FlushTrigger::Manual.as_label(),
239            AuthFailureReason::Expired.as_label(),
240            AuthFailureReason::InvalidSignature.as_label(),
241            AuthFailureReason::InvalidClient.as_label(),
242            AuthFailureReason::InvalidGrant.as_label(),
243            AuthFailureReason::InvalidScope.as_label(),
244            AuthFailureReason::MalformedToken.as_label(),
245            AuthFailureReason::RevokedToken.as_label(),
246            AuthFailureReason::RateLimited.as_label(),
247            AuthFailureReason::Unauthorized.as_label(),
248            AuthFailureReason::AccessDenied.as_label(),
249            ValidationFailureReason::SchemaInvalid.as_label(),
250            ValidationFailureReason::FieldMissing.as_label(),
251            ValidationFailureReason::TypeMismatch.as_label(),
252            ValidationFailureReason::OutOfRange.as_label(),
253            ValidationFailureReason::PatternMismatch.as_label(),
254            ValidationFailureReason::FormatInvalid.as_label(),
255            ValidationFailureReason::EnumViolation.as_label(),
256            ValidationFailureReason::AdditionalProperties.as_label(),
257            ValidationFailureReason::NullValue.as_label(),
258            ValidationFailureReason::EncodingError.as_label(),
259        ];
260        for s in all {
261            assert!(
262                s.bytes()
263                    .all(|b| b.is_ascii_lowercase() || b == b'_' || b.is_ascii_digit()),
264                "non-snake-case label: {s:?}"
265            );
266            assert!(!s.is_empty(), "empty label");
267        }
268    }
269}