Skip to main content

cli/logging/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Structured logging initialization and configuration.
3//!
4//! This module provides centralized logging setup using the `tracing` ecosystem.
5//! It supports both human-readable and JSON output formats, configurable via
6//! environment variables.
7//!
8//! # Configuration
9//!
10//! Logging is controlled via the `RUST_LOG` environment variable:
11//!
12//! ```bash
13//! # Default logging (info level)
14//! RUST_LOG=info
15//!
16//! # Debug level for heddle only
17//! RUST_LOG=heddle=debug
18//!
19//! # Trace everything
20//! RUST_LOG=trace
21//!
22//! # JSON output for machine parsing
23//! RUST_LOG=info HEDDLE_LOG_FORMAT=json
24//! ```
25
26use std::io::{self, IsTerminal};
27
28#[cfg(feature = "observability")]
29use opentelemetry::{KeyValue, global, trace::TracerProvider as _};
30#[cfg(feature = "observability")]
31use opentelemetry_otlp::WithExportConfig;
32#[cfg(feature = "observability")]
33use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider, trace::SdkTracerProvider};
34use tracing::Level;
35use tracing_subscriber::{
36    EnvFilter, fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt,
37};
38
39use crate::config::UserConfig;
40
41fn is_truthy(val: &str) -> bool {
42    matches!(
43        val.to_ascii_lowercase().as_str(),
44        "1" | "true" | "yes" | "on"
45    )
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum LogFormat {
50    Text,
51    Json,
52}
53
54#[derive(Debug, Clone)]
55pub struct LoggingConfig {
56    pub format: LogFormat,
57    /// Filter level used when `RUST_LOG` is unset. Foreground CLI commands
58    /// default this to `Warn`; `-v` raises to Info, `-vv` to Debug, `-vvv`
59    /// to Trace; `--quiet` lowers to Error. `RUST_LOG` always overrides.
60    pub default_level: Level,
61    pub include_location: bool,
62    pub include_thread_ids: bool,
63    pub log_spans: bool,
64    pub otel_service_name: Option<String>,
65    pub otel_endpoint: Option<String>,
66    pub otel_traces_endpoint: Option<String>,
67    pub otel_metrics_endpoint: Option<String>,
68}
69
70#[derive(Debug, Default)]
71pub struct LoggingGuard {
72    #[cfg(feature = "observability")]
73    tracer_provider: Option<SdkTracerProvider>,
74    #[cfg(feature = "observability")]
75    meter_provider: Option<SdkMeterProvider>,
76}
77
78impl LoggingGuard {
79    pub fn shutdown(self) {
80        #[cfg(feature = "observability")]
81        {
82            if let Some(meter_provider) = self.meter_provider {
83                let _ = meter_provider.shutdown();
84            }
85            if let Some(tracer_provider) = self.tracer_provider {
86                let _ = tracer_provider.shutdown();
87            }
88        }
89    }
90}
91
92#[cfg(feature = "observability")]
93#[derive(Debug, Clone)]
94struct OtelConfig {
95    service_name: String,
96    trace_endpoint: Option<String>,
97    metrics_endpoint: Option<String>,
98}
99
100impl Default for LoggingConfig {
101    fn default() -> Self {
102        Self {
103            format: LogFormat::Text,
104            default_level: Level::WARN,
105            include_location: false,
106            include_thread_ids: false,
107            log_spans: false,
108            otel_service_name: None,
109            otel_endpoint: None,
110            otel_traces_endpoint: None,
111            otel_metrics_endpoint: None,
112        }
113    }
114}
115
116impl LoggingConfig {
117    pub fn from_env() -> Self {
118        Self::from_user_and_env(None)
119    }
120
121    pub fn from_user_and_env(user_config: Option<&UserConfig>) -> Self {
122        let mut config = Self::default();
123
124        if let Some(user_config) = user_config {
125            if user_config
126                .logging
127                .format
128                .as_deref()
129                .is_some_and(|format| format.eq_ignore_ascii_case("json"))
130            {
131                config.format = LogFormat::Json;
132            }
133            config.include_location = user_config.logging.include_location;
134            config.include_thread_ids = user_config.logging.include_thread_ids;
135            config.log_spans = user_config.logging.log_spans;
136            config.otel_service_name = user_config.logging.otel_service_name.clone();
137            config.otel_endpoint = user_config.logging.otel_endpoint.clone();
138            config.otel_traces_endpoint = user_config.logging.otel_traces_endpoint.clone();
139            config.otel_metrics_endpoint = user_config.logging.otel_metrics_endpoint.clone();
140        }
141
142        if let Ok(format) = std::env::var("HEDDLE_LOG_FORMAT")
143            && format.eq_ignore_ascii_case("json")
144        {
145            config.format = LogFormat::Json;
146        }
147
148        if std::env::var("HEDDLE_LOG_LOCATION")
149            .map(|v| is_truthy(&v))
150            .unwrap_or(false)
151        {
152            config.include_location = true;
153        }
154
155        if std::env::var("HEDDLE_LOG_THREADS")
156            .map(|v| is_truthy(&v))
157            .unwrap_or(false)
158        {
159            config.include_thread_ids = true;
160        }
161
162        if std::env::var("HEDDLE_LOG_SPANS")
163            .map(|v| is_truthy(&v))
164            .unwrap_or(false)
165        {
166            config.log_spans = true;
167        }
168
169        if let Ok(service_name) = std::env::var("OTEL_SERVICE_NAME") {
170            config.otel_service_name = Some(service_name);
171        }
172        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
173            config.otel_endpoint = Some(endpoint);
174        }
175        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") {
176            config.otel_traces_endpoint = Some(endpoint);
177        }
178        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") {
179            config.otel_metrics_endpoint = Some(endpoint);
180        }
181
182        config
183    }
184
185    pub fn with_format(mut self, format: LogFormat) -> Self {
186        self.format = format;
187        self
188    }
189
190    /// Map CLI `-v`/`--quiet` counts to a default log level.
191    ///
192    /// `quiet` wins over `verbose`; `RUST_LOG` overrides both downstream.
193    /// 0 → keep current (e.g. `Warn` for foreground), 1 → Info, 2 → Debug,
194    /// 3+ → Trace.
195    pub fn with_verbosity(mut self, verbose: u8, quiet: bool) -> Self {
196        self.default_level = if quiet {
197            Level::ERROR
198        } else {
199            match verbose {
200                0 => self.default_level,
201                1 => Level::INFO,
202                2 => Level::DEBUG,
203                _ => Level::TRACE,
204            }
205        };
206        self
207    }
208
209    pub fn with_location(mut self, include: bool) -> Self {
210        self.include_location = include;
211        self
212    }
213
214    pub fn with_thread_ids(mut self, include: bool) -> Self {
215        self.include_thread_ids = include;
216        self
217    }
218
219    pub fn with_spans(mut self, include: bool) -> Self {
220        self.log_spans = include;
221        self
222    }
223}
224
225#[cfg(feature = "observability")]
226impl OtelConfig {
227    fn from_logging_config(config: &LoggingConfig) -> Self {
228        let shared_endpoint = config.otel_endpoint.clone();
229        Self {
230            service_name: config
231                .otel_service_name
232                .clone()
233                .unwrap_or_else(|| "heddle".to_string()),
234            trace_endpoint: config
235                .otel_traces_endpoint
236                .clone()
237                .or_else(|| shared_endpoint.clone()),
238            metrics_endpoint: config.otel_metrics_endpoint.clone().or(shared_endpoint),
239        }
240    }
241
242    fn enabled(&self) -> bool {
243        self.trace_endpoint.is_some() || self.metrics_endpoint.is_some()
244    }
245
246    #[cfg(feature = "observability")]
247    fn resource(&self) -> Resource {
248        Resource::builder_empty()
249            .with_attributes([KeyValue::new("service.name", self.service_name.clone())])
250            .build()
251    }
252}
253
254/// Initialize the global tracing subscriber.
255///
256/// # Example
257///
258/// ```rust
259/// use cli::logging::{LoggingConfig, init_logging};
260///
261/// fn main() {
262///     init_logging(LoggingConfig::from_env());
263///
264///     tracing::info!("Logging initialized");
265/// }
266/// ```
267pub fn init_logging(config: LoggingConfig) -> LoggingGuard {
268    let env_filter = EnvFilter::try_from_default_env()
269        .unwrap_or_else(|_| EnvFilter::new(level_to_filter(config.default_level)));
270    let span_events = if config.log_spans {
271        FmtSpan::FULL
272    } else {
273        FmtSpan::NONE
274    };
275    let telemetry = init_otel(&config);
276    let registry = tracing_subscriber::registry().with(env_filter);
277
278    #[cfg(feature = "observability")]
279    let init_result = match (config.format, telemetry.tracer_provider.as_ref()) {
280        (LogFormat::Text, Some(provider)) => registry
281            .with(
282                tracing_opentelemetry::layer()
283                    .with_tracer(provider.tracer(telemetry.service_name.clone())),
284            )
285            .with(
286                tracing_subscriber::fmt::layer()
287                    .with_writer(io::stderr)
288                    .with_target(true)
289                    .with_level(true)
290                    .with_thread_ids(config.include_thread_ids)
291                    .with_file(config.include_location)
292                    .with_line_number(config.include_location)
293                    .with_span_events(span_events)
294                    .with_ansi(io::stderr().is_terminal()),
295            )
296            .try_init(),
297        (LogFormat::Text, None) => registry
298            .with(
299                tracing_subscriber::fmt::layer()
300                    .with_writer(io::stderr)
301                    .with_target(true)
302                    .with_level(true)
303                    .with_thread_ids(config.include_thread_ids)
304                    .with_file(config.include_location)
305                    .with_line_number(config.include_location)
306                    .with_span_events(span_events)
307                    .with_ansi(io::stderr().is_terminal()),
308            )
309            .try_init(),
310        (LogFormat::Json, Some(provider)) => registry
311            .with(
312                tracing_opentelemetry::layer()
313                    .with_tracer(provider.tracer(telemetry.service_name.clone())),
314            )
315            .with(
316                tracing_subscriber::fmt::layer()
317                    .json()
318                    .with_writer(io::stderr)
319                    .with_target(true)
320                    .with_level(true)
321                    .with_thread_ids(config.include_thread_ids)
322                    .with_file(config.include_location)
323                    .with_line_number(config.include_location)
324                    .with_span_events(span_events),
325            )
326            .try_init(),
327        (LogFormat::Json, None) => registry
328            .with(
329                tracing_subscriber::fmt::layer()
330                    .json()
331                    .with_writer(io::stderr)
332                    .with_target(true)
333                    .with_level(true)
334                    .with_thread_ids(config.include_thread_ids)
335                    .with_file(config.include_location)
336                    .with_line_number(config.include_location)
337                    .with_span_events(span_events),
338            )
339            .try_init(),
340    };
341
342    #[cfg(not(feature = "observability"))]
343    let init_result = match config.format {
344        LogFormat::Text => registry
345            .with(
346                tracing_subscriber::fmt::layer()
347                    .with_writer(io::stderr)
348                    .with_target(true)
349                    .with_level(true)
350                    .with_thread_ids(config.include_thread_ids)
351                    .with_file(config.include_location)
352                    .with_line_number(config.include_location)
353                    .with_span_events(span_events)
354                    .with_ansi(io::stderr().is_terminal()),
355            )
356            .try_init(),
357        LogFormat::Json => registry
358            .with(
359                tracing_subscriber::fmt::layer()
360                    .json()
361                    .with_writer(io::stderr)
362                    .with_target(true)
363                    .with_level(true)
364                    .with_thread_ids(config.include_thread_ids)
365                    .with_file(config.include_location)
366                    .with_line_number(config.include_location)
367                    .with_span_events(span_events),
368            )
369            .try_init(),
370    };
371
372    if let Err(err) = init_result {
373        eprintln!("failed to initialize tracing subscriber: {err}");
374    }
375
376    telemetry.guard
377}
378
379pub fn init_logging_default() {
380    let _ = init_logging(LoggingConfig::default());
381}
382
383fn level_to_filter(level: Level) -> &'static str {
384    match level {
385        Level::TRACE => "trace",
386        Level::DEBUG => "debug",
387        Level::INFO => "info",
388        Level::WARN => "warn",
389        Level::ERROR => "error",
390    }
391}
392
393pub fn is_enabled(level: Level) -> bool {
394    tracing::level_enabled!(level)
395}
396
397#[macro_export]
398macro_rules! log_operation {
399    ($operation:expr, $($key:ident = $value:expr),+ $(,)?) => {
400        tracing::info!(
401            operation = %$operation,
402            $($key = %$value),+,
403            "Operation executed"
404        )
405    };
406    ($operation:expr) => {
407        tracing::info!(operation = %$operation, "Operation executed")
408    };
409}
410
411#[macro_export]
412macro_rules! log_repo_event {
413    ($event:expr, change_id = $change_id:expr $(, $key:ident = $value:expr)* $(,)?) => {
414        tracing::info!(
415            event = %$event,
416            change_id = %$change_id,
417            $($key = %$value),*,
418            "Repository event"
419        )
420    };
421}
422
423struct TelemetryInit {
424    guard: LoggingGuard,
425    #[cfg(feature = "observability")]
426    tracer_provider: Option<SdkTracerProvider>,
427    #[cfg(feature = "observability")]
428    service_name: String,
429}
430
431#[cfg(feature = "observability")]
432fn init_otel(logging: &LoggingConfig) -> TelemetryInit {
433    let config = OtelConfig::from_logging_config(logging);
434    if !config.enabled() {
435        return TelemetryInit {
436            guard: LoggingGuard::default(),
437            tracer_provider: None,
438            service_name: config.service_name,
439        };
440    }
441
442    let resource = config.resource();
443    let tracer_provider = config.trace_endpoint.as_ref().and_then(|endpoint| {
444        let exporter = opentelemetry_otlp::SpanExporter::builder()
445            .with_tonic()
446            .with_endpoint(endpoint.to_string())
447            .build()
448            .map_err(|err| {
449                eprintln!("failed to initialize OTLP trace exporter: {err}");
450                err
451            })
452            .ok()?;
453        let provider = SdkTracerProvider::builder()
454            .with_resource(resource.clone())
455            .with_batch_exporter(exporter)
456            .build();
457        global::set_tracer_provider(provider.clone());
458        Some(provider)
459    });
460
461    let meter_provider = config.metrics_endpoint.as_ref().and_then(|endpoint| {
462        let exporter = opentelemetry_otlp::MetricExporter::builder()
463            .with_tonic()
464            .with_endpoint(endpoint.to_string())
465            .build()
466            .map_err(|err| {
467                eprintln!("failed to initialize OTLP metric exporter: {err}");
468                err
469            })
470            .ok()?;
471        let provider = SdkMeterProvider::builder()
472            .with_periodic_exporter(exporter)
473            .with_resource(resource.clone())
474            .build();
475        global::set_meter_provider(provider.clone());
476        Some(provider)
477    });
478
479    TelemetryInit {
480        guard: LoggingGuard {
481            tracer_provider: tracer_provider.clone(),
482            meter_provider,
483        },
484        tracer_provider,
485        service_name: config.service_name,
486    }
487}
488
489#[cfg(not(feature = "observability"))]
490fn init_otel(_logging: &LoggingConfig) -> TelemetryInit {
491    TelemetryInit {
492        guard: LoggingGuard::default(),
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn test_logging_config_default() {
502        let config = LoggingConfig::default();
503        assert_eq!(config.format, LogFormat::Text);
504        assert!(!config.include_location);
505        assert!(!config.include_thread_ids);
506        assert!(!config.log_spans);
507    }
508
509    #[test]
510    fn test_logging_config_builder() {
511        let config = LoggingConfig::default()
512            .with_format(LogFormat::Json)
513            .with_location(true)
514            .with_thread_ids(true)
515            .with_spans(true);
516
517        assert_eq!(config.format, LogFormat::Json);
518        assert!(config.include_location);
519        assert!(config.include_thread_ids);
520        assert!(config.log_spans);
521    }
522
523    #[test]
524    fn test_is_truthy() {
525        assert!(is_truthy("1"));
526        assert!(is_truthy("true"));
527        assert!(is_truthy("TRUE"));
528        assert!(is_truthy("True"));
529        assert!(is_truthy("yes"));
530        assert!(is_truthy("YES"));
531        assert!(is_truthy("on"));
532        assert!(is_truthy("ON"));
533
534        assert!(!is_truthy("0"));
535        assert!(!is_truthy("false"));
536        assert!(!is_truthy("FALSE"));
537        assert!(!is_truthy("no"));
538        assert!(!is_truthy("off"));
539        assert!(!is_truthy(""));
540        assert!(!is_truthy("random"));
541    }
542}