Skip to main content

cli_shared/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#[cfg(feature = "telemetry")]
28use std::time::Duration;
29
30mod trace;
31#[cfg(feature = "telemetry")]
32use opentelemetry::{KeyValue, global, trace::TracerProvider as _};
33#[cfg(feature = "telemetry")]
34use opentelemetry_otlp::WithExportConfig;
35#[cfg(feature = "telemetry")]
36use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider, trace::SdkTracerProvider};
37pub use trace::{CommandTrace, record_phase_span, trace_export_enabled};
38use tracing::Level;
39#[cfg(feature = "telemetry")]
40use tracing_subscriber::filter::filter_fn;
41use tracing_subscriber::{
42    EnvFilter, Layer as _, fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt,
43};
44
45use crate::config::UserConfig;
46
47fn is_truthy(val: &str) -> bool {
48    matches!(
49        val.to_ascii_lowercase().as_str(),
50        "1" | "true" | "yes" | "on"
51    )
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum LogFormat {
56    Text,
57    Json,
58}
59
60#[derive(Debug, Clone)]
61pub struct LoggingConfig {
62    pub format: LogFormat,
63    /// Filter level used when `RUST_LOG` is unset. Foreground CLI commands
64    /// default this to `Warn`; `-v` raises to Info, `-vv` to Debug, `-vvv`
65    /// to Trace; `--quiet` lowers to Error. `RUST_LOG` always overrides.
66    pub default_level: Level,
67    pub include_location: bool,
68    pub include_thread_ids: bool,
69    pub log_spans: bool,
70    pub otel_service_name: Option<String>,
71    pub otel_endpoint: Option<String>,
72    pub otel_traces_endpoint: Option<String>,
73    pub otel_metrics_endpoint: Option<String>,
74}
75
76#[derive(Debug, Default)]
77pub struct LoggingGuard {
78    #[cfg(feature = "telemetry")]
79    tracer_provider: Option<SdkTracerProvider>,
80    #[cfg(feature = "telemetry")]
81    meter_provider: Option<SdkMeterProvider>,
82}
83
84impl LoggingGuard {
85    pub fn shutdown(mut self) {
86        self.shutdown_inner();
87    }
88
89    fn shutdown_inner(&mut self) {
90        #[cfg(feature = "telemetry")]
91        {
92            if let Some(meter_provider) = self.meter_provider.take() {
93                let _ = meter_provider.shutdown_with_timeout(Duration::from_millis(250));
94            }
95            if let Some(tracer_provider) = self.tracer_provider.take() {
96                let _ = tracer_provider.shutdown_with_timeout(Duration::from_millis(250));
97            }
98            trace::set_trace_export_enabled(false);
99        }
100    }
101}
102
103impl Drop for LoggingGuard {
104    fn drop(&mut self) {
105        self.shutdown_inner();
106    }
107}
108
109#[cfg(feature = "telemetry")]
110#[derive(Debug, Clone)]
111struct OtelConfig {
112    service_name: String,
113    trace_endpoint: Option<String>,
114    metrics_endpoint: Option<String>,
115}
116
117impl Default for LoggingConfig {
118    fn default() -> Self {
119        Self {
120            format: LogFormat::Text,
121            default_level: Level::WARN,
122            include_location: false,
123            include_thread_ids: false,
124            log_spans: false,
125            otel_service_name: None,
126            otel_endpoint: None,
127            otel_traces_endpoint: None,
128            otel_metrics_endpoint: None,
129        }
130    }
131}
132
133impl LoggingConfig {
134    pub fn from_env() -> Self {
135        Self::from_user_and_env(None)
136    }
137
138    pub fn from_user_and_env(user_config: Option<&UserConfig>) -> Self {
139        let mut config = Self::default();
140
141        if let Some(user_config) = user_config {
142            if user_config
143                .logging
144                .format
145                .as_deref()
146                .is_some_and(|format| format.eq_ignore_ascii_case("json"))
147            {
148                config.format = LogFormat::Json;
149            }
150            config.include_location = user_config.logging.include_location;
151            config.include_thread_ids = user_config.logging.include_thread_ids;
152            config.log_spans = user_config.logging.log_spans;
153            config.otel_service_name = user_config.logging.otel_service_name.clone();
154            config.otel_endpoint = user_config.logging.otel_endpoint.clone();
155            config.otel_traces_endpoint = user_config.logging.otel_traces_endpoint.clone();
156            config.otel_metrics_endpoint = user_config.logging.otel_metrics_endpoint.clone();
157        }
158
159        if let Ok(format) = std::env::var("HEDDLE_LOG_FORMAT")
160            && format.eq_ignore_ascii_case("json")
161        {
162            config.format = LogFormat::Json;
163        }
164
165        if std::env::var("HEDDLE_LOG_LOCATION")
166            .map(|v| is_truthy(&v))
167            .unwrap_or(false)
168        {
169            config.include_location = true;
170        }
171
172        if std::env::var("HEDDLE_LOG_THREADS")
173            .map(|v| is_truthy(&v))
174            .unwrap_or(false)
175        {
176            config.include_thread_ids = true;
177        }
178
179        if std::env::var("HEDDLE_LOG_SPANS")
180            .map(|v| is_truthy(&v))
181            .unwrap_or(false)
182        {
183            config.log_spans = true;
184        }
185
186        if let Ok(service_name) = std::env::var("OTEL_SERVICE_NAME") {
187            config.otel_service_name = Some(service_name);
188        }
189        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
190            config.otel_endpoint = Some(endpoint);
191        }
192        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") {
193            config.otel_traces_endpoint = Some(endpoint);
194        }
195        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") {
196            config.otel_metrics_endpoint = Some(endpoint);
197        }
198
199        config
200    }
201
202    pub fn with_format(mut self, format: LogFormat) -> Self {
203        self.format = format;
204        self
205    }
206
207    /// Map CLI `-v`/`--quiet` counts to a default log level.
208    ///
209    /// `quiet` wins over `verbose`; `RUST_LOG` overrides both downstream.
210    /// 0 → keep current (e.g. `Warn` for foreground), 1 → Info, 2 → Debug,
211    /// 3+ → Trace.
212    pub fn with_verbosity(mut self, verbose: u8, quiet: bool) -> Self {
213        self.default_level = if quiet {
214            Level::ERROR
215        } else {
216            match verbose {
217                0 => self.default_level,
218                1 => Level::INFO,
219                2 => Level::DEBUG,
220                _ => Level::TRACE,
221            }
222        };
223        self
224    }
225
226    pub fn with_location(mut self, include: bool) -> Self {
227        self.include_location = include;
228        self
229    }
230
231    pub fn with_thread_ids(mut self, include: bool) -> Self {
232        self.include_thread_ids = include;
233        self
234    }
235
236    pub fn with_spans(mut self, include: bool) -> Self {
237        self.log_spans = include;
238        self
239    }
240}
241
242#[cfg(feature = "telemetry")]
243impl OtelConfig {
244    fn from_logging_config(config: &LoggingConfig) -> Self {
245        Self {
246            service_name: config
247                .otel_service_name
248                .clone()
249                .unwrap_or_else(|| "heddle".to_string()),
250            trace_endpoint: config
251                .otel_traces_endpoint
252                .clone()
253                .or_else(|| signal_endpoint(config.otel_endpoint.as_deref(), "v1/traces")),
254            metrics_endpoint: config
255                .otel_metrics_endpoint
256                .clone()
257                .or_else(|| signal_endpoint(config.otel_endpoint.as_deref(), "v1/metrics")),
258        }
259    }
260
261    fn enabled(&self) -> bool {
262        self.trace_endpoint.is_some() || self.metrics_endpoint.is_some()
263    }
264
265    fn resource(&self) -> Resource {
266        Resource::builder_empty()
267            .with_attributes([KeyValue::new("service.name", self.service_name.clone())])
268            .build()
269    }
270}
271
272#[cfg(feature = "telemetry")]
273fn signal_endpoint(base: Option<&str>, signal_path: &str) -> Option<String> {
274    base.map(|endpoint| format!("{}/{signal_path}", endpoint.trim_end_matches('/')))
275}
276
277/// Initialize the global tracing subscriber.
278///
279/// # Example
280///
281/// ```rust
282/// use cli_shared::logging::{LoggingConfig, init_logging};
283///
284/// fn main() {
285///     init_logging(LoggingConfig::from_env());
286///
287///     tracing::info!("Logging initialized");
288/// }
289/// ```
290pub fn init_logging(config: LoggingConfig) -> LoggingGuard {
291    let env_filter = EnvFilter::try_from_default_env()
292        .unwrap_or_else(|_| EnvFilter::new(level_to_filter(config.default_level)));
293    let span_events = if config.log_spans {
294        FmtSpan::FULL
295    } else {
296        FmtSpan::NONE
297    };
298    let telemetry = init_otel(&config);
299    let registry = tracing_subscriber::registry();
300
301    #[cfg(feature = "telemetry")]
302    let init_result = match (config.format, telemetry.tracer_provider.as_ref()) {
303        (LogFormat::Text, Some(provider)) => registry
304            .with(
305                tracing_opentelemetry::layer()
306                    .with_tracer(provider.tracer(telemetry.service_name.clone()))
307                    .with_filter(filter_fn(|metadata| {
308                        metadata.target() == trace::TELEMETRY_TARGET
309                    })),
310            )
311            .with(
312                tracing_subscriber::fmt::layer()
313                    .with_writer(io::stderr)
314                    .with_target(true)
315                    .with_level(true)
316                    .with_thread_ids(config.include_thread_ids)
317                    .with_file(config.include_location)
318                    .with_line_number(config.include_location)
319                    .with_span_events(span_events)
320                    .with_ansi(io::stderr().is_terminal())
321                    .with_filter(env_filter),
322            )
323            .try_init(),
324        (LogFormat::Text, None) => registry
325            .with(
326                tracing_subscriber::fmt::layer()
327                    .with_writer(io::stderr)
328                    .with_target(true)
329                    .with_level(true)
330                    .with_thread_ids(config.include_thread_ids)
331                    .with_file(config.include_location)
332                    .with_line_number(config.include_location)
333                    .with_span_events(span_events)
334                    .with_ansi(io::stderr().is_terminal())
335                    .with_filter(env_filter),
336            )
337            .try_init(),
338        (LogFormat::Json, Some(provider)) => registry
339            .with(
340                tracing_opentelemetry::layer()
341                    .with_tracer(provider.tracer(telemetry.service_name.clone()))
342                    .with_filter(filter_fn(|metadata| {
343                        metadata.target() == trace::TELEMETRY_TARGET
344                    })),
345            )
346            .with(
347                tracing_subscriber::fmt::layer()
348                    .json()
349                    .with_writer(io::stderr)
350                    .with_target(true)
351                    .with_level(true)
352                    .with_thread_ids(config.include_thread_ids)
353                    .with_file(config.include_location)
354                    .with_line_number(config.include_location)
355                    .with_span_events(span_events)
356                    .with_filter(env_filter),
357            )
358            .try_init(),
359        (LogFormat::Json, None) => registry
360            .with(
361                tracing_subscriber::fmt::layer()
362                    .json()
363                    .with_writer(io::stderr)
364                    .with_target(true)
365                    .with_level(true)
366                    .with_thread_ids(config.include_thread_ids)
367                    .with_file(config.include_location)
368                    .with_line_number(config.include_location)
369                    .with_span_events(span_events)
370                    .with_filter(env_filter),
371            )
372            .try_init(),
373    };
374
375    #[cfg(not(feature = "telemetry"))]
376    let init_result = match config.format {
377        LogFormat::Text => registry
378            .with(
379                tracing_subscriber::fmt::layer()
380                    .with_writer(io::stderr)
381                    .with_target(true)
382                    .with_level(true)
383                    .with_thread_ids(config.include_thread_ids)
384                    .with_file(config.include_location)
385                    .with_line_number(config.include_location)
386                    .with_span_events(span_events)
387                    .with_ansi(io::stderr().is_terminal())
388                    .with_filter(env_filter),
389            )
390            .try_init(),
391        LogFormat::Json => registry
392            .with(
393                tracing_subscriber::fmt::layer()
394                    .json()
395                    .with_writer(io::stderr)
396                    .with_target(true)
397                    .with_level(true)
398                    .with_thread_ids(config.include_thread_ids)
399                    .with_file(config.include_location)
400                    .with_line_number(config.include_location)
401                    .with_span_events(span_events)
402                    .with_filter(env_filter),
403            )
404            .try_init(),
405    };
406
407    if let Err(err) = init_result {
408        eprintln!("failed to initialize tracing subscriber: {err}");
409    }
410
411    telemetry.guard
412}
413
414pub fn init_logging_default() {
415    let _ = init_logging(LoggingConfig::default());
416}
417
418fn level_to_filter(level: Level) -> &'static str {
419    match level {
420        Level::TRACE => "trace",
421        Level::DEBUG => "debug",
422        Level::INFO => "info",
423        Level::WARN => "warn",
424        Level::ERROR => "error",
425    }
426}
427
428pub fn is_enabled(level: Level) -> bool {
429    tracing::level_enabled!(level)
430}
431
432#[macro_export]
433macro_rules! log_operation {
434    ($operation:expr, $($key:ident = $value:expr),+ $(,)?) => {
435        tracing::info!(
436            operation = %$operation,
437            $($key = %$value),+,
438            "Operation executed"
439        )
440    };
441    ($operation:expr) => {
442        tracing::info!(operation = %$operation, "Operation executed")
443    };
444}
445
446#[macro_export]
447macro_rules! log_repo_event {
448    ($event:expr, change_id = $change_id:expr $(, $key:ident = $value:expr)* $(,)?) => {
449        tracing::info!(
450            event = %$event,
451            change_id = %$change_id,
452            $($key = %$value),*,
453            "Repository event"
454        )
455    };
456}
457
458struct TelemetryInit {
459    guard: LoggingGuard,
460    #[cfg(feature = "telemetry")]
461    tracer_provider: Option<SdkTracerProvider>,
462    #[cfg(feature = "telemetry")]
463    service_name: String,
464    #[cfg(all(test, feature = "telemetry"))]
465    network_client_initialized: bool,
466}
467
468#[cfg(feature = "telemetry")]
469fn init_otel(logging: &LoggingConfig) -> TelemetryInit {
470    let config = OtelConfig::from_logging_config(logging);
471    if !config.enabled() {
472        return TelemetryInit {
473            guard: LoggingGuard::default(),
474            tracer_provider: None,
475            service_name: config.service_name,
476            #[cfg(all(test, feature = "telemetry"))]
477            network_client_initialized: false,
478        };
479    }
480
481    let resource = config.resource();
482    let tracer_provider = config.trace_endpoint.as_ref().and_then(|endpoint| {
483        let exporter = opentelemetry_otlp::SpanExporter::builder()
484            .with_http()
485            .with_endpoint(endpoint.to_string())
486            .build()
487            .map_err(|err| {
488                eprintln!("failed to initialize OTLP trace exporter: {err}");
489                err
490            })
491            .ok()?;
492        let provider = SdkTracerProvider::builder()
493            .with_resource(resource.clone())
494            .with_batch_exporter(exporter)
495            .build();
496        global::set_tracer_provider(provider.clone());
497        Some(provider)
498    });
499    trace::set_trace_export_enabled(tracer_provider.is_some());
500
501    let meter_provider = config.metrics_endpoint.as_ref().and_then(|endpoint| {
502        let exporter = opentelemetry_otlp::MetricExporter::builder()
503            .with_http()
504            .with_endpoint(endpoint.to_string())
505            .build()
506            .map_err(|err| {
507                eprintln!("failed to initialize OTLP metric exporter: {err}");
508                err
509            })
510            .ok()?;
511        let provider = SdkMeterProvider::builder()
512            .with_periodic_exporter(exporter)
513            .with_resource(resource.clone())
514            .build();
515        global::set_meter_provider(provider.clone());
516        Some(provider)
517    });
518
519    TelemetryInit {
520        guard: LoggingGuard {
521            tracer_provider: tracer_provider.clone(),
522            meter_provider,
523        },
524        tracer_provider,
525        service_name: config.service_name,
526        #[cfg(all(test, feature = "telemetry"))]
527        network_client_initialized: config.trace_endpoint.is_some()
528            || config.metrics_endpoint.is_some(),
529    }
530}
531
532#[cfg(not(feature = "telemetry"))]
533fn init_otel(_logging: &LoggingConfig) -> TelemetryInit {
534    TelemetryInit {
535        guard: LoggingGuard::default(),
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    #[cfg(feature = "telemetry")]
542    use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider, SimpleSpanProcessor};
543
544    use super::*;
545
546    #[test]
547    fn test_logging_config_default() {
548        let config = LoggingConfig::default();
549        assert_eq!(config.format, LogFormat::Text);
550        assert!(!config.include_location);
551        assert!(!config.include_thread_ids);
552        assert!(!config.log_spans);
553    }
554
555    #[test]
556    fn test_logging_config_builder() {
557        let config = LoggingConfig::default()
558            .with_format(LogFormat::Json)
559            .with_location(true)
560            .with_thread_ids(true)
561            .with_spans(true);
562
563        assert_eq!(config.format, LogFormat::Json);
564        assert!(config.include_location);
565        assert!(config.include_thread_ids);
566        assert!(config.log_spans);
567    }
568
569    #[test]
570    fn test_is_truthy() {
571        assert!(is_truthy("1"));
572        assert!(is_truthy("true"));
573        assert!(is_truthy("TRUE"));
574        assert!(is_truthy("True"));
575        assert!(is_truthy("yes"));
576        assert!(is_truthy("YES"));
577        assert!(is_truthy("on"));
578        assert!(is_truthy("ON"));
579
580        assert!(!is_truthy("0"));
581        assert!(!is_truthy("false"));
582        assert!(!is_truthy("FALSE"));
583        assert!(!is_truthy("no"));
584        assert!(!is_truthy("off"));
585        assert!(!is_truthy(""));
586        assert!(!is_truthy("random"));
587    }
588
589    #[cfg(feature = "telemetry")]
590    #[test]
591    fn endpoint_gate_is_network_dormant_and_command_spans_export_when_enabled() {
592        let shared = LoggingConfig {
593            otel_endpoint: Some("http://collector:4318/".to_string()),
594            ..LoggingConfig::default()
595        };
596        let resolved = OtelConfig::from_logging_config(&shared);
597        assert_eq!(
598            resolved.trace_endpoint.as_deref(),
599            Some("http://collector:4318/v1/traces")
600        );
601
602        let telemetry = init_otel(&LoggingConfig::default());
603        assert!(telemetry.tracer_provider.is_none());
604        assert!(!telemetry.network_client_initialized);
605        drop(telemetry);
606
607        let exporter = InMemorySpanExporter::default();
608        let provider = SdkTracerProvider::builder()
609            .with_span_processor(SimpleSpanProcessor::new(exporter.clone()))
610            .build();
611        let subscriber = tracing_subscriber::registry()
612            .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("heddle-test")));
613        trace::set_trace_export_enabled(true);
614        tracing::subscriber::with_default(subscriber, || {
615            let mut command = CommandTrace::start("status", std::time::Instant::now())
616                .expect("test trace export is enabled");
617            command
618                .span()
619                .in_scope(|| record_phase_span("status repo open", 7));
620            command.finish(0);
621            drop(command);
622        });
623        trace::set_trace_export_enabled(false);
624
625        let spans = exporter.get_finished_spans().expect("read exported spans");
626        assert_eq!(spans.len(), 2);
627        let command = spans
628            .iter()
629            .find(|span| span.name == "heddle.command")
630            .expect("command span exported");
631        let phase = spans
632            .iter()
633            .find(|span| span.name == "heddle.phase")
634            .expect("phase span exported");
635        assert_eq!(phase.parent_span_id, command.span_context.span_id());
636        assert!(command.attributes.iter().any(|attribute| {
637            attribute.key.as_str() == "command.status" && attribute.value.to_string() == "ok"
638        }));
639        assert!(phase.attributes.iter().any(|attribute| {
640            attribute.key.as_str() == "phase.name"
641                && attribute.value.to_string() == "status repo open"
642        }));
643    }
644}