1use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::error::ValidationError;
18use crate::framing::project_dto;
19use crate::limits::Limits;
20use crate::marker::MAX_SAFE_INTEGER;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum LogLevel {
30 Trace,
32 Debug,
34 Info,
36 Warn,
38 Error,
40 Fatal,
42}
43
44impl LogLevel {
45 pub fn severity(self) -> u8 {
47 match self {
48 LogLevel::Trace => 10,
49 LogLevel::Debug => 20,
50 LogLevel::Info => 30,
51 LogLevel::Warn => 40,
52 LogLevel::Error => 50,
53 LogLevel::Fatal => 60,
54 }
55 }
56
57 pub fn as_str(self) -> &'static str {
59 match self {
60 LogLevel::Trace => "trace",
61 LogLevel::Debug => "debug",
62 LogLevel::Info => "info",
63 LogLevel::Warn => "warn",
64 LogLevel::Error => "error",
65 LogLevel::Fatal => "fatal",
66 }
67 }
68}
69
70pub const LOG_LEVELS: [&str; 6] = ["trace", "debug", "info", "warn", "error", "fatal"];
72
73pub const MAX_LOG_ATTRS: usize = 64;
75
76const RECORD_FIELDS: [&str; 7] = [
77 "ts", "level", "message", "attrs", "logger", "seq", "revision",
78];
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(untagged)]
84pub enum AttrValue {
85 Null,
87 Bool(bool),
89 Int(i64),
91 Float(f64),
93 Text(String),
95}
96
97impl From<bool> for AttrValue {
98 fn from(value: bool) -> Self {
99 AttrValue::Bool(value)
100 }
101}
102
103impl From<i64> for AttrValue {
104 fn from(value: i64) -> Self {
105 AttrValue::Int(value)
106 }
107}
108
109impl From<u64> for AttrValue {
110 fn from(value: u64) -> Self {
111 AttrValue::Int(value as i64)
112 }
113}
114
115impl From<f64> for AttrValue {
116 fn from(value: f64) -> Self {
117 if value.is_finite() {
118 AttrValue::Float(value)
119 } else {
120 AttrValue::Text(value.to_string())
121 }
122 }
123}
124
125impl From<&str> for AttrValue {
126 fn from(value: &str) -> Self {
127 AttrValue::Text(value.to_owned())
128 }
129}
130
131impl From<String> for AttrValue {
132 fn from(value: String) -> Self {
133 AttrValue::Text(value)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct LogRecord {
145 pub ts: i64,
147 pub level: LogLevel,
149 pub message: String,
151 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
153 pub attrs: BTreeMap<String, AttrValue>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub logger: Option<String>,
157 pub seq: i64,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub revision: Option<i64>,
163}
164
165impl LogRecord {
166 pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
168 Self {
169 ts: 0,
170 level,
171 message: message.into(),
172 attrs: BTreeMap::new(),
173 logger: None,
174 seq: 0,
175 revision: None,
176 }
177 }
178
179 pub fn with_attr(mut self, key: impl Into<String>, value: impl Into<AttrValue>) -> Self {
181 self.attrs.insert(key.into(), value.into());
182 self
183 }
184
185 pub fn with_logger(mut self, logger: impl Into<String>) -> Self {
187 self.logger = Some(logger.into());
188 self
189 }
190
191 pub fn validate(&self, limits: &Limits) -> Result<(), ValidationError> {
196 let value = serde_json::to_value(self)
197 .map_err(|_| ValidationError::new("schema", "log record is not JSON-serialisable"))?;
198 validate_log_record(&value, limits)
199 }
200}
201
202fn fail(code: &'static str, detail: impl Into<String>) -> ValidationError {
203 ValidationError::new(code, detail)
204}
205
206fn safe_non_negative(value: Option<&Value>) -> Option<i64> {
207 value
208 .and_then(Value::as_i64)
209 .filter(|number| *number >= 0 && *number <= MAX_SAFE_INTEGER)
210}
211
212pub fn validate_log_record(value: &Value, limits: &Limits) -> Result<(), ValidationError> {
221 if let Err(violation) = project_dto(value, limits.max_depth) {
222 let code = if violation.code == "dto-depth" {
223 "depth"
224 } else {
225 "schema"
226 };
227 return Err(fail(code, violation.to_string()));
228 }
229
230 let serialised = serde_json::to_vec(value)
231 .map_err(|_| fail("schema", "log record is not JSON-serialisable"))?;
232 if serialised.len() > limits.max_log_record_bytes {
233 return Err(fail(
234 "bytes",
235 format!(
236 "log record is {} bytes, ceiling is {}",
237 serialised.len(),
238 limits.max_log_record_bytes
239 ),
240 ));
241 }
242
243 let Some(record) = value.as_object() else {
244 return Err(fail("schema", "log record must be an object"));
245 };
246
247 for key in record.keys() {
248 if !RECORD_FIELDS.contains(&key.as_str()) {
249 return Err(fail(
250 "schema",
251 format!("unknown log record property \"{key}\""),
252 ));
253 }
254 }
255
256 match safe_non_negative(record.get("ts")) {
257 Some(ts) if ts > 0 => {}
258 _ => {
259 return Err(fail(
260 "schema",
261 "ts must be a positive safe integer (epoch milliseconds)",
262 ))
263 }
264 }
265 match record.get("level").and_then(Value::as_str) {
266 Some(level) if LOG_LEVELS.contains(&level) => {}
267 _ => {
268 return Err(fail(
269 "schema",
270 format!("level must be one of {}", LOG_LEVELS.join(", ")),
271 ))
272 }
273 }
274 let Some(message) = record.get("message").and_then(Value::as_str) else {
275 return Err(fail("schema", "message must be a string"));
276 };
277 if message.len() > limits.max_string_bytes {
278 return Err(fail(
279 "string-bytes",
280 format!("message exceeds {} UTF-8 bytes", limits.max_string_bytes),
281 ));
282 }
283 if safe_non_negative(record.get("seq")).is_none() {
284 return Err(fail("schema", "seq must be a non-negative safe integer"));
285 }
286
287 if let Some(logger) = record.get("logger") {
288 let Some(text) = logger.as_str() else {
289 return Err(fail("schema", "logger must be a string"));
290 };
291 if text.len() > limits.max_string_bytes {
292 return Err(fail(
293 "string-bytes",
294 format!("logger exceeds {} UTF-8 bytes", limits.max_string_bytes),
295 ));
296 }
297 }
298
299 if let Some(revision) = record.get("revision") {
300 match safe_non_negative(Some(revision)) {
301 Some(value) if value > 0 => {}
302 _ => return Err(fail("revision", "revision must be a positive safe integer")),
303 }
304 }
305
306 if let Some(attrs) = record.get("attrs") {
307 let Some(attrs) = attrs.as_object() else {
308 return Err(fail("schema", "attrs must be a flat object"));
309 };
310 if attrs.len() > MAX_LOG_ATTRS {
311 return Err(fail(
312 "count",
313 format!(
314 "attrs carries {} keys, ceiling is {MAX_LOG_ATTRS}",
315 attrs.len()
316 ),
317 ));
318 }
319 for (key, attr) in attrs {
320 if key.len() > limits.max_string_bytes {
321 return Err(fail(
322 "string-bytes",
323 format!("attribute key \"{key}\" exceeds the string ceiling"),
324 ));
325 }
326 match attr {
327 Value::Null | Value::Bool(_) => {}
328 Value::Number(number) => {
329 if number.as_f64().map_or(true, |value| !value.is_finite()) {
332 return Err(fail(
333 "schema",
334 format!("attribute \"{key}\" must be a finite number"),
335 ));
336 }
337 }
338 Value::String(text) => {
339 if text.len() > limits.max_string_bytes {
340 return Err(fail(
341 "string-bytes",
342 format!("attribute \"{key}\" exceeds the string ceiling"),
343 ));
344 }
345 }
346 _ => {
347 return Err(fail(
348 "schema",
349 format!("attribute \"{key}\" must be a string, number, boolean or null"),
350 ))
351 }
352 }
353 }
354 }
355
356 Ok(())
357}