use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
const LATENCY_BUCKETS_SECONDS: [f64; 13] = [
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0,
];
#[derive(Default)]
struct RouteLatency {
bucket_counts: [u64; LATENCY_BUCKETS_SECONDS.len()],
sum_seconds: f64,
count: u64,
}
impl RouteLatency {
fn observe(&mut self, seconds: f64) {
self.sum_seconds += seconds;
self.count += 1;
for (i, bound) in LATENCY_BUCKETS_SECONDS.iter().enumerate() {
if seconds <= *bound {
self.bucket_counts[i] += 1;
break;
}
}
}
}
#[derive(Default)]
pub struct ServeMetrics {
requests_total: Mutex<HashMap<(String, String, u16), u64>>,
route_latency: Mutex<HashMap<String, RouteLatency>>,
prompt_tokens_total: AtomicU64,
completion_tokens_total: AtomicU64,
errors_total: Mutex<HashMap<String, u64>>,
}
impl ServeMetrics {
pub fn record_request(&self, method: &str, route: &str, status: u16, dur_seconds: f64) {
let mut requests = self
.requests_total
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*requests
.entry((method.to_string(), route.to_string(), status))
.or_insert(0) += 1;
drop(requests);
let mut latencies = self
.route_latency
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
latencies
.entry(route.to_string())
.or_default()
.observe(dur_seconds);
}
pub fn record_tokens(&self, prompt_tokens: usize, completion_tokens: usize) {
if prompt_tokens > 0 {
self.prompt_tokens_total
.fetch_add(prompt_tokens as u64, Ordering::Relaxed);
}
if completion_tokens > 0 {
self.completion_tokens_total
.fetch_add(completion_tokens as u64, Ordering::Relaxed);
}
}
pub fn record_error(&self, code: &str) {
let mut errors = self
.errors_total
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*errors.entry(code.to_string()).or_insert(0) += 1;
}
pub fn render(&self, model_id: &str, in_flight: usize) -> String {
let model = escape_label(model_id);
let mut out = String::new();
out.push_str(
"# HELP lattice_http_requests_total Total HTTP requests processed.\n\
# TYPE lattice_http_requests_total counter\n",
);
{
let requests = self
.requests_total
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut rows: Vec<_> = requests.iter().collect();
rows.sort();
for ((method, route, status), count) in rows {
out.push_str(&format!(
"lattice_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{status}\",model=\"{model}\"}} {count}\n",
escape_label(method),
escape_label(route),
));
}
}
out.push_str(
"# HELP lattice_http_request_duration_seconds HTTP request latency in seconds.\n\
# TYPE lattice_http_request_duration_seconds histogram\n",
);
{
let latencies = self
.route_latency
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut rows: Vec<_> = latencies.iter().collect();
rows.sort_by(|a, b| a.0.cmp(b.0));
for (route, hist) in rows {
let route = escape_label(route);
let mut cumulative = 0u64;
for (bound, bucket_count) in LATENCY_BUCKETS_SECONDS.iter().zip(hist.bucket_counts)
{
cumulative += bucket_count;
out.push_str(&format!(
"lattice_http_request_duration_seconds_bucket{{route=\"{route}\",model=\"{model}\",le=\"{bound}\"}} {cumulative}\n"
));
}
out.push_str(&format!(
"lattice_http_request_duration_seconds_bucket{{route=\"{route}\",model=\"{model}\",le=\"+Inf\"}} {}\n",
hist.count
));
out.push_str(&format!(
"lattice_http_request_duration_seconds_sum{{route=\"{route}\",model=\"{model}\"}} {}\n",
hist.sum_seconds
));
out.push_str(&format!(
"lattice_http_request_duration_seconds_count{{route=\"{route}\",model=\"{model}\"}} {}\n",
hist.count
));
}
}
out.push_str(&format!(
"# HELP lattice_prompt_tokens_total Total prompt tokens processed.\n\
# TYPE lattice_prompt_tokens_total counter\n\
lattice_prompt_tokens_total{{model=\"{model}\"}} {}\n",
self.prompt_tokens_total.load(Ordering::Relaxed)
));
out.push_str(&format!(
"# HELP lattice_completion_tokens_total Total completion tokens generated.\n\
# TYPE lattice_completion_tokens_total counter\n\
lattice_completion_tokens_total{{model=\"{model}\"}} {}\n",
self.completion_tokens_total.load(Ordering::Relaxed)
));
out.push_str(&format!(
"# HELP lattice_inflight_requests Outstanding (queued + in-flight) requests on the shared worker.\n\
# TYPE lattice_inflight_requests gauge\n\
lattice_inflight_requests{{model=\"{model}\"}} {in_flight}\n"
));
out.push_str(
"# HELP lattice_errors_total Total error responses, labeled by error code.\n\
# TYPE lattice_errors_total counter\n",
);
{
let errors = self
.errors_total
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut rows: Vec<_> = errors.iter().collect();
rows.sort();
for (code, count) in rows {
out.push_str(&format!(
"lattice_errors_total{{code=\"{}\",model=\"{model}\"}} {count}\n",
escape_label(code),
));
}
}
out
}
}
fn escape_label(raw: &str) -> String {
raw.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_request_increments_count_and_histogram() {
let metrics = ServeMetrics::default();
metrics.record_request("GET", "/health", 200, 0.001);
metrics.record_request("GET", "/health", 200, 0.002);
let body = metrics.render("test-model", 0);
assert!(body.contains(
"lattice_http_requests_total{method=\"GET\",route=\"/health\",status=\"200\",model=\"test-model\"} 2\n"
));
assert!(body.contains(
"lattice_http_request_duration_seconds_count{route=\"/health\",model=\"test-model\"} 2\n"
));
}
#[test]
fn record_tokens_accumulates_across_calls() {
let metrics = ServeMetrics::default();
metrics.record_tokens(10, 5);
metrics.record_tokens(3, 7);
let body = metrics.render("m", 0);
assert!(body.contains("lattice_prompt_tokens_total{model=\"m\"} 13\n"));
assert!(body.contains("lattice_completion_tokens_total{model=\"m\"} 12\n"));
}
#[test]
fn record_error_counts_by_code() {
let metrics = ServeMetrics::default();
metrics.record_error("invalid_request");
metrics.record_error("invalid_request");
metrics.record_error("internal_error");
let body = metrics.render("m", 0);
assert!(body.contains("lattice_errors_total{code=\"internal_error\",model=\"m\"} 1\n"));
assert!(body.contains("lattice_errors_total{code=\"invalid_request\",model=\"m\"} 2\n"));
}
#[test]
fn in_flight_gauge_reflects_caller_supplied_snapshot() {
let metrics = ServeMetrics::default();
let body = metrics.render("m", 3);
assert!(body.contains("lattice_inflight_requests{model=\"m\"} 3\n"));
}
#[test]
fn latency_bucket_cumulative_counts_are_monotonic() {
let metrics = ServeMetrics::default();
metrics.record_request("POST", "/v1/chat/completions", 200, 0.01);
metrics.record_request("POST", "/v1/chat/completions", 200, 5.0);
let body = metrics.render("m", 0);
assert!(body.contains(
"lattice_http_request_duration_seconds_bucket{route=\"/v1/chat/completions\",model=\"m\",le=\"0.025\"} 1\n"
));
assert!(body.contains(
"lattice_http_request_duration_seconds_bucket{route=\"/v1/chat/completions\",model=\"m\",le=\"+Inf\"} 2\n"
));
}
#[test]
fn label_escaping_handles_special_characters() {
assert_eq!(escape_label("a\"b\\c\nd"), "a\\\"b\\\\c\\nd");
}
}