Skip to main content

dialcache/
observe.rs

1//! Backend-neutral diagnostics: metric events and structured log events.
2
3use std::fmt;
4use std::sync::Arc;
5
6use crate::error::BoxError;
7pub use crate::remote::MissReason;
8
9macro_rules! str_enum {
10    ($(#[$meta:meta])* $name:ident { $($(#[$vmeta:meta])* $variant:ident => $text:literal),+ $(,)? }) => {
11        $(#[$meta])*
12        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13        pub enum $name { $($(#[$vmeta])* $variant),+ }
14        impl $name {
15            /// The `snake_case` label value shared with the TypeScript and Go
16            /// ports and published by the bundled metric exporters.
17            pub fn as_str(self) -> &'static str {
18                match self { $($name::$variant => $text),+ }
19            }
20        }
21        impl fmt::Display for $name {
22            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) }
23        }
24    };
25}
26
27str_enum! {
28    /// The cache layer a diagnostic refers to.
29    Layer {
30        /// No layer was reached: caching was disabled for the scope, key
31        /// construction failed, or the policy could not be resolved.
32        Noop => "noop",
33        /// The outermost enabled scope's request memo.
34        RequestLocal => "request_local",
35        /// The process-local store.
36        Local => "local",
37        /// The remote adapter on the caller-serving path.
38        Remote => "remote",
39        /// The remote adapter inside detached shadow work: its reads, fills,
40        /// serialization, compression and payload sizes.
41        RemoteShadow => "remote_shadow",
42    }
43}
44
45str_enum! {
46    /// Where an in-flight execution was shared.
47    CoalescingScope {
48        /// The flight was registered on the outermost enabled scope of the
49        /// same request.
50        RequestLocal => "request_local",
51        /// The flight was registered on the cache instance; separate
52        /// instances in one process do not share flights.
53        Process => "process",
54    }
55}
56
57str_enum! {
58    /// Why a cache layer was skipped.
59    DisabledReason {
60        /// Caching is not enabled for the call's scope; the source runs directly.
61        Context => "context",
62        /// The layer has no effective TTL after the runtime overlay, which
63        /// includes the default of an omitted policy.
64        PolicyDisabled => "policy_disabled",
65        /// The merged TTL leaf is not a whole number of seconds within 365
66        /// days; only this layer is skipped, with a `config_resolution` error.
67        InvalidTtl => "invalid_ttl",
68        /// The merged ramp leaf is not a finite number in `[0, 100]`; only
69        /// this layer is skipped, with a `config_resolution` error.
70        InvalidRamp => "invalid_ramp",
71        /// The layer is configured but this key's stable cohort sample falls
72        /// outside its ramp.
73        RampedDown => "ramped_down",
74        /// The whole invocation's policy could not be resolved (`noop`
75        /// layer), or the process-local read failed (`local` layer).
76        ConfigError => "config_error",
77    }
78}
79
80str_enum! {
81    /// Stable failure sites.
82    ErrorKind {
83        /// The key selector failed or panicked, or the identity could not
84        /// form a key.
85        KeyConstruction => "key_construction",
86        /// The policy provider or resolution failed for the invocation, a
87        /// layer leaf was invalid, or a recovery or shadow option was rejected.
88        ConfigResolution => "config_resolution",
89        /// A process-local read failed, or a remote read failed for a reason
90        /// other than its deadline.
91        CacheRead => "cache_read",
92        /// A remote read did not answer within its read budget.
93        CacheReadTimeout => "cache_read_timeout",
94        /// A process-local or remote write failed, or the write stamp left
95        /// the safe-integer domain.
96        CacheWrite => "cache_write",
97        /// A tracked write asked for retention above the one-hour physical
98        /// cap and was clamped: a configuration signal, not a failure.
99        TrackedTtlClamped => "tracked_ttl_clamped",
100        /// The codec failed or panicked while decoding a stored payload.
101        SerializationLoad => "serialization_load",
102        /// The codec failed or panicked while encoding a value for the remote layer.
103        SerializationDump => "serialization_dump",
104        /// zstd compression failed while preparing a remote write.
105        Compression => "compression",
106        /// An explicit invalidation failed, including one made without a
107        /// remote adapter.
108        Invalidation => "invalidation",
109        /// The source failed, panicked or exceeded its deadline; reported
110        /// with `in_fallback` set.
111        Fallback => "fallback",
112    }
113}
114
115str_enum! {
116    /// Terminal outcomes of sampled shadow validation.
117    ShadowOutcome {
118        /// The cached and source values compared equal.
119        Match => "match",
120        /// They differed, and a confirmation read found the stored payload
121        /// unchanged.
122        Mismatch => "mismatch",
123        /// They differed, but the stored payload changed or disappeared
124        /// before confirmation.
125        Superseded => "superseded",
126        /// A clean shadow miss was populated.
127        Filled => "filled",
128        /// A tracked fill was withheld because its stamp did not exceed the
129        /// watermark observed by the initial read.
130        FillFenced => "fill_fenced",
131        /// Preparing (encoding, compression) or writing a clean-miss fill failed.
132        FillError => "fill_error",
133        /// The initial detached remote read failed, including by deadline.
134        RedisError => "redis_error",
135        /// The source-of-truth read failed.
136        SourceError => "source_error",
137        /// The retained payload could not be decoded for comparison.
138        DeserializationError => "deserialization_error",
139        /// The comparator returned an error or panicked.
140        ComparisonError => "comparison_error",
141        /// The confirmation remote read failed.
142        ConfirmationError => "confirmation_error",
143        /// The shadow deadline expired before a verdict.
144        Timeout => "timeout",
145        /// Per-key deduplication or the instance's in-flight cap rejected
146        /// the job before it started.
147        Dropped => "dropped",
148    }
149}
150
151str_enum! {
152    /// Outcomes of one confirmed-mismatch warning attempt. When more than one
153    /// failure occurs, `hook_error` ranks above `render_error`, which ranks
154    /// above `logger_error`.
155    ShadowMismatchLoggingOutcome {
156        /// Every requested field was built and the logger returned.
157        Logged => "logged",
158        /// The operation's `mismatch_diff` hook or the built-in walk panicked
159        /// or failed; `mismatch_paths`, `mismatch_path_count` and `diff_json`
160        /// are unavailable.
161        HookError => "hook_error",
162        /// A compared value had no JSON rendering, the operation has no
163        /// renderer for a requested field, or the bounded CPU executor
164        /// rejected the warning's work; the fields it feeds are unavailable.
165        RenderError => "render_error",
166        /// The logger panicked while receiving the warning.
167        LoggerError => "logger_error",
168    }
169}
170
171str_enum! {
172    /// Terminal outcomes of stale-on-error recovery.
173    RecoveryOutcome {
174        /// A retained stale frame passed the return-time age check and
175        /// supplied the result.
176        Served => "served",
177        /// No retained frame remained eligible: none was kept, or its age
178        /// reached the ceiling.
179        Miss => "miss",
180        /// Decoding the retained frame failed.
181        DeserializationError => "deserialization_error",
182    }
183}
184
185str_enum! {
186    /// Compression outcomes for writes and reads.
187    CompressionOutcome {
188        /// Write: the payload met the threshold and the marked zstd frame was
189        /// smaller than the escaped raw form.
190        Compressed => "compressed",
191        /// Write: the payload was below the threshold and was stored raw.
192        BelowThreshold => "below_threshold",
193        /// Write: compression ran but did not shrink the payload; stored raw.
194        NotSmaller => "not_smaller",
195        /// Write: the payload exceeds the decompression cap and was stored raw
196        /// so a read could still accept it; a capacity signal.
197        WriteOverLimit => "write_over_limit",
198        /// Read: a marked zstd frame decoded successfully.
199        Decompressed => "decompressed",
200        /// Read: zstd rejected a marked payload as malformed or truncated; the
201        /// stored bytes were handed through untouched.
202        FallbackRaw => "fallback_raw",
203        /// Read: decompressing would exceed the cap; the stored bytes were
204        /// handed through untouched. An integrity signal.
205        ReadOverLimit => "read_over_limit",
206    }
207}
208
209str_enum! {
210    /// Which codec direction a serialization timing measures.
211    SerializationOperation {
212        /// Encoding a value for the remote layer.
213        Dump => "dump",
214        /// Decoding a stored payload.
215        Load => "load",
216    }
217}
218
219str_enum! {
220    /// Which envelope direction a compression timing measures.
221    CompressionOperation {
222        /// zstd compression during a write.
223        Compress => "compress",
224        /// zstd decompression during a read.
225        Decompress => "decompress",
226    }
227}
228
229/// Labels shared by layer-scoped events. Logical keys are never labels.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct Labels {
232    /// The instance namespace (default `urn`); unrelated to any exporter's
233    /// metric-name namespace.
234    pub namespace: Arc<str>,
235    /// The operation name; `watermark` on invalidation error events.
236    pub use_case: Arc<str>,
237    /// The entity type of the identity.
238    pub key_type: Arc<str>,
239    /// The layer the event refers to, or [`Layer::Noop`] when none was reached.
240    pub layer: Layer,
241}
242
243/// Labels of outcome-scoped events (shadow validation and recovery).
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct OutcomeLabels {
246    /// The instance namespace (default `urn`); unrelated to any exporter's
247    /// metric-name namespace.
248    pub namespace: Arc<str>,
249    /// The operation name.
250    pub use_case: Arc<str>,
251    /// The entity type of the identity.
252    pub key_type: Arc<str>,
253}
254
255/// One public diagnostic. Counters carry no value; timers report seconds;
256/// sizes report bytes.
257#[derive(Debug, Clone, PartialEq)]
258pub enum Event {
259    /// One lookup of an enabled cache layer, hit or miss. Emitted before the
260    /// result for the remote layer and after it for the request-local and
261    /// process-local layers; a layer whose read failed emits `Error` and
262    /// `Disabled` instead.
263    Request {
264        /// The layer that was looked up.
265        labels: Labels,
266    },
267    /// A layer lookup found no servable value.
268    Miss {
269        /// The layer that missed.
270        labels: Labels,
271        /// The bounded cause; request-local and process-local misses are
272        /// always [`MissReason::ValueAbsent`].
273        reason: MissReason,
274    },
275    /// A layer, or the whole call (`noop`), was skipped.
276    Disabled {
277        /// The skipped layer, or [`Layer::Noop`] when no layer was reached.
278        labels: Labels,
279        /// Why it was skipped.
280        reason: DisabledReason,
281    },
282    /// A bounded failure site fired. Cache plumbing failures fail open; only
283    /// source failures reach the caller.
284    Error {
285        /// The layer whose work failed.
286        labels: Labels,
287        /// The failure site.
288        error: ErrorKind,
289        /// `true` when the source (fallback) failed or timed out rather than
290        /// cache plumbing.
291        in_fallback: bool,
292    },
293    /// One explicit invalidation attempt, emitted before the remote call and
294    /// even when no remote adapter is configured.
295    Invalidation {
296        /// The namespace whose entity watermark is advanced.
297        namespace: Arc<str>,
298        /// The entity type being invalidated.
299        key_type: Arc<str>,
300        /// Always [`Layer::Remote`]: watermarks live only in the remote layer.
301        layer: Layer,
302    },
303    /// A caller joined an execution already in flight for the same logical
304    /// key instead of starting its own.
305    Coalesced {
306        /// The identity of the joined execution.
307        labels: OutcomeLabels,
308        /// Whether the flight was registered on the request scope or the instance.
309        scope: CoalescingScope,
310    },
311    /// One terminal outcome of an admitted or explicitly dropped shadow job.
312    ShadowValidation {
313        /// The identity the job validated.
314        labels: OutcomeLabels,
315        /// The terminal verdict.
316        outcome: ShadowOutcome,
317    },
318    /// Age in seconds of the validated value at a match or confirmed mismatch, clamped at zero.
319    ShadowValueAge {
320        /// The identity the job validated.
321        labels: OutcomeLabels,
322        /// [`ShadowOutcome::Match`] or [`ShadowOutcome::Mismatch`]; no other
323        /// outcome carries an age.
324        outcome: ShadowOutcome,
325        /// Observing wall clock minus the frame's `created_at_ms`, never negative.
326        seconds: f64,
327    },
328    /// One confirmed-mismatch warning attempt, emitted after the warning and
329    /// only while mismatch logging is on.
330    ShadowMismatchLogging {
331        /// The identity the job validated.
332        labels: OutcomeLabels,
333        /// Whether the warning was built and delivered, or why not.
334        outcome: ShadowMismatchLoggingOutcome,
335    },
336    /// Positive offset in seconds of a frame dated after the observing clock.
337    FutureTimestampOffset {
338        /// The layer whose read decoded the frame.
339        labels: Labels,
340        /// How far the frame's `created_at_ms` lies ahead of the observing wall clock.
341        seconds: f64,
342    },
343    /// One classifier-authorized stale-on-error recovery check after a
344    /// source failure.
345    StaleRecovery {
346        /// The identity being recovered.
347        labels: OutcomeLabels,
348        /// Whether a retained frame was served.
349        outcome: RecoveryOutcome,
350    },
351    /// Age of the retained frame at the moment recovery served it; emitted
352    /// only alongside [`RecoveryOutcome::Served`].
353    StaleRecoveryValueAge {
354        /// The identity being recovered.
355        labels: OutcomeLabels,
356        /// Always [`RecoveryOutcome::Served`].
357        outcome: RecoveryOutcome,
358        /// Observing wall clock minus the frame's `created_at_ms`, clamped at zero.
359        seconds: f64,
360    },
361    /// One envelope decision: on every write while compression is enabled,
362    /// and on reads whose payload carried a zstd marker.
363    Compression {
364        /// The layer performing the write or read.
365        labels: Labels,
366        /// Write and read outcomes are disjoint sets; see [`CompressionOutcome`].
367        outcome: CompressionOutcome,
368    },
369    /// Lookup latency of one layer, hit or miss; a remote read's value
370    /// includes any wait up to its deadline.
371    Get {
372        /// The layer looked up.
373        labels: Labels,
374        /// Elapsed time of the lookup.
375        seconds: f64,
376    },
377    /// Time until the source settled or its deadline elapsed, once per
378    /// source invocation that served the caller (shadow re-validation runs
379    /// the source without it).
380    Fallback {
381        /// [`Layer::RequestLocal`] when the request memo was consulted,
382        /// [`Layer::Remote`] when the remote layer was consulted, otherwise
383        /// [`Layer::Local`] (even with the local layer disabled), and
384        /// [`Layer::Noop`] only when the call bypassed caching.
385        labels: Labels,
386        /// Elapsed time from invoking the source until it settled or its
387        /// deadline fired.
388        seconds: f64,
389    },
390    /// Codec latency of one encode or decode, emitted whether or not the
391    /// codec succeeded.
392    Serialization {
393        /// The layer the payload was written to or read from.
394        labels: Labels,
395        /// Encode (`dump`) or decode (`load`).
396        operation: SerializationOperation,
397        /// Elapsed time inside the codec.
398        seconds: f64,
399    },
400    /// Serializer output size of one remote write, before compression and escaping.
401    Size {
402        /// The layer being written.
403        labels: Labels,
404        /// Length of the codec output.
405        bytes: u64,
406    },
407    /// Prepared payload size of one remote write, after compression and
408    /// escaping, before dispatch.
409    StoredSize {
410        /// The layer being written.
411        labels: Labels,
412        /// Length of the payload handed to the adapter, excluding the
413        /// ten-byte frame header.
414        bytes: u64,
415    },
416    /// Size ratio of one write that chose the compressed form.
417    CompressionRatio {
418        /// The layer being written.
419        labels: Labels,
420        /// Marked compressed bytes divided by serializer output bytes.
421        ratio: f64,
422    },
423    /// zstd latency: on writes when compression ran (compressed or not
424    /// smaller), on reads when a marked frame was decoded or rejected.
425    CompressionDuration {
426        /// The layer performing the write or read.
427        labels: Labels,
428        /// Compress (write) or decompress (read).
429        operation: CompressionOperation,
430        /// Elapsed time of the envelope step.
431        seconds: f64,
432    },
433}
434
435/// Receives every public diagnostic. Failures (returned errors or panics)
436/// never change a cache, source or maintenance result.
437pub trait Observer: Send + Sync + 'static {
438    /// Receive one event. Called inline on the cache's own path under panic
439    /// isolation, so it should return quickly and never block.
440    fn observe(&self, event: &Event);
441
442    /// Whether this observer consumes shadow validation outcomes.
443    ///
444    /// Shadow validation is diagnostic work that exists only to be observed,
445    /// so the cache admits a shadow job only when the observer opts in by
446    /// returning `true`. The bundled metric exporters opt in.
447    fn observes_shadow_outcomes(&self) -> bool {
448        false
449    }
450}
451
452/// Bounded details of one confirmed shadow mismatch warning.
453///
454/// A field group is `None` when its leaf is off. Inside a requested group a
455/// `None` field is unavailable: [`logging_outcome`](Self::logging_outcome)
456/// names the failure, or, without one, the value was absent.
457#[derive(Debug, Clone, PartialEq)]
458pub struct ShadowMismatchDetails {
459    /// The instance namespace of the mismatching identity.
460    pub namespace: Arc<str>,
461    /// The operation name of the mismatching identity.
462    pub use_case: Arc<str>,
463    /// The entity type of the mismatching identity.
464    pub key_type: Arc<str>,
465    /// Age of the cached value at the verdict, as [`Event::ShadowValueAge`]
466    /// records it.
467    pub cached_value_age_seconds: f64,
468    /// The logical key, bounded to 2 KiB of UTF-8.
469    pub cache_key: String,
470    /// The difference fields; `None` when `diff` is off or construction failed.
471    pub diff: Option<ShadowMismatchDiffDetails>,
472    /// The JSON of both values; `None` when `value` is off or construction failed.
473    pub values: Option<ShadowMismatchValueDetails>,
474    /// Why requested fields are unavailable; `None` when every one was built.
475    /// Only [`ShadowMismatchLoggingOutcome::HookError`] and
476    /// [`ShadowMismatchLoggingOutcome::RenderError`] appear here.
477    pub logging_outcome: Option<ShadowMismatchLoggingOutcome>,
478}
479
480/// The difference fields of a confirmed-mismatch warning, present when
481/// `mismatch_logging.diff` is above [`MismatchDiff::Off`](crate::policy::MismatchDiff::Off).
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub struct ShadowMismatchDiffDetails {
484    /// The effective level: [`Paths`](crate::policy::MismatchDiff::Paths) or
485    /// [`Values`](crate::policy::MismatchDiff::Values).
486    pub level: crate::policy::MismatchDiff,
487    /// RFC 6901 JSON Pointers of the differing fields in walk order, at most
488    /// 64, each bounded to 512 bytes; `None` when the hook or walk failed.
489    pub mismatch_paths: Option<Vec<String>>,
490    /// The number of differences before the cut to 64; `None` with `mismatch_paths`.
491    pub mismatch_path_count: Option<u64>,
492    /// At level `Values`, the entries as a JSON array of `{path, cached,
493    /// source}` objects, each side bounded to 1 KiB and the whole to 8 KiB;
494    /// `None` at level `Paths` and when unavailable.
495    pub diff_json: Option<String>,
496}
497
498/// The whole-value fields of a confirmed-mismatch warning, present when
499/// `mismatch_logging.value` is on.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct ShadowMismatchValueDetails {
502    /// JSON of the decoded cached value, bounded to 8 KiB; `None` when unavailable.
503    pub cached_value_json: Option<String>,
504    /// JSON of the source value, bounded to 8 KiB; `None` when unavailable.
505    pub source_value_json: Option<String>,
506}
507
508/// Severity of a [`LogEvent`].
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
510pub enum LogLevel {
511    /// Verbose diagnostics; no [`LogEvent`] maps here today.
512    Debug,
513    /// A diagnostic on a path where the cache itself added no failure: a
514    /// layer failed open and the call went on to another layer or the
515    /// source, or an explicit invalidation or a recovery decode failed and
516    /// the operation reports that failure.
517    Warn,
518    /// A whole path is broken: key construction or the process-local store.
519    Error,
520}
521
522/// Structured log events. All are fail-open diagnostics except the
523/// invalidation failure, which is also returned to the caller.
524#[derive(Debug)]
525pub enum LogEvent {
526    /// The key selector failed or panicked, or the identity could not form a
527    /// key; the source ran uncached. [`LogLevel::Error`].
528    KeyConstructionFailed(BoxError),
529    /// The policy provider failed or panicked, or the merged policy was
530    /// malformed; the source ran uncached.
531    PolicyResolutionFailed(BoxError),
532    /// The [`LocalStore`](crate::LocalStore) read failed or panicked; the
533    /// local layer was skipped for the call. [`LogLevel::Error`].
534    LocalReadFailed(BoxError),
535    /// The [`LocalStore`](crate::LocalStore) write failed or panicked; the
536    /// value was still returned.
537    LocalWriteFailed(BoxError),
538    /// The caller-serving remote read failed or exceeded its read budget.
539    /// Detached shadow reads do not log.
540    RemoteReadFailed(BoxError),
541    /// Preparing or dispatching a caller-serving remote write failed:
542    /// encoding, compression, the write stamp or the adapter.
543    RemoteWriteFailed(BoxError),
544    /// The stale-recovery classifier panicked; recovery was denied.
545    RecoveryPredicateFailed(BoxError),
546    /// Decoding the retained stale frame failed during recovery; the source
547    /// error was returned. Default formatting omits value-bearing error text;
548    /// custom loggers can inspect the original error here.
549    RecoveryDecodeFailed(BoxError),
550    /// A shadow job's clean-miss fill failed while preparing or writing the payload.
551    ShadowFillFailed(BoxError),
552    /// A confirmed shadow mismatch, logged only when the resolved policy's
553    /// mismatch-logging `value` is on or its `diff` is above `off`.
554    ShadowMismatch(ShadowMismatchDetails),
555    /// An explicit invalidation failed; the same error is returned to the caller.
556    InvalidationFailed(BoxError),
557}
558
559impl LogEvent {
560    /// [`LogLevel::Error`] for key construction and process-local read
561    /// failures, [`LogLevel::Warn`] for everything else.
562    pub fn level(&self) -> LogLevel {
563        match self {
564            LogEvent::KeyConstructionFailed(_) | LogEvent::LocalReadFailed(_) => LogLevel::Error,
565            _ => LogLevel::Warn,
566        }
567    }
568}
569
570impl fmt::Display for LogEvent {
571    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572        match self {
573            LogEvent::KeyConstructionFailed(e) => {
574                write!(f, "Could not construct DialCache key: {e}")
575            }
576            LogEvent::PolicyResolutionFailed(e) => {
577                write!(f, "Could not resolve DialCache key config: {e}")
578            }
579            LogEvent::LocalReadFailed(e) => write!(f, "Error getting value from local cache: {e}"),
580            LogEvent::LocalWriteFailed(e) => write!(f, "Error putting value in local cache: {e}"),
581            LogEvent::RemoteReadFailed(e) => write!(f, "Error getting value from Redis cache: {e}"),
582            LogEvent::RemoteWriteFailed(e) => write!(f, "Error putting value in Redis cache: {e}"),
583            LogEvent::RecoveryPredicateFailed(e) => {
584                write!(
585                    f,
586                    "DialCache stale recovery predicate failed; recovery was denied: {e}"
587                )
588            }
589            LogEvent::RecoveryDecodeFailed(e) => {
590                write!(
591                    f,
592                    "Error using retained Redis value during stale recovery: "
593                )?;
594                if let Some(json) = e.downcast_ref::<serde_json::Error>() {
595                    write!(
596                        f,
597                        "JSON {:?} at line {} column {}",
598                        json.classify(),
599                        json.line(),
600                        json.column()
601                    )
602                } else {
603                    write!(f, "decoding failed")
604                }
605            }
606            LogEvent::ShadowFillFailed(e) => {
607                write!(f, "Error populating Redis from DialCache shadow work: {e}")
608            }
609            LogEvent::ShadowMismatch(d) => {
610                write!(f, "DialCache shadow validation mismatch: namespace={} useCase={} keyType={} cachedValueAgeSeconds={} cacheKey={}",
611                    d.namespace, d.use_case, d.key_type, d.cached_value_age_seconds, d.cache_key)?;
612                // Unavailable fields are left out; loggingOutcome says why.
613                if let Some(diff) = &d.diff {
614                    if let (Some(paths), Some(count)) =
615                        (&diff.mismatch_paths, diff.mismatch_path_count)
616                    {
617                        let paths = serde_json::to_string(paths).map_err(|_| fmt::Error)?;
618                        write!(f, " mismatchPaths={paths} mismatchPathCount={count}")?;
619                    }
620                    if let Some(json) = &diff.diff_json {
621                        write!(f, " diffJson={json}")?;
622                    }
623                }
624                if let Some(values) = &d.values {
625                    if let Some(value) = &values.cached_value_json {
626                        write!(f, " cachedValue={value}")?;
627                    }
628                    if let Some(value) = &values.source_value_json {
629                        write!(f, " sourceValue={value}")?;
630                    }
631                }
632                if let Some(outcome) = d.logging_outcome {
633                    write!(f, " loggingOutcome={outcome}")?;
634                }
635                Ok(())
636            }
637            LogEvent::InvalidationFailed(e) => {
638                write!(f, "Error writing DialCache invalidation watermark: {e}")
639            }
640        }
641    }
642}
643
644/// Receives structured log events. Panics are isolated from cache results.
645pub trait Logger: Send + Sync + 'static {
646    /// Receive one event. Called inline under panic isolation; a panic here
647    /// is swallowed and the cache result is unaffected.
648    fn log(&self, event: &LogEvent);
649}
650
651/// The default logger: the `log` crate facade.
652#[derive(Debug, Clone, Copy, Default)]
653pub struct LogFacadeLogger;
654
655impl Logger for LogFacadeLogger {
656    fn log(&self, event: &LogEvent) {
657        match event.level() {
658            LogLevel::Debug => log::debug!(target: "dialcache", "{event}"),
659            LogLevel::Warn => log::warn!(target: "dialcache", "{event}"),
660            LogLevel::Error => log::error!(target: "dialcache", "{event}"),
661        }
662    }
663}