Skip to main content

roder_usage_analytics/
model.rs

1//! Typed analytics records and query filters (roadmap phase 73).
2//!
3//! Records deliberately exclude prompt bodies, assistant text, tool output
4//! bodies, command payloads, and secrets. Tool names, provider/model ids,
5//! ids, timestamps, status, durations, usage counts, and bounded error
6//! classes are the entire vocabulary.
7
8use serde::{Deserialize, Serialize};
9
10/// Version stamped on exported/imported normalized JSONL records.
11pub const ANALYTICS_JSONL_SCHEMA_VERSION: u32 = 1;
12
13/// How workspace paths are recorded and reported.
14#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum WorkspaceLabelMode {
17    /// Record the full local path (local-only default).
18    #[default]
19    FullPath,
20    /// Record an FNV-1a hash of the path.
21    Hashed,
22    /// Record only the final path component.
23    BasenameOnly,
24}
25
26impl WorkspaceLabelMode {
27    pub fn parse(value: &str) -> anyhow::Result<Self> {
28        match value {
29            "full_path" => Ok(Self::FullPath),
30            "hashed" => Ok(Self::Hashed),
31            "basename_only" => Ok(Self::BasenameOnly),
32            other => anyhow::bail!(
33                "unknown workspace label mode {other:?}; expected full_path, hashed, or \
34                 basename_only"
35            ),
36        }
37    }
38
39    /// Stable grouping key plus display label for a workspace path.
40    pub fn label(&self, workspace: &str) -> (String, String) {
41        match self {
42            WorkspaceLabelMode::FullPath => (workspace.to_string(), workspace.to_string()),
43            WorkspaceLabelMode::Hashed => {
44                let hash = fnv1a(workspace.as_bytes());
45                (hash.clone(), hash)
46            }
47            WorkspaceLabelMode::BasenameOnly => {
48                let basename = workspace
49                    .trim_end_matches('/')
50                    .rsplit('/')
51                    .next()
52                    .unwrap_or(workspace)
53                    .to_string();
54                (fnv1a(workspace.as_bytes()), basename)
55            }
56        }
57    }
58}
59
60pub(crate) fn fnv1a(data: &[u8]) -> String {
61    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
62    for byte in data {
63        hash ^= u64::from(*byte);
64        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
65    }
66    format!("{hash:016x}")
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "camelCase")]
71pub struct SessionRecord {
72    pub thread_id: String,
73    pub workspace_key: Option<String>,
74    pub workspace_label: Option<String>,
75    pub provider: Option<String>,
76    pub model: Option<String>,
77    /// Milliseconds since the Unix epoch.
78    pub created_at_ms: i64,
79    pub updated_at_ms: i64,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "camelCase")]
84pub struct TurnRecord {
85    pub thread_id: String,
86    pub turn_id: String,
87    pub provider: Option<String>,
88    pub model: Option<String>,
89    pub runtime_profile: Option<String>,
90    pub started_at_ms: Option<i64>,
91    pub completed_at_ms: Option<i64>,
92    /// `running`, `completed`, `failed`, or `partial`.
93    pub status: String,
94    pub error_kind: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "camelCase")]
99pub struct TokenUsageRecord {
100    pub thread_id: String,
101    pub turn_id: String,
102    pub provider: Option<String>,
103    pub model: Option<String>,
104    pub recorded_at_ms: i64,
105    pub prompt_tokens: u32,
106    pub completion_tokens: u32,
107    pub total_tokens: u32,
108    pub cached_prompt_tokens: u32,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "camelCase")]
113pub struct ToolCallRecord {
114    pub thread_id: String,
115    pub turn_id: String,
116    pub tool_id: String,
117    pub tool_name: Option<String>,
118    pub started_at_ms: Option<i64>,
119    pub completed_at_ms: Option<i64>,
120    pub duration_ms: Option<i64>,
121    /// `running`, `success`, `error`, or `partial` (missing start event).
122    pub status: String,
123    pub is_error: bool,
124}
125
126/// Common filter for stats queries. All bounds are optional; `since_ms`
127/// and `until_ms` are inclusive/exclusive epoch-millisecond bounds.
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
129#[serde(rename_all = "camelCase")]
130pub struct StatsFilter {
131    pub since_ms: Option<i64>,
132    pub until_ms: Option<i64>,
133    pub workspace_key: Option<String>,
134    pub provider: Option<String>,
135    pub model: Option<String>,
136    pub thread_id: Option<String>,
137    pub tool_name: Option<String>,
138    pub min_calls: Option<u64>,
139    /// Maximum rows returned by listing queries (default applied by query
140    /// helpers; app-server callers must bound this).
141    pub limit: Option<u64>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145#[serde(rename_all = "camelCase")]
146pub struct ToolSummary {
147    pub tool_name: String,
148    pub call_count: u64,
149    pub error_count: u64,
150    pub error_rate: f64,
151    pub total_duration_ms: i64,
152    pub avg_duration_ms: Option<f64>,
153    pub p50_duration_ms: Option<i64>,
154    pub p95_duration_ms: Option<i64>,
155    pub p99_duration_ms: Option<i64>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "camelCase")]
160pub struct TokenSummaryRow {
161    /// Group key: a day (`YYYY-MM-DD`), thread id, provider, model, or
162    /// workspace key depending on the requested grouping.
163    pub group: String,
164    pub prompt_tokens: u64,
165    pub completion_tokens: u64,
166    pub total_tokens: u64,
167    pub cached_prompt_tokens: u64,
168    pub turn_count: u64,
169}
170
171#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(rename_all = "snake_case")]
173pub enum TokenGroup {
174    Day,
175    Session,
176    Provider,
177    Model,
178    Workspace,
179}
180
181impl TokenGroup {
182    pub fn parse(value: &str) -> anyhow::Result<Self> {
183        match value {
184            "day" => Ok(Self::Day),
185            "session" => Ok(Self::Session),
186            "provider" => Ok(Self::Provider),
187            "model" => Ok(Self::Model),
188            "workspace" => Ok(Self::Workspace),
189            other => anyhow::bail!(
190                "unknown token grouping {other:?}; expected day, session, provider, model, or \
191                 workspace"
192            ),
193        }
194    }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198#[serde(rename_all = "camelCase")]
199pub struct SessionSummary {
200    pub thread_id: String,
201    pub workspace_label: Option<String>,
202    pub provider: Option<String>,
203    pub model: Option<String>,
204    pub turn_count: u64,
205    pub tool_call_count: u64,
206    pub tool_error_count: u64,
207    pub total_tokens: u64,
208    pub total_tool_duration_ms: i64,
209    pub first_activity_ms: Option<i64>,
210    pub last_activity_ms: Option<i64>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214#[serde(rename_all = "camelCase")]
215pub struct UsageSummary {
216    pub turn_count: u64,
217    pub completed_turn_count: u64,
218    pub failed_turn_count: u64,
219    pub tool_call_count: u64,
220    pub tool_error_count: u64,
221    pub prompt_tokens: u64,
222    pub completion_tokens: u64,
223    pub total_tokens: u64,
224    pub cached_prompt_tokens: u64,
225    pub session_count: u64,
226    pub most_called_tool: Option<String>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230#[serde(rename_all = "camelCase")]
231pub struct DailyRollupRow {
232    pub day: String,
233    pub workspace_key: Option<String>,
234    pub provider: Option<String>,
235    pub model: Option<String>,
236    pub tool_name: Option<String>,
237    pub call_count: u64,
238    pub error_count: u64,
239    pub total_duration_ms: i64,
240    pub p50_duration_ms: Option<i64>,
241    pub p95_duration_ms: Option<i64>,
242    pub p99_duration_ms: Option<i64>,
243    pub prompt_tokens: u64,
244    pub completion_tokens: u64,
245    pub total_tokens: u64,
246    pub cached_prompt_tokens: u64,
247}