obs-core 0.2.1

Runtime engine for the obs SDK: Observer, Sink, schema registry, sampling, config.
Documentation
//! Auxiliary trait surface — the small set of cross-cutting traits the
//! codegen and the bridge use to talk to the runtime without dragging
//! the whole event type into every consumer. Spec 12 § 3.6.
//!
//! - [`BuildableTo`] — marker for typed-builder "all-required-set" state.
//! - [`FieldCapture`] — visitor used by tracing→obs auto-typing to lift recorded fields into a
//!   typed event.
//! - [`SpanCtx`] — read-only view of the active scope/span context that `register_typed`-style
//!   closures receive.
//! - [`EnumCount`] — compile-time variant count, emitted by `#[derive(EnumLabel)]` so lint L005 can
//!   run without nightly.
//!
//! `MetricEmitter` is defined in its own module ([`crate::metric`]); it
//! belongs alongside the metric pipeline rather than the codegen
//! support traits.

use std::{borrow::Cow, time::Duration};

use bytes::BytesMut;

/// Marker trait implemented by `typed-builder`'s "all-required-fields-set"
/// builder state. The codegen emits a blanket impl over the parameter
/// shape `typed-builder` produces, so `.emit()` only compiles when every
/// required setter has been called. Spec 12 § 3.6.
pub trait BuildableTo<Args> {
    /// Convert the builder into the event's args struct.
    fn build(self) -> Args;
}

/// Compile-time variant count for any enum used as a `LABEL` field.
/// Generated by `#[derive(EnumLabel)]` and consulted by lint L005 so we
/// do not depend on nightly's `variant_count`. Spec 12 § 3.6.
pub trait EnumCount {
    /// Number of variants in the enum.
    const COUNT: usize;
}

