Skip to main content

tracing_systemd/
layer.rs

1//! [`SystemdLayer`]: a `tracing-subscriber` Layer that pretty-prints
2//! span chains, with optional ANSI colors and timestamps.
3
4use std::borrow::Cow;
5use std::fmt;
6
7use tracing::{Event, Subscriber};
8use tracing_subscriber::Layer;
9use tracing_subscriber::layer::Context;
10use tracing_subscriber::registry::LookupSpan;
11
12use crate::format::event::{EventInput, render_event};
13use crate::format::span_chain::SpanLink;
14use crate::format::{FormatConfig, TimestampFormat, syslog_prefix};
15use crate::output::Output;
16use crate::visit::{FieldMap, FieldStorage, FieldVisitor};
17
18#[cfg(feature = "colors")]
19use crate::format::color::{ColorMode, ColorTheme};
20
21#[cfg(feature = "json")]
22use crate::format::RenderMode;
23#[cfg(feature = "json")]
24use crate::format::json::render_event_json;
25
26/// A `tracing-subscriber` Layer that emits a pretty span-chain line per event.
27///
28/// Construct with [`SystemdLayer::stdout`], then chain `with_*` methods to
29/// configure formatting, and pass it to a `tracing-subscriber::registry()`.
30///
31/// ```no_run
32/// use tracing_subscriber::prelude::*;
33/// use tracing_systemd::SystemdLayer;
34///
35/// tracing_subscriber::registry()
36///     .with(SystemdLayer::stdout().with_target(true))
37///     .init();
38/// ```
39pub struct SystemdLayer {
40    config: FormatConfig,
41    output: Output,
42    #[cfg(feature = "colors")]
43    color_mode: ColorMode,
44    #[cfg(feature = "colors")]
45    color_theme: ColorTheme,
46}
47
48impl fmt::Debug for SystemdLayer {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        let mut d = f.debug_struct("SystemdLayer");
51        d.field("config", &self.config).field("output", &self.output);
52        #[cfg(feature = "colors")]
53        d.field("color_mode", &self.color_mode)
54            .field("color_theme", &self.color_theme);
55        d.finish()
56    }
57}
58
59impl Default for SystemdLayer {
60    fn default() -> Self {
61        Self::stdout()
62    }
63}
64
65// ---------- Constructors ----------
66
67impl SystemdLayer {
68    /// Construct a layer that writes formatted lines to standard output.
69    /// All formatting options have sensible defaults; chain `with_*`
70    /// methods to override them.
71    #[must_use]
72    pub fn stdout() -> Self {
73        Self {
74            config: FormatConfig::default(),
75            output: Output::stdout(),
76            #[cfg(feature = "colors")]
77            color_mode: ColorMode::default(),
78            #[cfg(feature = "colors")]
79            color_theme: ColorTheme::default(),
80        }
81    }
82
83    /// Construct a layer that writes formatted lines to standard error.
84    #[must_use]
85    pub fn stderr() -> Self {
86        Self {
87            config: FormatConfig::default(),
88            output: Output::stderr(),
89            #[cfg(feature = "colors")]
90            color_mode: ColorMode::default(),
91            #[cfg(feature = "colors")]
92            color_theme: ColorTheme::default(),
93        }
94    }
95
96    /// Construct a layer that writes formatted lines to a journald-friendly
97    /// stream — i.e. **stdout with syslog-priority prefixes** (`<3>`–`<7>`).
98    /// `journalctl` understands these prefixes when no native journal field
99    /// is supplied, which is what happens for processes managed by a
100    /// systemd unit (their stdout is already routed to the journal).
101    ///
102    /// For direct journal-protocol writes (without going through stdout),
103    /// enable the `journald` feature and use [`crate::journald::layer`].
104    #[must_use]
105    pub fn unit_stdout() -> Self {
106        Self {
107            config: FormatConfig {
108                use_level_prefix: true,
109                ..FormatConfig::default()
110            },
111            output: Output::stdout(),
112            #[cfg(feature = "colors")]
113            color_mode: ColorMode::Never,
114            #[cfg(feature = "colors")]
115            color_theme: ColorTheme::monochrome(),
116        }
117    }
118
119    /// Construct a layer that emits a single-line JSON object per event to
120    /// standard output.
121    ///
122    /// Defaults differ from the pretty-mode constructors: `target` is on,
123    /// timestamps are on with [`TimestampFormat::Rfc3339`], and the syslog
124    /// level prefix is off (so each line is a valid standalone JSON object).
125    /// Pretty-only builders (`with_color_*`, separators, brackets, thread-id
126    /// prefix/suffix) compile but have no effect in JSON mode.
127    ///
128    /// Schema (per line):
129    ///
130    /// ```json
131    /// {
132    ///   "timestamp": "2026-05-05T14:23:45.123Z",
133    ///   "level": "INFO",
134    ///   "message": "served request",
135    ///   "target": "my_app::handlers",
136    ///   "span_chain": [
137    ///     {"name": "request", "fields": {"method": "GET"}},
138    ///     {"name": "handler", "fields": {}}
139    ///   ],
140    ///   "fields": {"latency_ms": 4}
141    /// }
142    /// ```
143    ///
144    /// `thread_id` is added at the top level when [`Self::with_thread_ids`]
145    /// is `true`. Non-finite `f64` values become JSON `null`, matching
146    /// `tracing-subscriber`'s JSON formatter.
147    #[cfg(feature = "json")]
148    #[cfg_attr(docsrs, doc(cfg(feature = "json")))]
149    #[must_use]
150    pub fn json() -> Self {
151        Self {
152            config: FormatConfig {
153                mode: RenderMode::Json,
154                show_target: true,
155                show_timestamp: true,
156                timestamp_format: TimestampFormat::Rfc3339,
157                use_level_prefix: false,
158                ..FormatConfig::default()
159            },
160            output: Output::stdout(),
161            #[cfg(feature = "colors")]
162            color_mode: ColorMode::Never,
163            #[cfg(feature = "colors")]
164            color_theme: ColorTheme::monochrome(),
165        }
166    }
167}
168
169// ---------- Builder methods ----------
170
171impl SystemdLayer {
172    /// Override the destination. Useful for `Output::stderr()` or
173    /// `Output::writer(buf)` (test capture).
174    #[must_use]
175    pub fn with_output(mut self, output: Output) -> Self {
176        self.output = output;
177        self
178    }
179
180    /// Show the event's `target` (typically the module path) before the span chain.
181    /// Default: `false`.
182    #[must_use]
183    pub fn with_target(mut self, show: bool) -> Self {
184        self.config.show_target = show;
185        self
186    }
187
188    /// Include the OS thread id in each line. Default: `false`.
189    #[must_use]
190    pub fn with_thread_ids(mut self, show: bool) -> Self {
191        self.config.show_thread_id = show;
192        self
193    }
194
195    /// Show a timestamp at the start of each line. Default: `false`.
196    /// See also [`SystemdLayer::with_timestamp_format`].
197    #[must_use]
198    pub fn with_timestamps(mut self, show: bool) -> Self {
199        self.config.show_timestamp = show;
200        self
201    }
202
203    /// Choose the timestamp format. Implies `with_timestamps(true)` for any
204    /// non-`None` value. Default: [`TimestampFormat::None`].
205    #[must_use]
206    pub fn with_timestamp_format(mut self, format: TimestampFormat) -> Self {
207        self.config.timestamp_format = format;
208        if format != TimestampFormat::None {
209            self.config.show_timestamp = true;
210        }
211        self
212    }
213
214    /// Emit a syslog-priority prefix (`<3>` – `<7>`) before each line. This
215    /// is what `journalctl` uses to assign a `PRIORITY` when ingesting plain
216    /// stdout from a systemd unit. Default: `true`.
217    #[must_use]
218    pub fn with_level_prefix(mut self, use_prefix: bool) -> Self {
219        self.config.use_level_prefix = use_prefix;
220        self
221    }
222
223    /// Separator between spans in the chain. Default: `"::"`.
224    #[must_use]
225    pub fn with_span_separator(mut self, sep: impl Into<Cow<'static, str>>) -> Self {
226        self.config.span_separator = sep.into();
227        self
228    }
229
230    /// Separator between the level/span chain and the event message. Default: `": "`.
231    #[must_use]
232    pub fn with_message_separator(mut self, sep: impl Into<Cow<'static, str>>) -> Self {
233        self.config.message_separator = sep.into();
234        self
235    }
236
237    /// Separator after the level. Default: `" "`.
238    #[must_use]
239    pub fn with_level_separator(mut self, sep: impl Into<Cow<'static, str>>) -> Self {
240        self.config.level_separator = sep.into();
241        self
242    }
243
244    /// String wrapping the *opening* of a span argument list. Default: `"("`.
245    #[must_use]
246    pub fn with_function_bracket_left(mut self, s: impl Into<Cow<'static, str>>) -> Self {
247        self.config.function_bracket_left = s.into();
248        self
249    }
250
251    /// String wrapping the *closing* of a span argument list. Default: `")"`.
252    #[must_use]
253    pub fn with_function_bracket_right(mut self, s: impl Into<Cow<'static, str>>) -> Self {
254        self.config.function_bracket_right = s.into();
255        self
256    }
257
258    /// String between an argument name and its value. Default: `": "`.
259    #[must_use]
260    pub fn with_arguments_equality(mut self, s: impl Into<Cow<'static, str>>) -> Self {
261        self.config.arguments_equality = s.into();
262        self
263    }
264
265    /// String between consecutive arguments. Default: `", "`.
266    #[must_use]
267    pub fn with_arguments_separator(mut self, s: impl Into<Cow<'static, str>>) -> Self {
268        self.config.arguments_separator = s.into();
269        self
270    }
271
272    /// Prefix for the thread id when `with_thread_ids(true)`. Default: `"["`.
273    #[must_use]
274    pub fn with_thread_id_prefix(mut self, s: impl Into<Cow<'static, str>>) -> Self {
275        self.config.thread_id_prefix = s.into();
276        self
277    }
278
279    /// Suffix for the thread id when `with_thread_ids(true)`. Default: `"] "`.
280    #[must_use]
281    pub fn with_thread_id_suffix(mut self, s: impl Into<Cow<'static, str>>) -> Self {
282        self.config.thread_id_suffix = s.into();
283        self
284    }
285
286    /// Set the [`ColorMode`]. Default: [`ColorMode::Auto`] (color iff TTY
287    /// and `NO_COLOR` is unset).
288    #[cfg(feature = "colors")]
289    #[cfg_attr(docsrs, doc(cfg(feature = "colors")))]
290    #[must_use]
291    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
292        self.color_mode = mode;
293        self
294    }
295
296    /// Set the [`ColorTheme`]. Default: [`ColorTheme::default`] (matches 0.1).
297    #[cfg(feature = "colors")]
298    #[cfg_attr(docsrs, doc(cfg(feature = "colors")))]
299    #[must_use]
300    pub fn with_color_theme(mut self, theme: ColorTheme) -> Self {
301        self.color_theme = theme;
302        self
303    }
304}
305
306// ---------- Layer impl ----------
307
308impl<S> Layer<S> for SystemdLayer
309where
310    S: Subscriber + for<'a> LookupSpan<'a>,
311{
312    fn on_new_span(
313        &self,
314        attrs: &tracing::span::Attributes<'_>,
315        id: &tracing::span::Id,
316        ctx: Context<'_, S>,
317    ) {
318        let mut fields = FieldMap::new();
319        attrs.record(&mut FieldVisitor::new(&mut fields));
320
321        if let Some(span) = ctx.span(id) {
322            span.extensions_mut().insert(FieldStorage(fields));
323        }
324    }
325
326    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
327        // Walk parent spans and copy their fields out.
328        // The leaf span (if any) is the *last* element; everything before
329        // it is "parents" in the chain.
330        let mut chain: Vec<SpanLink> = Vec::new();
331        if let Some(scope) = ctx.event_scope(event) {
332            for span in scope.from_root() {
333                let exts = span.extensions();
334                let fields = exts
335                    .get::<FieldStorage>()
336                    .map(|s| s.0.clone())
337                    .unwrap_or_default();
338                chain.push(SpanLink {
339                    name: span.name(),
340                    fields,
341                });
342            }
343        }
344        let leaf = chain.last().cloned();
345        let parents: &[SpanLink] = if chain.is_empty() {
346            &[]
347        } else {
348            &chain[..chain.len() - 1]
349        };
350
351        // Visit the event's own fields.
352        let mut event_fields = FieldMap::new();
353        event.record(&mut FieldVisitor::new(&mut event_fields));
354
355        let metadata = event.metadata();
356        let level = *metadata.level();
357
358        let input = EventInput {
359            level,
360            target: metadata.target(),
361            parents,
362            leaf: leaf.as_ref(),
363            fields: &event_fields,
364        };
365
366        let line = self.render(&input);
367
368        if self.config.use_level_prefix {
369            self.output.write_line(&format!("{}{}", syslog_prefix(level), line));
370        } else {
371            self.output.write_line(&line);
372        }
373    }
374}
375
376impl SystemdLayer {
377    fn render(&self, input: &EventInput<'_>) -> String {
378        #[cfg(feature = "json")]
379        if matches!(self.config.mode, RenderMode::Json) {
380            return render_event_json(&self.config, input);
381        }
382
383        #[cfg(feature = "colors")]
384        {
385            let use_color = self.color_mode.resolve_now(self.output.is_terminal());
386            let theme = if use_color { Some(&self.color_theme) } else { None };
387            render_event(&self.config, input, theme)
388        }
389        #[cfg(not(feature = "colors"))]
390        {
391            render_event(&self.config, input)
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::io::Write;
400    use std::sync::{Arc, Mutex};
401    use tracing::{Level, info, info_span, warn};
402    use tracing_subscriber::prelude::*;
403
404    #[derive(Clone, Default)]
405    struct Buf(Arc<Mutex<Vec<u8>>>);
406    impl Write for Buf {
407        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
408            self.0.lock().unwrap().extend_from_slice(b);
409            Ok(b.len())
410        }
411        fn flush(&mut self) -> std::io::Result<()> {
412            Ok(())
413        }
414    }
415
416    fn capture<F: FnOnce()>(layer: SystemdLayer, body: F) -> String {
417        let buf = Buf::default();
418        let captured = buf.0.clone();
419        let layer = layer.with_output(Output::writer(buf));
420        tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), body);
421        let bytes = captured.lock().unwrap().clone();
422        String::from_utf8(bytes).expect("utf-8 output")
423    }
424
425    #[test]
426    fn bare_info_event() {
427        let layer = SystemdLayer::stdout().with_level_prefix(false);
428        let out = capture(layer, || {
429            info!("hello");
430        });
431        // Target depends on test crate name; just check the suffix.
432        assert!(out.ends_with(": hello\n"), "got {out:?}");
433        assert!(out.starts_with("INFO "), "got {out:?}");
434        let _ = Level::INFO; // silence unused
435    }
436
437    #[test]
438    fn span_arguments_appear_in_output() {
439        let layer = SystemdLayer::stdout().with_level_prefix(false);
440        let out = capture(layer, || {
441            let span = info_span!("worker", id = 7u64);
442            let _g = span.enter();
443            warn!("done");
444        });
445        assert!(out.contains("worker(id: 7)"), "got {out:?}");
446        assert!(out.contains("done"), "got {out:?}");
447    }
448
449    #[test]
450    fn level_prefix_emits_syslog_marker() {
451        let layer = SystemdLayer::stdout().with_level_prefix(true);
452        let out = capture(layer, || {
453            info!("p");
454        });
455        assert!(out.starts_with("<5>INFO"), "got {out:?}");
456    }
457}