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}