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