use axum::extract::MatchedPath;
use axum::http::HeaderMap;
use axum::middleware::Next;
use axum::response::Response;
use opentelemetry::KeyValue;
use opentelemetry::propagation::Extractor;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::{SpanExporter, WithExportConfig};
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use crate::config::ServerConfig;
pub fn init_tracer_provider(cfg: &ServerConfig) -> anyhow::Result<SdkTracerProvider> {
let exporter = SpanExporter::builder()
.with_tonic()
.with_endpoint(cfg.otlp_endpoint.clone())
.build()?;
let resource = Resource::builder()
.with_service_name("knot-server")
.with_attributes([
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("knot.version", env!("KNOT_VERSION")),
])
.build();
let ratio = cfg.trace_sample_ratio;
let ratio = if (0.0..=1.0).contains(&ratio) {
ratio
} else {
let clamped = ratio.clamp(0.0, 1.0);
eprintln!(
"KNOT_SERVER_TRACE_SAMPLE_RATIO={ratio} is out of range [0.0, 1.0], clamping to {clamped}"
);
clamped
};
let sampler = Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(ratio)));
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.with_sampler(sampler)
.build();
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
Ok(provider)
}
pub fn init_subscriber(provider: Option<&SdkTracerProvider>) {
let otel_layer =
provider.map(|p| tracing_opentelemetry::layer().with_tracer(p.tracer("knot-server")));
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer())
.with(otel_layer)
.init();
}
pub async fn shutdown(provider: SdkTracerProvider) {
let result = tokio::task::spawn_blocking(move || provider.shutdown()).await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => eprintln!("OTel tracer shutdown error: {e}"),
Err(e) => eprintln!("OTel tracer shutdown task panicked: {e}"),
}
}
fn should_skip(path: &str) -> bool {
path == "/metrics"
|| path == "/favicon.ico"
|| path == "/graph"
|| path.starts_with("/docs")
|| path.starts_with("/api-docs")
|| path.starts_with("/assets")
}
struct HeaderExtractor<'a>(&'a HeaderMap);
impl Extractor for HeaderExtractor<'_> {
fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).and_then(|v| v.to_str().ok())
}
fn keys(&self) -> Vec<&str> {
self.0.keys().map(|k| k.as_str()).collect()
}
}
pub async fn trace_http(req: axum::extract::Request, next: Next) -> Response {
if should_skip(req.uri().path()) {
return next.run(req).await;
}
let method = req.method().clone();
let route = req
.extensions()
.get::<MatchedPath>()
.map(|mp| crate::metrics::intern_route(mp.as_str()))
.unwrap_or("unmatched")
.to_string();
let parent_cx = opentelemetry::global::get_text_map_propagator(|prop| {
prop.extract(&HeaderExtractor(req.headers()))
});
let span = tracing::info_span!(
"http_request",
otel.name = tracing::field::Empty,
otel.kind = "server",
otel.status_code = tracing::field::Empty,
http.request.method = %method,
http.route = %route,
http.response.status_code = tracing::field::Empty,
);
let _ = span.set_parent(parent_cx);
span.record("otel.name", format!("{} {route}", method.as_str()).as_str());
async move {
let response = next.run(req).await;
let status = response.status();
let span = tracing::Span::current();
span.record("http.response.status_code", status.as_u16());
if status.is_server_error() {
span.record("otel.status_code", "ERROR");
}
response
}
.instrument(span)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::routing::get;
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::TraceContextExt;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tower::util::ServiceExt;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
#[test]
fn should_skip_excludes_infra_and_assets() {
for p in [
"/metrics",
"/favicon.ico",
"/graph",
"/docs",
"/docs/swagger-ui.css",
"/api-docs/openapi.json",
"/assets/app.js",
] {
assert!(should_skip(p), "expected {p} to be skipped");
}
}
#[test]
fn should_skip_allows_api_routes() {
for p in ["/api/repos", "/api/health", "/api/repos/x/search"] {
assert!(!should_skip(p), "expected {p} to be traced");
}
}
#[test]
fn header_extractor_reads_and_lists_headers() {
let mut headers = HeaderMap::new();
headers.insert(
"traceparent",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
.parse()
.unwrap(),
);
let ex = HeaderExtractor(&headers);
assert_eq!(
ex.get("traceparent"),
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
);
assert!(ex.keys().contains(&"traceparent"));
}
#[test]
fn traceparent_extraction_yields_valid_remote_context() {
let mut headers = HeaderMap::new();
headers.insert(
"traceparent",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
.parse()
.unwrap(),
);
let prop = TraceContextPropagator::new();
let cx = prop.extract(&HeaderExtractor(&headers));
let binding = cx.span();
let sc = binding.span_context();
assert!(sc.is_valid(), "expected a valid remote span context");
assert_eq!(
sc.trace_id().to_string(),
"0af7651916cd43dd8448eb211c80319c"
);
}
#[derive(Clone, Default)]
#[expect(clippy::type_complexity, reason = "test recorder nested types")]
struct Recorder(Arc<Mutex<Vec<(String, HashMap<String, String>)>>>);
struct FieldVisitor(HashMap<String, String>);
impl tracing::field::Visit for FieldVisitor {
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0
.insert(field.name().to_string(), format!("{value:?}"));
}
}
impl<S: tracing::Subscriber> Layer<S> for Recorder {
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::span::Id,
_ctx: Context<'_, S>,
) {
let mut v = FieldVisitor(HashMap::new());
attrs.record(&mut v);
self.0
.lock()
.unwrap()
.push((attrs.metadata().name().to_string(), v.0));
}
}
#[tokio::test]
async fn trace_http_creates_span_with_method_and_route() {
let recorder = Recorder::default();
let subscriber = tracing_subscriber::registry().with(recorder.clone());
let _guard = tracing::subscriber::set_default(subscriber);
let app = Router::new()
.route("/api/health", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(trace_http));
let req = Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap();
let _ = app.oneshot(req).await.unwrap();
let spans = recorder.0.lock().unwrap();
let (_, fields) = spans
.iter()
.find(|(name, _)| name == "http_request")
.expect("trace_http should create an http_request span");
assert_eq!(
fields.get("http.request.method").map(String::as_str),
Some("GET")
);
assert_eq!(
fields.get("http.route").map(String::as_str),
Some("/api/health")
);
}
#[tokio::test]
async fn trace_http_skips_excluded_paths() {
let recorder = Recorder::default();
let subscriber = tracing_subscriber::registry().with(recorder.clone());
let _guard = tracing::subscriber::set_default(subscriber);
let app = Router::new()
.route("/metrics", get(|| async { "m" }))
.layer(axum::middleware::from_fn(trace_http));
let req = Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap();
let _ = app.oneshot(req).await.unwrap();
let spans = recorder.0.lock().unwrap();
assert!(
!spans.iter().any(|(name, _)| name == "http_request"),
"skip-list paths must not produce a span"
);
}
}