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:   BUSL-1.1
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    /// Generic / combined output sink where the concrete backend is not
46    /// distinguished -- e.g. an app with a single combined send-error
47    /// counter spanning more than one producer.
48    Output,
49}
50
51impl TransportKind {
52    /// Snake-case label value suitable for Prometheus / OTel exporters.
53    #[must_use]
54    pub const fn as_label(self) -> &'static str {
55        match self {
56            Self::Kafka => "kafka",
57            Self::Grpc => "grpc",
58            Self::Memory => "memory",
59            Self::File => "file",
60            Self::Pipe => "pipe",
61            Self::Http => "http",
62            Self::Redis => "redis",
63            Self::Routed => "routed",
64            Self::Output => "output",
65        }
66    }
67}
68
69impl std::fmt::Display for TransportKind {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(self.as_label())
72    }
73}
74
75/// Buffer-flush trigger. Bounded set covering the patterns in every
76/// batch processor I've seen.
77#[non_exhaustive]
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum FlushTrigger {
80    /// Byte threshold crossed.
81    Size,
82    /// Record-count threshold crossed.
83    Records,
84    /// Time / idle threshold crossed.
85    Age,
86    /// Cache eviction forced a flush.
87    Eviction,
88    /// Graceful drain at shutdown.
89    Shutdown,
90    /// Operator / test invoked.
91    Manual,
92}
93
94impl FlushTrigger {
95    #[must_use]
96    pub const fn as_label(self) -> &'static str {
97        match self {
98            Self::Size => "size",
99            Self::Records => "records",
100            Self::Age => "age",
101            Self::Eviction => "eviction",
102            Self::Shutdown => "shutdown",
103            Self::Manual => "manual",
104        }
105    }
106}
107
108impl std::fmt::Display for FlushTrigger {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.write_str(self.as_label())
111    }
112}
113
114/// Authentication failure reason. RFC 6749 / OAuth 2.0 error codes
115/// plus the JWT-specific failure modes most apps observe.
116///
117/// Label values match the RFC strings exactly so OAuth-aware
118/// dashboards and OTel collectors work without translation.
119#[non_exhaustive]
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121pub enum AuthFailureReason {
122    /// Token TTL expired (JWT exp / session timeout).
123    Expired,
124    /// JWT / HMAC signature verification failed.
125    InvalidSignature,
126    /// RFC 6749 `invalid_client` — client_id or secret wrong.
127    InvalidClient,
128    /// RFC 6749 `invalid_grant` — grant / code / refresh wrong.
129    InvalidGrant,
130    /// RFC 6749 `invalid_scope` — requested scope rejected.
131    InvalidScope,
132    /// Token couldn't be parsed (structurally malformed).
133    MalformedToken,
134    /// Token explicitly revoked.
135    RevokedToken,
136    /// Too many attempts (operator-policy rate limit).
137    RateLimited,
138    /// RFC 6749 `unauthorized_client`.
139    Unauthorized,
140    /// RFC 6749 `access_denied`.
141    AccessDenied,
142}
143
144impl AuthFailureReason {
145    #[must_use]
146    pub const fn as_label(self) -> &'static str {
147        match self {
148            Self::Expired => "expired",
149            Self::InvalidSignature => "invalid_signature",
150            Self::InvalidClient => "invalid_client",
151            Self::InvalidGrant => "invalid_grant",
152            Self::InvalidScope => "invalid_scope",
153            Self::MalformedToken => "malformed_token",
154            Self::RevokedToken => "revoked_token",
155            Self::RateLimited => "rate_limited",
156            Self::Unauthorized => "unauthorized",
157            Self::AccessDenied => "access_denied",
158        }
159    }
160}
161
162impl std::fmt::Display for AuthFailureReason {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.write_str(self.as_label())
165    }
166}
167
168/// Payload validation failure reason. Categories mirror the
169/// JSON Schema 2020-12 validator error taxonomy (Ajv,
170/// python-jsonschema, et al.).
171#[non_exhaustive]
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173pub enum ValidationFailureReason {
174    /// Combinator failure: `oneOf` / `anyOf` / `allOf` couldn't pick.
175    SchemaInvalid,
176    /// Required property absent (`required` keyword).
177    FieldMissing,
178    /// Wrong JSON type (`type` keyword).
179    TypeMismatch,
180    /// Numeric / length / array bound violated.
181    OutOfRange,
182    /// Regex `pattern` keyword failed.
183    PatternMismatch,
184    /// `format` keyword (email, date, uuid, ...).
185    FormatInvalid,
186    /// `enum` keyword: value not in allowed set.
187    EnumViolation,
188    /// Strict-mode unexpected property.
189    AdditionalProperties,
190    /// `null` where non-null required.
191    NullValue,
192    /// Bytes wouldn't decode (UTF-8 / base64 / hex).
193    EncodingError,
194}
195
196impl ValidationFailureReason {
197    #[must_use]
198    pub const fn as_label(self) -> &'static str {
199        match self {
200            Self::SchemaInvalid => "schema_invalid",
201            Self::FieldMissing => "field_missing",
202            Self::TypeMismatch => "type_mismatch",
203            Self::OutOfRange => "out_of_range",
204            Self::PatternMismatch => "pattern_mismatch",
205            Self::FormatInvalid => "format_invalid",
206            Self::EnumViolation => "enum_violation",
207            Self::AdditionalProperties => "additional_properties",
208            Self::NullValue => "null_value",
209            Self::EncodingError => "encoding_error",
210        }
211    }
212}
213
214impl std::fmt::Display for ValidationFailureReason {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.write_str(self.as_label())
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    /// All label values are snake_case ASCII, no spaces, no upper.
225    /// Cheap belt-and-braces check that the strings are stable
226    /// Prometheus / OTel label material.
227    #[test]
228    fn label_values_are_snake_case_ascii() {
229        let all: &[&str] = &[
230            TransportKind::Kafka.as_label(),
231            TransportKind::Grpc.as_label(),
232            TransportKind::Memory.as_label(),
233            TransportKind::File.as_label(),
234            TransportKind::Pipe.as_label(),
235            TransportKind::Http.as_label(),
236            TransportKind::Redis.as_label(),
237            TransportKind::Routed.as_label(),
238            TransportKind::Output.as_label(),
239            FlushTrigger::Size.as_label(),
240            FlushTrigger::Records.as_label(),
241            FlushTrigger::Age.as_label(),
242            FlushTrigger::Eviction.as_label(),
243            FlushTrigger::Shutdown.as_label(),
244            FlushTrigger::Manual.as_label(),
245            AuthFailureReason::Expired.as_label(),
246            AuthFailureReason::InvalidSignature.as_label(),
247            AuthFailureReason::InvalidClient.as_label(),
248            AuthFailureReason::InvalidGrant.as_label(),
249            AuthFailureReason::InvalidScope.as_label(),
250            AuthFailureReason::MalformedToken.as_label(),
251            AuthFailureReason::RevokedToken.as_label(),
252            AuthFailureReason::RateLimited.as_label(),
253            AuthFailureReason::Unauthorized.as_label(),
254            AuthFailureReason::AccessDenied.as_label(),
255            ValidationFailureReason::SchemaInvalid.as_label(),
256            ValidationFailureReason::FieldMissing.as_label(),
257            ValidationFailureReason::TypeMismatch.as_label(),
258            ValidationFailureReason::OutOfRange.as_label(),
259            ValidationFailureReason::PatternMismatch.as_label(),
260            ValidationFailureReason::FormatInvalid.as_label(),
261            ValidationFailureReason::EnumViolation.as_label(),
262            ValidationFailureReason::AdditionalProperties.as_label(),
263            ValidationFailureReason::NullValue.as_label(),
264            ValidationFailureReason::EncodingError.as_label(),
265        ];
266        for s in all {
267            assert!(
268                s.bytes()
269                    .all(|b| b.is_ascii_lowercase() || b == b'_' || b.is_ascii_digit()),
270                "non-snake-case label: {s:?}"
271            );
272            assert!(!s.is_empty(), "empty label");
273        }
274    }
275}