use bamboo_domain::TokenBudgetUsage;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextManagementTelemetry {
pub strategy: String,
pub model_context_epoch: u64,
pub model_context_reset_reason: Option<String>,
pub retrieval_archive_event_count: usize,
pub latest_retrieval_archive_event_id: Option<String>,
pub latest_retrieval_archive_trigger_type: Option<String>,
}
impl Default for ContextManagementTelemetry {
fn default() -> Self {
Self {
strategy: "summary".to_string(),
model_context_epoch: 0,
model_context_reset_reason: None,
retrieval_archive_event_count: 0,
latest_retrieval_archive_event_id: None,
latest_retrieval_archive_trigger_type: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TokenUsageRecord {
pub ts: String,
pub session_id: String,
pub model: String,
pub provider: String,
pub message_count: usize,
pub cache_creation_input_tokens: u64,
pub cache_read_input_tokens: u64,
pub cache_write_input_tokens: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub thinking_tokens: u64,
pub system_tokens: u32,
pub summary_tokens: u32,
pub window_tokens: u32,
pub total_tokens: u32,
pub max_context_tokens: u32,
pub budget_limit: u32,
pub prompt_cached_tool_outputs: usize,
pub prompt_cached_tool_tokens_saved: u32,
pub truncation_occurred: bool,
pub segments_removed: usize,
pub context_management_strategy: String,
pub model_context_epoch: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_context_reset_reason: Option<String>,
pub retrieval_archive_event_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_retrieval_archive_event_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_retrieval_archive_trigger_type: Option<String>,
}
impl TokenUsageRecord {
#[allow(clippy::too_many_arguments)]
pub fn new(
ts: String,
session_id: &str,
model: &str,
provider: &str,
message_count: usize,
usage: Option<&TokenBudgetUsage>,
cache_creation_input_tokens: u64,
cache_read_input_tokens: u64,
cache_write_input_tokens: u64,
input_tokens: u64,
output_tokens: u64,
thinking_tokens: u64,
context_management: ContextManagementTelemetry,
) -> Self {
Self {
ts,
session_id: session_id.to_string(),
model: model.to_string(),
provider: provider.to_string(),
message_count,
cache_creation_input_tokens,
cache_read_input_tokens,
cache_write_input_tokens,
input_tokens,
output_tokens,
thinking_tokens,
system_tokens: usage.map(|u| u.system_tokens).unwrap_or(0),
summary_tokens: usage.map(|u| u.summary_tokens).unwrap_or(0),
window_tokens: usage.map(|u| u.window_tokens).unwrap_or(0),
total_tokens: usage.map(|u| u.total_tokens).unwrap_or(0),
max_context_tokens: usage.map(|u| u.max_context_tokens).unwrap_or(0),
budget_limit: usage.map(|u| u.budget_limit).unwrap_or(0),
prompt_cached_tool_outputs: usage.map(|u| u.prompt_cached_tool_outputs).unwrap_or(0),
prompt_cached_tool_tokens_saved: usage
.map(|u| u.prompt_cached_tool_tokens_saved)
.unwrap_or(0),
truncation_occurred: usage.map(|u| u.truncation_occurred).unwrap_or(false),
segments_removed: usage.map(|u| u.segments_removed).unwrap_or(0),
context_management_strategy: context_management.strategy,
model_context_epoch: context_management.model_context_epoch,
model_context_reset_reason: context_management.model_context_reset_reason,
retrieval_archive_event_count: context_management.retrieval_archive_event_count,
latest_retrieval_archive_event_id: context_management.latest_retrieval_archive_event_id,
latest_retrieval_archive_trigger_type: context_management
.latest_retrieval_archive_trigger_type,
}
}
pub fn to_json_line(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_serializes_to_single_json_line_with_cache_creation() {
let usage = TokenBudgetUsage {
system_tokens: 5000,
summary_tokens: 2000,
window_tokens: 3000,
total_tokens: 10000,
max_context_tokens: 200_000,
budget_limit: 180_000,
truncation_occurred: false,
segments_removed: 0,
prompt_cached_tool_outputs: 1,
prompt_cached_tool_tokens_saved: 42,
thinking_tokens: 7,
cache_read_input_tokens: 12_000,
provider_prompt_usage: None,
};
let record = TokenUsageRecord::new(
"2026-06-15T00:00:00Z".to_string(),
"sess-1",
"claude-opus-4-8",
"anthropic",
24,
Some(&usage),
1500, 12_000,
0,
800, 300,
7,
ContextManagementTelemetry {
strategy: "retrieval_window".to_string(),
model_context_epoch: 4,
model_context_reset_reason: Some("compression".to_string()),
retrieval_archive_event_count: 2,
latest_retrieval_archive_event_id: Some("archive-event-2".to_string()),
latest_retrieval_archive_trigger_type: Some("auto".to_string()),
},
);
let line = record.to_json_line().expect("serializes");
assert!(!line.contains('\n'), "must be a single line");
assert!(line.contains("\"cache_creation_input_tokens\":1500"));
assert!(line.contains("\"cache_read_input_tokens\":12000"));
assert!(line.contains("\"cache_write_input_tokens\":0"));
assert!(line.contains("\"input_tokens\":800"));
assert!(line.contains("\"session_id\":\"sess-1\""));
assert!(line.contains("\"context_management_strategy\":\"retrieval_window\""));
assert!(line.contains("\"model_context_epoch\":4"));
assert!(line.contains("\"model_context_reset_reason\":\"compression\""));
assert!(line.contains("\"retrieval_archive_event_count\":2"));
assert!(line.contains("\"latest_retrieval_archive_event_id\":\"archive-event-2\""));
assert!(line.contains("\"latest_retrieval_archive_trigger_type\":\"auto\""));
}
#[test]
fn openai_flat_log_contract_keeps_fresh_cache_and_total_disjoint() {
let record = TokenUsageRecord::new(
"2026-07-29T00:00:00Z".to_string(),
"sess-openai",
"gpt-5",
"openai",
4,
None,
0,
768,
64,
232,
120,
20,
ContextManagementTelemetry::default(),
);
let prompt_total = record
.input_tokens
.saturating_add(record.cache_read_input_tokens)
.saturating_add(record.cache_creation_input_tokens);
let cache_hit_ratio = record.cache_read_input_tokens as f64 / prompt_total as f64;
assert_eq!(record.input_tokens, 232);
assert_eq!(prompt_total, 1000);
assert_eq!(record.cache_write_input_tokens, 64);
assert!((cache_hit_ratio - 0.768).abs() < f64::EPSILON);
assert_eq!(record.output_tokens, 120);
assert_eq!(record.thinking_tokens, 20);
assert_eq!(record.context_management_strategy, "summary");
assert_eq!(record.model_context_epoch, 0);
assert!(record.model_context_reset_reason.is_none());
}
}