1use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27use crate::frame;
28
29#[cfg(feature = "tracing-layer")]
34pub mod layer;
35
36pub mod emitter;
41
42pub const LOGD_MAX_FRAME: usize = 1024 * 1024;
45
46pub const WIRE_VERSION: u8 = 1;
48
49pub const ENVELOPE_VERSION: u8 = 1;
51
52pub const REDACT_PREFIX: &str = "redacted:";
55
56pub const SECRET_SUFFIXES: [&str; 5] = ["key", "code", "token", "password", "secret"];
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum Level {
66 Trace,
68 Debug,
70 Info,
72 Warn,
74 Error,
76}
77
78impl Level {
79 pub fn as_u8(self) -> u8 {
81 match self {
82 Level::Trace => 0,
83 Level::Debug => 1,
84 Level::Info => 2,
85 Level::Warn => 3,
86 Level::Error => 4,
87 }
88 }
89
90 pub fn from_u8(n: u8) -> Level {
92 match n {
93 0 => Level::Trace,
94 1 => Level::Debug,
95 2 => Level::Info,
96 3 => Level::Warn,
97 _ => Level::Error,
98 }
99 }
100}
101
102#[derive(Debug, Error)]
104pub enum LogdError {
105 #[error("msgpack encode error: {0}")]
106 Encode(#[from] rmp_serde::encode::Error),
107 #[error("msgpack decode error: {0}")]
108 Decode(#[from] rmp_serde::decode::Error),
109 #[error("framing error: {0}")]
110 Frame(#[from] frame::FrameError),
111 #[error("unsupported wire version {got} (this build speaks {ours})")]
112 Version { got: u8, ours: u8 },
113}
114
115pub use rmpv::Value;
119
120pub type Fields = BTreeMap<String, rmpv::Value>;
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct LogFrame {
129 #[serde(rename = "v")]
131 pub v: u8,
132 #[serde(rename = "ts")]
134 pub ts_us: i64,
135 #[serde(rename = "src")]
137 pub source: String,
138 #[serde(rename = "lvl")]
140 pub level: Level,
141 #[serde(rename = "tgt", default, skip_serializing_if = "Option::is_none")]
143 pub target: Option<String>,
144 #[serde(rename = "msg")]
146 pub msg: String,
147 #[serde(rename = "f", default, skip_serializing_if = "BTreeMap::is_empty")]
149 pub fields: Fields,
150}
151
152impl LogFrame {
153 pub fn new(
155 ts_us: i64,
156 source: impl Into<String>,
157 level: Level,
158 msg: impl Into<String>,
159 ) -> Self {
160 Self {
161 v: WIRE_VERSION,
162 ts_us,
163 source: source.into(),
164 level,
165 target: None,
166 msg: msg.into(),
167 fields: Fields::new(),
168 }
169 }
170
171 pub fn redact_fields(&mut self) -> bool {
174 redact_map(&mut self.fields)
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub struct TelemetryFrame {
182 #[serde(rename = "v")]
184 pub v: u8,
185 #[serde(rename = "ts")]
187 pub ts_us: i64,
188 #[serde(rename = "m")]
190 pub metric: String,
191 #[serde(rename = "val")]
193 pub value: f64,
194 #[serde(rename = "tg", default, skip_serializing_if = "BTreeMap::is_empty")]
196 pub tags: Fields,
197}
198
199impl TelemetryFrame {
200 pub fn new(ts_us: i64, metric: impl Into<String>, value: f64) -> Self {
202 Self {
203 v: WIRE_VERSION,
204 ts_us,
205 metric: metric.into(),
206 value,
207 tags: Fields::new(),
208 }
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
215pub struct EventFrame {
216 #[serde(rename = "v")]
218 pub v: u8,
219 #[serde(rename = "ts")]
221 pub ts_us: i64,
222 #[serde(rename = "k")]
224 pub kind: String,
225 #[serde(rename = "src")]
227 pub source: String,
228 #[serde(rename = "sev")]
230 pub severity: Level,
231 #[serde(rename = "d", default, skip_serializing_if = "BTreeMap::is_empty")]
233 pub detail: Fields,
234}
235
236impl EventFrame {
237 pub fn new(
239 ts_us: i64,
240 kind: impl Into<String>,
241 source: impl Into<String>,
242 severity: Level,
243 ) -> Self {
244 Self {
245 v: WIRE_VERSION,
246 ts_us,
247 kind: kind.into(),
248 source: source.into(),
249 severity,
250 detail: Fields::new(),
251 }
252 }
253
254 pub fn redact_detail(&mut self) -> bool {
257 redact_map(&mut self.detail)
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
265pub struct HwSnapshot {
266 #[serde(rename = "v")]
268 pub v: u8,
269 #[serde(rename = "ts")]
271 pub ts_us: i64,
272 #[serde(rename = "s", default, skip_serializing_if = "BTreeMap::is_empty")]
274 pub signals: Fields,
275}
276
277impl HwSnapshot {
278 pub fn new(ts_us: i64) -> Self {
280 Self {
281 v: WIRE_VERSION,
282 ts_us,
283 signals: Fields::new(),
284 }
285 }
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
291#[serde(tag = "t", rename_all = "lowercase")]
292pub enum IngestFrame {
293 Log(LogFrame),
295 Telemetry(TelemetryFrame),
297 Event(EventFrame),
299 Hw(HwSnapshot),
301}
302
303impl IngestFrame {
304 pub fn encode(&self) -> Result<Vec<u8>, LogdError> {
307 let body = rmp_serde::to_vec_named(self)?;
308 Ok(frame::encode_frame(&body, LOGD_MAX_FRAME)?)
309 }
310
311 pub fn decode(body: &[u8]) -> Result<Self, LogdError> {
314 let frame: IngestFrame = rmp_serde::from_slice(body)?;
315 let v = frame.version();
316 if v != WIRE_VERSION {
317 return Err(LogdError::Version {
318 got: v,
319 ours: WIRE_VERSION,
320 });
321 }
322 Ok(frame)
323 }
324
325 pub fn version(&self) -> u8 {
327 match self {
328 IngestFrame::Log(f) => f.v,
329 IngestFrame::Telemetry(f) => f.v,
330 IngestFrame::Event(f) => f.v,
331 IngestFrame::Hw(f) => f.v,
332 }
333 }
334
335 pub fn redact(&mut self) -> bool {
339 match self {
340 IngestFrame::Log(f) => f.redact_fields(),
341 IngestFrame::Event(f) => f.redact_detail(),
342 IngestFrame::Telemetry(_) | IngestFrame::Hw(_) => false,
345 }
346 }
347}
348
349#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
354pub struct QueryRequest {
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub from: Option<i64>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub to: Option<i64>,
361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 pub metric: Vec<String>,
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 pub source: Vec<String>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub level: Option<Level>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub text: Option<String>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub session: Option<i64>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub limit: Option<u32>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub cursor: Option<String>,
382}
383
384#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
386pub struct Page {
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub next_cursor: Option<String>,
390 pub count: u32,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
397pub struct Meta {
398 pub source: String,
400 pub v: u8,
402 pub ts: i64,
404 pub db_lag_ms: i64,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
412pub struct QueryResponse<T> {
413 pub data: T,
415 pub page: Page,
417 pub meta: Meta,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
423pub struct ApiError {
424 pub code: String,
426 pub message: String,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432pub struct ErrorEnvelope {
433 pub error: ApiError,
435}
436
437impl ErrorEnvelope {
438 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
440 Self {
441 error: ApiError {
442 code: code.into(),
443 message: message.into(),
444 },
445 }
446 }
447}
448
449pub fn is_secret_key(key: &str) -> bool {
454 let kl = key.to_ascii_lowercase();
455 SECRET_SUFFIXES.iter().any(|s| kl.ends_with(s) || kl == *s)
456}
457
458pub fn redact(key: &str, value: &str) -> String {
475 if value.is_empty() || value.starts_with(REDACT_PREFIX) {
476 return value.to_string();
477 }
478 if !is_secret_key(key) {
479 return value.to_string();
480 }
481 format!("{REDACT_PREFIX}len={}", value.chars().count())
482}
483
484pub fn redact_map(fields: &mut Fields) -> bool {
488 let mut changed = false;
489 for (k, v) in fields.iter_mut() {
490 if let rmpv::Value::String(s) = v {
491 if let Some(text) = s.as_str() {
492 let redacted = redact(k, text);
493 if redacted != text {
494 *v = rmpv::Value::from(redacted);
495 changed = true;
496 }
497 }
498 }
499 }
500 changed
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "lowercase")]
510pub enum SyncTable {
511 Logs,
513 Metrics,
515 Events,
517 Hw,
519}
520
521impl SyncTable {
522 pub fn as_str(self) -> &'static str {
524 match self {
525 Self::Logs => "logs",
526 Self::Metrics => "metrics",
527 Self::Events => "events",
528 Self::Hw => "hw",
529 }
530 }
531
532 pub const ALL: [SyncTable; 4] = [Self::Logs, Self::Metrics, Self::Events, Self::Hw];
535}
536
537#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
543pub struct SyncRequest {
544 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub session: Option<i64>,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub from_us: Option<i64>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub to_us: Option<i64>,
553 #[serde(default, skip_serializing_if = "Vec::is_empty")]
555 pub tables: Vec<SyncTable>,
556}
557
558impl SyncRequest {
559 pub fn tables_or_all(&self) -> Vec<SyncTable> {
561 if self.tables.is_empty() {
562 SyncTable::ALL.to_vec()
563 } else {
564 self.tables.clone()
565 }
566 }
567}
568
569#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
573pub struct SyncResponse {
574 pub marked: BTreeMap<String, i64>,
576 pub unsynced_after: BTreeMap<String, i64>,
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use rmpv::Value as MpVal;
584
585 fn mk_fields(pairs: &[(&str, &str)]) -> Fields {
586 pairs
587 .iter()
588 .map(|(k, v)| (k.to_string(), MpVal::from(*v)))
589 .collect()
590 }
591
592 #[test]
595 fn version_matches_registry() {
596 assert_eq!(
597 WIRE_VERSION as u16,
598 crate::contracts::contract_version("logd.ingest").unwrap()
599 );
600 assert_eq!(
601 ENVELOPE_VERSION as u16,
602 crate::contracts::contract_version("logd.read").unwrap()
603 );
604 }
605
606 #[test]
607 fn log_frame_round_trips_through_an_ingest_frame() {
608 let mut log = LogFrame::new(1_700_000_000_000_000, "ados-logd", Level::Warn, "hello");
609 log.target = Some("logd::db".to_string());
610 log.fields.insert("attempt".to_string(), MpVal::from(3u64));
611 let frame = IngestFrame::Log(log.clone());
612
613 let wire = frame.encode().unwrap();
614 assert_eq!(&wire[..4], &(wire.len() as u32 - 4).to_be_bytes());
616
617 let back = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap();
618 assert_eq!(back, IngestFrame::Log(log));
619 }
620
621 #[test]
622 fn telemetry_event_and_hw_frames_round_trip() {
623 let tele = IngestFrame::Telemetry(TelemetryFrame::new(1, "cpu.load", 0.42));
624 let evt = IngestFrame::Event(EventFrame::new(2, "radio.lock", "ados-radio", Level::Info));
625 let mut hw = HwSnapshot::new(3);
626 hw.signals
627 .insert("thermal.soc_c".to_string(), MpVal::from(54.5));
628 let hw = IngestFrame::Hw(hw);
629
630 for frame in [tele, evt, hw] {
631 let wire = frame.encode().unwrap();
632 let back = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap();
633 assert_eq!(back, frame);
634 }
635 }
636
637 #[test]
638 fn decode_rejects_an_unknown_wire_version() {
639 let mut log = LogFrame::new(1, "src", Level::Info, "m");
640 log.v = 99;
641 let frame = IngestFrame::Log(log);
642 let wire = frame.encode().unwrap();
643 let err = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap_err();
644 assert!(matches!(err, LogdError::Version { got: 99, ours: 1 }));
645 }
646
647 #[test]
648 fn level_integer_encoding_round_trips() {
649 for lvl in [
650 Level::Trace,
651 Level::Debug,
652 Level::Info,
653 Level::Warn,
654 Level::Error,
655 ] {
656 assert_eq!(Level::from_u8(lvl.as_u8()), lvl);
657 }
658 assert_eq!(Level::from_u8(200), Level::Error);
660 }
661
662 #[test]
663 fn query_response_envelope_serializes_with_the_expected_shape() {
664 let resp = QueryResponse {
665 data: vec![1u32, 2, 3],
666 page: Page {
667 next_cursor: Some("abc".to_string()),
668 count: 3,
669 },
670 meta: Meta {
671 source: "local".to_string(),
672 v: ENVELOPE_VERSION,
673 ts: 42,
674 db_lag_ms: 0,
675 },
676 };
677 let json = serde_json::to_value(&resp).unwrap();
678 assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
679 assert_eq!(json["page"]["next_cursor"], "abc");
680 assert_eq!(json["page"]["count"], 3);
681 assert_eq!(json["meta"]["source"], "local");
682 assert_eq!(json["meta"]["v"], 1);
683 assert_eq!(json["meta"]["db_lag_ms"], 0);
684 }
685
686 #[test]
687 fn error_envelope_shape() {
688 let err = ErrorEnvelope::new("bad_request", "unknown filter");
689 let json = serde_json::to_value(&err).unwrap();
690 assert_eq!(json["error"]["code"], "bad_request");
691 assert_eq!(json["error"]["message"], "unknown filter");
692 }
693
694 #[test]
695 fn is_secret_key_matches_suffix_and_exact_case_insensitively() {
696 assert!(is_secret_key("api_key"));
697 assert!(is_secret_key("KEY"));
698 assert!(is_secret_key("pairing_code"));
699 assert!(is_secret_key("token"));
700 assert!(is_secret_key("Password"));
701 assert!(is_secret_key("device_secret"));
702 assert!(!is_secret_key("device_id"));
704 assert!(!is_secret_key("keyboard")); assert!(!is_secret_key("message"));
706 }
707
708 #[test]
709 fn redact_leaves_non_secret_and_empty_and_sentinel_values_untouched() {
710 assert_eq!(redact("device_id", "abc123"), "abc123");
712 assert_eq!(redact("api_key", ""), "");
714 let once = redact("api_key", "ABCDEFGHIJ1234567890");
716 assert_eq!(redact("api_key", &once), once);
717 }
718
719 #[test]
723 fn redact_parity_vectors() {
724 let vectors: &[(&str, &str, &str)] = &[
725 ("api_key", "ABCDEFGHIJ1234567890", "redacted:len=20"),
726 ("pairing_code", "999888", "redacted:len=6"),
727 ("token", "tok_supersecretvalue", "redacted:len=20"),
728 ("password", "hunter2", "redacted:len=7"),
729 ("secret", "s", "redacted:len=1"),
730 ("device_secret", "0xDEADBEEFCAFE", "redacted:len=14"),
731 ];
732 for (key, value, expected) in vectors {
733 assert_eq!(&redact(key, value), expected, "key={key} value={value}");
734 assert_eq!(&redact(key, expected), expected, "idempotent key={key}");
736 }
737 }
738
739 #[test]
740 fn redact_map_redacts_only_secret_string_fields() {
741 let mut fields = mk_fields(&[("api_key", "ABCDEFGHIJ1234567890"), ("device_id", "abc123")]);
742 fields.insert("attempt".to_string(), MpVal::from(7u64)); let changed = redact_map(&mut fields);
744 assert!(changed, "redaction changed the secret field");
745 assert_eq!(
746 fields.get("api_key").and_then(|v| v.as_str()),
747 Some("redacted:len=20")
748 );
749 assert_eq!(
750 fields.get("device_id").and_then(|v| v.as_str()),
751 Some("abc123")
752 );
753 assert_eq!(fields.get("attempt").and_then(|v| v.as_u64()), Some(7));
754 }
755
756 #[test]
757 fn redact_map_reports_no_change_when_nothing_is_secret() {
758 let mut fields = mk_fields(&[("device_id", "abc123"), ("api_key", "redacted:len=20")]);
761 let changed = redact_map(&mut fields);
762 assert!(!changed, "no value should change");
763 assert_eq!(
764 fields.get("api_key").and_then(|v| v.as_str()),
765 Some("redacted:len=20")
766 );
767 }
768
769 #[test]
770 fn frame_level_redaction_is_applied_through_ingest() {
771 let mut log = LogFrame::new(1, "src", Level::Info, "m");
772 log.fields.insert(
773 "session_token".to_string(),
774 MpVal::from("tok_supersecretvalue"),
775 );
776 let mut frame = IngestFrame::Log(log);
777 frame.redact();
778 if let IngestFrame::Log(l) = &frame {
779 assert_eq!(
780 l.fields.get("session_token").and_then(|v| v.as_str()),
781 Some("redacted:len=20")
782 );
783 } else {
784 panic!("expected log frame");
785 }
786 }
787
788 #[test]
789 fn sync_request_and_response_round_trip() {
790 let req = SyncRequest {
792 session: Some(7),
793 from_us: Some(1_700_000_000_000_000),
794 to_us: Some(1_700_000_600_000_000),
795 tables: vec![SyncTable::Logs, SyncTable::Events],
796 };
797 let json = serde_json::to_value(&req).unwrap();
798 assert_eq!(json["session"], 7);
799 assert_eq!(json["tables"], serde_json::json!(["logs", "events"]));
800 let back: SyncRequest = serde_json::from_value(json).unwrap();
801 assert_eq!(back, req);
802
803 let empty = SyncRequest::default();
806 let ejson = serde_json::to_value(&empty).unwrap();
807 assert_eq!(ejson, serde_json::json!({}));
808 assert_eq!(
809 empty.tables_or_all(),
810 vec![
811 SyncTable::Logs,
812 SyncTable::Metrics,
813 SyncTable::Events,
814 SyncTable::Hw
815 ]
816 );
817 assert_eq!(
819 req.tables_or_all(),
820 vec![SyncTable::Logs, SyncTable::Events]
821 );
822
823 let resp = SyncResponse {
825 marked: BTreeMap::from([("logs".to_string(), 142)]),
826 unsynced_after: BTreeMap::from([
827 ("logs".to_string(), 0),
828 ("metrics".to_string(), 318),
829 ("events".to_string(), 12),
830 ("hw".to_string(), 90),
831 ]),
832 };
833 let rjson = serde_json::to_value(&resp).unwrap();
834 assert_eq!(rjson["marked"]["logs"], 142);
835 assert_eq!(rjson["unsynced_after"]["metrics"], 318);
836 let rback: SyncResponse = serde_json::from_value(rjson).unwrap();
837 assert_eq!(rback, resp);
838
839 for t in SyncTable::ALL {
841 let s = serde_json::to_value(t).unwrap();
842 assert_eq!(s.as_str().unwrap(), t.as_str());
843 }
844 }
845}