diff --git a/apps/skit/src/config.rs b/apps/skit/src/config.rs index c4944b2ad..d96ddd958 100644 --- a/apps/skit/src/config.rs +++ b/apps/skit/src/config.rs @@ -231,6 +231,145 @@ impl Default for CorsConfig { } } +fn default_label_fallback() -> String { + "other".to_string() +} + +/// A bounded metric label sourced from a trusted request header. +/// +/// The header value is trimmed and lowercased, then matched against `allowed`; +/// anything not in the allowlist (or a missing header) collapses to `fallback`, +/// so client-supplied headers can never inflate metric cardinality. +#[derive(Deserialize, Serialize, Debug, Clone, JsonSchema)] +pub struct RequestLabelConfig { + /// Metric label key (e.g. `service`). + pub name: String, + /// Trusted request header to read the value from (e.g. `X-StreamKit-Service`). + /// + /// Read before auth middleware runs, so point this at a header set by a + /// trusted upstream (e.g. the gateway, which strips client-supplied copies) + /// — not at an auth-injected header such as `X-StreamKit-Role`, whose + /// pre-auth value is client-controlled. + pub header: String, + /// Permitted values, matched case-insensitively after trimming. + #[serde(default)] + pub allowed: Vec, + /// Value emitted when the header is absent or its value is not in `allowed`. + #[serde(default = "default_label_fallback")] + pub fallback: String, +} + +/// Configuration for request-scoped metric labeling. +/// +/// Empty by default: no request metric gains a configured label unless an +/// operator opts in. Declaring `request_labels` sets the full list (figment +/// does not merge sequences). See the commented example in `samples/skit.toml`. +#[derive(Deserialize, Serialize, Debug, Clone, Default, JsonSchema)] +pub struct MetricsConfig { + /// Bounded labels attached to request metrics, each sourced from a trusted + /// request header. Applied to all HTTP request metrics and to oneshot + /// pipeline metrics. + #[serde(default)] + pub request_labels: Vec, +} + +/// Prometheus sanitizes any character outside `[a-zA-Z0-9_]` in a label key to +/// `_`, so `http.method` and `http_method` collapse to the same series key. We +/// compare sanitized keys to catch collisions that only appear after scrape. +fn sanitize_label_key(name: &str) -> String { + name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect() +} + +/// A metric label name must be a non-empty identifier (dots allowed, per the +/// OpenTelemetry convention used by the built-in keys) so it survives export. +fn is_valid_label_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {}, + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') +} + +impl MetricsConfig { + /// Normalize then validate — the single chokepoint that readies a metrics + /// config for use. Callers decide how to treat the error: `load()` rejects + /// the file, `create_app_state()` warns and disables the labels. + /// + /// # Errors + /// + /// Returns an error if validation fails after normalization. + pub fn prepare(&mut self) -> Result<(), String> { + self.normalize(); + self.validate() + } + + /// Lowercase and trim every allowlist entry and each `fallback` so the + /// per-request hot path only has to normalize the incoming header value and + /// every emitted value shares one normalized space. + pub fn normalize(&mut self) { + for label in &mut self.request_labels { + for allowed in &mut label.allowed { + *allowed = crate::metrics_labels::normalize(allowed); + } + label.fallback = crate::metrics_labels::normalize(&label.fallback); + } + } + + /// Reject label configs that would silently corrupt metrics: invalid names, + /// names colliding (after Prometheus sanitization) with a built-in key or + /// each other, invalid/empty headers, and empty allowlist or fallback values. + /// + /// # Errors + /// + /// Returns an error describing the first offending label. + pub fn validate(&self) -> Result<(), String> { + let reserved: std::collections::HashSet = + crate::metrics_labels::RESERVED_LABEL_KEYS + .iter() + .map(|k| sanitize_label_key(k)) + .collect(); + let mut seen = std::collections::HashSet::new(); + for label in &self.request_labels { + if !is_valid_label_name(&label.name) { + return Err(format!( + "metrics request_label name '{}' is not a valid metric label name", + label.name + )); + } + let key = sanitize_label_key(&label.name); + if reserved.contains(&key) { + return Err(format!( + "metrics request_label name '{}' collides with a built-in metric key", + label.name + )); + } + if !seen.insert(key) { + return Err(format!("duplicate metrics request_label name '{}'", label.name)); + } + if axum::http::HeaderName::try_from(label.header.as_str()).is_err() { + return Err(format!( + "metrics request_label '{}' has an invalid header '{}'", + label.name, label.header + )); + } + if label.allowed.iter().any(|v| v.trim().is_empty()) { + return Err(format!( + "metrics request_label '{}' has an empty allowed value", + label.name + )); + } + if label.fallback.trim().is_empty() { + return Err(format!( + "metrics request_label '{}' has an empty fallback value", + label.name + )); + } + } + Ok(()) + } +} + /// Telemetry and observability configuration (OpenTelemetry, tokio-console). #[derive(Deserialize, Serialize, Debug, Clone, JsonSchema)] pub struct TelemetryConfig { @@ -322,6 +461,9 @@ pub struct ServerConfig { /// CORS configuration for cross-origin requests #[serde(default)] pub cors: CorsConfig, + /// Bounded request-metric labeling configuration. + #[serde(default)] + pub metrics: MetricsConfig, #[cfg(feature = "moq")] pub moq_address: Option, /// TLS certificate for the MoQ WebTransport listener. @@ -350,6 +492,7 @@ impl Default for ServerConfig { max_body_size: default_max_body_size(), base_path: None, cors: CorsConfig::default(), + metrics: MetricsConfig::default(), #[cfg(feature = "moq")] moq_address: None, #[cfg(feature = "moq")] @@ -1035,6 +1178,9 @@ pub fn load(config_path: &str) -> Result> if let Err(e) = config.mcp.validate() { return Err(Box::new(figment::Error::from(e))); } + if let Err(e) = config.server.metrics.prepare() { + return Err(Box::new(figment::Error::from(e))); + } Ok(ConfigLoadResult { config, file_missing }) } @@ -1445,4 +1591,156 @@ allowed_plugins = [] Ok(()) }); } + + fn request_label(name: &str) -> RequestLabelConfig { + RequestLabelConfig { + name: name.to_string(), + header: "X-Test".to_string(), + allowed: vec![], + fallback: "other".to_string(), + } + } + + #[test] + fn metrics_validate_rejects_reserved_label_name() { + let metrics = MetricsConfig { request_labels: vec![request_label("status")] }; + assert!(metrics.validate().is_err()); + } + + #[test] + fn metrics_validate_rejects_duplicate_label_name() { + let metrics = MetricsConfig { + request_labels: vec![request_label("service"), request_label("service")], + }; + assert!(metrics.validate().is_err()); + } + + #[test] + fn metrics_validate_accepts_default() { + assert!(MetricsConfig::default().validate().is_ok()); + } + + #[test] + fn metrics_default_is_empty_opt_in() { + assert!(MetricsConfig::default().request_labels.is_empty()); + } + + #[test] + fn metrics_validate_rejects_empty_or_invalid_label_name() { + assert!(MetricsConfig { request_labels: vec![request_label("")] }.validate().is_err()); + assert!(MetricsConfig { request_labels: vec![request_label(" ")] }.validate().is_err()); + assert!(MetricsConfig { request_labels: vec![request_label("1service")] } + .validate() + .is_err()); + } + + #[test] + fn metrics_validate_rejects_sanitized_reserved_collision() { + // `http_method` sanitizes to the same Prometheus key as `http.method`. + let metrics = MetricsConfig { request_labels: vec![request_label("http_method")] }; + assert!(metrics.validate().is_err()); + } + + #[test] + fn metrics_validate_rejects_empty_allowed_value() { + let metrics = MetricsConfig { + request_labels: vec![RequestLabelConfig { + name: "service".to_string(), + header: "X-Test".to_string(), + allowed: vec!["tts".to_string(), " ".to_string()], + fallback: "other".to_string(), + }], + }; + assert!(metrics.validate().is_err()); + } + + #[test] + fn metrics_validate_rejects_empty_or_invalid_header() { + let empty = RequestLabelConfig { + name: "service".to_string(), + header: String::new(), + allowed: vec!["tts".to_string()], + fallback: "other".to_string(), + }; + assert!(MetricsConfig { request_labels: vec![empty] }.validate().is_err()); + let bad = RequestLabelConfig { + name: "service".to_string(), + header: "bad header".to_string(), + allowed: vec!["tts".to_string()], + fallback: "other".to_string(), + }; + assert!(MetricsConfig { request_labels: vec![bad] }.validate().is_err()); + } + + #[test] + fn metrics_validate_rejects_empty_fallback() { + let metrics = MetricsConfig { + request_labels: vec![RequestLabelConfig { + name: "service".to_string(), + header: "X-Test".to_string(), + allowed: vec!["tts".to_string()], + fallback: " ".to_string(), + }], + }; + assert!(metrics.validate().is_err()); + } + + #[test] + fn metrics_prepare_normalizes_then_validates() { + let mut metrics = MetricsConfig { + request_labels: vec![RequestLabelConfig { + name: "service".to_string(), + header: "X-StreamKit-Service".to_string(), + allowed: vec![" TTS ".to_string()], + fallback: "Other".to_string(), + }], + }; + assert!(metrics.prepare().is_ok()); + assert_eq!(metrics.request_labels[0].allowed, vec!["tts".to_string()]); + assert_eq!(metrics.request_labels[0].fallback, "other"); + } + + #[test] + fn metrics_normalize_lowercases_fallback() { + let mut metrics = MetricsConfig { + request_labels: vec![RequestLabelConfig { + name: "service".to_string(), + header: "X-Test".to_string(), + allowed: vec!["tts".to_string()], + fallback: " Other ".to_string(), + }], + }; + metrics.normalize(); + assert_eq!(metrics.request_labels[0].fallback, "other"); + } + + #[test] + fn metrics_normalize_lowercases_allowlist() { + let mut metrics = MetricsConfig { + request_labels: vec![RequestLabelConfig { + name: "service".to_string(), + header: "X-StreamKit-Service".to_string(), + allowed: vec![" TTS ".to_string(), "Stt".to_string()], + fallback: "other".to_string(), + }], + }; + metrics.normalize(); + assert_eq!(metrics.request_labels[0].allowed, vec!["tts".to_string(), "stt".to_string()]); + } + + #[test] + fn load_rejects_reserved_metrics_label_name() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "skit.toml", + r#"[[server.metrics.request_labels]] +name = "http.route" +header = "X-StreamKit-Service" +allowed = ["tts"] +"#, + )?; + assert!(load("skit.toml").is_err(), "reserved label name must fail load"); + Ok(()) + }); + } } diff --git a/apps/skit/src/lib.rs b/apps/skit/src/lib.rs index dab5e0e62..0e2ef6bfa 100644 --- a/apps/skit/src/lib.rs +++ b/apps/skit/src/lib.rs @@ -14,6 +14,7 @@ pub mod marketplace_installer; pub mod marketplace_security; #[cfg(feature = "mcp")] pub mod mcp; +pub mod metrics_labels; #[cfg(feature = "moq")] pub mod moq_gateway; pub mod mse_gateway; diff --git a/apps/skit/src/main.rs b/apps/skit/src/main.rs index c9a20a94e..64dac17ce 100644 --- a/apps/skit/src/main.rs +++ b/apps/skit/src/main.rs @@ -35,6 +35,7 @@ mod marketplace_installer; mod marketplace_security; #[cfg(feature = "mcp")] mod mcp; +mod metrics_labels; #[cfg(feature = "moq")] mod moq_gateway; mod mse_gateway; diff --git a/apps/skit/src/metrics_labels.rs b/apps/skit/src/metrics_labels.rs new file mode 100644 index 000000000..30f7cca2e --- /dev/null +++ b/apps/skit/src/metrics_labels.rs @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Resolve bounded metric labels from trusted request headers. +//! +//! The values are constrained to operator-configured allowlists so +//! client-supplied headers can never inflate metric cardinality. + +use axum::http::HeaderMap; +use opentelemetry::KeyValue; + +use crate::config::RequestLabelConfig; + +/// Label keys emitted by the built-in request instruments (HTTP middleware and +/// oneshot histogram). Configured request labels must not collide with these, +/// even after Prometheus sanitizes the key. +pub const STATUS_KEY: &str = "status"; +pub const HTTP_METHOD_KEY: &str = "http.method"; +pub const HTTP_ROUTE_KEY: &str = "http.route"; +pub const HTTP_STATUS_CODE_KEY: &str = "http.status_code"; + +/// Single source of truth for the built-in keys, referenced both at the emit +/// sites and by config validation so the reserved set cannot drift. +pub const RESERVED_LABEL_KEYS: [&str; 4] = + [STATUS_KEY, HTTP_METHOD_KEY, HTTP_ROUTE_KEY, HTTP_STATUS_CODE_KEY]; + +/// Bounded request labels resolved once per request and stashed in request +/// extensions so downstream handlers can reuse them without re-parsing headers. +#[derive(Clone)] +pub struct ResolvedRequestLabels(pub Vec); + +/// Canonical normalization for label values and allowlist entries: trim and +/// ASCII-lowercase. Shared so the per-request path and config load stay in step. +pub fn normalize(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +/// Constrain a header value to an allowlist, falling back when it is absent or +/// unrecognized. The incoming value is normalized (trim + lowercase); `allowed` +/// entries are expected to be pre-normalized at config load. An empty value +/// never matches, so a stray empty allowlist entry can't emit an empty label. +fn classify(value: Option<&str>, allowed: &[String], fallback: &str) -> String { + match value.map(normalize) { + Some(v) if !v.is_empty() && allowed.contains(&v) => v, + _ => fallback.to_string(), + } +} + +/// Resolve configured request labels into bounded metric key-values. +pub fn resolve_request_labels(labels: &[RequestLabelConfig], headers: &HeaderMap) -> Vec { + labels + .iter() + .map(|label| { + let value = headers.get(label.header.as_str()).and_then(|v| v.to_str().ok()); + KeyValue::new(label.name.clone(), classify(value, &label.allowed, &label.fallback)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn label(name: &str, header: &str, allowed: &[&str]) -> RequestLabelConfig { + RequestLabelConfig { + name: name.to_string(), + header: header.to_string(), + allowed: allowed.iter().map(|s| (*s).to_string()).collect(), + fallback: "other".to_string(), + } + } + + #[test] + fn classify_allows_listed_values() { + let allowed = vec!["tts".to_string(), "stt".to_string()]; + assert_eq!(classify(Some("tts"), &allowed, "other"), "tts"); + assert_eq!(classify(Some("stt"), &allowed, "other"), "stt"); + } + + #[test] + fn classify_normalizes_case_and_whitespace() { + let allowed = vec!["tts".to_string()]; + assert_eq!(classify(Some(" TTS "), &allowed, "other"), "tts"); + } + + #[test] + fn classify_unknown_empty_and_absent_fall_back() { + let allowed = vec!["tts".to_string()]; + assert_eq!(classify(Some("kokoro"), &allowed, "other"), "other"); + assert_eq!(classify(Some(""), &allowed, "other"), "other"); + assert_eq!(classify(None, &allowed, "other"), "other"); + } + + #[test] + fn classify_empty_allowlist_always_falls_back() { + assert_eq!(classify(Some("tts"), &[], "other"), "other"); + } + + #[test] + fn classify_empty_allowlist_entry_never_emits_empty_value() { + let allowed = vec![String::new()]; + assert_eq!(classify(Some(""), &allowed, "other"), "other"); + assert_eq!(classify(Some(" "), &allowed, "other"), "other"); + } + + #[test] + fn resolve_emits_one_keyvalue_per_label() { + let labels = vec![label("service", "X-StreamKit-Service", &["tts", "stt"])]; + let mut headers = HeaderMap::new(); + headers.insert("X-StreamKit-Service", HeaderValue::from_static("STT")); + + let resolved = resolve_request_labels(&labels, &headers); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].key.as_str(), "service"); + assert_eq!(resolved[0].value.as_str(), "stt"); + } + + #[test] + fn resolve_falls_back_when_header_missing() { + let labels = vec![label("service", "X-StreamKit-Service", &["tts", "stt"])]; + let resolved = resolve_request_labels(&labels, &HeaderMap::new()); + assert_eq!(resolved[0].value.as_str(), "other"); + } +} diff --git a/apps/skit/src/server/mod.rs b/apps/skit/src/server/mod.rs index 1ac8da177..599185a25 100644 --- a/apps/skit/src/server/mod.rs +++ b/apps/skit/src/server/mod.rs @@ -1562,13 +1562,27 @@ async fn static_handler( } } -async fn metrics_middleware(req: axum::http::Request, next: Next) -> Response { +async fn metrics_middleware( + State(app_state): State>, + mut req: axum::http::Request, + next: Next, +) -> Response { let start = Instant::now(); let method = req.method().clone(); let path = req.extensions().get::().map_or_else( || req.uri().path().to_owned(), |matched_path| matched_path.as_str().to_owned(), ); + let configured_request_labels = &app_state.config.server.metrics.request_labels; + let configured_labels = if configured_request_labels.is_empty() { + Vec::new() + } else { + let resolved = + crate::metrics_labels::resolve_request_labels(configured_request_labels, req.headers()); + // Let downstream handlers reuse the resolved labels instead of re-parsing headers. + req.extensions_mut().insert(crate::metrics_labels::ResolvedRequestLabels(resolved.clone())); + resolved + }; let response = next.run(req).await; @@ -1590,11 +1604,12 @@ async fn metrics_middleware(req: axum::http::Request, next: Next) -> Respo }) .clone(); - let labels = [ - KeyValue::new("http.method", method.to_string()), - KeyValue::new("http.route", path), - KeyValue::new("http.status_code", status), + let mut labels = vec![ + KeyValue::new(crate::metrics_labels::HTTP_METHOD_KEY, method.to_string()), + KeyValue::new(crate::metrics_labels::HTTP_ROUTE_KEY, path), + KeyValue::new(crate::metrics_labels::HTTP_STATUS_CODE_KEY, status), ]; + labels.extend(configured_labels); counter.add(1, &labels); histogram.record(latency, &labels); @@ -1615,6 +1630,14 @@ pub fn create_app_state( mut config: Config, auth: Option>, ) -> Arc { + // Prepare here (not only in config::load) so every AppState — tests, + // embedded/MCP callers — gets a normalized, validated metrics config. This + // path is infallible, so an invalid config is disabled rather than rejected. + if let Err(e) = config.server.metrics.prepare() { + tracing::warn!("disabling invalid metrics request_labels configuration: {e}"); + config.server.metrics.request_labels.clear(); + } + let (event_tx, _) = tokio::sync::broadcast::channel(128); let resource_policy = streamkit_core::ResourcePolicy { @@ -1958,7 +1981,7 @@ pub fn create_app( .on_response(DefaultOnResponse::new().level(tracing::Level::DEBUG)) .on_failure(DefaultOnFailure::new().level(tracing::Level::WARN)), )) - .layer(middleware::from_fn(metrics_middleware)) + .layer(middleware::from_fn_with_state(Arc::clone(&app_state), metrics_middleware)) .layer(SetResponseHeaderLayer::if_not_present( header::X_CONTENT_TYPE_OPTIONS, header::HeaderValue::from_static("nosniff"), diff --git a/apps/skit/src/server/oneshot.rs b/apps/skit/src/server/oneshot.rs index eb3f657df..02da4ea7a 100644 --- a/apps/skit/src/server/oneshot.rs +++ b/apps/skit/src/server/oneshot.rs @@ -43,6 +43,14 @@ struct HttpInputBinding { required: bool, } +/// Combine the per-request `status` with the resolved bounded labels. +fn duration_labels(status: &'static str, extra: &[KeyValue]) -> Vec { + let mut labels = Vec::with_capacity(extra.len() + 1); + labels.push(KeyValue::new(crate::metrics_labels::STATUS_KEY, status)); + labels.extend_from_slice(extra); + labels +} + /// Extract content-type header and multipart boundary from request headers. fn extract_multipart_boundary(headers: &HeaderMap) -> Result { let ct_header = headers @@ -399,6 +407,7 @@ fn build_streaming_response( pipeline_result: streamkit_engine::OneshotPipelineResult, start_time: Instant, duration_histogram: opentelemetry::metrics::Histogram, + metric_labels: Vec, ) -> Response { tracing::debug!( "Creating streaming response with content type: {}", @@ -406,7 +415,8 @@ fn build_streaming_response( ); let stream = ReceiverStream::new(pipeline_result.data_stream).map(Ok::<_, Infallible>); - let stream = InstrumentedOneshotStream::new(stream, start_time, duration_histogram); + let stream = + InstrumentedOneshotStream::new(stream, start_time, duration_histogram, metric_labels); let body = Body::from_stream(stream); let mut headers = HeaderMap::new(); @@ -436,6 +446,7 @@ struct InstrumentedOneshotStream { start_time: Instant, recorded: bool, duration_histogram: opentelemetry::metrics::Histogram, + metric_labels: Vec, } impl InstrumentedOneshotStream { @@ -443,8 +454,9 @@ impl InstrumentedOneshotStream { inner: S, start_time: Instant, duration_histogram: opentelemetry::metrics::Histogram, + metric_labels: Vec, ) -> Self { - Self { inner, start_time, recorded: false, duration_histogram } + Self { inner, start_time, recorded: false, duration_histogram, metric_labels } } fn record(&mut self, status: &'static str) { @@ -452,7 +464,7 @@ impl InstrumentedOneshotStream { return; } self.recorded = true; - let labels = [KeyValue::new("status", status)]; + let labels = duration_labels(status, &self.metric_labels); self.duration_histogram.record(self.start_time.elapsed().as_secs_f64(), &labels); } } @@ -492,6 +504,13 @@ pub(super) async fn process_oneshot_pipeline_handler( tracing::info!("Processing multipart request"); let headers = req.headers().clone(); + // Reuse the labels resolved by metrics_middleware; absent only when no + // request labels are configured, in which case there are none to record. + let metric_labels = req + .extensions() + .get::() + .map(|resolved| resolved.0.clone()) + .unwrap_or_default(); let (role_name, perms) = crate::role_extractor::get_role_and_permissions(&headers, &app_state); if !perms.create_sessions { return Err(AppError::Forbidden( @@ -652,7 +671,7 @@ pub(super) async fn process_oneshot_pipeline_handler( result }, Err(e) => { - let labels = [KeyValue::new("status", "error")]; + let labels = duration_labels("error", &metric_labels); oneshot_duration_histogram.record(oneshot_start_time.elapsed().as_secs_f64(), &labels); cancel_token.cancel(); return Err(e.into()); @@ -662,19 +681,26 @@ pub(super) async fn process_oneshot_pipeline_handler( match parse_done_rx.await { Ok(Ok(())) => {}, Ok(Err(err)) => { - let labels = [KeyValue::new("status", "error")]; + let labels = duration_labels("error", &metric_labels); oneshot_duration_histogram.record(oneshot_start_time.elapsed().as_secs_f64(), &labels); cancel_token.cancel(); return Err(err); }, Err(e) => { + let labels = duration_labels("error", &metric_labels); + oneshot_duration_histogram.record(oneshot_start_time.elapsed().as_secs_f64(), &labels); cancel_token.cancel(); return Err(AppError::BadRequest(format!("Multipart routing task aborted: {e}"))); }, } let _ = routing_task.await; - Ok(build_streaming_response(pipeline_result, oneshot_start_time, oneshot_duration_histogram)) + Ok(build_streaming_response( + pipeline_result, + oneshot_start_time, + oneshot_duration_histogram, + metric_labels, + )) } #[cfg(test)] diff --git a/samples/skit.toml b/samples/skit.toml index 80704bf03..2b387bd9e 100644 --- a/samples/skit.toml +++ b/samples/skit.toml @@ -67,6 +67,28 @@ allowed_origins = [ # "http://localhost:*", # Keep for development # ] +# [server.metrics] +# Bounded labels attached to request metrics (http.server.* and +# oneshot_pipeline.duration), each sourced from a trusted request header. +# +# Cardinality is operator-bounded: the header value is trimmed + lowercased and +# matched against `allowed`; anything else (or a missing header) collapses to +# `fallback`. Disabled by default — uncomment to opt in. Declaring any label +# sets the full list, so list every label you want here. +# +# The header is read before auth runs, so it must be set by a trusted upstream +# (e.g. the gateway, which strips client-supplied copies) — don't point it at an +# auth-injected header like `X-StreamKit-Role`. +# +# Example: distinguish hosted speech services on oneshot_pipeline.duration so +# TTS / STT / other don't collapse into one series. The gateway sets +# `X-StreamKit-Service` per endpoint. +# [[server.metrics.request_labels]] +# name = "service" +# header = "X-StreamKit-Service" +# allowed = ["tts", "stt"] # missing/unknown -> "other" +# fallback = "other" + [auth] # Built-in JWT authentication for: # - HTTP API + Web UI (cookie or Authorization header)