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 = "_metrics")]
use {opentelemetry::metrics::MeterProvider as _, opentelemetry_sdk::metrics::SdkMeterProvider};
#[cfg(feature = "otel-metrics")]
use {
opentelemetry_otlp::MetricExporter, opentelemetry_sdk::metrics::PeriodicReader,
std::time::Duration as StdDuration,
};
#[cfg(feature = "observability")]
static TRACER_PROVIDER: once_cell::sync::OnceCell<SdkTracerProvider> =
once_cell::sync::OnceCell::new();
#[cfg(feature = "_metrics")]
pub static METER_PROVIDER: once_cell::sync::OnceCell<SdkMeterProvider> =
once_cell::sync::OnceCell::new();
#[cfg(feature = "prometheus-metrics")]
pub static PROMETHEUS_REGISTRY: once_cell::sync::OnceCell<prometheus::Registry> =
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")]
fn otlp_metric_reader(
otlp_config: &crate::config::OtlpConfig,
) -> Result<PeriodicReader<MetricExporter>> {
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))
})?;
Ok(PeriodicReader::builder(exporter)
.with_interval(StdDuration::from_secs(15))
.build())
}
#[cfg(feature = "prometheus-metrics")]
fn prometheus_metric_reader() -> Result<(
opentelemetry_prometheus::PrometheusExporter,
prometheus::Registry,
)> {
let registry = prometheus::Registry::new();
let exporter = opentelemetry_prometheus::exporter()
.with_registry(registry.clone())
.build()
.map_err(|e| {
crate::error::Error::Internal(format!("Failed to build Prometheus exporter: {}", e))
})?;
Ok((exporter, registry))
}
#[cfg(feature = "prometheus-metrics")]
fn encode_registry(registry: &prometheus::Registry) -> Result<Vec<u8>> {
use prometheus::Encoder;
let encoder = prometheus::TextEncoder::new();
let metric_families = registry.gather();
let mut buffer = Vec::new();
encoder.encode(&metric_families, &mut buffer).map_err(|e| {
crate::error::Error::Internal(format!("Failed to encode Prometheus metrics: {}", e))
})?;
Ok(buffer)
}
#[cfg(feature = "prometheus-metrics")]
pub async fn metrics_handler() -> axum::response::Response {
use axum::response::IntoResponse;
use prometheus::Encoder;
let Some(registry) = PROMETHEUS_REGISTRY.get() else {
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"metrics not initialized",
)
.into_response();
};
match encode_registry(registry) {
Ok(buffer) => (
[(
axum::http::header::CONTENT_TYPE,
prometheus::TextEncoder::new().format_type(),
)],
buffer,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to encode Prometheus metrics");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"failed to encode metrics",
)
.into_response()
}
}
}
#[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 = "_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 = "_metrics")]
pub fn init_meter_provider<T>(config: &Config<T>) -> Result<()>
where
T: serde::Serialize + serde::de::DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
let resource = Resource::builder()
.with_service_name(config.service.name.clone())
.build();
let mut builder = SdkMeterProvider::builder().with_resource(resource);
let mut reader_count: usize = 0;
#[cfg(feature = "otel-metrics")]
if let Some(otlp_config) = &config.otlp {
if otlp_config.enabled {
match otlp_metric_reader(otlp_config) {
Ok(reader) => {
builder = builder.with_reader(reader);
reader_count += 1;
tracing::info!(
service = %config.service.name,
otlp_endpoint = %otlp_config.endpoint,
"OpenTelemetry metrics OTLP push reader initialized"
);
}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to initialize OTLP metric exporter (skipping OTLP reader)"
);
}
}
}
}
#[cfg(feature = "prometheus-metrics")]
match prometheus_metric_reader() {
Ok((reader, registry)) => {
builder = builder.with_reader(reader);
let _ = PROMETHEUS_REGISTRY.set(registry);
reader_count += 1;
tracing::info!(
service = %config.service.name,
"Prometheus metrics pull reader initialized (/metrics)"
);
}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to initialize Prometheus exporter (skipping Prometheus reader)"
);
}
}
if reader_count == 0 {
tracing::info!("Metrics not configured or disabled");
return Ok(());
}
let provider = builder.build();
let _ = METER_PROVIDER.set(provider.clone());
global::set_meter_provider(provider);
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 = "_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_otlp_metric_reader() {
let otlp_config = crate::config::OtlpConfig {
endpoint: "http://localhost:4317".to_string(),
service_name: Some("test-metrics-service".to_string()),
enabled: true,
};
let result = otlp_metric_reader(&otlp_config);
assert!(
result.is_ok(),
"OTLP metric reader should build even with potentially invalid endpoint (connection is lazy)"
);
}
#[test]
#[cfg(feature = "_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 = "prometheus-metrics")]
fn test_prometheus_metric_reader_builds() {
let result = prometheus_metric_reader();
assert!(
result.is_ok(),
"Prometheus exporter/registry should build successfully"
);
}
#[test]
#[cfg(feature = "prometheus-metrics")]
fn test_encode_registry_empty_is_ok() {
let registry = prometheus::Registry::new();
let bytes = encode_registry(®istry).expect("encoding an empty registry should succeed");
assert!(
bytes.is_empty(),
"empty registry should encode to no output"
);
}
#[test]
#[cfg(feature = "prometheus-metrics")]
fn test_encode_registry_with_counter_contains_metric() {
use prometheus::core::Collector;
let registry = prometheus::Registry::new();
let counter =
prometheus::IntCounter::new("acton_test_total", "A test counter").expect("counter");
registry
.register(Box::new(counter.clone()) as Box<dyn Collector>)
.expect("register counter");
counter.inc_by(3);
let bytes = encode_registry(®istry).expect("encoding should succeed");
let text = String::from_utf8(bytes).expect("encoder emits UTF-8");
assert!(
text.contains("acton_test_total"),
"encoded output should contain the metric name, got: {text}"
);
assert!(
text.contains("# TYPE acton_test_total counter"),
"encoded output should contain the TYPE line, got: {text}"
);
assert!(
text.contains("acton_test_total 3"),
"encoded output should contain the counter value, got: {text}"
);
}
#[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");
}
}