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