Skip to main content

obs_core/
codegen_helpers.rs

1//! Auxiliary trait surface — the small set of cross-cutting traits the
2//! codegen and the bridge use to talk to the runtime without dragging
3//! the whole event type into every consumer. Spec 12 § 3.6.
4//!
5//! - [`BuildableTo`] — marker for typed-builder "all-required-set" state.
6//! - [`FieldCapture`] — visitor used by tracing→obs auto-typing to lift recorded fields into a
7//!   typed event.
8//! - [`SpanCtx`] — read-only view of the active scope/span context that `register_typed`-style
9//!   closures receive.
10//! - [`EnumCount`] — compile-time variant count, emitted by `#[derive(EnumLabel)]` so lint L005 can
11//!   run without nightly.
12//!
13//! `MetricEmitter` is defined in its own module ([`crate::metric`]); it
14//! belongs alongside the metric pipeline rather than the codegen
15//! support traits.
16
17use std::{borrow::Cow, time::Duration};
18
19use bytes::BytesMut;
20
21/// Marker trait implemented by `typed-builder`'s "all-required-fields-set"
22/// builder state. The codegen emits a blanket impl over the parameter
23/// shape `typed-builder` produces, so `.emit()` only compiles when every
24/// required setter has been called. Spec 12 § 3.6.
25pub trait BuildableTo<Args> {
26    /// Convert the builder into the event's args struct.
27    fn build(self) -> Args;
28}
29
30/// Compile-time variant count for any enum used as a `LABEL` field.
31/// Generated by `#[derive(EnumLabel)]` and consulted by lint L005 so we
32/// do not depend on nightly's `variant_count`. Spec 12 § 3.6.
33pub trait EnumCount {
34    /// Number of variants in the enum.
35    const COUNT: usize;
36}
37
38/// Visitor used by tracing's `Event::record(visitor)` to extract typed
39/// values into a thread-local scratch space; reused across emissions
40/// (zero per-event allocation in the steady state). Spec 12 § 3.6.
41///
42/// The `tracing::field::Visit` impl lives in `obs-tracing-bridge`
43/// (Phase 4B) so `obs-core` does not pull in the tracing crate. The
44/// type lives here because the bridge's `register_typed::<E>(...)`
45/// closure receives `&mut FieldCapture` and a closure that returns
46/// `E: EventSchema` belongs to the schema layer.
47#[derive(Debug, Default)]
48pub struct FieldCapture {
49    strings: Vec<(&'static str, String)>,
50    u64s: Vec<(&'static str, u64)>,
51    i64s: Vec<(&'static str, i64)>,
52    f64s: Vec<(&'static str, f64)>,
53    bools: Vec<(&'static str, bool)>,
54    /// Reused encoder scratch for `record_debug` / `record_display`.
55    pub scratch: BytesMut,
56}
57
58impl FieldCapture {
59    /// Empty capture with default capacities.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Reset all sub-vectors but preserve their capacity. The bridge
66    /// calls this between events to keep the steady state allocation-
67    /// free. Spec 12 § 3.6.
68    pub fn clear(&mut self) {
69        self.strings.clear();
70        self.u64s.clear();
71        self.i64s.clear();
72        self.f64s.clear();
73        self.bools.clear();
74        self.scratch.clear();
75    }
76
77    /// Total number of recorded fields.
78    #[must_use]
79    pub fn len(&self) -> usize {
80        self.strings.len() + self.u64s.len() + self.i64s.len() + self.f64s.len() + self.bools.len()
81    }
82
83    /// True if no field has been recorded.
84    #[must_use]
85    pub fn is_empty(&self) -> bool {
86        self.len() == 0
87    }
88
89    /// Record a string field.
90    pub fn record_str(&mut self, name: &'static str, value: impl Into<String>) {
91        self.strings.push((name, value.into()));
92    }
93
94    /// Record a u64 field.
95    pub fn record_u64(&mut self, name: &'static str, value: u64) {
96        self.u64s.push((name, value));
97    }
98
99    /// Record an i64 field.
100    pub fn record_i64(&mut self, name: &'static str, value: i64) {
101        self.i64s.push((name, value));
102    }
103
104    /// Record an f64 field.
105    pub fn record_f64(&mut self, name: &'static str, value: f64) {
106        self.f64s.push((name, value));
107    }
108
109    /// Record a bool field.
110    pub fn record_bool(&mut self, name: &'static str, value: bool) {
111        self.bools.push((name, value));
112    }
113
114    /// Look up a string field by name; returns the **last** record (so
115    /// later writes shadow earlier ones, matching `tracing`'s own
116    /// `Visit` semantics).
117    #[must_use]
118    pub fn string(&self, name: &str) -> Option<&str> {
119        self.strings
120            .iter()
121            .rev()
122            .find(|(k, _)| *k == name)
123            .map(|(_, v)| v.as_str())
124    }
125
126    /// Look up a u64 field by name.
127    #[must_use]
128    pub fn u64(&self, name: &str) -> Option<u64> {
129        self.u64s
130            .iter()
131            .rev()
132            .find(|(k, _)| *k == name)
133            .map(|(_, v)| *v)
134    }
135
136    /// Look up an i64 field by name.
137    #[must_use]
138    pub fn i64(&self, name: &str) -> Option<i64> {
139        self.i64s
140            .iter()
141            .rev()
142            .find(|(k, _)| *k == name)
143            .map(|(_, v)| *v)
144    }
145
146    /// Look up an f64 field by name.
147    #[must_use]
148    pub fn f64(&self, name: &str) -> Option<f64> {
149        self.f64s
150            .iter()
151            .rev()
152            .find(|(k, _)| *k == name)
153            .map(|(_, v)| *v)
154    }
155
156    /// Look up a bool field by name.
157    #[must_use]
158    pub fn bool(&self, name: &str) -> Option<bool> {
159        self.bools
160            .iter()
161            .rev()
162            .find(|(k, _)| *k == name)
163            .map(|(_, v)| *v)
164    }
165
166    /// Look up a u64 field that should be interpreted as nanoseconds.
167    #[must_use]
168    pub fn duration(&self, name: &str) -> Option<Duration> {
169        self.u64(name).map(Duration::from_nanos)
170    }
171
172    /// Iterator of all recorded string fields. Used by promotion paths
173    /// that walk every field looking for known label names.
174    pub fn iter_strings(&self) -> impl Iterator<Item = (&'static str, &str)> + '_ {
175        self.strings.iter().map(|(k, v)| (*k, v.as_str()))
176    }
177}
178
179/// One frame of the bridge-visible span ancestry. Borrowed; never owns
180/// span data. Spec 12 § 3.6.
181#[derive(Debug, Clone, Copy)]
182pub struct SpanFrame<'a> {
183    /// Span's metadata `name` (e.g. `db_query`).
184    pub name: &'a str,
185    /// Span's metadata `target` (e.g. `myapp::auth`).
186    pub target: &'a str,
187}
188
189/// Read-only view of the active scope/span context that
190/// `register_typed`-style closures receive. Spec 12 § 3.6.
191///
192/// Carries the labels the user has named in `obs::scope!` plus the span
193/// ancestry (oldest first) for `tracing` source spans. Keeps two slices
194/// borrowed from caller-owned storage so the bridge never allocates.
195#[derive(Debug, Clone, Copy)]
196pub struct SpanCtx<'a> {
197    /// Labels from the active `obs::scope!` allowlist, outermost first.
198    pub labels: &'a [(&'static str, &'a str)],
199    /// Tracing span ancestry, oldest first; empty if this `SpanCtx`
200    /// originates from a non-bridge path.
201    pub spans: &'a [SpanFrame<'a>],
202}
203
204impl<'a> SpanCtx<'a> {
205    /// Empty context. Useful for non-bridge call sites that still want
206    /// to pass a `SpanCtx` to a typed promotion closure.
207    #[must_use]
208    pub const fn empty() -> Self {
209        Self {
210            labels: &[],
211            spans: &[],
212        }
213    }
214
215    /// Look up a label by name. Returns the **innermost** (last) match
216    /// so nested scopes shadow outer ones.
217    #[must_use]
218    pub fn label(&self, name: &str) -> Option<&'a str> {
219        self.labels
220            .iter()
221            .rev()
222            .find_map(|(k, v)| if *k == name { Some(*v) } else { None })
223    }
224
225    /// Render the span ancestry as `outer:middle:inner` (matches
226    /// `ObsTracingForensicEvent.span_path`). Empty stack ⇒ empty string.
227    #[must_use]
228    pub fn span_path(&self) -> Cow<'a, str> {
229        match self.spans {
230            [] => Cow::Borrowed(""),
231            [only] => Cow::Borrowed(only.name),
232            multi => {
233                let mut s = String::with_capacity(multi.iter().map(|f| f.name.len() + 1).sum());
234                for (i, f) in multi.iter().enumerate() {
235                    if i > 0 {
236                        s.push(':');
237                    }
238                    s.push_str(f.name);
239                }
240                Cow::Owned(s)
241            }
242        }
243    }
244
245    /// Innermost span's `target`, when the stack is non-empty.
246    #[must_use]
247    pub fn target(&self) -> Option<&'a str> {
248        self.spans.last().map(|f| f.target)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_field_capture_should_record_and_lookup() {
258        let mut fc = FieldCapture::new();
259        fc.record_str("route", "list_users");
260        fc.record_u64("latency_ms", 42);
261        fc.record_bool("ok", true);
262        assert_eq!(fc.string("route"), Some("list_users"));
263        assert_eq!(fc.u64("latency_ms"), Some(42));
264        assert_eq!(fc.bool("ok"), Some(true));
265        assert_eq!(fc.len(), 3);
266    }
267
268    #[test]
269    fn test_field_capture_clear_should_preserve_capacity() {
270        let mut fc = FieldCapture::new();
271        for i in 0..32 {
272            fc.record_u64("x", i);
273        }
274        let cap_u64 = fc.u64s.capacity();
275        fc.clear();
276        assert!(fc.is_empty());
277        assert_eq!(fc.u64s.capacity(), cap_u64);
278    }
279
280    #[test]
281    fn test_span_ctx_label_should_find_innermost_match() {
282        let labels: &[(&'static str, &str)] = &[("tenant", "alpha"), ("tenant", "beta")];
283        let ctx = SpanCtx { labels, spans: &[] };
284        assert_eq!(ctx.label("tenant"), Some("beta"));
285    }
286
287    #[test]
288    fn test_span_ctx_span_path_should_join() {
289        let frames = [
290            SpanFrame {
291                name: "request",
292                target: "axum",
293            },
294            SpanFrame {
295                name: "auth",
296                target: "myapp::auth",
297            },
298            SpanFrame {
299                name: "db_query",
300                target: "sqlx",
301            },
302        ];
303        let ctx = SpanCtx {
304            labels: &[],
305            spans: &frames,
306        };
307        assert_eq!(ctx.span_path(), Cow::Borrowed("request:auth:db_query"));
308        assert_eq!(ctx.target(), Some("sqlx"));
309    }
310}