obs-core 0.2.1

Runtime engine for the obs SDK: Observer, Sink, schema registry, sampling, config.
Documentation
//! `EventSchemaErased` — object-safe complement to `EventSchema`.

use bytes::BytesMut;
use obs_proto::obs::v1::{Severity, Tier};

use crate::{envelope::FieldMeta, metric::MetricEmitter};

/// Sealing supertrait — only `obs-build` codegen and
/// `obs-macros::derive(Event)` may implement [`EventSchemaErased`].
/// External crates go through the codegen so we can add methods to
/// the trait without breaking downstream impls. Spec 14 KD-D49.
pub trait Sealed {}

/// Object-safe view of a single schema. Sinks consume
/// `&'static dyn EventSchemaErased` looked up via the
/// [`crate::registry::SchemaRegistry`].
///
/// **Sealed** via [`Sealed`] supertrait so external crates cannot
/// implement it directly — they must go through `obs-build` codegen
/// or `#[derive(Event)]`. This lets us add methods to the trait
/// later (e.g. a Flatbuffers fast-path) without breaking downstream
/// impls. Spec 14 § 2 + § 11 KD-D49.
#[allow(missing_debug_implementations)]
pub trait EventSchemaErased: Sealed + Send + Sync + 'static {
    /// Stable identity (matches `EventSchema::FULL_NAME`).
    fn full_name(&self) -> &'static str;

    /// First 8 bytes of BLAKE3 over the canonical descriptor; baked
    /// at build time. Matches `EventSchema::SCHEMA_HASH`.
    fn schema_hash(&self) -> u64;

    /// Tier for routing decisions.
    fn tier(&self) -> Tier;

    /// Default severity used when the call site does not override.
    fn default_sev(&self) -> Severity;

    /// Field metadata table; same memory as `EventSchema::FIELDS`.
    fn fields(&self) -> &'static [FieldMeta];

    /// When this event is the `Started` half of a Started/Completed
    /// pair, returns the `full_name` of its sibling `Completed` event
    /// (or vice versa). Spec 20 § 2.5 B / spec 93 P1-7.
    fn spans_paired_with(&self) -> Option<&'static str> {
        None
    }

    /// Decode the buffa-encoded payload and emit metric data points
    /// for every `FIELD_KIND_MEASUREMENT` field. Phase-1 default impl
    /// is a no-op so MEASUREMENT-bearing schemas authored in Phase 1
    /// do not error in metric sinks; Phase 2 codegen overrides this.
    /// Spec 14 § 2.
    ///
    /// # Errors
    ///
    /// Returns `DecodeError` when the payload cannot be decoded.
    fn project_metrics(
        &self,
        payload: &[u8],
        emitter: &mut dyn MetricEmitter,
    ) -> Result<(), DecodeError> {
        super::payload_decode::project_metrics_default(payload, self.fields(), emitter)
    }

    /// Decode the payload into a `StructArray` row whose schema matches
    /// the codegen-emitted Arrow fragment for this event type. The
    /// default impl walks the buffa wire format using the schema's
    /// [`Self::fields`] table and dispatches to the matching
    /// [`ArrowStructBuilder`] method per declared field. Codegen may
    /// override for per-event projections that need column-specific
    /// projections beyond the generic shape. Spec 14 § 2 / spec 94 P1-C.
    ///
    /// # Errors
    ///
    /// Returns `DecodeError::Truncated` when the payload ends mid-field.
    fn decode_to_arrow_struct(
        &self,
        payload: &[u8],
        builder: &mut dyn ArrowStructBuilder,
    ) -> Result<(), DecodeError> {
        super::payload_decode::decode_to_arrow_struct_default(payload, self.fields(), builder)
    }

    /// Decode the payload into a flat `KeyValueList` body for OTLP
    /// `LogRecord.body`. The default impl walks the buffa wire format
    /// using the schema's [`Self::fields`] table; per spec 14 § 8 it
    /// silently skips unknown field numbers. Codegen may override for
    /// custom projection. Spec 14 § 2 / spec 93 P0-4.
    ///
    /// # Errors
    ///
    /// Returns `DecodeError::Truncated` when the payload ends mid-field.
    fn decode_to_otlp_kv(
        &self,
        payload: &[u8],
        out: &mut Vec<(&'static str, OtlpValue)>,
    ) -> Result<(), DecodeError> {
        super::payload_decode::decode_to_otlp_kv_default(payload, self.fields(), out)
    }

    /// Render the payload as a JSON object value (no envelope). The
    /// default impl walks the wire format and projects each declared
    /// field; Pii/Secret-classified fields are projected as the string
    /// `"<redacted>"` so the JSON output never carries the secret
    /// even if the upstream caller forgot to scrub. Spec 14 § 2 /
    /// § 4.2 / spec 93 P0-4.
    ///
    /// # Errors
    ///
    /// Returns `DecodeError::Truncated` when the payload ends mid-field.
    fn render_json(
        &self,
        payload: &[u8],
        out: &mut serde_json::Map<String, serde_json::Value>,
    ) -> Result<(), DecodeError> {
        super::payload_decode::render_json_default(payload, self.fields(), out)
    }

    /// Strip / redact classified fields in place. The default impl
    /// walks the buffa wire format using the schema's [`Self::fields`]
    /// table and re-emits the payload with `<redacted-{name}>`
    /// markers for `Classification::Pii` / `Classification::Secret`
    /// length-delimited fields, dropping classified varint/fixed
    /// fields entirely (proto3 default elision). Spec 14 § 2 + spec
    /// 70 § 4 / spec 93 P0-1.
    ///
    /// # Errors
    ///
    /// Returns `ScrubError::ReencodeFailed` when the payload is
    /// truncated mid-field. The error name pinpoints the failing
    /// decode site.
    fn scrub_for_log<'a>(
        &self,
        payload: &'a [u8],
        scratch: &'a mut BytesMut,
    ) -> Result<&'a [u8], ScrubError> {
        super::scrubber::scrub_payload(payload, self.fields(), scratch)
    }

    /// Returns the codegen-derived OTel attribute set for the per-event
    /// `event.name` plus any per-event constant attributes. Phase-1
    /// default impl returns an empty view. Spec 14 § 2.
    fn otel_attribute_view(&self) -> &'static OtelAttributeView {
        &EMPTY_OTEL_VIEW
    }
}

