use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Info,
Warn,
Error,
}
impl LogLevel {
fn as_str(self) -> &'static str {
match self {
LogLevel::Info => "info",
LogLevel::Warn => "warn",
LogLevel::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum LogValue {
Text(String),
Number(i64),
Flag(bool),
}
impl From<&str> for LogValue {
fn from(value: &str) -> Self {
LogValue::Text(value.to_owned())
}
}
impl From<String> for LogValue {
fn from(value: String) -> Self {
LogValue::Text(value)
}
}
impl From<i64> for LogValue {
fn from(value: i64) -> Self {
LogValue::Number(value)
}
}
impl From<u64> for LogValue {
fn from(value: u64) -> Self {
LogValue::Number(i64::try_from(value).unwrap_or(i64::MAX))
}
}
impl From<usize> for LogValue {
fn from(value: usize) -> Self {
LogValue::Number(i64::try_from(value).unwrap_or(i64::MAX))
}
}
impl From<bool> for LogValue {
fn from(value: bool) -> Self {
LogValue::Flag(value)
}
}
pub type LogFields = BTreeMap<String, LogValue>;
pub fn fields<const N: usize>(pairs: [(&str, LogValue); N]) -> LogFields {
pairs
.into_iter()
.map(|(key, value)| (key.to_owned(), value))
.collect()
}
pub type Sink = Arc<dyn Fn(LogLevel, &str) + Send + Sync>;
const MAX_ASCII: char = '\u{7f}';
pub fn to_ascii(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for character in text.chars() {
if character > MAX_ASCII || character.is_control() {
out.push_str("\\u{");
let digits = format!("{:04X}", character as u32);
out.push_str(&digits);
out.push('}');
} else {
out.push(character);
}
}
out
}
fn format_fields(fields: &LogFields) -> String {
if fields.is_empty() {
return String::new();
}
let parts: Vec<String> = fields
.iter()
.map(|(key, value)| {
let shown = match value {
LogValue::Text(text) => text.clone(),
LogValue::Number(number) => format!("{number}"),
LogValue::Flag(flag) => format!("{flag}"),
};
format!("{key}={}", to_ascii(&shown))
})
.collect();
format!(" {}", parts.join(" "))
}
pub fn format_line(level: LogLevel, message: &str, fields: &LogFields, at_ms: i64) -> String {
let moment = jiff::Timestamp::from_millisecond(at_ms).unwrap_or(jiff::Timestamp::UNIX_EPOCH);
let civil = moment.to_zoned(jiff::tz::TimeZone::UTC).datetime();
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z [{}] {}{}",
civil.year(),
civil.month(),
civil.day(),
civil.hour(),
civil.minute(),
civil.second(),
moment.subsec_nanosecond() / 1_000_000,
level.as_str(),
to_ascii(message),
format_fields(fields),
)
}
pub fn stream_sink(level: LogLevel, line: &str) {
use std::io::Write;
let line = format!("{line}\n");
if level == LogLevel::Error {
let _ = std::io::stderr().write_all(line.as_bytes());
} else {
let _ = std::io::stdout().write_all(line.as_bytes());
}
}
#[derive(Clone)]
pub struct Logger {
base: Arc<LogFields>,
sink: Sink,
}
impl Logger {
pub fn new(base: LogFields, sink: Sink) -> Self {
Self {
base: Arc::new(base),
sink,
}
}
fn emit(&self, level: LogLevel, message: &str, fields: &LogFields) {
let mut merged = (*self.base).clone();
for (key, value) in fields {
merged.insert(key.clone(), value.clone());
}
(self.sink)(level, &format_line(level, message, &merged, now_ms()));
}
pub fn info(&self, message: &str, fields: &LogFields) {
self.emit(LogLevel::Info, message, fields);
}
pub fn warn(&self, message: &str, fields: &LogFields) {
self.emit(LogLevel::Warn, message, fields);
}
pub fn error(&self, message: &str, fields: &LogFields) {
self.emit(LogLevel::Error, message, fields);
}
pub fn with(&self, fields: LogFields) -> Logger {
let mut merged = (*self.base).clone();
for (key, value) in fields {
merged.insert(key, value);
}
Logger {
base: Arc::new(merged),
sink: Arc::clone(&self.sink),
}
}
}
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| {
i64::try_from(since.as_millis()).unwrap_or(i64::MAX)
})
}
pub fn logger() -> Logger {
Logger::new(LogFields::new(), Arc::new(stream_sink))
}
#[cfg(test)]
mod tests;