/// Visitor used by tracing's `Event::record(visitor)` to extract typed
/// values into a thread-local scratch space; reused across emissions
/// (zero per-event allocation in the steady state). Spec 12 § 3.6.
///
/// The `tracing::field::Visit` impl lives in `obs-tracing-bridge`
/// (Phase 4B) so `obs-core` does not pull in the tracing crate. The
/// type lives here because the bridge's `register_typed::<E>(...)`
/// closure receives `&mut FieldCapture` and a closure that returns
/// `E: EventSchema` belongs to the schema layer.
#[derive(Debug, Default)]
pub struct FieldCapture {
    strings: Vec<(&'static str, String)>,
    u64s: Vec<(&'static str, u64)>,
    i64s: Vec<(&'static str, i64)>,
    f64s: Vec<(&'static str, f64)>,
    bools: Vec<(&'static str, bool)>,
    /// Reused encoder scratch for `record_debug` / `record_display`.
    pub scratch: BytesMut,
}

impl FieldCapture {
    /// Empty capture with default capacities.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Reset all sub-vectors but preserve their capacity. The bridge
    /// calls this between events to keep the steady state allocation-
    /// free. Spec 12 § 3.6.
    pub fn clear(&mut self) {
        self.strings.clear();
        self.u64s.clear();
        self.i64s.clear();
        self.f64s.clear();
        self.bools.clear();
        self.scratch.clear();
    }

    /// Total number of recorded fields.
    #[must_use]
    pub fn len(&self) -> usize {
        self.strings.len() + self.u64s.len() + self.i64s.len() + self.f64s.len() + self.bools.len()
    }

    /// True if no field has been recorded.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Record a string field.
    pub fn record_str(&mut self, name: &'static str, value: impl Into<String>) {
        self.strings.push((name, value.into()));
    }

    /// Record a u64 field.
    pub fn record_u64(&mut self, name: &'static str, value: u64) {
        self.u64s.push((name, value));
    }

    /// Record an i64 field.
    pub fn record_i64(&mut self, name: &'static str, value: i64) {
        self.i64s.push((name, value));
    }

    /// Record an f64 field.
    pub fn record_f64(&mut self, name: &'static str, value: f64) {
        self.f64s.push((name, value));
    }

    /// Record a bool field.
    pub fn record_bool(&mut self, name: &'static str, value: bool) {
        self.bools.push((name, value));
    }

    /// Look up a string field by name; returns the **last** record (so
    /// later writes shadow earlier ones, matching `tracing`'s own
    /// `Visit` semantics).
    #[must_use]
    pub fn string(&self, name: &str) -> Option<&str> {
        self.strings
            .iter()
            .rev()
            .find(|(k, _)| *k == name)
            .map(|(_, v)| v.as_str())
    }

    /// Look up a u64 field by name.
    #[must_use]
    pub fn u64(&self, name: &str) -> Option<u64> {
        self.u64s
            .iter()
            .rev()
            .find(|(k, _)| *k == name)
            .map(|(_, v)| *v)
    }

    /// Look up an i64 field by name.
    #[must_use]
    pub fn i64(&self, name: &str) -> Option<i64> {
        self.i64s
            .iter()
            .rev()
            .find(|(k, _)| *k == name)
            .map(|(_, v)| *v)
    }

    /// Look up an f64 field by name.
    #[must_use]
    pub fn f64(&self, name: &str) -> Option<f64> {
        self.f64s
            .iter()
            .rev()
            .find(|(k, _)| *k == name)
            .map(|(_, v)| *v)
    }

    /// Look up a bool field by name.
    #[must_use]
    pub fn bool(&self, name: &str) -> Option<bool> {
        self.bools
            .iter()
            .rev()
            .find(|(k, _)| *k == name)
            .map(|(_, v)| *v)
    }

    /// Look up a u64 field that should be interpreted as nanoseconds.
    #[must_use]
    pub fn duration(&self, name: &str) -> Option<Duration> {
        self.u64(name).map(Duration::from_nanos)
    }

    /// Iterator of all recorded string fields. Used by promotion paths
    /// that walk every field looking for known label names.
    pub fn iter_strings(&self) -> impl Iterator<Item = (&'static str, &str)> + '_ {
        self.strings.iter().map(|(k, v)| (*k, v.as_str()))
    }
}

/// One frame of the bridge-visible span ancestry. Borrowed; never owns
/// span data. Spec 12 § 3.6.
#[derive(Debug, Clone, Copy)]
pub struct SpanFrame<'a> {
    /// Span's metadata `name` (e.g. `db_query`).
    pub name: &'a str,
    /// Span's metadata `target` (e.g. `myapp::auth`).
    pub target: &'a str,
}

/// Read-only view of the active scope/span context that
/// `register_typed`-style closures receive. Spec 12 § 3.6.
///
/// Carries the labels the user has named in `obs::scope!` plus the span
/// ancestry (oldest first) for `tracing` source spans. Keeps two slices
/// borrowed from caller-owned storage so the bridge never allocates.
#[derive(Debug, Clone, Copy)]
pub struct SpanCtx<'a> {
    /// Labels from the active `obs::scope!` allowlist, outermost first.
    pub labels: &'a [(&'static str, &'a str)],
    /// Tracing span ancestry, oldest first; empty if this `SpanCtx`
    /// originates from a non-bridge path.
    pub spans: &'a [SpanFrame<'a>],
}

impl<'a> SpanCtx<'a> {
    /// Empty context. Useful for non-bridge call sites that still want
    /// to pass a `SpanCtx` to a typed promotion closure.
    #[must_use]
    pub const fn empty() -> Self {
        Self {
            labels: &[],
            spans: &[],
        }
    }

    /// Look up a label by name. Returns the **innermost** (last) match
    /// so nested scopes shadow outer ones.
    #[must_use]
    pub fn label(&self, name: &str) -> Option<&'a str> {
        self.labels
            .iter()
            .rev()
            .find_map(|(k, v)| if *k == name { Some(*v) } else { None })
    }

    /// Render the span ancestry as `outer:middle:inner` (matches
    /// `ObsTracingForensicEvent.span_path`). Empty stack ⇒ empty string.
    #[must_use]
    pub fn span_path(&self) -> Cow<'a, str> {
        match self.spans {
            [] => Cow::Borrowed(""),
            [only] => Cow::Borrowed(only.name),
            multi => {
                let mut s = String::with_capacity(multi.iter().map(|f| f.name.len() + 1).sum());
                for (i, f) in multi.iter().enumerate() {
                    if i > 0 {
                        s.push(':');
                    }
                    s.push_str(f.name);
                }
                Cow::Owned(s)
            }
        }
    }

    /// Innermost span's `target`, when the stack is non-empty.
    #[must_use]
    pub fn target(&self) -> Option<&'a str> {
        self.spans.last().map(|f| f.target)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_field_capture_should_record_and_lookup() {
        let mut fc = FieldCapture::new();
        fc.record_str("route", "list_users");
        fc.record_u64("latency_ms", 42);
        fc.record_bool("ok", true);
        assert_eq!(fc.string("route"), Some("list_users"));
        assert_eq!(fc.u64("latency_ms"), Some(42));
        assert_eq!(fc.bool("ok"), Some(true));
        assert_eq!(fc.len(), 3);
    }

    #[test]
    fn test_field_capture_clear_should_preserve_capacity() {
        let mut fc = FieldCapture::new();
        for i in 0..32 {
            fc.record_u64("x", i);
        }
        let cap_u64 = fc.u64s.capacity();
        fc.clear();
        assert!(fc.is_empty());
        assert_eq!(fc.u64s.capacity(), cap_u64);
    }

    #[test]
    fn test_span_ctx_label_should_find_innermost_match() {
        let labels: &[(&'static str, &str)] = &[("tenant", "alpha"), ("tenant", "beta")];
        let ctx = SpanCtx { labels, spans: &[] };
        assert_eq!(ctx.label("tenant"), Some("beta"));
    }

    #[test]
    fn test_span_ctx_span_path_should_join() {
        let frames = [
            SpanFrame {
                name: "request",
                target: "axum",
            },
            SpanFrame {
                name: "auth",
                target: "myapp::auth",
            },
            SpanFrame {
                name: "db_query",
                target: "sqlx",
            },
        ];
        let ctx = SpanCtx {
            labels: &[],
            spans: &frames,
        };
        assert_eq!(ctx.span_path(), Cow::Borrowed("request:auth:db_query"));
        assert_eq!(ctx.target(), Some("sqlx"));
    }
}