/// Codegen-emitted Arrow `StructArray` row builder. The default
/// [`EventSchemaErased::decode_to_arrow_struct`] implementation walks
/// the buffa wire format and calls the matching `append_*` method per
/// declared field, in declaration order from the schema's `FIELDS`
/// table. Sinks (`obs-parquet`, `obs-clickhouse`) implement this trait
/// over their builder types; Phase 4A codegen may override
/// `decode_to_arrow_struct` for per-event projections. Spec 14 § 2 /
/// spec 94 § 2.5.
pub trait ArrowStructBuilder: Send {
    /// Append a null in this row across every field of this struct.
    fn append_null(&mut self);

    /// Append a string value for the named field in this row.
    /// Default no-op so sinks that haven't wired this column can still
    /// satisfy the trait without breaking the build.
    fn append_str(&mut self, name: &'static str, value: &str) {
        let _ = (name, value);
    }

    /// Append an i64 value for the named field. Default no-op.
    fn append_i64(&mut self, name: &'static str, value: i64) {
        let _ = (name, value);
    }

    /// Append a u64 value for the named field. Default no-op.
    fn append_u64(&mut self, name: &'static str, value: u64) {
        let _ = (name, value);
    }

    /// Append an f64 value for the named field. Default no-op.
    fn append_f64(&mut self, name: &'static str, value: f64) {
        let _ = (name, value);
    }

    /// Append a bool value for the named field. Default no-op.
    fn append_bool(&mut self, name: &'static str, value: bool) {
        let _ = (name, value);
    }

    /// Append a binary blob value for the named field. Default no-op.
    fn append_bytes(&mut self, name: &'static str, value: &[u8]) {
        let _ = (name, value);
    }

    /// Mark a field as unset/null in this row. Default no-op.
    fn append_field_null(&mut self, name: &'static str) {
        let _ = name;
    }
}

/// OTLP `AnyValue` substitute for the Phase-1 surface. The real OTLP
/// types live in `obs-otel` (Phase 3 task 3.8); we use a small
/// substitute here so `EventSchemaErased::decode_to_otlp_kv` can be
/// declared without a circular `obs-core ↔ obs-otel` dependency.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum OtlpValue {
    /// String body.
    String(String),
    /// 64-bit integer body.
    Int(i64),
    /// Double body.
    Double(f64),
    /// Boolean body.
    Bool(bool),
    /// Raw bytes body.
    Bytes(Vec<u8>),
}

/// View of the OTel attribute set baked into a schema at codegen time.
/// Phase-1 ships an empty struct; codegen populates it in Phase 2.
/// Spec 14 § 2 / spec 20 § 2.3.
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct OtelAttributeView {
    /// `event.name` for OTLP (the schema's full name unless overridden).
    pub event_name: &'static str,
    /// Schema-constant attributes attached to every emit.
    pub constant_attrs: &'static [(&'static str, &'static str)],
}

static EMPTY_OTEL_VIEW: OtelAttributeView = OtelAttributeView {
    event_name: "",
    constant_attrs: &[],
};

/// Error returned by `EventSchemaErased::render_json` and the future
/// `decode_to_*` methods (Phase 2). Spec 14 § 2.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DecodeError {
    /// Payload bytes ended mid-record.
    #[error("payload truncated at offset {0}")]
    Truncated(usize),
    /// An unrecognised wire tag and the schema is in strict mode.
    #[error("unknown wire-tag {0}")]
    UnknownTag(u32),
    /// A schema-level invariant was violated.
    #[error("invariant violated: {0}")]
    Invariant(&'static str),
}

/// Error returned by `EventSchemaErased::scrub_for_log`. Spec 14 § 2.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ScrubError {
    /// Re-encoding after redaction failed.
    #[error("payload re-encode failed at field {0}")]
    ReencodeFailed(&'static str),
}