use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Surface {
Tui,
Exec,
Cli,
AppServer,
McpServer,
Serve,
}
impl Surface {
pub const ALL: &'static [Self] = &[
Self::Tui,
Self::Exec,
Self::Cli,
Self::AppServer,
Self::McpServer,
Self::Serve,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Tui => "tui",
Self::Exec => "exec",
Self::Cli => "cli",
Self::AppServer => "app-server",
Self::McpServer => "mcp-server",
Self::Serve => "serve",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Os {
Linux,
Macos,
Windows,
Freebsd,
Android,
Other,
}
impl Os {
pub const ALL: &'static [Self] = &[
Self::Linux,
Self::Macos,
Self::Windows,
Self::Freebsd,
Self::Android,
Self::Other,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Linux => "linux",
Self::Macos => "macos",
Self::Windows => "windows",
Self::Freebsd => "freebsd",
Self::Android => "android",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Arch {
X86_64,
Aarch64,
Other,
}
impl Arch {
pub const ALL: &'static [Self] = &[Self::X86_64, Self::Aarch64, Self::Other];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::X86_64 => "x86_64",
Self::Aarch64 => "aarch64",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Libc {
Gnu,
Musl,
None,
}
impl Libc {
pub const ALL: &'static [Self] = &[Self::Gnu, Self::Musl, Self::None];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Gnu => "gnu",
Self::Musl => "musl",
Self::None => "none",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallKind {
Install,
Upgrade,
Downgrade,
}
impl InstallKind {
pub const ALL: &'static [Self] = &[Self::Install, Self::Upgrade, Self::Downgrade];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Upgrade => "upgrade",
Self::Downgrade => "downgrade",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionSource {
Interactive,
Resume,
Fork,
Api,
Unknown,
}
impl SessionSource {
pub const ALL: &'static [Self] = &[
Self::Interactive,
Self::Resume,
Self::Fork,
Self::Api,
Self::Unknown,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Interactive => "interactive",
Self::Resume => "resume",
Self::Fork => "fork",
Self::Api => "api",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DurationBucket {
#[serde(rename = "lt_1m")]
Lt1m,
#[serde(rename = "1m_10m")]
OneToTen,
#[serde(rename = "10m_60m")]
TenToSixty,
#[serde(rename = "gt_60m")]
Gt60m,
}
impl DurationBucket {
pub const ALL: &'static [Self] = &[Self::Lt1m, Self::OneToTen, Self::TenToSixty, Self::Gt60m];
#[must_use]
pub fn from_secs(secs: u64) -> Self {
match secs {
0..60 => Self::Lt1m,
60..600 => Self::OneToTen,
600..3600 => Self::TenToSixty,
_ => Self::Gt60m,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Lt1m => "lt_1m",
Self::OneToTen => "1m_10m",
Self::TenToSixty => "10m_60m",
Self::Gt60m => "gt_60m",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitClass {
Clean,
Signal,
Panic,
Error,
}
impl ExitClass {
pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Clean => "clean",
Self::Signal => "signal",
Self::Panic => "panic",
Self::Error => "error",
}
}
#[must_use]
pub fn as_u8(self) -> u8 {
match self {
Self::Clean => 0,
Self::Signal => 1,
Self::Panic => 2,
Self::Error => 3,
}
}
#[must_use]
pub fn from_u8(value: u8) -> Self {
match value {
1 => Self::Signal,
2 => Self::Panic,
3 => Self::Error,
_ => Self::Clean,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ColdStartBucket {
#[serde(rename = "lt_250")]
Lt250,
#[serde(rename = "250_1000")]
Mid,
#[serde(rename = "1000_3000")]
Slow,
#[serde(rename = "gte_3000")]
Gte3000,
}
impl ColdStartBucket {
pub const ALL: &'static [Self] = &[Self::Lt250, Self::Mid, Self::Slow, Self::Gte3000];
#[must_use]
pub fn from_millis(ms: u64) -> Self {
match ms {
0..250 => Self::Lt250,
250..1000 => Self::Mid,
1000..3000 => Self::Slow,
_ => Self::Gte3000,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Lt250 => "lt_250",
Self::Mid => "250_1000",
Self::Slow => "1000_3000",
Self::Gte3000 => "gte_3000",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Counters {
pub turns: u32,
pub tool_calls: u32,
pub fleet_dispatch: u32,
pub workflow_run: u32,
pub subagent_spawn: u32,
pub mcp_server_connected: u32,
pub memory_search: u32,
pub approval_modal_shown: u32,
pub approval_auto_allowed: u32,
pub command_palette_open: u32,
}
impl Counters {
pub const FIELDS: &'static [&'static str] = &[
"turns",
"tool_calls",
"fleet_dispatch",
"workflow_run",
"subagent_spawn",
"mcp_server_connected",
"memory_search",
"approval_modal_shown",
"approval_auto_allowed",
"command_palette_open",
];
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Errors {
pub auth_preflight_failed: u32,
pub provider_http_4xx: u32,
pub provider_http_5xx: u32,
pub tool_denied_by_policy: u32,
pub tool_timeout: u32,
pub network_error: u32,
}
impl Errors {
pub const FIELDS: &'static [&'static str] = &[
"auth_preflight_failed",
"provider_http_4xx",
"provider_http_5xx",
"tool_denied_by_policy",
"tool_timeout",
"network_error",
];
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnWall {
pub lt_5s: u32,
#[serde(rename = "5_30s")]
pub five_to_thirty: u32,
#[serde(rename = "30_120s")]
pub thirty_to_onetwenty: u32,
pub gte_120s: u32,
}
impl TurnWall {
pub const FIELDS: &'static [&'static str] = &["lt_5s", "5_30s", "30_120s", "gte_120s"];
pub fn observe_secs(&mut self, secs: u64) {
match secs {
0..5 => self.lt_5s = self.lt_5s.saturating_add(1),
5..30 => self.five_to_thirty = self.five_to_thirty.saturating_add(1),
30..120 => self.thirty_to_onetwenty = self.thirty_to_onetwenty.saturating_add(1),
_ => self.gte_120s = self.gte_120s.saturating_add(1),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
InstallOrUpgrade {
kind: InstallKind,
previous_version: Option<String>,
},
SessionStart {
source: SessionSource,
},
SessionEnd {
duration_bucket: DurationBucket,
exit_class: ExitClass,
cold_start_bucket: Option<ColdStartBucket>,
providers: Vec<String>,
counters: Counters,
errors: Errors,
turn_wall: TurnWall,
},
Panic {
site: String,
},
}
impl Event {
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Self::InstallOrUpgrade { .. } => "install_or_upgrade",
Self::SessionStart { .. } => "session_start",
Self::SessionEnd { .. } => "session_end",
Self::Panic { .. } => "panic",
}
}
#[must_use]
pub fn is_bounded(&self) -> bool {
match self {
Self::SessionStart { .. } => true,
Self::InstallOrUpgrade {
previous_version, ..
} => previous_version
.as_deref()
.is_none_or(is_release_version_string),
Self::SessionEnd { providers, .. } => {
providers.iter().all(|name| is_known_provider_id(name))
}
Self::Panic { site } => is_reduced_panic_site(site),
}
}
}
#[must_use]
pub fn is_release_version_string(value: &str) -> bool {
let (core, pre) = match value.split_once('-') {
Some((core, pre)) => (core, Some(pre)),
None => (value, None),
};
let parts: Vec<&str> = core.split('.').collect();
if parts.len() != 3
|| !parts
.iter()
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
{
return false;
}
match pre {
None => true,
Some(pre) => !pre.is_empty() && pre.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.'),
}
}
#[must_use]
pub fn is_reduced_panic_site(value: &str) -> bool {
if value == "<dep>" {
return true;
}
let Some((file, rest)) = value.split_once(".rs:") else {
return false;
};
let Some((line, column)) = rest.split_once(':') else {
return false;
};
file.starts_with("crates/")
&& file
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'/' | b'.' | b'-'))
&& !line.is_empty()
&& line.bytes().all(|b| b.is_ascii_digit())
&& !column.is_empty()
&& column.bytes().all(|b| b.is_ascii_digit())
}
#[must_use]
pub fn is_known_provider_id(value: &str) -> bool {
codewhale_config::provider::all_providers()
.iter()
.any(|provider| provider.id() == value)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Batch {
pub schema_version: u32,
pub sent_at: String,
pub install_id: String,
pub app_version: String,
pub git_sha: Option<String>,
pub surface: Surface,
pub os: Os,
pub arch: Arch,
pub libc: Libc,
pub tty: bool,
pub events: Vec<Event>,
}
impl Batch {
pub const FIELDS: &'static [&'static str] = &[
"schema_version",
"sent_at",
"install_id",
"app_version",
"git_sha",
"surface",
"os",
"arch",
"libc",
"tty",
"events",
];
}