Skip to main content

rig_tap/
event.rs

1//! Observability event schema (v1).
2//!
3//! All events flow through the [`ObservabilityEvent`] envelope so consumers
4//! see a single, flat JSON shape regardless of the producing crate.
5
6use serde::{Deserialize, Serialize};
7
8/// Current schema version. Bumped on breaking changes to the wire format.
9pub const SCHEMA_VERSION: u32 = 1;
10
11/// Maximum byte length of inline `args_json` / `result_json` payloads before
12/// they are truncated and marked with `"truncated": true`.
13pub const PAYLOAD_TRUNCATE_BYTES: usize = 4096;
14
15/// A single observability event with envelope metadata.
16///
17/// `kind` is flattened so the wire JSON is a single flat object:
18///
19/// ```json
20/// {
21///   "version": 1,
22///   "occurred_at_millis": 1715000000000,
23///   "tick": 42,
24///   "conversation_id": "thread-1",
25///   "kind": "prompt.started",
26///   "model": "gpt-4o",
27///   "messages_in": 3
28/// }
29/// ```
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct ObservabilityEvent {
32    /// Schema version. See [`SCHEMA_VERSION`].
33    pub version: u32,
34    /// Wall-clock timestamp in milliseconds since the Unix epoch.
35    pub occurred_at_millis: u64,
36    /// Monotonic per-process counter. Use to order events without clock skew.
37    pub tick: u64,
38    /// Conversation / thread identifier this event belongs to.
39    pub conversation_id: String,
40    /// Event-specific payload. Flattened into the parent object.
41    #[serde(flatten)]
42    pub kind: EventKind,
43}
44
45impl ObservabilityEvent {
46    /// Build a new envelope around `kind` using the current schema version.
47    /// Callers normally use [`crate::emit::emit`] which fills in `tick` and
48    /// `occurred_at_millis` automatically.
49    pub fn new(conversation_id: impl Into<String>, kind: EventKind) -> Self {
50        Self {
51            version: SCHEMA_VERSION,
52            occurred_at_millis: 0,
53            tick: 0,
54            conversation_id: conversation_id.into(),
55            kind,
56        }
57    }
58}
59
60/// Payload variants. Tagged on the wire as `"kind": "<dotted.name>"`.
61///
62/// New variants are additive; rename or remove is a breaking change requiring
63/// a bump of [`SCHEMA_VERSION`].
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(tag = "kind")]
66#[non_exhaustive]
67pub enum EventKind {
68    /// A prompt is about to be sent to the model provider.
69    #[serde(rename = "prompt.started")]
70    PromptStarted {
71        /// Model name as declared on the agent.
72        model: String,
73        /// Number of messages in the history at the time of the call.
74        messages_in: usize,
75    },
76    /// A prompt finished; the model returned a completion response.
77    #[serde(rename = "prompt.completed")]
78    PromptCompleted {
79        /// Model name as reported by the provider response (may differ from
80        /// the requested model for routed providers).
81        model: String,
82        /// Provider-reported input tokens, if known.
83        #[serde(skip_serializing_if = "Option::is_none")]
84        tokens_in: Option<u64>,
85        /// Provider-reported output tokens, if known.
86        #[serde(skip_serializing_if = "Option::is_none")]
87        tokens_out: Option<u64>,
88        /// Provider response ID, if supplied.
89        #[serde(skip_serializing_if = "Option::is_none")]
90        response_id: Option<String>,
91    },
92    /// A tool is about to be invoked.
93    #[serde(rename = "tool.invoked")]
94    ToolInvoked {
95        /// Tool name as registered on the agent.
96        tool_name: String,
97        /// Provider-supplied tool-call ID, when present.
98        #[serde(skip_serializing_if = "Option::is_none")]
99        provider_call_id: Option<String>,
100        /// Stable internal correlation ID (always present).
101        call_id: String,
102        /// JSON-encoded arguments (possibly truncated; see `truncated`).
103        args_json: String,
104        /// `true` if `args_json` was truncated to
105        /// [`PAYLOAD_TRUNCATE_BYTES`].
106        truncated: bool,
107    },
108    /// A tool finished executing.
109    #[serde(rename = "tool.completed")]
110    ToolCompleted {
111        /// Tool name (matches the paired `tool.invoked`).
112        tool_name: String,
113        /// Provider-supplied tool-call ID, when present.
114        #[serde(skip_serializing_if = "Option::is_none")]
115        provider_call_id: Option<String>,
116        /// Stable internal correlation ID (matches the paired `tool.invoked`).
117        call_id: String,
118        /// Tool result text (possibly truncated; see `truncated`).
119        result: String,
120        /// `true` if `result` was truncated to [`PAYLOAD_TRUNCATE_BYTES`].
121        truncated: bool,
122    },
123    /// A previously-`ToolInvoked` call was skipped by a gating hook before
124    /// the tool body ran. Pairs by `call_id` and closes the
125    /// `tool.invoked`/`tool.completed` gap that would otherwise leave the
126    /// invoke event orphaned.
127    #[serde(rename = "tool.skipped")]
128    ToolSkipped {
129        /// Tool name (matches the paired `tool.invoked`).
130        tool_name: String,
131        /// Stable internal correlation ID (matches the paired `tool.invoked`).
132        call_id: String,
133        /// Human-readable reason from the gate.
134        reason: String,
135    },
136    /// A previously-`ToolInvoked` call triggered a hook-driven termination
137    /// of the agent loop. Pairs by `call_id`.
138    #[serde(rename = "tool.terminated")]
139    ToolTerminated {
140        /// Tool name (matches the paired `tool.invoked`).
141        tool_name: String,
142        /// Stable internal correlation ID (matches the paired `tool.invoked`).
143        call_id: String,
144        /// Human-readable reason from the hook.
145        reason: String,
146    },
147    /// The active context was sampled (typically on `ConversationMemory::load`).
148    #[serde(rename = "context.sampled")]
149    ContextSampled {
150        /// Number of messages in the loaded history.
151        message_count: usize,
152        /// JSON byte size of the loaded history (rough size estimate).
153        byte_size: usize,
154        /// Optional token-count estimate. `None` in the default build; populated
155        /// by consumers that wire a tokenizer.
156        #[serde(skip_serializing_if = "Option::is_none")]
157        token_estimate: Option<u64>,
158    },
159    /// A compactor fired, replacing some evicted history with a summary
160    /// artifact.
161    #[serde(rename = "context.compacted")]
162    ContextCompacted {
163        /// Number of messages evicted from the active context.
164        evicted_count: usize,
165        /// Approximate byte size of the evicted messages.
166        evicted_bytes: usize,
167        /// `true` if the compactor produced a carry-over artifact for the
168        /// next compaction cycle.
169        carry_over: bool,
170        /// Byte size of the summary text written to long-term memory.
171        summary_bytes: usize,
172    },
173    /// A demotion hook moved messages to long-term storage.
174    #[serde(rename = "memory.demoted")]
175    MemoryDemoted {
176        /// Number of messages demoted.
177        demoted_count: usize,
178        /// Tags applied to the demoted frames.
179        tags: Vec<String>,
180    },
181    /// A frame was written to the long-term store.
182    #[serde(rename = "memory.frame_written")]
183    MemoryFrameWritten {
184        /// Frame kind as classified by the producer (e.g. `"summary"`,
185        /// `"demoted"`).
186        frame_kind: String,
187        /// Total frame count in the store after the write. `None` when the
188        /// producer does not expose a cheap cumulative count (e.g. memvid).
189        /// Consumers SHOULD NOT assume `0` means "empty store" — use this
190        /// `Option` and treat absence as "unknown".
191        #[serde(skip_serializing_if = "Option::is_none")]
192        frame_count_after: Option<u64>,
193        /// Byte size of the written frame's text payload.
194        bytes_written: usize,
195    },
196}
197
198impl EventKind {
199    /// Returns the wire `kind` discriminant for this event.
200    pub fn discriminant(&self) -> &'static str {
201        match self {
202            EventKind::PromptStarted { .. } => "prompt.started",
203            EventKind::PromptCompleted { .. } => "prompt.completed",
204            EventKind::ToolInvoked { .. } => "tool.invoked",
205            EventKind::ToolCompleted { .. } => "tool.completed",
206            EventKind::ToolSkipped { .. } => "tool.skipped",
207            EventKind::ToolTerminated { .. } => "tool.terminated",
208            EventKind::ContextSampled { .. } => "context.sampled",
209            EventKind::ContextCompacted { .. } => "context.compacted",
210            EventKind::MemoryDemoted { .. } => "memory.demoted",
211            EventKind::MemoryFrameWritten { .. } => "memory.frame_written",
212        }
213    }
214
215    /// Returns `true` if the event is part of the prompt lifecycle (`prompt.started`, `prompt.completed`).
216    pub fn is_prompt_related(&self) -> bool {
217        matches!(
218            self,
219            EventKind::PromptStarted { .. } | EventKind::PromptCompleted { .. }
220        )
221    }
222
223    /// Returns `true` if the event is part of the tool lifecycle (`tool.invoked`, `tool.completed`, `tool.skipped`, `tool.terminated`).
224    pub fn is_tool_related(&self) -> bool {
225        matches!(
226            self,
227            EventKind::ToolInvoked { .. }
228                | EventKind::ToolCompleted { .. }
229                | EventKind::ToolSkipped { .. }
230                | EventKind::ToolTerminated { .. }
231        )
232    }
233
234    /// Returns `true` if the event is related to memory and context management.
235    pub fn is_memory_related(&self) -> bool {
236        matches!(
237            self,
238            EventKind::ContextSampled { .. }
239                | EventKind::ContextCompacted { .. }
240                | EventKind::MemoryDemoted { .. }
241                | EventKind::MemoryFrameWritten { .. }
242        )
243    }
244
245    /// Extacts the stable `call_id` for tool events, if present.
246    pub fn tool_call_id(&self) -> Option<&str> {
247        match self {
248            EventKind::ToolInvoked { call_id, .. } => Some(call_id),
249            EventKind::ToolCompleted { call_id, .. } => Some(call_id),
250            EventKind::ToolSkipped { call_id, .. } => Some(call_id),
251            EventKind::ToolTerminated { call_id, .. } => Some(call_id),
252            _ => None,
253        }
254    }
255}
256
257/// Truncate a UTF-8 string to at most `max_bytes`, returning the (possibly
258/// truncated) string and a flag indicating whether truncation occurred.
259///
260/// Truncation always happens on a `char` boundary to keep the result valid
261/// UTF-8.
262pub fn truncate_utf8(input: &str, max_bytes: usize) -> (String, bool) {
263    if input.len() <= max_bytes {
264        return (input.to_string(), false);
265    }
266
267    let mut end = max_bytes;
268    while end > 0 && !input.is_char_boundary(end) {
269        end -= 1;
270    }
271
272    match input.get(..end) {
273        Some(slice) => (slice.to_string(), true),
274        None => (String::new(), true),
275    }
276}
277
278#[cfg(test)]
279#[allow(
280    clippy::unwrap_used,
281    clippy::panic,
282    clippy::indexing_slicing,
283    clippy::expect_used
284)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn envelope_serializes_flat() {
290        let event = ObservabilityEvent {
291            version: SCHEMA_VERSION,
292            occurred_at_millis: 1715000000000,
293            tick: 42,
294            conversation_id: "thread-1".into(),
295            kind: EventKind::PromptStarted {
296                model: "gpt-4o".into(),
297                messages_in: 3,
298            },
299        };
300
301        let json = serde_json::to_value(&event).unwrap();
302        assert_eq!(json["kind"], "prompt.started");
303        assert_eq!(json["model"], "gpt-4o");
304        assert_eq!(json["messages_in"], 3);
305        assert_eq!(json["tick"], 42);
306        assert_eq!(json["version"], SCHEMA_VERSION);
307
308        // Round-trip.
309        let parsed: ObservabilityEvent = serde_json::from_value(json).unwrap();
310        assert_eq!(parsed, event);
311    }
312
313    #[test]
314    fn truncate_at_char_boundary() {
315        let s = "café-α-β-γ-δ-ε-ζ-η-θ-ι-κ-λ-μ-ν-ξ-ο-π";
316        let (out, truncated) = truncate_utf8(s, 6);
317        assert!(truncated);
318        // Must remain valid UTF-8 — round-tripping through String guarantees this.
319        assert!(out.is_char_boundary(out.len()));
320        assert!(out.len() <= 6);
321    }
322
323    #[test]
324    fn truncate_no_op_when_short() {
325        let (out, truncated) = truncate_utf8("ok", 100);
326        assert!(!truncated);
327        assert_eq!(out, "ok");
328    }
329
330    #[test]
331    fn all_discriminants_round_trip() {
332        let kinds = [
333            EventKind::PromptStarted {
334                model: "m".into(),
335                messages_in: 1,
336            },
337            EventKind::PromptCompleted {
338                model: "m".into(),
339                tokens_in: Some(10),
340                tokens_out: Some(20),
341                response_id: Some("r".into()),
342            },
343            EventKind::ToolInvoked {
344                tool_name: "t".into(),
345                provider_call_id: None,
346                call_id: "c".into(),
347                args_json: "{}".into(),
348                truncated: false,
349            },
350            EventKind::ToolCompleted {
351                tool_name: "t".into(),
352                provider_call_id: None,
353                call_id: "c".into(),
354                result: "ok".into(),
355                truncated: false,
356            },
357            EventKind::ToolSkipped {
358                tool_name: "t".into(),
359                call_id: "c".into(),
360                reason: "policy".into(),
361            },
362            EventKind::ToolTerminated {
363                tool_name: "t".into(),
364                call_id: "c".into(),
365                reason: "abort".into(),
366            },
367            EventKind::ContextSampled {
368                message_count: 5,
369                byte_size: 1024,
370                token_estimate: None,
371            },
372            EventKind::ContextCompacted {
373                evicted_count: 3,
374                evicted_bytes: 200,
375                carry_over: false,
376                summary_bytes: 80,
377            },
378            EventKind::MemoryDemoted {
379                demoted_count: 2,
380                tags: vec!["t".into()],
381            },
382            EventKind::MemoryFrameWritten {
383                frame_kind: "summary".into(),
384                frame_count_after: Some(7),
385                bytes_written: 42,
386            },
387        ];
388
389        for kind in kinds {
390            let discriminant = kind.discriminant();
391            let evt = ObservabilityEvent::new("c", kind.clone());
392            let json = serde_json::to_value(&evt).unwrap();
393            assert_eq!(json["kind"], discriminant);
394            let back: ObservabilityEvent = serde_json::from_value(json).unwrap();
395            assert_eq!(back.kind, kind);
396        }
397    }
398}