use std::sync::Once;
use crate::{config::Config, error::Result};
static TRACING_INIT: Once = Once::new();
#[cfg(feature = "observability")]
use {
opentelemetry::{global, trace::TracerProvider},
opentelemetry_otlp::{SpanExporter, WithExportConfig},
opentelemetry_sdk::{propagation::TraceContextPropagator, trace::SdkTracerProvider, Resource},
tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer},
};
#[cfg(feature = "otel-metrics")]
use {
opentelemetry::metrics::MeterProvider as _,
opentelemetry_otlp::MetricExporter,
opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider},
std::time::Duration as StdDuration,
};
#[cfg(feature = "observability")]
static TRACER_PROVIDER: once_cell::sync::OnceCell<SdkTracerProvider> =
once_cell::sync::OnceCell::new();
#[cfg(feature = "otel-metrics")]
pub static METER_PROVIDER: once_cell::sync::OnceCell<SdkMeterProvider> =
once_cell::sync::OnceCell::new();
#[cfg(feature = "observability")]
pub fn init_tracing<T>(config: &Config<T>) -> Result<()>
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
if TRACING_INIT.is_completed() {
return Ok(());
}
let log_level = config.service.log_level.clone();
let service_name = config.service.name.clone();
let otlp_config = config.otlp.clone();
#[cfg(feature = "journald")]
let journald_config = config.journald.clone();
TRACING_INIT.call_once(|| {
global::set_text_map_propagator(TraceContextPropagator::new());
#[cfg(feature = "journald")]
let suppress_fmt = journald_config
.as_ref()
.is_some_and(|j| j.enabled && j.disable_fmt_layer);
#[cfg(not(feature = "journald"))]
let suppress_fmt = false;
let fmt_layer = if suppress_fmt {
None
} else {
Some(
tracing_subscriber::fmt::layer().json().with_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(&log_level))
.unwrap_or_else(|_| EnvFilter::new("info")),
),
)
};
let mut tracer_provider_to_set: Option<SdkTracerProvider> = None;
let telemetry_layer = otlp_config.as_ref().filter(|c| c.enabled).and_then(|otlp| {
match init_otlp_tracer(otlp, &service_name) {
Ok(provider) => {
let tracer = provider.tracer(service_name.clone());
tracer_provider_to_set = Some(provider);
Some(tracing_opentelemetry::layer().with_tracer(tracer))
}
Err(e) => {
eprintln!(
"Failed to initialize OTLP exporter (falling back to JSON logging): {}",
e
);
None
}
}
});
#[cfg(feature = "journald")]
let journald_layer = journald_config
.as_ref()
.filter(|j| j.enabled)
.and_then(|j| init_journald_layer(j, &service_name));
let registry = tracing_subscriber::registry()
.with(fmt_layer)
.with(telemetry_layer);
#[cfg(feature = "journald")]
let registry = registry.with(journald_layer);
registry.init();
if let Some(provider) = tracer_provider_to_set {
let _ = TRACER_PROVIDER.set(provider.clone());
global::set_tracer_provider(provider);
}
tracing::info!(
service = %service_name,
"Tracing initialized"
);
});
Ok(())
}
#[cfg(feature = "observability")]
pub(crate) fn init_otlp_tracer(
otlp_config: &crate::config::OtlpConfig,
service_name: &str,
) -> Result<SdkTracerProvider> {
let trace_service_name = otlp_config
.service_name
.as_ref()
.unwrap_or(&service_name.to_string())
.clone();
let resource = Resource::builder()
.with_service_name(trace_service_name)
.build();
let mut exporter_builder = SpanExporter::builder().with_tonic();
if !otlp_config.endpoint.is_empty() {
exporter_builder = exporter_builder.with_endpoint(&otlp_config.endpoint);
}
let exporter = exporter_builder.build().map_err(|e| {
crate::error::Error::Internal(format!("Failed to build OTLP exporter: {}", e))
})?;
let provider = SdkTracerProvider::builder()
.with_resource(resource)
.with_batch_exporter(exporter)
.build();
Ok(provider)
}
#[cfg(feature = "otel-metrics")]
pub(crate) fn init_otlp_meter(
otlp_config: &crate::config::OtlpConfig,
service_name: &str,
) -> Result<SdkMeterProvider> {
let metrics_service_name = otlp_config
.service_name
.as_ref()
.unwrap_or(&service_name.to_string())
.clone();
let resource = Resource::builder()
.with_service_name(metrics_service_name)
.build();
let mut exporter_builder = MetricExporter::builder().with_tonic();
if !otlp_config.endpoint.is_empty() {
exporter_builder = exporter_builder.with_endpoint(&otlp_config.endpoint);
}
let exporter = exporter_builder.build().map_err(|e| {
crate::error::Error::Internal(format!("Failed to build OTLP metric exporter: {}", e))
})?;
let reader = PeriodicReader::builder(exporter)
.with_interval(StdDuration::from_secs(15))
.build();
let provider = SdkMeterProvider::builder()
.with_resource(resource)
.with_reader(reader)
.build();
Ok(provider)
}
#[cfg(feature = "journald")]
fn init_journald_layer(
config: &crate::config::JournaldConfig,
service_name: &str,
) -> Option<tracing_journald::Layer> {
match tracing_journald::Layer::new() {
Ok(layer) => {
let identifier = config.syslog_identifier.as_deref().unwrap_or(service_name);
let mut layer = layer.with_syslog_identifier(identifier.to_string());
if let Some(ref prefix) = config.field_prefix {
layer = layer.with_field_prefix(if prefix.is_empty() {
None
} else {
Some(prefix.clone())
});
}
Some(layer)
}
Err(e) => {
eprintln!(
"Warning: journald socket unavailable ({}), continuing without journald",
e
);
None
}
}
}
#[cfg(feature = "otel-metrics")]
pub fn get_meter() -> Option<opentelemetry::metrics::Meter> {
if let Some(provider) = METER_PROVIDER.get() {
return Some(provider.meter("acton-service"));
}
None
}
#[cfg(feature = "otel-metrics")]
pub fn init_meter_provider(config: &Config) -> Result<()> {
if let Some(otlp_config) = &config.otlp {
if otlp_config.enabled {
match init_otlp_meter(otlp_config, &config.service.name) {
Ok(meter_provider) => {
let _ = METER_PROVIDER.set(meter_provider.clone());
global::set_meter_provider(meter_provider);
tracing::info!(
service = %config.service.name,
otlp_endpoint = %otlp_config.endpoint,
"OpenTelemetry metrics initialized with OTLP export"
);
return Ok(());
}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to initialize OTLP metric exporter (metrics disabled)"
);
}
}
}
}
tracing::info!("Metrics not configured or disabled");
Ok(())
}
#[cfg(not(feature = "observability"))]
pub fn init_tracing<T>(config: &Config<T>) -> Result<()>
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer};
if TRACING_INIT.is_completed() {
return Ok(());
}
let log_level = config.service.log_level.clone();
let service_name = config.service.name.clone();
TRACING_INIT.call_once(|| {
#[cfg(feature = "journald")]
let suppress_fmt = config
.journald
.as_ref()
.is_some_and(|j| j.enabled && j.disable_fmt_layer);
#[cfg(not(feature = "journald"))]
let suppress_fmt = false;
let fmt_layer = if suppress_fmt {
None
} else {
Some(tracing_subscriber::fmt::layer().json().with_filter(
EnvFilter::try_new(&log_level).unwrap_or_else(|_| EnvFilter::new("info")),
))
};
#[cfg(feature = "journald")]
let journald_layer = config
.journald
.as_ref()
.filter(|j| j.enabled)
.and_then(|j| init_journald_layer(j, &service_name));
let registry = tracing_subscriber::registry().with(fmt_layer);
#[cfg(feature = "journald")]
let registry = registry.with(journald_layer);
registry.init();
tracing::info!(
service = %service_name,
"Tracing initialized (observability feature disabled)"
);
});
Ok(())
}
pub fn init_basic_tracing() {
TRACING_INIT.call_once(|| {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
tracing::debug!("Tracing initialized with default configuration");
});
}
#[cfg(feature = "observability")]
pub fn shutdown_tracing() {
tracing::info!("Shutting down tracing and flushing spans...");
if let Some(provider) = TRACER_PROVIDER.get() {
if let Err(e) = provider.shutdown() {
eprintln!("Error during tracer provider shutdown: {}", e);
} else {
tracing::debug!("OpenTelemetry tracer provider shutdown complete");
}
}
#[cfg(feature = "otel-metrics")]
if let Some(provider) = METER_PROVIDER.get() {
if let Err(e) = provider.shutdown() {
eprintln!("Error during meter provider shutdown: {}", e);
} else {
tracing::debug!("OpenTelemetry meter provider shutdown complete");
}
}
tracing::info!("Tracing shutdown complete");
}
#[cfg(not(feature = "observability"))]
pub fn shutdown_tracing() {
tracing::info!("Tracing shutdown (observability feature disabled)");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_tracing_without_otlp() {
let config = Config::<()>::default();
let result = init_tracing(&config);
assert!(result.is_ok(), "Tracing initialization should succeed");
}
#[tokio::test]
#[cfg(feature = "observability")]
async fn test_init_tracing_with_invalid_otlp() {
let otlp_config = crate::config::OtlpConfig {
endpoint: "http://invalid-endpoint:4317".to_string(),
service_name: Some("test-service".to_string()),
enabled: true,
};
let result = init_otlp_tracer(&otlp_config, "test-service");
assert!(
result.is_ok(),
"OTLP tracer should build even with invalid endpoint (connection is lazy)"
);
}
#[test]
fn test_shutdown_tracing() {
shutdown_tracing();
}
#[tokio::test]
#[cfg(feature = "otel-metrics")]
async fn test_init_meter_provider_without_config() {
let config = Config::<()>::default();
let result = init_meter_provider(&config);
assert!(
result.is_ok(),
"Meter provider init should succeed without config"
);
}
#[tokio::test]
#[cfg(feature = "otel-metrics")]
async fn test_init_otlp_meter() {
let otlp_config = crate::config::OtlpConfig {
endpoint: "http://localhost:4317".to_string(),
service_name: Some("test-metrics-service".to_string()),
enabled: true,
};
let result = init_otlp_meter(&otlp_config, "test-service");
assert!(
result.is_ok(),
"OTLP meter should build even with potentially invalid endpoint (connection is lazy)"
);
}
#[test]
#[cfg(feature = "otel-metrics")]
fn test_get_meter_without_init() {
let meter = get_meter();
assert!(
meter.is_none(),
"get_meter should return None before initialization"
);
}
#[test]
#[cfg(feature = "journald")]
fn test_init_journald_layer_graceful_fallback() {
let config = crate::config::JournaldConfig {
enabled: true,
syslog_identifier: Some("test-svc".to_string()),
field_prefix: None,
disable_fmt_layer: false,
};
let _ = init_journald_layer(&config, "test-svc");
}
}