Skip to main content

headgate_shared/
log.rs

1//! Versioned structured records inside the backwards-compatible string log transport.
2
3use serde_json::{Map, Value, json};
4
5pub const LOG_PREFIX: &str = "\u{1e}headgate-log-v1:";
6pub const MAX_LOG_BYTES: usize = 2048;
7pub const MAX_LOG_FIELDS: usize = 32;
8pub const LOG_CAP_MESSAGE: &str = "... log cap reached (100 lines/attempt)";
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum LogLevel {
12    Debug,
13    Info,
14    Warn,
15    Error,
16}
17
18impl LogLevel {
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::Debug => "debug",
22            Self::Info => "info",
23            Self::Warn => "warn",
24            Self::Error => "error",
25        }
26    }
27
28    pub fn parse(level: &str) -> Option<Self> {
29        match level {
30            "debug" => Some(Self::Debug),
31            "info" => Some(Self::Info),
32            "warn" => Some(Self::Warn),
33            "error" => Some(Self::Error),
34            _ => None,
35        }
36    }
37}
38
39/// Diagnostic worker-clock time does not participate in admission or lease decisions.
40#[derive(Clone, Debug, PartialEq)]
41pub struct LogEntry {
42    pub level: LogLevel,
43    pub at_ms: Option<i64>,
44    pub message: String,
45    pub fields: Map<String, Value>,
46    pub truncated: bool,
47}
48
49pub fn log_text(text: &str, limit: usize) -> String {
50    let mut end = text.len().min(limit);
51    while !text.is_char_boundary(end) {
52        end -= 1;
53    }
54    text[..end].to_owned()
55}
56
57impl LogEntry {
58    pub fn new(level: LogLevel, at_ms: Option<i64>, message: &str) -> Self {
59        Self {
60            level,
61            at_ms,
62            message: log_text(message, MAX_LOG_BYTES),
63            fields: Map::new(),
64            truncated: message.len() > MAX_LOG_BYTES,
65        }
66    }
67
68    /// Fields are bounded scalars. Application objects are not retained or traversed.
69    pub fn insert_field(&mut self, key: &str, value: Value) {
70        if self.fields.len() >= MAX_LOG_FIELDS {
71            self.truncated = true;
72            return;
73        }
74        self.truncated |= key.len() > 128;
75        let value = match value {
76            Value::String(s) => {
77                self.truncated |= s.len() > 1024;
78                Value::String(log_text(&s, 1024))
79            }
80            Value::Array(_) | Value::Object(_) => Value::String("[unsupported log value]".into()),
81            scalar => scalar,
82        };
83        self.fields.insert(log_text(key, 128), value);
84    }
85
86    /// Bounds the entire encoded record, not only its message. Truncation stays visible.
87    pub fn encode(mut self) -> String {
88        // Also normalize records constructed directly through the public fields.
89        let fields = std::mem::take(&mut self.fields);
90        for (key, value) in fields {
91            self.insert_field(&key, value);
92        }
93        self.truncated |= self.message.len() > MAX_LOG_BYTES;
94        self.message = log_text(&self.message, MAX_LOG_BYTES);
95        loop {
96            let mut value = json!({"level": self.level.as_str(), "message": self.message});
97            if let Some(at) = self.at_ms {
98                value["at_ms"] = at.into();
99            }
100            if !self.fields.is_empty() {
101                value["fields"] = Value::Object(self.fields.clone());
102            }
103            if self.truncated {
104                value["truncated"] = true.into();
105            }
106            let encoded = format!("{LOG_PREFIX}{value}");
107            if encoded.len() <= MAX_LOG_BYTES {
108                return encoded;
109            }
110            self.truncated = true;
111            if let Some(key) = self.fields.keys().next_back().cloned() {
112                self.fields.remove(&key);
113            } else {
114                self.message = log_text(&self.message, self.message.len() / 2);
115            }
116        }
117    }
118
119    /// Unknown versions or malformed records remain readable as literal info messages.
120    pub fn decode(line: &str) -> Self {
121        let plain = || Self {
122            level: LogLevel::Info,
123            at_ms: None,
124            message: line.to_owned(),
125            fields: Map::new(),
126            truncated: false,
127        };
128        if line.len() <= MAX_LOG_BYTES {
129            if let Some(body) = line.strip_prefix(LOG_PREFIX) {
130                if let Ok(value) = serde_json::from_str::<Value>(body) {
131                    if let (Some(level), Some(message)) = (
132                        value
133                            .get("level")
134                            .and_then(Value::as_str)
135                            .and_then(LogLevel::parse),
136                        value.get("message").and_then(Value::as_str),
137                    ) {
138                        let fields_valid = value.get("fields").is_none_or(|fields| {
139                            fields.as_object().is_some_and(|fields| {
140                                fields.values().all(|v| !v.is_array() && !v.is_object())
141                            })
142                        });
143                        if !fields_valid
144                            || value.get("at_ms").is_some_and(|at| !at.is_i64())
145                            || value.get("truncated").is_some_and(|v| !v.is_boolean())
146                        {
147                            return plain();
148                        }
149                        return Self {
150                            level,
151                            at_ms: value.get("at_ms").and_then(Value::as_i64),
152                            message: message.to_owned(),
153                            fields: value
154                                .get("fields")
155                                .and_then(Value::as_object)
156                                .cloned()
157                                .unwrap_or_default(),
158                            truncated: value
159                                .get("truncated")
160                                .and_then(Value::as_bool)
161                                .unwrap_or(false),
162                        };
163                    }
164                }
165            }
166        }
167        plain()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn log_wire_compatibility() {
177        for line in [
178            "plain",
179            r#"{"level":"error","message":"ordinary JSON"}"#,
180            "\u{1e}headgate-log-v2:{}",
181            "\u{1e}headgate-log-v1:{",
182        ] {
183            let entry = LogEntry::decode(line);
184            assert_eq!(entry.level, LogLevel::Info);
185            assert_eq!(entry.message, line);
186            assert_eq!(entry.at_ms, None);
187        }
188        let line = format!(
189            "{LOG_PREFIX}{}",
190            r#"{"at_ms":1788393600123,"fields":{"bytes":42,"cached":false,"file_id":"résumé"},"level":"warn","message":"download \"slow\""}"#
191        );
192        let entry = LogEntry::decode(&line);
193        assert_eq!(entry.level, LogLevel::Warn);
194        assert_eq!(entry.message, "download \"slow\"");
195        assert_eq!(entry.fields["file_id"], "résumé");
196        assert_eq!(LogEntry::decode(&entry.clone().encode()), entry);
197    }
198
199    #[test]
200    fn malformed_log_fields_remain_literal() {
201        for field in [
202            r#""fields":null"#,
203            r#""fields":{"x":[]}"#,
204            r#""at_ms":null"#,
205            r#""at_ms":"bad""#,
206            r#""truncated":null"#,
207            r#""truncated":1"#,
208        ] {
209            let line = format!("{LOG_PREFIX}{{\"level\":\"warn\",\"message\":\"test\",{field}}}");
210            let entry = LogEntry::decode(&line);
211            assert_eq!(entry.message, line);
212            assert_eq!(entry.level, LogLevel::Info);
213        }
214    }
215}