fix(bin): make observability.log_format actually take effect
The bootstrap subscriber installed before the config was loaded won the race for tracing's global subscriber, which can only be set once. The configured subscriber's try_init() was therefore always a silent no-op and observability.log_format was inert -- the deployed service, with log_format = "json", was writing ANSI-coloured text into the journal. The escape codes land in journald as literal bytes. Loads the configuration first and installs the subscriber once, since the configuration is what decides the format. Nothing is lost: a config load failure leaves main as an anyhow error with its context on stderr, which is where it was going anyway. Also disables ANSI unless stderr is a terminal. tracing_subscriber colours by default regardless of the writer, so escape codes were going into the journal and would go into any redirected log file. Verified: json emits one parseable object per line with no escapes, text emits plain text, and JOURNAL_STREAM still forces json for a service started by systemd whatever the file says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
This commit is contained in:
@@ -1,36 +1,39 @@
|
||||
//! Log setup.
|
||||
//!
|
||||
//! Structured JSON when systemd is listening, pretty text when a person is.
|
||||
//! Detection is by configuration first and `JOURNAL_STREAM` second, so a
|
||||
//! deliberate `log_format = "text"` is not overridden by the environment.
|
||||
//! Structured JSON when systemd is listening, human-readable text when a
|
||||
//! person is. The subscriber is installed exactly once, after configuration is
|
||||
//! loaded, because `tracing`'s global subscriber can only be set once: an
|
||||
//! earlier bootstrap subscriber would silently win and make
|
||||
//! `observability.log_format` inert.
|
||||
//!
|
||||
//! Configuration-loading failures do not need a subscriber — they propagate
|
||||
//! out of `main` as an `anyhow` error and are printed with their full context.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use rustingface_entities::config::{Config, LogFormat};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// A minimal subscriber for the window before configuration is loaded, so a
|
||||
/// failure to parse the config is still reported through the same channel.
|
||||
pub fn init_bootstrap(interactive: bool) {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter(interactive))
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
}
|
||||
|
||||
/// Install the configured subscriber.
|
||||
///
|
||||
/// A no-op when the bootstrap subscriber is already installed, which is the
|
||||
/// normal case; the bootstrap format is close enough that swapping it out
|
||||
/// mid-process would cost more than it buys.
|
||||
/// Install the subscriber. Call once, after the configuration is known.
|
||||
pub fn init(config: &Config, interactive: bool) {
|
||||
let json = !interactive
|
||||
&& (config.observability.log_format == LogFormat::Json
|
||||
|| std::env::var_os("JOURNAL_STREAM").is_some());
|
||||
// `JOURNAL_STREAM` is systemd telling us stderr goes to the journal, which
|
||||
// ingests structured records cleanly and renders escape codes literally.
|
||||
let journald = std::env::var_os("JOURNAL_STREAM").is_some();
|
||||
let json = !interactive && (config.observability.log_format == LogFormat::Json || journald);
|
||||
|
||||
// ANSI only for a real terminal. `tracing_subscriber` colours by default
|
||||
// regardless of the writer, which puts escape sequences into the journal
|
||||
// and into any redirected log file.
|
||||
let ansi = std::io::stderr().is_terminal();
|
||||
|
||||
let builder = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter(interactive))
|
||||
.with_writer(std::io::stderr);
|
||||
|
||||
if json {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.json()
|
||||
.with_env_filter(filter(interactive))
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
builder.json().with_ansi(false).init();
|
||||
} else {
|
||||
builder.with_ansi(ansi).init();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,11 +136,13 @@ fn main() -> Result<()> {
|
||||
// The server logs structurally for journald; the CLI writes to a terminal
|
||||
// an operator is reading, so it gets human output and its results go to
|
||||
// stdout rather than into a log line.
|
||||
//
|
||||
// Configuration is loaded before the subscriber is installed, because the
|
||||
// configuration is what decides the format and the global subscriber can
|
||||
// only be set once. A load failure needs no subscriber: it leaves `main`
|
||||
// as an `anyhow` error, with its context, on stderr.
|
||||
let interactive = !matches!(command, Command::Serve);
|
||||
let config = {
|
||||
logging::init_bootstrap(interactive);
|
||||
config::load(Some(&path), explicit)?
|
||||
};
|
||||
let config = config::load(Some(&path), explicit)?;
|
||||
logging::init(&config, interactive);
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
|
||||
Reference in New Issue
Block a user