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}