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_types::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}