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 headers: HashMap<String, String>,
30 pub service_name: String,
31 pub service_version: String,
32 pub sample_ratio: f64,
33 pub capture_content: bool,
34 pub traces_enabled: bool,
35 pub metrics_enabled: bool,
36}
37
38impl TelemetryRuntime {
39 pub fn new(config: &TelemetryConfig) -> Result<Self, TelemetryInitError> {
40 if !(0.0..=1.0).contains(&config.sample_ratio) {
41 return Err(TelemetryInitError::InvalidSampleRatio(config.sample_ratio));
42 }
43
44 let http_client = build_http_client(&config.headers)?;
45 let resource = Resource::builder()
46 .with_service_name(config.service_name.clone())
47 .with_attribute(KeyValue::new(genai_constants::SERVICE_VERSION, config.service_version.clone()))
48 .build();
49 let scope = genai_constants::genai_instrumentation_scope(config.service_version.clone());
50 let tracer_provider = build_tracer_provider(config, resource.clone(), http_client.clone())?;
51 let meter_provider = build_meter_provider(config, resource, http_client)?;
52 let instrumentation = OtelInstrumentation {
53 tracer: tracer_provider.tracer_with_scope(scope.clone()),
54 metrics: GenAiMetrics::new(&meter_provider.meter_with_scope(scope)),
55 capture_content: config.capture_content,
56 };
57
58 Ok(Self { tracer_provider, meter_provider, instrumentation })
59 }
60
61 pub fn observer_factory(&self) -> aether_core::events::ObserverFactory {
62 let instrumentation = self.instrumentation.clone();
63 std::sync::Arc::new(move || Box::new(crate::OtelObserver::new(instrumentation.clone())))
64 }
65
66 pub fn shutdown_or_log(&self) {
69 if let Err(error) = self.shutdown() {
70 tracing::error!("Failed to shutdown telemetry: {error}");
71 }
72 }
73
74 pub fn shutdown(&self) -> Result<(), TelemetryShutdownError> {
77 let traces = self.tracer_provider.shutdown().map_err(TelemetryShutdownError::Trace);
78 let metrics = self.meter_provider.shutdown().map_err(TelemetryShutdownError::Metric);
79 traces.and(metrics)
80 }
81}
82
83impl TelemetryConfig {
84 fn signal_endpoint(&self, signal: &str) -> Result<String, TelemetryInitError> {
85 Ok(format!("{}/v1/{signal}", self.required_endpoint()?.trim_end_matches('/')))
86 }
87
88 fn required_endpoint(&self) -> Result<&str, TelemetryInitError> {
89 self.endpoint.as_deref().filter(|endpoint| !endpoint.is_empty()).ok_or(TelemetryInitError::MissingOtlpEndpoint)
90 }
91}
92
93fn build_tracer_provider(
94 config: &TelemetryConfig,
95 resource: Resource,
96 http_client: reqwest::Client,
97) -> Result<SdkTracerProvider, TelemetryInitError> {
98 let builder = SdkTracerProvider::builder().with_resource(resource);
99 if !config.traces_enabled {
100 return Ok(builder.with_sampler(Sampler::AlwaysOff).build());
101 }
102
103 let endpoint = config.signal_endpoint("traces")?;
104 let exporter = SpanExporter::builder()
105 .with_http()
106 .with_endpoint(endpoint)
107 .with_http_client(http_client)
108 .build()
109 .map_err(TelemetryInitError::TraceExporter)?;
110
111 Ok(builder
112 .with_sampler(Sampler::TraceIdRatioBased(config.sample_ratio))
113 .with_span_processor(BatchSpanProcessor::builder(exporter, Tokio).build())
114 .build())
115}
116
117fn build_meter_provider(
118 config: &TelemetryConfig,
119 resource: Resource,
120 http_client: reqwest::Client,
121) -> Result<SdkMeterProvider, TelemetryInitError> {
122 let builder = SdkMeterProvider::builder().with_resource(resource);
123 if !config.metrics_enabled {
124 return Ok(builder.build());
125 }
126
127 let endpoint = config.signal_endpoint("metrics")?;
128 let exporter = MetricExporter::builder()
129 .with_http()
130 .with_endpoint(endpoint)
131 .with_http_client(http_client)
132 .build()
133 .map_err(TelemetryInitError::MetricExporter)?;
134
135 Ok(builder.with_reader(PeriodicReader::builder(exporter, Tokio).build()).build())
136}
137
138fn build_http_client(headers: &HashMap<String, String>) -> Result<reqwest::Client, TelemetryInitError> {
139 let mut parsed = reqwest::header::HeaderMap::new();
140 for (key, value) in headers {
141 let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
142 .map_err(|_| TelemetryInitError::InvalidHeaderName(key.clone()))?;
143 let value = reqwest::header::HeaderValue::from_str(value)
144 .map_err(|_| TelemetryInitError::InvalidHeaderValue(key.clone()))?;
145 parsed.insert(name, value);
146 }
147 reqwest::Client::builder().default_headers(parsed).build().map_err(TelemetryInitError::HttpClient)
148}