use serde::Serialize;
use std::fmt::Display;
use std::sync::{OnceLock, RwLock};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Level {
Debug,
Info,
Warn,
Error,
}
impl Level {
pub fn as_str(self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
}
}
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Record<'a> {
pub level: Level,
pub source: &'static str,
pub message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub workflow_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workflow_type: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub step: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attempt: Option<u32>,
}
impl<'a> Record<'a> {
pub fn engine(level: Level, message: &'a str) -> Self {
Self {
level,
source: "fusee",
message,
workflow_id: None,
workflow_type: None,
run: None,
step: None,
attempt: None,
}
}
}
type Hook = Box<dyn Fn(&Record<'_>) + Send + Sync>;
static HOOK: RwLock<Option<Hook>> = RwLock::new(None);
pub fn set_hook(f: impl Fn(&Record<'_>) + Send + Sync + 'static) {
if let Ok(mut h) = HOOK.write() {
*h = Some(Box::new(f));
}
}
pub fn reset_hook() {
if let Ok(mut h) = HOOK.write() {
*h = None;
}
}
pub fn emit(r: &Record<'_>) {
if let Ok(h) = HOOK.read()
&& let Some(f) = h.as_ref()
{
f(r);
return;
}
default(r);
}
pub(crate) fn warn(message: impl Display) {
emit(&Record::engine(Level::Warn, &message.to_string()));
}
pub(crate) fn info(message: impl Display) {
emit(&Record::engine(Level::Info, &message.to_string()));
}
struct Format {
json: bool,
debug: bool,
}
fn format() -> &'static Format {
static F: OnceLock<Format> = OnceLock::new();
F.get_or_init(|| Format {
json: std::env::var("FUSEE_LOG_FORMAT").is_ok_and(|v| v == "json"),
debug: std::env::var("FUSEE_LOG").is_ok_and(|v| v == "debug"),
})
}
fn default(r: &Record<'_>) {
let f = format();
if r.level == Level::Debug && !f.debug {
return;
}
if f.json {
#[derive(Serialize)]
struct Line<'a> {
ts: u64,
#[serde(flatten)]
r: &'a Record<'a>,
}
if let Ok(s) = serde_json::to_string(&Line {
ts: crate::local::unix_ms(),
r,
}) {
eprintln!("{s}");
}
return;
}
eprintln!("{}", text(r));
}
pub fn text(r: &Record<'_>) -> String {
if r.source == "fusee" {
return format!("fusee: {}", r.message);
}
let mut out = format!("{:<5} {}", r.level.as_str(), r.workflow_id.unwrap_or("-"));
if let Some(s) = r.step {
out.push_str(&format!(" step '{s}'"));
if let Some(a) = r.attempt.filter(|a| *a > 1) {
out.push_str(&format!(" attempt {a}"));
}
}
out.push_str(": ");
out.push_str(r.message);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_names_the_run_and_step() {
let r = Record {
level: Level::Info,
source: "workflow",
message: "charging",
workflow_id: Some("order-1"),
workflow_type: Some("checkout"),
run: Some(3),
step: Some("charge"),
attempt: Some(2),
};
assert_eq!(text(&r), "info order-1 step 'charge' attempt 2: charging");
assert_eq!(text(&Record::engine(Level::Warn, "x")), "fusee: x");
let j = serde_json::to_value(r).unwrap();
assert_eq!(j["workflowId"], "order-1");
assert_eq!(j["level"], "info");
}
}