Skip to main content

obs_core/
callsite.rs

1//! `ObsCallsite` and the atomic `Interest` cache.
2//!
3//! Every emit-site compiles to a unique `static ObsCallsite`. The
4//! atomic-`Interest` cache lets a filtered-out emit short-circuit on a
5//! single atomic load, with no observer virtual call. See spec 11 § 2.
6
7use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
8
9use obs_proto::obs::v1::Severity;
10
11/// Cached interest decision for a callsite. Mirrors `tracing::Interest`.
12///
13/// - `0` (`Unknown`) — not yet probed; observer must be queried.
14/// - `1` (`Never`) — disabled; skip the entire emit branch.
15/// - `2` (`Sometimes`) — enabled but still call `Observer::enabled()` per emit (e.g. severity-floor
16///   + per-callsite allowlist).
17/// - `3` (`Always`) — enabled unconditionally; skip the virtual call.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[repr(u8)]
20pub enum Interest {
21    /// Not yet decided — observer will be queried.
22    Unknown = 0,
23    /// Disabled. The emit branch is skipped after one atomic load.
24    Never = 1,
25    /// Enabled but `Observer::enabled` must still run.
26    Sometimes = 2,
27    /// Enabled unconditionally; the virtual call is skipped.
28    Always = 3,
29}
30
31impl Interest {
32    fn from_u8(value: u8) -> Self {
33        match value {
34            1 => Self::Never,
35            2 => Self::Sometimes,
36            3 => Self::Always,
37            _ => Self::Unknown,
38        }
39    }
40}
41
42/// Static metadata for a single emit site. Constructed by codegen via
43/// the `const fn` constructor — no heap allocation, no first-emit cost.
44///
45/// See spec 11 § 2.
46#[derive(Debug)]
47pub struct ObsCallsite {
48    /// Fully qualified event name (`myapp.v1.ObsXxx`).
49    full_name: &'static str,
50    /// Default severity declared by the schema.
51    default_sev: Severity,
52    /// `module_path!()` from the emit site.
53    module: &'static str,
54    /// `file!()` from the emit site.
55    file: &'static str,
56    /// `line!()` from the emit site.
57    line: u32,
58    /// Cached interest decision (see [`Interest`]).
59    interest: AtomicU8,
60    /// Bumped on every config reload so a stale `interest` is treated as
61    /// `Unknown` and re-queried (spec 11 § 3.2).
62    cached_gen: AtomicU32,
63}
64
65impl ObsCallsite {
66    /// Construct a callsite. Intended for use by codegen at static init;
67    /// the const-fn shape means no heap allocation on first emit.
68    #[must_use]
69    pub const fn new(
70        full_name: &'static str,
71        default_sev: Severity,
72        module: &'static str,
73        file: &'static str,
74        line: u32,
75    ) -> Self {
76        Self {
77            full_name,
78            default_sev,
79            module,
80            file,
81            line,
82            interest: AtomicU8::new(Interest::Unknown as u8),
83            cached_gen: AtomicU32::new(0),
84        }
85    }
86
87    /// Hot-path enabled check.
88    ///
89    /// Returns `true` if this callsite *might* fire (`Sometimes` /
90    /// `Always`); the caller is then expected to invoke
91    /// `Observer::enabled` only when the result is `Sometimes`.
92    ///
93    /// `current_gen` is the observer's `generation()`. On a generation
94    /// mismatch the cache is reset to `Unknown` and the caller re-probes.
95    #[inline(always)]
96    #[must_use]
97    pub fn enabled(&self, current_gen: u32) -> EnabledOutcome {
98        let cached_gen = self.cached_gen.load(Ordering::Relaxed);
99        if cached_gen != current_gen {
100            return EnabledOutcome::ReProbe;
101        }
102        match Interest::from_u8(self.interest.load(Ordering::Relaxed)) {
103            Interest::Unknown => EnabledOutcome::ReProbe,
104            Interest::Never => EnabledOutcome::Off,
105            Interest::Sometimes => EnabledOutcome::SometimesOn,
106            Interest::Always => EnabledOutcome::AlwaysOn,
107        }
108    }
109
110    /// Update the cached interest after probing the observer.
111    pub fn cache(&self, interest: Interest, current_gen: u32) {
112        self.interest.store(interest as u8, Ordering::Relaxed);
113        self.cached_gen.store(current_gen, Ordering::Relaxed);
114    }
115
116    /// Force the cache to `Unknown` so the next emit re-probes. Used by
117    /// tests; production reload uses [`ObsCallsite::cache`] with the new
118    /// generation, which has the same effect.
119    pub fn reset_cache(&self) {
120        self.interest
121            .store(Interest::Unknown as u8, Ordering::Relaxed);
122        self.cached_gen.store(0, Ordering::Relaxed);
123    }
124
125    /// Fully qualified event name.
126    #[must_use]
127    pub const fn full_name(&self) -> &'static str {
128        self.full_name
129    }
130
131    /// Default severity declared by the schema.
132    #[must_use]
133    pub const fn default_sev(&self) -> Severity {
134        self.default_sev
135    }
136
137    /// `module_path!()` from the emit site.
138    #[must_use]
139    pub const fn module(&self) -> &'static str {
140        self.module
141    }
142
143    /// `file!()` from the emit site.
144    #[must_use]
145    pub const fn file(&self) -> &'static str {
146        self.file
147    }
148
149    /// `line!()` from the emit site.
150    #[must_use]
151    pub const fn line(&self) -> u32 {
152        self.line
153    }
154}
155
156/// Result of the hot-path enabled check.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum EnabledOutcome {
160    /// Cached `Never` — skip the emit branch.
161    Off,
162    /// Cached `Sometimes` — caller must still call `Observer::enabled`.
163    SometimesOn,
164    /// Cached `Always` — caller skips `Observer::enabled` and emits.
165    AlwaysOn,
166    /// Cache is stale or empty — caller must probe and `cache(...)` the
167    /// result.
168    ReProbe,
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    // Each test owns its own callsite so cargo's parallel runner cannot
176    // race on the cache. Sharing one static across tests is what the
177    // production hot path does, but tests need independence to be
178    // deterministic.
179
180    #[test]
181    fn test_should_start_in_unknown() {
182        static CS: ObsCallsite = ObsCallsite::new(
183            "test.v1.ProbeUnknown",
184            Severity::Info,
185            module_path!(),
186            file!(),
187            line!(),
188        );
189        CS.reset_cache();
190        assert_eq!(CS.enabled(1), EnabledOutcome::ReProbe);
191    }
192
193    #[test]
194    fn test_should_short_circuit_on_never() {
195        static CS: ObsCallsite = ObsCallsite::new(
196            "test.v1.ProbeNever",
197            Severity::Info,
198            module_path!(),
199            file!(),
200            line!(),
201        );
202        CS.cache(Interest::Never, 7);
203        assert_eq!(CS.enabled(7), EnabledOutcome::Off);
204    }
205
206    #[test]
207    fn test_should_reprobe_on_generation_mismatch() {
208        static CS: ObsCallsite = ObsCallsite::new(
209            "test.v1.ProbeReprobe",
210            Severity::Info,
211            module_path!(),
212            file!(),
213            line!(),
214        );
215        CS.cache(Interest::Always, 7);
216        assert_eq!(CS.enabled(7), EnabledOutcome::AlwaysOn);
217        assert_eq!(CS.enabled(8), EnabledOutcome::ReProbe);
218    }
219}