faucet_core/observability/
install.rs1use thiserror::Error;
6
7#[derive(Debug, Clone, Default)]
10pub struct ObservabilityConfig {
11 pub prometheus: Option<PrometheusConfig>,
12 pub tracing: Option<TracingConfig>,
13 pub otel: Option<crate::observability::otel::OtelConfig>,
14}
15
16#[cfg(feature = "observability-install")]
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) enum MetricsMode {
22 None,
23 PrometheusOnly,
24 OtelOnly,
25 Fanout,
26}
27
28#[cfg(feature = "observability-install")]
29impl MetricsMode {
30 pub(crate) fn select(prometheus: bool, otel_metrics: bool) -> Self {
31 match (prometheus, otel_metrics) {
32 (true, true) => MetricsMode::Fanout,
33 (true, false) => MetricsMode::PrometheusOnly,
34 (false, true) => MetricsMode::OtelOnly,
35 (false, false) => MetricsMode::None,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct PrometheusConfig {
42 pub listen: String,
45 pub buckets: Option<Vec<f64>>,
48}
49
50#[derive(Debug, Clone)]
51pub struct TracingConfig {
52 pub level: String,
54}
55
56#[derive(Debug, Clone, Default)]
59pub struct InstallReport {
60 pub prometheus_listen: Option<String>,
61 pub prometheus_already_installed: bool,
62 pub tracing_already_installed: bool,
63 pub otel_installed: bool,
64 pub otel_signals: Vec<&'static str>,
65}
66
67#[derive(Debug, Error)]
68pub enum InstallError {
69 #[error("failed to bind Prometheus listener at {listen}: {source}")]
70 PrometheusBind {
71 listen: String,
72 #[source]
73 source: std::io::Error,
74 },
75 #[error("failed to install Prometheus recorder: {0}")]
76 PrometheusInstall(String),
77}
78
79#[cfg(feature = "observability-install")]
92pub fn install_observability(cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
93 let mut report = InstallReport::default();
94
95 #[cfg(feature = "otel")]
98 let mut otel_tracer: Option<opentelemetry_sdk::trace::SdkTracerProvider> = None;
99
100 #[cfg(feature = "otel")]
102 let otel_metrics = cfg
103 .otel
104 .as_ref()
105 .map(|o| o.exports(crate::observability::otel::OtelSignal::Metrics))
106 .unwrap_or(false);
107 #[cfg(not(feature = "otel"))]
108 let otel_metrics = false;
109
110 let mode = MetricsMode::select(cfg.prometheus.is_some(), otel_metrics);
111
112 #[cfg(feature = "otel")]
114 let mut otel_meter: Option<opentelemetry_sdk::metrics::SdkMeterProvider> = None;
115
116 match mode {
117 MetricsMode::None => {}
118 MetricsMode::PrometheusOnly => {
119 install_prometheus_only(cfg.prometheus.as_ref().unwrap(), &mut report)?;
120 }
121 #[cfg(feature = "otel")]
122 MetricsMode::OtelOnly => {
123 if let Some(otel) = cfg.otel.as_ref() {
124 match crate::observability::otel::build_meter_provider(otel) {
125 Ok((mp, recorder)) => {
126 if metrics::set_global_recorder(recorder).is_err() {
127 tracing::warn!("metrics recorder already installed; continuing");
128 report.prometheus_already_installed = true;
129 } else {
130 otel_meter = Some(mp);
131 report.otel_signals.push("metrics");
132 }
133 }
134 Err(e) => tracing::warn!("OTLP metrics exporter init failed; skipping: {e}"),
135 }
136 }
137 }
138 #[cfg(feature = "otel")]
139 MetricsMode::Fanout => {
140 install_fanout(
141 cfg.prometheus.as_ref().unwrap(),
142 cfg.otel.as_ref().unwrap(),
143 &mut report,
144 &mut otel_meter,
145 )?;
146 }
147 #[cfg(not(feature = "otel"))]
148 MetricsMode::OtelOnly | MetricsMode::Fanout => {
149 unreachable!("otel metrics mode selected without the otel feature")
150 }
151 }
152
153 if let Some(t) = cfg.tracing.as_ref() {
155 use tracing_subscriber::EnvFilter;
156 use tracing_subscriber::layer::SubscriberExt;
157 use tracing_subscriber::util::SubscriberInitExt;
158
159 let make_filter =
160 || EnvFilter::try_new(&t.level).unwrap_or_else(|_| EnvFilter::new("info"));
161
162 #[cfg_attr(not(feature = "otel"), allow(unused_mut))]
165 let mut installed = false;
166
167 #[cfg(feature = "otel")]
168 {
169 let otel_traces = cfg
170 .otel
171 .as_ref()
172 .map(|o| o.exports(crate::observability::otel::OtelSignal::Traces))
173 .unwrap_or(false);
174 if let (true, Some(otel)) = (otel_traces, cfg.otel.as_ref()) {
175 match crate::observability::otel::build_trace_provider(otel) {
176 Ok(tp) => {
177 use opentelemetry::trace::TracerProvider as _;
178 let tracer = tp.tracer("faucet");
179 crate::observability::otel::install_propagator();
180 let reg = tracing_subscriber::registry()
181 .with(make_filter())
182 .with(tracing_subscriber::fmt::layer())
183 .with(tracing_opentelemetry::layer().with_tracer(tracer))
184 .with(crate::observability::otel::OtelErrorCountLayer);
185 if reg.try_init().is_err() {
186 tracing::warn!("tracing subscriber already installed; continuing");
187 report.tracing_already_installed = true;
188 } else {
191 report.otel_signals.push("traces");
192 otel_tracer = Some(tp);
193 }
194 installed = true;
195 }
196 Err(e) => tracing::warn!("OTLP trace exporter init failed; logs-only: {e}"),
197 }
198 }
199 }
200
201 if !installed {
202 let reg = tracing_subscriber::registry()
203 .with(make_filter())
204 .with(tracing_subscriber::fmt::layer());
205 if reg.try_init().is_err() {
206 tracing::warn!("tracing subscriber already installed; continuing");
210 report.tracing_already_installed = true;
211 }
212 }
213 }
214
215 #[cfg(feature = "otel")]
217 {
218 if otel_tracer.is_some() || otel_meter.is_some() {
219 crate::observability::otel::describe();
220 let _ = crate::observability::otel::set_guard(crate::observability::otel::OtelGuard {
221 tracer: otel_tracer,
222 meter: otel_meter,
223 });
224 report.otel_installed = true;
225 }
226 }
227
228 crate::observability::resilience::describe();
232 crate::observability::drift::describe();
233 register_build_info();
234
235 Ok(report)
236}
237
238#[cfg(feature = "observability-install")]
242fn install_prometheus_only(
243 p: &PrometheusConfig,
244 report: &mut InstallReport,
245) -> Result<(), InstallError> {
246 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
247
248 let listen: std::net::SocketAddr =
249 p.listen
250 .parse()
251 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
252 listen: p.listen.clone(),
253 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
254 })?;
255
256 const DEFAULT_BUCKETS: &[f64] = &[
257 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
258 ];
259 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
260
261 let builder = PrometheusBuilder::new()
262 .with_http_listener(listen)
263 .set_buckets(buckets)
264 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?;
265
266 match builder.install() {
267 Ok(()) => report.prometheus_listen = Some(p.listen.clone()),
268 Err(e) => match e {
272 BuildError::FailedToSetGlobalRecorder(_) => {
275 tracing::warn!("Prometheus recorder already installed; continuing");
276 report.prometheus_already_installed = true;
277 }
278 BuildError::FailedToCreateHTTPListener(msg) => {
284 return Err(InstallError::PrometheusBind {
285 listen: p.listen.clone(),
286 source: std::io::Error::other(msg),
287 });
288 }
289 other => return Err(InstallError::PrometheusInstall(other.to_string())),
290 },
291 }
292 Ok(())
293}
294
295#[cfg(all(feature = "observability-install", feature = "otel"))]
301fn install_fanout(
302 p: &PrometheusConfig,
303 otel: &crate::observability::otel::OtelConfig,
304 report: &mut InstallReport,
305 otel_meter: &mut Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
306) -> Result<(), InstallError> {
307 use metrics_exporter_prometheus::{BuildError, PrometheusBuilder};
308 use metrics_util::layers::FanoutBuilder;
309
310 let listen: std::net::SocketAddr =
311 p.listen
312 .parse()
313 .map_err(|e: std::net::AddrParseError| InstallError::PrometheusBind {
314 listen: p.listen.clone(),
315 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()),
316 })?;
317 const DEFAULT_BUCKETS: &[f64] = &[
318 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
319 ];
320 let buckets = p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS);
321
322 let (prom_recorder, prom_exporter) = PrometheusBuilder::new()
325 .with_http_listener(listen)
326 .set_buckets(buckets)
327 .map_err(|e| InstallError::PrometheusInstall(e.to_string()))?
328 .build()
329 .map_err(|e| match e {
330 BuildError::FailedToCreateHTTPListener(msg) => InstallError::PrometheusBind {
331 listen: p.listen.clone(),
332 source: std::io::Error::other(msg),
333 },
334 other => InstallError::PrometheusInstall(other.to_string()),
335 })?;
336
337 match crate::observability::otel::build_meter_provider(otel) {
338 Ok((mp, otel_recorder)) => {
339 let fanout = FanoutBuilder::default()
340 .add_recorder(prom_recorder)
341 .add_recorder(otel_recorder)
342 .build();
343 if metrics::set_global_recorder(fanout).is_err() {
344 tracing::warn!("metrics recorder already installed; continuing");
345 report.prometheus_already_installed = true;
346 } else {
347 report.prometheus_listen = Some(p.listen.clone());
348 report.otel_signals.push("metrics");
349 *otel_meter = Some(mp);
350 tokio::spawn(prom_exporter);
351 }
352 }
353 Err(e) => {
354 tracing::warn!("OTLP metrics exporter init failed; Prometheus-only: {e}");
357 if metrics::set_global_recorder(prom_recorder).is_err() {
358 report.prometheus_already_installed = true;
359 } else {
360 report.prometheus_listen = Some(p.listen.clone());
361 tokio::spawn(prom_exporter);
362 }
363 }
364 }
365 Ok(())
366}
367
368#[cfg(not(feature = "observability-install"))]
370pub fn install_observability(_cfg: &ObservabilityConfig) -> Result<InstallReport, InstallError> {
371 crate::observability::resilience::describe();
372 crate::observability::drift::describe();
373 crate::observability::otel::describe();
374 register_build_info();
375 Ok(InstallReport::default())
376}
377
378pub fn register_build_info() {
388 metrics::gauge!(
389 "faucet_build_info",
390 "version" => env!("CARGO_PKG_VERSION"),
391 )
392 .set(1.0);
393}
394
395#[cfg(all(test, feature = "observability-install"))]
396mod tests {
397 use super::*;
398 use std::sync::Mutex;
399
400 static LOCK: Mutex<()> = Mutex::new(());
401
402 #[test]
403 fn metrics_mode_selection() {
404 use super::MetricsMode;
405 assert_eq!(MetricsMode::select(true, true), MetricsMode::Fanout);
406 assert_eq!(
407 MetricsMode::select(true, false),
408 MetricsMode::PrometheusOnly
409 );
410 assert_eq!(MetricsMode::select(false, true), MetricsMode::OtelOnly);
411 assert_eq!(MetricsMode::select(false, false), MetricsMode::None);
412 }
413
414 #[test]
415 fn no_config_returns_empty_report() {
416 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
417 let r = install_observability(&ObservabilityConfig::default()).unwrap();
418 assert!(r.prometheus_listen.is_none());
419 assert!(!r.prometheus_already_installed);
420 assert!(!r.tracing_already_installed);
421 }
422
423 #[test]
424 fn malformed_listen_returns_bind_error() {
425 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
426 let cfg = ObservabilityConfig {
427 prometheus: Some(PrometheusConfig {
428 listen: "not-a-socket".into(),
429 buckets: None,
430 }),
431 tracing: None,
432 otel: None,
433 };
434 match install_observability(&cfg) {
435 Err(InstallError::PrometheusBind { .. }) => {}
436 other => panic!("expected PrometheusBind error, got {other:?}"),
437 }
438 }
439
440 #[test]
441 fn register_build_info_is_callable_and_idempotent() {
442 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
443 register_build_info();
446 register_build_info();
447 }
448
449 #[test]
450 fn install_prometheus_and_tracing_returns_ok() {
451 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
457 let cfg = ObservabilityConfig {
458 prometheus: Some(PrometheusConfig {
459 listen: "127.0.0.1:0".into(),
460 buckets: Some(vec![0.01, 0.1, 1.0]),
462 }),
463 tracing: Some(TracingConfig {
464 level: "info".into(),
465 }),
466 otel: None,
467 };
468 let report = install_observability(&cfg).expect("install must return Ok");
469 assert!(
471 report.prometheus_listen.is_some() || report.prometheus_already_installed,
472 "prometheus install must either bind or report already-installed"
473 );
474 }
475
476 #[test]
477 fn install_tracing_with_invalid_directive_falls_back_to_info() {
478 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
481 let cfg = ObservabilityConfig {
482 prometheus: None,
483 tracing: Some(TracingConfig {
484 level: "this is !!! not a valid filter".into(),
486 }),
487 otel: None,
488 };
489 install_observability(&cfg).expect("invalid tracing directive must not fail install");
490 }
491}