use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::ValidationError;
use crate::framing::project_dto;
use crate::limits::Limits;
use crate::marker::MAX_SAFE_INTEGER;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
Fatal,
}
impl LogLevel {
pub fn severity(self) -> u8 {
match self {
LogLevel::Trace => 10,
LogLevel::Debug => 20,
LogLevel::Info => 30,
LogLevel::Warn => 40,
LogLevel::Error => 50,
LogLevel::Fatal => 60,
}
}
pub fn as_str(self) -> &'static str {
match self {
LogLevel::Trace => "trace",
LogLevel::Debug => "debug",
LogLevel::Info => "info",
LogLevel::Warn => "warn",
LogLevel::Error => "error",
LogLevel::Fatal => "fatal",
}
}
}
pub const LOG_LEVELS: [&str; 6] = ["trace", "debug", "info", "warn", "error", "fatal"];
pub const MAX_LOG_ATTRS: usize = 64;
const RECORD_FIELDS: [&str; 7] = [
"ts", "level", "message", "attrs", "logger", "seq", "revision",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AttrValue {
Null,
Bool(bool),
Int(i64),
Float(f64),
Text(String),
}
impl From<bool> for AttrValue {
fn from(value: bool) -> Self {
AttrValue::Bool(value)
}
}
impl From<i64> for AttrValue {
fn from(value: i64) -> Self {
AttrValue::Int(value)
}
}
impl From<u64> for AttrValue {
fn from(value: u64) -> Self {
AttrValue::Int(value as i64)
}
}
impl From<f64> for AttrValue {
fn from(value: f64) -> Self {
if value.is_finite() {
AttrValue::Float(value)
} else {
AttrValue::Text(value.to_string())
}
}
}
impl From<&str> for AttrValue {
fn from(value: &str) -> Self {
AttrValue::Text(value.to_owned())
}
}
impl From<String> for AttrValue {
fn from(value: String) -> Self {
AttrValue::Text(value)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogRecord {
pub ts: i64,
pub level: LogLevel,
pub message: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub attrs: BTreeMap<String, AttrValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logger: Option<String>,
pub seq: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<i64>,
}
impl LogRecord {
pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
Self {
ts: 0,
level,
message: message.into(),
attrs: BTreeMap::new(),
logger: None,
seq: 0,
revision: None,
}
}
pub fn with_attr(mut self, key: impl Into<String>, value: impl Into<AttrValue>) -> Self {
self.attrs.insert(key.into(), value.into());
self
}
pub fn with_logger(mut self, logger: impl Into<String>) -> Self {
self.logger = Some(logger.into());
self
}
pub fn validate(&self, limits: &Limits) -> Result<(), ValidationError> {
let value = serde_json::to_value(self)
.map_err(|_| ValidationError::new("schema", "log record is not JSON-serialisable"))?;
validate_log_record(&value, limits)
}
}
fn fail(code: &'static str, detail: impl Into<String>) -> ValidationError {
ValidationError::new(code, detail)
}
fn safe_non_negative(value: Option<&Value>) -> Option<i64> {
value
.and_then(Value::as_i64)
.filter(|number| *number >= 0 && *number <= MAX_SAFE_INTEGER)
}
pub fn validate_log_record(value: &Value, limits: &Limits) -> Result<(), ValidationError> {
if let Err(violation) = project_dto(value, limits.max_depth) {
let code = if violation.code == "dto-depth" {
"depth"
} else {
"schema"
};
return Err(fail(code, violation.to_string()));
}
let serialised = serde_json::to_vec(value)
.map_err(|_| fail("schema", "log record is not JSON-serialisable"))?;
if serialised.len() > limits.max_log_record_bytes {
return Err(fail(
"bytes",
format!(
"log record is {} bytes, ceiling is {}",
serialised.len(),
limits.max_log_record_bytes
),
));
}
let Some(record) = value.as_object() else {
return Err(fail("schema", "log record must be an object"));
};
for key in record.keys() {
if !RECORD_FIELDS.contains(&key.as_str()) {
return Err(fail(
"schema",
format!("unknown log record property \"{key}\""),
));
}
}
match safe_non_negative(record.get("ts")) {
Some(ts) if ts > 0 => {}
_ => {
return Err(fail(
"schema",
"ts must be a positive safe integer (epoch milliseconds)",
))
}
}
match record.get("level").and_then(Value::as_str) {
Some(level) if LOG_LEVELS.contains(&level) => {}
_ => {
return Err(fail(
"schema",
format!("level must be one of {}", LOG_LEVELS.join(", ")),
))
}
}
let Some(message) = record.get("message").and_then(Value::as_str) else {
return Err(fail("schema", "message must be a string"));
};
if message.len() > limits.max_string_bytes {
return Err(fail(
"string-bytes",
format!("message exceeds {} UTF-8 bytes", limits.max_string_bytes),
));
}
if safe_non_negative(record.get("seq")).is_none() {
return Err(fail("schema", "seq must be a non-negative safe integer"));
}
if let Some(logger) = record.get("logger") {
let Some(text) = logger.as_str() else {
return Err(fail("schema", "logger must be a string"));
};
if text.len() > limits.max_string_bytes {
return Err(fail(
"string-bytes",
format!("logger exceeds {} UTF-8 bytes", limits.max_string_bytes),
));
}
}
if let Some(revision) = record.get("revision") {
match safe_non_negative(Some(revision)) {
Some(value) if value > 0 => {}
_ => return Err(fail("revision", "revision must be a positive safe integer")),
}
}
if let Some(attrs) = record.get("attrs") {
let Some(attrs) = attrs.as_object() else {
return Err(fail("schema", "attrs must be a flat object"));
};
if attrs.len() > MAX_LOG_ATTRS {
return Err(fail(
"count",
format!(
"attrs carries {} keys, ceiling is {MAX_LOG_ATTRS}",
attrs.len()
),
));
}
for (key, attr) in attrs {
if key.len() > limits.max_string_bytes {
return Err(fail(
"string-bytes",
format!("attribute key \"{key}\" exceeds the string ceiling"),
));
}
match attr {
Value::Null | Value::Bool(_) => {}
Value::Number(number) => {
if number.as_f64().map_or(true, |value| !value.is_finite()) {
return Err(fail(
"schema",
format!("attribute \"{key}\" must be a finite number"),
));
}
}
Value::String(text) => {
if text.len() > limits.max_string_bytes {
return Err(fail(
"string-bytes",
format!("attribute \"{key}\" exceeds the string ceiling"),
));
}
}
_ => {
return Err(fail(
"schema",
format!("attribute \"{key}\" must be a string, number, boolean or null"),
))
}
}
}
}
Ok(())
}