use std::time::Duration;
#[cfg(not(test))]
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{metrics::SdkMeterProvider, trace::SdkTracerProvider};
#[cfg(not(test))]
use tracing_subscriber::{Registry, layer::SubscriberExt, util::SubscriberInitExt};
const DEFAULT_SERVICE_NAME: &str = "git-prism";
const EXPORT_TIMEOUT: Duration = Duration::from_secs(5);
const ENV_OTLP_ENDPOINT: &str = "GIT_PRISM_OTLP_ENDPOINT";
const ENV_SERVICE_NAME: &str = "GIT_PRISM_SERVICE_NAME";
const ENV_SERVICE_VERSION: &str = "GIT_PRISM_SERVICE_VERSION";
fn signal_endpoints(base: &str) -> (String, String) {
let trimmed = base.trim_end_matches('/');
(
format!("{trimmed}/v1/traces"),
format!("{trimmed}/v1/metrics"),
)
}
pub struct TelemetryGuard {
tracer_provider: Option<SdkTracerProvider>,
meter_provider: Option<SdkMeterProvider>,
}
impl TelemetryGuard {
pub fn is_active(&self) -> bool {
self.tracer_provider.is_some()
}
}
impl Drop for TelemetryGuard {
fn drop(&mut self) {
if let Some(tp) = self.tracer_provider.take()
&& let Err(e) = tp.shutdown()
{
eprintln!("git-prism: failed to flush traces on shutdown: {e}");
}
if let Some(mp) = self.meter_provider.take()
&& let Err(e) = mp.shutdown()
{
eprintln!("git-prism: failed to flush metrics on shutdown: {e}");
}
}
}
#[cfg(not(test))]
fn attach_tracing_subscriber_default(tracer_provider: &SdkTracerProvider) -> Result<(), String> {
let tracer = tracer_provider.tracer("git-prism");
let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
Registry::default()
.with(otel_layer)
.try_init()
.map_err(|e| e.to_string())
}
#[cfg(test)]
fn attach_tracing_subscriber_default(_tracer_provider: &SdkTracerProvider) -> Result<(), String> {
Ok(())
}
pub fn init() -> TelemetryGuard {
init_with_attacher(attach_tracing_subscriber_default)
}
fn init_with_attacher<F>(attach_subscriber: F) -> TelemetryGuard
where
F: FnOnce(&SdkTracerProvider) -> Result<(), String>,
{
let endpoint = match std::env::var(ENV_OTLP_ENDPOINT) {
Ok(ep) if !ep.is_empty() => ep,
_ => {
return TelemetryGuard {
tracer_provider: None,
meter_provider: None,
};
}
};
let base = endpoint.trim_end_matches('/');
let (traces_endpoint, metrics_endpoint) = signal_endpoints(base);
let service_name =
std::env::var(ENV_SERVICE_NAME).unwrap_or_else(|_| DEFAULT_SERVICE_NAME.to_string());
let service_version = std::env::var(ENV_SERVICE_VERSION)
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string());
let trace_exporter = match opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(&traces_endpoint)
.with_timeout(EXPORT_TIMEOUT)
.build()
{
Ok(exp) => exp,
Err(e) => {
eprintln!("git-prism: failed to initialize trace exporter: {e}");
return TelemetryGuard {
tracer_provider: None,
meter_provider: None,
};
}
};
let metrics_exporter = match opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_endpoint(&metrics_endpoint)
.with_timeout(EXPORT_TIMEOUT)
.build()
{
Ok(exp) => exp,
Err(e) => {
eprintln!("git-prism: failed to initialize metrics exporter: {e}");
return TelemetryGuard {
tracer_provider: None,
meter_provider: None,
};
}
};
let resource = opentelemetry_sdk::Resource::builder()
.with_service_name(service_name)
.with_attribute(opentelemetry::KeyValue::new(
"service.version",
service_version,
))
.build();
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(metrics_exporter).build();
let meter_provider = SdkMeterProvider::builder()
.with_reader(reader)
.with_resource(resource)
.build();
#[cfg(not(test))]
opentelemetry::global::set_meter_provider(meter_provider.clone());
if let Err(e) = attach_subscriber(&tracer_provider) {
eprintln!("git-prism: failed to initialize tracing subscriber: {e}");
return TelemetryGuard {
tracer_provider: None,
meter_provider: None,
};
}
eprintln!("git-prism: telemetry initialized (HTTP/protobuf, endpoint={base})");
TelemetryGuard {
tracer_provider: Some(tracer_provider),
meter_provider: Some(meter_provider),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
unsafe fn clear_telemetry_env() {
unsafe {
std::env::remove_var(ENV_OTLP_ENDPOINT);
std::env::remove_var(ENV_SERVICE_NAME);
std::env::remove_var(ENV_SERVICE_VERSION);
}
}
#[test]
fn test_init_without_env_returns_noop() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe {
clear_telemetry_env();
}
let guard = init();
assert!(
!guard.is_active(),
"guard should be no-op when no endpoint is set"
);
}
#[test]
fn test_init_with_empty_endpoint_returns_noop() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe {
clear_telemetry_env();
std::env::set_var(ENV_OTLP_ENDPOINT, "");
}
let guard = init();
assert!(
!guard.is_active(),
"guard should be no-op when endpoint is empty"
);
unsafe {
std::env::remove_var(ENV_OTLP_ENDPOINT);
}
}
#[tokio::test]
async fn test_init_with_endpoint_creates_providers() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe {
clear_telemetry_env();
std::env::set_var(ENV_OTLP_ENDPOINT, "http://localhost:4318");
}
let guard = init();
assert!(
guard.is_active(),
"guard should be active when endpoint is set"
);
unsafe {
std::env::remove_var(ENV_OTLP_ENDPOINT);
}
drop(guard);
}
#[tokio::test]
async fn test_guard_drop_does_not_panic() {
let _lock = ENV_MUTEX.lock().unwrap();
let noop_guard = TelemetryGuard {
tracer_provider: None,
meter_provider: None,
};
drop(noop_guard);
unsafe {
clear_telemetry_env();
std::env::set_var(ENV_OTLP_ENDPOINT, "http://localhost:4318");
}
let active_guard = init();
unsafe {
std::env::remove_var(ENV_OTLP_ENDPOINT);
}
drop(active_guard);
}
#[test]
fn it_trims_trailing_slash_when_computing_signal_paths() {
let (traces, metrics) = signal_endpoints("http://localhost:4318/");
assert_eq!(traces, "http://localhost:4318/v1/traces");
assert_eq!(metrics, "http://localhost:4318/v1/metrics");
}
#[test]
fn it_appends_signal_paths_to_a_bare_base() {
let (traces, metrics) = signal_endpoints("http://localhost:4318");
assert_eq!(traces, "http://localhost:4318/v1/traces");
assert_eq!(metrics, "http://localhost:4318/v1/metrics");
}
#[tokio::test]
async fn test_init_with_custom_service_name_succeeds() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe {
clear_telemetry_env();
std::env::set_var(ENV_OTLP_ENDPOINT, "http://localhost:4318");
std::env::set_var(ENV_SERVICE_NAME, "custom-prism");
}
let guard = init();
assert!(guard.is_active());
unsafe {
std::env::remove_var(ENV_OTLP_ENDPOINT);
std::env::remove_var(ENV_SERVICE_NAME);
}
drop(guard);
}
#[tokio::test]
async fn it_returns_noop_guard_when_tracing_subscriber_init_fails() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe {
clear_telemetry_env();
std::env::set_var(ENV_OTLP_ENDPOINT, "http://localhost:4318");
}
let guard =
init_with_attacher(|_tp| Err("subscriber already registered (simulated)".to_string()));
unsafe {
std::env::remove_var(ENV_OTLP_ENDPOINT);
}
assert!(
!guard.is_active(),
"guard must degrade to no-op when the tracing subscriber cannot be attached; \
returning an active guard with no attached subscriber silently drops every span"
);
}
}