Skip to main content

zerodds_foundation/
tracing.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Spans + Histogramme — grobgranulare Tracing-Primitive.
4//!
5//! Liefert die in-Crate-Primitive; der `zerodds-observability-otlp`-Crate
6//! wandelt sie in OTLP-HTTP-JSON-Frames fuer Collector-Export.
7//!
8//! ## Design-Linie
9//!
10//! Wie `observability::Event` sind Spans **grobgranular** — wir
11//! instrumentieren Endpoint-Lifecycle und Discovery-Phasen, **nicht**
12//! pro-Sample-Latenzen. Hot-Path-Latenzen kommen aus den Histogrammen
13//! im Aggregat (`Histogram` hier), nicht aus pro-Sample-Spans.
14//!
15//! ## Wire-Layer-Mapping
16//!
17//! | OTel-Konzept    | Hier                          |
18//! | --------------- | ----------------------------- |
19//! | TraceId         | [`TraceId`] (16 byte)         |
20//! | SpanId          | [`SpanId`] (8 byte)           |
21//! | Span            | [`Span`]                      |
22//! | SpanContext     | [`SpanContext`]               |
23//! | Histogram       | [`Histogram`]                 |
24//!
25//! Kein `tracing`-Crate-Dep — wir bleiben minimal. Adapter zu
26//! `tracing-opentelemetry` ist eine optionale Bridge im Konsumenten.
27
28use alloc::string::String;
29use alloc::vec::Vec;
30
31use crate::observability::Attribute;
32
33/// 16-Byte W3C-Trace-ID. Niemals all-zero (Zero ist Reserved fuer
34/// "Invalid").
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub struct TraceId(pub [u8; 16]);
37
38impl TraceId {
39    /// Zero-Sentinel, OTel-Spec: invalid.
40    pub const INVALID: Self = Self([0u8; 16]);
41
42    /// `true` wenn nicht all-zero.
43    #[must_use]
44    pub fn is_valid(&self) -> bool {
45        self.0.iter().any(|b| *b != 0)
46    }
47
48    /// Hex-Lowercase-Repr (OTLP-Wire).
49    #[must_use]
50    pub fn to_hex(&self) -> String {
51        bytes_to_hex(&self.0)
52    }
53}
54
55/// 8-Byte Span-ID.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub struct SpanId(pub [u8; 8]);
58
59impl SpanId {
60    /// Zero-Sentinel.
61    pub const INVALID: Self = Self([0u8; 8]);
62
63    /// `true` wenn nicht all-zero.
64    #[must_use]
65    pub fn is_valid(&self) -> bool {
66        self.0.iter().any(|b| *b != 0)
67    }
68
69    /// Hex-Lowercase-Repr.
70    #[must_use]
71    pub fn to_hex(&self) -> String {
72        bytes_to_hex(&self.0)
73    }
74}
75
76fn bytes_to_hex(b: &[u8]) -> String {
77    const HEX: &[u8; 16] = b"0123456789abcdef";
78    let mut out = String::with_capacity(b.len() * 2);
79    for &x in b {
80        out.push(HEX[(x >> 4) as usize] as char);
81        out.push(HEX[(x & 0x0f) as usize] as char);
82    }
83    out
84}
85
86/// Kontext der Span-Beziehung — von welcher Trace, mit welchem
87/// Parent.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct SpanContext {
90    /// Globale Trace-Identitaet.
91    pub trace_id: TraceId,
92    /// Eigene Span-ID.
93    pub span_id: SpanId,
94    /// Parent-Span (None = Root).
95    pub parent_span_id: Option<SpanId>,
96}
97
98impl SpanContext {
99    /// Root-Span: erzeugt SpanContext mit zufaellig gewaehlter
100    /// `trace_id` und `span_id` (keine Parent).
101    #[must_use]
102    pub fn new_root(trace_id: TraceId, span_id: SpanId) -> Self {
103        Self {
104            trace_id,
105            span_id,
106            parent_span_id: None,
107        }
108    }
109
110    /// Child-Span unter einem existierenden Parent.
111    #[must_use]
112    pub fn child_of(parent: &SpanContext, span_id: SpanId) -> Self {
113        Self {
114            trace_id: parent.trace_id,
115            span_id,
116            parent_span_id: Some(parent.span_id),
117        }
118    }
119}
120
121/// Ergebnis-Status eines Spans (OTel-Spec).
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum SpanStatus {
124    /// Default: ohne Status-Override.
125    Unset,
126    /// Span hat Erfolg signalisiert.
127    Ok,
128    /// Fehler — Beschreibung im Span-Description-Feld.
129    Error,
130}
131
132/// Span-Kind nach OTel-Spec — wir benutzen Internal/Server/Client.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum SpanKind {
135    /// Default fuer in-process Operations.
136    Internal,
137    /// Eingehende Operation (z.B. SEDP-Discovery-Receive).
138    Server,
139    /// Ausgehende Operation (z.B. Pub-Write).
140    Client,
141}
142
143/// Ein abgeschlossener Span.
144///
145/// In ZeroDDS bauen wir Spans **abgeschlossen** auf — kein
146/// scope-guard-Magic. Caller misst Start/Ende manuell, der Sink
147/// bekommt das Result.
148#[derive(Clone, Debug)]
149pub struct Span {
150    /// Span-Kontext (Trace+Span-ID + Parent).
151    pub context: SpanContext,
152    /// Span-Name (z.B. `"dcps.write"`).
153    pub name: String,
154    /// Span-Kind.
155    pub kind: SpanKind,
156    /// Start-Time relativ zu einem monotonen Origin (ns).
157    pub start_unix_ns: u64,
158    /// Ende-Time relativ zum gleichen Origin.
159    pub end_unix_ns: u64,
160    /// Status.
161    pub status: SpanStatus,
162    /// Optionale Status-Beschreibung (z.B. Fehler-Message).
163    pub status_description: Option<String>,
164    /// Attribute, beliebig viele.
165    pub attributes: Vec<Attribute>,
166}
167
168impl Span {
169    /// Span-Dauer in Nanosekunden.
170    #[must_use]
171    pub fn duration_ns(&self) -> u64 {
172        self.end_unix_ns.saturating_sub(self.start_unix_ns)
173    }
174}
175
176/// Histogram-Primitive — exponentielle Buckets (Powers of 10) plus
177/// Sum/Count/Min/Max. Bewusst minimalistisch: kein hdrhistogram-Dep,
178/// sondern `[u64; 10]`-Buckets fuer 1ns..10s in 10x-Schritten.
179///
180/// Für volle p99/p999-Aufloesung kann der Konsument `hdrhistogram`
181/// als Sink-Sink benutzen — wir liefern hier nur das aggregat-frei
182/// summierbare Format, das OTLP `Histogram` direkt mappt.
183#[derive(Clone, Debug)]
184pub struct Histogram {
185    /// Logischer Name (z.B. `"dds.write.latency"`).
186    pub name: String,
187    /// Anzahl aller Records.
188    pub count: u64,
189    /// Summe aller Records (in der Einheit; default: ns).
190    pub sum_ns: u64,
191    /// Min-Wert.
192    pub min_ns: u64,
193    /// Max-Wert.
194    pub max_ns: u64,
195    /// Bucket-Counts: `buckets[i]` = wie viele Records `<= 10^i ns`.
196    /// 10 Buckets fuer 1ns (10^0) bis 10s (10^10).
197    pub buckets: [u64; 11],
198}
199
200impl Histogram {
201    /// Konstruktor mit Namen.
202    #[must_use]
203    pub fn new(name: impl Into<String>) -> Self {
204        Self {
205            name: name.into(),
206            count: 0,
207            sum_ns: 0,
208            min_ns: u64::MAX,
209            max_ns: 0,
210            buckets: [0; 11],
211        }
212    }
213
214    /// Misst einen Wert in Nanosekunden.
215    pub fn record_ns(&mut self, ns: u64) {
216        self.count = self.count.saturating_add(1);
217        self.sum_ns = self.sum_ns.saturating_add(ns);
218        if ns < self.min_ns {
219            self.min_ns = ns;
220        }
221        if ns > self.max_ns {
222            self.max_ns = ns;
223        }
224        let idx = bucket_index(ns);
225        self.buckets[idx] = self.buckets[idx].saturating_add(1);
226    }
227
228    /// Mittelwert in Nanosekunden, oder 0 wenn count=0.
229    #[must_use]
230    pub fn mean_ns(&self) -> u64 {
231        if self.count == 0 {
232            0
233        } else {
234            self.sum_ns / self.count
235        }
236    }
237
238    /// Bucket-Boundaries als Array — fuer OTLP-Export.
239    /// `bounds[i]` ist der obere Cutoff von `buckets[i]` in Nanosekunden.
240    #[must_use]
241    pub fn bucket_bounds() -> [u64; 11] {
242        [
243            1,              // 10^0
244            10,             // 10^1
245            100,            // 10^2
246            1_000,          // 10^3
247            10_000,         // 10^4
248            100_000,        // 10^5
249            1_000_000,      // 10^6
250            10_000_000,     // 10^7
251            100_000_000,    // 10^8
252            1_000_000_000,  // 10^9
253            10_000_000_000, // 10^10
254        ]
255    }
256
257    /// Reset auf leeren Zustand.
258    pub fn reset(&mut self) {
259        self.count = 0;
260        self.sum_ns = 0;
261        self.min_ns = u64::MAX;
262        self.max_ns = 0;
263        self.buckets = [0; 11];
264    }
265}
266
267fn bucket_index(ns: u64) -> usize {
268    let bounds = Histogram::bucket_bounds();
269    for (i, b) in bounds.iter().enumerate() {
270        if ns <= *b {
271            return i;
272        }
273    }
274    bounds.len() - 1
275}
276
277/// Standardisierte Histogram-Namen (Spec-Plan F.3).
278pub mod metric_name {
279    /// Pub-Write-Latenz (User → Wire).
280    pub const DDS_WRITE_LATENCY: &str = "dds.write.latency";
281    /// Sub-Read-Latenz (Wire → User).
282    pub const DDS_READ_LATENCY: &str = "dds.read.latency";
283    /// Heartbeat-Roundtrip-Time.
284    pub const DDS_HEARTBEAT_RTT: &str = "dds.heartbeat.rtt";
285    /// SPDP→SEDP→Match-Dauer.
286    pub const DDS_DISCOVERY_MATCH_DURATION: &str = "dds.discovery.match.duration";
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn trace_id_invalid_is_zero() {
295        assert!(!TraceId::INVALID.is_valid());
296        assert!(TraceId([1u8; 16]).is_valid());
297    }
298
299    #[test]
300    fn span_id_hex_format() {
301        let id = SpanId([0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04]);
302        assert_eq!(id.to_hex(), "deadbeef01020304");
303    }
304
305    #[test]
306    fn trace_id_hex_format() {
307        let id = TraceId([
308            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
309            0xee, 0xff,
310        ]);
311        assert_eq!(id.to_hex(), "00112233445566778899aabbccddeeff");
312    }
313
314    #[test]
315    fn span_context_child_inherits_trace_id() {
316        let parent = SpanContext::new_root(TraceId([1u8; 16]), SpanId([2u8; 8]));
317        let child = SpanContext::child_of(&parent, SpanId([3u8; 8]));
318        assert_eq!(child.trace_id, parent.trace_id);
319        assert_eq!(child.parent_span_id, Some(parent.span_id));
320    }
321
322    #[test]
323    fn histogram_records_into_correct_bucket() {
324        let mut h = Histogram::new("test");
325        h.record_ns(500); // 10^2..10^3 → bucket 3 (≤1_000)
326        h.record_ns(50_000); // 10^4..10^5 → bucket 5 (≤100_000)
327        h.record_ns(2_000_000_000); // ≤10^10 → bucket 10
328        assert_eq!(h.count, 3);
329        assert_eq!(h.buckets[3], 1);
330        assert_eq!(h.buckets[5], 1);
331        assert_eq!(h.buckets[10], 1);
332    }
333
334    #[test]
335    fn histogram_min_max_mean() {
336        let mut h = Histogram::new("test");
337        h.record_ns(100);
338        h.record_ns(200);
339        h.record_ns(300);
340        assert_eq!(h.min_ns, 100);
341        assert_eq!(h.max_ns, 300);
342        assert_eq!(h.mean_ns(), 200);
343    }
344
345    #[test]
346    fn histogram_reset_clears_state() {
347        let mut h = Histogram::new("test");
348        h.record_ns(50);
349        h.reset();
350        assert_eq!(h.count, 0);
351        assert_eq!(h.min_ns, u64::MAX);
352        assert_eq!(h.max_ns, 0);
353        assert_eq!(h.buckets, [0u64; 11]);
354    }
355
356    #[test]
357    fn histogram_clamps_to_top_bucket_for_huge_values() {
358        let mut h = Histogram::new("test");
359        h.record_ns(u64::MAX);
360        assert_eq!(h.buckets[10], 1);
361    }
362
363    #[test]
364    fn span_duration() {
365        let s = Span {
366            context: SpanContext::new_root(TraceId([1u8; 16]), SpanId([2u8; 8])),
367            name: "x".into(),
368            kind: SpanKind::Internal,
369            start_unix_ns: 1_000_000,
370            end_unix_ns: 1_500_000,
371            status: SpanStatus::Ok,
372            status_description: None,
373            attributes: Vec::new(),
374        };
375        assert_eq!(s.duration_ns(), 500_000);
376    }
377}