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/// Per-variant scalar correlation fields surfaced as direct `tracing`
61/// attributes alongside the JSON event blob. See [`EventKind::scalar_fields`].
62///
63/// Absent fields are represented as `""` rather than `Option<&str>` because
64/// `tracing` 0.1's static-field model requires every field at the call site
65/// to satisfy `tracing::Value`, which is not implemented for `Option<T>`.
66#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
67pub struct ScalarFields<'a> {
68 /// `compose.*` event kernel identifier.
69 pub kernel_id: &'a str,
70 /// `tool.*` and `compose.retry_attempt` target/tool name.
71 pub tool_name: &'a str,
72 /// `tool.*` stable correlation identifier.
73 pub call_id: &'a str,
74 /// `compose.skill_resolved` / `compose.loop_iteration` skill identifier.
75 pub skill_id: &'a str,
76 /// `prompt.*` model identifier.
77 pub model: &'a str,
78}
79
80/// Payload variants. Tagged on the wire as `"kind": "<dotted.name>"`.
81///
82/// New variants are additive; rename or remove is a breaking change requiring
83/// a bump of [`SCHEMA_VERSION`].
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(tag = "kind")]
86#[non_exhaustive]
87pub enum EventKind {
88 /// A prompt is about to be sent to the model provider.
89 #[serde(rename = "prompt.started")]
90 PromptStarted {
91 /// Model name as declared on the agent.
92 model: String,
93 /// Number of messages in the history at the time of the call.
94 messages_in: usize,
95 },
96 /// A prompt finished; the model returned a completion response.
97 #[serde(rename = "prompt.completed")]
98 PromptCompleted {
99 /// Model name as reported by the provider response (may differ from
100 /// the requested model for routed providers).
101 model: String,
102 /// Provider-reported input tokens, if known.
103 #[serde(skip_serializing_if = "Option::is_none")]
104 tokens_in: Option<u64>,
105 /// Provider-reported output tokens, if known.
106 #[serde(skip_serializing_if = "Option::is_none")]
107 tokens_out: Option<u64>,
108 /// Provider response ID, if supplied.
109 #[serde(skip_serializing_if = "Option::is_none")]
110 response_id: Option<String>,
111 },
112 /// A tool is about to be invoked.
113 #[serde(rename = "tool.invoked")]
114 ToolInvoked {
115 /// Tool name as registered on the agent.
116 tool_name: String,
117 /// Provider-supplied tool-call ID, when present.
118 #[serde(skip_serializing_if = "Option::is_none")]
119 provider_call_id: Option<String>,
120 /// Stable internal correlation ID (always present).
121 call_id: String,
122 /// JSON-encoded arguments (possibly truncated; see `truncated`).
123 args_json: String,
124 /// `true` if `args_json` was truncated to
125 /// [`PAYLOAD_TRUNCATE_BYTES`].
126 truncated: bool,
127 },
128 /// A tool finished executing.
129 #[serde(rename = "tool.completed")]
130 ToolCompleted {
131 /// Tool name (matches the paired `tool.invoked`).
132 tool_name: String,
133 /// Provider-supplied tool-call ID, when present.
134 #[serde(skip_serializing_if = "Option::is_none")]
135 provider_call_id: Option<String>,
136 /// Stable internal correlation ID (matches the paired `tool.invoked`).
137 call_id: String,
138 /// Tool result text (possibly truncated; see `truncated`).
139 result: String,
140 /// `true` if `result` was truncated to [`PAYLOAD_TRUNCATE_BYTES`].
141 truncated: bool,
142 },
143 /// A previously-`ToolInvoked` call was skipped by a gating hook before
144 /// the tool body ran. Pairs by `call_id` and closes the
145 /// `tool.invoked`/`tool.completed` gap that would otherwise leave the
146 /// invoke event orphaned.
147 #[serde(rename = "tool.skipped")]
148 ToolSkipped {
149 /// Tool name (matches the paired `tool.invoked`).
150 tool_name: String,
151 /// Stable internal correlation ID (matches the paired `tool.invoked`).
152 call_id: String,
153 /// Human-readable reason from the gate.
154 reason: String,
155 },
156 /// A previously-`ToolInvoked` call triggered a hook-driven termination
157 /// of the agent loop. Pairs by `call_id`.
158 #[serde(rename = "tool.terminated")]
159 ToolTerminated {
160 /// Tool name (matches the paired `tool.invoked`).
161 tool_name: String,
162 /// Stable internal correlation ID (matches the paired `tool.invoked`).
163 call_id: String,
164 /// Human-readable reason from the hook.
165 reason: String,
166 },
167 /// The active context was sampled (typically on `ConversationMemory::load`).
168 #[serde(rename = "context.sampled")]
169 ContextSampled {
170 /// Number of messages in the loaded history.
171 message_count: usize,
172 /// JSON byte size of the loaded history (rough size estimate).
173 byte_size: usize,
174 /// Optional token-count estimate. `None` in the default build; populated
175 /// by consumers that wire a tokenizer.
176 #[serde(skip_serializing_if = "Option::is_none")]
177 token_estimate: Option<u64>,
178 },
179 /// A compactor fired, replacing some evicted history with a summary
180 /// artifact.
181 #[serde(rename = "context.compacted")]
182 ContextCompacted {
183 /// Number of messages evicted from the active context.
184 evicted_count: usize,
185 /// Approximate byte size of the evicted messages.
186 evicted_bytes: usize,
187 /// `true` if the compactor produced a carry-over artifact for the
188 /// next compaction cycle.
189 carry_over: bool,
190 /// Byte size of the summary text written to long-term memory.
191 summary_bytes: usize,
192 },
193 /// A demotion hook moved messages to long-term storage.
194 #[serde(rename = "memory.demoted")]
195 MemoryDemoted {
196 /// Number of messages demoted.
197 demoted_count: usize,
198 /// Tags applied to the demoted frames.
199 tags: Vec<String>,
200 },
201 /// A frame was written to the long-term store.
202 #[serde(rename = "memory.frame_written")]
203 MemoryFrameWritten {
204 /// Frame kind as classified by the producer (e.g. `"summary"`,
205 /// `"demoted"`).
206 frame_kind: String,
207 /// Total frame count in the store after the write. `None` when the
208 /// producer does not expose a cheap cumulative count (e.g. memvid).
209 /// Consumers SHOULD NOT assume `0` means "empty store" — use this
210 /// `Option` and treat absence as "unknown".
211 #[serde(skip_serializing_if = "Option::is_none")]
212 frame_count_after: Option<u64>,
213 /// Byte size of the written frame's text payload.
214 bytes_written: usize,
215 },
216 /// A `rig-compose` kernel became active for a conversation.
217 #[serde(rename = "compose.kernel_start")]
218 ComposeKernelStart {
219 /// Stable kernel identifier chosen by the producer.
220 kernel_id: String,
221 /// Number of skills registered at startup, when known.
222 #[serde(skip_serializing_if = "Option::is_none")]
223 skills_registered: Option<usize>,
224 /// Number of tools registered at startup, when known.
225 #[serde(skip_serializing_if = "Option::is_none")]
226 tools_registered: Option<usize>,
227 },
228 /// A `rig-compose` kernel stopped processing.
229 #[serde(rename = "compose.kernel_shutdown")]
230 ComposeKernelShutdown {
231 /// Stable kernel identifier chosen by the producer.
232 kernel_id: String,
233 /// Producer-specific shutdown reason (e.g. `"normal"`, `"error"`).
234 reason: String,
235 },
236 /// One iteration of a `rig-compose` agent/kernel loop began.
237 #[serde(rename = "compose.loop_iteration")]
238 ComposeLoopIteration {
239 /// Stable kernel identifier chosen by the producer.
240 kernel_id: String,
241 /// Monotonic iteration counter inside the kernel.
242 iteration: u64,
243 /// Skill being considered or executed during this iteration.
244 #[serde(skip_serializing_if = "Option::is_none")]
245 skill_id: Option<String>,
246 /// Current confidence score, when exposed by the producer.
247 #[serde(skip_serializing_if = "Option::is_none")]
248 confidence: Option<f64>,
249 },
250 /// A `rig-compose` skill resolution completed.
251 #[serde(rename = "compose.skill_resolved")]
252 ComposeSkillResolved {
253 /// Stable kernel identifier chosen by the producer.
254 kernel_id: String,
255 /// Skill identifier.
256 skill_id: String,
257 /// Whether the skill applied to the current context.
258 applies: bool,
259 /// Confidence delta returned by the skill, when present.
260 #[serde(skip_serializing_if = "Option::is_none")]
261 delta: Option<f64>,
262 /// Post-application confidence score, when exposed by the producer.
263 /// For `applies = false` resolutions this is the unchanged context
264 /// confidence; for `applies = true` it reflects `confidence + delta`
265 /// clamped to `[0.0, 1.0]`.
266 #[serde(skip_serializing_if = "Option::is_none", default)]
267 confidence: Option<f64>,
268 },
269 /// A retry attempt occurred in a `rig-compose` dispatch or recovery path.
270 ///
271 /// `rig-tap` does not emit this variant itself: the
272 /// [`crate::DispatchObserveHook`] only observes the lifecycle hooks
273 /// surfaced by `rig-compose` and `rig-compose` does not currently expose
274 /// a per-tool retry hook. Producers with their own retry policy (custom
275 /// skills, transports, or higher-level orchestrators) should emit this
276 /// variant directly via [`crate::emit_kind`] so consumers receive a
277 /// consistent shape.
278 #[serde(rename = "compose.retry_attempt")]
279 ComposeRetryAttempt {
280 /// Stable kernel identifier chosen by the producer.
281 kernel_id: String,
282 /// Tool or operation being retried.
283 target: String,
284 /// One-based retry attempt number.
285 attempt: u64,
286 /// Retry classification chosen by the producer.
287 classification: String,
288 },
289 /// A `rig-compose` recovery path completed.
290 #[serde(rename = "compose.recovery")]
291 ComposeRecovery {
292 /// Stable kernel identifier chosen by the producer.
293 kernel_id: String,
294 /// Recovery reason or source error classification.
295 reason: String,
296 /// Whether the recovery path restored normal execution.
297 recovered: bool,
298 },
299}
300
301impl EventKind {
302 /// Returns the wire `kind` discriminant for this event.
303 pub fn discriminant(&self) -> &'static str {
304 match self {
305 EventKind::PromptStarted { .. } => "prompt.started",
306 EventKind::PromptCompleted { .. } => "prompt.completed",
307 EventKind::ToolInvoked { .. } => "tool.invoked",
308 EventKind::ToolCompleted { .. } => "tool.completed",
309 EventKind::ToolSkipped { .. } => "tool.skipped",
310 EventKind::ToolTerminated { .. } => "tool.terminated",
311 EventKind::ContextSampled { .. } => "context.sampled",
312 EventKind::ContextCompacted { .. } => "context.compacted",
313 EventKind::MemoryDemoted { .. } => "memory.demoted",
314 EventKind::MemoryFrameWritten { .. } => "memory.frame_written",
315 EventKind::ComposeKernelStart { .. } => "compose.kernel_start",
316 EventKind::ComposeKernelShutdown { .. } => "compose.kernel_shutdown",
317 EventKind::ComposeLoopIteration { .. } => "compose.loop_iteration",
318 EventKind::ComposeSkillResolved { .. } => "compose.skill_resolved",
319 EventKind::ComposeRetryAttempt { .. } => "compose.retry_attempt",
320 EventKind::ComposeRecovery { .. } => "compose.recovery",
321 }
322 }
323
324 /// Extract the per-variant scalar correlation fields that
325 /// [`crate::emit()`] surfaces directly on the `tracing` event so that
326 /// OpenTelemetry collectors and log indexers can route on them without
327 /// parsing the JSON `event` blob.
328 ///
329 /// Absent fields are returned as `""` rather than `Option<&str>`
330 /// because `tracing` 0.1's static-field model does not accept
331 /// `Option<&str>` as a `Value`. Consumers should filter
332 /// `rig_tap.<field> != ""` to detect presence.
333 pub fn scalar_fields(&self) -> ScalarFields<'_> {
334 let mut f = ScalarFields::default();
335 match self {
336 EventKind::PromptStarted { model, .. } => f.model = model,
337 EventKind::PromptCompleted { model, .. } => f.model = model,
338 EventKind::ToolInvoked {
339 tool_name, call_id, ..
340 }
341 | EventKind::ToolCompleted {
342 tool_name, call_id, ..
343 } => {
344 f.tool_name = tool_name;
345 f.call_id = call_id;
346 }
347 EventKind::ToolSkipped {
348 tool_name, call_id, ..
349 }
350 | EventKind::ToolTerminated {
351 tool_name, call_id, ..
352 } => {
353 f.tool_name = tool_name;
354 f.call_id = call_id;
355 }
356 EventKind::ComposeKernelStart { kernel_id, .. }
357 | EventKind::ComposeKernelShutdown { kernel_id, .. }
358 | EventKind::ComposeRecovery { kernel_id, .. } => {
359 f.kernel_id = kernel_id;
360 }
361 EventKind::ComposeLoopIteration {
362 kernel_id,
363 skill_id,
364 ..
365 } => {
366 f.kernel_id = kernel_id;
367 if let Some(s) = skill_id {
368 f.skill_id = s;
369 }
370 }
371 EventKind::ComposeSkillResolved {
372 kernel_id,
373 skill_id,
374 ..
375 } => {
376 f.kernel_id = kernel_id;
377 f.skill_id = skill_id;
378 }
379 EventKind::ComposeRetryAttempt {
380 kernel_id, target, ..
381 } => {
382 f.kernel_id = kernel_id;
383 f.tool_name = target;
384 }
385 EventKind::ContextSampled { .. }
386 | EventKind::ContextCompacted { .. }
387 | EventKind::MemoryDemoted { .. }
388 | EventKind::MemoryFrameWritten { .. } => {}
389 }
390 f
391 }
392
393 /// Returns `true` if the event is part of the prompt lifecycle (`prompt.started`, `prompt.completed`).
394 pub fn is_prompt_related(&self) -> bool {
395 matches!(
396 self,
397 EventKind::PromptStarted { .. } | EventKind::PromptCompleted { .. }
398 )
399 }
400
401 /// Returns `true` if the event is part of the tool lifecycle (`tool.invoked`, `tool.completed`, `tool.skipped`, `tool.terminated`).
402 pub fn is_tool_related(&self) -> bool {
403 matches!(
404 self,
405 EventKind::ToolInvoked { .. }
406 | EventKind::ToolCompleted { .. }
407 | EventKind::ToolSkipped { .. }
408 | EventKind::ToolTerminated { .. }
409 )
410 }
411
412 /// Returns `true` if the event is related to memory and context management.
413 pub fn is_memory_related(&self) -> bool {
414 matches!(
415 self,
416 EventKind::ContextSampled { .. }
417 | EventKind::ContextCompacted { .. }
418 | EventKind::MemoryDemoted { .. }
419 | EventKind::MemoryFrameWritten { .. }
420 )
421 }
422
423 /// Returns `true` if the event is related to a `rig-compose` kernel or agent loop.
424 pub fn is_compose_related(&self) -> bool {
425 matches!(
426 self,
427 EventKind::ComposeKernelStart { .. }
428 | EventKind::ComposeKernelShutdown { .. }
429 | EventKind::ComposeLoopIteration { .. }
430 | EventKind::ComposeSkillResolved { .. }
431 | EventKind::ComposeRetryAttempt { .. }
432 | EventKind::ComposeRecovery { .. }
433 )
434 }
435
436 /// Extacts the stable `call_id` for tool events, if present.
437 pub fn tool_call_id(&self) -> Option<&str> {
438 match self {
439 EventKind::ToolInvoked { call_id, .. } => Some(call_id),
440 EventKind::ToolCompleted { call_id, .. } => Some(call_id),
441 EventKind::ToolSkipped { call_id, .. } => Some(call_id),
442 EventKind::ToolTerminated { call_id, .. } => Some(call_id),
443 _ => None,
444 }
445 }
446}
447
448/// Truncate a UTF-8 string to at most `max_bytes`, returning the (possibly
449/// truncated) string and a flag indicating whether truncation occurred.
450///
451/// Truncation always happens on a `char` boundary to keep the result valid
452/// UTF-8.
453pub fn truncate_utf8(input: &str, max_bytes: usize) -> (String, bool) {
454 if input.len() <= max_bytes {
455 return (input.to_string(), false);
456 }
457
458 let mut end = max_bytes;
459 while end > 0 && !input.is_char_boundary(end) {
460 end -= 1;
461 }
462
463 match input.get(..end) {
464 Some(slice) => (slice.to_string(), true),
465 None => (String::new(), true),
466 }
467}
468
469#[cfg(test)]
470#[allow(
471 clippy::unwrap_used,
472 clippy::panic,
473 clippy::indexing_slicing,
474 clippy::expect_used
475)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn envelope_serializes_flat() {
481 let event = ObservabilityEvent {
482 version: SCHEMA_VERSION,
483 occurred_at_millis: 1715000000000,
484 tick: 42,
485 conversation_id: "thread-1".into(),
486 kind: EventKind::PromptStarted {
487 model: "gpt-4o".into(),
488 messages_in: 3,
489 },
490 };
491
492 let json = serde_json::to_value(&event).unwrap();
493 assert_eq!(json["kind"], "prompt.started");
494 assert_eq!(json["model"], "gpt-4o");
495 assert_eq!(json["messages_in"], 3);
496 assert_eq!(json["tick"], 42);
497 assert_eq!(json["version"], SCHEMA_VERSION);
498
499 // Round-trip.
500 let parsed: ObservabilityEvent = serde_json::from_value(json).unwrap();
501 assert_eq!(parsed, event);
502 }
503
504 #[test]
505 fn truncate_at_char_boundary() {
506 let s = "café-α-β-γ-δ-ε-ζ-η-θ-ι-κ-λ-μ-ν-ξ-ο-π";
507 let (out, truncated) = truncate_utf8(s, 6);
508 assert!(truncated);
509 // Must remain valid UTF-8 — round-tripping through String guarantees this.
510 assert!(out.is_char_boundary(out.len()));
511 assert!(out.len() <= 6);
512 }
513
514 #[test]
515 fn truncate_no_op_when_short() {
516 let (out, truncated) = truncate_utf8("ok", 100);
517 assert!(!truncated);
518 assert_eq!(out, "ok");
519 }
520
521 #[test]
522 fn all_discriminants_round_trip() {
523 let kinds = [
524 EventKind::PromptStarted {
525 model: "m".into(),
526 messages_in: 1,
527 },
528 EventKind::PromptCompleted {
529 model: "m".into(),
530 tokens_in: Some(10),
531 tokens_out: Some(20),
532 response_id: Some("r".into()),
533 },
534 EventKind::ToolInvoked {
535 tool_name: "t".into(),
536 provider_call_id: None,
537 call_id: "c".into(),
538 args_json: "{}".into(),
539 truncated: false,
540 },
541 EventKind::ToolCompleted {
542 tool_name: "t".into(),
543 provider_call_id: None,
544 call_id: "c".into(),
545 result: "ok".into(),
546 truncated: false,
547 },
548 EventKind::ToolSkipped {
549 tool_name: "t".into(),
550 call_id: "c".into(),
551 reason: "policy".into(),
552 },
553 EventKind::ToolTerminated {
554 tool_name: "t".into(),
555 call_id: "c".into(),
556 reason: "abort".into(),
557 },
558 EventKind::ContextSampled {
559 message_count: 5,
560 byte_size: 1024,
561 token_estimate: None,
562 },
563 EventKind::ContextCompacted {
564 evicted_count: 3,
565 evicted_bytes: 200,
566 carry_over: false,
567 summary_bytes: 80,
568 },
569 EventKind::MemoryDemoted {
570 demoted_count: 2,
571 tags: vec!["t".into()],
572 },
573 EventKind::MemoryFrameWritten {
574 frame_kind: "summary".into(),
575 frame_count_after: Some(7),
576 bytes_written: 42,
577 },
578 EventKind::ComposeKernelStart {
579 kernel_id: "k".into(),
580 skills_registered: Some(2),
581 tools_registered: Some(3),
582 },
583 EventKind::ComposeKernelShutdown {
584 kernel_id: "k".into(),
585 reason: "normal".into(),
586 },
587 EventKind::ComposeLoopIteration {
588 kernel_id: "k".into(),
589 iteration: 1,
590 skill_id: Some("skill".into()),
591 confidence: Some(0.5),
592 },
593 EventKind::ComposeSkillResolved {
594 kernel_id: "k".into(),
595 skill_id: "skill".into(),
596 applies: true,
597 delta: Some(0.25),
598 confidence: Some(0.75),
599 },
600 EventKind::ComposeRetryAttempt {
601 kernel_id: "k".into(),
602 target: "tool".into(),
603 attempt: 2,
604 classification: "transient".into(),
605 },
606 EventKind::ComposeRecovery {
607 kernel_id: "k".into(),
608 reason: "retry_exhausted".into(),
609 recovered: false,
610 },
611 ];
612
613 for kind in kinds {
614 let discriminant = kind.discriminant();
615 let evt = ObservabilityEvent::new("c", kind.clone());
616 let json = serde_json::to_value(&evt).unwrap();
617 assert_eq!(json["kind"], discriminant);
618 let back: ObservabilityEvent = serde_json::from_value(json).unwrap();
619 assert_eq!(back.kind, kind);
620 }
621 }
622
623 #[test]
624 fn compose_events_are_classified() {
625 let event = EventKind::ComposeLoopIteration {
626 kernel_id: "kernel".into(),
627 iteration: 4,
628 skill_id: None,
629 confidence: None,
630 };
631
632 assert!(event.is_compose_related());
633 assert!(!event.is_prompt_related());
634 assert!(!event.is_tool_related());
635 assert!(!event.is_memory_related());
636 }
637}