use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
pub const PAYLOAD_TRUNCATE_BYTES: usize = 4096;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ObservabilityEvent {
pub version: u32,
pub occurred_at_millis: u64,
pub tick: u64,
pub conversation_id: String,
#[serde(flatten)]
pub kind: EventKind,
}
impl ObservabilityEvent {
pub fn new(conversation_id: impl Into<String>, kind: EventKind) -> Self {
Self {
version: SCHEMA_VERSION,
occurred_at_millis: 0,
tick: 0,
conversation_id: conversation_id.into(),
kind,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ScalarFields<'a> {
pub kernel_id: &'a str,
pub tool_name: &'a str,
pub call_id: &'a str,
pub skill_id: &'a str,
pub model: &'a str,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
#[non_exhaustive]
pub enum EventKind {
#[serde(rename = "prompt.started")]
PromptStarted {
model: String,
messages_in: usize,
},
#[serde(rename = "prompt.completed")]
PromptCompleted {
model: String,
#[serde(skip_serializing_if = "Option::is_none")]
tokens_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
tokens_out: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
response_id: Option<String>,
},
#[serde(rename = "tool.invoked")]
ToolInvoked {
tool_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
provider_call_id: Option<String>,
call_id: String,
args_json: String,
truncated: bool,
},
#[serde(rename = "tool.completed")]
ToolCompleted {
tool_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
provider_call_id: Option<String>,
call_id: String,
result: String,
truncated: bool,
},
#[serde(rename = "tool.skipped")]
ToolSkipped {
tool_name: String,
call_id: String,
reason: String,
},
#[serde(rename = "tool.terminated")]
ToolTerminated {
tool_name: String,
call_id: String,
reason: String,
},
#[serde(rename = "context.sampled")]
ContextSampled {
message_count: usize,
byte_size: usize,
#[serde(skip_serializing_if = "Option::is_none")]
token_estimate: Option<u64>,
},
#[serde(rename = "context.compacted")]
ContextCompacted {
evicted_count: usize,
evicted_bytes: usize,
carry_over: bool,
summary_bytes: usize,
},
#[serde(rename = "memory.demoted")]
MemoryDemoted {
demoted_count: usize,
tags: Vec<String>,
},
#[serde(rename = "memory.frame_written")]
MemoryFrameWritten {
frame_kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
frame_count_after: Option<u64>,
bytes_written: usize,
},
#[serde(rename = "compose.kernel_start")]
ComposeKernelStart {
kernel_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
skills_registered: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
tools_registered: Option<usize>,
},
#[serde(rename = "compose.kernel_shutdown")]
ComposeKernelShutdown {
kernel_id: String,
reason: String,
},
#[serde(rename = "compose.loop_iteration")]
ComposeLoopIteration {
kernel_id: String,
iteration: u64,
#[serde(skip_serializing_if = "Option::is_none")]
skill_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
confidence: Option<f64>,
},
#[serde(rename = "compose.skill_resolved")]
ComposeSkillResolved {
kernel_id: String,
skill_id: String,
applies: bool,
#[serde(skip_serializing_if = "Option::is_none")]
delta: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
confidence: Option<f64>,
},
#[serde(rename = "compose.retry_attempt")]
ComposeRetryAttempt {
kernel_id: String,
target: String,
attempt: u64,
classification: String,
},
#[serde(rename = "compose.recovery")]
ComposeRecovery {
kernel_id: String,
reason: String,
recovered: bool,
},
}
impl EventKind {
pub fn discriminant(&self) -> &'static str {
match self {
EventKind::PromptStarted { .. } => "prompt.started",
EventKind::PromptCompleted { .. } => "prompt.completed",
EventKind::ToolInvoked { .. } => "tool.invoked",
EventKind::ToolCompleted { .. } => "tool.completed",
EventKind::ToolSkipped { .. } => "tool.skipped",
EventKind::ToolTerminated { .. } => "tool.terminated",
EventKind::ContextSampled { .. } => "context.sampled",
EventKind::ContextCompacted { .. } => "context.compacted",
EventKind::MemoryDemoted { .. } => "memory.demoted",
EventKind::MemoryFrameWritten { .. } => "memory.frame_written",
EventKind::ComposeKernelStart { .. } => "compose.kernel_start",
EventKind::ComposeKernelShutdown { .. } => "compose.kernel_shutdown",
EventKind::ComposeLoopIteration { .. } => "compose.loop_iteration",
EventKind::ComposeSkillResolved { .. } => "compose.skill_resolved",
EventKind::ComposeRetryAttempt { .. } => "compose.retry_attempt",
EventKind::ComposeRecovery { .. } => "compose.recovery",
}
}
pub fn scalar_fields(&self) -> ScalarFields<'_> {
let mut f = ScalarFields::default();
match self {
EventKind::PromptStarted { model, .. } => f.model = model,
EventKind::PromptCompleted { model, .. } => f.model = model,
EventKind::ToolInvoked {
tool_name, call_id, ..
}
| EventKind::ToolCompleted {
tool_name, call_id, ..
} => {
f.tool_name = tool_name;
f.call_id = call_id;
}
EventKind::ToolSkipped {
tool_name, call_id, ..
}
| EventKind::ToolTerminated {
tool_name, call_id, ..
} => {
f.tool_name = tool_name;
f.call_id = call_id;
}
EventKind::ComposeKernelStart { kernel_id, .. }
| EventKind::ComposeKernelShutdown { kernel_id, .. }
| EventKind::ComposeRecovery { kernel_id, .. } => {
f.kernel_id = kernel_id;
}
EventKind::ComposeLoopIteration {
kernel_id,
skill_id,
..
} => {
f.kernel_id = kernel_id;
if let Some(s) = skill_id {
f.skill_id = s;
}
}
EventKind::ComposeSkillResolved {
kernel_id,
skill_id,
..
} => {
f.kernel_id = kernel_id;
f.skill_id = skill_id;
}
EventKind::ComposeRetryAttempt {
kernel_id, target, ..
} => {
f.kernel_id = kernel_id;
f.tool_name = target;
}
EventKind::ContextSampled { .. }
| EventKind::ContextCompacted { .. }
| EventKind::MemoryDemoted { .. }
| EventKind::MemoryFrameWritten { .. } => {}
}
f
}
pub fn is_prompt_related(&self) -> bool {
matches!(
self,
EventKind::PromptStarted { .. } | EventKind::PromptCompleted { .. }
)
}
pub fn is_tool_related(&self) -> bool {
matches!(
self,
EventKind::ToolInvoked { .. }
| EventKind::ToolCompleted { .. }
| EventKind::ToolSkipped { .. }
| EventKind::ToolTerminated { .. }
)
}
pub fn is_memory_related(&self) -> bool {
matches!(
self,
EventKind::ContextSampled { .. }
| EventKind::ContextCompacted { .. }
| EventKind::MemoryDemoted { .. }
| EventKind::MemoryFrameWritten { .. }
)
}
pub fn is_compose_related(&self) -> bool {
matches!(
self,
EventKind::ComposeKernelStart { .. }
| EventKind::ComposeKernelShutdown { .. }
| EventKind::ComposeLoopIteration { .. }
| EventKind::ComposeSkillResolved { .. }
| EventKind::ComposeRetryAttempt { .. }
| EventKind::ComposeRecovery { .. }
)
}
pub fn tool_call_id(&self) -> Option<&str> {
match self {
EventKind::ToolInvoked { call_id, .. } => Some(call_id),
EventKind::ToolCompleted { call_id, .. } => Some(call_id),
EventKind::ToolSkipped { call_id, .. } => Some(call_id),
EventKind::ToolTerminated { call_id, .. } => Some(call_id),
_ => None,
}
}
}
pub fn truncate_utf8(input: &str, max_bytes: usize) -> (String, bool) {
if input.len() <= max_bytes {
return (input.to_string(), false);
}
let mut end = max_bytes;
while end > 0 && !input.is_char_boundary(end) {
end -= 1;
}
match input.get(..end) {
Some(slice) => (slice.to_string(), true),
None => (String::new(), true),
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::indexing_slicing,
clippy::expect_used
)]
mod tests {
use super::*;
#[test]
fn envelope_serializes_flat() {
let event = ObservabilityEvent {
version: SCHEMA_VERSION,
occurred_at_millis: 1715000000000,
tick: 42,
conversation_id: "thread-1".into(),
kind: EventKind::PromptStarted {
model: "gpt-4o".into(),
messages_in: 3,
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["kind"], "prompt.started");
assert_eq!(json["model"], "gpt-4o");
assert_eq!(json["messages_in"], 3);
assert_eq!(json["tick"], 42);
assert_eq!(json["version"], SCHEMA_VERSION);
let parsed: ObservabilityEvent = serde_json::from_value(json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn truncate_at_char_boundary() {
let s = "café-α-β-γ-δ-ε-ζ-η-θ-ι-κ-λ-μ-ν-ξ-ο-π";
let (out, truncated) = truncate_utf8(s, 6);
assert!(truncated);
assert!(out.is_char_boundary(out.len()));
assert!(out.len() <= 6);
}
#[test]
fn truncate_no_op_when_short() {
let (out, truncated) = truncate_utf8("ok", 100);
assert!(!truncated);
assert_eq!(out, "ok");
}
#[test]
fn all_discriminants_round_trip() {
let kinds = [
EventKind::PromptStarted {
model: "m".into(),
messages_in: 1,
},
EventKind::PromptCompleted {
model: "m".into(),
tokens_in: Some(10),
tokens_out: Some(20),
response_id: Some("r".into()),
},
EventKind::ToolInvoked {
tool_name: "t".into(),
provider_call_id: None,
call_id: "c".into(),
args_json: "{}".into(),
truncated: false,
},
EventKind::ToolCompleted {
tool_name: "t".into(),
provider_call_id: None,
call_id: "c".into(),
result: "ok".into(),
truncated: false,
},
EventKind::ToolSkipped {
tool_name: "t".into(),
call_id: "c".into(),
reason: "policy".into(),
},
EventKind::ToolTerminated {
tool_name: "t".into(),
call_id: "c".into(),
reason: "abort".into(),
},
EventKind::ContextSampled {
message_count: 5,
byte_size: 1024,
token_estimate: None,
},
EventKind::ContextCompacted {
evicted_count: 3,
evicted_bytes: 200,
carry_over: false,
summary_bytes: 80,
},
EventKind::MemoryDemoted {
demoted_count: 2,
tags: vec!["t".into()],
},
EventKind::MemoryFrameWritten {
frame_kind: "summary".into(),
frame_count_after: Some(7),
bytes_written: 42,
},
EventKind::ComposeKernelStart {
kernel_id: "k".into(),
skills_registered: Some(2),
tools_registered: Some(3),
},
EventKind::ComposeKernelShutdown {
kernel_id: "k".into(),
reason: "normal".into(),
},
EventKind::ComposeLoopIteration {
kernel_id: "k".into(),
iteration: 1,
skill_id: Some("skill".into()),
confidence: Some(0.5),
},
EventKind::ComposeSkillResolved {
kernel_id: "k".into(),
skill_id: "skill".into(),
applies: true,
delta: Some(0.25),
confidence: Some(0.75),
},
EventKind::ComposeRetryAttempt {
kernel_id: "k".into(),
target: "tool".into(),
attempt: 2,
classification: "transient".into(),
},
EventKind::ComposeRecovery {
kernel_id: "k".into(),
reason: "retry_exhausted".into(),
recovered: false,
},
];
for kind in kinds {
let discriminant = kind.discriminant();
let evt = ObservabilityEvent::new("c", kind.clone());
let json = serde_json::to_value(&evt).unwrap();
assert_eq!(json["kind"], discriminant);
let back: ObservabilityEvent = serde_json::from_value(json).unwrap();
assert_eq!(back.kind, kind);
}
}
#[test]
fn compose_events_are_classified() {
let event = EventKind::ComposeLoopIteration {
kernel_id: "kernel".into(),
iteration: 4,
skill_id: None,
confidence: None,
};
assert!(event.is_compose_related());
assert!(!event.is_prompt_related());
assert!(!event.is_tool_related());
assert!(!event.is_memory_related());
}
}