Skip to main content

fastmcp_console/logging/
subscriber.rs

1//! Rich tracing subscriber integration.
2//!
3//! Provides a tracing `Layer` and builder that route events through the
4//! [`RichLogFormatter`] for styled output to stderr.
5
6use std::fmt;
7
8use time::{OffsetDateTime, format_description};
9use tracing::field::{Field, Visit};
10use tracing::{Event, Subscriber};
11use tracing_subscriber::filter::LevelFilter;
12use tracing_subscriber::layer::{Context, Layer};
13use tracing_subscriber::prelude::*;
14use tracing_subscriber::registry::LookupSpan;
15
16use crate::console::{FastMcpConsole, strip_markup};
17use crate::detection::DisplayContext;
18use crate::theme::FastMcpTheme;
19
20use super::{LogEvent, LogLevel, RichLogFormatter};
21
22/// A tracing layer that renders events using rich formatting.
23pub struct RichLayer {
24    formatter: RichLogFormatter,
25    console: &'static FastMcpConsole,
26    include_timestamps: bool,
27}
28
29impl RichLayer {
30    /// Create a new rich layer.
31    #[must_use]
32    pub fn new(formatter: RichLogFormatter, include_timestamps: bool) -> Self {
33        Self {
34            formatter,
35            console: crate::console::console(),
36            include_timestamps,
37        }
38    }
39
40    fn timestamp_string(&self) -> Option<String> {
41        if !self.include_timestamps {
42            return None;
43        }
44
45        let now = OffsetDateTime::now_utc();
46        if let Ok(fmt) = format_description::parse("[hour]:[minute]:[second]") {
47            now.format(&fmt).ok()
48        } else {
49            None
50        }
51    }
52}
53
54#[derive(Default)]
55struct FieldCollector {
56    message: Option<String>,
57    fields: Vec<(String, String)>,
58}
59
60impl FieldCollector {
61    fn record_value(&mut self, field: &Field, value: String) {
62        if field.name() == "message" {
63            if self.message.is_none() {
64                self.message = Some(value);
65            }
66        } else {
67            self.fields.push((field.name().to_string(), value));
68        }
69    }
70}
71
72impl Visit for FieldCollector {
73    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
74        self.record_value(field, format!("{value:?}"));
75    }
76
77    fn record_str(&mut self, field: &Field, value: &str) {
78        self.record_value(field, value.to_string());
79    }
80
81    fn record_bool(&mut self, field: &Field, value: bool) {
82        self.record_value(field, value.to_string());
83    }
84
85    fn record_i64(&mut self, field: &Field, value: i64) {
86        self.record_value(field, value.to_string());
87    }
88
89    fn record_u64(&mut self, field: &Field, value: u64) {
90        self.record_value(field, value.to_string());
91    }
92
93    fn record_f64(&mut self, field: &Field, value: f64) {
94        self.record_value(field, value.to_string());
95    }
96}
97
98impl<S> Layer<S> for RichLayer
99where
100    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
101{
102    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
103        let metadata = event.metadata();
104        let mut collector = FieldCollector::default();
105        event.record(&mut collector);
106
107        if let Some(scope) = ctx.event_scope(event) {
108            let spans: Vec<String> = scope
109                .from_root()
110                .map(|span| span.name().to_string())
111                .collect();
112            if !spans.is_empty() {
113                collector
114                    .fields
115                    .push(("span".to_string(), spans.join("::")));
116            }
117        }
118
119        let level = LogLevel::from(*metadata.level());
120        let message = collector
121            .message
122            .unwrap_or_else(|| metadata.name().to_string());
123
124        let mut log_event = LogEvent::new(level, message).with_target(metadata.target());
125
126        if let Some(ts) = self.timestamp_string() {
127            log_event = log_event.with_timestamp(ts);
128        }
129        if let Some(file) = metadata.file() {
130            log_event = log_event.with_file(file);
131        }
132        if let Some(line) = metadata.line() {
133            log_event = log_event.with_line(line);
134        }
135        for (key, value) in collector.fields {
136            log_event = log_event.with_field(key, value);
137        }
138
139        let line = self.formatter.format_line(&log_event);
140        if self.console.is_rich() {
141            self.console.print(&line);
142        } else {
143            eprintln!("{}", strip_markup(&line));
144        }
145    }
146}
147
148/// Builder for configuring a rich tracing subscriber.
149#[derive(Debug)]
150pub struct RichSubscriberBuilder {
151    theme: Option<&'static FastMcpTheme>,
152    show_timestamps: bool,
153    show_targets: bool,
154    show_file_line: bool,
155    max_width: Option<usize>,
156    level_filter: LevelFilter,
157}
158
159impl Default for RichSubscriberBuilder {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl RichSubscriberBuilder {
166    /// Create a new builder with defaults.
167    #[must_use]
168    pub fn new() -> Self {
169        Self {
170            theme: None,
171            show_timestamps: true,
172            show_targets: true,
173            show_file_line: false,
174            max_width: None,
175            level_filter: LevelFilter::INFO,
176        }
177    }
178
179    /// Set a custom theme.
180    #[must_use]
181    pub fn with_theme(mut self, theme: &'static FastMcpTheme) -> Self {
182        self.theme = Some(theme);
183        self
184    }
185
186    /// Toggle timestamp rendering.
187    #[must_use]
188    pub fn with_timestamps(mut self, show: bool) -> Self {
189        self.show_timestamps = show;
190        self
191    }
192
193    /// Toggle target/module rendering.
194    #[must_use]
195    pub fn with_targets(mut self, show: bool) -> Self {
196        self.show_targets = show;
197        self
198    }
199
200    /// Toggle file:line rendering.
201    #[must_use]
202    pub fn with_file_line(mut self, show: bool) -> Self {
203        self.show_file_line = show;
204        self
205    }
206
207    /// Set maximum width for message/target truncation.
208    #[must_use]
209    pub fn with_max_width(mut self, width: Option<usize>) -> Self {
210        self.max_width = width;
211        self
212    }
213
214    /// Set the minimum log level.
215    #[must_use]
216    pub fn with_level_filter(mut self, filter: LevelFilter) -> Self {
217        self.level_filter = filter;
218        self
219    }
220
221    /// Build the subscriber without installing it.
222    #[must_use]
223    pub fn build(self) -> impl Subscriber {
224        let context = DisplayContext::detect();
225        let theme = self.theme.unwrap_or_else(crate::theme::theme);
226
227        let formatter = RichLogFormatter::new(theme, context)
228            .with_timestamp(self.show_timestamps)
229            .with_target(self.show_targets)
230            .with_file_line(self.show_file_line)
231            .with_max_width(self.max_width);
232
233        let layer = RichLayer::new(formatter, self.show_timestamps);
234
235        tracing_subscriber::registry()
236            .with(self.level_filter)
237            .with(layer)
238    }
239
240    /// Build and install as the global subscriber.
241    pub fn init(self) -> Result<(), tracing::subscriber::SetGlobalDefaultError> {
242        let subscriber = self.build();
243        tracing::subscriber::set_global_default(subscriber)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use tracing::{Level, debug, event, info, info_span};
251
252    #[test]
253    fn test_builder_defaults() {
254        let builder = RichSubscriberBuilder::default();
255        assert!(builder.show_timestamps);
256        assert!(builder.show_targets);
257        assert!(!builder.show_file_line);
258        assert_eq!(builder.max_width, None);
259        assert_eq!(builder.level_filter, LevelFilter::INFO);
260    }
261
262    #[test]
263    fn test_builder_builds() {
264        let _subscriber = RichSubscriberBuilder::new().build();
265    }
266
267    #[test]
268    fn test_builder_option_setters() {
269        let builder = RichSubscriberBuilder::new()
270            .with_theme(crate::theme::theme())
271            .with_timestamps(false)
272            .with_targets(false)
273            .with_file_line(true)
274            .with_max_width(Some(64))
275            .with_level_filter(LevelFilter::DEBUG);
276
277        assert!(builder.theme.is_some());
278        assert!(!builder.show_timestamps);
279        assert!(!builder.show_targets);
280        assert!(builder.show_file_line);
281        assert_eq!(builder.max_width, Some(64));
282        assert_eq!(builder.level_filter, LevelFilter::DEBUG);
283    }
284
285    #[test]
286    fn test_rich_layer_timestamp_toggle() {
287        let formatter = RichLogFormatter::new(crate::theme::theme(), DisplayContext::new_agent());
288
289        let no_ts_layer = RichLayer::new(formatter, false);
290        assert_eq!(no_ts_layer.timestamp_string(), None);
291
292        let with_ts_layer = RichLayer::new(
293            RichLogFormatter::new(crate::theme::theme(), DisplayContext::new_agent()),
294            true,
295        );
296        let timestamp = with_ts_layer.timestamp_string();
297        assert!(timestamp.is_some());
298        let timestamp = timestamp.unwrap_or_default();
299        assert_eq!(timestamp.len(), 8);
300        assert_eq!(timestamp.chars().nth(2), Some(':'));
301        assert_eq!(timestamp.chars().nth(5), Some(':'));
302    }
303
304    #[test]
305    fn test_layer_processes_event_without_span_scope() {
306        let formatter = RichLogFormatter::new(crate::theme::theme(), DisplayContext::new_agent())
307            .with_timestamp(false)
308            .with_target(true)
309            .with_file_line(true)
310            .with_max_width(Some(80));
311        let layer = RichLayer::new(formatter, false);
312        let subscriber = tracing_subscriber::registry().with(layer);
313
314        tracing::subscriber::with_default(subscriber, || {
315            event!(Level::INFO, action = "sync");
316            info!(
317                message = "plain_event",
318                user = "alice",
319                retries = 2_u64,
320                ok = true
321            );
322        });
323    }
324
325    #[test]
326    fn test_layer_processes_event_with_span_scope_and_all_field_types() {
327        let formatter = RichLogFormatter::new(crate::theme::theme(), DisplayContext::new_agent())
328            .with_timestamp(true)
329            .with_target(true)
330            .with_file_line(true)
331            .with_max_width(Some(120));
332        let layer = RichLayer::new(formatter, true);
333        let subscriber = tracing_subscriber::registry().with(layer);
334
335        tracing::subscriber::with_default(subscriber, || {
336            let span = info_span!("subscriber_scope");
337            let _guard = span.enter();
338
339            info!(
340                message = "structured",
341                flag = true,
342                count_i = -5_i64,
343                count_u = 42_u64,
344                ratio = 3.5_f64,
345                debug_val = ?vec![1, 2, 3]
346            );
347
348            debug!(message = "second_message");
349        });
350    }
351
352    // =========================================================================
353    // Additional coverage tests (bd-i167)
354    // =========================================================================
355
356    #[test]
357    fn field_collector_default_is_empty() {
358        let collector = FieldCollector::default();
359        assert!(collector.message.is_none());
360        assert!(collector.fields.is_empty());
361    }
362
363    #[test]
364    fn field_collector_message_only_set_once() {
365        use tracing::field::FieldSet;
366
367        let mut collector = FieldCollector::default();
368
369        // Simulate first message field
370        let fields = FieldSet::new(&["message"], tracing::callsite::Identifier(&NOP_CALLSITE));
371        let field = fields.field("message").unwrap();
372        collector.record_str(&field, "first");
373        assert_eq!(collector.message.as_deref(), Some("first"));
374
375        // Second message field is ignored
376        collector.record_str(&field, "second");
377        assert_eq!(collector.message.as_deref(), Some("first"));
378    }
379
380    #[test]
381    fn field_collector_non_message_fields_accumulate() {
382        use tracing::field::FieldSet;
383
384        let mut collector = FieldCollector::default();
385
386        let fields = FieldSet::new(&["user"], tracing::callsite::Identifier(&NOP_CALLSITE));
387        let field = fields.field("user").unwrap();
388        collector.record_str(&field, "alice");
389
390        assert!(collector.message.is_none());
391        assert_eq!(collector.fields.len(), 1);
392        assert_eq!(collector.fields[0].0, "user");
393        assert_eq!(collector.fields[0].1, "alice");
394    }
395
396    #[test]
397    fn rich_subscriber_builder_debug_output() {
398        let builder = RichSubscriberBuilder::new();
399        let debug = format!("{builder:?}");
400        assert!(debug.contains("RichSubscriberBuilder"));
401        assert!(debug.contains("show_timestamps"));
402        assert!(debug.contains("level_filter"));
403    }
404
405    #[test]
406    fn field_collector_record_typed_values() {
407        use tracing::field::FieldSet;
408
409        let mut collector = FieldCollector::default();
410        let fields = FieldSet::new(
411            &["flag", "count_i", "count_u", "ratio"],
412            tracing::callsite::Identifier(&NOP_CALLSITE),
413        );
414
415        let flag = fields.field("flag").unwrap();
416        collector.record_bool(&flag, true);
417
418        let count_i = fields.field("count_i").unwrap();
419        collector.record_i64(&count_i, -42);
420
421        let count_u = fields.field("count_u").unwrap();
422        collector.record_u64(&count_u, 100);
423
424        let ratio = fields.field("ratio").unwrap();
425        collector.record_f64(&ratio, 3.14);
426
427        assert_eq!(collector.fields.len(), 4);
428        assert_eq!(
429            collector.fields[0],
430            ("flag".to_string(), "true".to_string())
431        );
432        assert_eq!(
433            collector.fields[1],
434            ("count_i".to_string(), "-42".to_string())
435        );
436        assert_eq!(
437            collector.fields[2],
438            ("count_u".to_string(), "100".to_string())
439        );
440        assert_eq!(
441            collector.fields[3],
442            ("ratio".to_string(), "3.14".to_string())
443        );
444    }
445
446    #[test]
447    fn field_collector_record_debug_format() {
448        use tracing::field::FieldSet;
449
450        let mut collector = FieldCollector::default();
451        let fields = FieldSet::new(&["data"], tracing::callsite::Identifier(&NOP_CALLSITE));
452        let field = fields.field("data").unwrap();
453        collector.record_debug(&field, &vec![1, 2, 3]);
454
455        assert_eq!(collector.fields.len(), 1);
456        assert_eq!(collector.fields[0].0, "data");
457        assert_eq!(collector.fields[0].1, "[1, 2, 3]");
458    }
459
460    #[test]
461    fn builder_default_matches_new() {
462        let def = RichSubscriberBuilder::default();
463        let new = RichSubscriberBuilder::new();
464        assert_eq!(def.show_timestamps, new.show_timestamps);
465        assert_eq!(def.show_targets, new.show_targets);
466        assert_eq!(def.show_file_line, new.show_file_line);
467        assert_eq!(def.max_width, new.max_width);
468        assert_eq!(def.level_filter, new.level_filter);
469    }
470
471    #[test]
472    fn builder_with_max_width_none_clears() {
473        let builder = RichSubscriberBuilder::new()
474            .with_max_width(Some(80))
475            .with_max_width(None);
476        assert_eq!(builder.max_width, None);
477    }
478
479    // Minimal callsite for field tests.
480    static NOP_CALLSITE: NopCallsite = NopCallsite;
481
482    struct NopCallsite;
483
484    impl tracing::callsite::Callsite for NopCallsite {
485        fn set_interest(&self, _interest: tracing::subscriber::Interest) {}
486        fn metadata(&self) -> &tracing::Metadata<'_> {
487            static META: tracing::Metadata<'static> = tracing::Metadata::new(
488                "nop",
489                "test",
490                Level::INFO,
491                None,
492                None,
493                None,
494                tracing::field::FieldSet::new(
495                    &[],
496                    tracing::callsite::Identifier(&NOP_CALLSITE_INNER),
497                ),
498                tracing::metadata::Kind::EVENT,
499            );
500            &META
501        }
502    }
503
504    static NOP_CALLSITE_INNER: NopCallsite = NopCallsite;
505}