Skip to main content

sova_observability/
lib.rs

1//! HTTP RED metrics (Prometheus) + optional OpenTelemetry / Elasticsearch logs for Sova.
2//!
3//! ```ignore
4//! app.use_middleware(request_id());
5//! app.install(
6//!     Observability::new()
7//!         .with_elasticsearch() // ELASTICSEARCH_URL → bulk ship tracing logs
8//! );
9//! app.use_middleware(logger());
10//! ```
11//!
12//! Declarative toggles also work via `[observability]` in `sova.toml`
13//! (`metrics_path`, `otel`, `elasticsearch`) when not set on the builder.
14
15#[cfg(feature = "prometheus")]
16mod prometheus;
17#[cfg(feature = "otel")]
18mod otel;
19#[cfg(feature = "elasticsearch")]
20mod elasticsearch;
21
22#[cfg(feature = "elasticsearch")]
23pub use elasticsearch::ElasticsearchLog;
24
25use sova_core::extend::{named, MwEntry};
26use sova_core::{with_state, App, MatchedRouteCapture, Plugin, Request, Response};
27use std::time::Instant;
28
29/// Observability plugin: Prometheus scrape + HTTP RED middleware (+ optional sinks).
30pub struct Observability {
31    metrics_path: String,
32    metrics_path_explicit: bool,
33    #[cfg(feature = "prometheus")]
34    handle: Option<prometheus::Handle>,
35    #[cfg(feature = "otel")]
36    otel: bool,
37    #[cfg(feature = "otel")]
38    otel_explicit: bool,
39    #[cfg(feature = "elasticsearch")]
40    elasticsearch: bool,
41    #[cfg(feature = "elasticsearch")]
42    elasticsearch_explicit: bool,
43}
44
45impl Observability {
46    /// Prometheus `/metrics` + request middleware (feature `prometheus`).
47    pub fn new() -> Self {
48        Self {
49            metrics_path: "/metrics".into(),
50            metrics_path_explicit: false,
51            #[cfg(feature = "prometheus")]
52            handle: Some(prometheus::install_recorder()),
53            #[cfg(feature = "otel")]
54            otel: false,
55            #[cfg(feature = "otel")]
56            otel_explicit: false,
57            #[cfg(feature = "elasticsearch")]
58            elasticsearch: false,
59            #[cfg(feature = "elasticsearch")]
60            elasticsearch_explicit: false,
61        }
62    }
63
64    pub fn metrics_path(mut self, path: impl Into<String>) -> Self {
65        self.metrics_path = path.into();
66        self.metrics_path_explicit = true;
67        self
68    }
69
70    /// Install OTLP tracing from env (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`).
71    #[cfg(feature = "otel")]
72    pub fn with_otel(mut self) -> Self {
73        self.otel = true;
74        self.otel_explicit = true;
75        self
76    }
77
78    /// Ship tracing logs to Elasticsearch (`ELASTICSEARCH_URL`, see [`ElasticsearchLog`]).
79    #[cfg(feature = "elasticsearch")]
80    pub fn with_elasticsearch(mut self) -> Self {
81        self.elasticsearch = true;
82        self.elasticsearch_explicit = true;
83        self
84    }
85
86    /// Alias for [`Self::new`].
87    pub fn prometheus() -> Self {
88        Self::new()
89    }
90
91    fn apply_config(&mut self, app: &App) {
92        let Some(doc) = app.config_doc() else {
93            return;
94        };
95        let Some(section) = doc.section("observability") else {
96            return;
97        };
98        if !self.metrics_path_explicit {
99            if let Some(p) = section.get("metrics_path").and_then(|v| v.as_str()) {
100                self.metrics_path = p.to_string();
101            }
102        }
103        #[cfg(feature = "otel")]
104        if !self.otel_explicit {
105            if let Some(v) = section.get("otel").and_then(|v| v.as_bool()) {
106                self.otel = v;
107            }
108        }
109        #[cfg(feature = "elasticsearch")]
110        if !self.elasticsearch_explicit {
111            if let Some(v) = section.get("elasticsearch").and_then(|v| v.as_bool()) {
112                self.elasticsearch = v;
113            }
114        }
115    }
116}
117
118impl Default for Observability {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl Plugin for Observability {
125    fn id(&self) -> &'static str {
126        "observability"
127    }
128
129    fn meta(&self) -> sova_core::PluginMeta {
130        sova_core::PluginMeta::new("Observability")
131            .description("HTTP metrics, OpenTelemetry, Elasticsearch log shipping")
132            .version(env!("CARGO_PKG_VERSION"))
133    }
134
135    fn install(mut self, app: &mut App) {
136        self.apply_config(app);
137
138        #[cfg(feature = "otel")]
139        if self.otel {
140            match otel::install_from_env() {
141                Ok(()) => tracing::info!("otel OTLP exporter installed"),
142                Err(e) => tracing::warn!(error = %e, "otel install skipped"),
143            }
144        }
145
146        #[cfg(feature = "elasticsearch")]
147        if self.elasticsearch {
148            match elasticsearch::install_from_env() {
149                Ok(()) => tracing::info!("elasticsearch log sink installed"),
150                Err(e) => tracing::warn!(error = %e, "elasticsearch log sink skipped"),
151            }
152        }
153
154        #[cfg(feature = "prometheus")]
155        if let Some(handle) = self.handle.clone() {
156            let path = self.metrics_path.clone();
157            let handle_route = handle.clone();
158            let mount = path.clone();
159            app.get(&mount, move |_req: Request| {
160                let handle = handle_route.clone();
161                async move {
162                    handle.run_upkeep();
163                    let body = handle.render();
164                    Ok::<_, sova_core::Error>(
165                        Response::text(body).header("content-type", "text/plain; version=0.0.4"),
166                    )
167                }
168            });
169
170            app.use_middleware(metrics_middleware(path));
171        }
172    }
173}
174
175fn is_probe(path: &str, metrics_path: &str) -> bool {
176    path == metrics_path || path == "/healthz" || path == "/ready"
177}
178
179#[cfg(feature = "prometheus")]
180fn metrics_middleware(metrics_path: String) -> MwEntry {
181    named(
182        "observability-metrics",
183        with_state(metrics_path, |metrics_path, mut req, next| async move {
184            let capture = MatchedRouteCapture::new();
185            req.set(capture.clone());
186            let method = req.method.as_str().to_string();
187            let path = req.path.clone();
188            let probe = is_probe(&path, metrics_path.as_str());
189            let start = Instant::now();
190            if !probe {
191                metrics::gauge!("http_requests_in_flight").increment(1.0);
192            }
193            let res = next(req).await;
194            if !probe {
195                metrics::gauge!("http_requests_in_flight").decrement(1.0);
196                let status = res.status_code().as_u16();
197                let route = capture
198                    .get()
199                    .map(|r| r.to_string())
200                    .unwrap_or_else(|| path.clone());
201                let elapsed = start.elapsed().as_secs_f64();
202                metrics::counter!(
203                    "http_requests_total",
204                    "method" => method.clone(),
205                    "route" => route.clone(),
206                    "status" => status.to_string()
207                )
208                .increment(1);
209                metrics::histogram!(
210                    "http_request_duration_seconds",
211                    "method" => method,
212                    "route" => route
213                )
214                .record(elapsed);
215            }
216            res
217        }),
218    )
219}