1mod 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct Retention {
47 pub max_file_mb: u32,
49 pub keep: u8,
51 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 #[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
88pub type Redactor = dyn for<'line> Fn(&'line str) -> Cow<'line, str> + Send + Sync;
90
91pub struct Config {
93 pub module_id: String,
96 pub logs_dir: PathBuf,
102 pub bound: Vec<(String, String)>,
107 pub spec: Option<String>,
109 pub retention: SegmentRetention,
111 pub redactor: Option<Arc<Redactor>>,
113 pub clock: Option<Arc<dyn Fn() -> SystemTime + Send + Sync>>,
115}
116
117impl Config {
118 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 #[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 #[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 #[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#[derive(Clone)]
185pub struct Handle {
186 inner: Arc<LoggerInner>,
187}
188
189impl Handle {
190 pub fn swallowed_writes(&self) -> u64 {
192 self.inner.swallowed_writes.load(Ordering::Relaxed)
193 }
194
195 pub fn path(&self) -> PathBuf {
197 self.inner.path_now()
198 }
199
200 pub fn logs_dir(&self) -> &Path {
202 &self.inner.logs_dir
203 }
204
205 pub fn fallback_active(&self) -> bool {
207 self.inner.fallback_active
208 }
209
210 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
232static INSTALLED: OnceLock<Handle> = OnceLock::new();
237
238pub fn installed() -> Option<Handle> {
240 INSTALLED.get().cloned()
241}
242
243#[derive(Clone, Debug, Eq, PartialEq)]
245pub enum InitError {
246 ModuleIdNotInEnvironment,
249 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
268pub 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#[cfg(feature = "store-paths")]
282pub fn init_from_env() -> Result<Handle, InitError> {
283 init(Config::from_env()?)
284}
285
286pub fn session_span(issuer: &str, id: &str) -> tracing::Span {
289 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
300pub 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 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 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 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 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 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 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;