1use 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 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 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
254pub 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}