use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MetricsConfig {
pub filter_duration: bool,
pub labels: MetricLabelsConfig,
pub route_templates: Vec<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricLabel {
Cluster,
Endpoint,
Listener,
Method,
Route,
StatusClass,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MetricLabelsConfig {
pub disabled: Vec<MetricLabel>,
}
impl MetricLabelsConfig {
#[must_use]
pub fn is_enabled(&self, label: MetricLabel) -> bool {
!self.disabled.contains(&label)
}
#[must_use]
pub fn all_enabled(&self) -> bool {
self.disabled.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum TemplateSegment {
Literal(Box<str>),
Placeholder,
}
#[derive(Clone, Debug)]
struct CompiledTemplate {
label: Box<str>,
segments: Vec<TemplateSegment>,
}
#[derive(Clone, Debug, Default)]
pub struct RouteTemplates {
by_len: HashMap<usize, Vec<CompiledTemplate>>,
}
impl RouteTemplates {
#[must_use]
pub fn compile(templates: &[String]) -> Self {
let mut by_len: HashMap<usize, Vec<CompiledTemplate>> = HashMap::new();
for template in templates {
let segments: Vec<TemplateSegment> = split_path(template)
.map(|segment| {
if segment.starts_with('{') && segment.ends_with('}') {
TemplateSegment::Placeholder
} else {
TemplateSegment::Literal(Box::from(segment))
}
})
.collect();
by_len.entry(segments.len()).or_default().push(CompiledTemplate {
label: Box::from(template.as_str()),
segments,
});
}
Self { by_len }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_len.is_empty()
}
#[must_use]
pub fn match_path(&self, path: &str) -> Option<&str> {
let path = path.split(['?', '#']).next().unwrap_or(path);
let len = split_path(path).count();
self.by_len.get(&len)?.iter().find_map(|template| {
split_path(path)
.zip(&template.segments)
.all(|(segment, expected)| match expected {
TemplateSegment::Literal(literal) => segment == literal.as_ref(),
TemplateSegment::Placeholder => !segment.is_empty(),
})
.then(|| template.label.as_ref())
})
}
}
fn split_path(path: &str) -> impl Iterator<Item = &str> {
path.split('/').filter(|segment| !segment.is_empty())
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, reason = "tests")]
mod tests {
use super::*;
#[test]
fn defaults_filter_duration_off() {
let metrics = MetricsConfig::default();
assert!(!metrics.filter_duration, "filter_duration should default to false");
}
#[test]
fn parse_empty_yields_defaults() {
let metrics: MetricsConfig = serde_yaml::from_str("{}").unwrap();
assert!(
!metrics.filter_duration,
"empty yaml should default filter_duration to false"
);
}
#[test]
fn label_dimensions_default_to_enabled() {
let labels = MetricLabelsConfig::default();
assert!(labels.all_enabled(), "the default must emit today's label set exactly");
for label in [
MetricLabel::Cluster,
MetricLabel::Endpoint,
MetricLabel::Listener,
MetricLabel::Method,
MetricLabel::Route,
MetricLabel::StatusClass,
] {
assert!(labels.is_enabled(label), "{label:?} should default to enabled");
}
}
#[test]
fn disabling_one_dimension_clears_all_enabled() {
let labels: MetricLabelsConfig = serde_yaml::from_str("disabled: [endpoint]").unwrap();
assert!(!labels.is_enabled(MetricLabel::Endpoint), "endpoint should be disabled");
assert!(
labels.is_enabled(MetricLabel::Cluster),
"unlisted dimensions stay enabled"
);
assert!(!labels.all_enabled(), "the fast path must not be taken");
}
#[test]
fn metrics_config_parses_nested_labels() {
let metrics: MetricsConfig = serde_yaml::from_str("labels:\n disabled: [route, endpoint]").unwrap();
assert!(
!metrics.labels.is_enabled(MetricLabel::Route),
"route should be disabled"
);
assert!(
!metrics.labels.is_enabled(MetricLabel::Endpoint),
"endpoint should be disabled"
);
assert!(
metrics.labels.is_enabled(MetricLabel::Method),
"method should remain enabled"
);
}
#[test]
fn unknown_label_names_are_rejected() {
let parsed: Result<MetricLabelsConfig, _> = serde_yaml::from_str("disabled: [not_a_label]");
assert!(parsed.is_err(), "an unknown dimension name must not parse silently");
}
#[test]
fn metrics_config_defaults_to_all_labels() {
let metrics: MetricsConfig = serde_yaml::from_str("{}").unwrap();
assert!(
metrics.labels.all_enabled(),
"an empty metrics section must not change any series"
);
}
#[test]
fn route_templates_default_to_empty() {
let metrics = MetricsConfig::default();
assert!(
metrics.route_templates.is_empty(),
"route_templates should default empty"
);
assert!(
RouteTemplates::compile(&metrics.route_templates).is_empty(),
"no templates compiles to an empty matcher"
);
}
#[test]
fn route_templates_collapse_dynamic_segments() {
let templates = RouteTemplates::compile(&["/users/{id}/orders".to_owned()]);
for path in ["/users/1/orders", "/users/abc-def/orders", "/users/42/orders/"] {
assert_eq!(
templates.match_path(path),
Some("/users/{id}/orders"),
"{path} should collapse to the template"
);
}
}
#[test]
fn route_templates_require_an_exact_segment_count() {
let templates = RouteTemplates::compile(&["/users/{id}".to_owned()]);
assert_eq!(
templates.match_path("/users/42/orders"),
None,
"extra segments must not match"
);
assert_eq!(templates.match_path("/users"), None, "missing segments must not match");
}
#[test]
fn route_templates_ignore_the_query_string() {
let templates = RouteTemplates::compile(&["/search/{term}".to_owned()]);
assert_eq!(
templates.match_path("/search/shoes?page=2"),
Some("/search/{term}"),
"a query string must not defeat the match"
);
}
#[test]
fn route_templates_match_literals_exactly() {
let templates = RouteTemplates::compile(&["/api/{version}/health".to_owned()]);
assert_eq!(templates.match_path("/api/v1/health"), Some("/api/{version}/health"));
assert_eq!(
templates.match_path("/api/v1/status"),
None,
"literal mismatch must not match"
);
}
#[test]
fn route_templates_prefer_the_first_configured_match() {
let templates = RouteTemplates::compile(&["/a/{x}".to_owned(), "/{y}/b".to_owned()]);
assert_eq!(
templates.match_path("/a/b"),
Some("/a/{x}"),
"configuration order decides when two templates overlap"
);
}
#[test]
fn parse_route_templates() {
let metrics: MetricsConfig = serde_yaml::from_str("route_templates:\n - \"/users/{id}\"").unwrap();
assert_eq!(metrics.route_templates, vec!["/users/{id}".to_owned()]);
}
#[test]
fn parse_explicit_filter_duration() {
let metrics: MetricsConfig = serde_yaml::from_str("filter_duration: true").unwrap();
assert!(metrics.filter_duration, "explicit filter_duration should be true");
}
}