Skip to main content

fast_telemetry/export/
otlp.rs

1//! OTLP (OpenTelemetry Protocol) export for fast-telemetry metrics.
2//!
3//! Converts fast-telemetry metric types into OTLP protobuf messages for export
4//! via HTTP/protobuf to any OTLP-compatible collector.
5//!
6//! All exports use **cumulative temporality** — values represent running totals
7//! since process start. No state tracking is required between export cycles.
8
9use crate::exp_buckets::ExpBucketsSnapshot;
10use crate::{
11    Counter, Distribution, DynamicCounter, DynamicDistribution, DynamicGauge, DynamicGaugeI64,
12    DynamicHistogram, Gauge, GaugeF64, Histogram, LabelEnum, LabeledCounter, LabeledGauge,
13    LabeledHistogram, LabeledSampledTimer, MaxGauge, MaxGaugeF64, MinGauge, MinGaugeF64,
14    SampledTimer,
15};
16
17/// Re-export proto types so downstream crates (and the derive macro) can reference them.
18pub mod pb {
19    pub use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest;
20    pub use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
21    pub use opentelemetry_proto::tonic::common::v1::{
22        AnyValue, InstrumentationScope, KeyValue, any_value,
23    };
24    pub use opentelemetry_proto::tonic::metrics::v1::{
25        self, AggregationTemporality, ExponentialHistogram as OtlpExpHistogram,
26        ExponentialHistogramDataPoint, Gauge as OtlpGauge, Histogram as OtlpHistogram,
27        HistogramDataPoint, Metric, NumberDataPoint, ResourceMetrics, ScopeMetrics, Sum,
28        exponential_histogram_data_point, metric, number_data_point,
29    };
30    pub use opentelemetry_proto::tonic::resource::v1::Resource;
31    pub use opentelemetry_proto::tonic::trace::v1::{
32        ResourceSpans, ScopeSpans, Span as OtlpSpan, Status as OtlpStatus,
33        span::{Event as OtlpEvent, SpanKind as OtlpSpanKind},
34        status::StatusCode as OtlpStatusCode,
35    };
36}
37
38/// Trait for exporting a metric as OTLP protobuf `Metric` messages.
39///
40/// Each implementation appends one or more `Metric` to the output vec.
41/// Uses cumulative temporality — no state tracking needed.
42///
43/// `time_unix_nano` is a pre-computed timestamp (via [`now_nanos`]) shared
44/// across all data points in one export cycle for consistency.
45pub trait OtlpExport {
46    fn export_otlp(
47        &self,
48        metrics: &mut Vec<pb::Metric>,
49        name: &str,
50        description: &str,
51        time_unix_nano: u64,
52    );
53}
54
55// ============================================================================
56// Helpers
57// ============================================================================
58
59fn make_kv(key: &str, value: &str) -> pb::KeyValue {
60    pb::KeyValue {
61        key: key.to_string(),
62        value: Some(pb::AnyValue {
63            value: Some(pb::any_value::Value::StringValue(value.to_string())),
64        }),
65    }
66}
67
68fn pairs_to_attributes(pairs: &[(String, String)]) -> Vec<pb::KeyValue> {
69    pairs.iter().map(|(k, v)| make_kv(k, v)).collect()
70}
71
72fn label_to_attribute<L: LabelEnum>(label: L) -> pb::KeyValue {
73    make_kv(L::LABEL_NAME, label.variant_name())
74}
75
76/// Returns the current time as nanoseconds since the Unix epoch.
77///
78/// Use this to compute a shared timestamp for a batch of OTLP exports.
79pub fn now_nanos() -> u64 {
80    std::time::SystemTime::now()
81        .duration_since(std::time::UNIX_EPOCH)
82        .unwrap_or_default()
83        .as_nanos() as u64
84}
85
86fn int_data_point(
87    value: i64,
88    attributes: Vec<pb::KeyValue>,
89    time_unix_nano: u64,
90) -> pb::NumberDataPoint {
91    pb::NumberDataPoint {
92        attributes,
93        time_unix_nano,
94        value: Some(pb::number_data_point::Value::AsInt(value)),
95        ..Default::default()
96    }
97}
98
99fn double_data_point(
100    value: f64,
101    attributes: Vec<pb::KeyValue>,
102    time_unix_nano: u64,
103) -> pb::NumberDataPoint {
104    pb::NumberDataPoint {
105        attributes,
106        time_unix_nano,
107        value: Some(pb::number_data_point::Value::AsDouble(value)),
108        ..Default::default()
109    }
110}
111
112/// Convert cumulative bucket counts (as returned by `buckets_cumulative()`) to
113/// OTLP's per-bucket counts and explicit bounds.
114///
115/// OTLP expects non-cumulative bucket counts and omits the +Inf bound from
116/// `explicit_bounds` (it's implied by the final bucket).
117fn cumulative_to_otlp_buckets(cumulative: &[(u64, u64)]) -> (Vec<u64>, Vec<f64>) {
118    cumulative_to_otlp_buckets_iter(cumulative.iter().copied())
119}
120
121fn cumulative_to_otlp_buckets_iter(
122    cumulative: impl IntoIterator<Item = (u64, u64)>,
123) -> (Vec<u64>, Vec<f64>) {
124    let iter = cumulative.into_iter();
125    let (lower, _) = iter.size_hint();
126    let mut bucket_counts = Vec::with_capacity(lower);
127    let mut explicit_bounds = Vec::with_capacity(lower.saturating_sub(1));
128    let mut prev = 0u64;
129
130    for (bound, cum_count) in iter {
131        bucket_counts.push(cum_count.saturating_sub(prev));
132        prev = cum_count;
133        if bound != u64::MAX {
134            explicit_bounds.push(bound as f64);
135        }
136    }
137
138    (bucket_counts, explicit_bounds)
139}
140
141/// Build an OTLP `Resource` with a service name and optional extra attributes.
142pub fn build_resource(service_name: &str, attrs: &[(&str, &str)]) -> pb::Resource {
143    let mut attributes = vec![make_kv("service.name", service_name)];
144    for (k, v) in attrs {
145        attributes.push(make_kv(k, v));
146    }
147    pb::Resource {
148        attributes,
149        ..Default::default()
150    }
151}
152
153/// Wrap a vec of `Metric` into a full `ExportMetricsServiceRequest`.
154///
155/// Takes the resource by reference and clones it into the request.
156pub fn build_export_request(
157    resource: &pb::Resource,
158    scope_name: &str,
159    metrics: Vec<pb::Metric>,
160) -> pb::ExportMetricsServiceRequest {
161    pb::ExportMetricsServiceRequest {
162        resource_metrics: vec![pb::ResourceMetrics {
163            resource: Some(resource.clone()),
164            scope_metrics: vec![pb::ScopeMetrics {
165                scope: Some(pb::InstrumentationScope {
166                    name: scope_name.to_string(),
167                    ..Default::default()
168                }),
169                metrics,
170                ..Default::default()
171            }],
172            ..Default::default()
173        }],
174    }
175}
176
177// ============================================================================
178// OtlpExport implementations
179// ============================================================================
180
181impl OtlpExport for Counter {
182    fn export_otlp(
183        &self,
184        metrics: &mut Vec<pb::Metric>,
185        name: &str,
186        description: &str,
187        time_unix_nano: u64,
188    ) {
189        let value = self.sum() as i64;
190        metrics.push(pb::Metric {
191            name: name.to_string(),
192            description: description.to_string(),
193            data: Some(pb::metric::Data::Sum(pb::Sum {
194                // Counter uses AtomicIsize — callers can add negative values,
195                // so we cannot guarantee monotonicity.
196                data_points: vec![int_data_point(value, Vec::new(), time_unix_nano)],
197                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
198                is_monotonic: false,
199            })),
200            ..Default::default()
201        });
202    }
203}
204
205impl OtlpExport for Gauge {
206    fn export_otlp(
207        &self,
208        metrics: &mut Vec<pb::Metric>,
209        name: &str,
210        description: &str,
211        time_unix_nano: u64,
212    ) {
213        metrics.push(pb::Metric {
214            name: name.to_string(),
215            description: description.to_string(),
216            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge {
217                data_points: vec![int_data_point(self.get(), Vec::new(), time_unix_nano)],
218            })),
219            ..Default::default()
220        });
221    }
222}
223
224impl OtlpExport for GaugeF64 {
225    fn export_otlp(
226        &self,
227        metrics: &mut Vec<pb::Metric>,
228        name: &str,
229        description: &str,
230        time_unix_nano: u64,
231    ) {
232        metrics.push(pb::Metric {
233            name: name.to_string(),
234            description: description.to_string(),
235            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge {
236                data_points: vec![double_data_point(self.get(), Vec::new(), time_unix_nano)],
237            })),
238            ..Default::default()
239        });
240    }
241}
242
243impl OtlpExport for MaxGauge {
244    fn export_otlp(
245        &self,
246        metrics: &mut Vec<pb::Metric>,
247        name: &str,
248        description: &str,
249        time_unix_nano: u64,
250    ) {
251        metrics.push(pb::Metric {
252            name: name.to_string(),
253            description: description.to_string(),
254            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge {
255                data_points: vec![int_data_point(self.get(), Vec::new(), time_unix_nano)],
256            })),
257            ..Default::default()
258        });
259    }
260}
261
262impl OtlpExport for MaxGaugeF64 {
263    fn export_otlp(
264        &self,
265        metrics: &mut Vec<pb::Metric>,
266        name: &str,
267        description: &str,
268        time_unix_nano: u64,
269    ) {
270        metrics.push(pb::Metric {
271            name: name.to_string(),
272            description: description.to_string(),
273            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge {
274                data_points: vec![double_data_point(self.get(), Vec::new(), time_unix_nano)],
275            })),
276            ..Default::default()
277        });
278    }
279}
280
281impl OtlpExport for MinGauge {
282    fn export_otlp(
283        &self,
284        metrics: &mut Vec<pb::Metric>,
285        name: &str,
286        description: &str,
287        time_unix_nano: u64,
288    ) {
289        metrics.push(pb::Metric {
290            name: name.to_string(),
291            description: description.to_string(),
292            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge {
293                data_points: vec![int_data_point(self.get(), Vec::new(), time_unix_nano)],
294            })),
295            ..Default::default()
296        });
297    }
298}
299
300impl OtlpExport for MinGaugeF64 {
301    fn export_otlp(
302        &self,
303        metrics: &mut Vec<pb::Metric>,
304        name: &str,
305        description: &str,
306        time_unix_nano: u64,
307    ) {
308        metrics.push(pb::Metric {
309            name: name.to_string(),
310            description: description.to_string(),
311            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge {
312                data_points: vec![double_data_point(self.get(), Vec::new(), time_unix_nano)],
313            })),
314            ..Default::default()
315        });
316    }
317}
318
319impl OtlpExport for Histogram {
320    fn export_otlp(
321        &self,
322        metrics: &mut Vec<pb::Metric>,
323        name: &str,
324        description: &str,
325        time_unix_nano: u64,
326    ) {
327        let cumulative = self.buckets_cumulative();
328        let count = self.count();
329        let sum = self.sum();
330        let (bucket_counts, explicit_bounds) = cumulative_to_otlp_buckets(&cumulative);
331
332        metrics.push(pb::Metric {
333            name: name.to_string(),
334            description: description.to_string(),
335            data: Some(pb::metric::Data::Histogram(pb::OtlpHistogram {
336                data_points: vec![pb::HistogramDataPoint {
337                    time_unix_nano,
338                    count,
339                    sum: Some(sum as f64),
340                    bucket_counts,
341                    explicit_bounds,
342                    ..Default::default()
343                }],
344                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
345            })),
346            ..Default::default()
347        });
348    }
349}
350
351impl OtlpExport for SampledTimer {
352    fn export_otlp(
353        &self,
354        metrics: &mut Vec<pb::Metric>,
355        name: &str,
356        description: &str,
357        time_unix_nano: u64,
358    ) {
359        let calls_name = format!("{name}.calls");
360        let samples_name = format!("{name}.samples");
361        let calls_description = format!("{description} total calls");
362        let samples_description = format!("{description} sampled latency in nanoseconds");
363        self.calls_metric()
364            .export_otlp(metrics, &calls_name, &calls_description, time_unix_nano);
365        self.histogram()
366            .export_otlp(metrics, &samples_name, &samples_description, time_unix_nano);
367    }
368}
369
370/// Build an OTLP ExponentialHistogramDataPoint from an ExpBucketsSnapshot.
371fn exp_histogram_data_point(
372    snap: &ExpBucketsSnapshot,
373    attributes: Vec<pb::KeyValue>,
374    time_unix_nano: u64,
375) -> pb::ExponentialHistogramDataPoint {
376    // Find the range of non-zero positive buckets to compact the array.
377    let mut first_nonzero: Option<usize> = None;
378    let mut last_nonzero: Option<usize> = None;
379    for (i, &c) in snap.positive.iter().enumerate() {
380        if c > 0 {
381            if first_nonzero.is_none() {
382                first_nonzero = Some(i);
383            }
384            last_nonzero = Some(i);
385        }
386    }
387
388    let positive = match (first_nonzero, last_nonzero) {
389        (Some(first), Some(last)) => {
390            let bucket_counts: Vec<u64> = snap.positive[first..=last].to_vec();
391            Some(pb::exponential_histogram_data_point::Buckets {
392                offset: first as i32,
393                bucket_counts,
394            })
395        }
396        _ => None,
397    };
398
399    pb::ExponentialHistogramDataPoint {
400        attributes,
401        time_unix_nano,
402        count: snap.count,
403        sum: Some(snap.sum as f64),
404        scale: 0, // base-2
405        zero_count: snap.zero_count,
406        positive,
407        negative: None, // u64 values are always non-negative
408        min: snap.min().map(|v| v as f64),
409        max: snap.max().map(|v| v as f64),
410        ..Default::default()
411    }
412}
413
414impl OtlpExport for Distribution {
415    /// Distribution exports as a native OTLP ExponentialHistogram (scale 0, base-2).
416    fn export_otlp(
417        &self,
418        metrics: &mut Vec<pb::Metric>,
419        name: &str,
420        description: &str,
421        time_unix_nano: u64,
422    ) {
423        let snap = self.buckets_snapshot();
424        let dp = exp_histogram_data_point(&snap, Vec::new(), time_unix_nano);
425
426        metrics.push(pb::Metric {
427            name: name.to_string(),
428            description: description.to_string(),
429            data: Some(pb::metric::Data::ExponentialHistogram(
430                pb::OtlpExpHistogram {
431                    data_points: vec![dp],
432                    aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
433                },
434            )),
435            ..Default::default()
436        });
437    }
438}
439
440// ============================================================================
441// Labeled metric implementations
442// ============================================================================
443
444impl<L: LabelEnum> OtlpExport for LabeledCounter<L> {
445    fn export_otlp(
446        &self,
447        metrics: &mut Vec<pb::Metric>,
448        name: &str,
449        description: &str,
450        time_unix_nano: u64,
451    ) {
452        let data_points: Vec<_> = self
453            .iter()
454            .map(|(label, count)| {
455                int_data_point(
456                    count as i64,
457                    vec![label_to_attribute(label)],
458                    time_unix_nano,
459                )
460            })
461            .collect();
462
463        metrics.push(pb::Metric {
464            name: name.to_string(),
465            description: description.to_string(),
466            data: Some(pb::metric::Data::Sum(pb::Sum {
467                data_points,
468                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
469                is_monotonic: false,
470            })),
471            ..Default::default()
472        });
473    }
474}
475
476impl<L: LabelEnum> OtlpExport for LabeledGauge<L> {
477    fn export_otlp(
478        &self,
479        metrics: &mut Vec<pb::Metric>,
480        name: &str,
481        description: &str,
482        time_unix_nano: u64,
483    ) {
484        let data_points: Vec<_> = self
485            .iter()
486            .map(|(label, value)| {
487                int_data_point(value, vec![label_to_attribute(label)], time_unix_nano)
488            })
489            .collect();
490
491        metrics.push(pb::Metric {
492            name: name.to_string(),
493            description: description.to_string(),
494            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge { data_points })),
495            ..Default::default()
496        });
497    }
498}
499
500impl<L: LabelEnum> OtlpExport for LabeledHistogram<L> {
501    fn export_otlp(
502        &self,
503        metrics: &mut Vec<pb::Metric>,
504        name: &str,
505        description: &str,
506        time_unix_nano: u64,
507    ) {
508        let mut data_points = Vec::new();
509
510        for (label, buckets, sum, count) in self.iter() {
511            let attrs = vec![label_to_attribute(label)];
512            let (bucket_counts, explicit_bounds) = cumulative_to_otlp_buckets(&buckets);
513
514            data_points.push(pb::HistogramDataPoint {
515                attributes: attrs,
516                time_unix_nano,
517                count,
518                sum: Some(sum as f64),
519                bucket_counts,
520                explicit_bounds,
521                ..Default::default()
522            });
523        }
524
525        metrics.push(pb::Metric {
526            name: name.to_string(),
527            description: description.to_string(),
528            data: Some(pb::metric::Data::Histogram(pb::OtlpHistogram {
529                data_points,
530                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
531            })),
532            ..Default::default()
533        });
534    }
535}
536
537impl<L: LabelEnum> OtlpExport for LabeledSampledTimer<L> {
538    fn export_otlp(
539        &self,
540        metrics: &mut Vec<pb::Metric>,
541        name: &str,
542        description: &str,
543        time_unix_nano: u64,
544    ) {
545        let calls_name = format!("{name}.calls");
546        let samples_name = format!("{name}.samples");
547        let calls_description = format!("{description} total calls");
548        let samples_description = format!("{description} sampled latency in nanoseconds");
549
550        let mut call_points = Vec::new();
551        let mut sample_points = Vec::new();
552
553        for (label, calls, histogram) in self.iter() {
554            call_points.push(int_data_point(
555                calls.sum() as i64,
556                vec![label_to_attribute(label)],
557                time_unix_nano,
558            ));
559
560            let (bucket_counts, explicit_bounds) =
561                cumulative_to_otlp_buckets(&histogram.buckets_cumulative());
562            sample_points.push(pb::HistogramDataPoint {
563                attributes: vec![label_to_attribute(label)],
564                time_unix_nano,
565                count: histogram.count(),
566                sum: Some(histogram.sum() as f64),
567                bucket_counts,
568                explicit_bounds,
569                ..Default::default()
570            });
571        }
572
573        metrics.push(pb::Metric {
574            name: calls_name,
575            description: calls_description,
576            data: Some(pb::metric::Data::Sum(pb::Sum {
577                data_points: call_points,
578                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
579                is_monotonic: false,
580            })),
581            ..Default::default()
582        });
583
584        metrics.push(pb::Metric {
585            name: samples_name,
586            description: samples_description,
587            data: Some(pb::metric::Data::Histogram(pb::OtlpHistogram {
588                data_points: sample_points,
589                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
590            })),
591            ..Default::default()
592        });
593    }
594}
595
596// ============================================================================
597// Dynamic metric implementations
598// ============================================================================
599
600impl OtlpExport for DynamicCounter {
601    fn export_otlp(
602        &self,
603        metrics: &mut Vec<pb::Metric>,
604        name: &str,
605        description: &str,
606        time_unix_nano: u64,
607    ) {
608        let mut data_points = Vec::new();
609        self.visit_series(|pairs, count| {
610            data_points.push(int_data_point(
611                count as i64,
612                pairs_to_attributes(pairs),
613                time_unix_nano,
614            ));
615        });
616
617        if data_points.is_empty() {
618            return;
619        }
620
621        metrics.push(pb::Metric {
622            name: name.to_string(),
623            description: description.to_string(),
624            data: Some(pb::metric::Data::Sum(pb::Sum {
625                data_points,
626                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
627                is_monotonic: false,
628            })),
629            ..Default::default()
630        });
631    }
632}
633
634impl OtlpExport for DynamicGauge {
635    fn export_otlp(
636        &self,
637        metrics: &mut Vec<pb::Metric>,
638        name: &str,
639        description: &str,
640        time_unix_nano: u64,
641    ) {
642        let mut data_points = Vec::new();
643        self.visit_series(|pairs, value| {
644            data_points.push(double_data_point(
645                value,
646                pairs_to_attributes(pairs),
647                time_unix_nano,
648            ));
649        });
650
651        if data_points.is_empty() {
652            return;
653        }
654
655        metrics.push(pb::Metric {
656            name: name.to_string(),
657            description: description.to_string(),
658            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge { data_points })),
659            ..Default::default()
660        });
661    }
662}
663
664impl OtlpExport for DynamicGaugeI64 {
665    fn export_otlp(
666        &self,
667        metrics: &mut Vec<pb::Metric>,
668        name: &str,
669        description: &str,
670        time_unix_nano: u64,
671    ) {
672        let mut data_points = Vec::new();
673        self.visit_series(|pairs, value| {
674            data_points.push(int_data_point(
675                value,
676                pairs_to_attributes(pairs),
677                time_unix_nano,
678            ));
679        });
680
681        if data_points.is_empty() {
682            return;
683        }
684
685        metrics.push(pb::Metric {
686            name: name.to_string(),
687            description: description.to_string(),
688            data: Some(pb::metric::Data::Gauge(pb::OtlpGauge { data_points })),
689            ..Default::default()
690        });
691    }
692}
693
694impl OtlpExport for DynamicHistogram {
695    fn export_otlp(
696        &self,
697        metrics: &mut Vec<pb::Metric>,
698        name: &str,
699        description: &str,
700        time_unix_nano: u64,
701    ) {
702        let mut data_points = Vec::new();
703
704        self.visit_series(|pairs, series| {
705            let (bucket_counts, explicit_bounds) =
706                cumulative_to_otlp_buckets_iter(series.buckets_cumulative_iter());
707
708            data_points.push(pb::HistogramDataPoint {
709                attributes: pairs_to_attributes(pairs),
710                time_unix_nano,
711                count: series.count(),
712                sum: Some(series.sum() as f64),
713                bucket_counts,
714                explicit_bounds,
715                ..Default::default()
716            });
717        });
718
719        if data_points.is_empty() {
720            return;
721        }
722
723        metrics.push(pb::Metric {
724            name: name.to_string(),
725            description: description.to_string(),
726            data: Some(pb::metric::Data::Histogram(pb::OtlpHistogram {
727                data_points,
728                aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
729            })),
730            ..Default::default()
731        });
732    }
733}
734
735impl OtlpExport for DynamicDistribution {
736    /// Exports as native OTLP ExponentialHistogram (scale 0, base-2) per label set.
737    fn export_otlp(
738        &self,
739        metrics: &mut Vec<pb::Metric>,
740        name: &str,
741        description: &str,
742        time_unix_nano: u64,
743    ) {
744        let mut data_points = Vec::new();
745
746        self.visit_series(|pairs, _count, _sum, snap| {
747            let attrs = pairs_to_attributes(pairs);
748            data_points.push(exp_histogram_data_point(&snap, attrs, time_unix_nano));
749        });
750
751        if data_points.is_empty() {
752            return;
753        }
754
755        metrics.push(pb::Metric {
756            name: name.to_string(),
757            description: description.to_string(),
758            data: Some(pb::metric::Data::ExponentialHistogram(
759                pb::OtlpExpHistogram {
760                    data_points,
761                    aggregation_temporality: pb::AggregationTemporality::Cumulative as i32,
762                },
763            )),
764            ..Default::default()
765        });
766    }
767}
768
769// ============================================================================
770// Trace export
771// ============================================================================
772
773use crate::span::{CompletedSpan, SpanKind, SpanStatus, SpanValue};
774
775impl CompletedSpan {
776    /// Convert this completed span into an OTLP protobuf `Span`.
777    pub fn to_otlp(&self) -> pb::OtlpSpan {
778        let kind = match self.kind {
779            SpanKind::Internal => pb::OtlpSpanKind::Internal,
780            SpanKind::Server => pb::OtlpSpanKind::Server,
781            SpanKind::Client => pb::OtlpSpanKind::Client,
782            SpanKind::Producer => pb::OtlpSpanKind::Producer,
783            SpanKind::Consumer => pb::OtlpSpanKind::Consumer,
784        };
785
786        let status = match &self.status {
787            SpanStatus::Unset => None,
788            SpanStatus::Ok => Some(pb::OtlpStatus {
789                code: pb::OtlpStatusCode::Ok as i32,
790                message: String::new(),
791            }),
792            SpanStatus::Error { message } => Some(pb::OtlpStatus {
793                code: pb::OtlpStatusCode::Error as i32,
794                message: message.to_string(),
795            }),
796        };
797
798        let attributes: Vec<pb::KeyValue> = self
799            .attributes
800            .iter()
801            .map(|attr| {
802                let value = match &attr.value {
803                    SpanValue::String(s) => pb::any_value::Value::StringValue(s.to_string()),
804                    SpanValue::I64(v) => pb::any_value::Value::IntValue(*v),
805                    SpanValue::F64(v) => pb::any_value::Value::DoubleValue(*v),
806                    SpanValue::Bool(v) => pb::any_value::Value::BoolValue(*v),
807                    SpanValue::Uuid(u) => pb::any_value::Value::StringValue(u.to_string()),
808                };
809                pb::KeyValue {
810                    key: attr.key.to_string(),
811                    value: Some(pb::AnyValue { value: Some(value) }),
812                }
813            })
814            .collect();
815
816        let events: Vec<pb::OtlpEvent> = self
817            .events
818            .iter()
819            .map(|evt| {
820                let attrs: Vec<pb::KeyValue> = evt
821                    .attributes
822                    .iter()
823                    .map(|a| {
824                        let v = match &a.value {
825                            SpanValue::String(s) => {
826                                pb::any_value::Value::StringValue(s.to_string())
827                            }
828                            SpanValue::I64(v) => pb::any_value::Value::IntValue(*v),
829                            SpanValue::F64(v) => pb::any_value::Value::DoubleValue(*v),
830                            SpanValue::Bool(v) => pb::any_value::Value::BoolValue(*v),
831                            SpanValue::Uuid(u) => pb::any_value::Value::StringValue(u.to_string()),
832                        };
833                        pb::KeyValue {
834                            key: a.key.to_string(),
835                            value: Some(pb::AnyValue { value: Some(v) }),
836                        }
837                    })
838                    .collect();
839                pb::OtlpEvent {
840                    time_unix_nano: evt.time_ns,
841                    name: evt.name.to_string(),
842                    attributes: attrs,
843                    dropped_attributes_count: 0,
844                }
845            })
846            .collect();
847
848        pb::OtlpSpan {
849            trace_id: self.trace_id.as_bytes().to_vec(),
850            span_id: self.span_id.as_bytes().to_vec(),
851            parent_span_id: if self.parent_span_id.is_invalid() {
852                Vec::new()
853            } else {
854                self.parent_span_id.as_bytes().to_vec()
855            },
856            name: self.name.to_string(),
857            kind: kind as i32,
858            start_time_unix_nano: self.start_time_ns,
859            end_time_unix_nano: self.end_time_ns,
860            attributes,
861            events,
862            status,
863            ..Default::default()
864        }
865    }
866}
867
868/// Wrap a vec of OTLP `Span` protos into a full `ExportTraceServiceRequest`.
869///
870/// Takes the resource by reference and clones it into the request.
871pub fn build_trace_export_request(
872    resource: &pb::Resource,
873    scope_name: &str,
874    spans: Vec<pb::OtlpSpan>,
875) -> pb::ExportTraceServiceRequest {
876    pb::ExportTraceServiceRequest {
877        resource_spans: vec![pb::ResourceSpans {
878            resource: Some(resource.clone()),
879            scope_spans: vec![pb::ScopeSpans {
880                scope: Some(pb::InstrumentationScope {
881                    name: scope_name.to_string(),
882                    ..Default::default()
883                }),
884                spans,
885                ..Default::default()
886            }],
887            ..Default::default()
888        }],
889    }
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895
896    fn test_timestamp() -> u64 {
897        1_000_000_000 // fixed timestamp for deterministic tests
898    }
899
900    #[test]
901    fn test_counter_otlp() {
902        let counter = Counter::new(4);
903        counter.add(42);
904
905        let mut metrics = Vec::new();
906        counter.export_otlp(
907            &mut metrics,
908            "test_counter",
909            "A test counter",
910            test_timestamp(),
911        );
912
913        assert_eq!(metrics.len(), 1);
914        assert_eq!(metrics[0].name, "test_counter");
915        assert_eq!(metrics[0].description, "A test counter");
916
917        let data = metrics[0].data.as_ref().expect("missing data");
918        match data {
919            pb::metric::Data::Sum(sum) => {
920                // Counter uses isize (can go negative), so is_monotonic must be false
921                assert!(!sum.is_monotonic);
922                assert_eq!(
923                    sum.aggregation_temporality,
924                    pb::AggregationTemporality::Cumulative as i32
925                );
926                assert_eq!(sum.data_points.len(), 1);
927                assert_eq!(
928                    sum.data_points[0].value,
929                    Some(pb::number_data_point::Value::AsInt(42))
930                );
931                assert_eq!(sum.data_points[0].time_unix_nano, test_timestamp());
932            }
933            _ => panic!("expected Sum, got {:?}", data),
934        }
935    }
936
937    #[test]
938    fn test_gauge_otlp() {
939        let gauge = Gauge::new();
940        gauge.set(-10);
941
942        let mut metrics = Vec::new();
943        gauge.export_otlp(&mut metrics, "test_gauge", "A test gauge", test_timestamp());
944
945        assert_eq!(metrics.len(), 1);
946        match metrics[0].data.as_ref().expect("missing data") {
947            pb::metric::Data::Gauge(g) => {
948                assert_eq!(g.data_points.len(), 1);
949                assert_eq!(
950                    g.data_points[0].value,
951                    Some(pb::number_data_point::Value::AsInt(-10))
952                );
953            }
954            other => panic!("expected Gauge, got {:?}", other),
955        }
956    }
957
958    #[test]
959    fn test_gauge_f64_otlp() {
960        let gauge = GaugeF64::new();
961        gauge.set(3.125);
962
963        let mut metrics = Vec::new();
964        gauge.export_otlp(&mut metrics, "test_gauge_f64", "", test_timestamp());
965
966        match metrics[0].data.as_ref().expect("missing data") {
967            pb::metric::Data::Gauge(g) => {
968                assert_eq!(g.data_points.len(), 1);
969                match g.data_points[0].value {
970                    Some(pb::number_data_point::Value::AsDouble(v)) => {
971                        assert!((v - 3.125).abs() < 1e-10);
972                    }
973                    ref other => panic!("expected AsDouble, got {:?}", other),
974                }
975            }
976            other => panic!("expected Gauge, got {:?}", other),
977        }
978    }
979
980    #[test]
981    fn test_histogram_otlp() {
982        let h = Histogram::new(&[10, 100], 4);
983        h.record(5);
984        h.record(50);
985        h.record(500);
986
987        let mut metrics = Vec::new();
988        h.export_otlp(
989            &mut metrics,
990            "test_hist",
991            "A test histogram",
992            test_timestamp(),
993        );
994
995        assert_eq!(metrics.len(), 1);
996        match metrics[0].data.as_ref().expect("missing data") {
997            pb::metric::Data::Histogram(hist) => {
998                assert_eq!(
999                    hist.aggregation_temporality,
1000                    pb::AggregationTemporality::Cumulative as i32
1001                );
1002                assert_eq!(hist.data_points.len(), 1);
1003
1004                let dp = &hist.data_points[0];
1005                assert_eq!(dp.count, 3);
1006                assert_eq!(dp.sum, Some(555.0));
1007                assert_eq!(dp.explicit_bounds, vec![10.0, 100.0]);
1008                assert_eq!(dp.bucket_counts, vec![1, 1, 1]);
1009                assert_eq!(dp.time_unix_nano, test_timestamp());
1010            }
1011            other => panic!("expected Histogram, got {:?}", other),
1012        }
1013    }
1014
1015    #[test]
1016    fn test_distribution_otlp() {
1017        let dist = Distribution::new(4);
1018        dist.record(100);
1019        dist.record(200);
1020        dist.record(300);
1021
1022        let mut metrics = Vec::new();
1023        dist.export_otlp(
1024            &mut metrics,
1025            "test_dist",
1026            "A distribution",
1027            test_timestamp(),
1028        );
1029
1030        assert_eq!(metrics.len(), 1);
1031        assert_eq!(metrics[0].name, "test_dist");
1032
1033        match metrics[0].data.as_ref().expect("missing data") {
1034            pb::metric::Data::ExponentialHistogram(hist) => {
1035                assert_eq!(
1036                    hist.aggregation_temporality,
1037                    pb::AggregationTemporality::Cumulative as i32
1038                );
1039                assert_eq!(hist.data_points.len(), 1);
1040
1041                let dp = &hist.data_points[0];
1042                assert_eq!(dp.count, 3);
1043                assert_eq!(dp.sum, Some(600.0));
1044                assert_eq!(dp.scale, 0);
1045                assert_eq!(dp.zero_count, 0);
1046                assert_eq!(dp.time_unix_nano, test_timestamp());
1047                // positive buckets should be set
1048                assert!(dp.positive.is_some());
1049                let positive = dp.positive.as_ref().expect("positive buckets");
1050                // 100 -> bucket 6, 200 -> bucket 7, 300 -> bucket 8
1051                assert!(!positive.bucket_counts.is_empty());
1052            }
1053            other => panic!("expected ExponentialHistogram, got {:?}", other),
1054        }
1055    }
1056
1057    #[test]
1058    fn test_dynamic_counter_otlp() {
1059        let counter = DynamicCounter::new(4);
1060        counter.add(&[("env", "prod"), ("region", "us")], 10);
1061        counter.add(&[("env", "staging"), ("region", "eu")], 5);
1062
1063        let mut metrics = Vec::new();
1064        counter.export_otlp(&mut metrics, "requests", "Request count", test_timestamp());
1065
1066        assert_eq!(metrics.len(), 1);
1067        match metrics[0].data.as_ref().expect("missing data") {
1068            pb::metric::Data::Sum(sum) => {
1069                assert!(!sum.is_monotonic);
1070                assert_eq!(sum.data_points.len(), 2);
1071                for dp in &sum.data_points {
1072                    assert_eq!(dp.attributes.len(), 2);
1073                }
1074            }
1075            other => panic!("expected Sum, got {:?}", other),
1076        }
1077    }
1078
1079    #[test]
1080    fn test_build_export_request() {
1081        let resource = build_resource("test-service", &[("version", "1.0")]);
1082        let counter = Counter::new(4);
1083        counter.add(1);
1084
1085        let mut metrics = Vec::new();
1086        counter.export_otlp(&mut metrics, "my_counter", "", test_timestamp());
1087
1088        let request = build_export_request(&resource, "fast-telemetry", metrics);
1089
1090        assert_eq!(request.resource_metrics.len(), 1);
1091        let rm = &request.resource_metrics[0];
1092        let res = rm.resource.as_ref().expect("missing resource");
1093        assert_eq!(res.attributes.len(), 2); // service.name + version
1094        assert_eq!(res.attributes[0].key, "service.name");
1095
1096        assert_eq!(rm.scope_metrics.len(), 1);
1097        let sm = &rm.scope_metrics[0];
1098        let scope = sm.scope.as_ref().expect("missing scope");
1099        assert_eq!(scope.name, "fast-telemetry");
1100        assert_eq!(sm.metrics.len(), 1);
1101    }
1102
1103    #[test]
1104    fn test_make_kv() {
1105        let kv = make_kv("foo", "bar");
1106        assert_eq!(kv.key, "foo");
1107        match kv
1108            .value
1109            .expect("missing value")
1110            .value
1111            .expect("missing inner")
1112        {
1113            pb::any_value::Value::StringValue(s) => assert_eq!(s, "bar"),
1114            other => panic!("expected StringValue, got {:?}", other),
1115        }
1116    }
1117
1118    // -- Labeled metric tests --
1119
1120    #[derive(Copy, Clone, Debug)]
1121    enum TestLabel {
1122        A,
1123        B,
1124        C,
1125    }
1126
1127    impl crate::LabelEnum for TestLabel {
1128        const CARDINALITY: usize = 3;
1129        const LABEL_NAME: &'static str = "test";
1130
1131        fn as_index(self) -> usize {
1132            self as usize
1133        }
1134        fn from_index(index: usize) -> Self {
1135            match index {
1136                0 => Self::A,
1137                1 => Self::B,
1138                _ => Self::C,
1139            }
1140        }
1141        fn variant_name(self) -> &'static str {
1142            match self {
1143                Self::A => "a",
1144                Self::B => "b",
1145                Self::C => "c",
1146            }
1147        }
1148    }
1149
1150    #[test]
1151    fn test_labeled_counter_otlp() {
1152        let counter = LabeledCounter::<TestLabel>::new(4);
1153        counter.add(TestLabel::A, 10);
1154        counter.add(TestLabel::B, 20);
1155
1156        let mut metrics = Vec::new();
1157        counter.export_otlp(
1158            &mut metrics,
1159            "labeled_counter",
1160            "By label",
1161            test_timestamp(),
1162        );
1163
1164        assert_eq!(metrics.len(), 1);
1165        match metrics[0].data.as_ref().expect("missing data") {
1166            pb::metric::Data::Sum(sum) => {
1167                assert!(!sum.is_monotonic);
1168                assert_eq!(sum.data_points.len(), 3); // A, B, C (all variants exported)
1169                // Find the data point for label A
1170                let dp_a = sum.data_points.iter().find(|dp| {
1171                    dp.attributes.iter().any(|kv| kv.key == "test" && matches!(&kv.value, Some(v) if matches!(&v.value, Some(pb::any_value::Value::StringValue(s)) if s == "a")))
1172                }).expect("missing data point for label A");
1173                assert_eq!(dp_a.value, Some(pb::number_data_point::Value::AsInt(10)));
1174            }
1175            other => panic!("expected Sum, got {:?}", other),
1176        }
1177    }
1178
1179    #[test]
1180    fn test_labeled_gauge_otlp() {
1181        let gauge = LabeledGauge::<TestLabel>::new();
1182        gauge.set(TestLabel::A, 42);
1183        gauge.set(TestLabel::C, -5);
1184
1185        let mut metrics = Vec::new();
1186        gauge.export_otlp(&mut metrics, "labeled_gauge", "By label", test_timestamp());
1187
1188        assert_eq!(metrics.len(), 1);
1189        match metrics[0].data.as_ref().expect("missing data") {
1190            pb::metric::Data::Gauge(g) => {
1191                assert_eq!(g.data_points.len(), 3);
1192            }
1193            other => panic!("expected Gauge, got {:?}", other),
1194        }
1195    }
1196
1197    #[test]
1198    fn test_labeled_histogram_otlp() {
1199        let h = LabeledHistogram::<TestLabel>::new(&[10, 100], 4);
1200        h.record(TestLabel::A, 5);
1201        h.record(TestLabel::A, 50);
1202        h.record(TestLabel::B, 500);
1203
1204        let mut metrics = Vec::new();
1205        h.export_otlp(&mut metrics, "labeled_hist", "By label", test_timestamp());
1206
1207        assert_eq!(metrics.len(), 1);
1208        match metrics[0].data.as_ref().expect("missing data") {
1209            pb::metric::Data::Histogram(hist) => {
1210                assert_eq!(
1211                    hist.aggregation_temporality,
1212                    pb::AggregationTemporality::Cumulative as i32
1213                );
1214                assert_eq!(hist.data_points.len(), 3); // all variants
1215                // Each data point should have a label attribute
1216                for dp in &hist.data_points {
1217                    assert_eq!(dp.attributes.len(), 1);
1218                    assert_eq!(dp.attributes[0].key, "test");
1219                    assert_eq!(dp.time_unix_nano, test_timestamp());
1220                }
1221            }
1222            other => panic!("expected Histogram, got {:?}", other),
1223        }
1224    }
1225
1226    // -- Dynamic metric tests --
1227
1228    #[test]
1229    fn test_dynamic_gauge_otlp() {
1230        let gauge = DynamicGauge::new(4);
1231        gauge.set(&[("host", "node1")], 3.125);
1232        gauge.set(&[("host", "node2")], 2.72);
1233
1234        let mut metrics = Vec::new();
1235        gauge.export_otlp(
1236            &mut metrics,
1237            "cpu_usage",
1238            "CPU percentage",
1239            test_timestamp(),
1240        );
1241
1242        assert_eq!(metrics.len(), 1);
1243        match metrics[0].data.as_ref().expect("missing data") {
1244            pb::metric::Data::Gauge(g) => {
1245                assert_eq!(g.data_points.len(), 2);
1246                for dp in &g.data_points {
1247                    assert_eq!(dp.attributes.len(), 1);
1248                    assert!(matches!(
1249                        dp.value,
1250                        Some(pb::number_data_point::Value::AsDouble(_))
1251                    ));
1252                }
1253            }
1254            other => panic!("expected Gauge, got {:?}", other),
1255        }
1256    }
1257
1258    #[test]
1259    fn test_dynamic_gauge_i64_otlp() {
1260        let gauge = DynamicGaugeI64::new(4);
1261        gauge.set(&[("region", "us")], 100);
1262        gauge.set(&[("region", "eu")], 200);
1263
1264        let mut metrics = Vec::new();
1265        gauge.export_otlp(
1266            &mut metrics,
1267            "connections",
1268            "Active connections",
1269            test_timestamp(),
1270        );
1271
1272        assert_eq!(metrics.len(), 1);
1273        match metrics[0].data.as_ref().expect("missing data") {
1274            pb::metric::Data::Gauge(g) => {
1275                assert_eq!(g.data_points.len(), 2);
1276                for dp in &g.data_points {
1277                    assert_eq!(dp.attributes.len(), 1);
1278                    assert!(matches!(
1279                        dp.value,
1280                        Some(pb::number_data_point::Value::AsInt(_))
1281                    ));
1282                }
1283            }
1284            other => panic!("expected Gauge, got {:?}", other),
1285        }
1286    }
1287
1288    #[test]
1289    fn test_dynamic_histogram_otlp() {
1290        let h = DynamicHistogram::new(&[10, 100, 1000], 4);
1291        h.record(&[("endpoint", "/api")], 5);
1292        h.record(&[("endpoint", "/api")], 50);
1293        h.record(&[("endpoint", "/health")], 500);
1294
1295        let mut metrics = Vec::new();
1296        h.export_otlp(&mut metrics, "latency", "Request latency", test_timestamp());
1297
1298        assert_eq!(metrics.len(), 1);
1299        match metrics[0].data.as_ref().expect("missing data") {
1300            pb::metric::Data::Histogram(hist) => {
1301                assert_eq!(
1302                    hist.aggregation_temporality,
1303                    pb::AggregationTemporality::Cumulative as i32
1304                );
1305                assert_eq!(hist.data_points.len(), 2); // /api and /health
1306                for dp in &hist.data_points {
1307                    assert_eq!(dp.attributes.len(), 1);
1308                    assert_eq!(dp.attributes[0].key, "endpoint");
1309                    assert_eq!(dp.time_unix_nano, test_timestamp());
1310                    // explicit_bounds should not include +Inf
1311                    assert_eq!(dp.explicit_bounds, vec![10.0, 100.0, 1000.0]);
1312                }
1313            }
1314            other => panic!("expected Histogram, got {:?}", other),
1315        }
1316    }
1317
1318    #[test]
1319    fn test_dynamic_distribution_otlp() {
1320        let dist = DynamicDistribution::new(4);
1321        dist.record(&[("method", "GET")], 100);
1322        dist.record(&[("method", "GET")], 200);
1323        dist.record(&[("method", "POST")], 300);
1324
1325        let mut metrics = Vec::new();
1326        dist.export_otlp(
1327            &mut metrics,
1328            "response_size",
1329            "Size in bytes",
1330            test_timestamp(),
1331        );
1332
1333        assert_eq!(metrics.len(), 1);
1334        assert_eq!(metrics[0].name, "response_size");
1335
1336        match metrics[0].data.as_ref().expect("missing data") {
1337            pb::metric::Data::ExponentialHistogram(hist) => {
1338                assert_eq!(
1339                    hist.aggregation_temporality,
1340                    pb::AggregationTemporality::Cumulative as i32
1341                );
1342                assert_eq!(hist.data_points.len(), 2); // GET and POST
1343                for dp in &hist.data_points {
1344                    assert_eq!(dp.attributes.len(), 1);
1345                    assert_eq!(dp.attributes[0].key, "method");
1346                    assert_eq!(dp.scale, 0);
1347                    assert!(dp.positive.is_some());
1348                }
1349            }
1350            other => panic!("expected ExponentialHistogram, got {:?}", other),
1351        }
1352    }
1353
1354    #[test]
1355    fn test_empty_dynamic_metrics_produce_nothing() {
1356        let counter = DynamicCounter::new(4);
1357        let gauge = DynamicGauge::new(4);
1358        let gauge_i64 = DynamicGaugeI64::new(4);
1359        let hist = DynamicHistogram::new(&[10], 4);
1360        let dist = DynamicDistribution::new(4);
1361
1362        let mut metrics = Vec::new();
1363        let ts = test_timestamp();
1364        counter.export_otlp(&mut metrics, "c", "", ts);
1365        gauge.export_otlp(&mut metrics, "g", "", ts);
1366        gauge_i64.export_otlp(&mut metrics, "gi", "", ts);
1367        hist.export_otlp(&mut metrics, "h", "", ts);
1368        dist.export_otlp(&mut metrics, "d", "", ts);
1369
1370        assert!(
1371            metrics.is_empty(),
1372            "empty dynamic metrics should produce no output"
1373        );
1374    }
1375
1376    #[test]
1377    fn test_cumulative_to_otlp_buckets_helper() {
1378        // Input: cumulative [(10, 1), (100, 3), (u64::MAX, 5)]
1379        // Expected per-bucket: [1, 2, 2], bounds: [10.0, 100.0]
1380        let cumulative = vec![(10, 1), (100, 3), (u64::MAX, 5)];
1381        let (counts, bounds) = cumulative_to_otlp_buckets(&cumulative);
1382        assert_eq!(counts, vec![1, 2, 2]);
1383        assert_eq!(bounds, vec![10.0, 100.0]);
1384    }
1385
1386    // -- Trace export tests --
1387
1388    #[test]
1389    fn test_completed_span_to_otlp() {
1390        use crate::span::{SpanAttribute, SpanEvent, SpanKind, SpanStatus};
1391        use crate::span::{SpanId, TraceId};
1392
1393        let completed = CompletedSpan {
1394            trace_id: TraceId::from_hex("4bf92f3577b34da6a3ce929d0e0e4736").unwrap(),
1395            span_id: SpanId::from_hex("00f067aa0ba902b7").unwrap(),
1396            parent_span_id: SpanId::from_hex("1234567890abcdef").unwrap(),
1397            name: "test_operation".into(),
1398            kind: SpanKind::Server,
1399            start_time_ns: 1_000_000_000,
1400            end_time_ns: 2_000_000_000,
1401            status: SpanStatus::Ok,
1402            attributes: vec![
1403                SpanAttribute::new("http.method", "GET"),
1404                SpanAttribute::new("http.status_code", 200i64),
1405            ],
1406            events: vec![SpanEvent {
1407                name: "processing".into(),
1408                time_ns: 1_500_000_000,
1409                attributes: vec![SpanAttribute::new("step", "validate")],
1410            }],
1411        };
1412
1413        let otlp = completed.to_otlp();
1414
1415        assert_eq!(
1416            otlp.trace_id,
1417            &[
1418                0x4b, 0xf9, 0x2f, 0x35, 0x77, 0xb3, 0x4d, 0xa6, 0xa3, 0xce, 0x92, 0x9d, 0x0e, 0x0e,
1419                0x47, 0x36
1420            ]
1421        );
1422        assert_eq!(
1423            otlp.span_id,
1424            &[0x00, 0xf0, 0x67, 0xaa, 0x0b, 0xa9, 0x02, 0xb7]
1425        );
1426        assert_eq!(
1427            otlp.parent_span_id,
1428            &[0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
1429        );
1430        assert_eq!(otlp.name, "test_operation");
1431        assert_eq!(otlp.kind, pb::OtlpSpanKind::Server as i32);
1432        assert_eq!(otlp.start_time_unix_nano, 1_000_000_000);
1433        assert_eq!(otlp.end_time_unix_nano, 2_000_000_000);
1434
1435        // Status
1436        let status = otlp.status.unwrap();
1437        assert_eq!(status.code, pb::OtlpStatusCode::Ok as i32);
1438
1439        // Attributes
1440        assert_eq!(otlp.attributes.len(), 2);
1441        assert_eq!(otlp.attributes[0].key, "http.method");
1442        assert_eq!(otlp.attributes[1].key, "http.status_code");
1443
1444        // Events
1445        assert_eq!(otlp.events.len(), 1);
1446        assert_eq!(otlp.events[0].name, "processing");
1447        assert_eq!(otlp.events[0].time_unix_nano, 1_500_000_000);
1448        assert_eq!(otlp.events[0].attributes.len(), 1);
1449    }
1450
1451    #[test]
1452    fn test_completed_span_root_has_empty_parent() {
1453        use crate::span::{SpanId, TraceId};
1454
1455        let completed = CompletedSpan {
1456            trace_id: TraceId::random(),
1457            span_id: SpanId::random(),
1458            parent_span_id: SpanId::INVALID,
1459            name: "root".into(),
1460            kind: SpanKind::Server,
1461            start_time_ns: 1_000_000_000,
1462            end_time_ns: 2_000_000_000,
1463            status: SpanStatus::Unset,
1464            attributes: Vec::new(),
1465            events: Vec::new(),
1466        };
1467
1468        let otlp = completed.to_otlp();
1469        assert!(
1470            otlp.parent_span_id.is_empty(),
1471            "root span should have empty parent_span_id"
1472        );
1473        assert!(otlp.status.is_none(), "Unset status should map to None");
1474    }
1475
1476    #[test]
1477    fn test_completed_span_error_status() {
1478        use crate::span::{SpanId, TraceId};
1479
1480        let completed = CompletedSpan {
1481            trace_id: TraceId::random(),
1482            span_id: SpanId::random(),
1483            parent_span_id: SpanId::INVALID,
1484            name: "failing_op".into(),
1485            kind: SpanKind::Internal,
1486            start_time_ns: 1_000_000_000,
1487            end_time_ns: 2_000_000_000,
1488            status: SpanStatus::Error {
1489                message: "connection refused".into(),
1490            },
1491            attributes: Vec::new(),
1492            events: Vec::new(),
1493        };
1494
1495        let otlp = completed.to_otlp();
1496        let status = otlp.status.unwrap();
1497        assert_eq!(status.code, pb::OtlpStatusCode::Error as i32);
1498        assert_eq!(status.message, "connection refused");
1499    }
1500
1501    #[test]
1502    fn test_build_trace_export_request() {
1503        use crate::span::{SpanId, TraceId};
1504
1505        let resource = build_resource("test-service", &[("version", "1.0")]);
1506        let completed = CompletedSpan {
1507            trace_id: TraceId::random(),
1508            span_id: SpanId::random(),
1509            parent_span_id: SpanId::INVALID,
1510            name: "test".into(),
1511            kind: SpanKind::Server,
1512            start_time_ns: 1_000_000_000,
1513            end_time_ns: 2_000_000_000,
1514            status: SpanStatus::Ok,
1515            attributes: Vec::new(),
1516            events: Vec::new(),
1517        };
1518
1519        let otlp_span = completed.to_otlp();
1520        let request = build_trace_export_request(&resource, "fast-telemetry", vec![otlp_span]);
1521
1522        assert_eq!(request.resource_spans.len(), 1);
1523        let rs = &request.resource_spans[0];
1524        let res = rs.resource.as_ref().unwrap();
1525        assert_eq!(res.attributes.len(), 2); // service.name + version
1526
1527        assert_eq!(rs.scope_spans.len(), 1);
1528        let ss = &rs.scope_spans[0];
1529        let scope = ss.scope.as_ref().unwrap();
1530        assert_eq!(scope.name, "fast-telemetry");
1531        assert_eq!(ss.spans.len(), 1);
1532    }
1533}