Skip to main content

zerodds_foundation/
observability.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Observability — structured DDS events for tracing and metrics.
4//!
5//! Provides a ZeroDDS-specific event vocabulary plus a lean `Sink`
6//! trait through which consumers can tap events.
7//!
8//! ## Design goals
9//!
10//! 1. **Zero-Overhead by Default**: without a sink, no event object is
11//!    constructed at all (`with_sink(...)` is the opt-in point).
12//! 2. **Sync, allocation-light**: `Sink::record(&Event)` takes events by
13//!    `&`; each sink decides for itself whether to clone/serialize.
14//! 3. **Production-ready**: the bundled [`StderrJsonSink`] writes
15//!    JSON lines to stderr — directly consumable by
16//!    Vector/fluentd/Datadog/Loki/journald.
17//! 4. **OTel bridge later**: a separate `dds-otel` crate (or a
18//!    `tracing-opentelemetry` adapter in the consumer) can implement
19//!    this sink trait and ship events as OTLP spans.
20//!
21//! ## Event model
22//!
23//! Events are coarse-grained: one event per endpoint lifecycle action
24//! or per sample-path phase. In the hot path (e.g. per-sample latency)
25//! we use **no** events — instead the atomic stats from D.4 Phase A.
26//! Events are for coarse-grained telemetry, not for p99 latency
27//! sampling.
28
29#[cfg(feature = "alloc")]
30use alloc::string::String;
31#[cfg(feature = "alloc")]
32use alloc::sync::Arc;
33#[cfg(feature = "alloc")]
34use alloc::vec::Vec;
35
36#[cfg(feature = "std")]
37use std::io::{self, Write};
38#[cfg(feature = "std")]
39use std::sync::Mutex;
40
41/// Severity of an event. Modeled on OTel/Syslog levels.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum Level {
44    /// Normal lifecycle event (endpoint create/destroy, match).
45    Info,
46    /// Indication of an abnormal but non-fatal situation
47    /// (discovery timeout, single drop).
48    Warn,
49    /// Functionally failed operation.
50    Error,
51}
52
53impl Level {
54    /// Lowercase spelling conforming to JSON/logfile conventions.
55    #[must_use]
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::Info => "info",
59            Self::Warn => "warn",
60            Self::Error => "error",
61        }
62    }
63}
64
65/// Event source. Identifies the layer.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum Component {
68    /// DCPS / domain-participant path.
69    Dcps,
70    /// SPDP/SEDP discovery.
71    Discovery,
72    /// RTPS wire / reader / writer.
73    Rtps,
74    /// Security plugins.
75    Security,
76    /// Transport layer (UDP/TCP/SHM).
77    Transport,
78    /// User-defined sub-system (Bridges, Tools).
79    User,
80}
81
82impl Component {
83    /// Machine-readable label.
84    #[must_use]
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::Dcps => "dcps",
88            Self::Discovery => "discovery",
89            Self::Rtps => "rtps",
90            Self::Security => "security",
91            Self::Transport => "transport",
92            Self::User => "user",
93        }
94    }
95}
96
97/// Structured key-value attribute. `value` is held as a String
98/// — the sink decides whether/how it serializes typed.
99#[cfg(feature = "alloc")]
100#[derive(Debug, Clone)]
101pub struct Attribute {
102    /// Stable key (kebab-case recommended).
103    pub key: &'static str,
104    /// String value.
105    pub value: String,
106}
107
108/// Event record. Produced by the DCPS runtime and plugins.
109#[cfg(feature = "alloc")]
110#[derive(Debug, Clone)]
111pub struct Event {
112    /// Severity.
113    pub level: Level,
114    /// Originating component.
115    pub component: Component,
116    /// Stable event name in `domain.event` form (e.g.
117    /// `dcps.user_writer.created`, `discovery.peer.matched`).
118    pub name: &'static str,
119    /// Optional structured attributes.
120    pub attrs: Vec<Attribute>,
121}
122
123#[cfg(feature = "alloc")]
124impl Event {
125    /// Constructs a new event without attributes.
126    #[must_use]
127    pub fn new(level: Level, component: Component, name: &'static str) -> Self {
128        Self {
129            level,
130            component,
131            name,
132            attrs: Vec::new(),
133        }
134    }
135
136    /// Builder form: append an attribute.
137    #[must_use]
138    pub fn with_attr(mut self, key: &'static str, value: impl Into<String>) -> Self {
139        self.attrs.push(Attribute {
140            key,
141            value: value.into(),
142        });
143        self
144    }
145}
146
147/// Sink trait: consumers implement `record` and decide where the
148/// event goes (stderr, OTLP, prometheus, /dev/null).
149#[cfg(feature = "alloc")]
150pub trait Sink: Send + Sync {
151    /// Processes an event. **Synchronous.** Sinks may write,
152    /// buffer or drop — the caller waits, so please don't block
153    /// (e.g. no synchronous HTTP POST from the hot path).
154    fn record(&self, event: &Event);
155}
156
157/// No-op sink. The default choice when no telemetry is configured —
158/// every `record` call is an immediate return.
159#[cfg(feature = "alloc")]
160#[derive(Debug, Clone, Copy)]
161pub struct NullSink;
162
163#[cfg(feature = "alloc")]
164impl Sink for NullSink {
165    fn record(&self, _event: &Event) {}
166}
167
168/// Stderr sink: writes each event as a JSON line to stderr.
169/// Suited for Docker/k8s/journald pipelines with downstream
170/// log sinks (Vector/fluentd/Datadog agent/Loki).
171///
172/// Format per line:
173///
174/// ```json
175/// {"level":"info","component":"dcps","name":"user_writer.created","attrs":{"topic":"Foo","reliable":"true"}}
176/// ```
177///
178/// Synchronous via `std::io::stderr()`. The mutex guards against
179/// interleaved output between threads.
180#[cfg(feature = "std")]
181#[derive(Debug)]
182pub struct StderrJsonSink {
183    out: Mutex<io::Stderr>,
184}
185
186#[cfg(feature = "std")]
187impl Default for StderrJsonSink {
188    fn default() -> Self {
189        Self {
190            out: Mutex::new(io::stderr()),
191        }
192    }
193}
194
195#[cfg(feature = "std")]
196impl StderrJsonSink {
197    /// Constructor.
198    #[must_use]
199    pub fn new() -> Self {
200        Self::default()
201    }
202}
203
204#[cfg(feature = "std")]
205impl Sink for StderrJsonSink {
206    fn record(&self, event: &Event) {
207        let line = serialize_json_line(event);
208        if let Ok(mut out) = self.out.lock() {
209            // Ignore errors on stderr — the sink must not torpedo
210            // the app path if someone closes stderr.
211            let _ = out.write_all(line.as_bytes());
212            let _ = out.write_all(b"\n");
213            let _ = out.flush();
214        }
215    }
216}
217
218/// In-memory sink for tests. Collects events in a `Mutex<Vec>`.
219#[cfg(feature = "std")]
220#[derive(Debug, Default)]
221pub struct VecSink {
222    events: Mutex<Vec<Event>>,
223}
224
225#[cfg(feature = "std")]
226impl VecSink {
227    /// Constructor.
228    #[must_use]
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Snapshot of the events collected so far.
234    #[must_use]
235    pub fn snapshot(&self) -> Vec<Event> {
236        self.events.lock().map(|e| e.clone()).unwrap_or_default()
237    }
238
239    /// Number of events so far.
240    #[must_use]
241    pub fn len(&self) -> usize {
242        self.events.lock().map(|e| e.len()).unwrap_or(0)
243    }
244
245    /// True if there are no events.
246    #[must_use]
247    pub fn is_empty(&self) -> bool {
248        self.len() == 0
249    }
250}
251
252#[cfg(feature = "std")]
253impl Sink for VecSink {
254    fn record(&self, event: &Event) {
255        if let Ok(mut v) = self.events.lock() {
256            v.push(event.clone());
257        }
258    }
259}
260
261// zerodds-lint: allow no_dyn_in_safe
262// SharedSink needs `Arc<dyn Sink>` so consumers can inject arbitrary
263// Sink implementations (StderrJsonSink, OTLP bridge, custom forwarder).
264// The sinks themselves are Send+Sync; trait objects here are an
265// architectural contract, not a memory-safety question.
266
267/// Type-erased shared sink handle.
268#[cfg(feature = "alloc")]
269pub type SharedSink = Arc<dyn Sink>;
270
271/// Returns a `SharedSink` that does nothing. The default choice.
272#[cfg(feature = "alloc")]
273#[must_use]
274pub fn null_sink() -> SharedSink {
275    Arc::new(NullSink)
276}
277
278// ============================================================================
279// JSON serialization — minimal, without serde (foundation should stay
280// dependency-free). RFC 8259 subset: strings with \"-escape, no Unicode
281// escapes except \\ \" \n \r \t.
282// ============================================================================
283
284// Only used by StderrJsonSink (feature=std); the alloc-only build
285// sees it as dead. Allow is cleaner than per-caller cfg.
286#[cfg(feature = "alloc")]
287#[allow(dead_code)]
288fn serialize_json_line(event: &Event) -> String {
289    let mut s = String::new();
290    s.push('{');
291    s.push_str("\"level\":");
292    push_json_string(&mut s, event.level.as_str());
293    s.push_str(",\"component\":");
294    push_json_string(&mut s, event.component.as_str());
295    s.push_str(",\"name\":");
296    push_json_string(&mut s, event.name);
297    if !event.attrs.is_empty() {
298        s.push_str(",\"attrs\":{");
299        for (i, a) in event.attrs.iter().enumerate() {
300            if i > 0 {
301                s.push(',');
302            }
303            push_json_string(&mut s, a.key);
304            s.push(':');
305            push_json_string(&mut s, &a.value);
306        }
307        s.push('}');
308    }
309    s.push('}');
310    s
311}
312
313#[cfg(feature = "alloc")]
314#[allow(dead_code)]
315fn push_json_string(out: &mut String, value: &str) {
316    out.push('"');
317    for ch in value.chars() {
318        match ch {
319            '"' => out.push_str("\\\""),
320            '\\' => out.push_str("\\\\"),
321            '\n' => out.push_str("\\n"),
322            '\r' => out.push_str("\\r"),
323            '\t' => out.push_str("\\t"),
324            c if (c as u32) < 0x20 => {
325                // Control char → \u00XX
326                let _ = core::fmt::Write::write_fmt(out, core::format_args!("\\u{:04x}", c as u32));
327            }
328            c => out.push(c),
329        }
330    }
331    out.push('"');
332}
333
334#[cfg(test)]
335#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn level_labels() {
341        assert_eq!(Level::Info.as_str(), "info");
342        assert_eq!(Level::Warn.as_str(), "warn");
343        assert_eq!(Level::Error.as_str(), "error");
344    }
345
346    #[test]
347    fn component_labels() {
348        assert_eq!(Component::Dcps.as_str(), "dcps");
349        assert_eq!(Component::Discovery.as_str(), "discovery");
350        assert_eq!(Component::Rtps.as_str(), "rtps");
351        assert_eq!(Component::Security.as_str(), "security");
352        assert_eq!(Component::Transport.as_str(), "transport");
353        assert_eq!(Component::User.as_str(), "user");
354    }
355
356    #[test]
357    fn event_builder_attrs() {
358        let e = Event::new(Level::Info, Component::Dcps, "user_writer.created")
359            .with_attr("topic", "Foo")
360            .with_attr("reliable", "true");
361        assert_eq!(e.attrs.len(), 2);
362        assert_eq!(e.attrs[0].key, "topic");
363        assert_eq!(e.attrs[0].value, "Foo");
364    }
365
366    #[test]
367    fn null_sink_is_no_op() {
368        let s = NullSink;
369        let e = Event::new(Level::Info, Component::Dcps, "x");
370        s.record(&e); // no panic, no mutation.
371    }
372
373    #[test]
374    fn vec_sink_collects() {
375        let s = VecSink::new();
376        s.record(&Event::new(Level::Info, Component::Dcps, "a"));
377        s.record(&Event::new(Level::Warn, Component::Rtps, "b"));
378        assert_eq!(s.len(), 2);
379        let snap = s.snapshot();
380        assert_eq!(snap[0].name, "a");
381        assert_eq!(snap[1].level, Level::Warn);
382    }
383
384    #[test]
385    fn serialize_json_line_basic() {
386        let e = Event::new(Level::Info, Component::Dcps, "user_writer.created");
387        let s = serialize_json_line(&e);
388        assert_eq!(
389            s,
390            r#"{"level":"info","component":"dcps","name":"user_writer.created"}"#
391        );
392    }
393
394    #[test]
395    fn serialize_json_line_with_attrs() {
396        let e = Event::new(Level::Info, Component::Dcps, "writer.created")
397            .with_attr("topic", "Foo")
398            .with_attr("reliable", "true");
399        let s = serialize_json_line(&e);
400        assert!(s.contains(r#""attrs":{"topic":"Foo","reliable":"true"}"#));
401    }
402
403    #[test]
404    fn serialize_escapes_special_chars() {
405        let e = Event::new(Level::Info, Component::User, "x").with_attr("k", "a\"b\\c\nd\te");
406        let s = serialize_json_line(&e);
407        assert!(s.contains(r#""k":"a\"b\\c\nd\te""#));
408    }
409
410    #[test]
411    fn serialize_escapes_control_chars() {
412        let e = Event::new(Level::Info, Component::User, "x").with_attr("k", "\x01");
413        let s = serialize_json_line(&e);
414        assert!(
415            s.contains("\\u0001"),
416            "control-char must be \\uXXXX, got: {s}"
417        );
418    }
419
420    #[test]
421    fn null_sink_handle_typed() {
422        let h: SharedSink = null_sink();
423        h.record(&Event::new(Level::Info, Component::Dcps, "x"));
424    }
425
426    #[test]
427    fn vec_sink_threadsafe_smoke() {
428        use std::sync::Arc as StdArc;
429        use std::thread;
430        let s: StdArc<VecSink> = StdArc::new(VecSink::new());
431        let mut handles = Vec::new();
432        for i in 0..4 {
433            let s = StdArc::clone(&s);
434            handles.push(thread::spawn(move || {
435                for _ in 0..100 {
436                    s.record(&Event::new(
437                        Level::Info,
438                        Component::User,
439                        if i % 2 == 0 { "even" } else { "odd" },
440                    ));
441                }
442            }));
443        }
444        for h in handles {
445            h.join().unwrap();
446        }
447        assert_eq!(s.len(), 400);
448    }
449
450    #[test]
451    fn stderr_json_sink_does_not_panic() {
452        // Smoke: writing to stderr should never panic.
453        let s = StderrJsonSink::new();
454        s.record(&Event::new(Level::Info, Component::Dcps, "stderr.smoke"));
455    }
456}