/home/noah/src/realizar/src/observability.rs
Line | Count | Source |
1 | | //! Observability Module for Production Monitoring |
2 | | //! |
3 | | //! Provides comprehensive observability features per spec ยง7: |
4 | | //! - Trueno-DB metrics integration for time-series storage |
5 | | //! - Renacer tracing for distributed request tracking |
6 | | //! - A/B testing support for model comparison |
7 | | //! |
8 | | //! ## Example |
9 | | //! |
10 | | //! ```rust,ignore |
11 | | //! use realizar::observability::{ObservabilityConfig, Observer, ABTest}; |
12 | | //! |
13 | | //! let config = ObservabilityConfig::new() |
14 | | //! .with_trueno_db("trueno-db://metrics") |
15 | | //! .with_tracing(true); |
16 | | //! |
17 | | //! let observer = Observer::new(config); |
18 | | //! |
19 | | //! // Record inference metrics |
20 | | //! observer.record_inference("model-v1", 150, 32, Duration::from_millis(45)); |
21 | | //! |
22 | | //! // A/B testing |
23 | | //! let ab_test = ABTest::new("model-comparison") |
24 | | //! .with_variant("control", "model-v1", 0.5) |
25 | | //! .with_variant("treatment", "model-v2", 0.5); |
26 | | //! let variant = ab_test.select("user-123"); |
27 | | //! ``` |
28 | | |
29 | | use std::{ |
30 | | collections::HashMap, |
31 | | sync::{ |
32 | | atomic::{AtomicU64, Ordering}, |
33 | | Arc, RwLock, |
34 | | }, |
35 | | time::{Duration, SystemTime, UNIX_EPOCH}, |
36 | | }; |
37 | | |
38 | | use serde::{Deserialize, Serialize}; |
39 | | |
40 | | // ============================================================================ |
41 | | // W3C Trace Context (per OpenTelemetry specification) |
42 | | // ============================================================================ |
43 | | |
44 | | /// W3C Trace Context for distributed tracing |
45 | | /// Implements <https://www.w3.org/TR/trace-context/> |
46 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
47 | | pub struct TraceContext { |
48 | | /// Trace ID (32 hex chars, 16 bytes) |
49 | | pub trace_id: String, |
50 | | /// Parent Span ID (16 hex chars, 8 bytes) |
51 | | pub parent_span_id: Option<String>, |
52 | | /// Trace flags (sampled, etc.) |
53 | | pub trace_flags: u8, |
54 | | /// Trace state (vendor-specific data) |
55 | | pub trace_state: Option<String>, |
56 | | } |
57 | | |
58 | | impl TraceContext { |
59 | | /// Create a new trace context with a fresh trace ID |
60 | | #[must_use] |
61 | 5 | pub fn new() -> Self { |
62 | 5 | Self { |
63 | 5 | trace_id: generate_trace_id(), |
64 | 5 | parent_span_id: None, |
65 | 5 | trace_flags: 0x01, // Sampled |
66 | 5 | trace_state: None, |
67 | 5 | } |
68 | 5 | } |
69 | | |
70 | | /// Create child context with new span ID |
71 | | #[must_use] |
72 | 1 | pub fn child(&self, parent_span_id: &str) -> Self { |
73 | 1 | Self { |
74 | 1 | trace_id: self.trace_id.clone(), |
75 | 1 | parent_span_id: Some(parent_span_id.to_string()), |
76 | 1 | trace_flags: self.trace_flags, |
77 | 1 | trace_state: self.trace_state.clone(), |
78 | 1 | } |
79 | 1 | } |
80 | | |
81 | | /// Parse from W3C traceparent header |
82 | | /// Format: {version}-{trace_id}-{parent_id}-{flags} |
83 | | /// Example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 |
84 | | #[must_use] |
85 | 6 | pub fn from_traceparent(header: &str) -> Option<Self> { |
86 | 6 | let parts: Vec<&str> = header.split('-').collect(); |
87 | 6 | if parts.len() != 4 { |
88 | 2 | return None; |
89 | 4 | } |
90 | | |
91 | 4 | let version = parts[0]; |
92 | 4 | if version != "00" { |
93 | 1 | return None; // Only version 00 supported |
94 | 3 | } |
95 | | |
96 | 3 | let trace_id = parts[1]; |
97 | 3 | let parent_span_id = parts[2]; |
98 | 3 | let flags = u8::from_str_radix(parts[3], 16).ok()?0 ; |
99 | | |
100 | | // Validate lengths |
101 | 3 | if trace_id.len() != 32 || parent_span_id.len() != 162 { |
102 | 1 | return None; |
103 | 2 | } |
104 | | |
105 | 2 | Some(Self { |
106 | 2 | trace_id: trace_id.to_string(), |
107 | 2 | parent_span_id: Some(parent_span_id.to_string()), |
108 | 2 | trace_flags: flags, |
109 | 2 | trace_state: None, |
110 | 2 | }) |
111 | 6 | } |
112 | | |
113 | | /// Generate W3C traceparent header |
114 | | #[must_use] |
115 | 1 | pub fn to_traceparent(&self, span_id: &str) -> String { |
116 | 1 | format!("00-{}-{}-{:02x}", self.trace_id, span_id, self.trace_flags) |
117 | 1 | } |
118 | | |
119 | | /// Set tracestate header value |
120 | | #[must_use] |
121 | 1 | pub fn with_tracestate(mut self, state: impl Into<String>) -> Self { |
122 | 1 | self.trace_state = Some(state.into()); |
123 | 1 | self |
124 | 1 | } |
125 | | |
126 | | /// Check if trace is sampled |
127 | | #[must_use] |
128 | 4 | pub fn is_sampled(&self) -> bool { |
129 | 4 | self.trace_flags & 0x01 != 0 |
130 | 4 | } |
131 | | |
132 | | /// Set sampled flag |
133 | 2 | pub fn set_sampled(&mut self, sampled: bool) { |
134 | 2 | if sampled { |
135 | 1 | self.trace_flags |= 0x01; |
136 | 1 | } else { |
137 | 1 | self.trace_flags &= !0x01; |
138 | 1 | } |
139 | 2 | } |
140 | | } |
141 | | |
142 | | impl Default for TraceContext { |
143 | 1 | fn default() -> Self { |
144 | 1 | Self::new() |
145 | 1 | } |
146 | | } |
147 | | |
148 | | // ============================================================================ |
149 | | // Latency Histogram for Percentile Calculations |
150 | | // ============================================================================ |
151 | | |
152 | | /// Histogram for tracking latency distributions and calculating percentiles |
153 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
154 | | pub struct LatencyHistogram { |
155 | | /// Bucket boundaries in microseconds |
156 | | buckets: Vec<u64>, |
157 | | /// Count per bucket |
158 | | counts: Vec<u64>, |
159 | | /// Total count |
160 | | total: u64, |
161 | | /// Sum of all values (for mean calculation) |
162 | | sum: u64, |
163 | | /// Min value seen |
164 | | min: Option<u64>, |
165 | | /// Max value seen |
166 | | max: Option<u64>, |
167 | | } |
168 | | |
169 | | impl LatencyHistogram { |
170 | | /// Create histogram with default buckets (exponential: 1ms to 60s) |
171 | | #[must_use] |
172 | 8 | pub fn new() -> Self { |
173 | | // Buckets: 1ms, 2ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s, 30s, 60s |
174 | 8 | let buckets = vec![ |
175 | | 1_000, // 1ms |
176 | | 2_000, // 2ms |
177 | | 5_000, // 5ms |
178 | | 10_000, // 10ms |
179 | | 25_000, // 25ms |
180 | | 50_000, // 50ms |
181 | | 100_000, // 100ms |
182 | | 250_000, // 250ms |
183 | | 500_000, // 500ms |
184 | | 1_000_000, // 1s |
185 | | 2_500_000, // 2.5s |
186 | | 5_000_000, // 5s |
187 | | 10_000_000, // 10s |
188 | | 30_000_000, // 30s |
189 | | 60_000_000, // 60s |
190 | | ]; |
191 | 8 | let counts = vec![0; buckets.len() + 1]; // +1 for overflow bucket |
192 | 8 | Self { |
193 | 8 | buckets, |
194 | 8 | counts, |
195 | 8 | total: 0, |
196 | 8 | sum: 0, |
197 | 8 | min: None, |
198 | 8 | max: None, |
199 | 8 | } |
200 | 8 | } |
201 | | |
202 | | /// Create histogram with custom buckets (in microseconds) |
203 | | #[must_use] |
204 | 1 | pub fn with_buckets(mut buckets: Vec<u64>) -> Self { |
205 | 1 | buckets.sort_unstable(); |
206 | 1 | let counts = vec![0; buckets.len() + 1]; |
207 | 1 | Self { |
208 | 1 | buckets, |
209 | 1 | counts, |
210 | 1 | total: 0, |
211 | 1 | sum: 0, |
212 | 1 | min: None, |
213 | 1 | max: None, |
214 | 1 | } |
215 | 1 | } |
216 | | |
217 | | /// Record a latency value in microseconds |
218 | 114 | pub fn observe(&mut self, value_us: u64) { |
219 | 114 | self.total += 1; |
220 | 114 | self.sum += value_us; |
221 | | |
222 | | // Update min/max |
223 | 114 | self.min = Some(self.min.map_or(value_us, |m| m107 .min107 (value_us107 ))); |
224 | 114 | self.max = Some(self.max.map_or(value_us, |m| m107 .max107 (value_us107 ))); |
225 | | |
226 | | // Find bucket |
227 | 643 | let bucket_idx114 = self.buckets.iter()114 .position114 (|&b| value_us <= b); |
228 | 114 | match bucket_idx { |
229 | 113 | Some(idx) => self.counts[idx] += 1, |
230 | 1 | None => *self.counts.last_mut().unwrap_or(&mut 0) += 1, |
231 | | } |
232 | 114 | } |
233 | | |
234 | | /// Record latency from Duration |
235 | 1 | pub fn observe_duration(&mut self, duration: Duration) { |
236 | 1 | self.observe(duration.as_micros() as u64); |
237 | 1 | } |
238 | | |
239 | | /// Get percentile value (0-100) |
240 | | #[must_use] |
241 | 9 | pub fn percentile(&self, p: f64) -> Option<u64> { |
242 | 9 | if self.total == 0 || !5 (0.0..=100.0)5 .contains(&p) { |
243 | 6 | return None; |
244 | 3 | } |
245 | | |
246 | 3 | let target = ((p / 100.0) * self.total as f64).ceil() as u64; |
247 | 3 | let mut cumulative = 0u64; |
248 | | |
249 | 20 | for (i, &count) in self.counts.iter()3 .enumerate3 () { |
250 | 20 | cumulative += count; |
251 | 20 | if cumulative >= target { |
252 | 3 | return if i < self.buckets.len() { |
253 | 3 | Some(self.buckets[i]) |
254 | | } else { |
255 | 0 | self.max // Overflow bucket |
256 | | }; |
257 | 17 | } |
258 | | } |
259 | | |
260 | 0 | self.max |
261 | 9 | } |
262 | | |
263 | | /// Get p50 (median) |
264 | | #[must_use] |
265 | 2 | pub fn p50(&self) -> Option<u64> { |
266 | 2 | self.percentile(50.0) |
267 | 2 | } |
268 | | |
269 | | /// Get p95 |
270 | | #[must_use] |
271 | 2 | pub fn p95(&self) -> Option<u64> { |
272 | 2 | self.percentile(95.0) |
273 | 2 | } |
274 | | |
275 | | /// Get p99 |
276 | | #[must_use] |
277 | 2 | pub fn p99(&self) -> Option<u64> { |
278 | 2 | self.percentile(99.0) |
279 | 2 | } |
280 | | |
281 | | /// Get mean latency |
282 | | #[must_use] |
283 | 2 | pub fn mean(&self) -> Option<f64> { |
284 | 2 | if self.total == 0 { |
285 | 1 | None |
286 | | } else { |
287 | 1 | Some(self.sum as f64 / self.total as f64) |
288 | | } |
289 | 2 | } |
290 | | |
291 | | /// Get total count |
292 | | #[must_use] |
293 | 4 | pub fn count(&self) -> u64 { |
294 | 4 | self.total |
295 | 4 | } |
296 | | |
297 | | /// Get min value |
298 | | #[must_use] |
299 | 4 | pub fn min(&self) -> Option<u64> { |
300 | 4 | self.min |
301 | 4 | } |
302 | | |
303 | | /// Get max value |
304 | | #[must_use] |
305 | 3 | pub fn max_val(&self) -> Option<u64> { |
306 | 3 | self.max |
307 | 3 | } |
308 | | |
309 | | /// Export as Prometheus histogram format |
310 | | #[must_use] |
311 | 1 | pub fn to_prometheus(&self, name: &str, labels: &str) -> String { |
312 | | use std::fmt::Write; |
313 | 1 | let mut output = String::new(); |
314 | | |
315 | | // Bucket counters (cumulative) |
316 | 1 | let mut cumulative = 0u64; |
317 | 15 | for (i, &boundary) in self.buckets.iter()1 .enumerate1 () { |
318 | 15 | cumulative += self.counts[i]; |
319 | 15 | let le = boundary as f64 / 1_000_000.0; // Convert to seconds |
320 | 15 | let _ = writeln!( |
321 | 15 | output, |
322 | 15 | "{name}_bucket{{le=\"{le:.6}\",{labels}}} {cumulative}" |
323 | 15 | ); |
324 | 15 | } |
325 | | // +Inf bucket |
326 | 1 | cumulative += self.counts.last().copied().unwrap_or(0); |
327 | 1 | let _ = writeln!(output, "{name}_bucket{{le=\"+Inf\",{labels}}} {cumulative}"); |
328 | | |
329 | | // Sum and count |
330 | 1 | let sum_secs = self.sum as f64 / 1_000_000.0; |
331 | 1 | let _ = writeln!(output, "{name}_sum{{{labels}}} {sum_secs:.6}"); |
332 | 1 | let _ = writeln!(output, "{name}_count{{{labels}}} {}", self.total); |
333 | | |
334 | 1 | output |
335 | 1 | } |
336 | | } |
337 | | |
338 | | // ============================================================================ |
339 | | // OpenTelemetry-Compatible Span Export |
340 | | // ============================================================================ |
341 | | |
342 | | /// OpenTelemetry-compatible span for export |
343 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
344 | | pub struct OtelSpan { |
345 | | /// Trace ID (hex string) |
346 | | #[serde(rename = "traceId")] |
347 | | pub trace_id: String, |
348 | | /// Span ID (hex string) |
349 | | #[serde(rename = "spanId")] |
350 | | pub span_id: String, |
351 | | /// Parent Span ID (optional) |
352 | | #[serde(rename = "parentSpanId", skip_serializing_if = "Option::is_none")] |
353 | | pub parent_span_id: Option<String>, |
354 | | /// Operation name |
355 | | #[serde(rename = "operationName")] |
356 | | pub operation_name: String, |
357 | | /// Service name |
358 | | #[serde(rename = "serviceName")] |
359 | | pub service_name: String, |
360 | | /// Start time (Unix epoch microseconds) |
361 | | #[serde(rename = "startTimeUnixNano")] |
362 | | pub start_time: u64, |
363 | | /// End time (Unix epoch microseconds) |
364 | | #[serde(rename = "endTimeUnixNano")] |
365 | | pub end_time: u64, |
366 | | /// Span kind |
367 | | #[serde(rename = "kind")] |
368 | | pub kind: SpanKind, |
369 | | /// Status |
370 | | pub status: OtelStatus, |
371 | | /// Attributes |
372 | | pub attributes: Vec<OtelAttribute>, |
373 | | } |
374 | | |
375 | | /// OpenTelemetry span kind |
376 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
377 | | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |
378 | | pub enum SpanKind { |
379 | | /// Internal operation |
380 | | #[default] |
381 | | Internal, |
382 | | /// Server-side of RPC |
383 | | Server, |
384 | | /// Client-side of RPC |
385 | | Client, |
386 | | /// Message producer |
387 | | Producer, |
388 | | /// Message consumer |
389 | | Consumer, |
390 | | } |
391 | | |
392 | | /// OpenTelemetry status |
393 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
394 | | pub struct OtelStatus { |
395 | | /// Status code |
396 | | pub code: OtelStatusCode, |
397 | | /// Optional message |
398 | | #[serde(skip_serializing_if = "Option::is_none")] |
399 | | pub message: Option<String>, |
400 | | } |
401 | | |
402 | | /// OpenTelemetry status code |
403 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
404 | | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |
405 | | pub enum OtelStatusCode { |
406 | | /// Unset |
407 | | #[default] |
408 | | Unset, |
409 | | /// OK |
410 | | Ok, |
411 | | /// Error |
412 | | Error, |
413 | | } |
414 | | |
415 | | /// OpenTelemetry attribute |
416 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
417 | | pub struct OtelAttribute { |
418 | | /// Attribute key |
419 | | pub key: String, |
420 | | /// Attribute value |
421 | | pub value: OtelValue, |
422 | | } |
423 | | |
424 | | /// OpenTelemetry attribute value |
425 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
426 | | #[serde(untagged)] |
427 | | pub enum OtelValue { |
428 | | /// String value |
429 | | String { |
430 | | /// The string value |
431 | | string_value: String, |
432 | | }, |
433 | | /// Integer value |
434 | | Int { |
435 | | /// The integer value |
436 | | int_value: i64, |
437 | | }, |
438 | | /// Float value |
439 | | Float { |
440 | | /// The double/float value |
441 | | double_value: f64, |
442 | | }, |
443 | | /// Boolean value |
444 | | Bool { |
445 | | /// The boolean value |
446 | | bool_value: bool, |
447 | | }, |
448 | | } |
449 | | |
450 | | impl From<&str> for OtelValue { |
451 | 5 | fn from(s: &str) -> Self { |
452 | 5 | OtelValue::String { |
453 | 5 | string_value: s.to_string(), |
454 | 5 | } |
455 | 5 | } |
456 | | } |
457 | | |
458 | | impl From<String> for OtelValue { |
459 | 1 | fn from(s: String) -> Self { |
460 | 1 | OtelValue::String { string_value: s } |
461 | 1 | } |
462 | | } |
463 | | |
464 | | impl From<i64> for OtelValue { |
465 | 1 | fn from(v: i64) -> Self { |
466 | 1 | OtelValue::Int { int_value: v } |
467 | 1 | } |
468 | | } |
469 | | |
470 | | impl From<f64> for OtelValue { |
471 | 1 | fn from(v: f64) -> Self { |
472 | 1 | OtelValue::Float { double_value: v } |
473 | 1 | } |
474 | | } |
475 | | |
476 | | impl From<bool> for OtelValue { |
477 | 1 | fn from(v: bool) -> Self { |
478 | 1 | OtelValue::Bool { bool_value: v } |
479 | 1 | } |
480 | | } |
481 | | |
482 | | // ============================================================================ |
483 | | // OBS-001: Configuration |
484 | | // ============================================================================ |
485 | | |
486 | | /// Observability configuration |
487 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
488 | | pub struct ObservabilityConfig { |
489 | | /// Trueno-DB connection URI for metrics storage |
490 | | pub trueno_db_uri: Option<String>, |
491 | | /// Enable distributed tracing |
492 | | pub tracing_enabled: bool, |
493 | | /// Sampling rate for traces (0.0 - 1.0) |
494 | | pub trace_sample_rate: f64, |
495 | | /// Metrics flush interval in seconds |
496 | | pub flush_interval_secs: u64, |
497 | | /// Enable A/B testing |
498 | | pub ab_testing_enabled: bool, |
499 | | } |
500 | | |
501 | | impl ObservabilityConfig { |
502 | | /// Create new configuration |
503 | | #[must_use] |
504 | 9 | pub fn new() -> Self { |
505 | 9 | Self { |
506 | 9 | trueno_db_uri: None, |
507 | 9 | tracing_enabled: true, |
508 | 9 | trace_sample_rate: 1.0, |
509 | 9 | flush_interval_secs: 60, |
510 | 9 | ab_testing_enabled: true, |
511 | 9 | } |
512 | 9 | } |
513 | | |
514 | | /// Set Trueno-DB URI |
515 | | #[must_use] |
516 | 1 | pub fn with_trueno_db(mut self, uri: impl Into<String>) -> Self { |
517 | 1 | self.trueno_db_uri = Some(uri.into()); |
518 | 1 | self |
519 | 1 | } |
520 | | |
521 | | /// Enable/disable tracing |
522 | | #[must_use] |
523 | 2 | pub fn with_tracing(mut self, enabled: bool) -> Self { |
524 | 2 | self.tracing_enabled = enabled; |
525 | 2 | self |
526 | 2 | } |
527 | | |
528 | | /// Set trace sampling rate |
529 | | #[must_use] |
530 | 2 | pub fn with_sample_rate(mut self, rate: f64) -> Self { |
531 | 2 | self.trace_sample_rate = rate.clamp(0.0, 1.0); |
532 | 2 | self |
533 | 2 | } |
534 | | |
535 | | /// Set flush interval |
536 | | #[must_use] |
537 | 0 | pub fn with_flush_interval(mut self, secs: u64) -> Self { |
538 | 0 | self.flush_interval_secs = secs; |
539 | 0 | self |
540 | 0 | } |
541 | | } |
542 | | |
543 | | // ============================================================================ |
544 | | // OBS-002: Metrics Point |
545 | | // ============================================================================ |
546 | | |
547 | | /// A single metrics data point |
548 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
549 | | pub struct MetricPoint { |
550 | | /// Metric name |
551 | | pub name: String, |
552 | | /// Metric value |
553 | | pub value: f64, |
554 | | /// Timestamp (Unix epoch millis) |
555 | | pub timestamp: u64, |
556 | | /// Labels/tags |
557 | | pub labels: HashMap<String, String>, |
558 | | } |
559 | | |
560 | | impl MetricPoint { |
561 | | /// Create a new metric point |
562 | | #[must_use] |
563 | 6 | pub fn new(name: impl Into<String>, value: f64) -> Self { |
564 | | Self { |
565 | 6 | name: name.into(), |
566 | 6 | value, |
567 | 6 | timestamp: SystemTime::now() |
568 | 6 | .duration_since(UNIX_EPOCH) |
569 | 6 | .map(|d| d.as_millis() as u64) |
570 | 6 | .unwrap_or(0), |
571 | 6 | labels: HashMap::new(), |
572 | | } |
573 | 6 | } |
574 | | |
575 | | /// Add a label |
576 | | #[must_use] |
577 | 7 | pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self { |
578 | 7 | self.labels.insert(key.into(), value.into()); |
579 | 7 | self |
580 | 7 | } |
581 | | |
582 | | /// Format for Trueno-DB line protocol |
583 | | #[must_use] |
584 | 4 | pub fn to_line_protocol(&self) -> String { |
585 | 4 | let labels_str = if self.labels.is_empty() { |
586 | 0 | String::new() |
587 | | } else { |
588 | 4 | let pairs: Vec<String> = self |
589 | 4 | .labels |
590 | 4 | .iter() |
591 | 4 | .map(|(k, v)| format!("{k}={v}")) |
592 | 4 | .collect(); |
593 | 4 | format!(",{}", pairs.join(",")) |
594 | | }; |
595 | | |
596 | 4 | format!( |
597 | 4 | "{}{} value={} {}", |
598 | | self.name, labels_str, self.value, self.timestamp |
599 | | ) |
600 | 4 | } |
601 | | } |
602 | | |
603 | | // ============================================================================ |
604 | | // OBS-003: Tracing Span |
605 | | // ============================================================================ |
606 | | |
607 | | /// A tracing span for distributed request tracking |
608 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
609 | | pub struct Span { |
610 | | /// Unique span ID |
611 | | pub span_id: String, |
612 | | /// Trace ID (shared across spans in same request) |
613 | | pub trace_id: String, |
614 | | /// Parent span ID (if any) |
615 | | pub parent_id: Option<String>, |
616 | | /// Operation name |
617 | | pub operation: String, |
618 | | /// Service name |
619 | | pub service: String, |
620 | | /// Start timestamp (Unix epoch micros) |
621 | | pub start_time: u64, |
622 | | /// Duration in microseconds |
623 | | pub duration_us: Option<u64>, |
624 | | /// Span status |
625 | | pub status: SpanStatus, |
626 | | /// Span attributes |
627 | | pub attributes: HashMap<String, String>, |
628 | | } |
629 | | |
630 | | /// Span status |
631 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
632 | | pub enum SpanStatus { |
633 | | /// Span is in progress |
634 | | #[default] |
635 | | InProgress, |
636 | | /// Span completed successfully |
637 | | Ok, |
638 | | /// Span completed with error |
639 | | Error, |
640 | | } |
641 | | |
642 | | impl Span { |
643 | | /// Create a new span |
644 | | #[must_use] |
645 | 17 | pub fn new(operation: impl Into<String>, trace_id: impl Into<String>) -> Self { |
646 | | Self { |
647 | 17 | span_id: generate_id(), |
648 | 17 | trace_id: trace_id.into(), |
649 | 17 | parent_id: None, |
650 | 17 | operation: operation.into(), |
651 | 17 | service: "realizar".to_string(), |
652 | 17 | start_time: SystemTime::now() |
653 | 17 | .duration_since(UNIX_EPOCH) |
654 | 17 | .map(|d| d.as_micros() as u64) |
655 | 17 | .unwrap_or(0), |
656 | 17 | duration_us: None, |
657 | 17 | status: SpanStatus::InProgress, |
658 | 17 | attributes: HashMap::new(), |
659 | | } |
660 | 17 | } |
661 | | |
662 | | /// Create a child span |
663 | | #[must_use] |
664 | 2 | pub fn child(&self, operation: impl Into<String>) -> Self { |
665 | 2 | let mut span = Self::new(operation, self.trace_id.clone()); |
666 | 2 | span.parent_id = Some(self.span_id.clone()); |
667 | 2 | span |
668 | 2 | } |
669 | | |
670 | | /// Set parent span |
671 | | #[must_use] |
672 | 0 | pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self { |
673 | 0 | self.parent_id = Some(parent_id.into()); |
674 | 0 | self |
675 | 0 | } |
676 | | |
677 | | /// Add attribute |
678 | | #[must_use] |
679 | 2 | pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self { |
680 | 2 | self.attributes.insert(key.into(), value.into()); |
681 | 2 | self |
682 | 2 | } |
683 | | |
684 | | /// End the span with success |
685 | 9 | pub fn end_ok(&mut self) { |
686 | 9 | self.status = SpanStatus::Ok; |
687 | | self.duration_us = Some( |
688 | 9 | SystemTime::now() |
689 | 9 | .duration_since(UNIX_EPOCH) |
690 | 9 | .map(|d| d.as_micros() as u64) |
691 | 9 | .unwrap_or(0) |
692 | 9 | .saturating_sub(self.start_time), |
693 | | ); |
694 | 9 | } |
695 | | |
696 | | /// End the span with error |
697 | 2 | pub fn end_error(&mut self, error: impl Into<String>) { |
698 | 2 | self.status = SpanStatus::Error; |
699 | 2 | self.attributes.insert("error".to_string(), error.into()); |
700 | | self.duration_us = Some( |
701 | 2 | SystemTime::now() |
702 | 2 | .duration_since(UNIX_EPOCH) |
703 | 2 | .map(|d| d.as_micros() as u64) |
704 | 2 | .unwrap_or(0) |
705 | 2 | .saturating_sub(self.start_time), |
706 | | ); |
707 | 2 | } |
708 | | |
709 | | /// Get duration as Duration |
710 | | #[must_use] |
711 | 0 | pub fn duration(&self) -> Option<Duration> { |
712 | 0 | self.duration_us.map(Duration::from_micros) |
713 | 0 | } |
714 | | |
715 | | /// Set span kind |
716 | | #[must_use] |
717 | 1 | pub fn with_kind(mut self, kind: SpanKind) -> Self { |
718 | 1 | self.attributes |
719 | 1 | .insert("span.kind".to_string(), format!("{kind:?}")); |
720 | 1 | self |
721 | 1 | } |
722 | | |
723 | | /// Convert to OpenTelemetry-compatible span format |
724 | | #[must_use] |
725 | 6 | pub fn to_otel(&self) -> OtelSpan { |
726 | 6 | let end_time = self.start_time + self.duration_us.unwrap_or(0); |
727 | | |
728 | 6 | let status = match self.status { |
729 | 5 | SpanStatus::Ok => OtelStatus { |
730 | 5 | code: OtelStatusCode::Ok, |
731 | 5 | message: None, |
732 | 5 | }, |
733 | 1 | SpanStatus::Error => OtelStatus { |
734 | 1 | code: OtelStatusCode::Error, |
735 | 1 | message: self.attributes.get("error").cloned(), |
736 | 1 | }, |
737 | 0 | SpanStatus::InProgress => OtelStatus { |
738 | 0 | code: OtelStatusCode::Unset, |
739 | 0 | message: None, |
740 | 0 | }, |
741 | | }; |
742 | | |
743 | 6 | let attributes: Vec<OtelAttribute> = self |
744 | 6 | .attributes |
745 | 6 | .iter() |
746 | 6 | .map(|(k, v)| OtelAttribute { |
747 | 4 | key: k.clone(), |
748 | 4 | value: OtelValue::from(v.as_str()), |
749 | 4 | }) |
750 | 6 | .collect(); |
751 | | |
752 | 6 | let kind = self |
753 | 6 | .attributes |
754 | 6 | .get("span.kind") |
755 | 6 | .map_or(SpanKind::Internal, |k| match k.as_str()1 { |
756 | 1 | "Server" => SpanKind::Server, |
757 | 0 | "Client" => SpanKind::Client, |
758 | 0 | "Producer" => SpanKind::Producer, |
759 | 0 | "Consumer" => SpanKind::Consumer, |
760 | 0 | _ => SpanKind::Internal, |
761 | 1 | }); |
762 | | |
763 | 6 | OtelSpan { |
764 | 6 | trace_id: self.trace_id.clone(), |
765 | 6 | span_id: self.span_id.clone(), |
766 | 6 | parent_span_id: self.parent_id.clone(), |
767 | 6 | operation_name: self.operation.clone(), |
768 | 6 | service_name: self.service.clone(), |
769 | 6 | start_time: self.start_time * 1000, // Convert to nanoseconds |
770 | 6 | end_time: end_time * 1000, |
771 | 6 | kind, |
772 | 6 | status, |
773 | 6 | attributes, |
774 | 6 | } |
775 | 6 | } |
776 | | |
777 | | /// Get trace context for propagation |
778 | | #[must_use] |
779 | 1 | pub fn trace_context(&self) -> TraceContext { |
780 | 1 | TraceContext { |
781 | 1 | trace_id: self.trace_id.clone(), |
782 | 1 | parent_span_id: Some(self.span_id.clone()), |
783 | 1 | trace_flags: 0x01, // Sampled |
784 | 1 | trace_state: None, |
785 | 1 | } |
786 | 1 | } |
787 | | |
788 | | /// Generate traceparent header for this span |
789 | | #[must_use] |
790 | 1 | pub fn traceparent(&self) -> String { |
791 | 1 | format!("00-{}-{}-01", self.trace_id, self.span_id) |
792 | 1 | } |
793 | | } |
794 | | |
795 | | // ============================================================================ |
796 | | // OBS-004: A/B Testing |
797 | | // ============================================================================ |
798 | | |
799 | | /// A/B test configuration |
800 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
801 | | pub struct ABTest { |
802 | | /// Test name |
803 | | pub name: String, |
804 | | /// Test variants |
805 | | pub variants: Vec<ABVariant>, |
806 | | /// Whether test is active |
807 | | pub active: bool, |
808 | | /// Test start timestamp |
809 | | pub start_time: u64, |
810 | | } |
811 | | |
812 | | /// A/B test variant |
813 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
814 | | pub struct ABVariant { |
815 | | /// Variant name |
816 | | pub name: String, |
817 | | /// Model to use for this variant |
818 | | pub model: String, |
819 | | /// Traffic weight (0.0 - 1.0) |
820 | | pub weight: f64, |
821 | | } |
822 | | |
823 | | impl ABTest { |
824 | | /// Create a new A/B test |
825 | | #[must_use] |
826 | 4 | pub fn new(name: impl Into<String>) -> Self { |
827 | | Self { |
828 | 4 | name: name.into(), |
829 | 4 | variants: Vec::new(), |
830 | | active: true, |
831 | 4 | start_time: SystemTime::now() |
832 | 4 | .duration_since(UNIX_EPOCH) |
833 | 4 | .map(|d| d.as_secs()) |
834 | 4 | .unwrap_or(0), |
835 | | } |
836 | 4 | } |
837 | | |
838 | | /// Add a variant |
839 | | #[must_use] |
840 | 8 | pub fn with_variant( |
841 | 8 | mut self, |
842 | 8 | name: impl Into<String>, |
843 | 8 | model: impl Into<String>, |
844 | 8 | weight: f64, |
845 | 8 | ) -> Self { |
846 | 8 | self.variants.push(ABVariant { |
847 | 8 | name: name.into(), |
848 | 8 | model: model.into(), |
849 | 8 | weight: weight.clamp(0.0, 1.0), |
850 | 8 | }); |
851 | 8 | self |
852 | 8 | } |
853 | | |
854 | | /// Select a variant for a user (deterministic based on user ID) |
855 | | #[must_use] |
856 | 1.00k | pub fn select(&self, user_id: &str) -> Option<&ABVariant> { |
857 | 1.00k | if !self.active || self.variants.is_empty() { |
858 | 0 | return None; |
859 | 1.00k | } |
860 | | |
861 | | // Normalize weights |
862 | 1.00k | let total_weight: f64 = self.variants.iter().map(|v| v.weight).sum(); |
863 | 1.00k | if total_weight <= 0.0 { |
864 | 0 | return self.variants.first(); |
865 | 1.00k | } |
866 | | |
867 | | // Deterministic hash-based selection |
868 | 1.00k | let hash = simple_hash(user_id); |
869 | 1.00k | let selection = (hash as f64 / u64::MAX as f64) * total_weight; |
870 | | |
871 | 1.00k | let mut cumulative = 0.0; |
872 | 1.51k | for variant in &self.variants { |
873 | 1.51k | cumulative += variant.weight; |
874 | 1.51k | if selection < cumulative { |
875 | 1.00k | return Some(variant); |
876 | 512 | } |
877 | | } |
878 | | |
879 | 0 | self.variants.last() |
880 | 1.00k | } |
881 | | |
882 | | /// Check if test is valid (weights sum to ~1.0) |
883 | | #[must_use] |
884 | 2 | pub fn is_valid(&self) -> bool { |
885 | 2 | if self.variants.is_empty() { |
886 | 0 | return false; |
887 | 2 | } |
888 | 2 | let total: f64 = self.variants.iter().map(|v| v.weight).sum(); |
889 | 2 | (total - 1.0).abs() < 0.001 |
890 | 2 | } |
891 | | } |
892 | | |
893 | | /// A/B test result tracking |
894 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
895 | | pub struct ABTestResult { |
896 | | /// Test name |
897 | | pub test_name: String, |
898 | | /// Results per variant |
899 | | pub variants: HashMap<String, VariantResult>, |
900 | | } |
901 | | |
902 | | /// Results for a single variant |
903 | | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
904 | | pub struct VariantResult { |
905 | | /// Number of requests |
906 | | pub requests: u64, |
907 | | /// Number of successes |
908 | | pub successes: u64, |
909 | | /// Total latency (ms) |
910 | | pub total_latency_ms: u64, |
911 | | /// Total tokens generated |
912 | | pub total_tokens: u64, |
913 | | } |
914 | | |
915 | | impl VariantResult { |
916 | | /// Calculate success rate |
917 | | #[must_use] |
918 | 1 | pub fn success_rate(&self) -> f64 { |
919 | 1 | if self.requests == 0 { |
920 | 0 | 0.0 |
921 | | } else { |
922 | 1 | self.successes as f64 / self.requests as f64 |
923 | | } |
924 | 1 | } |
925 | | |
926 | | /// Calculate average latency |
927 | | #[must_use] |
928 | 1 | pub fn avg_latency_ms(&self) -> f64 { |
929 | 1 | if self.requests == 0 { |
930 | 0 | 0.0 |
931 | | } else { |
932 | 1 | self.total_latency_ms as f64 / self.requests as f64 |
933 | | } |
934 | 1 | } |
935 | | |
936 | | /// Calculate tokens per request |
937 | | #[must_use] |
938 | 1 | pub fn tokens_per_request(&self) -> f64 { |
939 | 1 | if self.requests == 0 { |
940 | 0 | 0.0 |
941 | | } else { |
942 | 1 | self.total_tokens as f64 / self.requests as f64 |
943 | | } |
944 | 1 | } |
945 | | } |
946 | | |
947 | | // ============================================================================ |
948 | | // OBS-005: Observer (Main Interface) |
949 | | // ============================================================================ |
950 | | |
951 | | /// Central observability interface |
952 | | #[derive(Debug)] |
953 | | pub struct Observer { |
954 | | /// Configuration |
955 | | config: ObservabilityConfig, |
956 | | /// Metrics buffer |
957 | | metrics_buffer: Arc<RwLock<Vec<MetricPoint>>>, |
958 | | /// Spans buffer |
959 | | spans_buffer: Arc<RwLock<Vec<Span>>>, |
960 | | /// A/B test results |
961 | | ab_results: Arc<RwLock<HashMap<String, ABTestResult>>>, |
962 | | /// Request counter |
963 | | request_counter: AtomicU64, |
964 | | } |
965 | | |
966 | | impl Observer { |
967 | | /// Create a new observer |
968 | | #[must_use] |
969 | 7 | pub fn new(config: ObservabilityConfig) -> Self { |
970 | 7 | Self { |
971 | 7 | config, |
972 | 7 | metrics_buffer: Arc::new(RwLock::new(Vec::new())), |
973 | 7 | spans_buffer: Arc::new(RwLock::new(Vec::new())), |
974 | 7 | ab_results: Arc::new(RwLock::new(HashMap::new())), |
975 | 7 | request_counter: AtomicU64::new(0), |
976 | 7 | } |
977 | 7 | } |
978 | | |
979 | | /// Create with default configuration |
980 | | #[must_use] |
981 | 5 | pub fn default_observer() -> Self { |
982 | 5 | Self::new(ObservabilityConfig::new()) |
983 | 5 | } |
984 | | |
985 | | /// Record an inference metric |
986 | 1 | pub fn record_inference(&self, model: &str, tokens: usize, latency_ms: u64) { |
987 | | // Tokens metric |
988 | 1 | self.record_metric( |
989 | 1 | MetricPoint::new("realizar_tokens_total", tokens as f64).with_label("model", model), |
990 | | ); |
991 | | |
992 | | // Latency metric |
993 | 1 | self.record_metric( |
994 | 1 | MetricPoint::new("realizar_latency_ms", latency_ms as f64).with_label("model", model), |
995 | | ); |
996 | | |
997 | | // Request count |
998 | 1 | self.record_metric( |
999 | 1 | MetricPoint::new("realizar_requests_total", 1.0).with_label("model", model), |
1000 | | ); |
1001 | 1 | } |
1002 | | |
1003 | | /// Record a custom metric |
1004 | 4 | pub fn record_metric(&self, metric: MetricPoint) { |
1005 | 4 | if let Ok(mut buffer) = self.metrics_buffer.write() { |
1006 | 4 | buffer.push(metric); |
1007 | 4 | }0 |
1008 | 4 | } |
1009 | | |
1010 | | /// Create a new trace |
1011 | | #[must_use] |
1012 | 3 | pub fn start_trace(&self, operation: &str) -> Span { |
1013 | 3 | let trace_id = generate_id(); |
1014 | 3 | Span::new(operation, trace_id) |
1015 | 3 | } |
1016 | | |
1017 | | /// Record a completed span |
1018 | 3 | pub fn record_span(&self, span: Span) { |
1019 | 3 | if !self.config.tracing_enabled { |
1020 | 1 | return; |
1021 | 2 | } |
1022 | | |
1023 | | // Sample based on rate |
1024 | 2 | if self.config.trace_sample_rate < 1.0 { |
1025 | 1 | let sample = simple_hash(&span.span_id) as f64 / u64::MAX as f64; |
1026 | 1 | if sample > self.config.trace_sample_rate { |
1027 | 1 | return; |
1028 | 0 | } |
1029 | 1 | } |
1030 | | |
1031 | 1 | if let Ok(mut buffer) = self.spans_buffer.write() { |
1032 | 1 | buffer.push(span); |
1033 | 1 | }0 |
1034 | 3 | } |
1035 | | |
1036 | | /// Record A/B test result |
1037 | 3 | pub fn record_ab_result( |
1038 | 3 | &self, |
1039 | 3 | test_name: &str, |
1040 | 3 | variant_name: &str, |
1041 | 3 | success: bool, |
1042 | 3 | latency_ms: u64, |
1043 | 3 | tokens: u64, |
1044 | 3 | ) { |
1045 | 3 | if !self.config.ab_testing_enabled { |
1046 | 0 | return; |
1047 | 3 | } |
1048 | | |
1049 | 3 | if let Ok(mut results) = self.ab_results.write() { |
1050 | 3 | let test_result = |
1051 | 3 | results |
1052 | 3 | .entry(test_name.to_string()) |
1053 | 3 | .or_insert_with(|| ABTestResult { |
1054 | 1 | test_name: test_name.to_string(), |
1055 | 1 | variants: HashMap::new(), |
1056 | 1 | }); |
1057 | | |
1058 | 3 | let variant_result = test_result |
1059 | 3 | .variants |
1060 | 3 | .entry(variant_name.to_string()) |
1061 | 3 | .or_default(); |
1062 | | |
1063 | 3 | variant_result.requests += 1; |
1064 | 3 | if success { |
1065 | 2 | variant_result.successes += 1; |
1066 | 2 | }1 |
1067 | 3 | variant_result.total_latency_ms += latency_ms; |
1068 | 3 | variant_result.total_tokens += tokens; |
1069 | 0 | } |
1070 | 3 | } |
1071 | | |
1072 | | /// Get A/B test results |
1073 | | #[must_use] |
1074 | 1 | pub fn get_ab_results(&self, test_name: &str) -> Option<ABTestResult> { |
1075 | 1 | self.ab_results |
1076 | 1 | .read() |
1077 | 1 | .ok() |
1078 | 1 | .and_then(|r| r.get(test_name).cloned()) |
1079 | 1 | } |
1080 | | |
1081 | | /// Flush metrics to Trueno-DB (returns line protocol) |
1082 | 1 | pub fn flush_metrics(&self) -> Vec<String> { |
1083 | 1 | let metrics = if let Ok(mut buffer) = self.metrics_buffer.write() { |
1084 | 1 | std::mem::take(&mut *buffer) |
1085 | | } else { |
1086 | 0 | Vec::new() |
1087 | | }; |
1088 | | |
1089 | 1 | metrics.iter().map(MetricPoint::to_line_protocol).collect() |
1090 | 1 | } |
1091 | | |
1092 | | /// Flush spans |
1093 | 3 | pub fn flush_spans(&self) -> Vec<Span> { |
1094 | 3 | if let Ok(mut buffer) = self.spans_buffer.write() { |
1095 | 3 | std::mem::take(&mut *buffer) |
1096 | | } else { |
1097 | 0 | Vec::new() |
1098 | | } |
1099 | 3 | } |
1100 | | |
1101 | | /// Get next request ID |
1102 | 3 | pub fn next_request_id(&self) -> u64 { |
1103 | 3 | self.request_counter.fetch_add(1, Ordering::SeqCst) |
1104 | 3 | } |
1105 | | |
1106 | | /// Generate Prometheus-format metrics |
1107 | | #[must_use] |
1108 | 1 | pub fn prometheus_metrics(&self) -> String { |
1109 | | use std::fmt::Write; |
1110 | 1 | let metrics = if let Ok(buffer) = self.metrics_buffer.read() { |
1111 | 1 | buffer.clone() |
1112 | | } else { |
1113 | 0 | Vec::new() |
1114 | | }; |
1115 | | |
1116 | 1 | let mut output = String::new(); |
1117 | 1 | let mut by_name: HashMap<String, Vec<&MetricPoint>> = HashMap::new(); |
1118 | | |
1119 | 2 | for metric1 in &metrics { |
1120 | 1 | by_name.entry(metric.name.clone()).or_default().push(metric); |
1121 | 1 | } |
1122 | | |
1123 | 2 | for (name1 , points1 ) in by_name { |
1124 | 1 | let _ = writeln!(output, "# TYPE {name} gauge"); |
1125 | 2 | for point1 in points { |
1126 | 1 | let labels = if point.labels.is_empty() { |
1127 | 0 | String::new() |
1128 | | } else { |
1129 | 1 | let pairs: Vec<String> = point |
1130 | 1 | .labels |
1131 | 1 | .iter() |
1132 | 1 | .map(|(k, v)| format!("{k}=\"{v}\"")) |
1133 | 1 | .collect(); |
1134 | 1 | format!("{{{}}}", pairs.join(",")) |
1135 | | }; |
1136 | 1 | let _ = writeln!(output, "{name}{labels} {}", point.value); |
1137 | | } |
1138 | | } |
1139 | | |
1140 | 1 | output |
1141 | 1 | } |
1142 | | } |
1143 | | |
1144 | | impl Default for Observer { |
1145 | 0 | fn default() -> Self { |
1146 | 0 | Self::default_observer() |
1147 | 0 | } |
1148 | | } |
1149 | | |
1150 | | // ============================================================================ |
1151 | | // OBS-006: Helper Functions |
1152 | | // ============================================================================ |
1153 | | |
1154 | | /// Generate a unique ID (16 hex chars / 8 bytes for span ID) |
1155 | 22 | fn generate_id() -> String { |
1156 | | use std::{ |
1157 | | collections::hash_map::RandomState, |
1158 | | hash::{BuildHasher, Hasher}, |
1159 | | }; |
1160 | | |
1161 | 22 | let hasher_state = RandomState::new(); |
1162 | 22 | let mut hasher = hasher_state.build_hasher(); |
1163 | 22 | hasher.write_u64( |
1164 | 22 | SystemTime::now() |
1165 | 22 | .duration_since(UNIX_EPOCH) |
1166 | 22 | .map(|d| d.as_nanos() as u64) |
1167 | 22 | .unwrap_or(0), |
1168 | | ); |
1169 | 22 | format!("{:016x}", hasher.finish()) |
1170 | 22 | } |
1171 | | |
1172 | | /// Generate a trace ID (32 hex chars / 16 bytes per W3C spec) |
1173 | 7 | fn generate_trace_id() -> String { |
1174 | | use std::{ |
1175 | | collections::hash_map::RandomState, |
1176 | | hash::{BuildHasher, Hasher}, |
1177 | | }; |
1178 | | |
1179 | 7 | let hasher_state = RandomState::new(); |
1180 | 7 | let now = SystemTime::now() |
1181 | 7 | .duration_since(UNIX_EPOCH) |
1182 | 7 | .map(|d| d.as_nanos() as u64) |
1183 | 7 | .unwrap_or(0); |
1184 | | |
1185 | 7 | let mut hasher1 = hasher_state.build_hasher(); |
1186 | 7 | hasher1.write_u64(now); |
1187 | 7 | let high = hasher1.finish(); |
1188 | | |
1189 | 7 | let mut hasher2 = hasher_state.build_hasher(); |
1190 | 7 | hasher2.write_u64(now.wrapping_add(1)); |
1191 | 7 | let low = hasher2.finish(); |
1192 | | |
1193 | 7 | format!("{high:016x}{low:016x}") |
1194 | 7 | } |
1195 | | |
1196 | | /// Simple hash function for deterministic selection (FNV-1a variant) |
1197 | 1.00k | fn simple_hash(input: &str) -> u64 { |
1198 | | const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; |
1199 | | const FNV_PRIME: u64 = 0x0100_0000_01b3; |
1200 | | |
1201 | 1.00k | let mut hash: u64 = FNV_OFFSET; |
1202 | 7.95k | for byte in input1.00k .bytes1.00k () { |
1203 | 7.95k | hash ^= u64::from(byte); |
1204 | 7.95k | hash = hash.wrapping_mul(FNV_PRIME); |
1205 | 7.95k | } |
1206 | 1.00k | hash |
1207 | 1.00k | } |
1208 | | |
1209 | | // ============================================================================ |
1210 | | // Tests |
1211 | | // ============================================================================ |
1212 | | |
1213 | | #[cfg(test)] |
1214 | | mod tests { |
1215 | | use super::*; |
1216 | | |
1217 | | #[test] |
1218 | 1 | fn test_observability_config_default() { |
1219 | 1 | let config = ObservabilityConfig::new(); |
1220 | 1 | assert!(config.tracing_enabled); |
1221 | 1 | assert!((config.trace_sample_rate - 1.0).abs() < 0.001); |
1222 | 1 | } |
1223 | | |
1224 | | #[test] |
1225 | 1 | fn test_observability_config_builder() { |
1226 | 1 | let config = ObservabilityConfig::new() |
1227 | 1 | .with_trueno_db("trueno-db://localhost:5432") |
1228 | 1 | .with_tracing(false) |
1229 | 1 | .with_sample_rate(0.5); |
1230 | | |
1231 | 1 | assert_eq!( |
1232 | | config.trueno_db_uri, |
1233 | 1 | Some("trueno-db://localhost:5432".to_string()) |
1234 | | ); |
1235 | 1 | assert!(!config.tracing_enabled); |
1236 | 1 | assert!((config.trace_sample_rate - 0.5).abs() < 0.001); |
1237 | 1 | } |
1238 | | |
1239 | | #[test] |
1240 | 1 | fn test_metric_point() { |
1241 | 1 | let metric = MetricPoint::new("test_metric", 42.0) |
1242 | 1 | .with_label("model", "llama3") |
1243 | 1 | .with_label("version", "1.0"); |
1244 | | |
1245 | 1 | assert_eq!(metric.name, "test_metric"); |
1246 | 1 | assert!((metric.value - 42.0).abs() < 0.001); |
1247 | 1 | assert_eq!(metric.labels.get("model"), Some(&"llama3".to_string())); |
1248 | 1 | } |
1249 | | |
1250 | | #[test] |
1251 | 1 | fn test_metric_line_protocol() { |
1252 | 1 | let metric = MetricPoint::new("cpu_usage", 75.5).with_label("host", "server1"); |
1253 | | |
1254 | 1 | let line = metric.to_line_protocol(); |
1255 | 1 | assert!(line.contains("cpu_usage")); |
1256 | 1 | assert!(line.contains("host=server1")); |
1257 | 1 | assert!(line.contains("75.5")); |
1258 | 1 | } |
1259 | | |
1260 | | #[test] |
1261 | 1 | fn test_span_creation() { |
1262 | 1 | let span = Span::new("inference", "trace-123"); |
1263 | | |
1264 | 1 | assert!(!span.span_id.is_empty()); |
1265 | 1 | assert_eq!(span.trace_id, "trace-123"); |
1266 | 1 | assert_eq!(span.operation, "inference"); |
1267 | 1 | assert_eq!(span.status, SpanStatus::InProgress); |
1268 | 1 | } |
1269 | | |
1270 | | #[test] |
1271 | 1 | fn test_span_child() { |
1272 | 1 | let parent = Span::new("request", "trace-456"); |
1273 | 1 | let child = parent.child("tokenize"); |
1274 | | |
1275 | 1 | assert_eq!(child.trace_id, parent.trace_id); |
1276 | 1 | assert_eq!(child.parent_id, Some(parent.span_id.clone())); |
1277 | 1 | assert_eq!(child.operation, "tokenize"); |
1278 | 1 | } |
1279 | | |
1280 | | #[test] |
1281 | 1 | fn test_span_end_ok() { |
1282 | 1 | let mut span = Span::new("test", "trace"); |
1283 | 1 | std::thread::sleep(Duration::from_millis(10)); |
1284 | 1 | span.end_ok(); |
1285 | | |
1286 | 1 | assert_eq!(span.status, SpanStatus::Ok); |
1287 | 1 | assert!(span.duration_us.is_some()); |
1288 | 1 | assert!(span.duration_us.expect("test") >= 10000); // At least 10ms |
1289 | 1 | } |
1290 | | |
1291 | | #[test] |
1292 | 1 | fn test_span_end_error() { |
1293 | 1 | let mut span = Span::new("test", "trace"); |
1294 | 1 | span.end_error("Something went wrong"); |
1295 | | |
1296 | 1 | assert_eq!(span.status, SpanStatus::Error); |
1297 | 1 | assert_eq!( |
1298 | 1 | span.attributes.get("error"), |
1299 | 1 | Some(&"Something went wrong".to_string()) |
1300 | | ); |
1301 | 1 | } |
1302 | | |
1303 | | #[test] |
1304 | 1 | fn test_ab_test_creation() { |
1305 | 1 | let test = ABTest::new("model-comparison") |
1306 | 1 | .with_variant("control", "model-v1", 0.5) |
1307 | 1 | .with_variant("treatment", "model-v2", 0.5); |
1308 | | |
1309 | 1 | assert_eq!(test.name, "model-comparison"); |
1310 | 1 | assert_eq!(test.variants.len(), 2); |
1311 | 1 | assert!(test.is_valid()); |
1312 | 1 | } |
1313 | | |
1314 | | #[test] |
1315 | 1 | fn test_ab_test_selection_deterministic() { |
1316 | 1 | let test = ABTest::new("test") |
1317 | 1 | .with_variant("a", "model-a", 0.5) |
1318 | 1 | .with_variant("b", "model-b", 0.5); |
1319 | | |
1320 | | // Same user should always get same variant |
1321 | 1 | let variant1 = test.select("user-123"); |
1322 | 1 | let variant2 = test.select("user-123"); |
1323 | | |
1324 | 1 | assert_eq!(variant1.map(|v| &v.name), variant2.map(|v| &v.name)); |
1325 | 1 | } |
1326 | | |
1327 | | #[test] |
1328 | 1 | fn test_ab_test_selection_distribution() { |
1329 | 1 | let test = ABTest::new("test") |
1330 | 1 | .with_variant("a", "model-a", 0.5) |
1331 | 1 | .with_variant("b", "model-b", 0.5); |
1332 | | |
1333 | 1 | let mut count_a = 0; |
1334 | 1 | let mut count_b = 0; |
1335 | | |
1336 | 1.00k | for i1.00k in 0..1000 { |
1337 | 1.00k | let user_id = format!("user-{i}"); |
1338 | 1.00k | if let Some(variant) = test.select(&user_id) { |
1339 | 1.00k | if variant.name == "a" { |
1340 | 490 | count_a += 1; |
1341 | 510 | } else { |
1342 | 510 | count_b += 1; |
1343 | 510 | } |
1344 | 0 | } |
1345 | | } |
1346 | | |
1347 | | // Should be roughly 50/50 (within 10% tolerance) |
1348 | 1 | let ratio = count_a as f64 / (count_a + count_b) as f64; |
1349 | 1 | assert!(ratio > 0.4 && ratio < 0.6); |
1350 | 1 | } |
1351 | | |
1352 | | #[test] |
1353 | 1 | fn test_ab_test_invalid_weights() { |
1354 | 1 | let test = ABTest::new("test") |
1355 | 1 | .with_variant("a", "model-a", 0.3) |
1356 | 1 | .with_variant("b", "model-b", 0.3); |
1357 | | |
1358 | 1 | assert!(!test.is_valid()); // Weights sum to 0.6, not 1.0 |
1359 | 1 | } |
1360 | | |
1361 | | #[test] |
1362 | 1 | fn test_variant_result_calculations() { |
1363 | 1 | let result = VariantResult { |
1364 | 1 | requests: 100, |
1365 | 1 | successes: 90, |
1366 | 1 | total_latency_ms: 5000, |
1367 | 1 | total_tokens: 10000, |
1368 | 1 | }; |
1369 | | |
1370 | 1 | assert!((result.success_rate() - 0.9).abs() < 0.001); |
1371 | 1 | assert!((result.avg_latency_ms() - 50.0).abs() < 0.001); |
1372 | 1 | assert!((result.tokens_per_request() - 100.0).abs() < 0.001); |
1373 | 1 | } |
1374 | | |
1375 | | #[test] |
1376 | 1 | fn test_observer_record_inference() { |
1377 | 1 | let observer = Observer::default_observer(); |
1378 | 1 | observer.record_inference("llama3", 100, 50); |
1379 | | |
1380 | 1 | let metrics = observer.flush_metrics(); |
1381 | 1 | assert!(!metrics.is_empty()); |
1382 | 1 | } |
1383 | | |
1384 | | #[test] |
1385 | 1 | fn test_observer_record_span() { |
1386 | 1 | let observer = Observer::default_observer(); |
1387 | 1 | let mut span = observer.start_trace("test-op"); |
1388 | 1 | span.end_ok(); |
1389 | 1 | observer.record_span(span); |
1390 | | |
1391 | 1 | let spans = observer.flush_spans(); |
1392 | 1 | assert_eq!(spans.len(), 1); |
1393 | 1 | assert_eq!(spans[0].operation, "test-op"); |
1394 | 1 | } |
1395 | | |
1396 | | #[test] |
1397 | 1 | fn test_observer_ab_results() { |
1398 | 1 | let observer = Observer::default_observer(); |
1399 | | |
1400 | 1 | observer.record_ab_result("test", "control", true, 50, 100); |
1401 | 1 | observer.record_ab_result("test", "control", true, 60, 120); |
1402 | 1 | observer.record_ab_result("test", "treatment", false, 40, 80); |
1403 | | |
1404 | 1 | let results = observer.get_ab_results("test").expect("test"); |
1405 | 1 | let control = results.variants.get("control").expect("test"); |
1406 | 1 | let treatment = results.variants.get("treatment").expect("test"); |
1407 | | |
1408 | 1 | assert_eq!(control.requests, 2); |
1409 | 1 | assert_eq!(control.successes, 2); |
1410 | 1 | assert_eq!(treatment.requests, 1); |
1411 | 1 | assert_eq!(treatment.successes, 0); |
1412 | 1 | } |
1413 | | |
1414 | | #[test] |
1415 | 1 | fn test_observer_prometheus_format() { |
1416 | 1 | let observer = Observer::default_observer(); |
1417 | 1 | observer.record_metric(MetricPoint::new("test_metric", 42.0).with_label("env", "prod")); |
1418 | | |
1419 | 1 | let prom = observer.prometheus_metrics(); |
1420 | 1 | assert!(prom.contains("test_metric")); |
1421 | 1 | assert!(prom.contains("env=\"prod\"")); |
1422 | 1 | assert!(prom.contains("42")); |
1423 | 1 | } |
1424 | | |
1425 | | #[test] |
1426 | 1 | fn test_observer_request_id() { |
1427 | 1 | let observer = Observer::default_observer(); |
1428 | | |
1429 | 1 | let id1 = observer.next_request_id(); |
1430 | 1 | let id2 = observer.next_request_id(); |
1431 | 1 | let id3 = observer.next_request_id(); |
1432 | | |
1433 | 1 | assert_eq!(id1, 0); |
1434 | 1 | assert_eq!(id2, 1); |
1435 | 1 | assert_eq!(id3, 2); |
1436 | 1 | } |
1437 | | |
1438 | | #[test] |
1439 | 1 | fn test_span_status_default() { |
1440 | 1 | let status = SpanStatus::default(); |
1441 | 1 | assert_eq!(status, SpanStatus::InProgress); |
1442 | 1 | } |
1443 | | |
1444 | | #[test] |
1445 | 1 | fn test_generate_id_unique() { |
1446 | 1 | let id1 = generate_id(); |
1447 | 1 | let id2 = generate_id(); |
1448 | | |
1449 | | // IDs should be unique (though not guaranteed with fast calls) |
1450 | 1 | assert_eq!(id1.len(), 16); |
1451 | 1 | assert_eq!(id2.len(), 16); |
1452 | 1 | } |
1453 | | |
1454 | | #[test] |
1455 | 1 | fn test_simple_hash_deterministic() { |
1456 | 1 | let hash1 = simple_hash("test-input"); |
1457 | 1 | let hash2 = simple_hash("test-input"); |
1458 | 1 | let hash3 = simple_hash("different-input"); |
1459 | | |
1460 | 1 | assert_eq!(hash1, hash2); |
1461 | 1 | assert_ne!(hash1, hash3); |
1462 | 1 | } |
1463 | | |
1464 | | #[test] |
1465 | 1 | fn test_observer_sampling() { |
1466 | 1 | let config = ObservabilityConfig::new().with_sample_rate(0.0); |
1467 | 1 | let observer = Observer::new(config); |
1468 | | |
1469 | 1 | let mut span = observer.start_trace("test"); |
1470 | 1 | span.end_ok(); |
1471 | 1 | observer.record_span(span); |
1472 | | |
1473 | | // With 0% sampling, no spans should be recorded |
1474 | 1 | let spans = observer.flush_spans(); |
1475 | 1 | assert!(spans.is_empty()); |
1476 | 1 | } |
1477 | | |
1478 | | #[test] |
1479 | 1 | fn test_observer_tracing_disabled() { |
1480 | 1 | let config = ObservabilityConfig::new().with_tracing(false); |
1481 | 1 | let observer = Observer::new(config); |
1482 | | |
1483 | 1 | let mut span = observer.start_trace("test"); |
1484 | 1 | span.end_ok(); |
1485 | 1 | observer.record_span(span); |
1486 | | |
1487 | 1 | let spans = observer.flush_spans(); |
1488 | 1 | assert!(spans.is_empty()); |
1489 | 1 | } |
1490 | | |
1491 | | // ========================================================================= |
1492 | | // W3C Trace Context Tests |
1493 | | // ========================================================================= |
1494 | | |
1495 | | #[test] |
1496 | 1 | fn test_trace_context_new() { |
1497 | 1 | let ctx = TraceContext::new(); |
1498 | 1 | assert_eq!(ctx.trace_id.len(), 32); |
1499 | 1 | assert!(ctx.parent_span_id.is_none()); |
1500 | 1 | assert_eq!(ctx.trace_flags, 0x01); // Sampled by default |
1501 | 1 | assert!(ctx.trace_state.is_none()); |
1502 | 1 | } |
1503 | | |
1504 | | #[test] |
1505 | 1 | fn test_trace_context_child() { |
1506 | 1 | let parent_ctx = TraceContext::new(); |
1507 | 1 | let child_ctx = parent_ctx.child("abcdef0123456789"); |
1508 | | |
1509 | 1 | assert_eq!(child_ctx.trace_id, parent_ctx.trace_id); |
1510 | 1 | assert_eq!( |
1511 | | child_ctx.parent_span_id, |
1512 | 1 | Some("abcdef0123456789".to_string()) |
1513 | | ); |
1514 | 1 | assert_eq!(child_ctx.trace_flags, parent_ctx.trace_flags); |
1515 | 1 | } |
1516 | | |
1517 | | #[test] |
1518 | 1 | fn test_trace_context_from_traceparent_valid() { |
1519 | 1 | let header = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; |
1520 | 1 | let ctx = TraceContext::from_traceparent(header).expect("test"); |
1521 | | |
1522 | 1 | assert_eq!(ctx.trace_id, "0af7651916cd43dd8448eb211c80319c"); |
1523 | 1 | assert_eq!(ctx.parent_span_id, Some("b7ad6b7169203331".to_string())); |
1524 | 1 | assert_eq!(ctx.trace_flags, 0x01); |
1525 | 1 | } |
1526 | | |
1527 | | #[test] |
1528 | 1 | fn test_trace_context_from_traceparent_not_sampled() { |
1529 | 1 | let header = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"; |
1530 | 1 | let ctx = TraceContext::from_traceparent(header).expect("test"); |
1531 | | |
1532 | 1 | assert_eq!(ctx.trace_flags, 0x00); |
1533 | 1 | assert!(!ctx.is_sampled()); |
1534 | 1 | } |
1535 | | |
1536 | | #[test] |
1537 | 1 | fn test_trace_context_from_traceparent_invalid_version() { |
1538 | 1 | let header = "01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; |
1539 | 1 | assert!(TraceContext::from_traceparent(header).is_none()); |
1540 | 1 | } |
1541 | | |
1542 | | #[test] |
1543 | 1 | fn test_trace_context_from_traceparent_invalid_format() { |
1544 | 1 | assert!(TraceContext::from_traceparent("invalid").is_none()); |
1545 | 1 | assert!(TraceContext::from_traceparent("00-abc-def-01").is_none()); |
1546 | 1 | assert!(TraceContext::from_traceparent("").is_none()); |
1547 | 1 | } |
1548 | | |
1549 | | #[test] |
1550 | 1 | fn test_trace_context_to_traceparent() { |
1551 | 1 | let ctx = TraceContext { |
1552 | 1 | trace_id: "0af7651916cd43dd8448eb211c80319c".to_string(), |
1553 | 1 | parent_span_id: None, |
1554 | 1 | trace_flags: 0x01, |
1555 | 1 | trace_state: None, |
1556 | 1 | }; |
1557 | | |
1558 | 1 | let traceparent = ctx.to_traceparent("b7ad6b7169203331"); |
1559 | 1 | assert_eq!( |
1560 | | traceparent, |
1561 | | "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" |
1562 | | ); |
1563 | 1 | } |
1564 | | |
1565 | | #[test] |
1566 | 1 | fn test_trace_context_with_tracestate() { |
1567 | 1 | let ctx = TraceContext::new().with_tracestate("vendor=value"); |
1568 | 1 | assert_eq!(ctx.trace_state, Some("vendor=value".to_string())); |
1569 | 1 | } |
1570 | | |
1571 | | #[test] |
1572 | 1 | fn test_trace_context_sampled_flag() { |
1573 | 1 | let mut ctx = TraceContext::new(); |
1574 | 1 | assert!(ctx.is_sampled()); |
1575 | | |
1576 | 1 | ctx.set_sampled(false); |
1577 | 1 | assert!(!ctx.is_sampled()); |
1578 | 1 | assert_eq!(ctx.trace_flags, 0x00); |
1579 | | |
1580 | 1 | ctx.set_sampled(true); |
1581 | 1 | assert!(ctx.is_sampled()); |
1582 | 1 | assert_eq!(ctx.trace_flags, 0x01); |
1583 | 1 | } |
1584 | | |
1585 | | #[test] |
1586 | 1 | fn test_trace_context_default() { |
1587 | 1 | let ctx = TraceContext::default(); |
1588 | 1 | assert_eq!(ctx.trace_id.len(), 32); |
1589 | 1 | } |
1590 | | |
1591 | | // ========================================================================= |
1592 | | // Latency Histogram Tests |
1593 | | // ========================================================================= |
1594 | | |
1595 | | #[test] |
1596 | 1 | fn test_latency_histogram_new() { |
1597 | 1 | let hist = LatencyHistogram::new(); |
1598 | 1 | assert_eq!(hist.count(), 0); |
1599 | 1 | assert!(hist.min().is_none()); |
1600 | 1 | assert!(hist.max_val().is_none()); |
1601 | 1 | assert!(hist.mean().is_none()); |
1602 | 1 | } |
1603 | | |
1604 | | #[test] |
1605 | 1 | fn test_latency_histogram_observe() { |
1606 | 1 | let mut hist = LatencyHistogram::new(); |
1607 | 1 | hist.observe(1000); // 1ms |
1608 | 1 | hist.observe(5000); // 5ms |
1609 | 1 | hist.observe(10000); // 10ms |
1610 | | |
1611 | 1 | assert_eq!(hist.count(), 3); |
1612 | 1 | assert_eq!(hist.min(), Some(1000)); |
1613 | 1 | assert_eq!(hist.max_val(), Some(10000)); |
1614 | 1 | } |
1615 | | |
1616 | | #[test] |
1617 | 1 | fn test_latency_histogram_mean() { |
1618 | 1 | let mut hist = LatencyHistogram::new(); |
1619 | 1 | hist.observe(1000); |
1620 | 1 | hist.observe(2000); |
1621 | 1 | hist.observe(3000); |
1622 | | |
1623 | 1 | let mean = hist.mean().expect("test"); |
1624 | 1 | assert!((mean - 2000.0).abs() < 0.001); |
1625 | 1 | } |
1626 | | |
1627 | | #[test] |
1628 | 1 | fn test_latency_histogram_observe_duration() { |
1629 | 1 | let mut hist = LatencyHistogram::new(); |
1630 | 1 | hist.observe_duration(Duration::from_millis(5)); |
1631 | | |
1632 | 1 | assert_eq!(hist.count(), 1); |
1633 | 1 | assert_eq!(hist.min(), Some(5000)); // 5ms = 5000us |
1634 | 1 | } |
1635 | | |
1636 | | #[test] |
1637 | 1 | fn test_latency_histogram_percentiles() { |
1638 | 1 | let mut hist = LatencyHistogram::new(); |
1639 | | // Add 100 observations: 1ms, 2ms, 3ms, ... 100ms |
1640 | 101 | for i100 in 1..=100 { |
1641 | 100 | hist.observe(i * 1000); |
1642 | 100 | } |
1643 | | |
1644 | | // p50 should be around 50ms |
1645 | 1 | let p50 = hist.p50().expect("test"); |
1646 | 1 | assert!(p50 >= 25_000 && p50 <= 100_000); |
1647 | | |
1648 | | // p95 should be around 95ms |
1649 | 1 | let p95 = hist.p95().expect("test"); |
1650 | 1 | assert!(p95 >= 50000); |
1651 | | |
1652 | | // p99 should be around 99ms |
1653 | 1 | let p99 = hist.p99().expect("test"); |
1654 | 1 | assert!(p99 >= 50000); |
1655 | 1 | } |
1656 | | |
1657 | | #[test] |
1658 | 1 | fn test_latency_histogram_percentile_empty() { |
1659 | 1 | let hist = LatencyHistogram::new(); |
1660 | 1 | assert!(hist.percentile(50.0).is_none()); |
1661 | 1 | assert!(hist.p50().is_none()); |
1662 | 1 | assert!(hist.p95().is_none()); |
1663 | 1 | assert!(hist.p99().is_none()); |
1664 | 1 | } |
1665 | | |
1666 | | #[test] |
1667 | 1 | fn test_latency_histogram_percentile_invalid() { |
1668 | 1 | let mut hist = LatencyHistogram::new(); |
1669 | 1 | hist.observe(1000); |
1670 | | |
1671 | 1 | assert!(hist.percentile(-1.0).is_none()); |
1672 | 1 | assert!(hist.percentile(101.0).is_none()); |
1673 | 1 | } |
1674 | | |
1675 | | #[test] |
1676 | 1 | fn test_latency_histogram_custom_buckets() { |
1677 | 1 | let buckets = vec![100, 500, 1000, 5000, 10000]; |
1678 | 1 | let mut hist = LatencyHistogram::with_buckets(buckets); |
1679 | | |
1680 | 1 | hist.observe(50); // bucket 0 (<=100) |
1681 | 1 | hist.observe(200); // bucket 1 (<=500) |
1682 | 1 | hist.observe(750); // bucket 2 (<=1000) |
1683 | 1 | hist.observe(20000); // overflow bucket |
1684 | | |
1685 | 1 | assert_eq!(hist.count(), 4); |
1686 | 1 | assert_eq!(hist.min(), Some(50)); |
1687 | 1 | assert_eq!(hist.max_val(), Some(20000)); |
1688 | 1 | } |
1689 | | |
1690 | | #[test] |
1691 | 1 | fn test_latency_histogram_to_prometheus() { |
1692 | 1 | let mut hist = LatencyHistogram::new(); |
1693 | 1 | hist.observe(1000); // 1ms |
1694 | 1 | hist.observe(50000); // 50ms |
1695 | | |
1696 | 1 | let prom = hist.to_prometheus("request_latency", "service=\"api\""); |
1697 | | |
1698 | 1 | assert!(prom.contains("request_latency_bucket")); |
1699 | 1 | assert!(prom.contains("le=")); |
1700 | 1 | assert!(prom.contains("service=\"api\"")); |
1701 | 1 | assert!(prom.contains("request_latency_sum")); |
1702 | 1 | assert!(prom.contains("request_latency_count")); |
1703 | 1 | assert!(prom.contains("} 2")); // count = 2 |
1704 | 1 | } |
1705 | | |
1706 | | // ========================================================================= |
1707 | | // OpenTelemetry Export Tests |
1708 | | // ========================================================================= |
1709 | | |
1710 | | #[test] |
1711 | 1 | fn test_span_to_otel_ok() { |
1712 | 1 | let mut span = Span::new("test-op", "trace123456789012345678901234"); |
1713 | 1 | span.end_ok(); |
1714 | | |
1715 | 1 | let otel = span.to_otel(); |
1716 | | |
1717 | 1 | assert_eq!(otel.trace_id, "trace123456789012345678901234"); |
1718 | 1 | assert_eq!(otel.operation_name, "test-op"); |
1719 | 1 | assert_eq!(otel.service_name, "realizar"); |
1720 | 1 | assert_eq!(otel.status.code, OtelStatusCode::Ok); |
1721 | 1 | assert!(otel.status.message.is_none()); |
1722 | 1 | } |
1723 | | |
1724 | | #[test] |
1725 | 1 | fn test_span_to_otel_error() { |
1726 | 1 | let mut span = Span::new("failing-op", "trace123456789012345678901234"); |
1727 | 1 | span.end_error("Connection timeout"); |
1728 | | |
1729 | 1 | let otel = span.to_otel(); |
1730 | | |
1731 | 1 | assert_eq!(otel.status.code, OtelStatusCode::Error); |
1732 | 1 | assert_eq!(otel.status.message, Some("Connection timeout".to_string())); |
1733 | 1 | } |
1734 | | |
1735 | | #[test] |
1736 | 1 | fn test_span_to_otel_with_parent() { |
1737 | 1 | let parent = Span::new("parent-op", "trace123456789012345678901234"); |
1738 | 1 | let mut child = parent.child("child-op"); |
1739 | 1 | child.end_ok(); |
1740 | | |
1741 | 1 | let otel = child.to_otel(); |
1742 | | |
1743 | 1 | assert_eq!(otel.parent_span_id, Some(parent.span_id.clone())); |
1744 | 1 | } |
1745 | | |
1746 | | #[test] |
1747 | 1 | fn test_span_to_otel_with_attributes() { |
1748 | 1 | let mut span = Span::new("test-op", "trace123456789012345678901234") |
1749 | 1 | .with_attribute("model", "llama3") |
1750 | 1 | .with_attribute("tokens", "256"); |
1751 | 1 | span.end_ok(); |
1752 | | |
1753 | 1 | let otel = span.to_otel(); |
1754 | | |
1755 | 1 | assert!(otel.attributes.iter().any(|a| a.key == "model")); |
1756 | 2 | assert!1 (otel.attributes.iter()1 .any1 (|a| a.key == "tokens")); |
1757 | 1 | } |
1758 | | |
1759 | | #[test] |
1760 | 1 | fn test_span_to_otel_with_kind() { |
1761 | 1 | let mut span = |
1762 | 1 | Span::new("server-op", "trace123456789012345678901234").with_kind(SpanKind::Server); |
1763 | 1 | span.end_ok(); |
1764 | | |
1765 | 1 | let otel = span.to_otel(); |
1766 | 1 | assert_eq!(otel.kind, SpanKind::Server); |
1767 | 1 | } |
1768 | | |
1769 | | #[test] |
1770 | 1 | fn test_span_to_otel_timestamps() { |
1771 | 1 | let mut span = Span::new("test-op", "trace123456789012345678901234"); |
1772 | 1 | std::thread::sleep(Duration::from_millis(5)); |
1773 | 1 | span.end_ok(); |
1774 | | |
1775 | 1 | let otel = span.to_otel(); |
1776 | | |
1777 | | // End time should be >= start time |
1778 | 1 | assert!(otel.end_time >= otel.start_time); |
1779 | | // Timestamps should be in nanoseconds |
1780 | 1 | assert!(otel.start_time > 0); |
1781 | 1 | } |
1782 | | |
1783 | | #[test] |
1784 | 1 | fn test_span_trace_context() { |
1785 | 1 | let span = Span::new("test-op", "0af7651916cd43dd8448eb211c80319c"); |
1786 | 1 | let ctx = span.trace_context(); |
1787 | | |
1788 | 1 | assert_eq!(ctx.trace_id, "0af7651916cd43dd8448eb211c80319c"); |
1789 | 1 | assert_eq!(ctx.parent_span_id, Some(span.span_id.clone())); |
1790 | 1 | assert_eq!(ctx.trace_flags, 0x01); |
1791 | 1 | } |
1792 | | |
1793 | | #[test] |
1794 | 1 | fn test_span_traceparent() { |
1795 | 1 | let span = Span::new("test-op", "0af7651916cd43dd8448eb211c80319c"); |
1796 | 1 | let traceparent = span.traceparent(); |
1797 | | |
1798 | 1 | assert!(traceparent.starts_with("00-")); |
1799 | 1 | assert!(traceparent.contains("0af7651916cd43dd8448eb211c80319c")); |
1800 | 1 | assert!(traceparent.ends_with("-01")); |
1801 | | |
1802 | | // Should have format: 00-{trace_id}-{span_id}-01 |
1803 | 1 | let parts: Vec<&str> = traceparent.split('-').collect(); |
1804 | 1 | assert_eq!(parts.len(), 4); |
1805 | 1 | assert_eq!(parts[0], "00"); |
1806 | 1 | assert_eq!(parts[1], "0af7651916cd43dd8448eb211c80319c"); |
1807 | 1 | assert_eq!(parts[3], "01"); |
1808 | 1 | } |
1809 | | |
1810 | | // ========================================================================= |
1811 | | // OtelValue Tests |
1812 | | // ========================================================================= |
1813 | | |
1814 | | #[test] |
1815 | 1 | fn test_otel_value_from_str() { |
1816 | 1 | let val: OtelValue = "test".into(); |
1817 | 1 | match val { |
1818 | 1 | OtelValue::String { string_value } => assert_eq!(string_value, "test"), |
1819 | 0 | _ => panic!("Expected String variant"), |
1820 | | } |
1821 | 1 | } |
1822 | | |
1823 | | #[test] |
1824 | 1 | fn test_otel_value_from_string() { |
1825 | 1 | let val: OtelValue = String::from("test").into(); |
1826 | 1 | match val { |
1827 | 1 | OtelValue::String { string_value } => assert_eq!(string_value, "test"), |
1828 | 0 | _ => panic!("Expected String variant"), |
1829 | | } |
1830 | 1 | } |
1831 | | |
1832 | | #[test] |
1833 | 1 | fn test_otel_value_from_i64() { |
1834 | 1 | let val: OtelValue = 42i64.into(); |
1835 | 1 | match val { |
1836 | 1 | OtelValue::Int { int_value } => assert_eq!(int_value, 42), |
1837 | 0 | _ => panic!("Expected Int variant"), |
1838 | | } |
1839 | 1 | } |
1840 | | |
1841 | | #[test] |
1842 | 1 | fn test_otel_value_from_f64() { |
1843 | 1 | let val: OtelValue = 3.14f64.into(); |
1844 | 1 | match val { |
1845 | 1 | OtelValue::Float { double_value } => assert!((double_value - 3.14).abs() < 0.001), |
1846 | 0 | _ => panic!("Expected Float variant"), |
1847 | | } |
1848 | 1 | } |
1849 | | |
1850 | | #[test] |
1851 | 1 | fn test_otel_value_from_bool() { |
1852 | 1 | let val: OtelValue = true.into(); |
1853 | 1 | match val { |
1854 | 1 | OtelValue::Bool { bool_value } => assert!(bool_value), |
1855 | 0 | _ => panic!("Expected Bool variant"), |
1856 | | } |
1857 | 1 | } |
1858 | | |
1859 | | // ========================================================================= |
1860 | | // SpanKind Tests |
1861 | | // ========================================================================= |
1862 | | |
1863 | | #[test] |
1864 | 1 | fn test_span_kind_default() { |
1865 | 1 | let kind = SpanKind::default(); |
1866 | 1 | assert_eq!(kind, SpanKind::Internal); |
1867 | 1 | } |
1868 | | |
1869 | | #[test] |
1870 | 1 | fn test_otel_status_code_default() { |
1871 | 1 | let code = OtelStatusCode::default(); |
1872 | 1 | assert_eq!(code, OtelStatusCode::Unset); |
1873 | 1 | } |
1874 | | |
1875 | | // ========================================================================= |
1876 | | // Generate Trace ID Tests |
1877 | | // ========================================================================= |
1878 | | |
1879 | | #[test] |
1880 | 1 | fn test_generate_trace_id_length() { |
1881 | 1 | let id = generate_trace_id(); |
1882 | 1 | assert_eq!(id.len(), 32); // 16 bytes = 32 hex chars |
1883 | 1 | } |
1884 | | |
1885 | | #[test] |
1886 | 1 | fn test_generate_trace_id_hex() { |
1887 | 1 | let id = generate_trace_id(); |
1888 | 32 | assert!1 (id.chars()1 .all1 (|c| c.is_ascii_hexdigit())); |
1889 | 1 | } |
1890 | | } |