Skip to main content

llm_optimizer_processor/telemetry/
tracing.rs

1//! Distributed tracing with OpenTelemetry.
2//!
3//! This module provides trace provider initialization, span instrumentation,
4//! and helpers for distributed tracing.
5
6use crate::telemetry::resource::ResourceBuilder;
7use opentelemetry::{
8    global,
9    trace::{SpanKind, Status, TraceError, Tracer, TracerProvider as _},
10    KeyValue,
11};
12use opentelemetry_otlp::WithExportConfig;
13use opentelemetry_sdk::{
14    propagation::TraceContextPropagator,
15    runtime,
16    trace::{
17        BatchConfig, BatchSpanProcessor, RandomIdGenerator, Sampler, SpanLimits,
18        TracerProvider,
19    },
20    Resource,
21};
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::time::Duration;
25
26/// OTLP protocol type.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum OtlpProtocol {
30    /// gRPC protocol
31    Grpc,
32    /// HTTP protocol
33    Http,
34}
35
36impl Default for OtlpProtocol {
37    fn default() -> Self {
38        Self::Grpc
39    }
40}
41
42/// Sampling strategy for traces.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(tag = "type", rename_all = "snake_case")]
45pub enum SamplingStrategy {
46    /// Always sample (sample all traces).
47    AlwaysOn,
48    /// Never sample (drop all traces).
49    AlwaysOff,
50    /// Sample based on trace ID ratio.
51    TraceIdRatio { ratio: f64 },
52    /// Parent-based sampling (follow parent's sampling decision).
53    ParentBased {
54        #[serde(flatten)]
55        root: Box<SamplingStrategy>,
56    },
57}
58
59impl Default for SamplingStrategy {
60    fn default() -> Self {
61        Self::ParentBased {
62            root: Box::new(Self::TraceIdRatio { ratio: 1.0 }),
63        }
64    }
65}
66
67impl SamplingStrategy {
68    /// Converts the strategy to an OpenTelemetry sampler.
69    pub fn to_sampler(&self) -> Sampler {
70        match self {
71            Self::AlwaysOn => Sampler::AlwaysOn,
72            Self::AlwaysOff => Sampler::AlwaysOff,
73            Self::TraceIdRatio { ratio } => Sampler::TraceIdRatioBased(*ratio),
74            Self::ParentBased { root } => Sampler::ParentBased(Box::new(root.to_sampler())),
75        }
76    }
77}
78
79/// Batch configuration for span export.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct BatchConfiguration {
82    /// Maximum queue size for batching.
83    #[serde(default = "default_max_queue_size")]
84    pub max_queue_size: usize,
85    /// Maximum batch size.
86    #[serde(default = "default_max_export_batch_size")]
87    pub max_export_batch_size: usize,
88    /// Maximum time to wait before exporting.
89    #[serde(with = "humantime_serde", default = "default_scheduled_delay")]
90    pub scheduled_delay: Duration,
91    /// Maximum time to wait for export to complete.
92    #[serde(with = "humantime_serde", default = "default_export_timeout")]
93    pub export_timeout: Duration,
94}
95
96fn default_max_queue_size() -> usize {
97    2048
98}
99
100fn default_max_export_batch_size() -> usize {
101    512
102}
103
104fn default_scheduled_delay() -> Duration {
105    Duration::from_secs(5)
106}
107
108fn default_export_timeout() -> Duration {
109    Duration::from_secs(30)
110}
111
112impl Default for BatchConfiguration {
113    fn default() -> Self {
114        Self {
115            max_queue_size: default_max_queue_size(),
116            max_export_batch_size: default_max_export_batch_size(),
117            scheduled_delay: default_scheduled_delay(),
118            export_timeout: default_export_timeout(),
119        }
120    }
121}
122
123impl From<BatchConfiguration> for BatchConfig {
124    fn from(_config: BatchConfiguration) -> Self {
125        // Note: BatchConfig API changed in opentelemetry_sdk 0.24
126        // TODO: Update to use new BatchConfigBuilder API
127        BatchConfig::default()
128    }
129}
130
131/// Trace provider configuration.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct TraceConfig {
134    /// Whether tracing is enabled.
135    #[serde(default = "default_enabled")]
136    pub enabled: bool,
137
138    /// Service name.
139    pub service_name: String,
140
141    /// Service version.
142    #[serde(default = "default_service_version")]
143    pub service_version: String,
144
145    /// OTLP endpoint (e.g., "http://localhost:4317").
146    #[serde(default = "default_otlp_endpoint")]
147    pub otlp_endpoint: String,
148
149    /// OTLP protocol (grpc or http).
150    #[serde(default)]
151    pub otlp_protocol: OtlpProtocol,
152
153    /// Sampling strategy.
154    #[serde(default)]
155    pub sampling: SamplingStrategy,
156
157    /// Batch configuration.
158    #[serde(default)]
159    pub batch_config: BatchConfiguration,
160
161    /// Additional headers for OTLP export.
162    #[serde(default)]
163    pub headers: HashMap<String, String>,
164
165    /// Deployment environment.
166    #[serde(default)]
167    pub environment: Option<String>,
168}
169
170fn default_enabled() -> bool {
171    true
172}
173
174fn default_service_version() -> String {
175    env!("CARGO_PKG_VERSION").to_string()
176}
177
178fn default_otlp_endpoint() -> String {
179    "http://localhost:4317".to_string()
180}
181
182impl Default for TraceConfig {
183    fn default() -> Self {
184        Self {
185            enabled: default_enabled(),
186            service_name: "llm-optimizer-processor".to_string(),
187            service_version: default_service_version(),
188            otlp_endpoint: default_otlp_endpoint(),
189            otlp_protocol: OtlpProtocol::default(),
190            sampling: SamplingStrategy::default(),
191            batch_config: BatchConfiguration::default(),
192            headers: HashMap::new(),
193            environment: None,
194        }
195    }
196}
197
198/// Initializes the global trace provider with OTLP exporter.
199///
200/// # Errors
201///
202/// Returns an error if the trace provider cannot be initialized.
203pub fn init_tracer_provider(config: TraceConfig) -> Result<TracerProvider, TraceError> {
204    if !config.enabled {
205        tracing::info!("OpenTelemetry tracing is disabled");
206        return Err(TraceError::Other("tracing disabled".into()));
207    }
208
209    // Set up trace context propagator
210    global::set_text_map_propagator(TraceContextPropagator::new());
211
212    // Build resource
213    let resource = ResourceBuilder::new()
214        .with_service_name(config.service_name.clone())
215        .with_service_version(config.service_version.clone())
216        .with_attribute("service.component", "stream-processor");
217
218    let resource = if let Some(env) = config.environment.clone() {
219        resource.with_deployment_environment(env)
220    } else {
221        resource
222    };
223
224    let resource = resource.build();
225
226    // Create OTLP exporter
227    let exporter = match config.otlp_protocol {
228        OtlpProtocol::Grpc => {
229            let mut exporter = opentelemetry_otlp::new_exporter()
230                .tonic()
231                .with_endpoint(&config.otlp_endpoint);
232
233            // Add custom headers
234            if !config.headers.is_empty() {
235                let mut metadata = tonic::metadata::MetadataMap::new();
236                for (key, value) in &config.headers {
237                    if let (Ok(k), Ok(v)) = (
238                        key.parse::<tonic::metadata::MetadataKey<_>>(),
239                        value.parse::<tonic::metadata::MetadataValue<_>>(),
240                    ) {
241                        metadata.insert(k, v);
242                    }
243                }
244                exporter = exporter.with_metadata(metadata);
245            }
246
247            opentelemetry_otlp::new_pipeline()
248                .tracing()
249                .with_exporter(exporter)
250                .with_trace_config(
251                    opentelemetry_sdk::trace::Config::default()
252                        .with_sampler(config.sampling.to_sampler())
253                        .with_id_generator(RandomIdGenerator::default())
254                        .with_resource(resource),
255                )
256                .with_batch_config(config.batch_config.into())
257                .install_batch(runtime::Tokio)?
258        }
259        OtlpProtocol::Http => {
260            // Note: HTTP exporter API changed in opentelemetry-otlp 0.17
261            // For now, falling back to gRPC. TODO: Implement HTTP support with new API
262            opentelemetry_otlp::new_pipeline()
263                .tracing()
264                .with_exporter(
265                    opentelemetry_otlp::new_exporter()
266                        .tonic()
267                        .with_endpoint(&config.otlp_endpoint),
268                )
269                .with_trace_config(
270                    opentelemetry_sdk::trace::Config::default()
271                        .with_sampler(config.sampling.to_sampler())
272                        .with_id_generator(RandomIdGenerator::default())
273                        .with_resource(resource),
274                )
275                .with_batch_config(config.batch_config.into())
276                .install_batch(runtime::Tokio)?
277        }
278    };
279
280    Ok(exporter)
281}
282
283/// Shuts down the global trace provider and flushes all pending spans.
284pub async fn shutdown_tracer_provider() {
285    global::shutdown_tracer_provider();
286}
287
288/// Span builder helper for creating instrumented spans.
289pub struct SpanBuilder {
290    name: String,
291    kind: SpanKind,
292    attributes: Vec<KeyValue>,
293}
294
295impl SpanBuilder {
296    /// Creates a new span builder with the given name.
297    pub fn new(name: impl Into<String>) -> Self {
298        Self {
299            name: name.into(),
300            kind: SpanKind::Internal,
301            attributes: Vec::new(),
302        }
303    }
304
305    /// Sets the span kind.
306    pub fn with_kind(mut self, kind: SpanKind) -> Self {
307        self.kind = kind;
308        self
309    }
310
311    /// Adds an attribute to the span.
312    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
313        self.attributes
314            .push(KeyValue::new(key.into(), value.into()));
315        self
316    }
317
318    /// Adds multiple attributes to the span.
319    pub fn with_attributes(mut self, attributes: Vec<KeyValue>) -> Self {
320        self.attributes.extend(attributes);
321        self
322    }
323
324    /// Starts the span using the global tracer.
325    pub fn start(self) -> impl opentelemetry::trace::Span {
326        let tracer = global::tracer("llm-optimizer-processor");
327        let span = tracer
328            .span_builder(self.name)
329            .with_kind(self.kind)
330            .with_attributes(self.attributes)
331            .start(&tracer);
332        span
333    }
334
335    /// Starts the span with a custom tracer.
336    pub fn start_with_tracer<T: Tracer>(
337        self,
338        tracer: &T,
339    ) -> T::Span {
340        tracer
341            .span_builder(self.name)
342            .with_kind(self.kind)
343            .with_attributes(self.attributes)
344            .start(tracer)
345    }
346}
347
348/// Helper macro for creating instrumented spans.
349///
350/// # Example
351///
352/// ```ignore
353/// use crate::telemetry::span;
354///
355/// let span = span!("process_event", kind = SpanKind::Internal, "event.type" => "metric");
356/// ```
357#[macro_export]
358macro_rules! span {
359    ($name:expr) => {
360        $crate::telemetry::SpanBuilder::new($name).start()
361    };
362    ($name:expr, kind = $kind:expr) => {
363        $crate::telemetry::SpanBuilder::new($name)
364            .with_kind($kind)
365            .start()
366    };
367    ($name:expr, $($key:expr => $value:expr),+ $(,)?) => {
368        {
369            let mut builder = $crate::telemetry::SpanBuilder::new($name);
370            $(
371                builder = builder.with_attribute($key, $value);
372            )+
373            builder.start()
374        }
375    };
376    ($name:expr, kind = $kind:expr, $($key:expr => $value:expr),+ $(,)?) => {
377        {
378            let mut builder = $crate::telemetry::SpanBuilder::new($name)
379                .with_kind($kind);
380            $(
381                builder = builder.with_attribute($key, $value);
382            )+
383            builder.start()
384        }
385    };
386}
387
388/// Records an exception on a span.
389pub fn record_exception<T: opentelemetry::trace::Span>(
390    span: &mut T,
391    error: &dyn std::error::Error,
392) {
393    span.record_error(error);
394    span.set_status(Status::error(error.to_string()));
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_sampling_strategy_default() {
403        let strategy = SamplingStrategy::default();
404        let sampler = strategy.to_sampler();
405        // Default should be parent-based with always-on root
406        assert!(matches!(sampler, Sampler::ParentBased(_)));
407    }
408
409    #[test]
410    fn test_sampling_strategy_always_on() {
411        let strategy = SamplingStrategy::AlwaysOn;
412        let sampler = strategy.to_sampler();
413        assert!(matches!(sampler, Sampler::AlwaysOn));
414    }
415
416    #[test]
417    fn test_sampling_strategy_trace_id_ratio() {
418        let strategy = SamplingStrategy::TraceIdRatio { ratio: 0.5 };
419        let sampler = strategy.to_sampler();
420        assert!(matches!(sampler, Sampler::TraceIdRatioBased(_)));
421    }
422
423    #[test]
424    fn test_batch_config_default() {
425        let config = BatchConfiguration::default();
426        assert_eq!(config.max_queue_size, 2048);
427        assert_eq!(config.max_export_batch_size, 512);
428    }
429
430    #[test]
431    fn test_trace_config_default() {
432        let config = TraceConfig::default();
433        assert!(config.enabled);
434        assert_eq!(config.service_name, "llm-optimizer-processor");
435        assert_eq!(config.otlp_endpoint, "http://localhost:4317");
436        assert_eq!(config.otlp_protocol, OtlpProtocol::Grpc);
437    }
438
439    #[test]
440    fn test_span_builder() {
441        let builder = SpanBuilder::new("test-span")
442            .with_kind(SpanKind::Server)
443            .with_attribute("test.key", "test.value");
444
445        assert_eq!(builder.name, "test-span");
446        assert_eq!(builder.kind, SpanKind::Server);
447        assert_eq!(builder.attributes.len(), 1);
448    }
449
450    #[test]
451    fn test_otlp_protocol_serialization() {
452        let grpc = OtlpProtocol::Grpc;
453        let http = OtlpProtocol::Http;
454
455        let grpc_json = serde_json::to_string(&grpc).unwrap();
456        let http_json = serde_json::to_string(&http).unwrap();
457
458        assert_eq!(grpc_json, "\"grpc\"");
459        assert_eq!(http_json, "\"http\"");
460    }
461}