Skip to main content

ados_protocol/logd/
emitter.rs

1//! A reusable, non-blocking emitter that ships discrete [`EventFrame`] records
2//! from any Rust service to the logging daemon's ingest socket.
3//!
4//! The log-shipping `layer` module (feature `tracing-layer`) carries `tracing` *log* events; this
5//! emitter carries the structured *events* a service wants to record as
6//! first-class, queryable rows (a regulatory-gate verdict, a bind-session
7//! lifecycle transition, a received-side link-proof state change). Until this
8//! module existed, only log lines reached the daemon; a service had no way to
9//! emit a typed event with an open detail map. This closes that gap with the
10//! same transport discipline as the log layer:
11//!
12//! - [`EventEmitter::emit`](crate::logd::emitter::EventEmitter::emit) redacts any secret-bearing detail field, frames an
13//!   [`EventFrame`], and hands it to a bounded channel with a non-blocking send.
14//!   A full channel drops the event and counts it — the producer never blocks.
15//! - A single background task drains the channel, batches frames, and writes
16//!   them to the ingest socket through a reconnecting writer. An absent socket
17//!   (the daemon not yet up, or restarting) backs off quietly; it never panics,
18//!   never blocks the producer, and never logs an error that could disrupt the
19//!   service.
20//! - The writer's hot path never emits a `tracing` event, so shipping an event
21//!   can never recurse into another shipped log.
22//!
23//! Unlike the log layer, this module is **not** behind the `tracing-layer`
24//! feature: events are a first-class capture path a service reaches for directly
25//! (`emitter.emit(...)`), independent of whether the binary installs the log
26//! layer. Redaction happens before a frame ever leaves the process, so a secret
27//! is never written to the socket or to disk.
28
29use std::collections::VecDeque;
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::Arc;
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use tokio::io::AsyncWriteExt;
36use tokio::net::UnixStream;
37use tokio::sync::mpsc;
38
39use crate::logd::{EventFrame, Fields, IngestFrame, Level, TelemetryFrame};
40
41/// Default ingest socket path. Producers connect here to write framed records.
42/// The single source of truth for the path shared by the log layer and the
43/// event emitter.
44pub const DEFAULT_INGEST_SOCK: &str = "/run/ados/logd.sock";
45
46/// Channel capacity between an emitter and its background shipper. Events are
47/// low-rate (a verdict, a lifecycle transition), so a modest buffer rides a
48/// brief writer stall without dropping while bounding pinned memory.
49pub const EVENT_CHANNEL_CAPACITY: usize = 256;
50
51/// Channel capacity between an [`IngestEmitter`] and its background shipper.
52/// Larger than the event buffer because this channel also carries periodic
53/// telemetry samples (a handful of metrics per second per producer); the extra
54/// headroom rides a brief writer stall without shedding the low-severity stream.
55pub const INGEST_CHANNEL_CAPACITY: usize = 512;
56
57/// Maximum frames coalesced into one socket write.
58const BATCH_MAX_FRAMES: usize = 64;
59
60/// Upper bound on the frames the shipper holds across a disconnected window. The
61/// shipper carries its pending batch over a failed connect (so an early producer's
62/// frames survive the daemon coming up late) instead of dropping it; this caps the
63/// pinned memory of that buffer. At a few hundred bytes per frame this is well
64/// under a megabyte. When full, the oldest frame is dropped (and counted) so the
65/// freshest state — the one an RCA wants — is the one retained.
66const SHIPPER_BUFFER_MAX_FRAMES: usize = 1024;
67
68/// Reconnect pacing.
69///
70/// Was a `[250, 500, 1000, 2000, 5000]` ms ladder that held at the last value.
71/// The socket is `/run/ados/logd.sock` on the same board, and this is its only
72/// writer, so the growth spread no herd — it just meant that after a handful of
73/// failures every producer waited five seconds before noticing the store had
74/// come up, and shipped nothing in the meantime. Fixed instead, from
75/// [`crate::retry`], which is also where the byte-identical second copy of this
76/// ladder in the `layer` module now gets its pace.
77const RECONNECT_PACE: crate::retry::RetryPace = crate::retry::LOCAL_SOCKET;
78
79/// Counters surfaced for diagnostics: events enqueued for shipping and events
80/// dropped because the channel was full. A visible drop is better than a silent
81/// one. Cheap atomics so the emit path stays lock-free.
82#[derive(Debug, Default)]
83pub struct EmitterStats {
84    enqueued: AtomicU64,
85    dropped: AtomicU64,
86}
87
88impl EmitterStats {
89    fn record_enqueued(&self) {
90        self.enqueued.fetch_add(1, Ordering::Relaxed);
91    }
92
93    fn record_dropped(&self) {
94        self.dropped.fetch_add(1, Ordering::Relaxed);
95    }
96
97    /// Total events handed to the shipper channel.
98    pub fn enqueued(&self) -> u64 {
99        self.enqueued.load(Ordering::Relaxed)
100    }
101
102    /// Total events dropped at the channel boundary under backpressure.
103    pub fn dropped(&self) -> u64 {
104        self.dropped.load(Ordering::Relaxed)
105    }
106}
107
108/// Ships structured events to the logging daemon's ingest socket.
109///
110/// Construct it with [`EventEmitter::new`] (or [`EventEmitter::with_socket`])
111/// inside a running tokio runtime — the background shipper is spawned at
112/// construction. Clone it freely: every clone shares the one shipper channel and
113/// the one stats handle, so a service can hand a clone to each task that needs to
114/// record an event without standing up a second shipper.
115#[derive(Clone)]
116pub struct EventEmitter {
117    source: Arc<str>,
118    tx: mpsc::Sender<EventFrame>,
119    stats: Arc<EmitterStats>,
120}
121
122impl EventEmitter {
123    /// Build an emitter for the default ingest socket, tagging every event with
124    /// `source` (the binary name). Spawns the background shipper on the current
125    /// tokio runtime; must be called from within a runtime context.
126    pub fn new(source: impl Into<String>) -> Self {
127        Self::with_socket(source, DEFAULT_INGEST_SOCK)
128    }
129
130    /// Build an emitter that ships to an explicit socket path (used by tests).
131    /// Spawns the background shipper on the current tokio runtime.
132    pub fn with_socket(source: impl Into<String>, socket: impl AsRef<Path>) -> Self {
133        let (tx, rx) = mpsc::channel::<EventFrame>(EVENT_CHANNEL_CAPACITY);
134        let stats = Arc::new(EmitterStats::default());
135        let path = socket.as_ref().to_path_buf();
136        tokio::spawn(shipper_task(rx, path, Arc::clone(&stats)));
137        Self {
138            source: source.into().into(),
139            tx,
140            stats,
141        }
142    }
143
144    /// The diagnostic counters for this emitter (enqueued / dropped).
145    pub fn stats(&self) -> Arc<EmitterStats> {
146        Arc::clone(&self.stats)
147    }
148
149    /// Record one event: `kind` is the dotted classifier, `severity` the level,
150    /// `detail` the open field map (redacted in place before it leaves the
151    /// process). Wait-free: a full channel drops the event and counts it, a
152    /// closed channel (shipper gone) is a silent drop. Never blocks the caller,
153    /// so an event recorded from a hot loop or a heartbeat tick cannot stall.
154    pub fn emit(&self, kind: impl Into<String>, severity: Level, detail: Fields) {
155        let mut frame = EventFrame::new(now_us(), kind.into(), self.source.to_string(), severity);
156        frame.detail = detail;
157        // Redact before the frame leaves the process: a secret-bearing detail
158        // field is hashed at the source so a raw value never reaches the socket
159        // or disk (the daemon redacts again at ingest as belt-and-suspenders).
160        frame.redact_detail();
161        match self.tx.try_send(frame) {
162            Ok(()) => self.stats.record_enqueued(),
163            Err(mpsc::error::TrySendError::Full(_)) => self.stats.record_dropped(),
164            Err(mpsc::error::TrySendError::Closed(_)) => self.stats.record_dropped(),
165        }
166    }
167}
168
169/// Ships telemetry samples and discrete events to the logging daemon's ingest
170/// socket over the same reconnecting, batched, non-blocking transport the
171/// [`EventEmitter`] uses, but over one channel typed on the full [`IngestFrame`]
172/// so a single shipper carries both [`TelemetryFrame`] metrics and
173/// [`EventFrame`] events.
174///
175/// This is the producer-side counterpart to the in-process metric path the
176/// logging daemon's own hardware collector uses: a Rust service outside the
177/// daemon cannot push onto that in-process channel, so it frames each sample and
178/// writes it to the ingest socket here. Construct it with [`IngestEmitter::new`]
179/// inside a running tokio runtime (the background shipper is spawned at
180/// construction); clone it freely (every clone shares the one shipper and stats).
181///
182/// Backpressure: telemetry is low-severity and droppable. A full channel drops
183/// the frame and counts it; the producer never blocks. An absent daemon socket
184/// (not yet up, or restarting) backs off quietly and is never an error.
185#[derive(Clone)]
186pub struct IngestEmitter {
187    source: Arc<str>,
188    tx: mpsc::Sender<IngestFrame>,
189    stats: Arc<EmitterStats>,
190}
191
192impl IngestEmitter {
193    /// Build an emitter for the default ingest socket, tagging events with
194    /// `source` (the binary name). Spawns the background shipper on the current
195    /// tokio runtime; must be called from within a runtime context.
196    pub fn new(source: impl Into<String>) -> Self {
197        Self::with_socket(source, DEFAULT_INGEST_SOCK)
198    }
199
200    /// Build an emitter that ships to an explicit socket path (used by tests).
201    /// Spawns the background shipper on the current tokio runtime.
202    pub fn with_socket(source: impl Into<String>, socket: impl AsRef<Path>) -> Self {
203        let (tx, rx) = mpsc::channel::<IngestFrame>(INGEST_CHANNEL_CAPACITY);
204        let stats = Arc::new(EmitterStats::default());
205        let path = socket.as_ref().to_path_buf();
206        tokio::spawn(ingest_shipper_task(rx, path, Arc::clone(&stats)));
207        Self {
208            source: source.into().into(),
209            tx,
210            stats,
211        }
212    }
213
214    /// The diagnostic counters for this emitter (enqueued / dropped).
215    pub fn stats(&self) -> Arc<EmitterStats> {
216        Arc::clone(&self.stats)
217    }
218
219    /// Record one telemetry sample: `metric` is the dotted key, `value` the
220    /// numeric reading, `tags` the open dimension map. Wait-free: a full channel
221    /// drops the sample and counts it, so a metric emitted from a 1 Hz loop can
222    /// never stall the producer. Telemetry carries no secret-bearing material
223    /// (the keys are fixed dotted names), so no redaction is applied here; the
224    /// daemon redacts again at ingest as belt-and-suspenders.
225    pub fn emit_metric(&self, metric: impl Into<String>, value: f64, tags: Fields) {
226        let mut frame = TelemetryFrame::new(now_us(), metric, value);
227        frame.tags = tags;
228        self.send(IngestFrame::Telemetry(frame));
229    }
230
231    /// Record one discrete event: `kind` is the dotted classifier, `severity`
232    /// the level, `detail` the open field map (redacted in place before it leaves
233    /// the process). Wait-free, same drop-and-count discipline as
234    /// [`EventEmitter::emit`].
235    pub fn emit_event(&self, kind: impl Into<String>, severity: Level, detail: Fields) {
236        let mut frame = EventFrame::new(now_us(), kind.into(), self.source.to_string(), severity);
237        frame.detail = detail;
238        frame.redact_detail();
239        self.send(IngestFrame::Event(frame));
240    }
241
242    /// Non-blocking enqueue: a full or closed channel is a counted drop.
243    fn send(&self, frame: IngestFrame) {
244        match self.tx.try_send(frame) {
245            Ok(()) => self.stats.record_enqueued(),
246            Err(_) => self.stats.record_dropped(),
247        }
248    }
249}
250
251/// Encode a batch of ingest frames into one length-prefixed byte buffer. A frame
252/// that fails to encode (e.g. an oversized payload) is skipped rather than
253/// aborting the batch, so one bad record never blocks the rest.
254fn encode_ingest_batch(batch: &[IngestFrame]) -> Vec<u8> {
255    let mut buf = Vec::new();
256    for frame in batch {
257        if let Ok(bytes) = frame.encode() {
258            buf.extend_from_slice(&bytes);
259        }
260    }
261    buf
262}
263
264/// Drain the ingest channel and ship over the shared transport. Parallels
265/// [`shipper_task`] but over the [`IngestFrame`] channel so the one shipper carries
266/// both telemetry and events.
267async fn ingest_shipper_task(
268    rx: mpsc::Receiver<IngestFrame>,
269    path: PathBuf,
270    stats: Arc<EmitterStats>,
271) {
272    run_shipper(rx, path, stats, encode_ingest_batch).await;
273}
274
275/// Push a frame onto the pending buffer, bounded at [`SHIPPER_BUFFER_MAX_FRAMES`].
276/// When full, the oldest frame is evicted (and counted as a drop) so the freshest
277/// frames win — the state an RCA cares about. Memory stays pinned at the cap.
278fn push_bounded<T>(pending: &mut VecDeque<T>, frame: T, stats: &EmitterStats) {
279    if pending.len() >= SHIPPER_BUFFER_MAX_FRAMES {
280        pending.pop_front();
281        stats.record_dropped();
282    }
283    pending.push_back(frame);
284}
285
286/// Ship one front batch (up to [`BATCH_MAX_FRAMES`]) and, on a confirmed write,
287/// remove those frames from the buffer. A batch whose every frame failed to encode
288/// is consumed too, so a single bad record can never wedge the buffer. Returns
289/// whether the buffer advanced (false on a write failure, which also drops the
290/// connection so the next attempt reconnects).
291async fn ship_front<T>(
292    writer: &mut SocketWriter,
293    pending: &mut VecDeque<T>,
294    encode: fn(&[T]) -> Vec<u8>,
295) -> bool {
296    let take = pending.len().min(BATCH_MAX_FRAMES);
297    if take == 0 {
298        return false;
299    }
300    let buf = encode(&pending.make_contiguous()[..take]);
301    if buf.is_empty() || writer.write_all(&buf).await {
302        for _ in 0..take {
303            pending.pop_front();
304        }
305        true
306    } else {
307        false
308    }
309}
310
311/// Drain a frame channel, hold the pending frames in a bounded buffer across a
312/// disconnected window, and ship them once the ingest socket is reachable. Runs
313/// until the channel closes and the buffer drains (or the channel is closed and
314/// the socket is unreachable, so nothing more can be delivered), then exits.
315///
316/// The key property over a per-iteration throwaway batch: a failed connect does
317/// NOT drop the held frames. An emitter that records before the daemon's socket
318/// exists (e.g. a service's startup-time state transitions, emitted before the
319/// logging unit is listening) keeps those frames buffered and flushes them the
320/// instant the socket appears, instead of losing the first batch. Wait-free for
321/// `emit()` callers is unchanged: the bounded channel + `try_send` drop-on-full is
322/// still the producer-side overflow valve; this buffer is the shipper-side one.
323/// The hot path never emits a `tracing` event, so shipping can never recurse.
324async fn run_shipper<T>(
325    mut rx: mpsc::Receiver<T>,
326    path: PathBuf,
327    stats: Arc<EmitterStats>,
328    encode: fn(&[T]) -> Vec<u8>,
329) {
330    let mut writer = SocketWriter::new(path);
331    let mut pending: VecDeque<T> = VecDeque::with_capacity(BATCH_MAX_FRAMES);
332    let mut closed = false;
333
334    loop {
335        // Idle cheaply when nothing is buffered: block for the next frame. A
336        // closed channel with an empty buffer means there is no more work.
337        if pending.is_empty() {
338            if closed {
339                break;
340            }
341            match rx.recv().await {
342                Some(frame) => push_bounded(&mut pending, frame, &stats),
343                None => break,
344            }
345        }
346
347        // Pull everything already queued into the bounded buffer without blocking.
348        loop {
349            match rx.try_recv() {
350                Ok(frame) => push_bounded(&mut pending, frame, &stats),
351                Err(mpsc::error::TryRecvError::Empty) => break,
352                Err(mpsc::error::TryRecvError::Disconnected) => {
353                    closed = true;
354                    break;
355                }
356            }
357        }
358
359        // Connect (with backoff) and ship a front batch; a failed connect holds
360        // the buffer and retries on the next pass. If the channel is also closed
361        // and the socket is unreachable, nothing more can arrive or be delivered,
362        // so stop rather than spin.
363        if writer.ensure_connected().await {
364            ship_front(&mut writer, &mut pending, encode).await;
365        } else if closed {
366            break;
367        }
368    }
369
370    // Bounded final flush: drain whatever remains while a live connection holds,
371    // never opening a new one (no backoff), so shutdown stays prompt.
372    while writer.stream.is_some() && ship_front(&mut writer, &mut pending, encode).await {}
373}
374
375/// Microsecond epoch timestamp for the current instant.
376fn now_us() -> i64 {
377    SystemTime::now()
378        .duration_since(UNIX_EPOCH)
379        .map(|d| d.as_micros() as i64)
380        .unwrap_or(0)
381}
382
383/// The reconnecting socket writer. Holds at most one live connection; on any I/O
384/// error it drops the connection and reconnects on the fixed pace. All
385/// failures are swallowed — the socket being absent is the expected steady state
386/// before the daemon's unit is enabled, so it must never log or panic.
387struct SocketWriter {
388    path: PathBuf,
389    stream: Option<UnixStream>,
390}
391
392impl SocketWriter {
393    fn new(path: PathBuf) -> Self {
394        Self { path, stream: None }
395    }
396
397    /// Ensure a live connection, connecting if needed. Returns `false` (after a
398    /// backoff sleep) when the socket cannot be reached, so the caller can retry
399    /// later without hot-spinning.
400    async fn ensure_connected(&mut self) -> bool {
401        if self.stream.is_some() {
402            return true;
403        }
404        match UnixStream::connect(&self.path).await {
405            Ok(s) => {
406                self.stream = Some(s);
407
408                true
409            }
410            Err(_) => {
411                tokio::time::sleep(RECONNECT_PACE.wait()).await;
412                false
413            }
414        }
415    }
416
417    /// Write one pre-framed byte buffer (a concatenation of length-prefixed
418    /// frames) to the socket. On error, drops the connection so the next call
419    /// reconnects. Returns `true` on success.
420    async fn write_all(&mut self, buf: &[u8]) -> bool {
421        let Some(stream) = self.stream.as_mut() else {
422            return false;
423        };
424        match stream.write_all(buf).await {
425            Ok(()) => true,
426            Err(_) => {
427                // The daemon went away or the socket errored: drop the handle so
428                // the next batch reconnects from scratch. The dropped batch is
429                // lost on the durable path, but journald still has the line the
430                // service logged alongside the event.
431                self.stream = None;
432                false
433            }
434        }
435    }
436}
437
438/// Encode a batch of event frames into one length-prefixed byte buffer. A frame
439/// that fails to encode (e.g. an oversized payload) is skipped rather than
440/// aborting the batch, so one bad record never blocks the rest.
441fn encode_batch(batch: &[EventFrame]) -> Vec<u8> {
442    let mut buf = Vec::new();
443    for frame in batch {
444        if let Ok(bytes) = IngestFrame::Event(frame.clone()).encode() {
445            buf.extend_from_slice(&bytes);
446        }
447    }
448    buf
449}
450
451/// Drain the event channel and ship over the shared transport. A thin wrapper
452/// over [`run_shipper`] specialised to [`EventFrame`].
453async fn shipper_task(rx: mpsc::Receiver<EventFrame>, path: PathBuf, stats: Arc<EmitterStats>) {
454    run_shipper(rx, path, stats, encode_batch).await;
455}
456
457#[cfg(test)]
458mod tests {
459    use std::time::Duration;
460
461    use super::*;
462    use rmpv::Value as MpVal;
463    use tokio::io::AsyncReadExt;
464    use tokio::net::{UnixListener, UnixStream};
465
466    use crate::frame::{decode_len, HEADER_SIZE};
467    use crate::logd::LOGD_MAX_FRAME;
468
469    fn tmp_sock() -> PathBuf {
470        // A process-unique monotonic counter avoids the same-microsecond
471        // collision two concurrent tests would otherwise hit on `now_us()`.
472        static SEQ: AtomicU64 = AtomicU64::new(0);
473        let mut p = std::env::temp_dir();
474        let uniq = format!(
475            "ados-logd-emitter-test-{}-{}-{}.sock",
476            std::process::id(),
477            now_us(),
478            SEQ.fetch_add(1, Ordering::Relaxed)
479        );
480        p.push(uniq);
481        let _ = std::fs::remove_file(&p);
482        p
483    }
484
485    fn detail(pairs: &[(&str, MpVal)]) -> Fields {
486        pairs
487            .iter()
488            .map(|(k, v)| (k.to_string(), v.clone()))
489            .collect()
490    }
491
492    /// Read one length-prefixed ingest frame off a connected stream.
493    async fn read_one(stream: &mut UnixStream) -> IngestFrame {
494        let mut header = [0u8; HEADER_SIZE];
495        stream.read_exact(&mut header).await.unwrap();
496        let len = decode_len(header, LOGD_MAX_FRAME, true).unwrap();
497        let mut body = vec![0u8; len];
498        stream.read_exact(&mut body).await.unwrap();
499        IngestFrame::decode(&body).unwrap()
500    }
501
502    #[tokio::test]
503    async fn ships_a_redacted_event_to_a_live_socket() {
504        let path = tmp_sock();
505        let listener = UnixListener::bind(&path).unwrap();
506        let accept = {
507            let listener = listener;
508            tokio::spawn(async move {
509                let (mut stream, _) = listener.accept().await.unwrap();
510                read_one(&mut stream).await
511            })
512        };
513
514        let emitter = EventEmitter::with_socket("ados-test", &path);
515        emitter.emit(
516            "radio.reg_gate",
517            Level::Warn,
518            detail(&[
519                ("result", MpVal::from("blocked")),
520                ("channel", MpVal::from(149u64)),
521                // A secret-bearing detail field must be redacted before it leaves.
522                ("session_token", MpVal::from("tok_supersecretvalue")),
523            ]),
524        );
525
526        let frame = tokio::time::timeout(Duration::from_secs(5), accept)
527            .await
528            .expect("event delivered within the deadline")
529            .expect("accept task ok");
530
531        match frame {
532            IngestFrame::Event(evt) => {
533                assert_eq!(evt.source, "ados-test");
534                assert_eq!(evt.kind, "radio.reg_gate");
535                assert_eq!(evt.severity, Level::Warn);
536                assert_eq!(
537                    evt.detail.get("result").and_then(|v| v.as_str()),
538                    Some("blocked")
539                );
540                assert_eq!(
541                    evt.detail.get("channel").and_then(|v| v.as_u64()),
542                    Some(149)
543                );
544                assert_eq!(
545                    evt.detail.get("session_token").and_then(|v| v.as_str()),
546                    Some("redacted:len=20")
547                );
548            }
549            other => panic!("expected an event frame, got {other:?}"),
550        }
551        let _ = std::fs::remove_file(&path);
552    }
553
554    #[tokio::test]
555    async fn absent_socket_never_blocks_or_panics() {
556        // Point the emitter at a path with no listener. Recording events must not
557        // block the producer or panic; frames are dropped on the durable path
558        // while the shipper backs off quietly.
559        let path = tmp_sock(); // never bound
560        let emitter = EventEmitter::with_socket("ados-test", &path);
561        for i in 0..20 {
562            emitter.emit("radio.bind", Level::Info, detail(&[("n", MpVal::from(i))]));
563        }
564        assert!(emitter.stats().enqueued() >= 1, "at least one enqueued");
565        // Give the shipper a moment to attempt and fail a connection; it must
566        // still be alive and the test still running (no panic propagated).
567        tokio::time::sleep(Duration::from_millis(50)).await;
568        let _ = std::fs::remove_file(&path);
569    }
570
571    #[tokio::test]
572    async fn full_channel_drops_without_blocking_and_counts_it() {
573        // Point at an absent socket so the shipper parks in backoff and never
574        // drains, then flood past the channel capacity. Emits must return
575        // immediately and the overflow is counted.
576        let path = tmp_sock(); // never bound
577        let emitter = EventEmitter::with_socket("ados-test", &path);
578        let total = EVENT_CHANNEL_CAPACITY * 2;
579        for n in 0..total {
580            emitter.emit(
581                "radio.rf_unverified",
582                Level::Warn,
583                detail(&[("n", MpVal::from(n as u64))]),
584            );
585        }
586        let enq = emitter.stats().enqueued();
587        let drp = emitter.stats().dropped();
588        assert_eq!(enq + drp, total as u64, "every event accounted for");
589        assert!(drp >= 1, "overflow past capacity was dropped and counted");
590        let _ = std::fs::remove_file(&path);
591    }
592
593    #[tokio::test]
594    async fn a_clone_shares_the_shipper_and_stats() {
595        let path = tmp_sock();
596        let listener = UnixListener::bind(&path).unwrap();
597        // Drain everything so the channel stays empty across both handles.
598        tokio::spawn(async move {
599            loop {
600                let Ok((mut stream, _)) = listener.accept().await else {
601                    break;
602                };
603                let mut sink = [0u8; 4096];
604                while stream.read(&mut sink).await.unwrap_or(0) > 0 {}
605            }
606        });
607
608        let emitter = EventEmitter::with_socket("ados-test", &path);
609        let clone = emitter.clone();
610        emitter.emit("radio.bind", Level::Info, Fields::new());
611        clone.emit("radio.bind", Level::Info, Fields::new());
612        // Both handles report through the one shared stats counter.
613        assert_eq!(emitter.stats().enqueued(), 2);
614        assert_eq!(clone.stats().dropped(), 0);
615        let _ = std::fs::remove_file(&path);
616    }
617
618    // --- IngestEmitter (telemetry + events over one shipper) ------------
619
620    #[tokio::test]
621    async fn ingest_ships_a_telemetry_frame_with_tags() {
622        let path = tmp_sock();
623        let listener = UnixListener::bind(&path).unwrap();
624        let accept = tokio::spawn(async move {
625            let (mut stream, _) = listener.accept().await.unwrap();
626            read_one(&mut stream).await
627        });
628
629        let emitter = IngestEmitter::with_socket("ados-test", &path);
630        let mut tags = Fields::new();
631        tags.insert("direction".to_string(), MpVal::from("downlink"));
632        tags.insert("link".to_string(), MpVal::from("video"));
633        emitter.emit_metric("link.rssi_dbm", -53.0, tags);
634
635        let frame = tokio::time::timeout(Duration::from_secs(5), accept)
636            .await
637            .expect("metric delivered within the deadline")
638            .expect("accept task ok");
639
640        match frame {
641            IngestFrame::Telemetry(t) => {
642                assert_eq!(t.metric, "link.rssi_dbm");
643                assert_eq!(t.value, -53.0);
644                assert_eq!(
645                    t.tags.get("direction").and_then(|v| v.as_str()),
646                    Some("downlink")
647                );
648                assert_eq!(t.tags.get("link").and_then(|v| v.as_str()), Some("video"));
649            }
650            other => panic!("expected a telemetry frame, got {other:?}"),
651        }
652        let _ = std::fs::remove_file(&path);
653    }
654
655    #[tokio::test]
656    async fn ingest_ships_a_redacted_event() {
657        let path = tmp_sock();
658        let listener = UnixListener::bind(&path).unwrap();
659        let accept = tokio::spawn(async move {
660            let (mut stream, _) = listener.accept().await.unwrap();
661            read_one(&mut stream).await
662        });
663
664        let emitter = IngestEmitter::with_socket("ados-test", &path);
665        emitter.emit_event(
666            "link.unlock",
667            Level::Warn,
668            detail(&[
669                ("reason", MpVal::from("loss")),
670                // A secret-bearing detail field must be redacted before it leaves.
671                ("session_token", MpVal::from("tok_supersecretvalue")),
672            ]),
673        );
674
675        let frame = tokio::time::timeout(Duration::from_secs(5), accept)
676            .await
677            .expect("event delivered within the deadline")
678            .expect("accept task ok");
679
680        match frame {
681            IngestFrame::Event(evt) => {
682                assert_eq!(evt.source, "ados-test");
683                assert_eq!(evt.kind, "link.unlock");
684                assert_eq!(evt.severity, Level::Warn);
685                assert_eq!(
686                    evt.detail.get("session_token").and_then(|v| v.as_str()),
687                    Some("redacted:len=20")
688                );
689            }
690            other => panic!("expected an event frame, got {other:?}"),
691        }
692        let _ = std::fs::remove_file(&path);
693    }
694
695    #[tokio::test]
696    async fn ingest_absent_socket_never_blocks_or_panics() {
697        let path = tmp_sock(); // never bound
698        let emitter = IngestEmitter::with_socket("ados-test", &path);
699        for i in 0..20 {
700            let mut tags = Fields::new();
701            tags.insert("n".to_string(), MpVal::from(i as u64));
702            emitter.emit_metric("video.framerate_hz", 30.0, tags);
703        }
704        assert!(emitter.stats().enqueued() >= 1, "at least one enqueued");
705        tokio::time::sleep(Duration::from_millis(50)).await;
706        let _ = std::fs::remove_file(&path);
707    }
708
709    #[tokio::test]
710    async fn ingest_full_channel_drops_without_blocking_and_counts_it() {
711        let path = tmp_sock(); // never bound, so the shipper parks in backoff
712        let emitter = IngestEmitter::with_socket("ados-test", &path);
713        let total = INGEST_CHANNEL_CAPACITY * 2;
714        for n in 0..total {
715            emitter.emit_metric("cpu.utilization_pct", n as f64, Fields::new());
716        }
717        let enq = emitter.stats().enqueued();
718        let drp = emitter.stats().dropped();
719        assert_eq!(enq + drp, total as u64, "every sample accounted for");
720        assert!(drp >= 1, "overflow past capacity was dropped and counted");
721        let _ = std::fs::remove_file(&path);
722    }
723
724    // --- shipper buffering (the early-producer fix) ----------------------
725
726    #[test]
727    fn push_bounded_caps_the_buffer_and_drops_oldest() {
728        let stats = EmitterStats::default();
729        let mut pending: VecDeque<u64> = VecDeque::new();
730        // Push ten past the cap: the buffer holds exactly the cap, the ten oldest
731        // are dropped and counted, and the newest survive.
732        let total = SHIPPER_BUFFER_MAX_FRAMES as u64 + 10;
733        for i in 0..total {
734            push_bounded(&mut pending, i, &stats);
735        }
736        assert_eq!(pending.len(), SHIPPER_BUFFER_MAX_FRAMES);
737        assert_eq!(
738            stats.dropped(),
739            10,
740            "the ten oldest were dropped and counted"
741        );
742        assert_eq!(*pending.front().unwrap(), 10, "oldest survivor is frame 10");
743        assert_eq!(*pending.back().unwrap(), total - 1, "newest frame retained");
744    }
745
746    #[tokio::test]
747    async fn buffered_events_survive_a_late_socket() {
748        // Emit before any listener exists. The old shipper dropped the batch on
749        // its first failed connect; the buffered shipper must hold the frames and
750        // flush them once the socket appears.
751        let path = tmp_sock(); // not bound yet
752        let emitter = EventEmitter::with_socket("ados-test", &path);
753        for i in 0..3u64 {
754            emitter.emit("radio.bind", Level::Info, detail(&[("n", MpVal::from(i))]));
755        }
756        // Yield so the shipper attempts a connect, fails, and parks in backoff
757        // while holding the three frames.
758        tokio::time::sleep(Duration::from_millis(60)).await;
759
760        // Bring the socket up; the held frames must arrive, in order.
761        let listener = UnixListener::bind(&path).unwrap();
762        let got = tokio::time::timeout(Duration::from_secs(5), async {
763            let (mut stream, _) = listener.accept().await.unwrap();
764            let mut ns = Vec::new();
765            for _ in 0..3 {
766                match read_one(&mut stream).await {
767                    IngestFrame::Event(e) => {
768                        ns.push(e.detail.get("n").and_then(|v| v.as_u64()).unwrap());
769                    }
770                    other => panic!("expected an event frame, got {other:?}"),
771                }
772            }
773            ns
774        })
775        .await
776        .expect("the held events were delivered after the socket appeared");
777        assert_eq!(
778            got,
779            vec![0, 1, 2],
780            "all three early events arrived in order"
781        );
782        let _ = std::fs::remove_file(&path);
783    }
784
785    #[tokio::test]
786    async fn ingest_buffered_metrics_survive_a_late_socket() {
787        // The IngestEmitter shares the one shipper, so the late-socket hold applies
788        // to telemetry too.
789        let path = tmp_sock(); // not bound yet
790        let emitter = IngestEmitter::with_socket("ados-test", &path);
791        for i in 0..3u64 {
792            let mut tags = Fields::new();
793            tags.insert("n".to_string(), MpVal::from(i));
794            emitter.emit_metric("cpu.utilization_pct", i as f64, tags);
795        }
796        tokio::time::sleep(Duration::from_millis(60)).await;
797
798        let listener = UnixListener::bind(&path).unwrap();
799        let got = tokio::time::timeout(Duration::from_secs(5), async {
800            let (mut stream, _) = listener.accept().await.unwrap();
801            let mut ns = Vec::new();
802            for _ in 0..3 {
803                match read_one(&mut stream).await {
804                    IngestFrame::Telemetry(t) => {
805                        ns.push(t.tags.get("n").and_then(|v| v.as_u64()).unwrap());
806                    }
807                    other => panic!("expected a telemetry frame, got {other:?}"),
808                }
809            }
810            ns
811        })
812        .await
813        .expect("the held metrics were delivered after the socket appeared");
814        assert_eq!(
815            got,
816            vec![0, 1, 2],
817            "all three early metrics arrived in order"
818        );
819        let _ = std::fs::remove_file(&path);
820    }
821}