Skip to main content

cortexkit_log/
lib.rs

1//! A synchronous `tracing` layer for CortexKit's module-owned fleet logs.
2//!
3//! One crate, one format, one file per module per day. See
4//! `subconscious/docs/specs/fleet-logging.md` (r2) for the contract every line
5//! here implements; the golden fixture beside it is what the tests pin to.
6
7mod filter;
8mod format;
9mod redaction;
10mod segment;
11mod sink;
12
13pub use segment::{prune_candidates, segment_day, segment_name, SegmentRetention};
14pub use sink::LineSink;
15
16use std::backtrace::Backtrace;
17use std::borrow::Cow;
18use std::env;
19use std::fmt;
20use std::io::{self, Write};
21use std::panic;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24use std::sync::{Arc, Mutex, OnceLock};
25use std::time::SystemTime;
26
27use filter::LevelFilter;
28pub use format::{ParseError, ParsedLevel, ParsedLine};
29use redaction::fleet_redact;
30use segment::{SegmentDestination, SegmentNotice};
31use tracing::field::{Field, Visit};
32use tracing::span::{Attributes, Id, Record};
33use tracing::{Event, Metadata, Subscriber};
34use tracing_subscriber::layer::{Context, SubscriberExt};
35use tracing_subscriber::registry::LookupSpan;
36use tracing_subscriber::{Layer, Registry};
37
38static WRITE_FAILURE_REPORTED: AtomicBool = AtomicBool::new(false);
39static FILTER_FAILURE_REPORTED: AtomicBool = AtomicBool::new(false);
40
41/// Size and age bounds for the rename-rotated [`LineSink`], which has exactly
42/// one writer (the daemon's per-child stderr capture). The logger's own
43/// segments use [`SegmentRetention`]; the two are different files with
44/// different writer counts, and only the single-writer one may rename.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct Retention {
47    /// Maximum active-file size in mebibytes.
48    pub max_file_mb: u32,
49    /// Number of rotated generations to retain.
50    pub keep: u8,
51    /// Maximum age of rotated generations in days.
52    pub max_age_days: u32,
53}
54
55impl Default for Retention {
56    fn default() -> Self {
57        Self {
58            max_file_mb: 32,
59            keep: 2,
60            max_age_days: 14,
61        }
62    }
63}
64
65impl Retention {
66    const TEST_BYTES_MARKER: u32 = 1 << 31;
67
68    /// Constructs a byte-sized cap so retention tests do not write whole MiB files.
69    #[doc(hidden)]
70    pub fn from_bytes_for_testing(max_file_bytes: u32, keep: u8, max_age_days: u32) -> Self {
71        assert!(max_file_bytes < Self::TEST_BYTES_MARKER);
72        Self {
73            max_file_mb: Self::TEST_BYTES_MARKER | max_file_bytes,
74            keep,
75            max_age_days,
76        }
77    }
78
79    pub(crate) fn max_bytes(self) -> u64 {
80        if self.max_file_mb & Self::TEST_BYTES_MARKER != 0 {
81            return u64::from(self.max_file_mb & !Self::TEST_BYTES_MARKER);
82        }
83
84        u64::from(self.max_file_mb) * 1024 * 1024
85    }
86}
87
88/// A complete-line redactor composed after the fleet credential redactor.
89pub type Redactor = dyn for<'line> Fn(&'line str) -> Cow<'line, str> + Send + Sync;
90
91/// Logger configuration for one process.
92pub struct Config {
93    /// Stable fleet module identifier: the root of every logger name and the
94    /// first part of every segment's file name.
95    pub module_id: String,
96    /// The directory the segments live in. Fleet callers never assemble this:
97    /// [`Config::for_module`] derives it from the module id through the same
98    /// resolver every store uses, so a log cannot land beside the wrong data.
99    /// Public for tests and for the daemon's own `run/logs/` lane, which is not
100    /// a module data directory.
101    pub logs_dir: PathBuf,
102    /// Fields bound for the whole process and rendered in the bracket on every
103    /// line, in this order. A harness-hosted plugin binds `harness=<name>`
104    /// here: after r2 every lane shares one segment and this is what tells
105    /// them apart.
106    pub bound: Vec<(String, String)>,
107    /// `CK_LOG` override; `None` reads the process environment.
108    pub spec: Option<String>,
109    /// Age window and oversize alarm for the segments.
110    pub retention: SegmentRetention,
111    /// Optional module redactor, applied after fleet credential redaction.
112    pub redactor: Option<Arc<Redactor>>,
113    /// Optional clock override for deterministic callers and tests.
114    pub clock: Option<Arc<dyn Fn() -> SystemTime + Send + Sync>>,
115}
116
117impl Config {
118    /// A configuration whose segment directory the caller already resolved:
119    /// `CK_LOG` from the environment, fleet default retention, nothing bound.
120    /// This is the path for a consumer that owns its module data directory
121    /// through its own pinned store crate and must not pull a second copy of
122    /// it through this one (see the `store-paths` feature).
123    pub fn in_dir(module_id: &str, logs_dir: impl Into<PathBuf>) -> Self {
124        Self {
125            module_id: module_id.to_owned(),
126            logs_dir: logs_dir.into(),
127            bound: Vec::new(),
128            spec: None,
129            retention: SegmentRetention::default(),
130            redactor: None,
131            clock: None,
132        }
133    }
134
135    /// The fleet configuration for a supervised module's own process: segments
136    /// under `<module data dir>/logs/`, `CK_LOG` from the environment (the
137    /// daemon injects it at spawn), fleet default retention, nothing bound.
138    #[cfg(feature = "store-paths")]
139    pub fn for_module(module_id: &str) -> Self {
140        let data_dir = PathBuf::from(cortexkit_store_types::module_data_dir(module_id));
141        Self::in_dir(module_id, data_dir.join("logs"))
142    }
143
144    /// [`Config::for_module`] with `harness=<harness>` bound on every line, for
145    /// a plugin running inside a harness process.
146    #[cfg(feature = "store-paths")]
147    pub fn for_plugin(module_id: &str, harness: &str) -> Self {
148        let mut config = Self::for_module(module_id);
149        config
150            .bound
151            .push(("harness".to_owned(), harness.to_owned()));
152        config
153    }
154
155    /// The configuration a supervised module can build with no arguments,
156    /// because the daemon already injects everything it needs at spawn:
157    /// `SUBC_MODULE_ID` (for launch attestation), `CK_LOG`, and the retention
158    /// knobs as `CK_LOG_MAX_AGE_DAYS` / `CK_LOG_ALARM_SEGMENT_MB`. This is the
159    /// zero-argument path the r2 spec requires so that reaching for the crate
160    /// costs no more than reaching for `eprintln!`.
161    #[cfg(feature = "store-paths")]
162    pub fn from_env() -> Result<Self, InitError> {
163        let module_id = env::var("SUBC_MODULE_ID")
164            .ok()
165            .filter(|value| !value.is_empty())
166            .ok_or(InitError::ModuleIdNotInEnvironment)?;
167        let mut config = Self::for_module(&module_id);
168        if let Some(days) = env_u32("CK_LOG_MAX_AGE_DAYS") {
169            config.retention.max_age_days = days;
170        }
171        if let Some(mb) = env_u32("CK_LOG_ALARM_SEGMENT_MB") {
172            config.retention.alarm_segment_mb = mb;
173        }
174        Ok(config)
175    }
176}
177
178#[cfg(feature = "store-paths")]
179fn env_u32(name: &str) -> Option<u32> {
180    env::var(name).ok()?.trim().parse().ok()
181}
182
183/// A live logger handle suitable for inclusion in module health reports.
184#[derive(Clone)]
185pub struct Handle {
186    inner: Arc<LoggerInner>,
187}
188
189impl Handle {
190    /// Returns the number of lines dropped after a write failure.
191    pub fn swallowed_writes(&self) -> u64 {
192        self.inner.swallowed_writes.load(Ordering::Relaxed)
193    }
194
195    /// Returns the segment path for the current instant.
196    pub fn path(&self) -> PathBuf {
197        self.inner.path_now()
198    }
199
200    /// The directory the segments live in.
201    pub fn logs_dir(&self) -> &Path {
202        &self.inner.logs_dir
203    }
204
205    /// Reports whether opening the directory failed and events are going to stderr.
206    pub fn fallback_active(&self) -> bool {
207        self.inner.fallback_active
208    }
209
210    /// Writes one line stamped with `at` into the segment `at` names, instead
211    /// of the one the logger's clock names now.
212    ///
213    /// For a record that must sit beside lines written under a different clock
214    /// reading. The case it exists for is a wall-clock step: the lines before
215    /// the step went to the segment the old clock named, so a marker describing
216    /// them belongs there too, stamped the way its neighbours are, or a reader
217    /// opening that file finds the lines and not the warning. Not level-filtered
218    /// (CK_LOG does not apply) and not for routine logging, which goes through
219    /// `tracing` so filtering and span context hold.
220    pub fn emit_at(
221        &self,
222        at: SystemTime,
223        level: tracing::Level,
224        logger: &str,
225        message: &str,
226        fields: &[(String, String)],
227    ) {
228        self.inner.emit_at(at, &level, logger, &[], message, fields);
229    }
230}
231
232// The handle of the logger `init` installed, so code that did not install it
233// (a library running inside a module's process) can still reach
234// `Handle::emit_at`. `init` can succeed only once per process, since the
235// tracing global can only be set once, so there is at most one to hold.
236static INSTALLED: OnceLock<Handle> = OnceLock::new();
237
238/// The logger this process installed with [`init`], if it installed one.
239pub fn installed() -> Option<Handle> {
240    INSTALLED.get().cloned()
241}
242
243/// An error that prevents installation of the global logger.
244#[derive(Clone, Debug, Eq, PartialEq)]
245pub enum InitError {
246    /// [`Config::from_env`] found no `SUBC_MODULE_ID`; the process is not a
247    /// daemon-spawned module and must name itself.
248    ModuleIdNotInEnvironment,
249    /// A process-global tracing subscriber was already installed.
250    GlobalSubscriberAlreadySet,
251}
252
253impl fmt::Display for InitError {
254    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
255        match self {
256            Self::ModuleIdNotInEnvironment => formatter.write_str(
257                "SUBC_MODULE_ID is not set; use Config::for_module(<id>) outside supervision",
258            ),
259            Self::GlobalSubscriberAlreadySet => {
260                formatter.write_str("a global tracing subscriber is already installed")
261            }
262        }
263    }
264}
265
266impl std::error::Error for InitError {}
267
268/// Installs the fleet logger as the process-global `tracing` subscriber.
269pub fn init(config: Config) -> Result<Handle, InitError> {
270    let (layer, handle) = build_layer(config, Box::new(io::stderr()))?;
271    let panic_inner = Arc::clone(&handle.inner);
272    let subscriber = Registry::default().with(layer);
273    tracing::subscriber::set_global_default(subscriber)
274        .map_err(|_| InitError::GlobalSubscriberAlreadySet)?;
275    install_panic_hook(panic_inner);
276    let _ = INSTALLED.set(handle.clone());
277    Ok(handle)
278}
279
280/// [`init`] with [`Config::from_env`]: the whole setup for a supervised module.
281#[cfg(feature = "store-paths")]
282pub fn init_from_env() -> Result<Handle, InitError> {
283    init(Config::from_env()?)
284}
285
286/// Creates a span carrying canonical session lineage for nested events. Every
287/// event inside it renders `session=<issuer>:<id>` in the bound bracket.
288pub fn session_span(issuer: &str, id: &str) -> tracing::Span {
289    // An empty half means "no session": the line carries no field rather
290    // than a placeholder. Callers that used to log a synthetic id must pass
291    // nothing here instead; the crate does not know their sentinels.
292    if issuer.is_empty() || id.is_empty() {
293        tracing::Span::none()
294    } else {
295        let session = format!("{issuer}:{id}");
296        tracing::info_span!("cortexkit.session", session = %session)
297    }
298}
299
300/// Parses the fixed columns used by merged and filtered fleet log views.
301pub fn parse_line(line: &str) -> Result<ParsedLine<'_>, ParseError> {
302    format::parse(line)
303}
304
305struct LoggerInner {
306    module_id: String,
307    logs_dir: PathBuf,
308    process_bound: Vec<(String, String)>,
309    destination: Mutex<Option<SegmentDestination>>,
310    stderr: Mutex<Box<dyn Write + Send>>,
311    swallowed_writes: AtomicU64,
312    fallback_active: bool,
313    redactor: Option<Arc<Redactor>>,
314    clock: Arc<dyn Fn() -> SystemTime + Send + Sync>,
315}
316
317impl LoggerInner {
318    fn path_now(&self) -> PathBuf {
319        self.logs_dir
320            .join(segment::segment_name(&self.module_id, (self.clock)()))
321    }
322
323    fn emit(
324        &self,
325        level: &tracing::Level,
326        logger: &str,
327        scoped_bound: &[(String, String)],
328        message: &str,
329        fields: &[(String, String)],
330    ) {
331        self.emit_at((self.clock)(), level, logger, scoped_bound, message, fields);
332    }
333
334    fn emit_at(
335        &self,
336        at: SystemTime,
337        level: &tracing::Level,
338        logger: &str,
339        scoped_bound: &[(String, String)],
340        message: &str,
341        fields: &[(String, String)],
342    ) {
343        // Process-level context first, then scoped: the process-level part is
344        // identical on every line the process writes, so putting it first keeps
345        // the bracket's leading columns stable within a file.
346        let mut bound: Vec<(String, String)> = self.process_bound.clone();
347        for (key, value) in scoped_bound {
348            match bound.iter_mut().find(|(existing, _)| existing == key) {
349                Some(slot) => slot.1 = value.clone(),
350                None => bound.push((key.clone(), value.clone())),
351            }
352        }
353        let raw = format::render_line(at, level, logger, &bound, message, fields);
354        let fleet_redacted = fleet_redact(&raw);
355        let module_redacted = self.redactor.as_ref().map_or_else(
356            || Cow::Borrowed(fleet_redacted.as_ref()),
357            |redactor| redactor(&fleet_redacted),
358        );
359        // Redactors are extensibility points, so the final guard preserves the
360        // one-line, no-ANSI contract even if a module redactor introduces such
361        // bytes.
362        let guarded = format::strip_ansi(&module_redacted)
363            .replace('\r', "\\r")
364            .replace('\n', "\\n");
365        self.write_line(&guarded, at);
366    }
367
368    fn write_line(&self, line: &str, at: SystemTime) {
369        let mut bytes = Vec::with_capacity(line.len() + 1);
370        bytes.extend_from_slice(line.as_bytes());
371        bytes.push(b'\n');
372
373        // The mutex keeps this process's lines whole against its own threads;
374        // O_APPEND keeps them whole against other processes. No queue, no
375        // background flusher: a caller waits only for the write in front of it.
376        let result = {
377            let mut destination = self
378                .destination
379                .lock()
380                .unwrap_or_else(std::sync::PoisonError::into_inner);
381            match destination.as_mut() {
382                None => {
383                    let mut stderr = self
384                        .stderr
385                        .lock()
386                        .unwrap_or_else(std::sync::PoisonError::into_inner);
387                    stderr.write_all(&bytes).map(|()| None)
388                }
389                Some(segment) => segment.write(&bytes, at),
390            }
391        };
392
393        match result {
394            Ok(None) => {}
395            Ok(Some(notice)) => self.report_notice(notice),
396            Err(error) => {
397                self.swallowed_writes.fetch_add(1, Ordering::Relaxed);
398                if !WRITE_FAILURE_REPORTED.swap(true, Ordering::Relaxed) {
399                    self.report(&format!(
400                        "cortexkit-log: log write failed; future failures will be swallowed: {error}\n"
401                    ));
402                }
403            }
404        }
405    }
406
407    // A feature that fires later must say that it fired: without these lines,
408    // "retention never ran" and "ran and found nothing to prune" are the same
409    // observation from outside. Oversize is reported once per process because
410    // a runaway writer must not get a second flood from its own alarm.
411    fn report_notice(&self, notice: SegmentNotice) {
412        match notice {
413            SegmentNotice::Pruned { removed, kept } => self.report(&format!(
414                "cortexkit-log: {}.retention pruned={removed} kept={kept}\n",
415                self.module_id
416            )),
417            SegmentNotice::Oversized { path, bytes } => self.report(&format!(
418                "cortexkit-log: segment oversized, NOT truncated: path={} bytes={bytes}\n",
419                path.display()
420            )),
421        }
422    }
423
424    fn report(&self, report: &str) {
425        let mut stderr = self
426            .stderr
427            .lock()
428            .unwrap_or_else(std::sync::PoisonError::into_inner);
429        let _ = stderr.write_all(report.as_bytes());
430    }
431
432    fn write_panic(&self, information: &panic::PanicHookInfo<'_>) {
433        let at = (self.clock)();
434        let logger = format!("{}.panic", self.module_id);
435        let panic_text = information.to_string();
436        for line in panic_text.lines() {
437            self.emit_at(at, &tracing::Level::ERROR, &logger, &[], line, &[]);
438        }
439        let backtrace = Backtrace::force_capture().to_string();
440        for line in backtrace.lines() {
441            self.emit_at(at, &tracing::Level::ERROR, &logger, &[], line, &[]);
442        }
443    }
444}
445
446struct LogLayer {
447    inner: Arc<LoggerInner>,
448    filter: LevelFilter,
449}
450
451impl<S> Layer<S> for LogLayer
452where
453    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
454{
455    fn enabled(&self, metadata: &Metadata<'_>, _context: Context<'_, S>) -> bool {
456        if metadata.is_span() {
457            return true;
458        }
459        let logger = format::logger_name(&self.inner.module_id, metadata.target());
460        self.filter.enabled(&logger, metadata.level())
461    }
462
463    fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) {
464        let mut visitor = BoundVisitor::default();
465        attributes.record(&mut visitor);
466        if let (false, Some(span)) = (visitor.bound.is_empty(), context.span(id)) {
467            span.extensions_mut().insert(SpanBound(visitor.bound));
468        }
469    }
470
471    fn on_record(&self, id: &Id, values: &Record<'_>, context: Context<'_, S>) {
472        let mut visitor = BoundVisitor::default();
473        values.record(&mut visitor);
474        if let (false, Some(span)) = (visitor.bound.is_empty(), context.span(id)) {
475            let mut extensions = span.extensions_mut();
476            match extensions.get_mut::<SpanBound>() {
477                Some(existing) => existing.0.extend(visitor.bound),
478                None => extensions.insert(SpanBound(visitor.bound)),
479            }
480        }
481    }
482
483    fn on_event(&self, event: &Event<'_>, context: Context<'_, S>) {
484        let mut visitor = EventVisitor::default();
485        event.record(&mut visitor);
486        // Every field on every span enclosing the event is bound context,
487        // root-first so an inner span overrides an outer one with the same key.
488        // This is MDC: the module decides what is in scope by what it puts on
489        // its spans, and the crate renders whatever is there.
490        let mut scoped: Vec<(String, String)> = Vec::new();
491        if let Some(scope) = context.event_scope(event) {
492            for span in scope.from_root() {
493                if let Some(bound) = span.extensions().get::<SpanBound>() {
494                    for (key, value) in &bound.0 {
495                        match scoped.iter_mut().find(|(existing, _)| existing == key) {
496                            Some(slot) => slot.1 = value.clone(),
497                            None => scoped.push((key.clone(), value.clone())),
498                        }
499                    }
500                }
501            }
502        }
503        let logger = format::logger_name(&self.inner.module_id, event.metadata().target());
504        self.inner.emit(
505            event.metadata().level(),
506            &logger,
507            &scoped,
508            visitor.message.as_deref().unwrap_or(""),
509            &visitor.fields,
510        );
511    }
512}
513
514#[derive(Clone)]
515struct SpanBound(Vec<(String, String)>);
516
517#[derive(Default)]
518struct BoundVisitor {
519    bound: Vec<(String, String)>,
520}
521
522impl BoundVisitor {
523    fn record(&mut self, field: &Field, value: String) {
524        // An empty value binds nothing: `session_span("", "")` and a plugin
525        // with no harness must produce a line with no bracket, never `key=`.
526        if !value.is_empty() {
527            self.bound.push((field.name().to_owned(), value));
528        }
529    }
530}
531
532impl Visit for BoundVisitor {
533    fn record_str(&mut self, field: &Field, value: &str) {
534        self.record(field, value.to_owned());
535    }
536
537    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
538        let rendered = format!("{value:?}");
539        let value = rendered
540            .strip_prefix('"')
541            .and_then(|unquoted| unquoted.strip_suffix('"'))
542            .unwrap_or(&rendered);
543        self.record(field, value.to_owned());
544    }
545
546    fn record_i64(&mut self, field: &Field, value: i64) {
547        self.record(field, value.to_string());
548    }
549
550    fn record_u64(&mut self, field: &Field, value: u64) {
551        self.record(field, value.to_string());
552    }
553
554    fn record_bool(&mut self, field: &Field, value: bool) {
555        self.record(field, value.to_string());
556    }
557}
558
559#[derive(Default)]
560struct EventVisitor {
561    message: Option<String>,
562    fields: Vec<(String, String)>,
563}
564
565impl EventVisitor {
566    fn record(&mut self, field: &Field, value: String) {
567        if field.name() == "message" {
568            self.message = Some(value);
569        } else {
570            self.fields.push((field.name().to_owned(), value));
571        }
572    }
573}
574
575impl Visit for EventVisitor {
576    fn record_f64(&mut self, field: &Field, value: f64) {
577        self.record(field, value.to_string());
578    }
579
580    fn record_i64(&mut self, field: &Field, value: i64) {
581        self.record(field, value.to_string());
582    }
583
584    fn record_u64(&mut self, field: &Field, value: u64) {
585        self.record(field, value.to_string());
586    }
587
588    fn record_i128(&mut self, field: &Field, value: i128) {
589        self.record(field, value.to_string());
590    }
591
592    fn record_u128(&mut self, field: &Field, value: u128) {
593        self.record(field, value.to_string());
594    }
595
596    fn record_bool(&mut self, field: &Field, value: bool) {
597        self.record(field, value.to_string());
598    }
599
600    fn record_str(&mut self, field: &Field, value: &str) {
601        self.record(field, value.to_owned());
602    }
603
604    fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
605        self.record(field, value.to_string());
606    }
607
608    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
609        self.record(field, format!("{value:?}"));
610    }
611}
612
613fn build_layer(
614    config: Config,
615    stderr: Box<dyn Write + Send>,
616) -> Result<(LogLayer, Handle), InitError> {
617    let clock = config.clock.unwrap_or_else(|| Arc::new(SystemTime::now));
618    let now = clock();
619    let (destination, open_notice, open_error) = match SegmentDestination::open(
620        &config.logs_dir,
621        &config.module_id,
622        config.retention,
623        now,
624        true,
625    ) {
626        Ok((destination, notice)) => (Some(destination), notice, None),
627        Err(error) => (None, None, Some(error)),
628    };
629    let fallback_active = open_error.is_some();
630    let inner = Arc::new(LoggerInner {
631        module_id: config.module_id,
632        logs_dir: config.logs_dir,
633        process_bound: config.bound,
634        destination: Mutex::new(destination),
635        stderr: Mutex::new(stderr),
636        swallowed_writes: AtomicU64::new(0),
637        fallback_active,
638        redactor: config.redactor,
639        clock,
640    });
641
642    if let Some(notice) = open_notice {
643        inner.report_notice(notice);
644    }
645    if let Some(error) = open_error {
646        let logger = inner.module_id.clone();
647        inner.emit(
648            &tracing::Level::ERROR,
649            &logger,
650            &[],
651            "log directory unavailable; falling back to stderr",
652            &[
653                ("dir".to_owned(), inner.logs_dir.display().to_string()),
654                ("error".to_owned(), error.to_string()),
655            ],
656        );
657    }
658
659    let filter = make_filter(config.spec, &inner);
660    let layer = LogLayer {
661        inner: Arc::clone(&inner),
662        filter,
663    };
664    Ok((layer, Handle { inner }))
665}
666
667fn make_filter(spec_override: Option<String>, inner: &LoggerInner) -> LevelFilter {
668    let spec = spec_override.or_else(|| env::var("CK_LOG").ok());
669    let spec = spec
670        .as_deref()
671        .map(str::trim)
672        .filter(|value| !value.is_empty());
673    match spec {
674        Some(spec) => LevelFilter::parse(spec).unwrap_or_else(|error| {
675            if !FILTER_FAILURE_REPORTED.swap(true, Ordering::Relaxed) {
676                inner.report(&format!(
677                    "cortexkit-log: invalid CK_LOG value {spec:?}; using info: {error}\n"
678                ));
679            }
680            LevelFilter::info()
681        }),
682        None => LevelFilter::info(),
683    }
684}
685
686fn install_panic_hook(inner: Arc<LoggerInner>) {
687    let previous = panic::take_hook();
688    panic::set_hook(Box::new(move |information| {
689        inner.write_panic(information);
690        previous(information);
691    }));
692}
693
694#[cfg(test)]
695mod tests;