Skip to main content

evo_common/
logging.rs

1use std::env;
2use std::path::PathBuf;
3use tracing_appender::non_blocking::WorkerGuard;
4use tracing_subscriber::EnvFilter;
5use tracing_subscriber::fmt;
6use tracing_subscriber::prelude::*;
7
8const DEFAULT_LOG_DIR: &str = "./logs";
9const ENV_LOG_DIR: &str = "EVO_LOG_DIR";
10
11pub fn log_dir() -> PathBuf {
12    env::var(ENV_LOG_DIR)
13        .map(PathBuf::from)
14        .unwrap_or_else(|_| PathBuf::from(DEFAULT_LOG_DIR))
15}
16
17pub fn init_logging(component: &str) -> WorkerGuard {
18    let dir = log_dir();
19    std::fs::create_dir_all(&dir).expect("Failed to create log directory");
20
21    let file_appender = tracing_appender::rolling::daily(&dir, format!("{component}.log"));
22    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
23
24    let file_layer = fmt::layer()
25        .json()
26        .with_writer(non_blocking)
27        .with_target(true)
28        .with_thread_ids(true)
29        .with_file(true)
30        .with_line_number(true);
31
32    let stdout_layer = fmt::layer().with_target(true).with_thread_ids(false);
33
34    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
35
36    tracing_subscriber::registry()
37        .with(filter)
38        .with(file_layer)
39        .with(stdout_layer)
40        .init();
41
42    guard
43}
44
45// ─── OpenTelemetry integration (behind "tracing-otel" feature) ────────────────
46
47#[cfg(feature = "tracing-otel")]
48pub struct OtelGuard {
49    provider: opentelemetry_sdk::trace::SdkTracerProvider,
50}
51
52#[cfg(feature = "tracing-otel")]
53impl Drop for OtelGuard {
54    fn drop(&mut self) {
55        if let Err(e) = self.provider.shutdown() {
56            eprintln!("OpenTelemetry shutdown error: {e}");
57        }
58    }
59}
60
61/// Initialise structured logging **with** an OpenTelemetry tracing layer.
62///
63/// Spans produced by the `tracing` crate are forwarded to the given OTLP HTTP
64/// endpoint (e.g. `http://localhost:3300`) as distributed traces.  The
65/// `component` name is used both as the log-file stem and as the OTel
66/// `service.name` resource attribute.
67///
68/// Returns two guards that **must** be held for the process lifetime:
69/// * `WorkerGuard` – flushes the non-blocking file appender on drop.
70/// * `OtelGuard`   – shuts down the tracer provider on drop.
71#[cfg(feature = "tracing-otel")]
72pub fn init_logging_with_otel(component: &str, otlp_endpoint: &str) -> (WorkerGuard, OtelGuard) {
73    use opentelemetry::global;
74    use opentelemetry::trace::TracerProvider;
75    use opentelemetry_otlp::{SpanExporter, WithExportConfig};
76    use opentelemetry_sdk::Resource;
77    use opentelemetry_sdk::propagation::TraceContextPropagator;
78    use opentelemetry_sdk::trace::SdkTracerProvider;
79    use tracing_opentelemetry::OpenTelemetryLayer;
80
81    // W3C Trace-Context propagator (traceparent / tracestate headers)
82    global::set_text_map_propagator(TraceContextPropagator::new());
83
84    // OTLP HTTP span exporter – the SDK appends `/v1/traces` automatically
85    let exporter = SpanExporter::builder()
86        .with_http()
87        .with_endpoint(otlp_endpoint)
88        .build()
89        .expect("Failed to build OTLP span exporter");
90
91    let provider = SdkTracerProvider::builder()
92        .with_batch_exporter(exporter)
93        .with_resource(
94            Resource::builder()
95                .with_service_name(component.to_owned())
96                .build(),
97        )
98        .build();
99
100    global::set_tracer_provider(provider.clone());
101
102    let otel_layer = OpenTelemetryLayer::new(provider.tracer(component.to_owned()));
103
104    // File + stdout layers (identical to `init_logging`)
105    let dir = log_dir();
106    std::fs::create_dir_all(&dir).expect("Failed to create log directory");
107    let file_appender = tracing_appender::rolling::daily(&dir, format!("{component}.log"));
108    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
109
110    let file_layer = fmt::layer()
111        .json()
112        .with_writer(non_blocking)
113        .with_target(true)
114        .with_thread_ids(true)
115        .with_file(true)
116        .with_line_number(true);
117
118    let stdout_layer = fmt::layer().with_target(true).with_thread_ids(false);
119
120    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
121
122    tracing_subscriber::registry()
123        .with(filter)
124        .with(file_layer)
125        .with(stdout_layer)
126        .with(otel_layer)
127        .init();
128
129    (guard, OtelGuard { provider })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    // Serialise env-var mutation tests so parallel test threads don't race.
137    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
138
139    #[test]
140    fn default_log_dir() {
141        let _guard = ENV_MUTEX.lock().unwrap();
142        unsafe { env::remove_var(ENV_LOG_DIR) };
143        assert_eq!(log_dir(), PathBuf::from("./logs"));
144    }
145
146    #[test]
147    fn custom_log_dir() {
148        let _guard = ENV_MUTEX.lock().unwrap();
149        unsafe { env::set_var(ENV_LOG_DIR, "/tmp/evo-test-logs") };
150        let result = log_dir();
151        unsafe { env::remove_var(ENV_LOG_DIR) };
152        assert_eq!(result, PathBuf::from("/tmp/evo-test-logs"));
153    }
154}