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}