sova_observability/
lib.rs1#[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
29pub 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 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 #[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 #[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 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}