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/// 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}