Skip to main content

obs_core/registry/
erased.rs

1//! `EventSchemaErased` — object-safe complement to `EventSchema`.
2
3use bytes::BytesMut;
4use obs_proto::obs::v1::{Severity, Tier};
5
6use crate::{envelope::FieldMeta, metric::MetricEmitter};
7
8/// Sealing supertrait — only `obs-build` codegen and
9/// `obs-macros::derive(Event)` may implement [`EventSchemaErased`].
10/// External crates go through the codegen so we can add methods to
11/// the trait without breaking downstream impls. Spec 14 KD-D49.
12pub trait Sealed {}
13
14/// Object-safe view of a single schema. Sinks consume
15/// `&'static dyn EventSchemaErased` looked up via the
16/// [`crate::registry::SchemaRegistry`].
17///
18/// **Sealed** via [`Sealed`] supertrait so external crates cannot
19/// implement it directly — they must go through `obs-build` codegen
20/// or `#[derive(Event)]`. This lets us add methods to the trait
21/// later (e.g. a Flatbuffers fast-path) without breaking downstream
22/// impls. Spec 14 § 2 + § 11 KD-D49.
23#[allow(missing_debug_implementations)]
24pub trait EventSchemaErased: Sealed + Send + Sync + 'static {
25    /// Stable identity (matches `EventSchema::FULL_NAME`).
26    fn full_name(&self) -> &'static str;
27
28    /// First 8 bytes of BLAKE3 over the canonical descriptor; baked
29    /// at build time. Matches `EventSchema::SCHEMA_HASH`.
30    fn schema_hash(&self) -> u64;
31
32    /// Tier for routing decisions.
33    fn tier(&self) -> Tier;
34
35    /// Default severity used when the call site does not override.
36    fn default_sev(&self) -> Severity;
37
38    /// Field metadata table; same memory as `EventSchema::FIELDS`.
39    fn fields(&self) -> &'static [FieldMeta];
40
41    /// When this event is the `Started` half of a Started/Completed
42    /// pair, returns the `full_name` of its sibling `Completed` event
43    /// (or vice versa). Spec 20 § 2.5 B / spec 93 P1-7.
44    fn spans_paired_with(&self) -> Option<&'static str> {
45        None
46    }
47
48    /// Decode the buffa-encoded payload and emit metric data points
49    /// for every `FIELD_KIND_MEASUREMENT` field. Phase-1 default impl
50    /// is a no-op so MEASUREMENT-bearing schemas authored in Phase 1
51    /// do not error in metric sinks; Phase 2 codegen overrides this.
52    /// Spec 14 § 2.
53    ///
54    /// # Errors
55    ///
56    /// Returns `DecodeError` when the payload cannot be decoded.
57    fn project_metrics(
58        &self,
59        payload: &[u8],
60        emitter: &mut dyn MetricEmitter,
61    ) -> Result<(), DecodeError> {
62        super::payload_decode::project_metrics_default(payload, self.fields(), emitter)
63    }
64
65    /// Decode the payload into a `StructArray` row whose schema matches
66    /// the codegen-emitted Arrow fragment for this event type. The
67    /// default impl walks the buffa wire format using the schema's
68    /// [`Self::fields`] table and dispatches to the matching
69    /// [`ArrowStructBuilder`] method per declared field. Codegen may
70    /// override for per-event projections that need column-specific
71    /// projections beyond the generic shape. Spec 14 § 2 / spec 94 P1-C.
72    ///
73    /// # Errors
74    ///
75    /// Returns `DecodeError::Truncated` when the payload ends mid-field.
76    fn decode_to_arrow_struct(
77        &self,
78        payload: &[u8],
79        builder: &mut dyn ArrowStructBuilder,
80    ) -> Result<(), DecodeError> {
81        super::payload_decode::decode_to_arrow_struct_default(payload, self.fields(), builder)
82    }
83
84    /// Decode the payload into a flat `KeyValueList` body for OTLP
85    /// `LogRecord.body`. The default impl walks the buffa wire format
86    /// using the schema's [`Self::fields`] table; per spec 14 § 8 it
87    /// silently skips unknown field numbers. Codegen may override for
88    /// custom projection. Spec 14 § 2 / spec 93 P0-4.
89    ///
90    /// # Errors
91    ///
92    /// Returns `DecodeError::Truncated` when the payload ends mid-field.
93    fn decode_to_otlp_kv(
94        &self,
95        payload: &[u8],
96        out: &mut Vec<(&'static str, OtlpValue)>,
97    ) -> Result<(), DecodeError> {
98        super::payload_decode::decode_to_otlp_kv_default(payload, self.fields(), out)
99    }
100
101    /// Render the payload as a JSON object value (no envelope). The
102    /// default impl walks the wire format and projects each declared
103    /// field; Pii/Secret-classified fields are projected as the string
104    /// `"<redacted>"` so the JSON output never carries the secret
105    /// even if the upstream caller forgot to scrub. Spec 14 § 2 /
106    /// § 4.2 / spec 93 P0-4.
107    ///
108    /// # Errors
109    ///
110    /// Returns `DecodeError::Truncated` when the payload ends mid-field.
111    fn render_json(
112        &self,
113        payload: &[u8],
114        out: &mut serde_json::Map<String, serde_json::Value>,
115    ) -> Result<(), DecodeError> {
116        super::payload_decode::render_json_default(payload, self.fields(), out)
117    }
118
119    /// Strip / redact classified fields in place. The default impl
120    /// walks the buffa wire format using the schema's [`Self::fields`]
121    /// table and re-emits the payload with `<redacted-{name}>`
122    /// markers for `Classification::Pii` / `Classification::Secret`
123    /// length-delimited fields, dropping classified varint/fixed
124    /// fields entirely (proto3 default elision). Spec 14 § 2 + spec
125    /// 70 § 4 / spec 93 P0-1.
126    ///
127    /// # Errors
128    ///
129    /// Returns `ScrubError::ReencodeFailed` when the payload is
130    /// truncated mid-field. The error name pinpoints the failing
131    /// decode site.
132    fn scrub_for_log<'a>(
133        &self,
134        payload: &'a [u8],
135        scratch: &'a mut BytesMut,
136    ) -> Result<&'a [u8], ScrubError> {
137        super::scrubber::scrub_payload(payload, self.fields(), scratch)
138    }
139
140    /// Returns the codegen-derived OTel attribute set for the per-event
141    /// `event.name` plus any per-event constant attributes. Phase-1
142    /// default impl returns an empty view. Spec 14 § 2.
143    fn otel_attribute_view(&self) -> &'static OtelAttributeView {
144        &EMPTY_OTEL_VIEW
145    }
146}
147
148/// Codegen-emitted Arrow `StructArray` row builder. The default
149/// [`EventSchemaErased::decode_to_arrow_struct`] implementation walks
150/// the buffa wire format and calls the matching `append_*` method per
151/// declared field, in declaration order from the schema's `FIELDS`
152/// table. Sinks (`obs-parquet`, `obs-clickhouse`) implement this trait
153/// over their builder types; Phase 4A codegen may override
154/// `decode_to_arrow_struct` for per-event projections. Spec 14 § 2 /
155/// spec 94 § 2.5.
156pub trait ArrowStructBuilder: Send {
157    /// Append a null in this row across every field of this struct.
158    fn append_null(&mut self);
159
160    /// Append a string value for the named field in this row.
161    /// Default no-op so sinks that haven't wired this column can still
162    /// satisfy the trait without breaking the build.
163    fn append_str(&mut self, name: &'static str, value: &str) {
164        let _ = (name, value);
165    }
166
167    /// Append an i64 value for the named field. Default no-op.
168    fn append_i64(&mut self, name: &'static str, value: i64) {
169        let _ = (name, value);
170    }
171
172    /// Append a u64 value for the named field. Default no-op.
173    fn append_u64(&mut self, name: &'static str, value: u64) {
174        let _ = (name, value);
175    }
176
177    /// Append an f64 value for the named field. Default no-op.
178    fn append_f64(&mut self, name: &'static str, value: f64) {
179        let _ = (name, value);
180    }
181
182    /// Append a bool value for the named field. Default no-op.
183    fn append_bool(&mut self, name: &'static str, value: bool) {
184        let _ = (name, value);
185    }
186
187    /// Append a binary blob value for the named field. Default no-op.
188    fn append_bytes(&mut self, name: &'static str, value: &[u8]) {
189        let _ = (name, value);
190    }
191
192    /// Mark a field as unset/null in this row. Default no-op.
193    fn append_field_null(&mut self, name: &'static str) {
194        let _ = name;
195    }
196}
197
198/// OTLP `AnyValue` substitute for the Phase-1 surface. The real OTLP
199/// types live in `obs-otel` (Phase 3 task 3.8); we use a small
200/// substitute here so `EventSchemaErased::decode_to_otlp_kv` can be
201/// declared without a circular `obs-core ↔ obs-otel` dependency.
202#[derive(Debug, Clone)]
203#[non_exhaustive]
204pub enum OtlpValue {
205    /// String body.
206    String(String),
207    /// 64-bit integer body.
208    Int(i64),
209    /// Double body.
210    Double(f64),
211    /// Boolean body.
212    Bool(bool),
213    /// Raw bytes body.
214    Bytes(Vec<u8>),
215}
216
217/// View of the OTel attribute set baked into a schema at codegen time.
218/// Phase-1 ships an empty struct; codegen populates it in Phase 2.
219/// Spec 14 § 2 / spec 20 § 2.3.
220#[derive(Debug, Default)]
221#[non_exhaustive]
222pub struct OtelAttributeView {
223    /// `event.name` for OTLP (the schema's full name unless overridden).
224    pub event_name: &'static str,
225    /// Schema-constant attributes attached to every emit.
226    pub constant_attrs: &'static [(&'static str, &'static str)],
227}
228
229static EMPTY_OTEL_VIEW: OtelAttributeView = OtelAttributeView {
230    event_name: "",
231    constant_attrs: &[],
232};
233
234/// Error returned by `EventSchemaErased::render_json` and the future
235/// `decode_to_*` methods (Phase 2). Spec 14 § 2.
236#[derive(Debug, thiserror::Error)]
237#[non_exhaustive]
238pub enum DecodeError {
239    /// Payload bytes ended mid-record.
240    #[error("payload truncated at offset {0}")]
241    Truncated(usize),
242    /// An unrecognised wire tag and the schema is in strict mode.
243    #[error("unknown wire-tag {0}")]
244    UnknownTag(u32),
245    /// A schema-level invariant was violated.
246    #[error("invariant violated: {0}")]
247    Invariant(&'static str),
248}
249
250/// Error returned by `EventSchemaErased::scrub_for_log`. Spec 14 § 2.
251#[derive(Debug, thiserror::Error)]
252#[non_exhaustive]
253pub enum ScrubError {
254    /// Re-encoding after redaction failed.
255    #[error("payload re-encode failed at field {0}")]
256    ReencodeFailed(&'static str),
257}