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 all_discriminants_round_trip() {
820 let kinds = [
821 EventKind::PromptStarted {
822 model: "m".into(),
823 messages_in: 1,
824 },
825 EventKind::PromptCompleted {
826 model: "m".into(),
827 tokens_in: Some(10),
828 tokens_out: Some(20),
829 response_id: Some("r".into()),
830 previous_response_id: Some("r_prev".into()),
831 },
832 EventKind::ToolInvoked {
833 tool_name: "t".into(),
834 provider_call_id: None,
835 call_id: "c".into(),
836 args_json: "{}".into(),
837 truncated: false,
838 },
839 EventKind::ToolCompleted {
840 tool_name: "t".into(),
841 provider_call_id: None,
842 call_id: "c".into(),
843 result: "ok".into(),
844 truncated: false,
845 },
846 EventKind::ToolSkipped {
847 tool_name: "t".into(),
848 call_id: "c".into(),
849 reason: "policy".into(),
850 },
851 EventKind::ToolTerminated {
852 tool_name: "t".into(),
853 call_id: "c".into(),
854 reason: "abort".into(),
855 },
856 EventKind::ContextSampled {
857 message_count: 5,
858 byte_size: 1024,
859 token_estimate: None,
860 },
861 EventKind::ContextCompacted {
862 evicted_count: 3,
863 evicted_bytes: 200,
864 carry_over: false,
865 summary_bytes: 80,
866 },
867 EventKind::MemoryDemoted {
868 demoted_count: 2,
869 tags: vec!["t".into()],
870 },
871 EventKind::MemoryFrameWritten {
872 frame_kind: "summary".into(),
873 frame_count_after: Some(7),
874 bytes_written: 42,
875 },
876 EventKind::ComposeKernelStart {
877 kernel_id: "k".into(),
878 skills_registered: Some(2),
879 tools_registered: Some(3),
880 },
881 EventKind::ComposeKernelShutdown {
882 kernel_id: "k".into(),
883 reason: "normal".into(),
884 },
885 EventKind::ComposeLoopIteration {
886 kernel_id: "k".into(),
887 iteration: 1,
888 skill_id: Some("skill".into()),
889 confidence: Some(0.5),
890 },
891 EventKind::ComposeSkillResolved {
892 kernel_id: "k".into(),
893 skill_id: "skill".into(),
894 applies: true,
895 delta: Some(0.25),
896 confidence: Some(0.75),
897 },
898 EventKind::ComposeRetryAttempt {
899 kernel_id: "k".into(),
900 target: "tool".into(),
901 attempt: 2,
902 classification: "transient".into(),
903 },
904 EventKind::ComposeRecovery {
905 kernel_id: "k".into(),
906 reason: "retry_exhausted".into(),
907 recovered: false,
908 },
909 EventKind::ToolHostedInvoked {
910 tool_name: "web_search".into(),
911 provider_call_id: Some("call_abc".into()),
912 call_id: "hc".into(),
913 response_id: Some("resp_1".into()),
914 args_json: "{\"q\":\"x\"}".into(),
915 truncated: false,
916 },
917 EventKind::ToolHostedCompleted {
918 tool_name: "web_search".into(),
919 provider_call_id: Some("call_abc".into()),
920 call_id: "hc".into(),
921 response_id: Some("resp_1".into()),
922 status: Some("completed".into()),
923 result: "".into(),
924 truncated: false,
925 },
926 EventKind::ResponseSessionStarted {
927 model: "gpt-4o".into(),
928 session_id: "sess-1".into(),
929 },
930 EventKind::ResponseTurnStarted {
931 session_id: "sess-1".into(),
932 previous_response_id: Some("resp_0".into()),
933 },
934 EventKind::ResponseTurnCompleted {
935 session_id: "sess-1".into(),
936 response_id: "resp_1".into(),
937 previous_response_id: Some("resp_0".into()),
938 status: "completed".into(),
939 tokens_in: Some(10),
940 tokens_out: Some(20),
941 hosted_tool_calls: 2,
942 },
943 EventKind::ResponseSessionEnded {
944 session_id: "sess-1".into(),
945 reason: "client_close".into(),
946 },
947 EventKind::EvalReport {
948 report_id: "run-2026-05-27".into(),
949 dataset: "beir/scifact".into(),
950 metric: "ndcg@10".into(),
951 value: 0.512,
952 ci_low: Some(0.487),
953 ci_high: Some(0.538),
954 baseline_value: Some(0.498),
955 delta: Some(0.014),
956 verdict: Some("improved".into()),
957 sample_size: Some(300),
958 },
959 ];
960
961 for kind in kinds {
962 let discriminant = kind.discriminant();
963 let evt = ObservabilityEvent::new("c", kind.clone());
964 let json = serde_json::to_value(&evt).unwrap();
965 assert_eq!(json["kind"], discriminant);
966 let back: ObservabilityEvent = serde_json::from_value(json).unwrap();
967 assert_eq!(back.kind, kind);
968 }
969 }
970
971 #[test]
972 fn compose_events_are_classified() {
973 let event = EventKind::ComposeLoopIteration {
974 kernel_id: "kernel".into(),
975 iteration: 4,
976 skill_id: None,
977 confidence: None,
978 };
979
980 assert!(event.is_compose_related());
981 assert!(!event.is_prompt_related());
982 assert!(!event.is_tool_related());
983 assert!(!event.is_memory_related());
984 }
985
986 #[test]
987 fn hosted_tool_events_are_tool_related() {
988 let invoked = EventKind::ToolHostedInvoked {
989 tool_name: "web_search".into(),
990 provider_call_id: None,
991 call_id: "hc".into(),
992 response_id: None,
993 args_json: String::new(),
994 truncated: false,
995 };
996 assert!(invoked.is_tool_related());
997 assert!(!invoked.is_response_lifecycle_related());
998 assert_eq!(invoked.tool_call_id(), Some("hc"));
999 }
1000
1001 #[test]
1002 fn response_lifecycle_events_are_classified() {
1003 let started = EventKind::ResponseSessionStarted {
1004 model: "gpt-4o".into(),
1005 session_id: "sess-1".into(),
1006 };
1007 assert!(started.is_response_lifecycle_related());
1008 assert!(!started.is_tool_related());
1009 assert!(!started.is_prompt_related());
1010 assert!(!started.is_memory_related());
1011 assert!(!started.is_compose_related());
1012 }
1013
1014 #[test]
1015 fn turn_completed_surfaces_response_ids_as_scalars() {
1016 let evt = EventKind::ResponseTurnCompleted {
1017 session_id: "sess-1".into(),
1018 response_id: "resp_1".into(),
1019 previous_response_id: Some("resp_0".into()),
1020 status: "completed".into(),
1021 tokens_in: None,
1022 tokens_out: None,
1023 hosted_tool_calls: 0,
1024 };
1025 let fields = evt.scalar_fields();
1026 assert_eq!(fields.response_id, "resp_1");
1027 assert_eq!(fields.previous_response_id, "resp_0");
1028 }
1029
1030 #[test]
1031 fn prompt_completed_omits_previous_response_id_when_none() {
1032 let evt = ObservabilityEvent::new(
1033 "c",
1034 EventKind::PromptCompleted {
1035 model: "m".into(),
1036 tokens_in: None,
1037 tokens_out: None,
1038 response_id: None,
1039 previous_response_id: None,
1040 },
1041 );
1042 let json = serde_json::to_value(&evt).unwrap();
1043 assert!(json.get("previous_response_id").is_none());
1044 assert!(json.get("response_id").is_none());
1045 }
1046
1047 #[test]
1048 fn turn_completed_omits_zero_hosted_tool_calls() {
1049 let evt = ObservabilityEvent::new(
1050 "c",
1051 EventKind::ResponseTurnCompleted {
1052 session_id: "sess-1".into(),
1053 response_id: "resp_1".into(),
1054 previous_response_id: None,
1055 status: "completed".into(),
1056 tokens_in: None,
1057 tokens_out: None,
1058 hosted_tool_calls: 0,
1059 },
1060 );
1061 let json = serde_json::to_value(&evt).unwrap();
1062 assert!(json.get("hosted_tool_calls").is_none());
1063 }
1064
1065 #[test]
1066 fn prompt_completed_round_trips_without_previous_response_id() {
1067 // Schema-evolution guard: events emitted by v0.1.x producers will not
1068 // include `previous_response_id`. Ensure the new v0.1.3 reader still
1069 // accepts the old shape.
1070 let legacy = serde_json::json!({
1071 "version": SCHEMA_VERSION,
1072 "occurred_at_millis": 0_u64,
1073 "tick": 0_u64,
1074 "conversation_id": "c",
1075 "kind": "prompt.completed",
1076 "model": "m",
1077 });
1078 let parsed: ObservabilityEvent = serde_json::from_value(legacy).unwrap();
1079 match parsed.kind {
1080 EventKind::PromptCompleted {
1081 previous_response_id,
1082 response_id,
1083 ..
1084 } => {
1085 assert!(previous_response_id.is_none());
1086 assert!(response_id.is_none());
1087 }
1088 other => panic!("unexpected kind: {other:?}"),
1089 }
1090 }
1091
1092 #[test]
1093 fn eval_report_surfaces_scalars_and_classifies() {
1094 let evt = EventKind::EvalReport {
1095 report_id: "run-1".into(),
1096 dataset: "beir/scifact".into(),
1097 metric: "ndcg@10".into(),
1098 value: 0.5,
1099 ci_low: Some(0.48),
1100 ci_high: Some(0.52),
1101 baseline_value: Some(0.49),
1102 delta: Some(0.01),
1103 verdict: Some("improved".into()),
1104 sample_size: Some(300),
1105 };
1106 assert!(evt.is_eval_related());
1107 assert!(!evt.is_prompt_related());
1108 assert!(!evt.is_tool_related());
1109 assert!(!evt.is_memory_related());
1110 assert!(!evt.is_compose_related());
1111 assert!(!evt.is_response_lifecycle_related());
1112
1113 let fields = evt.scalar_fields();
1114 assert_eq!(fields.dataset, "beir/scifact");
1115 assert_eq!(fields.metric, "ndcg@10");
1116 assert_eq!(fields.verdict, "improved");
1117 }
1118
1119 #[test]
1120 fn eval_report_omits_optional_fields_when_none() {
1121 let evt = ObservabilityEvent::new(
1122 "c",
1123 EventKind::EvalReport {
1124 report_id: "run-1".into(),
1125 dataset: "beir/scifact".into(),
1126 metric: "recall@100".into(),
1127 value: 0.91,
1128 ci_low: None,
1129 ci_high: None,
1130 baseline_value: None,
1131 delta: None,
1132 verdict: None,
1133 sample_size: None,
1134 },
1135 );
1136 let json = serde_json::to_value(&evt).unwrap();
1137 assert_eq!(json["kind"], "eval.report");
1138 assert!(json.get("ci_low").is_none());
1139 assert!(json.get("ci_high").is_none());
1140 assert!(json.get("baseline_value").is_none());
1141 assert!(json.get("delta").is_none());
1142 assert!(json.get("verdict").is_none());
1143 assert!(json.get("sample_size").is_none());
1144 }
1145}