use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::frame;
#[cfg(feature = "tracing-layer")]
pub mod layer;
pub mod emitter;
pub const LOGD_MAX_FRAME: usize = 1024 * 1024;
pub const WIRE_VERSION: u8 = 1;
pub const ENVELOPE_VERSION: u8 = 1;
pub const REDACT_PREFIX: &str = "redacted:";
pub const SECRET_SUFFIXES: [&str; 5] = ["key", "code", "token", "password", "secret"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Level {
Trace,
Debug,
Info,
Warn,
Error,
}
impl Level {
pub fn as_u8(self) -> u8 {
match self {
Level::Trace => 0,
Level::Debug => 1,
Level::Info => 2,
Level::Warn => 3,
Level::Error => 4,
}
}
pub fn from_u8(n: u8) -> Level {
match n {
0 => Level::Trace,
1 => Level::Debug,
2 => Level::Info,
3 => Level::Warn,
_ => Level::Error,
}
}
}
#[derive(Debug, Error)]
pub enum LogdError {
#[error("msgpack encode error: {0}")]
Encode(#[from] rmp_serde::encode::Error),
#[error("msgpack decode error: {0}")]
Decode(#[from] rmp_serde::decode::Error),
#[error("framing error: {0}")]
Frame(#[from] frame::FrameError),
#[error("unsupported wire version {got} (this build speaks {ours})")]
Version { got: u8, ours: u8 },
}
pub use rmpv::Value;
pub type Fields = BTreeMap<String, rmpv::Value>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LogFrame {
#[serde(rename = "v")]
pub v: u8,
#[serde(rename = "ts")]
pub ts_us: i64,
#[serde(rename = "src")]
pub source: String,
#[serde(rename = "lvl")]
pub level: Level,
#[serde(rename = "tgt", default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(rename = "msg")]
pub msg: String,
#[serde(rename = "f", default, skip_serializing_if = "BTreeMap::is_empty")]
pub fields: Fields,
}
impl LogFrame {
pub fn new(
ts_us: i64,
source: impl Into<String>,
level: Level,
msg: impl Into<String>,
) -> Self {
Self {
v: WIRE_VERSION,
ts_us,
source: source.into(),
level,
target: None,
msg: msg.into(),
fields: Fields::new(),
}
}
pub fn redact_fields(&mut self) -> bool {
redact_map(&mut self.fields)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TelemetryFrame {
#[serde(rename = "v")]
pub v: u8,
#[serde(rename = "ts")]
pub ts_us: i64,
#[serde(rename = "m")]
pub metric: String,
#[serde(rename = "val")]
pub value: f64,
#[serde(rename = "tg", default, skip_serializing_if = "BTreeMap::is_empty")]
pub tags: Fields,
}
impl TelemetryFrame {
pub fn new(ts_us: i64, metric: impl Into<String>, value: f64) -> Self {
Self {
v: WIRE_VERSION,
ts_us,
metric: metric.into(),
value,
tags: Fields::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EventFrame {
#[serde(rename = "v")]
pub v: u8,
#[serde(rename = "ts")]
pub ts_us: i64,
#[serde(rename = "k")]
pub kind: String,
#[serde(rename = "src")]
pub source: String,
#[serde(rename = "sev")]
pub severity: Level,
#[serde(rename = "d", default, skip_serializing_if = "BTreeMap::is_empty")]
pub detail: Fields,
}
impl EventFrame {
pub fn new(
ts_us: i64,
kind: impl Into<String>,
source: impl Into<String>,
severity: Level,
) -> Self {
Self {
v: WIRE_VERSION,
ts_us,
kind: kind.into(),
source: source.into(),
severity,
detail: Fields::new(),
}
}
pub fn redact_detail(&mut self) -> bool {
redact_map(&mut self.detail)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HwSnapshot {
#[serde(rename = "v")]
pub v: u8,
#[serde(rename = "ts")]
pub ts_us: i64,
#[serde(rename = "s", default, skip_serializing_if = "BTreeMap::is_empty")]
pub signals: Fields,
}
impl HwSnapshot {
pub fn new(ts_us: i64) -> Self {
Self {
v: WIRE_VERSION,
ts_us,
signals: Fields::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "t", rename_all = "lowercase")]
pub enum IngestFrame {
Log(LogFrame),
Telemetry(TelemetryFrame),
Event(EventFrame),
Hw(HwSnapshot),
}
impl IngestFrame {
pub fn encode(&self) -> Result<Vec<u8>, LogdError> {
let body = rmp_serde::to_vec_named(self)?;
Ok(frame::encode_frame(&body, LOGD_MAX_FRAME)?)
}
pub fn decode(body: &[u8]) -> Result<Self, LogdError> {
let frame: IngestFrame = rmp_serde::from_slice(body)?;
let v = frame.version();
if v != WIRE_VERSION {
return Err(LogdError::Version {
got: v,
ours: WIRE_VERSION,
});
}
Ok(frame)
}
pub fn version(&self) -> u8 {
match self {
IngestFrame::Log(f) => f.v,
IngestFrame::Telemetry(f) => f.v,
IngestFrame::Event(f) => f.v,
IngestFrame::Hw(f) => f.v,
}
}
pub fn redact(&mut self) -> bool {
match self {
IngestFrame::Log(f) => f.redact_fields(),
IngestFrame::Event(f) => f.redact_detail(),
IngestFrame::Telemetry(_) | IngestFrame::Hw(_) => false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct QueryRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metric: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<Level>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Page {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Meta {
pub source: String,
pub v: u8,
pub ts: i64,
pub db_lag_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QueryResponse<T> {
pub data: T,
pub page: Page,
pub meta: Meta,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ErrorEnvelope {
pub error: ApiError,
}
impl ErrorEnvelope {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
error: ApiError {
code: code.into(),
message: message.into(),
},
}
}
}
pub fn is_secret_key(key: &str) -> bool {
let kl = key.to_ascii_lowercase();
SECRET_SUFFIXES.iter().any(|s| kl.ends_with(s) || kl == *s)
}
pub fn redact(key: &str, value: &str) -> String {
if value.is_empty() || value.starts_with(REDACT_PREFIX) {
return value.to_string();
}
if !is_secret_key(key) {
return value.to_string();
}
format!("{REDACT_PREFIX}len={}", value.chars().count())
}
pub fn redact_map(fields: &mut Fields) -> bool {
let mut changed = false;
for (k, v) in fields.iter_mut() {
if let rmpv::Value::String(s) = v {
if let Some(text) = s.as_str() {
let redacted = redact(k, text);
if redacted != text {
*v = rmpv::Value::from(redacted);
changed = true;
}
}
}
}
changed
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SyncTable {
Logs,
Metrics,
Events,
Hw,
}
impl SyncTable {
pub fn as_str(self) -> &'static str {
match self {
Self::Logs => "logs",
Self::Metrics => "metrics",
Self::Events => "events",
Self::Hw => "hw",
}
}
pub const ALL: [SyncTable; 4] = [Self::Logs, Self::Metrics, Self::Events, Self::Hw];
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SyncRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_us: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_us: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tables: Vec<SyncTable>,
}
impl SyncRequest {
pub fn tables_or_all(&self) -> Vec<SyncTable> {
if self.tables.is_empty() {
SyncTable::ALL.to_vec()
} else {
self.tables.clone()
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SyncResponse {
pub marked: BTreeMap<String, i64>,
pub unsynced_after: BTreeMap<String, i64>,
}
#[cfg(test)]
mod tests {
use super::*;
use rmpv::Value as MpVal;
fn mk_fields(pairs: &[(&str, &str)]) -> Fields {
pairs
.iter()
.map(|(k, v)| (k.to_string(), MpVal::from(*v)))
.collect()
}
#[test]
fn version_matches_registry() {
assert_eq!(
WIRE_VERSION as u16,
crate::contracts::contract_version("logd.ingest").unwrap()
);
assert_eq!(
ENVELOPE_VERSION as u16,
crate::contracts::contract_version("logd.read").unwrap()
);
}
#[test]
fn log_frame_round_trips_through_an_ingest_frame() {
let mut log = LogFrame::new(1_700_000_000_000_000, "ados-logd", Level::Warn, "hello");
log.target = Some("logd::db".to_string());
log.fields.insert("attempt".to_string(), MpVal::from(3u64));
let frame = IngestFrame::Log(log.clone());
let wire = frame.encode().unwrap();
assert_eq!(&wire[..4], &(wire.len() as u32 - 4).to_be_bytes());
let back = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap();
assert_eq!(back, IngestFrame::Log(log));
}
#[test]
fn telemetry_event_and_hw_frames_round_trip() {
let tele = IngestFrame::Telemetry(TelemetryFrame::new(1, "cpu.load", 0.42));
let evt = IngestFrame::Event(EventFrame::new(2, "radio.lock", "ados-radio", Level::Info));
let mut hw = HwSnapshot::new(3);
hw.signals
.insert("thermal.soc_c".to_string(), MpVal::from(54.5));
let hw = IngestFrame::Hw(hw);
for frame in [tele, evt, hw] {
let wire = frame.encode().unwrap();
let back = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap();
assert_eq!(back, frame);
}
}
#[test]
fn decode_rejects_an_unknown_wire_version() {
let mut log = LogFrame::new(1, "src", Level::Info, "m");
log.v = 99;
let frame = IngestFrame::Log(log);
let wire = frame.encode().unwrap();
let err = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap_err();
assert!(matches!(err, LogdError::Version { got: 99, ours: 1 }));
}
#[test]
fn level_integer_encoding_round_trips() {
for lvl in [
Level::Trace,
Level::Debug,
Level::Info,
Level::Warn,
Level::Error,
] {
assert_eq!(Level::from_u8(lvl.as_u8()), lvl);
}
assert_eq!(Level::from_u8(200), Level::Error);
}
#[test]
fn query_response_envelope_serializes_with_the_expected_shape() {
let resp = QueryResponse {
data: vec![1u32, 2, 3],
page: Page {
next_cursor: Some("abc".to_string()),
count: 3,
},
meta: Meta {
source: "local".to_string(),
v: ENVELOPE_VERSION,
ts: 42,
db_lag_ms: 0,
},
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
assert_eq!(json["page"]["next_cursor"], "abc");
assert_eq!(json["page"]["count"], 3);
assert_eq!(json["meta"]["source"], "local");
assert_eq!(json["meta"]["v"], 1);
assert_eq!(json["meta"]["db_lag_ms"], 0);
}
#[test]
fn error_envelope_shape() {
let err = ErrorEnvelope::new("bad_request", "unknown filter");
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["error"]["code"], "bad_request");
assert_eq!(json["error"]["message"], "unknown filter");
}
#[test]
fn is_secret_key_matches_suffix_and_exact_case_insensitively() {
assert!(is_secret_key("api_key"));
assert!(is_secret_key("KEY"));
assert!(is_secret_key("pairing_code"));
assert!(is_secret_key("token"));
assert!(is_secret_key("Password"));
assert!(is_secret_key("device_secret"));
assert!(!is_secret_key("device_id"));
assert!(!is_secret_key("keyboard")); assert!(!is_secret_key("message"));
}
#[test]
fn redact_leaves_non_secret_and_empty_and_sentinel_values_untouched() {
assert_eq!(redact("device_id", "abc123"), "abc123");
assert_eq!(redact("api_key", ""), "");
let once = redact("api_key", "ABCDEFGHIJ1234567890");
assert_eq!(redact("api_key", &once), once);
}
#[test]
fn redact_parity_vectors() {
let vectors: &[(&str, &str, &str)] = &[
("api_key", "ABCDEFGHIJ1234567890", "redacted:len=20"),
("pairing_code", "999888", "redacted:len=6"),
("token", "tok_supersecretvalue", "redacted:len=20"),
("password", "hunter2", "redacted:len=7"),
("secret", "s", "redacted:len=1"),
("device_secret", "0xDEADBEEFCAFE", "redacted:len=14"),
];
for (key, value, expected) in vectors {
assert_eq!(&redact(key, value), expected, "key={key} value={value}");
assert_eq!(&redact(key, expected), expected, "idempotent key={key}");
}
}
#[test]
fn redact_map_redacts_only_secret_string_fields() {
let mut fields = mk_fields(&[("api_key", "ABCDEFGHIJ1234567890"), ("device_id", "abc123")]);
fields.insert("attempt".to_string(), MpVal::from(7u64)); let changed = redact_map(&mut fields);
assert!(changed, "redaction changed the secret field");
assert_eq!(
fields.get("api_key").and_then(|v| v.as_str()),
Some("redacted:len=20")
);
assert_eq!(
fields.get("device_id").and_then(|v| v.as_str()),
Some("abc123")
);
assert_eq!(fields.get("attempt").and_then(|v| v.as_u64()), Some(7));
}
#[test]
fn redact_map_reports_no_change_when_nothing_is_secret() {
let mut fields = mk_fields(&[("device_id", "abc123"), ("api_key", "redacted:len=20")]);
let changed = redact_map(&mut fields);
assert!(!changed, "no value should change");
assert_eq!(
fields.get("api_key").and_then(|v| v.as_str()),
Some("redacted:len=20")
);
}
#[test]
fn frame_level_redaction_is_applied_through_ingest() {
let mut log = LogFrame::new(1, "src", Level::Info, "m");
log.fields.insert(
"session_token".to_string(),
MpVal::from("tok_supersecretvalue"),
);
let mut frame = IngestFrame::Log(log);
frame.redact();
if let IngestFrame::Log(l) = &frame {
assert_eq!(
l.fields.get("session_token").and_then(|v| v.as_str()),
Some("redacted:len=20")
);
} else {
panic!("expected log frame");
}
}
#[test]
fn sync_request_and_response_round_trip() {
let req = SyncRequest {
session: Some(7),
from_us: Some(1_700_000_000_000_000),
to_us: Some(1_700_000_600_000_000),
tables: vec![SyncTable::Logs, SyncTable::Events],
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["session"], 7);
assert_eq!(json["tables"], serde_json::json!(["logs", "events"]));
let back: SyncRequest = serde_json::from_value(json).unwrap();
assert_eq!(back, req);
let empty = SyncRequest::default();
let ejson = serde_json::to_value(&empty).unwrap();
assert_eq!(ejson, serde_json::json!({}));
assert_eq!(
empty.tables_or_all(),
vec![
SyncTable::Logs,
SyncTable::Metrics,
SyncTable::Events,
SyncTable::Hw
]
);
assert_eq!(
req.tables_or_all(),
vec![SyncTable::Logs, SyncTable::Events]
);
let resp = SyncResponse {
marked: BTreeMap::from([("logs".to_string(), 142)]),
unsynced_after: BTreeMap::from([
("logs".to_string(), 0),
("metrics".to_string(), 318),
("events".to_string(), 12),
("hw".to_string(), 90),
]),
};
let rjson = serde_json::to_value(&resp).unwrap();
assert_eq!(rjson["marked"]["logs"], 142);
assert_eq!(rjson["unsynced_after"]["metrics"], 318);
let rback: SyncResponse = serde_json::from_value(rjson).unwrap();
assert_eq!(rback, resp);
for t in SyncTable::ALL {
let s = serde_json::to_value(t).unwrap();
assert_eq!(s.as_str().unwrap(), t.as_str());
}
}
}