Skip to main content

gammalooprs/utils/
tracing.rs

1use bincode_trait_derive::{Decode, Encode};
2use clap::ValueEnum;
3use gammaloop_tracing_filter::{GammaDisplayFormat, GammaLogFilter};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::{path::PathBuf, sync::OnceLock};
7use tracing::level_filters::LevelFilter;
8use tracing_appender::non_blocking::WorkerGuard;
9use tracing_subscriber::{fmt, prelude::*};
10#[cfg_attr(
11    feature = "python_api",
12    pyo3::pyclass(from_py_object, get_all, set_all)
13)]
14/// Amount of prefix and source detail shown by the terminal and logfile sinks.
15#[repr(usize)]
16#[derive(
17    Clone,
18    Copy,
19    PartialEq,
20    Eq,
21    PartialOrd,
22    Ord,
23    Debug,
24    Hash,
25    Encode,
26    Decode,
27    JsonSchema,
28    ValueEnum,
29    Serialize,
30    Deserialize,
31    Default,
32)]
33pub enum LogFormat {
34    #[default]
35    Long,
36    Full,
37    Short,
38    Min,
39    None,
40}
41
42#[cfg_attr(
43    feature = "python_api",
44    pyo3::pyclass(from_py_object, get_all, set_all)
45)]
46#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, JsonSchema, PartialEq, Default)]
47#[serde(default, deny_unknown_fields)]
48pub struct LogStyle {
49    /// Amount of timestamp, target, level, and source context included in each log line.
50    #[serde(skip_serializing_if = "crate::utils::serde_utils::IsDefault::is_default")]
51    pub log_format: LogFormat,
52    /// Use compact timestamps instead of full date-and-time values.
53    #[serde(skip_serializing_if = "crate::utils::serde_utils::is_false")]
54    pub short_timestamp: bool,
55    /// Include the complete source file path and line number for each event.
56    #[serde(skip_serializing_if = "crate::utils::serde_utils::is_false")]
57    pub full_line_source: bool,
58    /// Render structured tracing fields in addition to the formatted event message.
59    #[serde(skip_serializing_if = "crate::utils::serde_utils::is_false")]
60    pub include_fields: bool,
61}
62
63impl From<LogFormat> for gammaloop_tracing_filter::LogFormat {
64    fn from(value: LogFormat) -> Self {
65        match value {
66            LogFormat::Long => Self::Long,
67            LogFormat::Full => Self::Full,
68            LogFormat::Short => Self::Short,
69            LogFormat::Min => Self::Min,
70            LogFormat::None => Self::None,
71        }
72    }
73}
74
75impl LogStyle {
76    pub fn to_runtime(&self) -> gammaloop_tracing_filter::LogStyle {
77        gammaloop_tracing_filter::LogStyle {
78            log_format: self.log_format.into(),
79            short_timestamp: self.short_timestamp,
80            full_line_source: self.full_line_source,
81            include_fields: self.include_fields,
82        }
83    }
84}
85
86impl From<&LogStyle> for gammaloop_tracing_filter::LogStyle {
87    fn from(value: &LogStyle) -> Self {
88        value.to_runtime()
89    }
90}
91
92impl From<LogStyle> for gammaloop_tracing_filter::LogStyle {
93    fn from(value: LogStyle) -> Self {
94        value.to_runtime()
95    }
96}
97
98#[repr(usize)]
99#[derive(
100    Clone,
101    Copy,
102    PartialEq,
103    Eq,
104    PartialOrd,
105    Ord,
106    Debug,
107    Hash,
108    ValueEnum,
109    Serialize,
110    Deserialize,
111    Encode,
112    Decode,
113    JsonSchema,
114)]
115#[cfg_attr(
116    feature = "python_stubgen",
117    pyo3_stub_gen::derive::gen_stub_pyclass_enum
118)]
119#[cfg_attr(feature = "python_api", pyo3::pyclass(from_py_object))]
120#[derive(Default)]
121pub enum LogLevel {
122    /// A level lower than all log levels.
123    Off,
124    /// Corresponds to the `Error` log level.
125    Error,
126    /// Corresponds to the `Warn` log level.
127    Warn,
128    /// Corresponds to the `Info` log level.
129    #[default]
130    Info,
131    /// Corresponds to the `Debug` log level.
132    Debug,
133    /// Corresponds to the `Trace` log level.
134    Trace,
135}
136
137impl From<LogLevel> for LevelFilter {
138    fn from(value: LogLevel) -> Self {
139        match value {
140            LogLevel::Debug => LevelFilter::DEBUG,
141            LogLevel::Trace => LevelFilter::TRACE,
142            LogLevel::Warn => LevelFilter::WARN,
143            LogLevel::Error => LevelFilter::ERROR,
144            LogLevel::Off => LevelFilter::OFF,
145            LogLevel::Info => LevelFilter::INFO,
146        }
147    }
148}
149
150impl LogLevel {
151    pub fn to_env_spec(self) -> &'static str {
152        match self {
153            LogLevel::Off => "gammalooprs=off",
154            LogLevel::Error => "gammalooprs=error",
155            LogLevel::Warn => "gammalooprs=warn",
156            LogLevel::Info => "gammalooprs=info",
157            LogLevel::Debug => "gammalooprs=debug",
158            LogLevel::Trace => "gammalooprs=trace",
159        }
160    }
161
162    pub fn to_cli_display_directive_spec(self) -> &'static str {
163        match self {
164            LogLevel::Off => "gammaloop_api=off,gammalooprs=off",
165            LogLevel::Error => "gammaloop_api=error,gammalooprs=error",
166            LogLevel::Warn => "gammaloop_api=warn,gammalooprs=warn",
167            LogLevel::Info => "gammaloop_api=info,gammalooprs=info",
168            LogLevel::Debug => "gammaloop_api=debug,gammalooprs=debug",
169            LogLevel::Trace => "gammaloop_api=trace,gammalooprs=trace",
170        }
171    }
172
173    pub fn to_cli_logfile_directive_spec(self) -> &'static str {
174        self.to_cli_display_directive_spec()
175    }
176}
177
178// Global one-time slots.
179static TEST_TRACING_INITIALISED: OnceLock<()> = OnceLock::new();
180pub static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
181
182const ENV_FILE_LOG_FILTER: &str = "GL_LOGFILE_FILTER";
183const ENV_DISPLAY_LOG_FILTER: &str = "GL_DISPLAY_FILTER";
184const ENV_ALL_LOG_FILTER: &str = "GL_ALL_LOG_FILTER";
185const ENV_TEST_LOG_DIR: &str = "GL_TEST_LOG_DIR";
186
187pub fn init_test_tracing() {
188    TEST_TRACING_INITIALISED.get_or_init(|| {
189        init_test_tracing_with_defaults("info", "off");
190    });
191}
192
193pub fn init_bench_tracing() {
194    TEST_TRACING_INITIALISED.get_or_init(|| {
195        init_test_tracing_with_defaults("warn", "off");
196    });
197}
198
199fn init_test_tracing_with_defaults(display_default: &str, file_default: &str) {
200    let display_spec = log_filter_env_override(ENV_DISPLAY_LOG_FILTER)
201        .unwrap_or_else(|| display_default.to_string());
202    let file_spec =
203        log_filter_env_override(ENV_FILE_LOG_FILTER).unwrap_or_else(|| file_default.to_string());
204
205    let (_display_spec, display_filter) =
206        parse_log_filter_or_default(&display_spec, display_default, "display");
207    let (file_spec, file_filter) = parse_log_filter_or_default(&file_spec, file_default, "file");
208
209    let display_layer = fmt::layer()
210        .event_format(GammaDisplayFormat::new(
211            LogStyle {
212                log_format: LogFormat::Full,
213                short_timestamp: true,
214                full_line_source: true,
215                include_fields: true,
216            }
217            .to_runtime(),
218        ))
219        .with_writer(std::io::stderr)
220        .with_filter(display_filter);
221
222    let subscriber = tracing_subscriber::registry().with(display_layer);
223    if GammaLogFilter::is_effectively_off(&file_spec) {
224        _ = subscriber.try_init();
225    } else {
226        let file_appender = tracing_appender::rolling::never(
227            test_log_dir(),
228            format!("gammaloop-test-{}.jsonl", std::process::id()),
229        );
230        let (file_writer, file_guard) = tracing_appender::non_blocking(file_appender);
231        let _ = LOG_GUARD.set(file_guard);
232
233        let file_layer = fmt::layer()
234            .json()
235            .with_writer(file_writer)
236            .with_filter(file_filter);
237
238        _ = subscriber.with(file_layer).try_init();
239    }
240}
241
242fn log_filter_env_override(specific_env: &str) -> Option<String> {
243    if let Ok(all) = std::env::var(ENV_ALL_LOG_FILTER) {
244        Some(all)
245    } else {
246        std::env::var(specific_env).ok()
247    }
248}
249
250fn parse_log_filter_or_default(
251    spec: &str,
252    default_spec: &str,
253    sink: &str,
254) -> (String, GammaLogFilter) {
255    let spec = if spec.trim().is_empty() {
256        default_spec
257    } else {
258        spec
259    };
260
261    match GammaLogFilter::parse(spec) {
262        Ok(filter) => (spec.to_string(), filter),
263        Err(err) => {
264            eprintln!(
265                "Invalid test {sink} log filter '{spec}': {err}. Falling back to {default_spec}."
266            );
267            (
268                default_spec.to_string(),
269                GammaLogFilter::parse(default_spec).expect("test log default filter must parse"),
270            )
271        }
272    }
273}
274
275fn test_log_dir() -> PathBuf {
276    std::env::var(ENV_TEST_LOG_DIR)
277        .map(PathBuf::from)
278        .unwrap_or_else(|_| {
279            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
280                .join("../..")
281                .join("target/test-logs")
282        })
283}
284
285use serde_json::Value;
286use std::borrow::Cow;
287
288/// Anything you want to show in status + attach as structured JSON.
289pub trait StatusRenderable {
290    /// Human-friendly, possibly multi-line (e.g. a table).
291    fn status_pretty(&self) -> Cow<'_, str>;
292
293    /// Machine-readable payload (stable shape for analysis).
294    fn status_json(&self) -> Value;
295}
296
297// If you already implement Display + Serialize on a type,
298// you get a decent default for free.
299impl<T> StatusRenderable for T
300where
301    T: Serialize + std::fmt::Display,
302{
303    fn status_pretty(&self) -> Cow<'_, str> {
304        Cow::Owned(self.to_string())
305    }
306    fn status_json(&self) -> Value {
307        serde_json::to_value(self).unwrap_or(Value::Null)
308    }
309}
310
311use std::io::IsTerminal;
312pub fn stderr_is_tty() -> bool {
313    std::io::stderr().is_terminal()
314}