aether_telemetry/
telemetry_runtime.rs1use crate::AgentTraceContext;
2use crate::error::{TelemetryInitError, TelemetryShutdownError};
3use crate::gen_ai_metrics::GenAiMetrics;
4use crate::genai_constants;
5use crate::otel_observer::OtelInstrumentation;
6use opentelemetry::metrics::MeterProvider as _;
7use opentelemetry::propagation::TextMapPropagator;
8use opentelemetry::trace::{TraceContextExt, TraceId, TraceState, TracerProvider as _};
9use opentelemetry::{Context, KeyValue};
10use opentelemetry_otlp::{MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig};
11use opentelemetry_sdk::Resource;
12use opentelemetry_sdk::metrics::SdkMeterProvider;
13use opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader;
14use opentelemetry_sdk::propagation::TraceContextPropagator;
15use opentelemetry_sdk::runtime::Tokio;
16use opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor;
17use opentelemetry_sdk::trace::{IdGenerator, RandomIdGenerator, Sampler, SdkTracerProvider};
18use std::collections::HashMap;
19use std::str::FromStr;
20
21pub struct TelemetryRuntime {
22 tracer_provider: SdkTracerProvider,
23 meter_provider: SdkMeterProvider,
24 instrumentation: OtelInstrumentation,
25}
26
27pub struct TelemetryConfig {
31 pub endpoint: Option<String>,
33 pub traces_endpoint: Option<String>,
36 pub metrics_endpoint: Option<String>,
39 pub headers: HashMap<String, String>,
40 pub service_name: String,
41 pub service_version: String,
42 pub sample_ratio: f64,
43 pub capture_content: bool,
44 pub trace_context: Option<AgentTraceContext>,
45 pub traces_enabled: bool,
46 pub metrics_enabled: bool,
47}
48
49impl TelemetryRuntime {
50 pub fn new(config: &TelemetryConfig) -> Result<Self, TelemetryInitError> {
51 if !(0.0..=1.0).contains(&config.sample_ratio) {
52 return Err(TelemetryInitError::InvalidSampleRatio(config.sample_ratio));
53 }
54
55 let trace_root = resolve_trace_root(config.trace_context.as_ref())?;
56 let http_client = build_http_client(&config.headers)?;
57 let resource = Resource::builder()
58 .with_service_name(config.service_name.clone())
59 .with_attribute(KeyValue::new(genai_constants::SERVICE_VERSION, config.service_version.clone()))
60 .build();
61 let scope = genai_constants::genai_instrumentation_scope(config.service_version.clone());
62 let tracer_provider =
63 build_tracer_provider(config, resource.clone(), http_client.clone(), trace_root.trace_id)?;
64 let meter_provider = build_meter_provider(config, resource, http_client)?;
65 let instrumentation = OtelInstrumentation {
66 tracer: tracer_provider.tracer_with_scope(scope.clone()),
67 metrics: GenAiMetrics::new(&meter_provider.meter_with_scope(scope)),
68 capture_content: config.capture_content,
69 root_parent: trace_root.parent,
70 };
71
72 Ok(Self { tracer_provider, meter_provider, instrumentation })
73 }
74
75 pub fn observer_factory(&self) -> aether_core::events::ObserverFactory {
76 let instrumentation = self.instrumentation.clone();
77 std::sync::Arc::new(move || Box::new(crate::OtelObserver::new(instrumentation.clone())))
78 }
79
80 pub fn shutdown_or_log(&self) {
83 if let Err(error) = self.shutdown() {
84 tracing::error!("Failed to shutdown telemetry: {error}");
85 }
86 }
87
88 pub fn shutdown(&self) -> Result<(), TelemetryShutdownError> {
91 let traces = self.tracer_provider.shutdown().map_err(TelemetryShutdownError::Trace);
92 let metrics = self.meter_provider.shutdown().map_err(TelemetryShutdownError::Metric);
93 traces.and(metrics)
94 }
95}
96
97impl TelemetryConfig {
98 fn signal_endpoint(&self, signal: &str) -> Result<String, TelemetryInitError> {
99 let signal_specific_endpoint = match signal {
100 "traces" => self.traces_endpoint.as_deref(),
101 "metrics" => self.metrics_endpoint.as_deref(),
102 _ => None,
103 };
104 if let Some(endpoint) = signal_specific_endpoint.filter(|endpoint| !endpoint.is_empty()) {
105 return Ok(endpoint.to_string());
106 }
107
108 Ok(format!("{}/v1/{signal}", self.required_endpoint()?.trim_end_matches('/')))
109 }
110
111 fn required_endpoint(&self) -> Result<&str, TelemetryInitError> {
112 self.endpoint.as_deref().filter(|endpoint| !endpoint.is_empty()).ok_or(TelemetryInitError::MissingOtlpEndpoint)
113 }
114}
115
116#[derive(Default)]
117struct TraceRoot {
118 parent: Option<Context>,
119 trace_id: Option<TraceId>,
120}
121
122#[derive(Debug)]
123struct TraceIdGenerator {
124 trace_id: TraceId,
125 random: RandomIdGenerator,
126}
127
128impl IdGenerator for TraceIdGenerator {
129 fn new_trace_id(&self) -> TraceId {
130 self.trace_id
131 }
132
133 fn new_span_id(&self) -> opentelemetry::trace::SpanId {
134 self.random.new_span_id()
135 }
136}
137
138fn resolve_trace_root(trace_context: Option<&AgentTraceContext>) -> Result<TraceRoot, TelemetryInitError> {
139 let Some(trace_context) = trace_context else {
140 return Ok(TraceRoot::default());
141 };
142
143 match trace_context {
144 AgentTraceContext::Parent { tracestate, .. } => {
145 if let Some(tracestate) = tracestate {
146 TraceState::from_str(tracestate).map_err(|_| TelemetryInitError::InvalidTraceContext("tracestate"))?;
147 }
148
149 let context = TraceContextPropagator::new().extract(trace_context);
150 if !context.span().span_context().is_valid() {
151 return Err(TelemetryInitError::InvalidTraceContext("traceparent"));
152 }
153
154 Ok(TraceRoot { parent: Some(context), trace_id: None })
155 }
156 AgentTraceContext::Root { trace_id } => {
157 let trace_id = parse_trace_id(trace_id)?;
158 Ok(TraceRoot { parent: None, trace_id: Some(trace_id) })
159 }
160 }
161}
162
163fn parse_trace_id(value: &str) -> Result<TraceId, TelemetryInitError> {
164 let valid_hex =
165 value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
166 if !valid_hex {
167 return Err(TelemetryInitError::InvalidTraceContext("traceId"));
168 }
169
170 let trace_id = TraceId::from_hex(value).map_err(|_| TelemetryInitError::InvalidTraceContext("traceId"))?;
171 if trace_id == TraceId::INVALID {
172 return Err(TelemetryInitError::InvalidTraceContext("traceId"));
173 }
174
175 Ok(trace_id)
176}
177
178fn build_tracer_provider(
179 config: &TelemetryConfig,
180 resource: Resource,
181 http_client: reqwest::Client,
182 trace_id: Option<TraceId>,
183) -> Result<SdkTracerProvider, TelemetryInitError> {
184 let builder = SdkTracerProvider::builder().with_resource(resource);
185 let builder = match trace_id {
186 Some(trace_id) => {
187 builder.with_id_generator(TraceIdGenerator { trace_id, random: RandomIdGenerator::default() })
188 }
189 None => builder,
190 };
191 if !config.traces_enabled {
192 return Ok(builder.with_sampler(Sampler::AlwaysOff).build());
193 }
194
195 let endpoint = config.signal_endpoint("traces")?;
196 let exporter = SpanExporter::builder()
197 .with_http()
198 .with_endpoint(endpoint)
199 .with_http_client(http_client)
200 .build()
201 .map_err(TelemetryInitError::TraceExporter)?;
202
203 Ok(builder
204 .with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(config.sample_ratio))))
205 .with_span_processor(BatchSpanProcessor::builder(exporter, Tokio).build())
206 .build())
207}
208
209fn build_meter_provider(
210 config: &TelemetryConfig,
211 resource: Resource,
212 http_client: reqwest::Client,
213) -> Result<SdkMeterProvider, TelemetryInitError> {
214 let builder = SdkMeterProvider::builder().with_resource(resource);
215 if !config.metrics_enabled {
216 return Ok(builder.build());
217 }
218
219 let endpoint = config.signal_endpoint("metrics")?;
220 let exporter = MetricExporter::builder()
221 .with_http()
222 .with_endpoint(endpoint)
223 .with_http_client(http_client)
224 .build()
225 .map_err(TelemetryInitError::MetricExporter)?;
226
227 Ok(builder.with_reader(PeriodicReader::builder(exporter, Tokio).build()).build())
228}
229
230fn build_http_client(headers: &HashMap<String, String>) -> Result<reqwest::Client, TelemetryInitError> {
231 let mut parsed = reqwest::header::HeaderMap::new();
232 for (key, value) in headers {
233 let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
234 .map_err(|_| TelemetryInitError::InvalidHeaderName(key.clone()))?;
235 let value = reqwest::header::HeaderValue::from_str(value)
236 .map_err(|_| TelemetryInitError::InvalidHeaderValue(key.clone()))?;
237 parsed.insert(name, value);
238 }
239 reqwest::Client::builder().default_headers(parsed).build().map_err(TelemetryInitError::HttpClient)
240}