use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(test)]
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum LogLevel {
#[default]
Off,
Flow,
}
impl LogLevel {
pub(super) fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Flow => "flow",
}
}
pub(super) fn parse(raw: &str) -> Result<Self, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" | "none" | "quiet" => Ok(Self::Off),
"flow" | "info" | "verbose" => Ok(Self::Flow),
other => Err(format!(
"unknown log level `{other}`; expected `off` or `flow`"
)),
}
}
pub(super) fn resolve(cli: Option<&str>, env: Option<&str>) -> Result<Self, String> {
if let Some(raw) = cli.filter(|raw| !raw.trim().is_empty()) {
return Self::parse(raw);
}
if let Some(raw) = env.filter(|raw| !raw.trim().is_empty()) {
return Self::parse(raw);
}
Ok(Self::default())
}
}
#[derive(Clone, Default)]
pub(super) enum FlowSink {
#[default]
Off,
Stderr,
#[cfg(test)]
Capture(Arc<Mutex<Vec<String>>>),
}
impl FlowSink {
pub(super) fn for_level(level: LogLevel) -> Self {
match level {
LogLevel::Off => Self::Off,
LogLevel::Flow => Self::Stderr,
}
}
#[cfg(test)]
pub(super) fn capture() -> Self {
Self::Capture(Arc::new(Mutex::new(Vec::new())))
}
#[cfg(test)]
pub(super) fn lines(&self) -> Vec<String> {
match self {
Self::Capture(lines) => lines
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone(),
_ => Vec::new(),
}
}
pub(super) fn enabled(&self) -> bool {
!matches!(self, Self::Off)
}
fn emit(&self, line: String) {
match self {
Self::Off => {}
Self::Stderr => eprintln!("{line}"),
#[cfg(test)]
Self::Capture(lines) => lines
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(line),
}
}
}
impl std::fmt::Debug for FlowSink {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Off => "FlowSink::Off",
Self::Stderr => "FlowSink::Stderr",
#[cfg(test)]
Self::Capture(_) => "FlowSink::Capture",
})
}
}
static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone)]
pub(super) struct RequestFlow {
id: u64,
method: &'static str,
path: String,
started: Instant,
sink: FlowSink,
}
impl RequestFlow {
pub(super) fn begin(sink: &FlowSink, method: &'static str, path: &str) -> Self {
Self {
id: NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed),
method,
path: path.to_string(),
started: Instant::now(),
sink: sink.clone(),
}
}
pub(super) fn id(&self) -> u64 {
self.id
}
pub(super) fn response(&self, status: u16, bytes: usize) {
if !self.sink.enabled() {
return;
}
self.sink.emit(format!(
"serve-flow: req={} {} {} -> {} bytes={} in {}ms",
self.id,
self.method,
self.path,
status,
bytes,
self.started.elapsed().as_millis()
));
}
pub(super) fn job_rejected(&self, queue: &str, status: u16, code: &str, detail: &str) {
if !self.sink.enabled() {
return;
}
self.sink.emit(format!(
"serve-flow: req={} queue={} admission=rejected status={} code={} detail={}",
self.id,
queue,
status,
code,
one_line(detail)
));
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct JobIdentity<'a> {
pub request: u64,
pub job: &'a str,
pub queue: &'a str,
}
impl JobIdentity<'_> {
fn prefix(&self) -> String {
format!(
"serve-flow: req={} job={} queue={}",
self.request, self.job, self.queue
)
}
}
pub(super) fn job_admitted(sink: &FlowSink, id: JobIdentity<'_>, position: usize, decision: &str) {
if !sink.enabled() {
return;
}
sink.emit(format!(
"{} admission=accepted decision={decision} queued position={position}",
id.prefix()
));
}
pub(super) fn job_running(sink: &FlowSink, id: JobIdentity<'_>, wait_ms: u64) {
if !sink.enabled() {
return;
}
sink.emit(format!("{} running wait_ms={wait_ms}", id.prefix()));
}
pub(super) fn job_terminal(
sink: &FlowSink,
id: JobIdentity<'_>,
state: &str,
result_bytes: u64,
backend_ms: u64,
failure: Option<(u16, &str, String)>,
) {
if !sink.enabled() {
return;
}
let outcome = match failure {
Some((status, code, detail)) => {
format!("status={status} code={code} detail={}", one_line(&detail))
}
None => format!("result_bytes={result_bytes}"),
};
sink.emit(format!(
"{} terminal state={state} {outcome} backend_ms={backend_ms}",
id.prefix()
));
}
fn one_line(text: &str) -> String {
let flat: String = text
.chars()
.map(|character| match character {
'\n' | '\r' | '\t' => ' ',
other => other,
})
.collect();
let mut out = String::with_capacity(flat.len());
let mut last_space = false;
for character in flat.chars() {
let is_space = character == ' ';
if is_space && last_space {
continue;
}
last_space = is_space;
out.push(character);
}
out.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::{
FlowSink, JobIdentity, LogLevel, RequestFlow, job_admitted, job_running, job_terminal,
one_line,
};
#[test]
fn the_level_switch_is_off_unless_asked_for_explicitly() {
assert_eq!(
LogLevel::resolve(None, None).expect("default"),
LogLevel::Off
);
assert_eq!(LogLevel::default(), LogLevel::Off);
assert_eq!(LogLevel::parse("off").expect("off"), LogLevel::Off);
assert_eq!(LogLevel::parse("FLOW").expect("flow"), LogLevel::Flow);
assert_eq!(
LogLevel::resolve(Some("flow"), Some("off")).expect("cli wins"),
LogLevel::Flow
);
assert_eq!(
LogLevel::resolve(None, Some("flow")).expect("env when no cli"),
LogLevel::Flow
);
assert_eq!(
LogLevel::resolve(Some(" "), Some("")).expect("blank means unset"),
LogLevel::Off
);
let error = LogLevel::resolve(Some("chatty"), None).expect_err("unknown level");
assert!(error.contains("chatty"), "{error}");
assert!(error.contains("off") && error.contains("flow"), "{error}");
}
fn identity<'a>(request: u64, job: &'a str, queue: &'a str) -> JobIdentity<'a> {
JobIdentity {
request,
job,
queue,
}
}
#[test]
fn the_off_sink_records_nothing_at_all() {
let sink = FlowSink::for_level(LogLevel::Off);
assert!(!sink.enabled());
let flow = RequestFlow::begin(&sink, "POST", "/api/ocr");
flow.response(202, 334);
flow.job_rejected("text", 409, "models_missing", "rec.onnx");
job_admitted(&sink, identity(flow.id(), "job-1", "text"), 0, "run");
job_running(&sink, identity(flow.id(), "job-1", "text"), 3);
job_terminal(
&sink,
identity(flow.id(), "job-1", "text"),
"succeeded",
42,
7,
None,
);
assert!(sink.lines().is_empty());
}
#[test]
fn one_upload_is_followable_end_to_end_by_its_request_id() {
let sink = FlowSink::capture();
let flow = RequestFlow::begin(&sink, "POST", "/api/ocr");
let request = flow.id();
assert!(request > 0, "request ids start at 1");
job_admitted(&sink, identity(request, "job-7", "text"), 0, "run");
flow.response(202, 334);
job_running(&sink, identity(request, "job-7", "text"), 4);
job_terminal(
&sink,
identity(request, "job-7", "text"),
"succeeded",
35201,
885,
None,
);
let lines = sink.lines();
assert_eq!(lines.len(), 4, "{lines:#?}");
for line in &lines {
assert!(
line.contains(&format!("req={request}")),
"every line of one upload must carry its request id: {line}"
);
}
assert!(lines[0].contains("admission=accepted"), "{}", lines[0]);
assert!(lines[0].contains("decision=run"), "{}", lines[0]);
assert!(lines[0].contains("position=0"), "{}", lines[0]);
assert_eq!(
lines[1],
format!("serve-flow: req={request} POST /api/ocr -> 202 bytes=334 in 0ms"),
"the request line always ends with the measured duration"
);
assert!(lines[2].contains("running wait_ms=4"), "{}", lines[2]);
assert!(
lines[3].contains("terminal state=succeeded"),
"{}",
lines[3]
);
assert!(lines[3].contains("result_bytes=35201"), "{}", lines[3]);
assert!(lines[3].contains("backend_ms=885"), "{}", lines[3]);
}
#[test]
fn a_failed_job_carries_its_status_code_and_detail_on_one_line() {
let sink = FlowSink::capture();
let flow = RequestFlow::begin(&sink, "POST", "/api/ocr");
flow.job_rejected("formula", 409, "models_corrupt", "pix2text-mfd-1.5.onnx");
job_terminal(
&sink,
identity(flow.id(), "job-9", "formula"),
"failed",
12,
30,
Some((
422,
"unsupported_input",
"decode failed\nsecond line".into(),
)),
);
let lines = sink.lines();
assert!(
lines[0].contains("admission=rejected")
&& lines[0].contains("status=409")
&& lines[0].contains("code=models_corrupt")
&& lines[0].contains("detail=pix2text-mfd-1.5.onnx"),
"{}",
lines[0]
);
assert!(
lines[1].contains("state=failed")
&& lines[1].contains("status=422")
&& lines[1].contains("code=unsupported_input")
&& lines[1].contains("detail=decode failed second line"),
"{}",
lines[1]
);
assert!(!lines[1].contains('\n'), "{:?}", lines[1]);
assert!(
lines[1].contains("second line"),
"content must survive: {}",
lines[1]
);
}
#[test]
fn request_ids_are_unique_and_increasing() {
let sink = FlowSink::for_level(LogLevel::Off);
let first = RequestFlow::begin(&sink, "GET", "/api/status").id();
let second = RequestFlow::begin(&sink, "GET", "/api/status").id();
assert!(second > first, "{second} must be greater than {first}");
}
#[test]
fn a_detail_is_flattened_onto_exactly_one_line() {
assert_eq!(one_line("a\nb\tc"), "a b c");
assert_eq!(one_line(" a b "), "a b");
assert_eq!(one_line(""), "");
}
}