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}