Skip to main content

effect_logger/
lib.rs

1//! Injectable [`EffectLogger`] service for the effect system.
2//!
3//! # Service/Tag pattern
4//!
5//! Extract the logger from the environment once with `~EffectLogger`, then call
6//! its methods as regular effectful steps:
7//!
8//! ```ignore
9//! effect!(|_r: &mut R| {
10//!     let logger = ~EffectLogger;
11//!     ~logger.warn("something suspicious");
12//!     ~logger.info("all good");
13//!     result
14//! })
15//! ```
16//!
17//! The environment `R` only needs to satisfy
18//! `R: Get<EffectLogKey, Here, Target = EffectLogger>`.  The caller composes
19//! layers at the top of the program. For a minimal stack that only provides
20//! [`EffectLogger`], build `Context::new(Cons(layer_effect_logger().build().expect(\"…\"), Nil))`
21//! or `Context::new(Cons(Service::<EffectLogKey, _>::new(EffectLogger), Nil))` at the program edge.
22//!
23//! Log methods accept `impl Into<Cow<'static, str>>`: literals stay zero-copy;
24//! runtime text passes as `String` or `format!(...)`.
25
26#![deny(missing_docs)]
27
28use core::convert::Infallible;
29use std::borrow::Cow;
30use std::cell::RefCell;
31use std::future::ready;
32use std::sync::Arc;
33
34use ::id_effect::{BoxFuture, Effect, EffectHashMap, FiberRef, Get, Here, IntoBind, box_future};
35
36mod pipeline;
37
38pub use pipeline::{
39  CompositeLogBackend, JsonLogBackend, LogBackend, LogRecord, Logger, StructuredLogBackend,
40  TracingLogBackend,
41};
42
43use pipeline::TracingLogBackend as TracingSink;
44
45/// Supertrait alias for `Get<EffectLogKey, Here, Target = EffectLogger>`.
46///
47/// Use `R: NeedsEffectLogger` in `where` clauses instead of the full bound:
48///
49/// ```ignore
50/// fn my_fn<R: NeedsEffectLogger + 'static>(...) -> Effect<..., R> { ... }
51/// ```
52pub trait NeedsEffectLogger: Get<EffectLogKey, Here, Target = EffectLogger> {}
53impl<R: Get<EffectLogKey, Here, Target = EffectLogger>> NeedsEffectLogger for R {}
54
55id_effect::service_key!(
56  /// Tag for [`EffectLogger`] in an [`id_effect::Context`] stack.
57  pub struct EffectLogKey
58);
59
60id_effect::service_key!(
61  /// Tag for the fiber-local minimum [`LogLevel`] used by [`EffectLogger::log`].
62  pub struct EffectLogMinLevelKey
63);
64
65thread_local! {
66  static MIN_LOG_LEVEL_FIBER_REF: RefCell<Option<FiberRef<LogLevel>>> = const { RefCell::new(None) };
67}
68
69thread_local! {
70  static COMPOSITE_LOG_BACKEND: RefCell<Option<Arc<CompositeLogBackend>>> = const { RefCell::new(None) };
71}
72
73thread_local! {
74  static LOG_ANNOTATIONS_FIBER_REF: RefCell<Option<FiberRef<EffectHashMap<String, String>>>> =
75    const { RefCell::new(None) };
76}
77
78thread_local! {
79  static LOG_SPAN_STACK_FIBER_REF: RefCell<Option<FiberRef<Vec<String>>>> = const { RefCell::new(None) };
80}
81
82fn install_min_log_level_fiber_ref(fr: FiberRef<LogLevel>) {
83  MIN_LOG_LEVEL_FIBER_REF.with(|c| {
84    *c.borrow_mut() = Some(fr);
85  });
86}
87
88fn install_composite_log_backend(c: Arc<CompositeLogBackend>) {
89  COMPOSITE_LOG_BACKEND.with(|cell| {
90    *cell.borrow_mut() = Some(c);
91  });
92}
93
94fn install_log_annotations_fiber_ref(fr: FiberRef<EffectHashMap<String, String>>) {
95  LOG_ANNOTATIONS_FIBER_REF.with(|c| {
96    *c.borrow_mut() = Some(fr);
97  });
98}
99
100fn install_log_spans_fiber_ref(fr: FiberRef<Vec<String>>) {
101  LOG_SPAN_STACK_FIBER_REF.with(|c| {
102    *c.borrow_mut() = Some(fr);
103  });
104}
105
106#[cfg(test)]
107fn test_clear_min_log_level_fiber_ref() {
108  MIN_LOG_LEVEL_FIBER_REF.with(|c| {
109    *c.borrow_mut() = None;
110  });
111}
112
113#[cfg(test)]
114fn test_clear_composite_log_backend() {
115  COMPOSITE_LOG_BACKEND.with(|c| {
116    *c.borrow_mut() = None;
117  });
118}
119
120#[cfg(test)]
121fn test_clear_log_metadata_fiber_refs() {
122  LOG_ANNOTATIONS_FIBER_REF.with(|c| *c.borrow_mut() = None);
123  LOG_SPAN_STACK_FIBER_REF.with(|c| *c.borrow_mut() = None);
124}
125
126#[cfg(test)]
127fn test_clear_all_logger_tls() {
128  test_clear_min_log_level_fiber_ref();
129  test_clear_composite_log_backend();
130  test_clear_log_metadata_fiber_refs();
131}
132
133/// Log sink for use as [`id_effect::Service<EffectLogKey, Self>`](id_effect::Service); forwards to [`tracing`].
134///
135/// Extracted from the environment with `~EffectLogger` inside [`id_effect::effect!`].
136/// After extraction its methods return `Effect<(), EffectLoggerError, R>` and
137/// are themselves awaited with `~`.
138#[derive(Clone, Copy, Debug, Default)]
139pub struct EffectLogger;
140
141/// Errors that a log sink may produce.
142///
143/// Currently the only backend is [`tracing`], which is infallible, so no
144/// variant is ever constructed at runtime.  The type exists so that callers
145/// can compose it into their `E` bound and gain compile-time proof that the
146/// logger's error channel is handled, without changing the API when a
147/// fallible backend (e.g. a network sink) is added later.
148#[derive(Debug, Clone, ::id_effect::EffectData)]
149pub enum EffectLoggerError {
150  /// The underlying log sink returned an error.
151  Sink(String),
152}
153
154impl std::fmt::Display for EffectLoggerError {
155  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156    match self {
157      EffectLoggerError::Sink(msg) => write!(f, "log sink error: {msg}"),
158    }
159  }
160}
161
162impl std::error::Error for EffectLoggerError {}
163
164impl From<Infallible> for EffectLoggerError {
165  fn from(e: Infallible) -> Self {
166    match e {}
167  }
168}
169
170/// Metadata attached to log lines (wall-clock UTC, etc.).
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct LogContext {
173  /// Wall-clock instant in UTC when the log context was created or captured.
174  pub timestamp: ::id_effect::UtcDateTime,
175}
176
177impl LogContext {
178  /// Build a context with an explicit UTC timestamp.
179  #[inline]
180  pub const fn new(timestamp: ::id_effect::UtcDateTime) -> Self {
181    Self { timestamp }
182  }
183
184  /// Capture the current system time as UTC.
185  #[inline]
186  pub fn with_now_timestamp() -> Self {
187    Self {
188      timestamp: ::id_effect::UtcDateTime::from_std(std::time::SystemTime::now())
189        .expect("system time should be representable as UtcDateTime"),
190    }
191  }
192}
193
194/// Logging level for [`EffectLogger`].
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196#[repr(u8)]
197pub enum LogLevel {
198  /// Most verbose; diagnostic detail.
199  Trace = 0,
200  /// Development diagnostics.
201  Debug = 1,
202  /// Normal operational messages.
203  Info = 2,
204  /// Something unexpected but recoverable.
205  Warn = 3,
206  /// Failure or serious problem.
207  Error = 4,
208  /// Highest severity; mapped to `tracing::error!` with a distinct target in composite pipelines.
209  Fatal = 5,
210  /// Use as a **minimum** level only: no messages pass the filter. Do not emit log lines at this level.
211  None = 255,
212}
213
214impl LogLevel {
215  /// Numeric severity (higher = more severe). Used for minimum-level filtering.
216  #[inline]
217  pub const fn severity(self) -> u8 {
218    match self {
219      LogLevel::None => 255,
220      _ => self as u8,
221    }
222  }
223
224  /// Whether a log at `message_level` should be emitted when `self` is the configured minimum.
225  #[inline]
226  pub const fn allows(self, message_level: LogLevel) -> bool {
227    match (self, message_level) {
228      (LogLevel::None, _) | (_, LogLevel::None) => false,
229      _ => message_level.severity() >= self.severity(),
230    }
231  }
232
233  /// Stable uppercase name for structured / JSON backends.
234  #[inline]
235  pub const fn as_str(self) -> &'static str {
236    match self {
237      LogLevel::Trace => "TRACE",
238      LogLevel::Debug => "DEBUG",
239      LogLevel::Info => "INFO",
240      LogLevel::Warn => "WARN",
241      LogLevel::Error => "ERROR",
242      LogLevel::Fatal => "FATAL",
243      LogLevel::None => "NONE",
244    }
245  }
246}
247
248impl std::str::FromStr for LogLevel {
249  type Err = String;
250
251  fn from_str(s: &str) -> Result<Self, Self::Err> {
252    match s.trim().to_ascii_lowercase().as_str() {
253      "trace" => Ok(LogLevel::Trace),
254      "debug" => Ok(LogLevel::Debug),
255      "info" => Ok(LogLevel::Info),
256      "warn" | "warning" => Ok(LogLevel::Warn),
257      "error" => Ok(LogLevel::Error),
258      "fatal" => Ok(LogLevel::Fatal),
259      "none" => Ok(LogLevel::None),
260      other => Err(format!("unknown log level: {other:?}")),
261    }
262  }
263}
264
265impl EffectLogger {
266  /// Run `inner` with this fiber’s minimum log level overridden to `level` ([`id_effect::FiberRef::locally`]).
267  pub fn with_minimum_log_level<B, E, R>(
268    fiber_ref: FiberRef<LogLevel>,
269    level: LogLevel,
270    inner: Effect<B, E, R>,
271  ) -> Effect<B, E, R>
272  where
273    B: 'static,
274    E: 'static,
275    R: 'static,
276  {
277    fiber_ref.locally(level, inner)
278  }
279
280  /// Emit a log line at `level`.  Returns an effect that, when run, forwards
281  /// to [`tracing`].  The environment `R` is ignored — the logger is
282  /// self-contained after extraction.
283  ///
284  /// When [`layer_minimum_log_level`] has been built on this thread, messages below the current
285  /// fiber’s minimum [`LogLevel`] (from that [`FiberRef`]) are dropped without calling [`tracing`].
286  ///
287  /// `msg` may be a `&'static str`, `String`, or other `Into<Cow<'static, str>>`.
288  pub fn log<R: 'static>(
289    &self,
290    level: LogLevel,
291    msg: impl Into<Cow<'static, str>>,
292  ) -> Effect<(), EffectLoggerError, R> {
293    let msg = msg.into();
294    if level == LogLevel::None {
295      return Effect::new(|_r: &mut R| Ok(()));
296    }
297    Effect::new(move |_r: &mut R| {
298      let emit = MIN_LOG_LEVEL_FIBER_REF.with(|c| match c.borrow().as_ref() {
299        None => true,
300        Some(fr) => ::id_effect::run_blocking(fr.get(), ())
301          .map(|min| min.allows(level))
302          .unwrap_or(true),
303      });
304      if !emit {
305        return Ok(());
306      }
307
308      let annotations = LOG_ANNOTATIONS_FIBER_REF
309        .with(|c| {
310          c.borrow()
311            .as_ref()
312            .and_then(|fr| ::id_effect::run_blocking(fr.get(), ()).ok())
313        })
314        .unwrap_or_default();
315
316      let spans = LOG_SPAN_STACK_FIBER_REF
317        .with(|c| {
318          c.borrow()
319            .as_ref()
320            .and_then(|fr| ::id_effect::run_blocking(fr.get(), ()).ok())
321        })
322        .unwrap_or_default();
323
324      let rec = LogRecord {
325        level,
326        message: msg.clone(),
327        annotations,
328        spans,
329      };
330
331      COMPOSITE_LOG_BACKEND.with(|c| {
332        if let Some(comp) = c.borrow().as_ref() {
333          comp.emit_all(&rec)?;
334        } else {
335          LogBackend::emit(&TracingSink, &rec)?;
336        }
337        Ok::<(), EffectLoggerError>(())
338      })?;
339      Ok(())
340    })
341  }
342
343  /// Same as [`Self::log`]; kept for call sites that already hold a [`String`].
344  #[inline]
345  pub fn log_string<R: 'static>(
346    &self,
347    level: LogLevel,
348    msg: String,
349  ) -> Effect<(), EffectLoggerError, R> {
350    self.log(level, msg)
351  }
352
353  /// Shorthand for [`Self::log`] at [`LogLevel::Trace`].
354  pub fn trace<R: 'static>(
355    &self,
356    msg: impl Into<Cow<'static, str>>,
357  ) -> Effect<(), EffectLoggerError, R> {
358    self.log(LogLevel::Trace, msg)
359  }
360
361  /// Shorthand for [`Self::log`] at [`LogLevel::Debug`].
362  pub fn debug<R: 'static>(
363    &self,
364    msg: impl Into<Cow<'static, str>>,
365  ) -> Effect<(), EffectLoggerError, R> {
366    self.log(LogLevel::Debug, msg)
367  }
368
369  /// Shorthand for [`Self::log`] at [`LogLevel::Info`].
370  pub fn info<R: 'static>(
371    &self,
372    msg: impl Into<Cow<'static, str>>,
373  ) -> Effect<(), EffectLoggerError, R> {
374    self.log(LogLevel::Info, msg)
375  }
376
377  /// Shorthand for [`Self::log`] at [`LogLevel::Warn`].
378  pub fn warn<R: 'static>(
379    &self,
380    msg: impl Into<Cow<'static, str>>,
381  ) -> Effect<(), EffectLoggerError, R> {
382    self.log(LogLevel::Warn, msg)
383  }
384
385  /// Shorthand for [`Self::log`] at [`LogLevel::Error`].
386  pub fn error<R: 'static>(
387    &self,
388    msg: impl Into<Cow<'static, str>>,
389  ) -> Effect<(), EffectLoggerError, R> {
390    self.log(LogLevel::Error, msg)
391  }
392
393  /// Shorthand for [`Self::log`] at [`LogLevel::Fatal`].
394  pub fn fatal<R: 'static>(
395    &self,
396    msg: impl Into<Cow<'static, str>>,
397  ) -> Effect<(), EffectLoggerError, R> {
398    self.log(LogLevel::Fatal, msg)
399  }
400}
401
402// ---------------------------------------------------------------------------
403// Service extraction: `~EffectLogger` inside `effect!`
404// ---------------------------------------------------------------------------
405
406/// Implementing [`IntoBind`] for [`EffectLogger`] makes `~EffectLogger` valid
407/// inside any `effect!` whose environment `R` holds an `EffectLogger` under
408/// [`EffectLogKey`].  The zero-sized struct acts as its own "request token":
409/// passing it to `~` copies the concrete value out of `R` and binds it as a
410/// local variable.
411impl<'a, R> IntoBind<'a, R, EffectLogger, EffectLoggerError> for EffectLogger
412where
413  R: Get<EffectLogKey, Here, Target = EffectLogger> + 'a,
414{
415  fn into_bind(self, r: &'a mut R) -> BoxFuture<'a, Result<EffectLogger, EffectLoggerError>> {
416    Box::pin(ready(Ok(*Get::<EffectLogKey, Here>::get(r))))
417  }
418}
419
420/// [`id_effect::layer_service`] constructor for [`EffectLogger`].
421#[inline]
422pub fn layer_effect_logger() -> id_effect::layer::LayerFn<
423  impl Fn() -> Result<id_effect::Service<EffectLogKey, EffectLogger>, Infallible>,
424> {
425  id_effect::layer_service(EffectLogger)
426}
427
428/// Layer that allocates a [`FiberRef`]`<`[`LogLevel`]`>` (default `initial`) and registers it in a
429/// thread-local slot consulted by [`EffectLogger::log`].
430#[inline]
431pub fn layer_minimum_log_level(
432  initial: LogLevel,
433) -> id_effect::layer::LayerEffect<
434  id_effect::Service<EffectLogMinLevelKey, FiberRef<LogLevel>>,
435  (),
436  (),
437> {
438  id_effect::layer::effect(FiberRef::make(move || initial).flat_map(|fr| {
439    Effect::new(move |_r: &mut ()| {
440      install_min_log_level_fiber_ref(fr.clone());
441      Ok(id_effect::service::<EffectLogMinLevelKey, _>(fr))
442    })
443  }))
444}
445
446/// Layer: install a [`CompositeLogBackend`] on this thread so [`EffectLogger::log`] fans out to all
447/// registered [`LogBackend`]s (see [`Logger::add`], [`Logger::replace`], [`Logger::remove`]).
448#[inline]
449pub fn layer_composite_logger(
450  composite: Arc<CompositeLogBackend>,
451) -> id_effect::layer::LayerEffect<(), (), ()> {
452  let c = composite.clone();
453  id_effect::layer::effect(Effect::new(move |_r: &mut ()| {
454    install_composite_log_backend(c.clone());
455    Ok(())
456  }))
457}
458
459/// Layer: allocate fiber-local annotation and span-stack [`FiberRef`]s used by [`annotate_logs`] and
460/// [`with_log_span`].
461#[inline]
462pub fn layer_log_metadata() -> id_effect::layer::LayerEffect<(), (), ()> {
463  use ::id_effect::collections::hash_map;
464  let eff = FiberRef::make_with(
465    hash_map::empty::<String, String>,
466    |m| m.clone(),
467    |p, _c| p.clone(),
468  )
469  .flat_map(|ann| {
470    FiberRef::make_with(Vec::<String>::new, |v| v.clone(), |p, _c| p.clone()).flat_map(move |sp| {
471      Effect::new(move |_r: &mut ()| {
472        install_log_annotations_fiber_ref(ann.clone());
473        install_log_spans_fiber_ref(sp.clone());
474        Ok(())
475      })
476    })
477  });
478  id_effect::layer::effect(eff)
479}
480
481/// Run `inner` with `key=value` merged into the fiber-local annotation map (restored afterward).
482pub fn annotate_logs<A, E, R>(
483  key: impl Into<String> + Send + 'static,
484  value: impl Into<String> + Send + 'static,
485  inner: Effect<A, E, R>,
486) -> Effect<A, E, R>
487where
488  A: Send + 'static,
489  E: Send + 'static,
490  R: Send + 'static,
491{
492  let key = key.into();
493  let value = value.into();
494  Effect::new_async(move |r| {
495    let tls = LOG_ANNOTATIONS_FIBER_REF.with(|c| c.borrow().clone());
496    let Some(fr) = tls else {
497      return box_future(async move { inner.run(r).await });
498    };
499    let cur = match ::id_effect::run_blocking(fr.get(), ()) {
500      Ok(m) => m,
501      Err(_) => return box_future(async move { inner.run(r).await }),
502    };
503    let next = ::id_effect::collections::hash_map::set(&cur, key, value);
504    let fr = fr.clone();
505    box_future(async move { fr.locally(next, inner).run(r).await })
506  })
507}
508
509/// Run `inner` while a span label is pushed on the fiber-local span stack (restored afterward).
510pub fn with_log_span<A, E, R>(
511  label: impl Into<String> + Send + 'static,
512  inner: Effect<A, E, R>,
513) -> Effect<A, E, R>
514where
515  A: Send + 'static,
516  E: Send + 'static,
517  R: Send + 'static,
518{
519  let label = label.into();
520  Effect::new_async(move |r| {
521    let tls = LOG_SPAN_STACK_FIBER_REF.with(|c| c.borrow().clone());
522    let Some(fr) = tls else {
523      return box_future(async move { inner.run(r).await });
524    };
525    let cur = ::id_effect::run_blocking(fr.get(), ()).unwrap_or_default();
526    let mut next = cur.clone();
527    next.push(label);
528    let fr = fr.clone();
529    box_future(async move { fr.locally(next, inner).run(r).await })
530  })
531}
532
533#[cfg(test)]
534mod tests {
535  use rstest::rstest;
536
537  use super::*;
538  use ::id_effect::{Cons, Context, Layer, Nil, Service, run_blocking};
539
540  // ========== Fixtures ==========
541
542  type LogCtx = Context<Cons<Service<EffectLogKey, EffectLogger>, Nil>>;
543
544  type LogCtxMin = Context<
545    Cons<
546      Service<EffectLogKey, EffectLogger>,
547      Cons<Service<EffectLogMinLevelKey, FiberRef<LogLevel>>, Nil>,
548    >,
549  >;
550
551  fn test_ctx() -> LogCtx {
552    Context::new(Cons(Service::<EffectLogKey, _>::new(EffectLogger), Nil))
553  }
554
555  fn test_ctx_with_min(initial: LogLevel) -> LogCtxMin {
556    test_clear_all_logger_tls();
557    let logger = layer_effect_logger().build().expect("logger layer");
558    let min = layer_minimum_log_level(initial).build().expect("min layer");
559    Context::new(Cons(logger, Cons(min, Nil)))
560  }
561
562  fn init_subscriber() {
563    test_clear_all_logger_tls();
564    let _ = tracing_subscriber::fmt()
565      .with_env_filter(tracing_subscriber::EnvFilter::new("trace"))
566      .with_test_writer()
567      .try_init();
568  }
569
570  // ========== fiber_min_log_level ==========
571
572  mod fiber_min_log_level {
573    use super::*;
574    use std::sync::{Arc, Mutex};
575    use tracing::Subscriber;
576    use tracing_subscriber::Registry;
577    use tracing_subscriber::layer::SubscriberExt;
578    use tracing_subscriber::layer::{Context, Layer};
579
580    struct Capture(Arc<Mutex<Vec<tracing::Level>>>);
581
582    impl<S: Subscriber> Layer<S> for Capture {
583      fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
584        self
585          .0
586          .lock()
587          .expect("capture mutex")
588          .push(*event.metadata().level());
589      }
590    }
591
592    fn subscriber_with_capture(levels: Arc<Mutex<Vec<tracing::Level>>>) -> impl Subscriber {
593      Registry::default().with(Capture(levels))
594    }
595
596    #[test]
597    fn logger_filters_below_minimum_level() {
598      test_clear_all_logger_tls();
599      let levels = Arc::new(Mutex::new(Vec::new()));
600      let _g = tracing::subscriber::set_default(subscriber_with_capture(levels.clone()));
601
602      let ctx = test_ctx_with_min(LogLevel::Trace);
603      let fr = ctx.0.1.0.value.clone();
604      run_blocking(fr.set(LogLevel::Warn), ()).expect("set min");
605      run_blocking(EffectLogger.info::<LogCtxMin>("filtered-info"), ctx).expect("log");
606
607      let got = levels.lock().expect("capture");
608      assert!(
609        !got.contains(&tracing::Level::INFO),
610        "expected INFO suppressed, got {got:?}"
611      );
612    }
613
614    #[test]
615    fn logger_with_minimum_log_level_overrides_globally() {
616      test_clear_all_logger_tls();
617      let levels = Arc::new(Mutex::new(Vec::new()));
618      let _g = tracing::subscriber::set_default(subscriber_with_capture(levels.clone()));
619
620      let ctx = test_ctx_with_min(LogLevel::Trace);
621      let fr = ctx.0.1.0.value.clone();
622      let inner = EffectLogger.info::<LogCtxMin>("inside-scope");
623      run_blocking(
624        EffectLogger::with_minimum_log_level(fr.clone(), LogLevel::Warn, inner),
625        ctx,
626      )
627      .expect("scoped");
628
629      assert!(
630        !levels
631          .lock()
632          .expect("capture")
633          .contains(&tracing::Level::INFO),
634        "expected INFO suppressed inside locally"
635      );
636
637      levels.lock().expect("capture").clear();
638      let ctx = test_ctx_with_min(LogLevel::Trace);
639      run_blocking(EffectLogger.info::<LogCtxMin>("outside-scope"), ctx).expect("outer");
640
641      assert!(
642        levels
643          .lock()
644          .expect("capture")
645          .contains(&tracing::Level::INFO),
646        "expected INFO after new stack without Warn override"
647      );
648    }
649
650    #[test]
651    fn logger_restores_level_after_locally_scope() {
652      test_clear_all_logger_tls();
653      let _g =
654        tracing::subscriber::set_default(subscriber_with_capture(Arc::new(Mutex::new(Vec::new()))));
655
656      let ctx = test_ctx_with_min(LogLevel::Trace);
657      let fr = ctx.0.1.0.value.clone();
658      run_blocking(fr.set(LogLevel::Debug), ()).expect("set");
659      assert_eq!(run_blocking(fr.get::<()>(), ()), Ok(LogLevel::Debug));
660
661      let scoped =
662        EffectLogger::with_minimum_log_level(fr.clone(), LogLevel::Warn, fr.get::<LogCtxMin>());
663      assert_eq!(run_blocking(scoped, ctx), Ok(LogLevel::Warn));
664      assert_eq!(run_blocking(fr.get::<()>(), ()), Ok(LogLevel::Debug));
665    }
666  }
667
668  // ========== log_context ==========
669
670  mod log_context {
671    use super::*;
672    use ::id_effect::UtcDateTime;
673
674    #[test]
675    fn log_context_timestamp_format_iso() {
676      let ctx = LogContext::new(UtcDateTime::unsafe_make(1_700_000_000_000));
677      let s = ctx.timestamp.format_iso();
678      assert!(
679        s.ends_with('Z'),
680        "format_iso should be UTC / RFC 3339 style: {s}"
681      );
682      assert!(s.contains('T'), "expected date-time separator: {s}");
683    }
684
685    #[test]
686    fn log_context_with_now_timestamp_is_valid_iso() {
687      let ctx = LogContext::with_now_timestamp();
688      let s = ctx.timestamp.format_iso();
689      assert!(s.ends_with('Z'), "{s}");
690      assert!(s.contains('T'), "{s}");
691    }
692  }
693
694  // ========== effect_logger_log ==========
695
696  mod effect_logger_log {
697    use super::*;
698
699    mod with_unit_env {
700      use super::*;
701
702      #[rstest]
703      #[case::trace(LogLevel::Trace)]
704      #[case::debug(LogLevel::Debug)]
705      #[case::info(LogLevel::Info)]
706      #[case::warn(LogLevel::Warn)]
707      #[case::error(LogLevel::Error)]
708      #[case::fatal(LogLevel::Fatal)]
709      fn returns_ok_for_every_level(#[case] level: LogLevel) {
710        init_subscriber();
711        let result = run_blocking(EffectLogger.log::<()>(level, "msg"), ());
712        assert_eq!(result, Ok(()));
713      }
714    }
715
716    mod with_context_env {
717      use super::*;
718
719      #[rstest]
720      #[case::trace(LogLevel::Trace)]
721      #[case::debug(LogLevel::Debug)]
722      #[case::info(LogLevel::Info)]
723      #[case::warn(LogLevel::Warn)]
724      #[case::error(LogLevel::Error)]
725      #[case::fatal(LogLevel::Fatal)]
726      fn returns_ok_for_every_level(#[case] level: LogLevel) {
727        init_subscriber();
728        let result = run_blocking(EffectLogger.log::<LogCtx>(level, "msg"), test_ctx());
729        assert_eq!(result, Ok(()));
730      }
731    }
732  }
733
734  // ========== effect_logger_level_methods ==========
735
736  mod no_tls_paths {
737    use super::*;
738    use ::id_effect::run_blocking;
739
740    #[test]
741    fn annotate_logs_without_tls_still_runs_inner() {
742      crate::test_clear_all_logger_tls();
743      let result = run_blocking(
744        annotate_logs("k", "v", id_effect::succeed::<i32, (), ()>(42)),
745        (),
746      );
747      assert_eq!(result, Ok(42));
748    }
749
750    #[test]
751    fn with_log_span_without_tls_still_runs_inner() {
752      crate::test_clear_all_logger_tls();
753      let result = run_blocking(
754        with_log_span("span", id_effect::succeed::<i32, (), ()>(99)),
755        (),
756      );
757      assert_eq!(result, Ok(99));
758    }
759  }
760
761  mod effect_logger_level_methods {
762    use super::*;
763
764    #[test]
765    fn trace_delegates_to_log_at_trace_level() {
766      init_subscriber();
767      assert_eq!(run_blocking(EffectLogger.trace::<()>("t"), ()), Ok(()));
768    }
769
770    #[test]
771    fn debug_delegates_to_log_at_debug_level() {
772      init_subscriber();
773      assert_eq!(run_blocking(EffectLogger.debug::<()>("d"), ()), Ok(()));
774    }
775
776    #[test]
777    fn info_delegates_to_log_at_info_level() {
778      init_subscriber();
779      assert_eq!(run_blocking(EffectLogger.info::<()>("i"), ()), Ok(()));
780    }
781
782    #[test]
783    fn warn_delegates_to_log_at_warn_level() {
784      init_subscriber();
785      assert_eq!(run_blocking(EffectLogger.warn::<()>("w"), ()), Ok(()));
786    }
787
788    #[test]
789    fn error_delegates_to_log_at_error_level() {
790      init_subscriber();
791      assert_eq!(run_blocking(EffectLogger.error::<()>("e"), ()), Ok(()));
792    }
793
794    #[test]
795    fn log_string_delegates_at_info_level() {
796      init_subscriber();
797      assert_eq!(
798        run_blocking(
799          EffectLogger.log_string::<()>(LogLevel::Info, "owned msg".to_string()),
800          (),
801        ),
802        Ok(()),
803      );
804    }
805
806    #[test]
807    fn info_accepts_formatted_string() {
808      init_subscriber();
809      let n = 42u32;
810      assert_eq!(
811        run_blocking(EffectLogger.info::<()>(format!("n={n}")), ()),
812        Ok(()),
813      );
814    }
815
816    #[test]
817    fn fatal_delegates_to_log_at_fatal_level() {
818      init_subscriber();
819      assert_eq!(run_blocking(EffectLogger.fatal::<()>("f"), ()), Ok(()));
820    }
821
822    #[test]
823    fn log_none_level_returns_ok_without_side_effects() {
824      init_subscriber();
825      assert_eq!(
826        run_blocking(EffectLogger.log::<()>(LogLevel::None, "silenced"), ()),
827        Ok(())
828      );
829    }
830  }
831
832  // ========== into_bind_extraction ==========
833
834  mod into_bind_extraction {
835    use super::*;
836
837    #[test]
838    fn extracts_logger_copy_from_context() {
839      let effect: ::id_effect::Effect<EffectLogger, EffectLoggerError, LogCtx> =
840        ::id_effect::Effect::new_async(move |r| {
841          Box::pin(async move { IntoBind::into_bind(EffectLogger, r).await })
842        });
843      let result = run_blocking(effect, test_ctx());
844      assert!(result.is_ok());
845    }
846
847    #[test]
848    fn extracted_logger_can_emit_log_via_run_blocking() {
849      init_subscriber();
850      let effect: ::id_effect::Effect<EffectLogger, EffectLoggerError, LogCtx> =
851        ::id_effect::Effect::new_async(move |r| {
852          Box::pin(async move { IntoBind::into_bind(EffectLogger, r).await })
853        });
854      let logger = run_blocking(effect, test_ctx()).expect("extraction is infallible");
855      assert_eq!(run_blocking(logger.info::<()>("extracted"), ()), Ok(()));
856    }
857  }
858
859  // ========== layer_effect_logger ==========
860
861  mod layer_effect_logger_fn {
862    use super::*;
863
864    #[test]
865    fn builds_without_error() {
866      let result = layer_effect_logger().build();
867      assert!(result.is_ok());
868    }
869
870    #[test]
871    fn produced_service_can_be_placed_in_context() {
872      let cell = layer_effect_logger().build().expect("infallible");
873      let ctx: LogCtx = Context::new(Cons(cell, Nil));
874      let result = run_blocking(EffectLogger.info::<LogCtx>("layer build ok"), ctx);
875      assert_eq!(result, Ok(()));
876    }
877  }
878
879  // ========== effect_logger_error ==========
880
881  mod effect_logger_error {
882    use super::*;
883
884    #[test]
885    fn sink_variant_display_contains_message() {
886      let err = EffectLoggerError::Sink("oops".to_owned());
887      assert!(err.to_string().contains("oops"));
888    }
889
890    #[test]
891    fn sink_variant_display_has_prefix() {
892      let err = EffectLoggerError::Sink("x".to_owned());
893      assert!(err.to_string().starts_with("log sink error:"));
894    }
895
896    #[test]
897    fn sink_variant_implements_error_trait() {
898      let err: Box<dyn std::error::Error> = Box::new(EffectLoggerError::Sink("e".to_owned()));
899      assert!(err.to_string().contains("e"));
900    }
901
902    #[test]
903    fn two_equal_sink_errors_are_eq() {
904      assert_eq!(
905        EffectLoggerError::Sink("a".to_owned()),
906        EffectLoggerError::Sink("a".to_owned())
907      );
908    }
909
910    #[test]
911    fn two_different_sink_errors_are_ne() {
912      assert_ne!(
913        EffectLoggerError::Sink("a".to_owned()),
914        EffectLoggerError::Sink("b".to_owned())
915      );
916    }
917  }
918
919  // ========== beads 263w: Logger pipeline + backends ==========
920
921  mod wave5_full_logger {
922    use std::sync::{Arc, Mutex};
923
924    use ::id_effect::{Layer, run_blocking};
925
926    use crate::{
927      CompositeLogBackend, EffectLogger, EffectLoggerError, JsonLogBackend, LogBackend, LogLevel,
928      LogRecord, Logger, StructuredLogBackend, annotate_logs, with_log_span,
929    };
930
931    struct MsgCap(Arc<Mutex<Vec<String>>>);
932
933    impl LogBackend for MsgCap {
934      fn emit(&self, rec: &LogRecord<'_>) -> Result<(), EffectLoggerError> {
935        self.0.lock().expect("cap").push(rec.message.to_string());
936        Ok(())
937      }
938    }
939
940    fn setup_json() -> Arc<Mutex<Vec<u8>>> {
941      crate::test_clear_all_logger_tls();
942      let jb = JsonLogBackend::new(Vec::<u8>::new());
943      let buf = jb.writer_arc();
944      let comp = Arc::new(CompositeLogBackend::new());
945      comp.add(Arc::new(jb)).expect("add json");
946      crate::layer_log_metadata().build().expect("metadata layer");
947      crate::layer_composite_logger(comp)
948        .build()
949        .expect("composite layer");
950      buf
951    }
952
953    #[test]
954    fn logger_json_backend_produces_valid_json() {
955      let buf = setup_json();
956      run_blocking(
957        annotate_logs("k", "v", EffectLogger.info::<()>("hello")),
958        (),
959      )
960      .expect("log");
961      let bytes = buf.lock().expect("buf");
962      let line = std::str::from_utf8(bytes.as_slice()).expect("utf8");
963      let line = line.trim();
964      let v: serde_json::Value = serde_json::from_str(line).expect("valid json");
965      assert_eq!(v["level"], "INFO");
966      assert_eq!(v["message"], "hello");
967      assert_eq!(v["fields"]["k"], "v");
968    }
969
970    #[test]
971    fn logger_add_replaces_layer() {
972      crate::test_clear_all_logger_tls();
973      let a = Arc::new(Mutex::new(Vec::new()));
974      let b = Arc::new(Mutex::new(Vec::new()));
975      let comp = Arc::new(CompositeLogBackend::new());
976      comp.add(Arc::new(MsgCap(a.clone()))).unwrap();
977      comp.add(Arc::new(MsgCap(b.clone()))).unwrap();
978      crate::layer_composite_logger(comp.clone()).build().unwrap();
979      run_blocking(EffectLogger.info::<()>("m1"), ()).unwrap();
980      assert_eq!(*a.lock().unwrap(), vec!["m1".to_string()]);
981      assert_eq!(*b.lock().unwrap(), vec!["m1".to_string()]);
982
983      let c = Arc::new(Mutex::new(Vec::new()));
984      comp.replace(0, Arc::new(MsgCap(c.clone()))).unwrap();
985      a.lock().unwrap().clear();
986      b.lock().unwrap().clear();
987      run_blocking(EffectLogger.info::<()>("m2"), ()).unwrap();
988      assert!(a.lock().unwrap().is_empty());
989      assert_eq!(*b.lock().unwrap(), vec!["m2".to_string()]);
990      assert_eq!(*c.lock().unwrap(), vec!["m2".to_string()]);
991    }
992
993    #[test]
994    fn logger_fatal_is_highest_level() {
995      assert!(LogLevel::Fatal.severity() > LogLevel::Error.severity());
996      assert!(LogLevel::Trace.allows(LogLevel::Fatal));
997      assert!(!LogLevel::Fatal.allows(LogLevel::Error));
998      assert!(LogLevel::Fatal.allows(LogLevel::Fatal));
999      assert!(!LogLevel::None.allows(LogLevel::Info));
1000    }
1001
1002    #[test]
1003    fn annotate_logs_visible_in_structured_output() {
1004      crate::test_clear_all_logger_tls();
1005      let structured = StructuredLogBackend::new(Vec::<u8>::new());
1006      let buf = structured.writer_arc();
1007      let comp = Arc::new(CompositeLogBackend::new());
1008      comp.add(Arc::new(structured)).unwrap();
1009      crate::layer_log_metadata().build().unwrap();
1010      crate::layer_composite_logger(comp).build().unwrap();
1011      run_blocking(
1012        annotate_logs("trace_id", "abc", EffectLogger.info::<()>("done")),
1013        (),
1014      )
1015      .unwrap();
1016      let s = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1017      assert!(
1018        s.contains("trace_id") && s.contains("abc"),
1019        "expected annotation in output: {s:?}"
1020      );
1021    }
1022
1023    #[test]
1024    fn with_log_span_visible_in_json() {
1025      let buf = setup_json();
1026      run_blocking(
1027        with_log_span("outer", EffectLogger.warn::<()>("inside")),
1028        (),
1029      )
1030      .unwrap();
1031      let bytes = buf.lock().unwrap().clone();
1032      let line = std::str::from_utf8(&bytes).unwrap().trim();
1033      let v: serde_json::Value = serde_json::from_str(line).unwrap();
1034      assert_eq!(v["spans"], serde_json::json!(["outer"]));
1035    }
1036  }
1037}