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    /// Numeric id of the `tracing::Span` that was current when this event
41    /// was emitted, when one exists. Mirrors
42    /// [`tracing::span::Id::into_u64`] so consumers using
43    /// `tracing-opentelemetry` (or any subscriber that attaches span ids to
44    /// events) can stitch `rig-tap` events into the existing span
45    /// waterfall without conversation-id post-processing. Absent (`None`)
46    /// when no span is active at emit time.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub span_id: Option<u64>,
49    /// Event-specific payload. Flattened into the parent object.
50    #[serde(flatten)]
51    pub kind: EventKind,
52}
53
54impl ObservabilityEvent {
55    /// Build a new envelope around `kind` using the current schema version.
56    /// Callers normally use [`crate::emit::emit`] which fills in `tick` and
57    /// `occurred_at_millis` automatically.
58    pub fn new(conversation_id: impl Into<String>, kind: EventKind) -> Self {
59        Self {
60            version: SCHEMA_VERSION,
61            occurred_at_millis: 0,
62            tick: 0,
63            conversation_id: conversation_id.into(),
64            span_id: None,
65            kind,
66        }
67    }
68}
69
70/// Per-variant scalar correlation fields surfaced as direct `tracing`
71/// attributes alongside the JSON event blob. See [`EventKind::scalar_fields`].
72///
73/// Absent fields are represented as `""` rather than `Option<&str>` because
74/// `tracing` 0.1's static-field model requires every field at the call site
75/// to satisfy `tracing::Value`, which is not implemented for `Option<T>`.
76///
77/// Marked `#[non_exhaustive]` so future schema-additive releases can append
78/// new scalar correlators without a breaking change. Build a value via
79/// [`Default::default`] and field-update syntax (`ScalarFields { tool_name,
80/// ..Default::default() }`) rather than the full struct literal.
81#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct ScalarFields<'a> {
84    /// `compose.*` event kernel identifier.
85    pub kernel_id: &'a str,
86    /// `tool.*` and `compose.retry_attempt` target/tool name.
87    pub tool_name: &'a str,
88    /// `tool.*` stable correlation identifier.
89    pub call_id: &'a str,
90    /// `compose.skill_resolved` / `compose.loop_iteration` skill identifier.
91    pub skill_id: &'a str,
92    /// `prompt.*` model identifier.
93    pub model: &'a str,
94    /// `prompt.completed` / `response.*` provider response identifier.
95    pub response_id: &'a str,
96    /// `prompt.completed` / `response.turn_*` chain ancestor — populated when
97    /// the producer is on a stateful endpoint such as OpenAI's Responses API
98    /// where the current turn was created with `previous_response_id`.
99    pub previous_response_id: &'a str,
100    /// `eval.report` dataset / qrels label.
101    pub dataset: &'a str,
102    /// `eval.report` metric name.
103    pub metric: &'a str,
104    /// `eval.report` regression-gate verdict.
105    pub verdict: &'a str,
106}
107
108/// Payload variants. Tagged on the wire as `"kind": "<dotted.name>"`.
109///
110/// New variants are additive; rename or remove is a breaking change requiring
111/// a bump of [`SCHEMA_VERSION`].
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113#[serde(tag = "kind")]
114#[non_exhaustive]
115pub enum EventKind {
116    /// A prompt is about to be sent to the model provider.
117    #[serde(rename = "prompt.started")]
118    PromptStarted {
119        /// Model name as declared on the agent.
120        model: String,
121        /// Number of messages in the history at the time of the call.
122        messages_in: usize,
123    },
124    /// A prompt finished; the model returned a completion response.
125    #[serde(rename = "prompt.completed")]
126    PromptCompleted {
127        /// Model name as reported by the provider response (may differ from
128        /// the requested model for routed providers).
129        model: String,
130        /// Provider-reported input tokens, if known.
131        #[serde(skip_serializing_if = "Option::is_none")]
132        tokens_in: Option<u64>,
133        /// Provider-reported output tokens, if known.
134        #[serde(skip_serializing_if = "Option::is_none")]
135        tokens_out: Option<u64>,
136        /// Provider response ID, if supplied.
137        #[serde(skip_serializing_if = "Option::is_none")]
138        response_id: Option<String>,
139        /// Server-side chain ancestor when the producer is on a stateful
140        /// endpoint (e.g. OpenAI's Responses API). `None` for one-shot
141        /// Chat Completions or the first turn of a chain. Populated by
142        /// [`crate::TelemetryHook::with_previous_response_id_resolver`] or
143        /// by producer crates emitting the kind directly.
144        #[serde(skip_serializing_if = "Option::is_none", default)]
145        previous_response_id: Option<String>,
146    },
147    /// A tool is about to be invoked.
148    #[serde(rename = "tool.invoked")]
149    ToolInvoked {
150        /// Tool name as registered on the agent.
151        tool_name: String,
152        /// Provider-supplied tool-call ID, when present.
153        #[serde(skip_serializing_if = "Option::is_none")]
154        provider_call_id: Option<String>,
155        /// Stable internal correlation ID (always present).
156        call_id: String,
157        /// JSON-encoded arguments (possibly truncated; see `truncated`).
158        args_json: String,
159        /// `true` if `args_json` was truncated to
160        /// [`PAYLOAD_TRUNCATE_BYTES`].
161        truncated: bool,
162    },
163    /// A tool finished executing.
164    #[serde(rename = "tool.completed")]
165    ToolCompleted {
166        /// Tool name (matches the paired `tool.invoked`).
167        tool_name: String,
168        /// Provider-supplied tool-call ID, when present.
169        #[serde(skip_serializing_if = "Option::is_none")]
170        provider_call_id: Option<String>,
171        /// Stable internal correlation ID (matches the paired `tool.invoked`).
172        call_id: String,
173        /// Tool result text (possibly truncated; see `truncated`).
174        result: String,
175        /// `true` if `result` was truncated to [`PAYLOAD_TRUNCATE_BYTES`].
176        truncated: bool,
177    },
178    /// A previously-`ToolInvoked` call was skipped by a gating hook before
179    /// the tool body ran. Pairs by `call_id` and closes the
180    /// `tool.invoked`/`tool.completed` gap that would otherwise leave the
181    /// invoke event orphaned.
182    #[serde(rename = "tool.skipped")]
183    ToolSkipped {
184        /// Tool name (matches the paired `tool.invoked`).
185        tool_name: String,
186        /// Stable internal correlation ID (matches the paired `tool.invoked`).
187        call_id: String,
188        /// Human-readable reason from the gate.
189        reason: String,
190    },
191    /// A previously-`ToolInvoked` call triggered a hook-driven termination
192    /// of the agent loop. Pairs by `call_id`.
193    #[serde(rename = "tool.terminated")]
194    ToolTerminated {
195        /// Tool name (matches the paired `tool.invoked`).
196        tool_name: String,
197        /// Stable internal correlation ID (matches the paired `tool.invoked`).
198        call_id: String,
199        /// Human-readable reason from the hook.
200        reason: String,
201    },
202    /// A provider-native hosted tool was invoked. Hosted tools (OpenAI
203    /// Responses `web_search` / `file_search` / `computer_use` /
204    /// `code_interpreter`, future Anthropic/Google equivalents) run inside
205    /// the provider's infrastructure rather than in the Rig agent loop, so
206    /// `PromptHook::on_tool_call` never fires for them. Producers wire this
207    /// variant from a streaming-chunk tap or session decorator.
208    #[serde(rename = "tool.hosted_invoked")]
209    ToolHostedInvoked {
210        /// Provider-native hosted tool name (e.g. `"web_search"`,
211        /// `"file_search"`, `"computer_use"`, `"code_interpreter"`).
212        tool_name: String,
213        /// Provider-supplied call ID for the hosted invocation, when
214        /// surfaced by the provider stream.
215        #[serde(skip_serializing_if = "Option::is_none")]
216        provider_call_id: Option<String>,
217        /// Stable correlation ID chosen by the producer so the matching
218        /// `tool.hosted_completed` can be paired.
219        call_id: String,
220        /// Provider response ID the hosted call belongs to, when known.
221        #[serde(skip_serializing_if = "Option::is_none")]
222        response_id: Option<String>,
223        /// JSON-encoded arguments visible to the producer (possibly
224        /// truncated; see `truncated`). May be empty for providers that
225        /// do not expose hosted-tool inputs in the stream.
226        args_json: String,
227        /// `true` if `args_json` was truncated to
228        /// [`PAYLOAD_TRUNCATE_BYTES`].
229        truncated: bool,
230    },
231    /// A provider-native hosted tool finished. Pairs with
232    /// [`EventKind::ToolHostedInvoked`] by `call_id`.
233    #[serde(rename = "tool.hosted_completed")]
234    ToolHostedCompleted {
235        /// Hosted tool name (matches the paired `tool.hosted_invoked`).
236        tool_name: String,
237        /// Provider-supplied call ID, when surfaced.
238        #[serde(skip_serializing_if = "Option::is_none")]
239        provider_call_id: Option<String>,
240        /// Stable correlation ID (matches the paired `tool.hosted_invoked`).
241        call_id: String,
242        /// Provider response ID the hosted call belongs to, when known.
243        #[serde(skip_serializing_if = "Option::is_none")]
244        response_id: Option<String>,
245        /// Provider-reported status (e.g. `"completed"`, `"failed"`),
246        /// when surfaced. Free-form string per provider.
247        #[serde(skip_serializing_if = "Option::is_none")]
248        status: Option<String>,
249        /// Hosted result text or JSON (possibly truncated). May be empty
250        /// for providers that do not surface hosted-tool outputs in the
251        /// stream beyond the status.
252        result: String,
253        /// `true` if `result` was truncated to [`PAYLOAD_TRUNCATE_BYTES`].
254        truncated: bool,
255    },
256    /// The active context was sampled (typically on `ConversationMemory::load`).
257    #[serde(rename = "context.sampled")]
258    ContextSampled {
259        /// Number of messages in the loaded history.
260        message_count: usize,
261        /// JSON byte size of the loaded history (rough size estimate).
262        byte_size: usize,
263        /// Optional token-count estimate. `None` in the default build; populated
264        /// by consumers that wire a tokenizer.
265        #[serde(skip_serializing_if = "Option::is_none")]
266        token_estimate: Option<u64>,
267    },
268    /// A compactor fired, replacing some evicted history with a summary
269    /// artifact.
270    #[serde(rename = "context.compacted")]
271    ContextCompacted {
272        /// Number of messages evicted from the active context.
273        evicted_count: usize,
274        /// Approximate byte size of the evicted messages.
275        evicted_bytes: usize,
276        /// `true` if the compactor produced a carry-over artifact for the
277        /// next compaction cycle.
278        carry_over: bool,
279        /// Byte size of the summary text written to long-term memory.
280        summary_bytes: usize,
281    },
282    /// A demotion hook moved messages to long-term storage.
283    #[serde(rename = "memory.demoted")]
284    MemoryDemoted {
285        /// Number of messages demoted.
286        demoted_count: usize,
287        /// Tags applied to the demoted frames.
288        tags: Vec<String>,
289    },
290    /// A frame was written to the long-term store.
291    #[serde(rename = "memory.frame_written")]
292    MemoryFrameWritten {
293        /// Frame kind as classified by the producer (e.g. `"summary"`,
294        /// `"demoted"`).
295        frame_kind: String,
296        /// Total frame count in the store after the write. `None` when the
297        /// producer does not expose a cheap cumulative count (e.g. memvid).
298        /// Consumers SHOULD NOT assume `0` means "empty store" — use this
299        /// `Option` and treat absence as "unknown".
300        #[serde(skip_serializing_if = "Option::is_none")]
301        frame_count_after: Option<u64>,
302        /// Byte size of the written frame's text payload.
303        bytes_written: usize,
304    },
305    /// A `rig-compose` kernel became active for a conversation.
306    #[serde(rename = "compose.kernel_start")]
307    ComposeKernelStart {
308        /// Stable kernel identifier chosen by the producer.
309        kernel_id: String,
310        /// Number of skills registered at startup, when known.
311        #[serde(skip_serializing_if = "Option::is_none")]
312        skills_registered: Option<usize>,
313        /// Number of tools registered at startup, when known.
314        #[serde(skip_serializing_if = "Option::is_none")]
315        tools_registered: Option<usize>,
316    },
317    /// A `rig-compose` kernel stopped processing.
318    #[serde(rename = "compose.kernel_shutdown")]
319    ComposeKernelShutdown {
320        /// Stable kernel identifier chosen by the producer.
321        kernel_id: String,
322        /// Producer-specific shutdown reason (e.g. `"normal"`, `"error"`).
323        reason: String,
324    },
325    /// One iteration of a `rig-compose` agent/kernel loop began.
326    #[serde(rename = "compose.loop_iteration")]
327    ComposeLoopIteration {
328        /// Stable kernel identifier chosen by the producer.
329        kernel_id: String,
330        /// Monotonic iteration counter inside the kernel.
331        iteration: u64,
332        /// Skill being considered or executed during this iteration.
333        #[serde(skip_serializing_if = "Option::is_none")]
334        skill_id: Option<String>,
335        /// Current confidence score, when exposed by the producer.
336        #[serde(skip_serializing_if = "Option::is_none")]
337        confidence: Option<f64>,
338    },
339    /// A `rig-compose` skill resolution completed.
340    #[serde(rename = "compose.skill_resolved")]
341    ComposeSkillResolved {
342        /// Stable kernel identifier chosen by the producer.
343        kernel_id: String,
344        /// Skill identifier.
345        skill_id: String,
346        /// Whether the skill applied to the current context.
347        applies: bool,
348        /// Confidence delta returned by the skill, when present.
349        #[serde(skip_serializing_if = "Option::is_none")]
350        delta: Option<f64>,
351        /// Post-application confidence score, when exposed by the producer.
352        /// For `applies = false` resolutions this is the unchanged context
353        /// confidence; for `applies = true` it reflects `confidence + delta`
354        /// clamped to `[0.0, 1.0]`.
355        #[serde(skip_serializing_if = "Option::is_none", default)]
356        confidence: Option<f64>,
357    },
358    /// A retry attempt occurred in a `rig-compose` dispatch or recovery path.
359    ///
360    /// `rig-tap` does not emit this variant itself: the
361    /// [`crate::DispatchObserveHook`] only observes the lifecycle hooks
362    /// surfaced by `rig-compose` and `rig-compose` does not currently expose
363    /// a per-tool retry hook. Producers with their own retry policy (custom
364    /// skills, transports, or higher-level orchestrators) should emit this
365    /// variant directly via [`crate::emit_kind`] so consumers receive a
366    /// consistent shape.
367    #[serde(rename = "compose.retry_attempt")]
368    ComposeRetryAttempt {
369        /// Stable kernel identifier chosen by the producer.
370        kernel_id: String,
371        /// Tool or operation being retried.
372        target: String,
373        /// One-based retry attempt number.
374        attempt: u64,
375        /// Retry classification chosen by the producer.
376        classification: String,
377    },
378    /// A `rig-compose` recovery path completed.
379    #[serde(rename = "compose.recovery")]
380    ComposeRecovery {
381        /// Stable kernel identifier chosen by the producer.
382        kernel_id: String,
383        /// Recovery reason or source error classification.
384        reason: String,
385        /// Whether the recovery path restored normal execution.
386        recovered: bool,
387    },
388    /// A stateful provider session opened. Producers wrap a long-lived
389    /// session (today: OpenAI Responses WebSocket) and emit this on connect.
390    #[serde(rename = "response.session_started")]
391    ResponseSessionStarted {
392        /// Model name as declared on the session.
393        model: String,
394        /// Producer-chosen session identifier. Stable for the lifetime of
395        /// the wrapped session; correlates every `response.turn_*` and
396        /// the final `response.session_ended`.
397        session_id: String,
398    },
399    /// A turn began inside a stateful provider session. Producers emit this
400    /// when the session enqueues a new server-side response.
401    #[serde(rename = "response.turn_started")]
402    ResponseTurnStarted {
403        /// Session identifier (matches the paired
404        /// `response.session_started`).
405        session_id: String,
406        /// Chain ancestor for this turn (`previous_response_id` sent to the
407        /// provider). `None` for the first turn of a session.
408        #[serde(skip_serializing_if = "Option::is_none")]
409        previous_response_id: Option<String>,
410    },
411    /// A turn finished inside a stateful provider session. Pairs with the
412    /// most recent `response.turn_started` by `session_id`.
413    #[serde(rename = "response.turn_completed")]
414    ResponseTurnCompleted {
415        /// Session identifier (matches the paired `response.turn_started`).
416        session_id: String,
417        /// Provider response identifier for this turn.
418        response_id: String,
419        /// Chain ancestor for this turn, when present.
420        #[serde(skip_serializing_if = "Option::is_none")]
421        previous_response_id: Option<String>,
422        /// Terminal provider status (`"completed"`, `"failed"`,
423        /// `"incomplete"`).
424        status: String,
425        /// Provider-reported input tokens, if known.
426        #[serde(skip_serializing_if = "Option::is_none")]
427        tokens_in: Option<u64>,
428        /// Provider-reported output tokens, if known.
429        #[serde(skip_serializing_if = "Option::is_none")]
430        tokens_out: Option<u64>,
431        /// Number of hosted-tool invocations observed during this turn.
432        /// Each hosted call is also emitted individually via
433        /// [`EventKind::ToolHostedInvoked`] / [`EventKind::ToolHostedCompleted`].
434        #[serde(skip_serializing_if = "crate::event::is_zero_usize", default)]
435        hosted_tool_calls: usize,
436    },
437    /// A stateful provider session closed. Producers emit this on the
438    /// underlying close handshake, on a provider `response.failed`, or on
439    /// any session-fatal transport error.
440    #[serde(rename = "response.session_ended")]
441    ResponseSessionEnded {
442        /// Session identifier (matches the paired `response.session_started`).
443        session_id: String,
444        /// Human-readable reason for the close. Free-form, producer-chosen
445        /// (e.g. `"client_close"`, `"response_failed"`,
446        /// `"transport_error"`).
447        reason: String,
448    },
449    /// One evaluation metric from a retrieval/RAG eval report. Producers
450    /// emit one event per `(report_id, dataset, metric)` triple so
451    /// consumers can filter and aggregate via the `rig_tap.*` scalars
452    /// without parsing the JSON envelope. Pairs naturally with the
453    /// `MultiReport` / `ReportDiff` summaries surfaced by
454    /// `rig-retrieval-evals`, but the variant is producer-agnostic: any
455    /// crate emitting metric verdicts on the same tracing target can
456    /// reuse it.
457    #[serde(rename = "eval.report")]
458    EvalReport {
459        /// Stable identifier for the report run (e.g. a commit SHA, a
460        /// harness invocation id, or a wall-clock-named run).
461        report_id: String,
462        /// Dataset / qrels label the metric was computed against
463        /// (e.g. `"beir/scifact"`, `"internal/v3"`).
464        dataset: String,
465        /// Metric name (e.g. `"ndcg@10"`, `"recall@100"`, `"mrr"`).
466        metric: String,
467        /// Point estimate for the metric.
468        value: f64,
469        /// Bootstrap confidence-interval lower bound, when computed.
470        #[serde(skip_serializing_if = "Option::is_none")]
471        ci_low: Option<f64>,
472        /// Bootstrap confidence-interval upper bound, when computed.
473        #[serde(skip_serializing_if = "Option::is_none")]
474        ci_high: Option<f64>,
475        /// Baseline value the report was compared against, when a
476        /// `ReportDiff` is being emitted.
477        #[serde(skip_serializing_if = "Option::is_none")]
478        baseline_value: Option<f64>,
479        /// Signed delta vs `baseline_value`, when a diff is being
480        /// emitted. Positive = improvement for higher-is-better metrics.
481        #[serde(skip_serializing_if = "Option::is_none")]
482        delta: Option<f64>,
483        /// Regression-gate verdict (e.g. `"improved"`, `"regressed"`,
484        /// `"neutral"`, `"flaky"`). Free-form so producers can carry
485        /// their own taxonomy.
486        #[serde(skip_serializing_if = "Option::is_none")]
487        verdict: Option<String>,
488        /// Number of underlying samples (queries, judgments, etc.) the
489        /// metric was computed over, when known.
490        #[serde(skip_serializing_if = "Option::is_none")]
491        sample_size: Option<u64>,
492    },
493}
494
495#[doc(hidden)]
496pub(crate) fn is_zero_usize(value: &usize) -> bool {
497    *value == 0
498}
499
500impl EventKind {
501    /// Returns the wire `kind` discriminant for this event.
502    pub fn discriminant(&self) -> &'static str {
503        match self {
504            EventKind::PromptStarted { .. } => "prompt.started",
505            EventKind::PromptCompleted { .. } => "prompt.completed",
506            EventKind::ToolInvoked { .. } => "tool.invoked",
507            EventKind::ToolCompleted { .. } => "tool.completed",
508            EventKind::ToolSkipped { .. } => "tool.skipped",
509            EventKind::ToolTerminated { .. } => "tool.terminated",
510            EventKind::ToolHostedInvoked { .. } => "tool.hosted_invoked",
511            EventKind::ToolHostedCompleted { .. } => "tool.hosted_completed",
512            EventKind::ContextSampled { .. } => "context.sampled",
513            EventKind::ContextCompacted { .. } => "context.compacted",
514            EventKind::MemoryDemoted { .. } => "memory.demoted",
515            EventKind::MemoryFrameWritten { .. } => "memory.frame_written",
516            EventKind::ComposeKernelStart { .. } => "compose.kernel_start",
517            EventKind::ComposeKernelShutdown { .. } => "compose.kernel_shutdown",
518            EventKind::ComposeLoopIteration { .. } => "compose.loop_iteration",
519            EventKind::ComposeSkillResolved { .. } => "compose.skill_resolved",
520            EventKind::ComposeRetryAttempt { .. } => "compose.retry_attempt",
521            EventKind::ComposeRecovery { .. } => "compose.recovery",
522            EventKind::ResponseSessionStarted { .. } => "response.session_started",
523            EventKind::ResponseTurnStarted { .. } => "response.turn_started",
524            EventKind::ResponseTurnCompleted { .. } => "response.turn_completed",
525            EventKind::ResponseSessionEnded { .. } => "response.session_ended",
526            EventKind::EvalReport { .. } => "eval.report",
527        }
528    }
529
530    /// Extract the per-variant scalar correlation fields that
531    /// [`crate::emit()`] surfaces directly on the `tracing` event so that
532    /// OpenTelemetry collectors and log indexers can route on them without
533    /// parsing the JSON `event` blob.
534    ///
535    /// Absent fields are returned as `""` rather than `Option<&str>`
536    /// because `tracing` 0.1's static-field model does not accept
537    /// `Option<&str>` as a `Value`. Consumers should filter
538    /// `rig_tap.<field> != ""` to detect presence.
539    pub fn scalar_fields(&self) -> ScalarFields<'_> {
540        let mut f = ScalarFields::default();
541        match self {
542            EventKind::PromptStarted { model, .. } => f.model = model,
543            EventKind::PromptCompleted {
544                model,
545                response_id,
546                previous_response_id,
547                ..
548            } => {
549                f.model = model;
550                if let Some(rid) = response_id {
551                    f.response_id = rid;
552                }
553                if let Some(pid) = previous_response_id {
554                    f.previous_response_id = pid;
555                }
556            }
557            EventKind::ToolInvoked {
558                tool_name, call_id, ..
559            }
560            | EventKind::ToolCompleted {
561                tool_name, call_id, ..
562            } => {
563                f.tool_name = tool_name;
564                f.call_id = call_id;
565            }
566            EventKind::ToolSkipped {
567                tool_name, call_id, ..
568            }
569            | EventKind::ToolTerminated {
570                tool_name, call_id, ..
571            } => {
572                f.tool_name = tool_name;
573                f.call_id = call_id;
574            }
575            EventKind::ToolHostedInvoked {
576                tool_name,
577                call_id,
578                response_id,
579                ..
580            }
581            | EventKind::ToolHostedCompleted {
582                tool_name,
583                call_id,
584                response_id,
585                ..
586            } => {
587                f.tool_name = tool_name;
588                f.call_id = call_id;
589                if let Some(rid) = response_id {
590                    f.response_id = rid;
591                }
592            }
593            EventKind::ComposeKernelStart { kernel_id, .. }
594            | EventKind::ComposeKernelShutdown { kernel_id, .. }
595            | EventKind::ComposeRecovery { kernel_id, .. } => {
596                f.kernel_id = kernel_id;
597            }
598            EventKind::ComposeLoopIteration {
599                kernel_id,
600                skill_id,
601                ..
602            } => {
603                f.kernel_id = kernel_id;
604                if let Some(s) = skill_id {
605                    f.skill_id = s;
606                }
607            }
608            EventKind::ComposeSkillResolved {
609                kernel_id,
610                skill_id,
611                ..
612            } => {
613                f.kernel_id = kernel_id;
614                f.skill_id = skill_id;
615            }
616            EventKind::ComposeRetryAttempt {
617                kernel_id, target, ..
618            } => {
619                f.kernel_id = kernel_id;
620                f.tool_name = target;
621            }
622            EventKind::ResponseSessionStarted { model, .. } => {
623                f.model = model;
624            }
625            EventKind::ResponseTurnStarted {
626                previous_response_id,
627                ..
628            } => {
629                if let Some(pid) = previous_response_id {
630                    f.previous_response_id = pid;
631                }
632            }
633            EventKind::ResponseTurnCompleted {
634                response_id,
635                previous_response_id,
636                ..
637            } => {
638                f.response_id = response_id;
639                if let Some(pid) = previous_response_id {
640                    f.previous_response_id = pid;
641                }
642            }
643            EventKind::ResponseSessionEnded { .. } => {}
644            EventKind::EvalReport {
645                dataset,
646                metric,
647                verdict,
648                ..
649            } => {
650                f.dataset = dataset;
651                f.metric = metric;
652                if let Some(v) = verdict {
653                    f.verdict = v;
654                }
655            }
656            EventKind::ContextSampled { .. }
657            | EventKind::ContextCompacted { .. }
658            | EventKind::MemoryDemoted { .. }
659            | EventKind::MemoryFrameWritten { .. } => {}
660        }
661        f
662    }
663
664    /// Returns `true` if the event is part of the prompt lifecycle (`prompt.started`, `prompt.completed`).
665    pub fn is_prompt_related(&self) -> bool {
666        matches!(
667            self,
668            EventKind::PromptStarted { .. } | EventKind::PromptCompleted { .. }
669        )
670    }
671
672    /// Returns `true` if the event is part of the tool lifecycle
673    /// (`tool.invoked`, `tool.completed`, `tool.skipped`, `tool.terminated`,
674    /// `tool.hosted_invoked`, `tool.hosted_completed`).
675    pub fn is_tool_related(&self) -> bool {
676        matches!(
677            self,
678            EventKind::ToolInvoked { .. }
679                | EventKind::ToolCompleted { .. }
680                | EventKind::ToolSkipped { .. }
681                | EventKind::ToolTerminated { .. }
682                | EventKind::ToolHostedInvoked { .. }
683                | EventKind::ToolHostedCompleted { .. }
684        )
685    }
686
687    /// Returns `true` if the event is part of the stateful response-session
688    /// lifecycle (`response.session_started`, `response.turn_started`,
689    /// `response.turn_completed`, `response.session_ended`).
690    pub fn is_response_lifecycle_related(&self) -> bool {
691        matches!(
692            self,
693            EventKind::ResponseSessionStarted { .. }
694                | EventKind::ResponseTurnStarted { .. }
695                | EventKind::ResponseTurnCompleted { .. }
696                | EventKind::ResponseSessionEnded { .. }
697        )
698    }
699
700    /// Returns `true` if the event is related to memory and context management.
701    pub fn is_memory_related(&self) -> bool {
702        matches!(
703            self,
704            EventKind::ContextSampled { .. }
705                | EventKind::ContextCompacted { .. }
706                | EventKind::MemoryDemoted { .. }
707                | EventKind::MemoryFrameWritten { .. }
708        )
709    }
710
711    /// Returns `true` if the event is related to a `rig-compose` kernel or agent loop.
712    pub fn is_compose_related(&self) -> bool {
713        matches!(
714            self,
715            EventKind::ComposeKernelStart { .. }
716                | EventKind::ComposeKernelShutdown { .. }
717                | EventKind::ComposeLoopIteration { .. }
718                | EventKind::ComposeSkillResolved { .. }
719                | EventKind::ComposeRetryAttempt { .. }
720                | EventKind::ComposeRecovery { .. }
721        )
722    }
723
724    /// Returns `true` if the event is an evaluation report metric
725    /// (`eval.report`).
726    pub fn is_eval_related(&self) -> bool {
727        matches!(self, EventKind::EvalReport { .. })
728    }
729
730    /// Extracts the stable `call_id` for tool events, if present.
731    pub fn tool_call_id(&self) -> Option<&str> {
732        match self {
733            EventKind::ToolInvoked { call_id, .. } => Some(call_id),
734            EventKind::ToolCompleted { call_id, .. } => Some(call_id),
735            EventKind::ToolSkipped { call_id, .. } => Some(call_id),
736            EventKind::ToolTerminated { call_id, .. } => Some(call_id),
737            EventKind::ToolHostedInvoked { call_id, .. } => Some(call_id),
738            EventKind::ToolHostedCompleted { call_id, .. } => Some(call_id),
739            _ => None,
740        }
741    }
742}
743
744/// Truncate a UTF-8 string to at most `max_bytes`, returning the (possibly
745/// truncated) string and a flag indicating whether truncation occurred.
746///
747/// Truncation always happens on a `char` boundary to keep the result valid
748/// UTF-8.
749pub fn truncate_utf8(input: &str, max_bytes: usize) -> (String, bool) {
750    if input.len() <= max_bytes {
751        return (input.to_string(), false);
752    }
753
754    let mut end = max_bytes;
755    while end > 0 && !input.is_char_boundary(end) {
756        end -= 1;
757    }
758
759    match input.get(..end) {
760        Some(slice) => (slice.to_string(), true),
761        None => (String::new(), true),
762    }
763}
764
765#[cfg(test)]
766#[allow(
767    clippy::unwrap_used,
768    clippy::panic,
769    clippy::indexing_slicing,
770    clippy::expect_used
771)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn envelope_serializes_flat() {
777        let event = ObservabilityEvent {
778            version: SCHEMA_VERSION,
779            occurred_at_millis: 1715000000000,
780            tick: 42,
781            conversation_id: "thread-1".into(),
782            span_id: None,
783            kind: EventKind::PromptStarted {
784                model: "gpt-4o".into(),
785                messages_in: 3,
786            },
787        };
788
789        let json = serde_json::to_value(&event).unwrap();
790        assert_eq!(json["kind"], "prompt.started");
791        assert_eq!(json["model"], "gpt-4o");
792        assert_eq!(json["messages_in"], 3);
793        assert_eq!(json["tick"], 42);
794        assert_eq!(json["version"], SCHEMA_VERSION);
795
796        // Round-trip.
797        let parsed: ObservabilityEvent = serde_json::from_value(json).unwrap();
798        assert_eq!(parsed, event);
799    }
800
801    #[test]
802    fn truncate_at_char_boundary() {
803        let s = "café-α-β-γ-δ-ε-ζ-η-θ-ι-κ-λ-μ-ν-ξ-ο-π";
804        let (out, truncated) = truncate_utf8(s, 6);
805        assert!(truncated);
806        // Must remain valid UTF-8 — round-tripping through String guarantees this.
807        assert!(out.is_char_boundary(out.len()));
808        assert!(out.len() <= 6);
809    }
810
811    #[test]
812    fn truncate_no_op_when_short() {
813        let (out, truncated) = truncate_utf8("ok", 100);
814        assert!(!truncated);
815        assert_eq!(out, "ok");
816    }
817
818    #[test]
819    fn truncate_boundary_drops_partial_multibyte_codepoint() {
820        let input = format!("{}é", "a".repeat(PAYLOAD_TRUNCATE_BYTES - 1));
821        let (out, truncated) = truncate_utf8(&input, PAYLOAD_TRUNCATE_BYTES);
822        assert!(truncated);
823        assert_eq!(out.len(), PAYLOAD_TRUNCATE_BYTES - 1);
824        assert!(out.ends_with('a'));
825        assert!(out.is_char_boundary(out.len()));
826    }
827
828    #[test]
829    fn all_discriminants_round_trip() {
830        let kinds = [
831            EventKind::PromptStarted {
832                model: "m".into(),
833                messages_in: 1,
834            },
835            EventKind::PromptCompleted {
836                model: "m".into(),
837                tokens_in: Some(10),
838                tokens_out: Some(20),
839                response_id: Some("r".into()),
840                previous_response_id: Some("r_prev".into()),
841            },
842            EventKind::ToolInvoked {
843                tool_name: "t".into(),
844                provider_call_id: None,
845                call_id: "c".into(),
846                args_json: "{}".into(),
847                truncated: false,
848            },
849            EventKind::ToolCompleted {
850                tool_name: "t".into(),
851                provider_call_id: None,
852                call_id: "c".into(),
853                result: "ok".into(),
854                truncated: false,
855            },
856            EventKind::ToolSkipped {
857                tool_name: "t".into(),
858                call_id: "c".into(),
859                reason: "policy".into(),
860            },
861            EventKind::ToolTerminated {
862                tool_name: "t".into(),
863                call_id: "c".into(),
864                reason: "abort".into(),
865            },
866            EventKind::ContextSampled {
867                message_count: 5,
868                byte_size: 1024,
869                token_estimate: None,
870            },
871            EventKind::ContextCompacted {
872                evicted_count: 3,
873                evicted_bytes: 200,
874                carry_over: false,
875                summary_bytes: 80,
876            },
877            EventKind::MemoryDemoted {
878                demoted_count: 2,
879                tags: vec!["t".into()],
880            },
881            EventKind::MemoryFrameWritten {
882                frame_kind: "summary".into(),
883                frame_count_after: Some(7),
884                bytes_written: 42,
885            },
886            EventKind::ComposeKernelStart {
887                kernel_id: "k".into(),
888                skills_registered: Some(2),
889                tools_registered: Some(3),
890            },
891            EventKind::ComposeKernelShutdown {
892                kernel_id: "k".into(),
893                reason: "normal".into(),
894            },
895            EventKind::ComposeLoopIteration {
896                kernel_id: "k".into(),
897                iteration: 1,
898                skill_id: Some("skill".into()),
899                confidence: Some(0.5),
900            },
901            EventKind::ComposeSkillResolved {
902                kernel_id: "k".into(),
903                skill_id: "skill".into(),
904                applies: true,
905                delta: Some(0.25),
906                confidence: Some(0.75),
907            },
908            EventKind::ComposeRetryAttempt {
909                kernel_id: "k".into(),
910                target: "tool".into(),
911                attempt: 2,
912                classification: "transient".into(),
913            },
914            EventKind::ComposeRecovery {
915                kernel_id: "k".into(),
916                reason: "retry_exhausted".into(),
917                recovered: false,
918            },
919            EventKind::ToolHostedInvoked {
920                tool_name: "web_search".into(),
921                provider_call_id: Some("call_abc".into()),
922                call_id: "hc".into(),
923                response_id: Some("resp_1".into()),
924                args_json: "{\"q\":\"x\"}".into(),
925                truncated: false,
926            },
927            EventKind::ToolHostedCompleted {
928                tool_name: "web_search".into(),
929                provider_call_id: Some("call_abc".into()),
930                call_id: "hc".into(),
931                response_id: Some("resp_1".into()),
932                status: Some("completed".into()),
933                result: "".into(),
934                truncated: false,
935            },
936            EventKind::ResponseSessionStarted {
937                model: "gpt-4o".into(),
938                session_id: "sess-1".into(),
939            },
940            EventKind::ResponseTurnStarted {
941                session_id: "sess-1".into(),
942                previous_response_id: Some("resp_0".into()),
943            },
944            EventKind::ResponseTurnCompleted {
945                session_id: "sess-1".into(),
946                response_id: "resp_1".into(),
947                previous_response_id: Some("resp_0".into()),
948                status: "completed".into(),
949                tokens_in: Some(10),
950                tokens_out: Some(20),
951                hosted_tool_calls: 2,
952            },
953            EventKind::ResponseSessionEnded {
954                session_id: "sess-1".into(),
955                reason: "client_close".into(),
956            },
957            EventKind::EvalReport {
958                report_id: "run-2026-05-27".into(),
959                dataset: "beir/scifact".into(),
960                metric: "ndcg@10".into(),
961                value: 0.512,
962                ci_low: Some(0.487),
963                ci_high: Some(0.538),
964                baseline_value: Some(0.498),
965                delta: Some(0.014),
966                verdict: Some("improved".into()),
967                sample_size: Some(300),
968            },
969        ];
970
971        for kind in kinds {
972            let discriminant = kind.discriminant();
973            let evt = ObservabilityEvent::new("c", kind.clone());
974            let json = serde_json::to_value(&evt).unwrap();
975            assert_eq!(json["kind"], discriminant);
976            let back: ObservabilityEvent = serde_json::from_value(json).unwrap();
977            assert_eq!(back.kind, kind);
978        }
979    }
980
981    #[test]
982    fn compose_events_are_classified() {
983        let event = EventKind::ComposeLoopIteration {
984            kernel_id: "kernel".into(),
985            iteration: 4,
986            skill_id: None,
987            confidence: None,
988        };
989
990        assert!(event.is_compose_related());
991        assert!(!event.is_prompt_related());
992        assert!(!event.is_tool_related());
993        assert!(!event.is_memory_related());
994    }
995
996    #[test]
997    fn hosted_tool_events_are_tool_related() {
998        let invoked = EventKind::ToolHostedInvoked {
999            tool_name: "web_search".into(),
1000            provider_call_id: None,
1001            call_id: "hc".into(),
1002            response_id: None,
1003            args_json: String::new(),
1004            truncated: false,
1005        };
1006        assert!(invoked.is_tool_related());
1007        assert!(!invoked.is_response_lifecycle_related());
1008        assert_eq!(invoked.tool_call_id(), Some("hc"));
1009    }
1010
1011    #[test]
1012    fn response_lifecycle_events_are_classified() {
1013        let started = EventKind::ResponseSessionStarted {
1014            model: "gpt-4o".into(),
1015            session_id: "sess-1".into(),
1016        };
1017        assert!(started.is_response_lifecycle_related());
1018        assert!(!started.is_tool_related());
1019        assert!(!started.is_prompt_related());
1020        assert!(!started.is_memory_related());
1021        assert!(!started.is_compose_related());
1022    }
1023
1024    #[test]
1025    fn turn_completed_surfaces_response_ids_as_scalars() {
1026        let evt = EventKind::ResponseTurnCompleted {
1027            session_id: "sess-1".into(),
1028            response_id: "resp_1".into(),
1029            previous_response_id: Some("resp_0".into()),
1030            status: "completed".into(),
1031            tokens_in: None,
1032            tokens_out: None,
1033            hosted_tool_calls: 0,
1034        };
1035        let fields = evt.scalar_fields();
1036        assert_eq!(fields.response_id, "resp_1");
1037        assert_eq!(fields.previous_response_id, "resp_0");
1038    }
1039
1040    #[test]
1041    fn prompt_completed_omits_previous_response_id_when_none() {
1042        let evt = ObservabilityEvent::new(
1043            "c",
1044            EventKind::PromptCompleted {
1045                model: "m".into(),
1046                tokens_in: None,
1047                tokens_out: None,
1048                response_id: None,
1049                previous_response_id: None,
1050            },
1051        );
1052        let json = serde_json::to_value(&evt).unwrap();
1053        assert!(json.get("previous_response_id").is_none());
1054        assert!(json.get("response_id").is_none());
1055    }
1056
1057    #[test]
1058    fn turn_completed_omits_zero_hosted_tool_calls() {
1059        let evt = ObservabilityEvent::new(
1060            "c",
1061            EventKind::ResponseTurnCompleted {
1062                session_id: "sess-1".into(),
1063                response_id: "resp_1".into(),
1064                previous_response_id: None,
1065                status: "completed".into(),
1066                tokens_in: None,
1067                tokens_out: None,
1068                hosted_tool_calls: 0,
1069            },
1070        );
1071        let json = serde_json::to_value(&evt).unwrap();
1072        assert!(json.get("hosted_tool_calls").is_none());
1073    }
1074
1075    #[test]
1076    fn prompt_completed_round_trips_without_previous_response_id() {
1077        // Schema-evolution guard: events emitted by v0.1.x producers will not
1078        // include `previous_response_id`. Ensure the new v0.1.3 reader still
1079        // accepts the old shape.
1080        let legacy = serde_json::json!({
1081            "version": SCHEMA_VERSION,
1082            "occurred_at_millis": 0_u64,
1083            "tick": 0_u64,
1084            "conversation_id": "c",
1085            "kind": "prompt.completed",
1086            "model": "m",
1087        });
1088        let parsed: ObservabilityEvent = serde_json::from_value(legacy).unwrap();
1089        match parsed.kind {
1090            EventKind::PromptCompleted {
1091                previous_response_id,
1092                response_id,
1093                ..
1094            } => {
1095                assert!(previous_response_id.is_none());
1096                assert!(response_id.is_none());
1097            }
1098            other => panic!("unexpected kind: {other:?}"),
1099        }
1100    }
1101
1102    #[test]
1103    fn eval_report_surfaces_scalars_and_classifies() {
1104        let evt = EventKind::EvalReport {
1105            report_id: "run-1".into(),
1106            dataset: "beir/scifact".into(),
1107            metric: "ndcg@10".into(),
1108            value: 0.5,
1109            ci_low: Some(0.48),
1110            ci_high: Some(0.52),
1111            baseline_value: Some(0.49),
1112            delta: Some(0.01),
1113            verdict: Some("improved".into()),
1114            sample_size: Some(300),
1115        };
1116        assert!(evt.is_eval_related());
1117        assert!(!evt.is_prompt_related());
1118        assert!(!evt.is_tool_related());
1119        assert!(!evt.is_memory_related());
1120        assert!(!evt.is_compose_related());
1121        assert!(!evt.is_response_lifecycle_related());
1122
1123        let fields = evt.scalar_fields();
1124        assert_eq!(fields.dataset, "beir/scifact");
1125        assert_eq!(fields.metric, "ndcg@10");
1126        assert_eq!(fields.verdict, "improved");
1127    }
1128
1129    #[test]
1130    fn eval_report_omits_optional_fields_when_none() {
1131        let evt = ObservabilityEvent::new(
1132            "c",
1133            EventKind::EvalReport {
1134                report_id: "run-1".into(),
1135                dataset: "beir/scifact".into(),
1136                metric: "recall@100".into(),
1137                value: 0.91,
1138                ci_low: None,
1139                ci_high: None,
1140                baseline_value: None,
1141                delta: None,
1142                verdict: None,
1143                sample_size: None,
1144            },
1145        );
1146        let json = serde_json::to_value(&evt).unwrap();
1147        assert_eq!(json["kind"], "eval.report");
1148        assert!(json.get("ci_low").is_none());
1149        assert!(json.get("ci_high").is_none());
1150        assert!(json.get("baseline_value").is_none());
1151        assert!(json.get("delta").is_none());
1152        assert!(json.get("verdict").is_none());
1153        assert!(json.get("sample_size").is_none());
1154    }
1155}