crates/telemetry/src/event.rs
Rama main · réplica de lectura del repositorio original.
//! The wire schema. This module is the whole of what may leave the machine.
//!
//! Every field below is an integer, a boolean, or a **closed enum string**,
//! except exactly four bounded strings: `app_version`, `git_sha`,
//! `panic_site`, and `model_usage.model`, which is closed against the bundled
//! model catalog. Each of those four has a written rule. There is no free-form string type here and no open-keyed map, which is
//! what makes "aggregates and events, never content" a property the compiler
//! and the test suite can enforce rather than a promise.
//!
//! Bounded is not the same as *built bounded*. These types are also a
//! **deserialization target**: `flush` reads `buffer.jsonl` back off disk and
//! hands the lines to `serde`, which will fill `site`, `previous_version`, and
//! `providers` with any string the file contains. Anything running as the user
//! can append to that file, `$CODEWHALE_HOME` is a predictable path, and this
//! product executes model-authored shell commands. [`Event::is_bounded`] is
//! therefore checked on the drain path, and it is the reason the guarantee
//! above survives contact with the filesystem.
//!
//! Three standing rules for anyone extending this file:
//!
//! 1. **Never `#[derive(Serialize)]` over an existing state type.**
//! `codewhale_state::Thread` carries `git_sha`, `git_branch`,
//! `git_origin_url`, `cwd`, and `path`. A payload builder that accepts one
//! and derives breaches the red lines in a single line. Every struct here is
//! built from scratch with explicit fields.
//! 2. **Bump [`SCHEMA_VERSION`] on any field add, remove, or retype**, and
//! never reuse a number. The golden snapshot test fails until you do.
//! 3. **A new string field needs a clause in [`Event::is_bounded`]**, not just
//! a doc comment naming its rule. A rule only the constructor honours is a
//! rule the drain path does not have.
use serde::{Deserialize, Serialize};
/// Wire schema version. Bumped on any field add, remove, or retype; never
/// reused. `crates/telemetry/tests/golden/v1.json` pins what v1 was.
pub const SCHEMA_VERSION: u32 = 4;
/// Default-on policy disclosure version. This is not human consent.
pub const NOTICE_VERSION: u32 = 6;
/// Which product surface produced a batch.
///
/// Deliberately **not** derived from the executable: `codewhale-tui` serves at
/// least five surfaces, and app-server runs in-process inside `codewhale`, so
/// `current_exe()` would report every app-server session as CLI. Each
/// subcommand dispatch names its own surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Surface {
/// The interactive terminal UI.
Tui,
/// `codewhale exec` — one non-interactive run.
Exec,
/// Terminal `config` / `auth` / `update` subcommands.
Cli,
/// The app-server protocol surface (in-process inside `codewhale`).
AppServer,
/// The MCP server surface.
McpServer,
/// `codewhale serve`.
Serve,
/// Public website product interactions.
Website,
/// Browser application interactions.
WebApp,
/// Native desktop application interactions.
Desktop,
/// Control-plane application interactions.
ControlPlane,
/// A VS Code-family extension client of this schema.
///
/// Deliberately **generic**, the way `web-app` and `desktop` are: the
/// client is not one publisher's extension, and a surface naming a vendor
/// would narrow the whitelist for every later extension built on the same
/// client. The editor's own name and version are **not** collected — this
/// value names the kind of client that emitted the batch, never the host
/// application it happened to run inside.
VscodeExtension,
}
impl Surface {
/// Every surface, for exhaustive iteration in tests and schema checks.
pub const ALL: &'static [Self] = &[
Self::Tui,
Self::Exec,
Self::Cli,
Self::AppServer,
Self::McpServer,
Self::Serve,
Self::Website,
Self::WebApp,
Self::Desktop,
Self::ControlPlane,
Self::VscodeExtension,
];
/// The wire spelling.
#[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",
Self::Website => "website",
Self::WebApp => "web-app",
Self::Desktop => "desktop",
Self::ControlPlane => "control-plane",
Self::VscodeExtension => "vscode-extension",
}
}
/// The inverse of [`Self::as_str`], or `None` for anything else.
///
/// The round trip is pinned by a test, so a value that can be spelled can
/// also be parsed and vice versa. This is the only place a surface name is
/// turned back into a variant, which is what lets an *embedder* declare one
/// without a second copy of the closed list.
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|surface| surface.as_str() == value)
}
}
/// Operating system family. A closed whitelist, so an unrecognised
/// `std::env::consts::OS` reports `other` rather than shipping a novel string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Os {
/// Linux.
Linux,
/// macOS.
Macos,
/// Windows.
Windows,
/// FreeBSD.
Freebsd,
/// Android.
Android,
/// Anything else.
Other,
}
impl Os {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[
Self::Linux,
Self::Macos,
Self::Windows,
Self::Freebsd,
Self::Android,
Self::Other,
];
/// The wire spelling.
#[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",
}
}
}
/// CPU family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Arch {
/// 64-bit x86.
X86_64,
/// 64-bit ARM.
Aarch64,
/// Anything else.
Other,
}
impl Arch {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[Self::X86_64, Self::Aarch64, Self::Other];
/// The wire spelling.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::X86_64 => "x86_64",
Self::Aarch64 => "aarch64",
Self::Other => "other",
}
}
}
/// C runtime the binary was **compiled** against.
///
/// Compile-time (`cfg!(target_env)`), never runtime-detected: the only way to
/// read this at runtime is `/etc/os-release` or shelling to `ldd`, both of which
/// surface corporate golden-image vendor strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Libc {
/// glibc.
Gnu,
/// musl.
Musl,
/// Not a libc target (macOS, Windows, and anything else).
None,
}
impl Libc {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[Self::Gnu, Self::Musl, Self::None];
/// The wire spelling.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Gnu => "gnu",
Self::Musl => "musl",
Self::None => "none",
}
}
}
/// Whether this binary is newly installed, upgraded, or downgraded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallKind {
/// No prior version record on this machine.
Install,
/// The recorded version is older than this one.
Upgrade,
/// The recorded version is newer than this one.
Downgrade,
}
impl InstallKind {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[Self::Install, Self::Upgrade, Self::Downgrade];
/// The wire spelling.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Upgrade => "upgrade",
Self::Downgrade => "downgrade",
}
}
}
/// How a session was started.
///
/// Mirrors `codewhale_state::SessionSource` by value, deliberately re-declared
/// here rather than imported: this crate must not depend on the thread store,
/// which is the one crate whose types carry paths and git identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionSource {
/// A user opened a session directly.
Interactive,
/// Resumed from a persisted session.
Resume,
/// Forked from an existing conversation.
Fork,
/// Started programmatically.
Api,
/// Not stated.
Unknown,
/// Started under CI (`CI` or `GITHUB_ACTIONS` set), including the GitHub
/// Action, so automation is never counted as a person.
Ci,
}
impl SessionSource {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[
Self::Interactive,
Self::Resume,
Self::Fork,
Self::Api,
Self::Unknown,
Self::Ci,
];
/// The wire spelling.
#[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",
Self::Ci => "ci",
}
}
}
/// How long a session lasted, bucketed. Half-open intervals, in seconds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DurationBucket {
/// `d < 60`
#[serde(rename = "lt_1m")]
Lt1m,
/// `60 <= d < 600`
#[serde(rename = "1m_10m")]
OneToTen,
/// `600 <= d < 3600`
#[serde(rename = "10m_60m")]
TenToSixty,
/// `d >= 3600`
#[serde(rename = "gt_60m")]
Gt60m,
}
impl DurationBucket {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[Self::Lt1m, Self::OneToTen, Self::TenToSixty, Self::Gt60m];
/// Bucket a session duration in whole seconds.
#[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,
}
}
/// The wire spelling.
#[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",
}
}
}
/// How the process ended.
///
/// Derived from an explicit atomic set by the panic hook, the signal task, and
/// the clean path — **never from an exit code**. `RunTerminationReason::Canceled`
/// maps to exit 130, the same value the SIGINT path uses, so a code-based
/// derivation would report every Esc-cancelled turn as a signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitClass {
/// Ordinary successful exit.
Clean,
/// Terminated by a signal.
Signal,
/// Terminated by a panic.
Panic,
/// Exited non-successfully without a signal or a panic.
Error,
}
impl ExitClass {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];
/// The wire spelling.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Clean => "clean",
Self::Signal => "signal",
Self::Panic => "panic",
Self::Error => "error",
}
}
/// Stable numeric encoding for the process-wide `AtomicU8`.
#[must_use]
pub fn as_u8(self) -> u8 {
match self {
Self::Clean => 0,
Self::Signal => 1,
Self::Panic => 2,
Self::Error => 3,
}
}
/// Inverse of [`Self::as_u8`]; anything unrecognised reads as `Clean`.
#[must_use]
pub fn from_u8(value: u8) -> Self {
match value {
1 => Self::Signal,
2 => Self::Panic,
3 => Self::Error,
_ => Self::Clean,
}
}
}
/// Cold-start time, bucketed, in milliseconds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ColdStartBucket {
/// `ms < 250`
#[serde(rename = "lt_250")]
Lt250,
/// `250 <= ms < 1000`
#[serde(rename = "250_1000")]
Mid,
/// `1000 <= ms < 3000`
#[serde(rename = "1000_3000")]
Slow,
/// `ms >= 3000`
#[serde(rename = "gte_3000")]
Gte3000,
}
impl ColdStartBucket {
/// Every value, for exhaustive iteration.
pub const ALL: &'static [Self] = &[Self::Lt250, Self::Mid, Self::Slow, Self::Gte3000];
/// Bucket a cold-start measurement in milliseconds.
#[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,
}
}
/// The wire spelling.
#[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",
}
}
}
/// Feature-use counts for one session.
///
/// A struct of named `u32`s rather than a map, deliberately: a
/// `BTreeMap<&'static str, u32>` is an open key set the compiler cannot police,
/// so the doc-match test would be asserting that the doc matches a fixture
/// rather than the binary. Adding a counter now requires editing this file,
/// which is where that test lives. Every field serializes, including zeros.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Counters {
/// Model turns completed.
pub turns: u32,
/// Tool calls executed, across every surface.
pub tool_calls: u32,
/// Fleet dispatches started.
pub fleet_dispatch: u32,
/// `workflow_run` invocations, keyed off the parsed action discriminant.
pub workflow_run: u32,
/// Sub-agents spawned.
pub subagent_spawn: u32,
/// MCP servers that reached `connected`.
pub mcp_server_connected: u32,
/// Native-memory searches.
pub memory_search: u32,
/// Approval modals shown.
pub approval_modal_shown: u32,
/// Approvals granted by an auto-allow rule.
pub approval_auto_allowed: u32,
/// Command-palette opens.
pub command_palette_open: u32,
}
impl Counters {
/// Field names in declaration order, for the doc-match test.
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",
];
}
/// Closed aggregate product counters shared with browser and app clients.
/// Runtime sessions keep their existing counters and add no second collector.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProductCounters {
/// Aggregate page view count.
pub page_view: u32,
/// Aggregate docs view count.
pub docs_view: u32,
/// Aggregate install copy count.
pub install_copy: u32,
/// Aggregate download count.
pub download: u32,
/// Aggregate signup count.
pub signup: u32,
/// Aggregate login count.
pub login: u32,
/// Aggregate session create count.
pub session_create: u32,
/// Aggregate session resume count.
pub session_resume: u32,
/// Aggregate turn submit count.
pub turn_submit: u32,
/// Aggregate turn complete count.
pub turn_complete: u32,
/// Aggregate settings open count.
pub settings_open: u32,
/// Aggregate integration connect count.
pub integration_connect: u32,
/// Aggregate error shown count.
pub error_shown: u32,
}
impl ProductCounters {
/// Closed wire field names.
pub const FIELDS: &'static [&'static str] = &[
"page_view",
"docs_view",
"install_copy",
"download",
"signup",
"login",
"session_create",
"session_resume",
"turn_submit",
"turn_complete",
"settings_open",
"integration_connect",
"error_shown",
];
}
/// Error counts for one session.
///
/// Every value is a count of a **variant discriminant**, never of an
/// `err.to_string()`. `ToolError::PathEscape`'s `Display` *is* an absolute path;
/// the secret store's *is* the store's absolute path; every `LlmError` variant
/// carries the raw provider HTTP body verbatim, and a 400 from a content filter
/// routinely echoes the prompt.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Errors {
/// Credential preflight rejected the route.
pub auth_preflight_failed: u32,
/// Provider responded 4xx.
pub provider_http_4xx: u32,
/// Provider responded 5xx.
pub provider_http_5xx: u32,
/// A tool call was denied by policy.
pub tool_denied_by_policy: u32,
/// A tool call timed out.
pub tool_timeout: u32,
/// A request failed below HTTP — DNS, connect, TLS, or timeout.
pub network_error: u32,
}
impl Errors {
/// Field names in declaration order, for the doc-match test.
pub const FIELDS: &'static [&'static str] = &[
"auth_preflight_failed",
"provider_http_4xx",
"provider_http_5xx",
"tool_denied_by_policy",
"tool_timeout",
"network_error",
];
}
/// Per-session histogram of turn wall-clock time.
///
/// A histogram, never a per-turn series: a timestamped stream of turn durations
/// reconstructs a session's working rhythm, which is the same objection that
/// rules out per-tool-call phone-home.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnWall {
/// Turns under 5 seconds.
pub lt_5s: u32,
/// Turns in `[5s, 30s)`.
#[serde(rename = "5_30s")]
pub five_to_thirty: u32,
/// Turns in `[30s, 120s)`.
#[serde(rename = "30_120s")]
pub thirty_to_onetwenty: u32,
/// Turns at or over 120 seconds.
pub gte_120s: u32,
}
impl TurnWall {
/// Field names in wire spelling, in declaration order.
pub const FIELDS: &'static [&'static str] = &["lt_5s", "5_30s", "30_120s", "gte_120s"];
/// Record one turn of `secs` wall-clock seconds.
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),
}
}
}
/// One telemetry event.
///
/// The tag is the `event` key, so the wire form is flat and the variant set is
/// closed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
/// This binary's version differs from the one last recorded on this machine.
InstallOrUpgrade {
/// Install, upgrade, or downgrade.
kind: InstallKind,
/// The previously recorded version, read from the telemetry state file
/// **only** — never from session history or config mtimes, which have a
/// different privacy contract.
previous_version: Option<String>,
},
/// A session began.
SessionStart {
/// How it was started.
source: SessionSource,
},
/// A session ended. Everything the session accumulated ships here, once.
SessionEnd {
/// Bucketed wall-clock session length.
duration_bucket: DurationBucket,
/// How the process ended.
exit_class: ExitClass,
/// Bucketed cold start. `null` on surfaces that do not measure it.
cold_start_bucket: Option<ColdStartBucket>,
/// Sorted, deduplicated `ProviderKind` names. A custom provider yields
/// the literal `"custom"`, never the customer's `[providers.<name>]`
/// table key, and no model id is sent for any provider.
providers: Vec<String>,
/// Feature-use counts.
counters: Counters,
/// Error counts.
errors: Errors,
/// Turn wall-clock histogram.
turn_wall: TurnWall,
},
/// The process panicked.
Panic {
/// Source location, reduced by the `crates/` allowlist. Never the panic
/// *message*: a slicing panic embeds the entire string being sliced, and
/// this tree slices user and model text in dozens of places.
site: String,
},
/// Anonymous service health aggregates under explicit operator consent.
OperationsSummary {
/// Aggregate requests.
requests: u32,
/// Aggregate errors.
errors: u32,
/// Aggregate duration ms total.
duration_ms_total: u32,
/// Aggregate duration ms max.
duration_ms_max: u32,
/// Aggregate probes.
probes: u32,
/// Aggregate probes failed.
probes_failed: u32,
},
/// Aggregate browser or application interactions without a timeline.
ProductUsage {
/// Closed counts; never a URL, user identity, or work content.
counters: ProductCounters,
},
/// One session's model turns and token counts for one provider and model.
ModelUsage {
/// A known provider id ([`is_known_provider_id`]).
provider: String,
/// A bundled-catalog model id, or `other` ([`is_reported_model_id`]).
model: String,
/// Turn outcomes and token totals.
counters: ModelCounters,
/// Turn wall-clock buckets for these turns.
turn_wall: TurnWall,
},
/// One session's calls of one tool.
ToolUsage {
/// A first-party tool name, `mcp` or `other` ([`is_reported_tool_name`]).
tool: String,
/// Calls made.
calls: u32,
/// Calls that returned an error.
errors: u32,
},
}
/// Turn outcomes and token totals for one provider and model in a session.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelCounters {
/// Turns that completed.
pub turns_ok: u32,
/// Turns that failed.
pub turns_failed: u32,
/// Turns the user or the runtime interrupted.
pub turns_interrupted: u32,
/// Input tokens, including cache reads.
pub input_tokens: u64,
/// Output tokens, including reasoning.
pub output_tokens: u64,
/// Input tokens served from the provider's prompt cache.
pub cache_read_tokens: u64,
/// Input tokens written to the provider's prompt cache.
pub cache_write_tokens: u64,
}
impl ModelCounters {
/// Wire keys, in order.
pub const FIELDS: &'static [&'static str] = &[
"turns_ok",
"turns_failed",
"turns_interrupted",
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
];
}
/// First-party tool names reported by `tool_usage`. Any other name is
/// reported as `mcp` or `other`, so a plugin's or server's tool name never
/// leaves the machine.
pub const TOOL_NAMES: &[&str] = &[
"agent",
"apply_patch",
"authenticate",
"bash",
"code_execution",
"context_archive_get",
"context_archive_search",
"create_goal",
"diagnostics",
"edit",
"edit_file",
"exec_shell",
"execute_tools",
"fetch_url",
"file_search",
"fim_edit",
"finance",
"get_goal",
"git_blame",
"git_commit_plan",
"git_diff",
"git_fetch",
"git_log",
"git_merge_tree",
"git_show",
"git_status",
"grep_files",
"handle_read",
"image_ocr",
"js_execution",
"list_dir",
"load_skill",
"lsp",
"memory_get",
"memory_search",
"note",
"notify",
"pandoc_convert",
"project_map",
"read",
"read_file",
"read_media",
"remember",
"request_plugin_install",
"request_user_input",
"retrieve_tool_result",
"revert_turn",
"review",
"rlm",
"run_tests",
"run_verifiers",
"session_get",
"session_search",
"start_mcp_server",
"task_create",
"task_shell_wait",
"todo_write",
"tool_search",
"update_goal",
"update_plan",
"validate_data",
"verify",
"wait_for_dev_server",
"web_search",
"workflow",
"write",
"write_file",
];
/// The `tool` value for a tool name: itself when first-party, `mcp` for an
/// MCP pool tool, otherwise `other`.
#[must_use]
pub fn reported_tool_name(name: &str, is_mcp: bool) -> &'static str {
match TOOL_NAMES.iter().find(|known| **known == name) {
Some(known) => known,
None if is_mcp => "mcp",
None => "other",
}
}
/// Whether `value` may appear as `tool_usage.tool`.
#[must_use]
pub fn is_reported_tool_name(value: &str) -> bool {
value == "mcp" || value == "other" || TOOL_NAMES.contains(&value)
}
/// The `model` value for a model id: the lowercased id when the bundled model
/// catalog lists it, otherwise `other`. Custom, local and fine-tuned model
/// names therefore never leave the machine.
#[must_use]
pub fn reported_model_id(model: &str) -> String {
let id = model.trim().to_ascii_lowercase();
if is_catalogued_model(&id) {
id
} else {
"other".to_string()
}
}
/// Whether `value` may appear as `model_usage.model`.
#[must_use]
pub fn is_reported_model_id(value: &str) -> bool {
value == "other" || (value == value.to_ascii_lowercase() && is_catalogued_model(value))
}
fn is_catalogued_model(id: &str) -> bool {
if id.is_empty() || id.len() > 128 {
return false;
}
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
catalog.reviewed.intrinsic.contains_key(id)
|| catalog
.models
.keys()
.any(|key| key.eq_ignore_ascii_case(id))
|| catalog.providers.values().any(|provider| {
provider
.models
.keys()
.any(|key| key.eq_ignore_ascii_case(id))
})
}
impl Event {
/// The `event` discriminant, for the doc-match test.
#[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",
Self::ProductUsage { .. } => "product_usage",
Self::ModelUsage { .. } => "model_usage",
Self::ToolUsage { .. } => "tool_usage",
Self::OperationsSummary { .. } => "operations_summary",
}
}
/// Whether every string this event carries is inside its declared bound.
///
/// The bounds above are enforced by the *constructors* — `Counters` is a
/// struct of `u32`s, `providers` comes from `ProviderKind::as_str()`, and
/// `site` comes from [`crate::reduce_panic_site`]. That holds only for an
/// event this process built. Events are also **read back from
/// `buffer.jsonl` and deserialized** before a batch is assembled, and
/// `serde` will happily fill `site`, `previous_version`, and `providers`
/// with any string the file contains. Anything running as this user can
/// append a line to that file — including a `Bash` tool call this session
/// made on the model's behalf — so the drain path must re-establish the
/// bound rather than inherit it.
///
/// Failing this check drops the event. It is never *sanitized*: a payload
/// that the schema cannot account for is not made safe by editing it.
#[must_use]
pub fn is_bounded(&self) -> bool {
match self {
Self::SessionStart { .. }
| Self::ProductUsage { .. }
| Self::OperationsSummary { .. } => 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),
Self::ModelUsage {
provider, model, ..
} => is_known_provider_id(provider) && is_reported_model_id(model),
Self::ToolUsage { tool, .. } => is_reported_tool_name(tool),
}
}
}
/// Whether `value` is a release version this schema may carry.
///
/// `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$` — the rule already written on
/// [`Batch::app_version`], applied to `previous_version` as well because that
/// field is read back from `state.json` rather than built in this process.
#[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'.'),
}
}
/// Whether `value` is in the output space of [`crate::reduce_panic_site`]:
/// the literal `<dep>`, or `crates/….rs:<line>:<column>` over the allowlist
/// charset.
#[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())
}
/// Whether `value` is a provider id this build knows.
///
/// Checked against the **full** provider registry, not
/// `ProviderKind::all()`: that list is the selectable catalog subset, while
/// captured intrinsic kinds legitimately include dialect kinds
/// (`deepseek-anthropic`, the Model Studio plan variants) that are absent from
/// it. Narrowing to the catalog would silently drop a real user's route.
#[must_use]
pub fn is_known_provider_id(value: &str) -> bool {
codewhale_config::provider::all_providers()
.iter()
.any(|provider| provider.id() == value)
}
/// The POST body. One per flush.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Batch {
/// [`SCHEMA_VERSION`].
pub schema_version: u32,
/// [`NOTICE_VERSION`], policy metadata rather than proof of acceptance.
pub notice_version: u32,
/// RFC3339 UTC, second precision. Per-**batch** only — events carry no
/// timestamps at all.
pub sent_at: String,
/// Random v4 UUID stored on this machine, rotated every 90 days.
pub install_id: String,
/// `CARGO_PKG_VERSION`. Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`.
pub app_version: String,
/// First 12 hex chars of the release-CI build sha, or `null` for every
/// locally built binary.
pub git_sha: Option<String>,
/// Which surface produced this batch.
pub surface: Surface,
/// OS family.
pub os: Os,
/// CPU family.
pub arch: Arch,
/// Compile-time libc.
pub libc: Libc,
/// Whether both stdin and stdout were terminals.
pub tty: bool,
/// The events.
pub events: Vec<Event>,
}
impl Batch {
/// Envelope field names in declaration order, for the doc-match test.
pub const FIELDS: &'static [&'static str] = &[
"schema_version",
"notice_version",
"sent_at",
"install_id",
"app_version",
"git_sha",
"surface",
"os",
"arch",
"libc",
"tty",
"events",
];
}