aether_telemetry/
telemetry_runtime.rs1use crate::error::{TelemetryInitError, TelemetryShutdownError};
2use crate::gen_ai_metrics::GenAiMetrics;
3use crate::genai_constants;
4use crate::otel_observer::OtelInstrumentation;
5use opentelemetry::KeyValue;
6use opentelemetry::metrics::MeterProvider as _;
7use opentelemetry::trace::TracerProvider as _;
8use opentelemetry_otlp::{MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig};
9use opentelemetry_sdk::Resource;
10use opentelemetry_sdk::metrics::SdkMeterProvider;
11use opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader;
12use opentelemetry_sdk::runtime::Tokio;
13use opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor;
14use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
15use std::collections::HashMap;
16
17pub struct TelemetryRuntime {
18 tracer_provider: SdkTracerProvider,
19 meter_provider: SdkMeterProvider,
20 instrumentation: OtelInstrumentation,
21}
22
23pub struct TelemetryConfig {
27 pub endpoint: Option<String>,
29 pub traces_endpoint: Option<String>,
32 pub metrics_endpoint: Option<String>,
35 pub headers: HashMap<String, String>,
36 pub service_name: String,
37 pub service_version: String,
38 pub sample_ratio: f64,
39 pub capture_content: bool,
40 pub traces_enabled: bool,
41 pub metrics_enabled: bool,
42}
43
44impl TelemetryRuntime {
45 pub fn new(config: &TelemetryConfig) -> Result<Self, TelemetryInitError> {
46 if !(0.0..=1.0).contains(&config.sample_ratio) {
47 return Err(TelemetryInitError::InvalidSampleRatio(config.sample_ratio));
48 }
49
50 let http_client = build_http_client(&config.headers)?;
51 let resource = Resource::builder()
52 .with_service_name(config.service_name.clone())
53 .with_attribute(KeyValue::new(genai_constants::SERVICE_VERSION, config.service_version.clone()))
54 .build();
55 let scope = genai_constants::genai_instrumentation_scope(config.service_version.clone());
56 let tracer_provider = build_tracer_provider(config, resource.clone(), http_client.clone())?;
57 let meter_provider = build_meter_provider(config, resource, http_client)?;
58 let instrumentation = OtelInstrumentation {
59 tracer: tracer_provider.tracer_with_scope(scope.clone()),
60 metrics: GenAiMetrics::new(&meter_provider.meter_with_scope(scope)),
61 capture_content: config.capture_content,
62 };
63
64 Ok(Self { tracer_provider, meter_provider, instrumentation })
65 }
66
67 pub fn observer_factory(&self) -> aether_core::events::ObserverFactory {
68 let instrumentation = self.instrumentation.clone();
69 std::sync::Arc::new(move || Box::new(crate::OtelObserver::new(instrumentation.clone())))
70 }
71
72 pub fn shutdown_or_log(&self) {
75 if let Err(error) = self.shutdown() {
76 tracing::error!("Failed to shutdown telemetry: {error}");
77 }
78 }
79
80 pub fn shutdown(&self) -> Result<(), TelemetryShutdownError> {
83 let traces = self.tracer_provider.shutdown().map_err(TelemetryShutdownError::Trace);
84 let metrics = self.meter_provider.shutdown().map_err(TelemetryShutdownError::Metric);
85 traces.and(metrics)
86 }
87}
88
89impl TelemetryConfig {
90 fn signal_endpoint(&self, signal: &str) -> Result<String, TelemetryInitError> {
91 let signal_specific_endpoint = match signal {
92 "traces" => self.traces_endpoint.as_deref(),
93 "metrics" => self.metrics_endpoint.as_deref(),
94 _ => None,
95 };
96 if let Some(endpoint) = signal_specific_endpoint.filter(|endpoint| !endpoint.is_empty()) {
97 return Ok(endpoint.to_string());
98 }
99
100 Ok(format!("{}/v1/{signal}", self.required_endpoint()?.trim_end_matches('/')))
101 }
102
103 fn required_endpoint(&self) -> Result<&str, TelemetryInitError> {
104 self.endpoint.as_deref().filter(|endpoint| !endpoint.is_empty()).ok_or(TelemetryInitError::MissingOtlpEndpoint)
105 }
106}
107
108fn build_tracer_provider(
109 config: &TelemetryConfig,
110 resource: Resource,
111 http_client: reqwest::Client,
112) -> Result<SdkTracerProvider, TelemetryInitError> {
113 let builder = SdkTracerProvider::builder().with_resource(resource);
114 if !config.traces_enabled {
115 return Ok(builder.with_sampler(Sampler::AlwaysOff).build());
116 }
117
118 let endpoint = config.signal_endpoint("traces")?;
119 let exporter = SpanExporter::builder()
120 .with_http()
121 .with_endpoint(endpoint)
122 .with_http_client(http_client)
123 .build()
124 .map_err(TelemetryInitError::TraceExporter)?;
125
126 Ok(builder
127 .with_sampler(Sampler::TraceIdRatioBased(config.sample_ratio))
128 .with_span_processor(BatchSpanProcessor::builder(exporter, Tokio).build())
129 .build())
130}
131
132fn build_meter_provider(
133 config: &TelemetryConfig,
134 resource: Resource,
135 http_client: reqwest::Client,
136) -> Result<SdkMeterProvider, TelemetryInitError> {
137 let builder = SdkMeterProvider::builder().with_resource(resource);
138 if !config.metrics_enabled {
139 return Ok(builder.build());
140 }
141
142 let endpoint = config.signal_endpoint("metrics")?;
143 let exporter = MetricExporter::builder()
144 .with_http()
145 .with_endpoint(endpoint)
146 .with_http_client(http_client)
147 .build()
148 .map_err(TelemetryInitError::MetricExporter)?;
149
150 Ok(builder.with_reader(PeriodicReader::builder(exporter, Tokio).build()).build())
151}
152
153fn build_http_client(headers: &HashMap<String, String>) -> Result<reqwest::Client, TelemetryInitError> {
154 let mut parsed = reqwest::header::HeaderMap::new();
155 for (key, value) in headers {
156 let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
157 .map_err(|_| TelemetryInitError::InvalidHeaderName(key.clone()))?;
158 let value = reqwest::header::HeaderValue::from_str(value)
159 .map_err(|_| TelemetryInitError::InvalidHeaderValue(key.clone()))?;
160 parsed.insert(name, value);
161 }
162 reqwest::Client::builder().default_headers(parsed).build().map_err(TelemetryInitError::HttpClient)
163}