1use 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#[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 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 pub fn encode(mut self) -> String {
88 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 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}