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