use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use tracing::field::{Field, Visit};
use tracing::span::Attributes;
use tracing::{Id, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
#[derive(Debug, Clone)]
pub struct SpanRecord {
pub name: String,
pub target: String,
pub fields: HashMap<String, String>,
}
#[derive(Debug, Clone, Default)]
pub struct InMemorySpans {
inner: Arc<Mutex<Vec<SpanRecord>>>,
}
impl InMemorySpans {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn layer(&self) -> InMemoryLayer {
InMemoryLayer {
inner: Arc::clone(&self.inner),
}
}
pub fn snapshot(&self) -> Vec<SpanRecord> {
self.inner.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
pub fn drain(&self) -> Vec<SpanRecord> {
std::mem::take(&mut *self.inner.lock().unwrap_or_else(|p| p.into_inner()))
}
}
#[derive(Debug, Clone)]
pub struct InMemoryLayer {
inner: Arc<Mutex<Vec<SpanRecord>>>,
}
impl<S> Layer<S> for InMemoryLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, _ctx: Context<'_, S>) {
let metadata = attrs.metadata();
let mut visitor = FieldVisitor::default();
attrs.record(&mut visitor);
let record = SpanRecord {
name: metadata.name().to_string(),
target: metadata.target().to_string(),
fields: visitor.0,
};
self.inner
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(record);
}
}
#[derive(Default)]
struct FieldVisitor(HashMap<String, String>);
impl Visit for FieldVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.0
.insert(field.name().to_string(), format!("{value:?}"));
}
fn record_str(&mut self, field: &Field, value: &str) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.0.insert(field.name().to_string(), value.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use tracing_subscriber::layer::SubscriberExt;
#[test]
fn captures_span_name_and_string_attribute() {
let spans = InMemorySpans::new();
let dispatch =
tracing::subscriber::set_default(tracing_subscriber::registry().with(spans.layer()));
tracing::info_span!(target: "klieo::llm", "llm.call", gen_ai.system = "ollama")
.in_scope(|| {});
drop(dispatch);
let captured = spans.snapshot();
let llm = captured
.iter()
.find(|r| r.name == "llm.call")
.expect("llm.call span captured");
assert_eq!(llm.target, "klieo::llm");
assert_eq!(
llm.fields.get("gen_ai.system").map(String::as_str),
Some("ollama")
);
}
#[test]
fn captures_sibling_spans_in_order() {
let spans = InMemorySpans::new();
let dispatch =
tracing::subscriber::set_default(tracing_subscriber::registry().with(spans.layer()));
tracing::info_span!("first").in_scope(|| {});
tracing::info_span!("second").in_scope(|| {});
tracing::info_span!("third").in_scope(|| {});
drop(dispatch);
let names: Vec<String> = spans
.snapshot()
.into_iter()
.map(|r| r.name)
.filter(|n| n == "first" || n == "second" || n == "third")
.collect();
assert_eq!(names, vec!["first", "second", "third"]);
}
#[test]
fn drain_empties_the_buffer() {
let spans = InMemorySpans::new();
let dispatch =
tracing::subscriber::set_default(tracing_subscriber::registry().with(spans.layer()));
tracing::info_span!("once").in_scope(|| {});
drop(dispatch);
let first = spans.drain();
assert_eq!(first.len(), 1);
let second = spans.snapshot();
assert!(second.is_empty(), "drain must reset the buffer");
}
#[test]
fn captures_numeric_and_boolean_fields() {
let spans = InMemorySpans::new();
let dispatch =
tracing::subscriber::set_default(tracing_subscriber::registry().with(spans.layer()));
tracing::info_span!("metrics", count = 7_i64, hit = true, tokens = 42_u64).in_scope(|| {});
drop(dispatch);
let r = spans.snapshot();
let m = r.iter().find(|x| x.name == "metrics").unwrap();
assert_eq!(m.fields.get("count").map(String::as_str), Some("7"));
assert_eq!(m.fields.get("hit").map(String::as_str), Some("true"));
assert_eq!(m.fields.get("tokens").map(String::as_str), Some("42"));
}
}