Skip to main content

boost/
log_capture.rs

1//! In-memory log capture layer for `read-log-entries` / `last-error`.
2//!
3//! Plugs into the existing `tracing` stack as a Layer that records the last N
4//! events to a ring buffer. The MCP server reads from the buffer on demand.
5
6use std::sync::Arc;
7
8use chrono::{DateTime, Utc};
9use parking_lot::Mutex;
10use serde::Serialize;
11use tracing::{Event, Subscriber};
12use tracing_subscriber::layer::{Context, Layer};
13use tracing_subscriber::registry::LookupSpan;
14
15const MAX_ENTRIES: usize = 5_000;
16
17#[derive(Debug, Clone, Serialize)]
18pub struct LogEntry {
19    pub timestamp: DateTime<Utc>,
20    pub level: String,
21    pub target: String,
22    pub message: String,
23    pub fields: serde_json::Value,
24}
25
26pub struct LogBuffer {
27    entries: Mutex<std::collections::VecDeque<LogEntry>>,
28}
29
30impl LogBuffer {
31    pub fn new() -> Arc<Self> {
32        Arc::new(Self {
33            entries: Mutex::new(std::collections::VecDeque::with_capacity(MAX_ENTRIES)),
34        })
35    }
36
37    pub fn push(&self, entry: LogEntry) {
38        let mut g = self.entries.lock();
39        if g.len() == MAX_ENTRIES {
40            g.pop_front();
41        }
42        g.push_back(entry);
43    }
44
45    pub fn tail(&self, n: usize) -> Vec<LogEntry> {
46        let g = self.entries.lock();
47        let start = g.len().saturating_sub(n);
48        g.iter().skip(start).cloned().collect()
49    }
50
51    pub fn last_error(&self) -> Option<LogEntry> {
52        let g = self.entries.lock();
53        g.iter()
54            .rev()
55            .find(|e| e.level.eq_ignore_ascii_case("ERROR"))
56            .cloned()
57    }
58
59    pub fn count(&self) -> usize {
60        self.entries.lock().len()
61    }
62}
63
64pub struct CaptureLayer {
65    buffer: Arc<LogBuffer>,
66}
67
68impl CaptureLayer {
69    pub fn new(buffer: Arc<LogBuffer>) -> Self {
70        Self { buffer }
71    }
72}
73
74impl<S> Layer<S> for CaptureLayer
75where
76    S: Subscriber + for<'a> LookupSpan<'a>,
77{
78    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
79        let mut visitor = FieldVisitor::default();
80        event.record(&mut visitor);
81
82        let metadata = event.metadata();
83        let entry = LogEntry {
84            timestamp: Utc::now(),
85            level: metadata.level().to_string(),
86            target: metadata.target().to_string(),
87            message: visitor.message.unwrap_or_default(),
88            fields: serde_json::Value::Object(visitor.fields),
89        };
90        self.buffer.push(entry);
91    }
92}
93
94#[derive(Default)]
95struct FieldVisitor {
96    message: Option<String>,
97    fields: serde_json::Map<String, serde_json::Value>,
98}
99
100impl tracing::field::Visit for FieldVisitor {
101    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
102        let value = format!("{value:?}");
103        if field.name() == "message" {
104            self.message = Some(value);
105        } else {
106            self.fields
107                .insert(field.name().to_string(), serde_json::Value::String(value));
108        }
109    }
110
111    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
112        if field.name() == "message" {
113            self.message = Some(value.to_string());
114        } else {
115            self.fields.insert(
116                field.name().to_string(),
117                serde_json::Value::String(value.to_string()),
118            );
119        }
120    }
121
122    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
123        self.fields
124            .insert(field.name().to_string(), serde_json::json!(value));
125    }
126
127    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
128        self.fields
129            .insert(field.name().to_string(), serde_json::json!(value));
130    }
131
132    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
133        self.fields
134            .insert(field.name().to_string(), serde_json::json!(value));
135    }
136}
137
138/// Install the capture layer on the global tracing subscriber. Idempotent —
139/// safe to call multiple times. Returns the shared buffer.
140pub fn install() -> Arc<LogBuffer> {
141    use tracing_subscriber::prelude::*;
142    let buffer = LogBuffer::new();
143    let layer = CaptureLayer::new(buffer.clone());
144    // Try to attach to the existing subscriber. If none is set, build one.
145    let _ = tracing_subscriber::registry().with(layer).try_init();
146    buffer
147}