faucet_core/observability/
otel.rs1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
12#[serde(rename_all = "lowercase")]
13pub enum OtelProtocol {
14 #[default]
17 Grpc,
18 Http,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "lowercase")]
25pub enum OtelSignal {
26 Traces,
27 Metrics,
28}
29
30fn default_protocol() -> OtelProtocol {
31 OtelProtocol::Grpc
32}
33fn default_sample_ratio() -> f64 {
34 1.0
35}
36fn default_export() -> Vec<OtelSignal> {
37 vec![OtelSignal::Traces, OtelSignal::Metrics]
38}
39fn default_service_name() -> String {
40 "faucet".to_string()
41}
42fn default_timeout_secs() -> u64 {
43 10
44}
45fn default_metric_interval_secs() -> u64 {
46 60
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct OtelConfig {
52 #[serde(default)]
55 pub endpoint: String,
56 #[serde(default = "default_protocol")]
57 pub protocol: OtelProtocol,
58 #[serde(default)]
60 pub headers: std::collections::HashMap<String, String>,
61 #[serde(default = "default_sample_ratio")]
63 pub sample_ratio: f64,
64 #[serde(default = "default_export")]
66 pub export: Vec<OtelSignal>,
67 #[serde(default = "default_service_name")]
69 pub service_name: String,
70 #[serde(default = "default_timeout_secs")]
72 pub timeout_secs: u64,
73 #[serde(default = "default_metric_interval_secs")]
75 pub metric_interval_secs: u64,
76}
77
78impl Default for OtelConfig {
79 fn default() -> Self {
80 Self {
81 endpoint: String::new(),
82 protocol: default_protocol(),
83 headers: std::collections::HashMap::new(),
84 sample_ratio: default_sample_ratio(),
85 export: default_export(),
86 service_name: default_service_name(),
87 timeout_secs: default_timeout_secs(),
88 metric_interval_secs: default_metric_interval_secs(),
89 }
90 }
91}
92
93impl OtelConfig {
94 pub fn exports(&self, signal: OtelSignal) -> bool {
96 self.export.contains(&signal)
97 }
98
99 pub fn resolve_endpoint(&self) -> String {
102 if !self.endpoint.is_empty() {
103 return self.endpoint.clone();
104 }
105 match self.protocol {
106 OtelProtocol::Grpc => "http://localhost:4317".to_string(),
107 OtelProtocol::Http => "http://localhost:4318".to_string(),
108 }
109 }
110
111 pub fn validate(&self) -> Result<(), String> {
114 if !(0.0..=1.0).contains(&self.sample_ratio) {
115 return Err(format!(
116 "otel.sample_ratio must be in 0.0..=1.0, got {}",
117 self.sample_ratio
118 ));
119 }
120 if self.timeout_secs == 0 {
121 return Err("otel.timeout_secs must be > 0".to_string());
122 }
123 if self.metric_interval_secs == 0 {
124 return Err("otel.metric_interval_secs must be > 0".to_string());
125 }
126 let ep = self.resolve_endpoint();
128 url::Url::parse(&ep)
129 .map_err(|e| format!("otel.endpoint is not a valid URL ({ep}): {e}"))?;
130 Ok(())
131 }
132}
133
134#[cfg_attr(not(feature = "otel"), allow(dead_code))]
141pub(crate) fn http_signal_endpoint(base: &str, signal_path: &str) -> String {
142 let trimmed = base.trim_end_matches('/');
143 if trimmed.ends_with(signal_path) {
144 trimmed.to_string()
145 } else {
146 format!("{trimmed}{signal_path}")
147 }
148}
149
150#[cfg_attr(not(feature = "otel"), allow(dead_code))]
156pub(crate) fn otel_signal_label(target: &str) -> &'static str {
157 if target.contains("metric") {
158 "metrics"
159 } else if target.contains("trace") || target.contains("span") {
160 "traces"
161 } else {
162 "export"
163 }
164}
165
166pub fn describe() {
170 metrics::describe_counter!(
171 "faucet_otel_export_failures_total",
172 "OTLP export attempts that failed (collector unreachable, serialization error, etc.)."
173 );
174}
175
176#[cfg(feature = "otel")]
177mod layer {
178 use super::otel_signal_label;
179 use tracing::{Event, Level, Subscriber};
180 use tracing_subscriber::layer::{Context, Layer};
181
182 pub struct OtelErrorCountLayer;
187
188 impl<S: Subscriber> Layer<S> for OtelErrorCountLayer {
189 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
190 let meta = event.metadata();
191 let target = meta.target();
192 if target.starts_with("opentelemetry")
193 && matches!(*meta.level(), Level::ERROR | Level::WARN)
194 {
195 metrics::counter!(
196 "faucet_otel_export_failures_total",
197 "signal" => otel_signal_label(target),
198 )
199 .increment(1);
200 }
201 }
202 }
203}
204
205#[cfg(feature = "otel")]
206pub use layer::OtelErrorCountLayer;
207
208#[cfg(feature = "otel")]
209mod sdk {
210 use super::{OtelConfig, OtelProtocol};
211 use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
212 use opentelemetry_sdk::Resource;
213 use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
214 use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
215 use std::sync::OnceLock;
216 use std::time::Duration;
217
218 pub type OtelError = Box<dyn std::error::Error + Send + Sync>;
219
220 fn resource(cfg: &OtelConfig) -> Resource {
221 Resource::builder()
222 .with_service_name(cfg.service_name.clone())
223 .build()
224 }
225
226 fn header_map(cfg: &OtelConfig) -> std::collections::HashMap<String, String> {
227 cfg.headers.clone()
228 }
229
230 pub fn build_trace_provider(cfg: &OtelConfig) -> Result<SdkTracerProvider, OtelError> {
233 let endpoint = cfg.resolve_endpoint();
234 let timeout = Duration::from_secs(cfg.timeout_secs);
235 let exporter = match cfg.protocol {
236 OtelProtocol::Grpc => opentelemetry_otlp::SpanExporter::builder()
237 .with_tonic()
238 .with_endpoint(&endpoint)
239 .with_timeout(timeout)
240 .build()?,
241 OtelProtocol::Http => opentelemetry_otlp::SpanExporter::builder()
242 .with_http()
243 .with_endpoint(super::http_signal_endpoint(&endpoint, "/v1/traces"))
244 .with_headers(header_map(cfg))
245 .with_timeout(timeout)
246 .build()?,
247 };
248 let provider = SdkTracerProvider::builder()
249 .with_batch_exporter(exporter)
250 .with_sampler(Sampler::TraceIdRatioBased(cfg.sample_ratio))
251 .with_resource(resource(cfg))
252 .build();
253 Ok(provider)
254 }
255
256 pub fn build_meter_provider(
259 cfg: &OtelConfig,
260 ) -> Result<(SdkMeterProvider, metrics_exporter_opentelemetry::Recorder), OtelError> {
261 let endpoint = cfg.resolve_endpoint();
262 let timeout = Duration::from_secs(cfg.timeout_secs);
263 let exporter = match cfg.protocol {
264 OtelProtocol::Grpc => opentelemetry_otlp::MetricExporter::builder()
265 .with_tonic()
266 .with_endpoint(&endpoint)
267 .with_timeout(timeout)
268 .build()?,
269 OtelProtocol::Http => opentelemetry_otlp::MetricExporter::builder()
270 .with_http()
271 .with_endpoint(super::http_signal_endpoint(&endpoint, "/v1/metrics"))
272 .with_headers(header_map(cfg))
273 .with_timeout(timeout)
274 .build()?,
275 };
276 let reader = PeriodicReader::builder(exporter)
277 .with_interval(Duration::from_secs(cfg.metric_interval_secs))
278 .build();
279 let res = resource(cfg);
280 let (provider, recorder) =
281 metrics_exporter_opentelemetry::Recorder::builder(cfg.service_name.clone())
282 .with_meter_provider(move |mb| mb.with_reader(reader).with_resource(res))
283 .build();
284 Ok((provider, recorder))
285 }
286
287 pub struct OtelGuard {
288 pub tracer: Option<SdkTracerProvider>,
289 pub meter: Option<SdkMeterProvider>,
290 }
291
292 static GUARD: OnceLock<OtelGuard> = OnceLock::new();
293
294 pub fn set_guard(guard: OtelGuard) -> bool {
296 GUARD.set(guard).is_ok()
297 }
298
299 pub fn shutdown_otel() {
301 if let Some(g) = GUARD.get() {
302 if let Some(t) = g.tracer.as_ref() {
303 let _ = t.force_flush();
304 let _ = t.shutdown();
305 }
306 if let Some(m) = g.meter.as_ref() {
307 let _ = m.force_flush();
308 let _ = m.shutdown();
309 }
310 }
311 }
312
313 pub fn install_propagator() {
315 use opentelemetry_sdk::propagation::TraceContextPropagator;
316 opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
317 }
318}
319
320#[cfg(feature = "otel")]
321pub use sdk::{
322 OtelError, OtelGuard, build_meter_provider, build_trace_provider, install_propagator,
323 set_guard, shutdown_otel,
324};
325
326#[cfg(not(feature = "otel"))]
329pub fn shutdown_otel() {}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[cfg(feature = "otel")]
336 #[test]
337 fn error_layer_increments_export_failures_counter() {
338 use crate::observability::decorator::source_tests::{LOCK, snapshotter};
344 use metrics_util::debugging::DebugValue;
345 use tracing_subscriber::layer::SubscriberExt;
346
347 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
348 let snap = snapshotter();
349
350 let subscriber = tracing_subscriber::registry().with(OtelErrorCountLayer);
351 tracing::subscriber::with_default(subscriber, || {
352 tracing::error!(
353 target: "opentelemetry_sdk::metrics::periodic_reader",
354 "simulated export failure"
355 );
356 });
357
358 let found = snap.snapshot().into_vec().into_iter().any(
359 |(key, _u, _d, v): (metrics_util::CompositeKey, _, _, _)| {
360 key.key().name() == "faucet_otel_export_failures_total"
361 && key
362 .key()
363 .labels()
364 .any(|l: &metrics::Label| l.key() == "signal" && l.value() == "metrics")
365 && matches!(v, DebugValue::Counter(c) if c >= 1)
366 },
367 );
368 assert!(
369 found,
370 "OtelErrorCountLayer must increment faucet_otel_export_failures_total{{signal=metrics}}"
371 );
372 }
373
374 #[cfg(feature = "otel")]
375 #[tokio::test]
376 async fn build_providers_construct_without_a_live_collector() {
377 let cfg = OtelConfig {
378 endpoint: "http://localhost:4317".into(),
379 protocol: OtelProtocol::Grpc,
380 ..Default::default()
381 };
382 let tp = build_trace_provider(&cfg).expect("trace provider builds");
383 let (mp, _recorder) = build_meter_provider(&cfg).expect("meter provider builds");
384 let _ = tp.force_flush();
385 let _ = mp.force_flush();
386 let _ = tp.shutdown();
387 let _ = mp.shutdown();
388 }
389
390 #[test]
391 fn http_signal_endpoint_appends_path_when_absent() {
392 assert_eq!(
393 http_signal_endpoint("http://c:4318", "/v1/traces"),
394 "http://c:4318/v1/traces"
395 );
396 assert_eq!(
397 http_signal_endpoint("http://c:4318/", "/v1/traces"),
398 "http://c:4318/v1/traces"
399 );
400 assert_eq!(
402 http_signal_endpoint("http://c:4318/v1/traces", "/v1/traces"),
403 "http://c:4318/v1/traces"
404 );
405 assert_eq!(
406 http_signal_endpoint("http://c:4318", "/v1/metrics"),
407 "http://c:4318/v1/metrics"
408 );
409 }
410
411 #[test]
412 fn record_otel_error_classifies_signal_by_target() {
413 assert_eq!(
414 otel_signal_label("opentelemetry_sdk::metrics::periodic_reader"),
415 "metrics"
416 );
417 assert_eq!(
418 otel_signal_label("opentelemetry_sdk::trace::span_processor"),
419 "traces"
420 );
421 assert_eq!(otel_signal_label("opentelemetry_otlp::exporter"), "export");
422 assert_eq!(otel_signal_label("opentelemetry"), "export");
423 }
424
425 #[test]
426 fn config_defaults_are_applied_from_empty_yaml() {
427 let cfg: OtelConfig = serde_json::from_str("{}").unwrap();
428 assert_eq!(cfg.protocol, OtelProtocol::Grpc);
429 assert_eq!(cfg.sample_ratio, 1.0);
430 assert_eq!(cfg.export, vec![OtelSignal::Traces, OtelSignal::Metrics]);
431 assert_eq!(cfg.service_name, "faucet");
432 assert_eq!(cfg.timeout_secs, 10);
433 assert_eq!(cfg.metric_interval_secs, 60);
434 assert!(cfg.exports(OtelSignal::Traces));
435 assert!(cfg.exports(OtelSignal::Metrics));
436 }
437
438 #[test]
439 fn resolve_endpoint_defaults_per_protocol() {
440 let mut cfg = OtelConfig::default();
441 assert_eq!(cfg.resolve_endpoint(), "http://localhost:4317");
442 cfg.protocol = OtelProtocol::Http;
443 assert_eq!(cfg.resolve_endpoint(), "http://localhost:4318");
444 cfg.endpoint = "http://collector:4317".into();
445 assert_eq!(cfg.resolve_endpoint(), "http://collector:4317");
446 }
447
448 #[test]
449 fn validate_rejects_bad_values() {
450 let mut cfg = OtelConfig::default();
451 assert!(cfg.validate().is_ok());
452
453 cfg.sample_ratio = 1.5;
454 assert!(cfg.validate().is_err());
455 cfg.sample_ratio = -0.1;
456 assert!(cfg.validate().is_err());
457 cfg.sample_ratio = 0.5;
458 assert!(cfg.validate().is_ok());
459
460 cfg.timeout_secs = 0;
461 assert!(cfg.validate().is_err());
462 cfg.timeout_secs = 10;
463
464 cfg.metric_interval_secs = 0;
465 assert!(cfg.validate().is_err());
466 cfg.metric_interval_secs = 60;
467
468 cfg.endpoint = "not a url".into();
469 assert!(cfg.validate().is_err());
470 }
471}