use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryConfig {
pub enabled: bool,
pub otlp_endpoint: String,
pub service_name: String,
pub sample_rate: f64,
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
enabled: false,
otlp_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_string()),
service_name: "argentor".to_string(),
sample_rate: 1.0,
}
}
}
impl TelemetryConfig {
pub fn enabled(endpoint: impl Into<String>, service_name: impl Into<String>) -> Self {
Self {
enabled: true,
otlp_endpoint: endpoint.into(),
service_name: service_name.into(),
sample_rate: 1.0,
}
}
pub fn disabled() -> Self {
Self::default()
}
pub fn with_sample_rate(mut self, rate: f64) -> Self {
self.sample_rate = rate.clamp(0.0, 1.0);
self
}
}
#[cfg(feature = "telemetry")]
static TRACER_PROVIDER: std::sync::OnceLock<opentelemetry_sdk::trace::SdkTracerProvider> =
std::sync::OnceLock::new();
#[cfg(feature = "telemetry")]
pub fn init_telemetry(config: &TelemetryConfig) -> Result<(), Box<dyn std::error::Error>> {
use opentelemetry::trace::TracerProvider as _;
use opentelemetry::KeyValue;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
use opentelemetry_sdk::Resource;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if !config.enabled {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer())
.try_init()
.ok();
tracing::info!("Telemetry: OTLP export disabled, using local tracing only");
return Ok(());
}
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&config.otlp_endpoint)
.build()?;
let sampler = if (config.sample_rate - 1.0).abs() < f64::EPSILON {
Sampler::AlwaysOn
} else if config.sample_rate <= 0.0 {
Sampler::AlwaysOff
} else {
Sampler::TraceIdRatioBased(config.sample_rate)
};
let resource = Resource::builder()
.with_attributes([KeyValue::new("service.name", config.service_name.clone())])
.build();
let provider = SdkTracerProvider::builder()
.with_sampler(sampler)
.with_resource(resource)
.with_batch_exporter(exporter)
.build();
let tracer = provider.tracer(config.service_name.clone());
let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer())
.with(otel_layer)
.try_init()
.ok();
let _ = TRACER_PROVIDER.set(provider);
tracing::info!(
endpoint = %config.otlp_endpoint,
service = %config.service_name,
sample_rate = config.sample_rate,
"Telemetry: OTLP export initialized"
);
Ok(())
}
#[cfg(not(feature = "telemetry"))]
pub fn init_telemetry(config: &TelemetryConfig) -> Result<(), Box<dyn std::error::Error>> {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if config.enabled {
tracing::warn!(
"TelemetryConfig has enabled=true but the `telemetry` feature is not compiled in. \
OTLP export will not be available. Enable the feature: \
argentor-core = {{ features = [\"telemetry\"] }}"
);
}
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer())
.try_init()
.ok();
tracing::info!("Telemetry: using local tracing only (telemetry feature disabled)");
Ok(())
}
#[cfg(feature = "telemetry")]
pub fn shutdown_telemetry() {
tracing::info!("Telemetry: shutting down OpenTelemetry pipeline");
if let Some(provider) = TRACER_PROVIDER.get() {
if let Err(e) = provider.shutdown() {
tracing::warn!("Telemetry: shutdown error: {e:?}");
}
}
}
#[cfg(not(feature = "telemetry"))]
pub fn shutdown_telemetry() {
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_config_defaults() {
let config = TelemetryConfig::default();
assert!(!config.enabled);
assert_eq!(config.otlp_endpoint, "http://localhost:4317");
assert_eq!(config.service_name, "argentor");
assert!((config.sample_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_config_enabled_constructor() {
let config = TelemetryConfig::enabled("http://otel:4317", "my-service");
assert!(config.enabled);
assert_eq!(config.otlp_endpoint, "http://otel:4317");
assert_eq!(config.service_name, "my-service");
assert!((config.sample_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_config_disabled_constructor() {
let config = TelemetryConfig::disabled();
assert!(!config.enabled);
assert_eq!(config.service_name, "argentor");
}
#[test]
fn test_config_sample_rate_clamping() {
let config = TelemetryConfig::default().with_sample_rate(2.5);
assert!((config.sample_rate - 1.0).abs() < f64::EPSILON);
let config = TelemetryConfig::default().with_sample_rate(-0.5);
assert!(config.sample_rate.abs() < f64::EPSILON);
let config = TelemetryConfig::default().with_sample_rate(0.5);
assert!((config.sample_rate - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_config_serialization_roundtrip() {
let config =
TelemetryConfig::enabled("http://collector:4317", "test-svc").with_sample_rate(0.25);
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"enabled\":true"));
assert!(json.contains("http://collector:4317"));
assert!(json.contains("test-svc"));
let parsed: TelemetryConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.otlp_endpoint, "http://collector:4317");
assert_eq!(parsed.service_name, "test-svc");
assert!((parsed.sample_rate - 0.25).abs() < f64::EPSILON);
}
#[test]
fn test_config_deserialization_from_partial_json() {
let json = r#"{"enabled": true, "otlp_endpoint": "http://localhost:4317", "service_name": "x", "sample_rate": 0.1}"#;
let config: TelemetryConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert_eq!(config.service_name, "x");
assert!((config.sample_rate - 0.1).abs() < f64::EPSILON);
}
#[test]
fn test_init_disabled_does_not_panic() {
let config = TelemetryConfig::disabled();
let result = init_telemetry(&config);
assert!(result.is_ok());
}
#[test]
fn test_shutdown_does_not_panic() {
shutdown_telemetry();
}
#[test]
fn test_config_debug_repr() {
let config =
TelemetryConfig::enabled("http://otel:4317", "debug-test").with_sample_rate(0.75);
let debug = format!("{config:?}");
assert!(debug.contains("debug-test"));
assert!(debug.contains("http://otel:4317"));
assert!(debug.contains("0.75"));
assert!(debug.contains("enabled: true"));
}
#[cfg(feature = "telemetry")]
#[tokio::test]
async fn test_telemetry_feature_init_and_shutdown() {
let config = TelemetryConfig::enabled("http://127.0.0.1:4317", "telemetry-test-feature")
.with_sample_rate(1.0);
let result = init_telemetry(&config);
assert!(result.is_ok(), "init_telemetry failed: {result:?}");
shutdown_telemetry();
}
}