feat: freeze typed metrics registry and bounded exemplars

This commit is contained in:
2026-08-14 11:52:00 +03:00
parent 17c8e3a8f0
commit 8e709acea9
29 changed files with 1690 additions and 103 deletions
@@ -5,7 +5,9 @@ use axum::{
middleware::Next,
response::Response,
};
use crank_metrics::{DbPoolState, HttpMethod, HttpRoute, HttpStatusClass, InFlightGuard};
use crank_metrics::{
DbPoolState, ExemplarTraceId, HttpMethod, HttpRoute, HttpStatusClass, InFlightGuard,
};
use metrics::Unit;
use crate::{MetricKind, MetricUnit, metric_schema};
@@ -22,11 +24,17 @@ pub async fn record_http_request(request: Request, next: Next) -> Response {
let _inflight = InFlightGuard::http();
let response = next.run(request).await;
crank_metrics::record_http_request(
let exemplar = response
.headers()
.get("x-trace-id")
.and_then(|value| value.to_str().ok())
.and_then(ExemplarTraceId::parse);
crank_metrics::record_http_request_with_exemplar(
route,
method,
HttpStatusClass::from_status(response.status().as_u16()),
started_at.elapsed(),
exemplar,
);
response
+85 -3
View File
@@ -18,8 +18,11 @@ use thiserror::Error;
use tokio::net::TcpListener;
use crate::{DURATION_BUCKETS_SECONDS, ServiceIdentity};
use crank_metrics::{ExemplarObservation, MAX_EXPOSITION_BYTES, MetricService, exemplar_snapshot};
const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
const OPENMETRICS_CONTENT_TYPE: &str = "application/openmetrics-text; version=1.0.0; charset=utf-8";
const EXPOSITION_TOO_LARGE: &str = "metrics exposition exceeds configured bound\n";
#[derive(Clone)]
pub struct MetricsConfig {
@@ -190,6 +193,7 @@ pub(crate) fn install_prometheus_recorder(
fn prometheus_builder(
identity: &ServiceIdentity,
) -> Result<PrometheusBuilder, MetricsSurfaceError> {
MetricService::parse(identity.service()).ok_or(MetricsSurfaceError::RecorderConfiguration)?;
PrometheusBuilder::new()
.set_buckets(DURATION_BUCKETS_SECONDS)
.map(|builder| {
@@ -201,15 +205,70 @@ fn prometheus_builder(
.map_err(|_| MetricsSurfaceError::RecorderConfiguration)
}
async fn render_metrics(State(state): State<MetricsState>) -> Response {
let mut response = state.handle.render().into_response();
async fn render_metrics(State(state): State<MetricsState>, headers: HeaderMap) -> Response {
let legacy = state.handle.render();
let openmetrics = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|part| part.trim().starts_with("application/openmetrics-text"))
});
let body = if openmetrics {
render_openmetrics(&legacy, &exemplar_snapshot())
} else {
legacy
};
if body.len() > MAX_EXPOSITION_BYTES {
return (StatusCode::SERVICE_UNAVAILABLE, EXPOSITION_TOO_LARGE).into_response();
}
let mut response = body.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(PROMETHEUS_CONTENT_TYPE),
HeaderValue::from_static(if openmetrics {
OPENMETRICS_CONTENT_TYPE
} else {
PROMETHEUS_CONTENT_TYPE
}),
);
response
}
fn render_openmetrics(legacy: &str, exemplars: &[ExemplarObservation]) -> String {
let mut output = String::with_capacity(legacy.len() + exemplars.len().saturating_mul(96) + 6);
for line in legacy.lines() {
output.push_str(line);
if let Some(exemplar) = exemplars
.iter()
.find(|candidate| line_matches_exemplar(line, candidate))
{
output.push_str(" # {trace_id=\"");
output.push_str(exemplar.trace_id.as_str());
output.push_str("\"} ");
output.push_str(&exemplar.value.to_string());
}
output.push('\n');
}
output.push_str("# EOF\n");
output
}
fn line_matches_exemplar(line: &str, exemplar: &ExemplarObservation) -> bool {
if !line.starts_with(exemplar.metric) || !line[exemplar.metric.len()..].starts_with("_bucket{")
{
return false;
}
let expected_bound = exemplar
.bucket_upper_bound
.map_or_else(|| "+Inf".to_owned(), |value| value.to_string());
line.contains(&format!("le=\"{expected_bound}\""))
&& exemplar
.labels
.iter()
.all(|(key, value)| line.contains(&format!("{key}=\"{value}\"")))
}
async fn metrics_health() -> impl IntoResponse {
(StatusCode::OK, "ok\n")
}
@@ -251,3 +310,26 @@ fn bearer_token(headers: &HeaderMap) -> Option<&[u8]> {
fn token_digest(token: &[u8]) -> [u8; 32] {
Sha256::digest(token).into()
}
#[cfg(test)]
mod rendering_tests {
use crank_metrics::{ExemplarObservation, ExemplarTraceId};
use super::render_openmetrics;
#[test]
fn openmetrics_adds_bounded_exemplar_without_changing_aggregate() {
let legacy = "# TYPE crank_http_request_duration_seconds histogram\ncrank_http_request_duration_seconds_bucket{method=\"GET\",route=\"/health\",le=\"0.01\"} 1\ncrank_http_request_duration_seconds_sum{method=\"GET\",route=\"/health\"} 0.007\n";
let exemplar = ExemplarObservation {
metric: "crank_http_request_duration_seconds",
labels: vec![("route", "/health"), ("method", "GET")],
bucket_upper_bound: Some(0.01),
value: 0.007,
trace_id: ExemplarTraceId::parse("0123456789abcdef0123456789abcdef").unwrap(),
};
let rendered = render_openmetrics(legacy, &[exemplar]);
assert!(rendered.contains("# {trace_id=\"0123456789abcdef0123456789abcdef\"} 0.007"));
assert!(rendered.ends_with("# EOF\n"));
assert_eq!(rendered.matches(" 1").count(), legacy.matches(" 1").count());
}
}