1use 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
22pub struct RichLayer {
24 formatter: RichLogFormatter,
25 console: &'static FastMcpConsole,
26 include_timestamps: bool,
27}
28
29impl RichLayer {
30 #[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#[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 #[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 #[must_use]
181 pub fn with_theme(mut self, theme: &'static FastMcpTheme) -> Self {
182 self.theme = Some(theme);
183 self
184 }
185
186 #[must_use]
188 pub fn with_timestamps(mut self, show: bool) -> Self {
189 self.show_timestamps = show;
190 self
191 }
192
193 #[must_use]
195 pub fn with_targets(mut self, show: bool) -> Self {
196 self.show_targets = show;
197 self
198 }
199
200 #[must_use]
202 pub fn with_file_line(mut self, show: bool) -> Self {
203 self.show_file_line = show;
204 self
205 }
206
207 #[must_use]
209 pub fn with_max_width(mut self, width: Option<usize>) -> Self {
210 self.max_width = width;
211 self
212 }
213
214 #[must_use]
216 pub fn with_level_filter(mut self, filter: LevelFilter) -> Self {
217 self.level_filter = filter;
218 self
219 }
220
221 #[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 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 #[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 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 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 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}