axum_conf/fluent/observability.rs
1//! Observability middleware: logging, metrics, and OpenTelemetry tracing.
2
3use super::router::FluentRouter;
4use crate::HttpMiddleware;
5
6use {axum::body::Body, http::Request, tower_http::trace::TraceLayer as TowerHTTPLayer};
7
8#[cfg(feature = "metrics")]
9use axum_prometheus::PrometheusMetricLayerBuilder;
10
11#[cfg(feature = "opentelemetry")]
12use {
13 crate::{Error, Result},
14 opentelemetry::{global, trace::TracerProvider},
15 opentelemetry_otlp::WithExportConfig,
16 opentelemetry_sdk::{
17 Resource,
18 trace::{RandomIdGenerator, Sampler},
19 },
20 tracing_opentelemetry::OpenTelemetrySpanExt,
21};
22
23impl<State> FluentRouter<State>
24where
25 State: Clone + Send + Sync + 'static,
26{
27 /// Sets up Prometheus metrics collection and endpoint.
28 ///
29 /// When `config.http.with_metrics` is true, this method:
30 /// - Adds a metrics endpoint at the configured route (default: `/metrics`)
31 /// - Installs Prometheus metric collection middleware
32 /// - Tracks request counts, durations, and HTTP status codes
33 ///
34 /// Metrics are exposed in Prometheus format for scraping by monitoring systems.
35 ///
36 /// # Configuration
37 ///
38 /// ```toml
39 /// [http]
40 /// with_metrics = true
41 /// metrics_route = "/metrics"
42 /// ```
43 ///
44 /// # Note
45 ///
46 /// Disable metrics in tests to avoid conflicts with the global Prometheus registry:
47 /// ```rust
48 /// # use axum_conf::Config;
49 /// let mut config = Config::default();
50 /// config.http.with_metrics = false;
51 /// ```
52 #[cfg(feature = "metrics")]
53 #[must_use]
54 pub fn setup_metrics(mut self) -> Self {
55 if self.config.http.with_metrics && self.is_middleware_enabled(HttpMiddleware::Metrics) {
56 tracing::trace!(
57 route = %self.config.http.metrics_route,
58 "Metrics middleware enabled"
59 );
60 const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME");
61 let metrics_path: &str =
62 Box::leak(self.config.http.metrics_route.clone().into_boxed_str());
63 let (prometheus_layer, metrics_handle) = PrometheusMetricLayerBuilder::new()
64 .enable_response_body_size(true)
65 .with_prefix(PACKAGE_NAME)
66 .with_ignore_pattern(metrics_path)
67 .with_default_metrics()
68 .build_pair();
69
70 self.inner = self
71 .inner
72 .route(
73 metrics_path,
74 axum::routing::get(|| async move { metrics_handle.render() }),
75 )
76 .layer(prometheus_layer);
77 }
78 self
79 }
80
81 /// No-op when `metrics` feature is disabled.
82 #[cfg(not(feature = "metrics"))]
83 #[must_use]
84 pub fn setup_metrics(self) -> Self {
85 if self.config.http.with_metrics {
86 tracing::warn!(
87 "Metrics are enabled in config but the 'metrics' feature is not enabled. \
88 Add `metrics` to your Cargo.toml features to enable metrics support."
89 );
90 }
91 self
92 }
93
94 /// Sets up HTTP request/response logging middleware.
95 ///
96 /// Adds structured tracing for all HTTP requests, logging:
97 /// - Request method and path
98 /// - Response status code
99 /// - Request duration
100 /// - Client IP address
101 /// - Request ID (when available)
102 ///
103 /// Log output format is controlled by the `logging.format` configuration.
104 /// Request IDs are automatically included in the trace span context.
105 ///
106 /// When OpenTelemetry is enabled, extracts trace context from incoming W3C traceparent headers
107 /// and propagates it through the application for distributed tracing.
108 #[must_use]
109 pub fn setup_logging(mut self) -> Self {
110 if !self.is_middleware_enabled(HttpMiddleware::Logging) {
111 tracing::trace!("Logging middleware skipped (disabled in config)");
112 return self;
113 }
114
115 tracing::trace!("Logging middleware enabled");
116 self.inner = self
117 .inner
118 .layer(
119 TowerHTTPLayer::new_for_http().make_span_with(|request: &Request<Body>| {
120 let request_id = request
121 .headers()
122 .get("x-request-id")
123 .and_then(|v| v.to_str().ok())
124 .unwrap_or("unknown");
125
126 let span = tracing::info_span!(
127 "http_request",
128 method = %request.method(),
129 uri = %request.uri(),
130 request_id = %request_id,
131 user = tracing::field::Empty,
132 );
133
134 // Extract OpenTelemetry context from incoming headers if feature is enabled
135 #[cfg(feature = "opentelemetry")]
136 {
137 use opentelemetry::propagation::Extractor;
138
139 // Create an extractor that reads from HTTP headers
140 struct HeaderExtractor<'a>(&'a http::HeaderMap);
141
142 impl<'a> Extractor for HeaderExtractor<'a> {
143 fn get(&self, key: &str) -> Option<&str> {
144 self.0.get(key).and_then(|v| v.to_str().ok())
145 }
146
147 fn keys(&self) -> Vec<&str> {
148 self.0.keys().map(|k| k.as_str()).collect()
149 }
150 }
151
152 let extractor = HeaderExtractor(request.headers());
153 let context =
154 opentelemetry::global::get_text_map_propagator(|propagator| {
155 propagator.extract(&extractor)
156 });
157
158 // Set the extracted context as the parent of this span
159 span.set_parent(context).ok();
160 }
161
162 span
163 }),
164 );
165
166 self
167 }
168
169 /// Initializes OpenTelemetry distributed tracing with W3C Trace Context propagation.
170 ///
171 /// Sets up OTLP export to a collector (e.g., Jaeger, Tempo) for distributed tracing and
172 /// configures automatic extraction and injection of W3C `traceparent` and `tracestate` headers.
173 /// This enables seamless trace propagation across microservices.
174 ///
175 /// This must be called before other setup methods to ensure traces are properly captured.
176 ///
177 /// # Configuration
178 ///
179 /// ```toml
180 /// [logging.opentelemetry]
181 /// endpoint = "http://localhost:4317"
182 /// service_name = "my-service"
183 /// ```
184 ///
185 /// # Returns
186 ///
187 /// A `Result` containing the configured router or an error if initialization fails.
188 ///
189 /// # Errors
190 ///
191 /// Returns an error if:
192 /// - Cannot connect to the OTLP endpoint
193 /// - Configuration is invalid
194 ///
195 /// # Examples
196 ///
197 /// ```rust,no_run
198 /// # use axum_conf::{Config, FluentRouter};
199 /// # async fn example() -> axum_conf::Result<()> {
200 /// let config = Config::default();
201 /// let router = FluentRouter::without_state(config)?
202 /// .setup_opentelemetry()?
203 /// .setup_logging()
204 /// .into_inner();
205 /// # Ok(())
206 /// # }
207 /// ```
208 #[cfg(feature = "opentelemetry")]
209 pub fn setup_opentelemetry(self) -> Result<Self> {
210 if let Some(otel_config) = &self.config.logging.opentelemetry {
211 use tracing_subscriber::prelude::*;
212
213 let service_name = otel_config
214 .service_name
215 .clone()
216 .unwrap_or_else(|| env!("CARGO_PKG_NAME").to_string());
217
218 // Create OTLP exporter using new() with endpoint
219 let exporter = opentelemetry_otlp::SpanExporter::builder()
220 .with_tonic()
221 .with_endpoint(&otel_config.endpoint)
222 .build()
223 .map_err(|e| Error::internal(format!("Failed to create OTLP exporter: {}", e)))?;
224
225 // Create tracer provider
226 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
227 .with_batch_exporter(exporter)
228 .with_id_generator(RandomIdGenerator::default())
229 .with_sampler(Sampler::AlwaysOn)
230 .with_resource(
231 Resource::builder()
232 .with_service_name(service_name.clone())
233 .build(),
234 )
235 .build();
236
237 // Set as global tracer provider
238 global::set_tracer_provider(provider.clone());
239
240 // Set up W3C trace context propagation (traceparent/tracestate headers)
241 global::set_text_map_propagator(
242 opentelemetry_sdk::propagation::TraceContextPropagator::new(),
243 );
244
245 // Create OpenTelemetry tracing layer to bridge tracing spans to OpenTelemetry
246 let tracer = provider.tracer(service_name);
247 let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
248
249 // Add the OpenTelemetry layer to the existing tracing subscriber
250 // This allows tracing spans to be exported as OpenTelemetry spans
251 let _ = tracing_subscriber::registry().with(otel_layer).try_init();
252
253 tracing::info!(
254 endpoint = %otel_config.endpoint,
255 "OpenTelemetry tracing initialized with context propagation"
256 );
257 }
258 Ok(self)
259 }
260}