1use serde::{Deserialize, Serialize};
7
8pub const SCHEMA_VERSION: u32 = 1;
10
11pub const PAYLOAD_TRUNCATE_BYTES: usize = 4096;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct ObservabilityEvent {
32 pub version: u32,
34 pub occurred_at_millis: u64,
36 pub tick: u64,
38 pub conversation_id: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub span_id: Option<u64>,
49 #[serde(flatten)]
51 pub kind: EventKind,
52}
53
54impl ObservabilityEvent {
55 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct ScalarFields<'a> {
84 pub kernel_id: &'a str,
86 pub tool_name: &'a str,
88 pub call_id: &'a str,
90 pub skill_id: &'a str,
92 pub model: &'a str,
94 pub response_id: &'a str,
96 pub previous_response_id: &'a str,
100 pub dataset: &'a str,
102 pub metric: &'a str,
104 pub verdict: &'a str,
106 pub error_class: &'a str,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113#[non_exhaustive]
114pub enum ErrorClass {
115 Timeout,
117 RateLimit,
119 Auth,
121 Transport,
123 Validation,
125 ProviderServer,
127 Cancelled,
129 Unknown,
131}
132
133impl ErrorClass {
134 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(tag = "kind")]
155#[non_exhaustive]
156pub enum EventKind {
157 #[serde(rename = "prompt.started")]
159 PromptStarted {
160 model: String,
162 messages_in: usize,
164 },
165 #[serde(rename = "prompt.completed")]
167 PromptCompleted {
168 model: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 tokens_in: Option<u64>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 tokens_out: Option<u64>,
177 #[serde(skip_serializing_if = "Option::is_none", default)]
179 cached_tokens_in: Option<u64>,
180 #[serde(skip_serializing_if = "Option::is_none", default)]
182 reasoning_tokens: Option<u64>,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
185 cost_usd: Option<f64>,
186 #[serde(skip_serializing_if = "Option::is_none", default)]
188 finish_reason: Option<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 response_id: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
198 previous_response_id: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none", default)]
201 time_to_first_token_ms: Option<u64>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
204 duration_ms: Option<u64>,
205 },
206
207 #[serde(rename = "prompt.failed")]
209 PromptFailed {
210 model: String,
212 error_class: ErrorClass,
214 message: String,
216 retriable: bool,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 provider_error_code: Option<String>,
221 #[serde(skip_serializing_if = "Option::is_none")]
223 http_status: Option<u16>,
224 },
225 #[serde(rename = "tool.invoked")]
227 ToolInvoked {
228 tool_name: String,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 provider_call_id: Option<String>,
233 call_id: String,
235 args_json: String,
237 truncated: bool,
240 },
241 #[serde(rename = "tool.completed")]
243 ToolCompleted {
244 tool_name: String,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 provider_call_id: Option<String>,
249 call_id: String,
251 result: String,
253 truncated: bool,
255 #[serde(skip_serializing_if = "Option::is_none", default)]
257 duration_ms: Option<u64>,
258 },
259 #[serde(rename = "tool.failed")]
261 ToolFailed {
262 tool_name: String,
264 call_id: String,
266 error_class: ErrorClass,
268 message: String,
270 },
271 #[serde(rename = "tool.skipped")]
276 ToolSkipped {
277 tool_name: String,
279 call_id: String,
281 reason: String,
283 },
284 #[serde(rename = "tool.terminated")]
287 ToolTerminated {
288 tool_name: String,
290 call_id: String,
292 reason: String,
294 },
295 #[serde(rename = "tool.hosted_invoked")]
302 ToolHostedInvoked {
303 tool_name: String,
306 #[serde(skip_serializing_if = "Option::is_none")]
309 provider_call_id: Option<String>,
310 call_id: String,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 response_id: Option<String>,
316 args_json: String,
320 truncated: bool,
323 },
324 #[serde(rename = "tool.hosted_completed")]
327 ToolHostedCompleted {
328 tool_name: String,
330 #[serde(skip_serializing_if = "Option::is_none")]
332 provider_call_id: Option<String>,
333 call_id: String,
335 #[serde(skip_serializing_if = "Option::is_none")]
337 response_id: Option<String>,
338 #[serde(skip_serializing_if = "Option::is_none")]
341 status: Option<String>,
342 result: String,
346 truncated: bool,
348 #[serde(skip_serializing_if = "Option::is_none", default)]
350 duration_ms: Option<u64>,
351 },
352 #[serde(rename = "context.sampled")]
354 ContextSampled {
355 message_count: usize,
357 byte_size: usize,
359 #[serde(skip_serializing_if = "Option::is_none")]
362 token_estimate: Option<u64>,
363 },
364 #[serde(rename = "context.compacted")]
367 ContextCompacted {
368 evicted_count: usize,
370 evicted_bytes: usize,
372 carry_over: bool,
375 summary_bytes: usize,
377 },
378 #[serde(rename = "memory.demoted")]
380 MemoryDemoted {
381 demoted_count: usize,
383 tags: Vec<String>,
385 },
386 #[serde(rename = "memory.frame_written")]
388 MemoryFrameWritten {
389 frame_kind: String,
392 #[serde(skip_serializing_if = "Option::is_none")]
397 frame_count_after: Option<u64>,
398 bytes_written: usize,
400 },
401 #[serde(rename = "compose.kernel_start")]
403 ComposeKernelStart {
404 kernel_id: String,
406 #[serde(skip_serializing_if = "Option::is_none")]
408 skills_registered: Option<usize>,
409 #[serde(skip_serializing_if = "Option::is_none")]
411 tools_registered: Option<usize>,
412 },
413 #[serde(rename = "compose.kernel_shutdown")]
415 ComposeKernelShutdown {
416 kernel_id: String,
418 reason: String,
420 },
421 #[serde(rename = "compose.loop_iteration")]
423 ComposeLoopIteration {
424 kernel_id: String,
426 iteration: u64,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 skill_id: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none")]
433 confidence: Option<f64>,
434 },
435 #[serde(rename = "compose.skill_resolved")]
437 ComposeSkillResolved {
438 kernel_id: String,
440 skill_id: String,
442 applies: bool,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 delta: Option<f64>,
447 #[serde(skip_serializing_if = "Option::is_none", default)]
452 confidence: Option<f64>,
453 },
454 #[serde(rename = "compose.retry_attempt")]
464 ComposeRetryAttempt {
465 kernel_id: String,
467 target: String,
469 attempt: u64,
471 classification: String,
473 },
474 #[serde(rename = "compose.recovery")]
476 ComposeRecovery {
477 kernel_id: String,
479 reason: String,
481 recovered: bool,
483 },
484 #[serde(rename = "response.session_started")]
487 ResponseSessionStarted {
488 model: String,
490 session_id: String,
494 },
495 #[serde(rename = "response.turn_started")]
498 ResponseTurnStarted {
499 session_id: String,
502 #[serde(skip_serializing_if = "Option::is_none")]
505 previous_response_id: Option<String>,
506 },
507 #[serde(rename = "response.turn_completed")]
510 ResponseTurnCompleted {
511 session_id: String,
513 response_id: String,
515 #[serde(skip_serializing_if = "Option::is_none")]
517 previous_response_id: Option<String>,
518 status: String,
521 #[serde(skip_serializing_if = "Option::is_none")]
523 tokens_in: Option<u64>,
524 #[serde(skip_serializing_if = "Option::is_none")]
526 tokens_out: Option<u64>,
527 #[serde(skip_serializing_if = "crate::event::is_zero_usize", default)]
531 hosted_tool_calls: usize,
532 #[serde(skip_serializing_if = "Option::is_none", default)]
534 duration_ms: Option<u64>,
535 },
536 #[serde(rename = "response.session_ended")]
540 ResponseSessionEnded {
541 session_id: String,
543 reason: String,
547 },
548 #[serde(rename = "eval.report")]
557 EvalReport {
558 report_id: String,
561 dataset: String,
564 metric: String,
566 value: f64,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 ci_low: Option<f64>,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 ci_high: Option<f64>,
574 #[serde(skip_serializing_if = "Option::is_none")]
577 baseline_value: Option<f64>,
578 #[serde(skip_serializing_if = "Option::is_none")]
581 delta: Option<f64>,
582 #[serde(skip_serializing_if = "Option::is_none")]
586 verdict: Option<String>,
587 #[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 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 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 pub fn is_prompt_related(&self) -> bool {
783 matches!(
784 self,
785 EventKind::PromptStarted { .. }
786 | EventKind::PromptCompleted { .. }
787 | EventKind::PromptFailed { .. }
788 )
789 }
790
791 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 pub fn is_failure_related(&self) -> bool {
809 matches!(
810 self,
811 EventKind::PromptFailed { .. } | EventKind::ToolFailed { .. }
812 )
813 }
814
815 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 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 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 pub fn is_eval_related(&self) -> bool {
855 matches!(self, EventKind::EvalReport { .. })
856 }
857
858 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
873pub 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 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 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 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 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}