Skip to main content

endpoint_libs/libs/
log.rs

1//! Tracing-based logging setup with stdout and file logging
2//!
3//! # OpenTelemetry
4//! There is no OTLP export here any more. The exporter stack this crate used --
5//! `opentelemetry-otlp` and the SDK providers behind it -- was the last thing in the
6//! graph that pulled tokio in, and it did so for the protobuf *message types* rather
7//! than for a runtime: `http-proto` requires `opentelemetry-proto/gen-tonic-messages`,
8//! that feature is `["tonic", "tonic-prost", "prost"]`, and tonic 0.14 lists
9//! `tokio-stream` as a non-optional dependency, which in turn lists tokio. No transport
10//! or encoding choice on `opentelemetry-otlp` 0.31 avoids those types, so the only ways
11//! out were to hand-roll an OTLP encoder or to drop the exporter. Nothing in the fleet
12//! enabled the `otel` feature, so the exporter went.
13//!
14//! [`OtelConfig`] stays because several backends construct it inside a [`LoggingConfig`]
15//! literal and deleting it would break their builds for no gain. It is inert: setting
16//! `enabled: true` now warns at setup and forwards nothing.
17
18use std::{collections::HashMap, path::PathBuf, time::Duration};
19
20use chrono::SecondsFormat;
21use eyre::{DefaultHandler, EyreHandler, bail};
22use tracing::Subscriber;
23use tracing_appender::rolling::RollingFileAppender;
24use tracing_subscriber::{
25    EnvFilter, Layer, Registry,
26    layer::SubscriberExt,
27    registry,
28    reload::{self, Handle},
29};
30
31#[cfg(feature = "log_throttling")]
32use tracing_throttle::TracingRateLimitLayer;
33
34#[cfg(feature = "error_aggregation")]
35use {crate::libs::log::error_aggregation::*, std::sync::Arc};
36
37#[deprecated(
38    since = "1.3.0",
39    note = "This code is not used in current projects and should probably not be used going forward"
40)]
41pub mod legacy;
42
43#[cfg(feature = "error_aggregation")]
44pub mod error_aggregation;
45pub mod level_filter;
46
47pub use level_filter::*;
48
49// Public re-export of Rotation so clients don't need to include tracing_appender just for log setup
50pub use tracing_appender::rolling::Rotation as LogRotation;
51
52pub use tracing_appender::non_blocking::WorkerGuard;
53
54/// Configuration for OpenTelemetry integration.
55///
56/// Inert. It used to drive an OTLP exporter; that exporter is gone (see the module
57/// docs for why), and nothing reads these fields any more except the `enabled` flag,
58/// which [`setup_logging`] warns about so an operator who configured a collector is
59/// told to their face that nothing is being sent.
60///
61/// It is kept, rather than deleted with the exporter, because it lives in the `types`
62/// feature and four backends name it in a `LoggingConfig` struct literal. It carries no
63/// dependency of its own -- a bool, two `Option<String>`s and a map -- so keeping it
64/// costs nothing and removing it would be a source break with no compile-time benefit.
65/// It is also the seam to reattach an exporter to, if one ever comes back on a
66/// tokio-free transport.
67#[derive(Debug, Clone, Default)]
68pub struct OtelConfig {
69    /// Whether OTel log/trace forwarding was requested. Requesting it now only warns.
70    pub enabled: bool,
71    /// Service name that would identify this application to a collector.
72    pub service_name: Option<String>,
73    /// OTLP collector endpoint (e.g., "http://localhost:4318").
74    pub endpoint: Option<String>,
75    /// Additional headers that would be sent with OTLP requests (e.g., authentication).
76    pub headers: HashMap<String, String>,
77}
78
79#[derive(Debug)]
80pub struct LoggingConfig {
81    pub level: LogLevel,
82    pub file_config: Option<FileLoggingConfig>,
83    /// OpenTelemetry configuration. Accepted and ignored: OTLP export was removed, so
84    /// `enabled: true` forwards nothing and logs a warning at setup. The field stays so
85    /// existing `LoggingConfig` literals keep compiling. See [`OtelConfig`].
86    pub otel_config: OtelConfig,
87    #[cfg(feature = "error_aggregation")]
88    pub error_aggregation: ErrorAggregationConfig,
89    #[cfg(feature = "log_throttling")]
90    pub throttling_config: Option<LogThrottlingConfig>,
91}
92
93#[derive(Debug, Clone)]
94pub struct FileLoggingConfig {
95    pub path: PathBuf,
96    /// The name of the log file before the '.log', e.g. <file_prefix>.log will be the final log file.
97    /// Set to None for the current timestamp
98    pub file_prefix: Option<String>,
99    // Specifying None means that there will be one log file per program execution
100    pub rotation: Option<LogRotation>,
101}
102
103#[derive(Debug, Default, Clone)]
104pub struct LogThrottlingConfig {
105    /// How often to emit throttling summaries as WARN events, set to None to disable entirely
106    pub summary_emission_interval: Option<Duration>,
107    /// How often to throttling metrics as INFO events, set to None to disable entirely
108    pub metrics_emission_interval: Option<Duration>,
109    /// Fields to exclude from uniqueness checks, set to None to disable entirely.<br><br>
110    /// Example:
111    /// ```ignore
112    /// tracing::info!(user_id=1, "User joined");
113    /// tracing::info!(user_id=2, "User joined");  
114    ///```
115    /// If the `user_id` field is excluded, these will be treated as exactly the same log, so multiple users join messages could be throttled
116    pub excluded_fields: Option<Vec<String>>,
117    /// Targets to exempt from any throttling. This allows the caller to ensure that any high priority logs are always displayed.<br><br>
118    /// Example:
119    /// ```ignore
120    /// let exemptions = Some(vec!["nothrottle"]); // Assuming this is passed during config stage
121    ///
122    /// tracing::error!(target: "nothrottle", user_id=1, "User joined"); // Will never be throttled
123    /// tracing::error!(user_id=2, "User joined");  // Can possibly be throttled
124    /// ```
125    pub exemptions: Option<Vec<String>>,
126}
127
128pub struct LogSetupReturn {
129    pub reload_handle: LogReloadHandle,
130    pub log_guards: (WorkerGuard, Option<WorkerGuard>),
131    #[cfg(feature = "error_aggregation")]
132    pub errors_container: Arc<ErrorAggregationContainer>,
133    #[cfg(feature = "log_throttling")]
134    /// Call shutdown on this during graceful shutdown
135    pub log_throttling_handle: TracingRateLimitLayer,
136}
137
138/// Internal struct to hold the result of building the logging subscriber
139struct LoggingSubscriberParts {
140    subscriber: Box<dyn Subscriber + Send + Sync + 'static>,
141    reload_handle: LogReloadHandle,
142    log_guards: (WorkerGuard, Option<WorkerGuard>), // Stdout and optional file log guards
143    #[cfg(feature = "error_aggregation")]
144    errors_container: Arc<ErrorAggregationContainer>,
145    #[cfg(feature = "log_throttling")]
146    log_throttling_handle: TracingRateLimitLayer,
147}
148
149/// Internal function that builds the logging subscriber without initializing it.
150/// Returns the subscriber along with reload handles and guards that need to be retained.
151fn build_logging_subscriber(config: LoggingConfig) -> eyre::Result<LoggingSubscriberParts> {
152    // Build global env filter and wrap in reload::Layer for runtime reloading
153    let global_env_filter = build_env_filter(config.level)?;
154    let (reloadable_global_filter, global_reload_handle) = reload::Layer::new(global_env_filter);
155
156    let (non_blocking_stdout, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
157
158    let stdout_layer = tracing_subscriber::fmt::layer()
159        .with_thread_names(true)
160        .with_line_number(true)
161        .with_writer(non_blocking_stdout);
162
163    #[cfg(feature = "error_aggregation")]
164    let error_aggregation_config = config.error_aggregation.clone();
165
166    #[cfg(feature = "log_throttling")]
167    let throttling_config = config.throttling_config.clone().unwrap_or_default();
168
169    #[cfg(feature = "log_throttling")]
170    let mut exemptions = vec!["tracing_throttle::infrastructure::layer".to_string()]; // It seems like it tries to throttle its own logs
171    #[cfg(feature = "log_throttling")]
172    exemptions.extend(throttling_config.exemptions.unwrap_or_default());
173
174    #[cfg(feature = "log_throttling")]
175    let rate_limit_filter = TracingRateLimitLayer::builder()
176        .with_excluded_fields(throttling_config.excluded_fields.unwrap_or_default())
177        .with_exempt_targets(exemptions)
178        .with_active_emission(throttling_config.summary_emission_interval.is_some())
179        .with_summary_interval(
180            throttling_config
181                .summary_emission_interval
182                .unwrap_or(Duration::from_secs(5 * 60)),
183        )
184        .build()
185        .expect("Error building tracing rate limit layer");
186
187    #[cfg(feature = "log_throttling")]
188    if let Some(metrics_duration) = throttling_config.metrics_emission_interval {
189        let metrics = rate_limit_filter.metrics().clone();
190
191        // Periodic metrics reporting. Detached, as before: nothing collects this
192        // handle, and dropping a nagoya `JoinHandle` detaches rather than cancels.
193        nagoya::runtime::background().spawn(async move {
194            loop {
195                nagoya::sleep(metrics_duration).await;
196
197                let snapshot = metrics.snapshot();
198                tracing::info!(
199                    events_allowed = snapshot.events_allowed,
200                    events_suppressed = snapshot.events_suppressed,
201                    suppression_rate = format!("{:.1}%", snapshot.suppression_rate() * 100.0),
202                    "Rate limiting metrics"
203                );
204            }
205        });
206    }
207
208    #[cfg(feature = "log_throttling")]
209    let log_throttling_handle = rate_limit_filter.clone();
210
211    let (file_layer, file_guard) = match config.file_config {
212        None => (None, None),
213        Some(file_config) => {
214            let appender = RollingFileAppender::builder()
215                .rotation(file_config.rotation.unwrap_or(LogRotation::NEVER))
216                .filename_prefix(file_config.file_prefix.unwrap_or_else(|| {
217                    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
218                })) // Default filename prefix is a timestamp. RollingFileAppender requires a prefix if we are using a simple suffix like ".log"
219                .filename_suffix("log")
220                .build(file_config.path)?;
221
222            let (non_blocking_appender, guard) = tracing_appender::non_blocking(appender);
223
224            let base_layer = tracing_subscriber::fmt::layer()
225                .with_thread_names(true)
226                .with_line_number(true)
227                .with_ansi(false)
228                .with_writer(non_blocking_appender);
229
230            #[cfg(feature = "log_throttling")]
231            let file_layer = base_layer.with_filter(rate_limit_filter);
232
233            #[cfg(not(feature = "log_throttling"))]
234            let file_layer = base_layer;
235
236            (Some(file_layer), Some(guard))
237        }
238    };
239
240    let reload_handle = LogReloadHandle(global_reload_handle);
241
242    // There is no exporter to build any more, and no feature flag that brings one back.
243    // An operator who set `enabled: true` pointed this process at a collector and is
244    // entitled to find out that nothing arrives there, so the flag warns rather than
245    // being ignored. The warning fires once per `setup_logging` call, which is once per
246    // process in every consumer, and it names the config field so the fix is obvious.
247    if config.otel_config.enabled {
248        tracing::warn!(
249            target: "otel::setup",
250            endpoint = config.otel_config.endpoint.as_deref().unwrap_or("unset"),
251            "otel_config.enabled is true but OTLP export has been removed from endpoint-libs - \
252             no traces or logs will be forwarded to a collector"
253        );
254    }
255
256    // --- Subscriber Composition ---
257
258    // Start with Registry and Global Filter
259    let subscriber = registry().with(reloadable_global_filter);
260
261    // Compose the "Sink" layers (Stdout + File)
262    let sinks = stdout_layer.and_then(file_layer);
263
264    // Add Error Aggregation if enabled
265    #[cfg(feature = "error_aggregation")]
266    let (sinks, errors_container) = {
267        use crate::libs::log::error_aggregation::get_error_aggregation;
268        let (container, error_layer) = get_error_aggregation(error_aggregation_config);
269        (sinks.and_then(error_layer), container)
270    };
271
272    // Combine Subscriber with Sinks
273    let subscriber = subscriber.with(sinks);
274
275    // The box is still here with no OTel arm to choose against: `setup_logging` and
276    // `setup_logging_test` install the same concrete type, and the erasure is what lets
277    // the feature-gated layers above change the subscriber's type without changing this
278    // function's signature.
279    let subscriber: Box<dyn Subscriber + Send + Sync + 'static> = Box::new(subscriber);
280
281    Ok(LoggingSubscriberParts {
282        subscriber,
283        reload_handle,
284        log_guards: (stdout_guard, file_guard),
285        #[cfg(feature = "error_aggregation")]
286        errors_container,
287        #[cfg(feature = "log_throttling")]
288        log_throttling_handle,
289    })
290}
291
292/// Sets up a fully filtered tracing logging system with a global env filter applied at the registry level.
293/// This ensures all layers (stdout, file, etc.) only receive events that pass the global filter.
294///
295/// By default with the minimal config a stdout layer is setup with a custom env filter that filters according to the specified log level,
296/// as well as according to a list of hardcoded crates. See [build_env_filter] for hardcoded crate log filtering.
297/// Optionally, file logging can be configured with [FileLoggingConfig], which minimally will create a timestamp prefixed log file in the given
298/// path that does not rotate (one log file per program execution)
299///
300/// Extra configuration for file logging is:
301/// - Log file rotation. See [LogRotation] for possible options
302///
303/// Returns [LogSetupReturn], which is a composite struct containing objects that need to be retained by the client such as:
304/// - [LogReloadHandle], for setting a new global log level during runtime
305/// - [WorkerGuard], so that the non-blocking file writer can continue writing. This cannot be dropped and needs to be kept alive for the duration of the program execution
306/// - [ErrorAggregationContainer], if the [error_aggregation] feature is enabled. This object allows recent errors to be queried from the logging framework
307///
308/// `otel_config.enabled` is accepted and warned about; there is no OTel guard to return
309/// any more because there is no exporter to flush.
310pub fn setup_logging(config: LoggingConfig) -> eyre::Result<LogSetupReturn> {
311    use tracing_subscriber::util::SubscriberInitExt;
312
313    let parts = build_logging_subscriber(config)?;
314
315    parts.subscriber.init();
316
317    Ok(LogSetupReturn {
318        reload_handle: parts.reload_handle,
319        log_guards: parts.log_guards,
320        #[cfg(feature = "error_aggregation")]
321        errors_container: parts.errors_container,
322        #[cfg(feature = "log_throttling")]
323        log_throttling_handle: parts.log_throttling_handle,
324    })
325}
326
327/// Handle for reloading the global EnvFilter at runtime
328pub type GlobalLogReloadHandle = Handle<EnvFilter, Registry>;
329
330/// Handle for reloading the global log level at runtime
331#[derive(Clone)]
332pub struct LogReloadHandle(GlobalLogReloadHandle);
333
334impl std::fmt::Debug for LogReloadHandle {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.debug_struct("LogReloadHandle")
337            .field("inner", &"Handle<EnvFilter, Registry>")
338            .finish()
339    }
340}
341
342impl LogReloadHandle {
343    /// Sets a new global log level that applies to all layers (stdout, file, etc.)
344    /// Errors will be logged by this function and can be safely ignored, but are returned for display purposes
345    pub fn set_log_level(&self, level: LogLevel) -> eyre::Result<()> {
346        match build_env_filter(level) {
347            Ok(filter) => match self.0.modify(|env_filter| *env_filter = filter) {
348                Ok(_) => Ok(()),
349                Err(error) => {
350                    tracing::error!(
351                        ?error,
352                        "Error setting new global log filter. Ignoring reload attempt"
353                    );
354                    bail!("Error setting new global log filter: {error}. Ignoring reload attempt")
355                }
356            },
357            Err(error) => {
358                tracing::error!(
359                    ?error,
360                    "Error building new filter from given log level. Ignoring reload attempt"
361                );
362                bail!(
363                    "Error building new filter from given log level: {error}. Ignoring reload attempt"
364                )
365            }
366        }
367    }
368}
369
370/// This is used to wrap the default EyreHandler but simply expose the [CustomEyreHandler::location] field via [CustomEyreHandler::get_location]
371/// This allows the code in [crate::libs::ws::internal_error_to_resp] to access the original caller location which is then stored in a [tracing::Field]
372/// for access within [error_aggregation::ErrorAggregationLayer::on_event] (see trait impl) so that the original caller can be recorded within the displayed target
373pub struct CustomEyreHandler {
374    default_handler: Box<dyn EyreHandler>,
375    location: Option<&'static std::panic::Location<'static>>,
376}
377
378impl CustomEyreHandler {
379    pub fn default_with_location_saving(
380        error: &(dyn std::error::Error + 'static),
381    ) -> Box<dyn EyreHandler> {
382        Box::new(Self {
383            default_handler: DefaultHandler::default_with(error),
384            location: None,
385        })
386    }
387
388    pub fn get_location(&self) -> &Option<&'static std::panic::Location<'static>> {
389        &self.location
390    }
391}
392
393impl EyreHandler for CustomEyreHandler {
394    fn display(
395        &self,
396        error: &(dyn std::error::Error + 'static),
397        f: &mut core::fmt::Formatter<'_>,
398    ) -> core::fmt::Result {
399        self.default_handler.display(error, f)
400    }
401
402    fn debug(
403        &self,
404        error: &(dyn std::error::Error + 'static),
405        f: &mut core::fmt::Formatter<'_>,
406    ) -> core::fmt::Result {
407        self.default_handler.debug(error, f)
408    }
409
410    fn track_caller(&mut self, location: &'static std::panic::Location<'static>) {
411        self.location = Some(location); // Store the location for access later
412        self.default_handler.track_caller(location);
413    }
414}
415
416#[cfg(test)]
417pub struct LogSetupReturnTest {
418    _guard: tracing::subscriber::DefaultGuard,
419    reload_handle: LogReloadHandle,
420    #[allow(dead_code)]
421    log_guards: (WorkerGuard, Option<WorkerGuard>),
422    #[cfg(feature = "error_aggregation")]
423    #[allow(dead_code)]
424    errors_container: Arc<ErrorAggregationContainer>,
425}
426
427/// Test-specific logging setup that uses thread-local scoped dispatcher instead of global
428/// This allows multiple tests to run without conflicting over the global subscriber
429#[cfg(test)]
430pub fn setup_logging_test(config: LoggingConfig) -> eyre::Result<LogSetupReturnTest> {
431    let parts = build_logging_subscriber(config)?;
432
433    // Use thread-local default instead of global
434    let guard = tracing::subscriber::set_default(parts.subscriber);
435
436    Ok(LogSetupReturnTest {
437        _guard: guard,
438        reload_handle: parts.reload_handle,
439        log_guards: parts.log_guards,
440        #[cfg(feature = "error_aggregation")]
441        errors_container: parts.errors_container,
442    })
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use std::fs;
449    use tracing::{debug, error, info};
450
451    #[cfg(feature = "error_aggregation")]
452    fn default_error_aggregation_config() -> ErrorAggregationConfig {
453        ErrorAggregationConfig {
454            limit: 100,
455            normalize: true,
456        }
457    }
458
459    /// Basic test that verifies logging setup succeeds and logs can be emitted
460    // These were `#[tokio::test]` only to have a runtime in scope; not one of
461    // them awaits anything, and the waits are `std::thread::sleep` for the
462    // non-blocking appender to drain. They are plain tests now.
463    #[test]
464    fn test_basic_logging_stdout() {
465        let config = LoggingConfig {
466            level: LogLevel::Info,
467            file_config: None,
468            otel_config: OtelConfig::default(),
469            #[cfg(feature = "error_aggregation")]
470            error_aggregation: default_error_aggregation_config(),
471            #[cfg(feature = "log_throttling")]
472            throttling_config: Some(LogThrottlingConfig::default()),
473        };
474
475        let _guard = setup_logging_test(config);
476        assert!(_guard.is_ok(), "Failed to setup logging");
477
478        // Emit some logs to verify the system works
479        info!("Test info message");
480        error!("Test error message");
481    }
482
483    /// Test file logging functionality including:
484    /// - Basic file logging
485    /// - Log level filtering (separate stdout vs file levels)
486    /// - Log level reloading at runtime
487    #[test]
488    fn test_file_logging_comprehensive() {
489        let temp_dir = tempfile::tempdir().unwrap();
490
491        let config = LoggingConfig {
492            level: LogLevel::Info,
493            file_config: Some(FileLoggingConfig {
494                path: temp_dir.path().to_path_buf(),
495                file_prefix: Some("test".to_string()),
496                rotation: None,
497            }),
498            otel_config: OtelConfig::default(),
499            #[cfg(feature = "error_aggregation")]
500            error_aggregation: default_error_aggregation_config(),
501            #[cfg(feature = "log_throttling")]
502            throttling_config: Some(LogThrottlingConfig::default()),
503        };
504
505        let log_setup = setup_logging_test(config).unwrap();
506
507        // Test 1: Basic logging and level filtering (before reload)
508        info!("info_message_before_reload");
509        debug!("debug_message_before_reload");
510        error!("error_message");
511
512        std::thread::sleep(std::time::Duration::from_millis(100));
513
514        // Test 2: Reload global log level to DEBUG
515        let result = log_setup.reload_handle.set_log_level(LogLevel::Debug);
516        assert!(result.is_ok());
517
518        // Test 3: Verify debug logs now appear after reload
519        info!("info_message_after_reload");
520        debug!("debug_message_after_reload");
521
522        std::thread::sleep(std::time::Duration::from_millis(100));
523        drop(log_setup);
524
525        std::thread::sleep(std::time::Duration::from_millis(100));
526
527        // Verify file was created and contains expected logs
528        let log_files: Vec<_> = fs::read_dir(temp_dir.path()).unwrap().collect();
529        assert_eq!(log_files.len(), 1, "Expected exactly one log file");
530
531        let log_file = log_files[0].as_ref().unwrap();
532        let log_contents = fs::read_to_string(log_file.path()).unwrap();
533
534        // Verify messages before reload
535        assert!(log_contents.contains("info_message_before_reload"));
536        assert!(
537            !log_contents.contains("debug_message_before_reload"),
538            "Debug should not appear before reload"
539        );
540        assert!(log_contents.contains("error_message"));
541
542        // Verify messages after reload
543        assert!(log_contents.contains("info_message_after_reload"));
544
545        // Global log level reload applies to all layers including file
546        assert!(
547            log_contents.contains("debug_message_after_reload"),
548            "Debug should appear after reload to DEBUG level"
549        );
550    }
551
552    // === OTel config tests ===
553    //
554    // OTLP export is gone; `OtelConfig` remains as an inert config struct so
555    // the backends that build a `LoggingConfig` literal keep compiling. What
556    // is worth asserting is therefore no longer that an exporter came up, but
557    // that asking for one is accepted, warned about, and harmless.
558
559    /// A config with `enabled: true` must not fail setup, and must not take
560    /// the rest of the subscriber down with it.
561    ///
562    /// This replaces `test_otel_disabled_returns_none_guards` and
563    /// `test_otel_graceful_degradation_unreachable_endpoint`, which asserted
564    /// on `LogSetupReturn::otel_guards`. That field went with the exporter.
565    /// The endpoint is still the unreachable one those tests used, because the
566    /// property that matters is unchanged: nothing here may try to reach it at
567    /// setup time.
568    #[test]
569    fn enabling_otel_is_accepted_and_changes_nothing() {
570        let config = LoggingConfig {
571            level: LogLevel::Info,
572            file_config: None,
573            otel_config: OtelConfig {
574                enabled: true,
575                endpoint: Some("http://nonexistent.invalid:4317".to_string()),
576                ..OtelConfig::default()
577            },
578            #[cfg(feature = "error_aggregation")]
579            error_aggregation: default_error_aggregation_config(),
580            #[cfg(feature = "log_throttling")]
581            throttling_config: None,
582        };
583
584        let result = setup_logging_test(config);
585        assert!(
586            result.is_ok(),
587            "an otel_config asking for export must not fail setup"
588        );
589    }
590
591    /// The default config asks for nothing and is the common case.
592    #[test]
593    fn a_default_otel_config_sets_up_cleanly() {
594        let config = LoggingConfig {
595            level: LogLevel::Info,
596            file_config: None,
597            otel_config: OtelConfig::default(),
598            #[cfg(feature = "error_aggregation")]
599            error_aggregation: default_error_aggregation_config(),
600            #[cfg(feature = "log_throttling")]
601            throttling_config: None,
602        };
603
604        assert!(setup_logging_test(config).is_ok());
605    }
606
607    /// Test that file logging works alongside OTel enabled (but with invalid endpoint)
608    /// This verifies that the layer composition doesn't break when OTel is attempted
609    #[test]
610    fn test_file_logging_with_otel_attempted() {
611        let temp_dir = tempfile::tempdir().unwrap();
612
613        let config = LoggingConfig {
614            level: LogLevel::Info,
615            file_config: Some(FileLoggingConfig {
616                path: temp_dir.path().to_path_buf(),
617                file_prefix: Some("otel_test".to_string()),
618                rotation: None,
619            }),
620            otel_config: OtelConfig {
621                enabled: true,
622                endpoint: Some("http://localhost:4317".to_string()), // Not running
623                ..OtelConfig::default()
624            },
625            #[cfg(feature = "error_aggregation")]
626            error_aggregation: default_error_aggregation_config(),
627            #[cfg(feature = "log_throttling")]
628            throttling_config: None,
629        };
630
631        let log_setup = setup_logging_test(config).unwrap();
632
633        // Emit logs - these should go to file regardless of OTel status
634        info!("file_log_with_otel_attempted");
635        error!("error_log_with_otel_attempted");
636
637        std::thread::sleep(std::time::Duration::from_millis(100));
638        drop(log_setup);
639        std::thread::sleep(std::time::Duration::from_millis(100));
640
641        // Verify file was created and contains logs
642        let log_files: Vec<_> = fs::read_dir(temp_dir.path()).unwrap().collect();
643        assert_eq!(log_files.len(), 1, "Expected exactly one log file");
644
645        let log_contents = fs::read_to_string(log_files[0].as_ref().unwrap().path()).unwrap();
646        assert!(log_contents.contains("file_log_with_otel_attempted"));
647        assert!(log_contents.contains("error_log_with_otel_attempted"));
648    }
649
650    /// Test that log level reload works correctly when OTel is enabled
651    #[test]
652    fn test_log_level_reload_with_otel_enabled() {
653        let temp_dir = tempfile::tempdir().unwrap();
654
655        let config = LoggingConfig {
656            level: LogLevel::Info,
657            file_config: Some(FileLoggingConfig {
658                path: temp_dir.path().to_path_buf(),
659                file_prefix: Some("reload_test".to_string()),
660                rotation: None,
661            }),
662            otel_config: OtelConfig {
663                enabled: true,
664                endpoint: Some("http://localhost:4317".to_string()),
665                ..OtelConfig::default()
666            },
667            #[cfg(feature = "error_aggregation")]
668            error_aggregation: default_error_aggregation_config(),
669            #[cfg(feature = "log_throttling")]
670            throttling_config: None,
671        };
672
673        let log_setup = setup_logging_test(config).unwrap();
674
675        // Log at INFO level (should appear)
676        info!("info_before_reload");
677        debug!("debug_before_reload"); // Should NOT appear
678
679        std::thread::sleep(std::time::Duration::from_millis(100));
680
681        // Reload to DEBUG
682        let result = log_setup.reload_handle.set_log_level(LogLevel::Debug);
683        assert!(
684            result.is_ok(),
685            "Log level reload should succeed with OTel enabled"
686        );
687
688        // Log at DEBUG level (should now appear)
689        info!("info_after_reload");
690        debug!("debug_after_reload");
691
692        std::thread::sleep(std::time::Duration::from_millis(100));
693        drop(log_setup);
694        std::thread::sleep(std::time::Duration::from_millis(100));
695
696        let log_files: Vec<_> = fs::read_dir(temp_dir.path()).unwrap().collect();
697        let log_contents = fs::read_to_string(log_files[0].as_ref().unwrap().path()).unwrap();
698
699        assert!(log_contents.contains("info_before_reload"));
700        assert!(
701            !log_contents.contains("debug_before_reload"),
702            "Debug should not appear before reload"
703        );
704        assert!(log_contents.contains("info_after_reload"));
705        assert!(
706            log_contents.contains("debug_after_reload"),
707            "Debug should appear after reload"
708        );
709    }
710
711    /// Test that OTel config fields are correctly structured
712    #[test]
713    fn test_otel_config_fields() {
714        use std::collections::HashMap;
715
716        let config = OtelConfig {
717            enabled: true,
718            service_name: Some("test-service".to_string()),
719            endpoint: Some("http://collector:4317".to_string()),
720            headers: {
721                let mut h = HashMap::new();
722                h.insert("x-api-key".to_string(), "secret-key".to_string());
723                h
724            },
725        };
726
727        assert!(config.enabled);
728        assert_eq!(config.service_name, Some("test-service".to_string()));
729        assert_eq!(config.endpoint, Some("http://collector:4317".to_string()));
730        assert_eq!(
731            config.headers.get("x-api-key"),
732            Some(&"secret-key".to_string())
733        );
734
735        // Default config should be disabled
736        let default_config = OtelConfig::default();
737        assert!(!default_config.enabled);
738        assert!(default_config.service_name.is_none());
739        assert!(default_config.endpoint.is_none());
740        assert!(default_config.headers.is_empty());
741    }
742}