use crate::conventions;
use crate::{ObservabilityError, Result};
use tracing_subscriber::EnvFilter;
#[derive(Debug, Clone)]
pub struct TracingConfig {
pub service_name: String,
pub environment: String,
pub region: String,
}
impl TracingConfig {
pub fn new(
service_name: impl Into<String>,
environment: impl Into<String>,
region: impl Into<String>,
) -> Self {
Self {
service_name: service_name.into(),
environment: environment.into(),
region: region.into(),
}
}
}
pub fn init_tracing(service_name: &str, environment: &str, region: &str) -> Result<()> {
if service_name.is_empty() {
return Err(ObservabilityError::InvalidConfig(
"service_name must be non-empty".to_string(),
));
}
if environment.is_empty() {
return Err(ObservabilityError::InvalidConfig("environment must be non-empty".to_string()));
}
if region.is_empty() {
return Err(ObservabilityError::InvalidConfig("region must be non-empty".to_string()));
}
if tracing::dispatcher::has_been_set() {
return Ok(());
}
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let subscriber = tracing_subscriber::fmt().with_env_filter(filter).with_target(true).finish();
match tracing::subscriber::set_global_default(subscriber) {
Ok(()) => {
tracing::info!(
service_name = service_name,
environment = environment,
region = region,
"initialized tracing subscriber"
);
Ok(())
}
Err(_err) if tracing::dispatcher::has_been_set() => Ok(()),
Err(err) => Err(ObservabilityError::TracingInitError(err.to_string())),
}
}
pub fn init_tracing_with(config: TracingConfig) -> Result<()> {
init_tracing(&config.service_name, &config.environment, &config.region)
}
#[must_use]
pub fn canonical_span_name(operation: &str) -> String {
conventions::operation_span_name(operation)
}
#[cfg(feature = "otel")]
pub fn init_tracing_otel(config: &TracingConfig) -> Result<()> {
use opentelemetry::KeyValue;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::Resource;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
if tracing::dispatcher::has_been_set() {
return Ok(());
}
let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_string());
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(opentelemetry_otlp::new_exporter().tonic().with_endpoint(&endpoint))
.with_trace_config(opentelemetry_sdk::trace::Config::default().with_resource(
Resource::new(vec![
KeyValue::new("service.name", config.service_name.clone()),
KeyValue::new("deployment.environment", config.environment.clone()),
KeyValue::new("cloud.region", config.region.clone()),
]),
))
.install_batch(opentelemetry_sdk::runtime::Tokio)
.map_err(|e| ObservabilityError::ExporterError(e.to_string()))?;
let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let fmt_layer = tracing_subscriber::fmt::layer().with_target(true);
tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.with(otel_layer)
.try_init()
.map_err(|e| ObservabilityError::TracingInitError(e.to_string()))?;
tracing::info!(
service_name = config.service_name.as_str(),
environment = config.environment.as_str(),
endpoint = endpoint.as_str(),
"initialized OpenTelemetry OTLP tracing"
);
Ok(())
}
#[cfg(feature = "otel")]
pub fn shutdown_otel() {
opentelemetry::global::shutdown_tracer_provider();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty_service_name() {
let result = init_tracing("", "test", "local");
assert!(matches!(result, Err(ObservabilityError::InvalidConfig(_))));
}
#[test]
fn rejects_empty_environment() {
let result = init_tracing("stateset", "", "local");
assert!(matches!(result, Err(ObservabilityError::InvalidConfig(_))));
}
#[test]
fn rejects_empty_region() {
let result = init_tracing("stateset", "test", "");
assert!(matches!(result, Err(ObservabilityError::InvalidConfig(_))));
}
#[test]
fn canonical_span_name_is_normalized() {
assert_eq!(canonical_span_name("Order Created"), "stateset.order_created");
}
#[test]
fn tracing_config_new_str() {
let cfg = TracingConfig::new("svc", "prod", "us-east-1");
assert_eq!(cfg.service_name, "svc");
assert_eq!(cfg.environment, "prod");
assert_eq!(cfg.region, "us-east-1");
}
#[test]
fn tracing_config_new_string() {
let cfg = TracingConfig::new(
String::from("my-svc"),
String::from("staging"),
String::from("eu-west-1"),
);
assert_eq!(cfg.service_name, "my-svc");
assert_eq!(cfg.environment, "staging");
assert_eq!(cfg.region, "eu-west-1");
}
#[test]
fn tracing_config_debug() {
let cfg = TracingConfig::new("svc", "prod", "us-east-1");
let debug = format!("{cfg:?}");
assert!(debug.contains("TracingConfig"));
assert!(debug.contains("svc"));
}
#[test]
fn tracing_config_clone() {
let cfg = TracingConfig::new("svc", "prod", "us-east-1");
#[allow(clippy::redundant_clone)]
let cloned = cfg.clone();
assert_eq!(cloned.service_name, "svc");
assert_eq!(cloned.environment, "prod");
assert_eq!(cloned.region, "us-east-1");
}
#[test]
fn rejects_all_empty_fields() {
assert!(init_tracing("", "", "").is_err());
}
#[test]
fn rejects_empty_service_only() {
let r = init_tracing("", "env", "region");
assert!(r.is_err());
let msg = r.unwrap_err().to_string();
assert!(msg.contains("service_name"), "Error should mention service_name: {msg}");
}
#[test]
fn rejects_empty_environment_only() {
let r = init_tracing("svc", "", "region");
assert!(r.is_err());
let msg = r.unwrap_err().to_string();
assert!(msg.contains("environment"), "Error should mention environment: {msg}");
}
#[test]
fn rejects_empty_region_only() {
let r = init_tracing("svc", "env", "");
assert!(r.is_err());
let msg = r.unwrap_err().to_string();
assert!(msg.contains("region"), "Error should mention region: {msg}");
}
#[test]
fn init_tracing_with_rejects_empty_service() {
let cfg = TracingConfig::new("", "test", "local");
assert!(init_tracing_with(cfg).is_err());
}
#[test]
fn init_tracing_with_rejects_empty_env() {
let cfg = TracingConfig::new("svc", "", "local");
assert!(init_tracing_with(cfg).is_err());
}
#[test]
fn init_tracing_with_rejects_empty_region() {
let cfg = TracingConfig::new("svc", "test", "");
assert!(init_tracing_with(cfg).is_err());
}
#[test]
fn init_tracing_succeeds_or_is_noop_when_already_set() {
let result = init_tracing("stateset-test", "test", "local");
assert!(result.is_ok());
}
#[test]
fn init_tracing_with_succeeds_or_is_noop() {
let cfg = TracingConfig::new("stateset-test", "test", "local");
assert!(init_tracing_with(cfg).is_ok());
}
#[test]
fn canonical_span_name_empty() {
assert_eq!(canonical_span_name(""), "stateset.unknown");
}
#[test]
fn canonical_span_name_with_dots() {
assert_eq!(canonical_span_name("order.create"), "stateset.order_create");
}
#[test]
fn canonical_span_name_with_slashes() {
assert_eq!(canonical_span_name("api/v1/orders"), "stateset.api_v1_orders");
}
#[test]
fn canonical_span_name_uppercase() {
assert_eq!(canonical_span_name("ORDER"), "stateset.order");
}
#[test]
fn canonical_span_name_mixed_separators() {
assert_eq!(canonical_span_name("Order---Created"), "stateset.order_created");
}
#[test]
fn init_tracing_error_is_invalid_config_variant() {
let err = init_tracing("", "env", "region").unwrap_err();
assert!(matches!(err, ObservabilityError::InvalidConfig(_)));
}
#[test]
fn error_display_includes_field_name() {
let err = init_tracing("", "env", "region").unwrap_err();
let display = err.to_string();
assert!(display.contains("Invalid configuration"));
assert!(display.contains("service_name"));
}
}