use crate::error::{TelemetryInitError, TelemetryShutdownError};
use crate::gen_ai_metrics::GenAiMetrics;
use crate::genai_constants;
use crate::otel_observer::OtelInstrumentation;
use opentelemetry::KeyValue;
use opentelemetry::metrics::MeterProvider as _;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::{MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::metrics::SdkMeterProvider;
use opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader;
use opentelemetry_sdk::runtime::Tokio;
use opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor;
use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
use std::collections::HashMap;
pub struct TelemetryRuntime {
tracer_provider: SdkTracerProvider,
meter_provider: SdkMeterProvider,
instrumentation: OtelInstrumentation,
}
pub struct TelemetryConfig {
pub endpoint: Option<String>,
pub traces_endpoint: Option<String>,
pub metrics_endpoint: Option<String>,
pub headers: HashMap<String, String>,
pub service_name: String,
pub service_version: String,
pub sample_ratio: f64,
pub capture_content: bool,
pub traces_enabled: bool,
pub metrics_enabled: bool,
}
impl TelemetryRuntime {
pub fn new(config: &TelemetryConfig) -> Result<Self, TelemetryInitError> {
if !(0.0..=1.0).contains(&config.sample_ratio) {
return Err(TelemetryInitError::InvalidSampleRatio(config.sample_ratio));
}
let http_client = build_http_client(&config.headers)?;
let resource = Resource::builder()
.with_service_name(config.service_name.clone())
.with_attribute(KeyValue::new(genai_constants::SERVICE_VERSION, config.service_version.clone()))
.build();
let scope = genai_constants::genai_instrumentation_scope(config.service_version.clone());
let tracer_provider = build_tracer_provider(config, resource.clone(), http_client.clone())?;
let meter_provider = build_meter_provider(config, resource, http_client)?;
let instrumentation = OtelInstrumentation {
tracer: tracer_provider.tracer_with_scope(scope.clone()),
metrics: GenAiMetrics::new(&meter_provider.meter_with_scope(scope)),
capture_content: config.capture_content,
};
Ok(Self { tracer_provider, meter_provider, instrumentation })
}
pub fn observer_factory(&self) -> aether_core::events::ObserverFactory {
let instrumentation = self.instrumentation.clone();
std::sync::Arc::new(move || Box::new(crate::OtelObserver::new(instrumentation.clone())))
}
pub fn shutdown_or_log(&self) {
if let Err(error) = self.shutdown() {
tracing::error!("Failed to shutdown telemetry: {error}");
}
}
pub fn shutdown(&self) -> Result<(), TelemetryShutdownError> {
let traces = self.tracer_provider.shutdown().map_err(TelemetryShutdownError::Trace);
let metrics = self.meter_provider.shutdown().map_err(TelemetryShutdownError::Metric);
traces.and(metrics)
}
}
impl TelemetryConfig {
fn signal_endpoint(&self, signal: &str) -> Result<String, TelemetryInitError> {
let signal_specific_endpoint = match signal {
"traces" => self.traces_endpoint.as_deref(),
"metrics" => self.metrics_endpoint.as_deref(),
_ => None,
};
if let Some(endpoint) = signal_specific_endpoint.filter(|endpoint| !endpoint.is_empty()) {
return Ok(endpoint.to_string());
}
Ok(format!("{}/v1/{signal}", self.required_endpoint()?.trim_end_matches('/')))
}
fn required_endpoint(&self) -> Result<&str, TelemetryInitError> {
self.endpoint.as_deref().filter(|endpoint| !endpoint.is_empty()).ok_or(TelemetryInitError::MissingOtlpEndpoint)
}
}
fn build_tracer_provider(
config: &TelemetryConfig,
resource: Resource,
http_client: reqwest::Client,
) -> Result<SdkTracerProvider, TelemetryInitError> {
let builder = SdkTracerProvider::builder().with_resource(resource);
if !config.traces_enabled {
return Ok(builder.with_sampler(Sampler::AlwaysOff).build());
}
let endpoint = config.signal_endpoint("traces")?;
let exporter = SpanExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_http_client(http_client)
.build()
.map_err(TelemetryInitError::TraceExporter)?;
Ok(builder
.with_sampler(Sampler::TraceIdRatioBased(config.sample_ratio))
.with_span_processor(BatchSpanProcessor::builder(exporter, Tokio).build())
.build())
}
fn build_meter_provider(
config: &TelemetryConfig,
resource: Resource,
http_client: reqwest::Client,
) -> Result<SdkMeterProvider, TelemetryInitError> {
let builder = SdkMeterProvider::builder().with_resource(resource);
if !config.metrics_enabled {
return Ok(builder.build());
}
let endpoint = config.signal_endpoint("metrics")?;
let exporter = MetricExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_http_client(http_client)
.build()
.map_err(TelemetryInitError::MetricExporter)?;
Ok(builder.with_reader(PeriodicReader::builder(exporter, Tokio).build()).build())
}
fn build_http_client(headers: &HashMap<String, String>) -> Result<reqwest::Client, TelemetryInitError> {
let mut parsed = reqwest::header::HeaderMap::new();
for (key, value) in headers {
let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
.map_err(|_| TelemetryInitError::InvalidHeaderName(key.clone()))?;
let value = reqwest::header::HeaderValue::from_str(value)
.map_err(|_| TelemetryInitError::InvalidHeaderValue(key.clone()))?;
parsed.insert(name, value);
}
reqwest::Client::builder().default_headers(parsed).build().map_err(TelemetryInitError::HttpClient)
}