use std::fmt;
#[cfg(feature = "otel")]
use opentelemetry::global;
#[cfg(feature = "otel")]
use opentelemetry::metrics::MeterProvider as _;
#[cfg(feature = "otel")]
use opentelemetry::trace::TracerProvider as _;
#[cfg(feature = "otel")]
use opentelemetry_otlp::MetricExporter;
#[cfg(feature = "otel")]
use opentelemetry_otlp::WithExportConfig;
#[cfg(feature = "otel")]
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
#[cfg(feature = "otel")]
use opentelemetry_sdk::runtime::Tokio;
#[cfg(feature = "otel")]
use opentelemetry_sdk::{Resource, trace::TracerProvider};
#[cfg(feature = "otel")]
use tracing_subscriber::prelude::*;
#[cfg(feature = "otel")]
use crate::metrics::MetricsRegistry;
#[derive(Debug)]
pub enum TracingError {
InstallFailed(String),
}
impl fmt::Display for TracingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InstallFailed(msg) => write!(f, "Failed to install tracing subscriber: {msg}"),
}
}
}
impl std::error::Error for TracingError {}
#[cfg(feature = "otel")]
pub type TracingInstallResult = Result<Option<MetricsRegistry>, TracingError>;
#[cfg(feature = "otel")]
#[derive(Clone, Debug)]
pub struct TracingConfig {
otlp_endpoint: Option<String>,
service_name: String,
service_version: String,
resource_attributes: Vec<(String, String)>,
trace_sampling: f64,
metrics_enabled: bool,
log_level: tracing::Level,
}
#[cfg(feature = "otel")]
impl Default for TracingConfig {
fn default() -> Self {
Self {
otlp_endpoint: None,
service_name: "juncture-app".to_string(),
service_version: env!("CARGO_PKG_VERSION").to_string(),
resource_attributes: Vec::new(),
trace_sampling: 1.0,
metrics_enabled: false,
log_level: tracing::Level::INFO,
}
}
}
#[cfg(feature = "otel")]
impl TracingConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.otlp_endpoint = Some(endpoint.into());
self
}
#[must_use]
pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
self.service_name = name.into();
self
}
#[must_use]
pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
self.service_version = version.into();
self
}
#[must_use]
pub fn with_resource_attributes(mut self, attrs: impl Into<Vec<(String, String)>>) -> Self {
self.resource_attributes = attrs.into();
self
}
#[must_use]
pub const fn with_trace_sampling(mut self, rate: f64) -> Self {
self.trace_sampling = rate;
self
}
#[must_use]
pub const fn with_metrics(mut self, enabled: bool) -> Self {
self.metrics_enabled = enabled;
self
}
#[must_use]
pub const fn with_log_level(mut self, level: tracing::Level) -> Self {
self.log_level = level;
self
}
pub fn install(self) -> Result<Option<MetricsRegistry>, TracingError> {
let env_filter = tracing_subscriber::EnvFilter::builder()
.with_default_directive(self.log_level.into())
.from_env_lossy();
#[cfg(feature = "otel")]
{
let otlp_endpoint = self.otlp_endpoint.clone();
if let Some(endpoint) = otlp_endpoint.as_ref() {
return self.install_otel(env_filter, endpoint);
}
if self.metrics_enabled {
tracing::warn!(
"Metrics export is enabled but no OTLP endpoint is configured. \
Call .with_otlp_endpoint() to enable OTLP trace and metrics export."
);
}
}
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.try_init()
.map_err(|e| TracingError::InstallFailed(e.to_string()))?;
Ok(None)
}
#[cfg(feature = "otel")]
#[allow(
clippy::too_many_lines,
reason = "OTLP setup requires: resource config, OTLP exporters (trace + metrics), tracer provider, meter provider, layers, subscriber initialization"
)]
fn install_otel(
self,
env_filter: tracing_subscriber::EnvFilter,
otlp_endpoint: &str,
) -> Result<Option<MetricsRegistry>, TracingError> {
let mut resource_attributes = vec![
opentelemetry::KeyValue::new("service.name", self.service_name),
opentelemetry::KeyValue::new("service.version", self.service_version),
];
for (key, value) in self.resource_attributes {
resource_attributes.push(opentelemetry::KeyValue::new(key, value));
}
let resource = Resource::new(resource_attributes);
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(otlp_endpoint)
.build()
.map_err(|e| {
TracingError::InstallFailed(format!("OTLP trace exporter build failed: {e}"))
})?;
let tracer_provider = TracerProvider::builder()
.with_resource(resource.clone())
.with_simple_exporter(exporter)
.build();
let otel_layer =
tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("juncture"));
let fmt_layer = tracing_subscriber::fmt::layer().with_filter(env_filter);
tracing_subscriber::registry()
.with(otel_layer)
.with(fmt_layer)
.try_init()
.map_err(|e| TracingError::InstallFailed(format!("Subscriber init failed: {e}")))?;
if self.metrics_enabled {
let metric_exporter = MetricExporter::builder()
.with_http()
.with_endpoint(otlp_endpoint)
.build()
.map_err(|e| {
TracingError::InstallFailed(format!("OTLP metric exporter build failed: {e}"))
})?;
let reader = PeriodicReader::builder(metric_exporter, Tokio)
.with_interval(std::time::Duration::from_secs(5))
.build();
let meter_provider = SdkMeterProvider::builder()
.with_resource(resource)
.with_reader(reader)
.build();
let meter = meter_provider.meter("juncture");
global::set_meter_provider(meter_provider);
let registry = MetricsRegistry::with_meter(meter);
Ok(Some(registry))
} else {
Ok(None)
}
}
}
#[cfg(feature = "otel")]
#[must_use]
pub fn init() -> TracingConfig {
TracingConfig::new()
}
#[cfg(all(test, feature = "otel"))]
mod tests {
use super::*;
#[test]
fn test_tracing_config_default() {
let config = TracingConfig::new();
assert_eq!(config.service_name, "juncture-app");
assert!(!config.metrics_enabled);
assert!((config.trace_sampling - 1.0).abs() < f64::EPSILON);
assert_eq!(config.log_level, tracing::Level::INFO);
}
#[test]
fn test_tracing_config_builder() {
let config = TracingConfig::new()
.with_otlp_endpoint("http://localhost:4317")
.with_service_name("test-service")
.with_service_version("2.0.0")
.with_resource_attributes(vec![("key".to_string(), "value".to_string())])
.with_trace_sampling(0.5)
.with_metrics(true)
.with_log_level(tracing::Level::DEBUG);
assert_eq!(
config.otlp_endpoint,
Some("http://localhost:4317".to_string())
);
assert_eq!(config.service_name, "test-service");
assert_eq!(config.service_version, "2.0.0");
assert_eq!(config.resource_attributes.len(), 1);
assert!((config.trace_sampling - 0.5).abs() < f64::EPSILON);
assert!(config.metrics_enabled);
assert_eq!(config.log_level, tracing::Level::DEBUG);
}
#[test]
fn test_init_function() {
let config = init();
assert_eq!(config.service_name, "juncture-app");
}
#[test]
fn test_tracing_error_display() {
let err = TracingError::InstallFailed("test error".to_string());
let display_str = format!("{err}");
assert!(display_str.contains("Failed to install"));
assert!(display_str.contains("test error"));
}
#[test]
fn test_install_no_panic() {
let config = TracingConfig::new();
let _result = std::panic::catch_unwind(|| {
let _ = config.install();
});
}
#[test]
fn test_install_returns_option_registry() {
#[allow(
clippy::type_complexity,
reason = "complex type is needed for compile-time type checking of the install() return signature"
)]
let _check: std::sync::Arc<
std::sync::Mutex<dyn Fn() -> Result<Option<MetricsRegistry>, TracingError>>,
> = std::sync::Arc::new(std::sync::Mutex::new(|| Ok(None)));
}
#[test]
fn test_metrics_flag_is_properly_set() {
let config = TracingConfig::new().with_metrics(true);
assert!(config.metrics_enabled);
let config = TracingConfig::new().with_metrics(false);
assert!(!config.metrics_enabled);
}
#[test]
fn test_metrics_flag_false_by_default() {
let config = TracingConfig::new();
assert!(!config.metrics_enabled);
}
#[tokio::test]
async fn test_install_with_metrics_does_not_panic() {
let config = TracingConfig::new()
.with_service_name("test-metrics-install")
.with_otlp_endpoint("http://127.0.0.1:4318")
.with_metrics(true);
let _result = std::panic::catch_unwind(|| {
let _ = config.clone().install();
});
let _type_check: Result<Option<MetricsRegistry>, _> = config.install();
}
}