Skip to main content

tuwunel_core/log/
console.rs

1//! Console formatting and output routing.
2//!
3//! The module selects stdout, stderr, or native journal output and formats each
4//! event according to logging configuration.
5
6use std::{
7	env, io,
8	io::{IsTerminal, stdin},
9	sync::LazyLock,
10};
11
12use tracing::{
13	Event, Level, Metadata, Subscriber,
14	field::{Field, Visit},
15};
16use tracing_subscriber::{
17	field::RecordFields,
18	fmt,
19	fmt::{
20		FmtContext, FormatEvent, FormatFields, MakeWriter,
21		format::{Compact, DefaultVisitor, Format, Full, Pretty, Writer},
22	},
23	registry::LookupSpan,
24};
25
26use super::journald::{Entry, Journal, enabled as journald_enabled};
27use crate::{Config, Result, apply, debug, is_equal_to};
28
29static SYSTEMD_MODE: LazyLock<bool> =
30	LazyLock::new(|| env::var("SYSTEMD_EXEC_PID").is_ok() && env::var("JOURNAL_STREAM").is_ok());
31
32static TERMINAL_MODE: LazyLock<bool> = LazyLock::new(|| stdin().is_terminal());
33
34/// Routes formatted tracing events to console streams or the native journal.
35///
36/// Construction detects systemd stream mode and configured output preferences.
37/// Each event can then select a destination through `MakeWriter`.
38pub struct ConsoleWriter {
39	stdout: io::Stdout,
40	stderr: io::Stderr,
41	_journal_stream: [u64; 2],
42	use_stderr: bool,
43	journal: Option<Journal>,
44}
45
46/// Writable destination selected for one formatted tracing event.
47///
48/// Console output delegates to the shared writer while journal output owns an
49/// entry buffer that submits when dropped.
50pub enum Sink<'a> {
51	/// Standard output or standard error through the shared console writer.
52	///
53	/// The writer selects the actual file descriptor from process and
54	/// configuration state.
55	Console(&'a ConsoleWriter),
56
57	/// Native journal entry associated with the event metadata.
58	///
59	/// Formatted bytes accumulate in the entry and are submitted when it is
60	/// dropped.
61	Journal(Entry<'a>),
62}
63
64impl ConsoleWriter {
65	/// Creates an output router from logging configuration and process state.
66	///
67	/// A detected journal stream or explicit setting selects standard error for
68	/// console output. Native journal submission is opened when enabled and
69	/// available.
70	#[must_use]
71	pub fn new(config: &Config) -> Self {
72		let journal_stream = get_journal_stream();
73
74		Self {
75			stdout: io::stdout(),
76			stderr: io::stderr(),
77			_journal_stream: journal_stream.into(),
78			use_stderr: journal_stream.0 != 0 || config.log_to_stderr,
79			journal: Journal::open(config),
80		}
81	}
82}
83
84impl<'a> MakeWriter<'a> for ConsoleWriter {
85	type Writer = Sink<'a>;
86
87	fn make_writer(&'a self) -> Self::Writer { Sink::Console(self) }
88
89	fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Self::Writer {
90		self.journal
91			.as_ref()
92			.map_or(Sink::Console(self), |journal| Sink::Journal(journal.entry(meta)))
93	}
94}
95
96impl io::Write for Sink<'_> {
97	fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
98		match self {
99			| Self::Console(console) => console.write(buf),
100			| Self::Journal(entry) => entry.write(buf),
101		}
102	}
103
104	fn flush(&mut self) -> io::Result<()> {
105		match self {
106			| Self::Console(console) => console.flush(),
107			| Self::Journal(entry) => entry.flush(),
108		}
109	}
110}
111
112impl io::Write for &'_ ConsoleWriter {
113	fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
114		if self.use_stderr {
115			self.stderr.lock().write(buf)
116		} else {
117			self.stdout.lock().write(buf)
118		}
119	}
120
121	fn flush(&mut self) -> io::Result<()> {
122		if self.use_stderr {
123			self.stderr.lock().flush()
124		} else {
125			self.stdout.lock().flush()
126		}
127	}
128}
129
130/// Selects the configured tracing formatter for each console event.
131///
132/// Compact mode applies globally. Otherwise non-debug errors use the pretty
133/// formatter and remaining events use the full formatter. ANSI follows
134/// `log_colors` and is disabled for native journal submission.
135pub struct ConsoleFormat {
136	pretty: Format<Pretty>,
137	full: Format<Full>,
138	compact: Format<Compact>,
139	compact_mode: bool,
140}
141
142impl ConsoleFormat {
143	/// Creates console formatters from logging configuration.
144	///
145	/// The method configures ANSI output, thread identifiers, source locations,
146	/// and compact-mode selection. All formatter variants share the same ANSI
147	/// decision.
148	#[must_use]
149	pub fn new(config: &Config) -> Self {
150		let ansi = ansi_enabled(config);
151
152		Self {
153			pretty: fmt::format()
154				.pretty()
155				.with_ansi(ansi)
156				.with_thread_names(true)
157				.with_thread_ids(true)
158				.with_target(true)
159				.with_file(true)
160				.with_line_number(true)
161				.with_source_location(true),
162
163			full: Format::<Full>::default()
164				.with_thread_ids(config.log_thread_ids)
165				.with_ansi(ansi),
166
167			compact: fmt::format().compact().with_ansi(ansi),
168
169			compact_mode: config.log_compact,
170		}
171	}
172}
173
174impl<S, N> FormatEvent<S, N> for ConsoleFormat
175where
176	S: Subscriber + for<'a> LookupSpan<'a>,
177	N: for<'a> FormatFields<'a> + 'static,
178{
179	fn format_event(
180		&self,
181		ctx: &FmtContext<'_, S, N>,
182		writer: Writer<'_>,
183		event: &Event<'_>,
184	) -> Result<(), std::fmt::Error> {
185		let is_debug = debug::logging()
186			&& event
187				.fields()
188				.map(|field| field.name())
189				.any(is_equal_to!("_debug"));
190
191		match *event.metadata().level() {
192			| _ if self.compact_mode => self.compact.format_event(ctx, writer, event),
193			| Level::ERROR if !is_debug => self.pretty.format_event(ctx, writer, event),
194			| _ => self.full.format_event(ctx, writer, event),
195		}
196	}
197}
198
199struct ConsoleVisitor<'a> {
200	visitor: DefaultVisitor<'a>,
201}
202
203impl<'writer> FormatFields<'writer> for ConsoleFormat {
204	fn format_fields<R>(&self, writer: Writer<'writer>, fields: R) -> Result<(), std::fmt::Error>
205	where
206		R: RecordFields,
207	{
208		let mut visitor = ConsoleVisitor {
209			visitor: DefaultVisitor::<'_>::new(writer, true),
210		};
211
212		fields.record(&mut visitor);
213
214		Ok(())
215	}
216}
217
218impl Visit for ConsoleVisitor<'_> {
219	fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
220		if field.name().starts_with('_') {
221			return;
222		}
223
224		self.visitor.record_debug(field, value);
225	}
226}
227
228#[must_use]
229fn get_journal_stream() -> (u64, u64) {
230	is_systemd_mode()
231		.then(|| env::var("JOURNAL_STREAM").ok())
232		.flatten()
233		.as_deref()
234		.and_then(|s| s.split_once(':'))
235		.map(apply!(2, str::parse))
236		.map(apply!(2, Result::unwrap_or_default))
237		.unwrap_or((0, 0))
238}
239
240/// Whether to color the formatted line.
241///
242/// The journal takes that line verbatim and classifies a message carrying
243/// control bytes as binary rather than text, so colors are suppressed while
244/// entries are submitted to it.
245#[inline]
246#[must_use]
247pub fn ansi_enabled(config: &Config) -> bool { config.log_colors && !journald_enabled(config) }
248
249/// Whether the process was started by systemd, sampled once.
250///
251/// Both `SYSTEMD_EXEC_PID` and `JOURNAL_STREAM` have to be present, which the
252/// service manager sets for a unit it launched itself.
253#[inline]
254#[must_use]
255pub fn is_systemd_mode() -> bool { *SYSTEMD_MODE }
256
257/// Whether standard input is attached to a terminal, sampled once.
258#[inline]
259#[must_use]
260pub fn is_terminal_mode() -> bool { *TERMINAL_MODE }