Skip to main content

rig_core/observe/
mod.rs

1//! Typed runtime and policy observations, separate from effect replay records.
2//! [`Witness`] receives facts and [`ObservationLog`] retains a bounded trace.
3//! Optional timestamps come from a host-supplied [`Clock`], not replay identity.
4//!
5//! ```
6//! use rig_core::observe::ObservationLog;
7//!
8//! let log = ObservationLog::with_capacity(128);
9//! log.finalize();
10//! assert!(log.trace().finalized);
11//! ```
12
13use std::{
14    sync::{Arc, Mutex, PoisonError},
15    time::Duration,
16};
17
18use serde::{Deserialize, Serialize};
19
20use crate::{
21    effect::{EffectFamily, EffectId, HandlerKey},
22    wasm_compat::{WasmCompatSend, WasmCompatSync},
23};
24
25mod adapter;
26pub use adapter::{
27    AdapterAnalysis, AdapterContext, AdapterEnding, AdapterErrorBoundary, AdapterErrorEnvelope,
28    AdapterEvent, AdapterObservation, AdapterUsage, AdapterVerdict, ObservationSink,
29    diagnostic_url_secrets, scrub_diagnostic,
30};
31pub(crate) use adapter::{AdapterSlot, ObservedError};
32
33#[cfg(test)]
34mod tests;
35
36/// One observed fact.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct Observation {
39    /// Sink-assigned sequence position in observation order.
40    pub seq: u64,
41    /// What the fact is about.
42    pub subject: Subject,
43    /// Where in the pipeline it was seen.
44    pub stage: Stage,
45    /// Who owns the decision or the observation.
46    pub emitter: Emitter,
47    /// The fact.
48    pub action: Action,
49    /// When, as a host-owned monotonic elapsed duration; a measurement,
50    /// never a semantic field. `None` when the sink has no [`Clock`].
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub at: Option<Duration>,
53}
54
55impl Observation {
56    /// A fact with no sequence yet; the sink assigns one.
57    pub fn new(subject: Subject, stage: Stage, emitter: Emitter, action: Action) -> Self {
58        Self {
59            seq: 0,
60            subject,
61            stage,
62            emitter,
63            action,
64            at: None,
65        }
66    }
67}
68
69/// What an observation is about. Every field is optional because facts
70/// exist before an effect has an id (a gate decides a pending intent), and
71/// some have no effect at all (a run ending). Correlation never depends on
72/// a runtime handle: `scope` is the program's serde id, `order` the
73/// driver's dispatch order, `effect`/`parent` the record's ids.
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Subject {
76    /// The program scope (the run or agent), as the record names it.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub scope: Option<String>,
79    /// The driver's dispatch order for the effect, when it has one; stable
80    /// before an id is issued, so a pre-dispatch decision correlates.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub order: Option<u64>,
83    /// The effect's id once issued.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub effect: Option<EffectId>,
86    /// The dispatch this one was made from, when a handler made it.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub parent: Option<EffectId>,
89    /// The key the effect is routed to.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub key: Option<HandlerKey>,
92    /// The family of the effect, when known.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub family: Option<EffectFamily>,
95}
96
97impl Subject {
98    /// A subject with nothing but a scope: a run-level fact.
99    pub fn scoped(scope: impl Into<String>) -> Self {
100        Self {
101            scope: Some(scope.into()),
102            ..Self::default()
103        }
104    }
105}
106
107/// Where a fact was seen: the handler side (the provider request
108/// boundary), or the host.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum Stage {
112    /// Handler-side: the provider request boundary.
113    Handler,
114    /// The application's own policies and state.
115    Host,
116}
117
118/// Stable emitter name and optional version.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct Emitter {
121    /// The emitter's stable name (a layer's name, a host
122    /// system's name).
123    pub name: String,
124    /// Its declared version, when it has one.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub version: Option<String>,
127}
128
129impl Emitter {
130    /// A named, unversioned emitter.
131    pub fn named(name: impl Into<String>) -> Self {
132        Self {
133            name: name.into(),
134            version: None,
135        }
136    }
137
138    /// A named, versioned emitter.
139    pub fn versioned(name: impl Into<String>, version: impl Into<String>) -> Self {
140        Self {
141            name: name.into(),
142            version: Some(version.into()),
143        }
144    }
145}
146
147/// A structured reason: a stable code and an optional free-text detail.
148/// The code is what a comparison keys on; the detail is for a reader.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct Reason {
151    /// A stable, machine-readable code, such as `incomplete_expected`.
152    pub code: String,
153    /// What a reader wants to know.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub detail: Option<String>,
156}
157
158impl Reason {
159    /// A reason with a code and a detail.
160    pub fn with_detail(code: impl Into<String>, detail: impl Into<String>) -> Self {
161        Self {
162            code: code.into(),
163            detail: Some(detail.into()),
164        }
165    }
166}
167
168/// The fact: one the provider request boundary saw, or a host's own
169/// ([`Action::Host`]).
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(tag = "action", rename_all = "snake_case")]
172pub enum Action {
173    /// A fact emitted by the provider request boundary.
174    Adapter {
175        /// Correlation and typed boundary metadata.
176        observation: AdapterObservation,
177    },
178    /// A host policy's own fact: named by its kind, carried as its serde
179    /// payload. Build one through [`HostAction`].
180    Host {
181        /// The host action's declared kind.
182        kind: String,
183        /// Its payload.
184        payload: serde_json::Value,
185    },
186}
187
188/// A host-defined action: a named, serde-typed fact a host policy emits
189/// through [`Action::Host`]. The kind is declared once per type, so a
190/// trace names every host fact and a consumer deserializes it back.
191pub trait HostAction: Serialize + serde::de::DeserializeOwned {
192    /// The stable kind name (`rigcoder/approval`).
193    const KIND: &'static str;
194
195    /// This fact as an [`Action::Host`]; an unserializable fact is an error,
196    /// never a silent omission.
197    fn action(&self) -> Result<Action, serde_json::Error> {
198        Ok(Action::Host {
199            kind: Self::KIND.to_owned(),
200            payload: serde_json::to_value(self)?,
201        })
202    }
203
204    /// The fact back out of an [`Action::Host`] of this kind.
205    fn from_action(action: &Action) -> Option<Result<Self, serde_json::Error>> {
206        match action {
207            Action::Host { kind, payload } if kind == Self::KIND => {
208                Some(serde_json::from_value(payload.clone()))
209            }
210            _ => None,
211        }
212    }
213}
214
215/// A host-owned monotonic clock: what a sink stamps [`Observation::at`]
216/// from. rig-core reads no clock itself; a test supplies a counter.
217pub trait Clock: WasmCompatSend + WasmCompatSync {
218    /// Elapsed time since the clock's origin.
219    fn elapsed(&self) -> Duration;
220}
221
222/// Where observations go: the seam a driver and a host emit through. A
223/// witness is shared, so it takes `&self`; it must never block the caller.
224pub trait Witness: WasmCompatSend + WasmCompatSync + 'static {
225    /// One fact. The sink assigns its sequence.
226    fn observe(&self, observation: Observation);
227}
228
229/// The serializable trace a sink produces: the analysis artifact.
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231pub struct ObservationTrace {
232    /// The sink's declared session, when it has one (a run's scope, a
233    /// trial id). Lineage, not semantics.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub session: Option<String>,
236    /// The facts, in sequence.
237    pub observations: Vec<Observation>,
238    /// Facts discarded because sink capacity was reached. Nonzero means the
239    /// trace is incomplete.
240    #[serde(default)]
241    pub dropped: u64,
242    /// Whether the sink was told the session finished normally.
243    #[serde(default)]
244    pub finalized: bool,
245}
246
247impl ObservationTrace {
248    /// Whether every fact the session produced is here.
249    pub fn is_complete(&self) -> bool {
250        self.dropped == 0
251    }
252}
253
254/// The bounded in-memory sink, in one of two shapes. A capture
255/// ([`Self::with_capacity`], the default) keeps the first `capacity` facts
256/// and counts the rest as dropped: the trace is a complete prefix or says
257/// how much of the session it missed, which is what a comparison wants. A
258/// ring ([`Self::ring`]) keeps the *last* `capacity` facts and counts what
259/// it let go: a long-running host always sees its recent past, which is
260/// what a dashboard wants. Either way [`Self::drain`] takes what is kept
261/// and starts over, so a host that exports periodically never fills up.
262pub struct ObservationLog {
263    inner: Mutex<LogState>,
264    capacity: usize,
265    ring: bool,
266    clock: Option<Arc<dyn Clock + Send + Sync>>,
267}
268
269struct LogState {
270    session: Option<String>,
271    observations: std::collections::VecDeque<Observation>,
272    next: u64,
273    dropped: u64,
274    finalized: bool,
275}
276
277impl std::fmt::Debug for ObservationLog {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        let state = self.lock();
280        f.debug_struct("ObservationLog")
281            .field("observations", &state.observations.len())
282            .field("dropped", &state.dropped)
283            .field("capacity", &self.capacity)
284            .finish_non_exhaustive()
285    }
286}
287
288/// The default capacity of an [`ObservationLog`].
289pub const DEFAULT_CAPACITY: usize = 65_536;
290
291impl Default for ObservationLog {
292    fn default() -> Self {
293        Self::with_capacity(DEFAULT_CAPACITY)
294    }
295}
296
297impl ObservationLog {
298    /// A capture keeping the first `capacity` facts; later ones are counted
299    /// as dropped.
300    pub fn with_capacity(capacity: usize) -> Self {
301        Self {
302            inner: Mutex::new(LogState {
303                session: None,
304                observations: std::collections::VecDeque::new(),
305                next: 0,
306                dropped: 0,
307                finalized: false,
308            }),
309            capacity,
310            ring: false,
311            clock: None,
312        }
313    }
314
315    /// Keeps the last `capacity` facts and counts evictions as dropped.
316    /// Capacity zero is raised to one.
317    pub fn ring(capacity: usize) -> Self {
318        Self {
319            ring: true,
320            // A ring of nothing would keep every fact and count it dropped.
321            ..Self::with_capacity(capacity.max(1))
322        }
323    }
324
325    /// Take the kept facts and start over: the session name and the
326    /// sequence continue, the kept facts and the dropped count reset. A
327    /// host that exports its trace periodically drains rather than
328    /// letting a capture fill and stop.
329    pub fn drain(&self) -> ObservationTrace {
330        let mut state = self.lock();
331        let trace = ObservationTrace {
332            session: state.session.clone(),
333            observations: state.observations.drain(..).collect(),
334            dropped: state.dropped,
335            finalized: state.finalized,
336        };
337        state.dropped = 0;
338        trace
339    }
340
341    /// Stamp every fact with `clock`'s elapsed time.
342    pub fn with_clock(mut self, clock: Arc<dyn Clock + Send + Sync>) -> Self {
343        self.clock = Some(clock);
344        self
345    }
346
347    /// Name the session the trace belongs to.
348    pub fn with_session(self, session: impl Into<String>) -> Self {
349        self.lock().session = Some(session.into());
350        self
351    }
352
353    fn lock(&self) -> std::sync::MutexGuard<'_, LogState> {
354        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
355    }
356
357    /// The session finished normally: later readers know the trace is not
358    /// a partial artifact of a killed process. A later fact reopens the capture.
359    /// Hosts must drain their producers before exporting a finalized snapshot.
360    pub fn finalize(&self) {
361        self.lock().finalized = true;
362    }
363
364    /// The facts so far.
365    pub fn trace(&self) -> ObservationTrace {
366        let state = self.lock();
367        ObservationTrace {
368            session: state.session.clone(),
369            observations: state.observations.iter().cloned().collect(),
370            dropped: state.dropped,
371            finalized: state.finalized,
372        }
373    }
374
375    /// How many facts are kept.
376    pub fn len(&self) -> usize {
377        self.lock().observations.len()
378    }
379
380    /// Whether no observations are currently retained.
381    pub fn is_empty(&self) -> bool {
382        self.lock().observations.is_empty()
383    }
384}
385
386impl Witness for ObservationLog {
387    fn observe(&self, mut observation: Observation) {
388        let at = self.clock.as_ref().map(|clock| clock.elapsed());
389        let mut state = self.lock();
390        state.finalized = false;
391        observation.seq = state.next;
392        state.next += 1;
393        if state.observations.len() >= self.capacity {
394            state.dropped += 1;
395            if !self.ring {
396                return;
397            }
398            state.observations.pop_front();
399        }
400        observation.at = at;
401        state.observations.push_back(observation);
402    }
403}
404
405impl<W: Witness + ?Sized> Witness for Arc<W> {
406    fn observe(&self, observation: Observation) {
407        (**self).observe(observation);
408    }
409}
410
411// A trace serializes and crosses threads on every target.
412const _: fn() = || {
413    fn assert_wire<T: Clone + Send + Sync + 'static + Serialize + serde::de::DeserializeOwned>() {}
414    assert_wire::<Observation>();
415    assert_wire::<ObservationTrace>();
416};