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}