Skip to main content

codewhale_telemetry/
counters.rs

1//! Process-wide session accumulators.
2//!
3//! Atomics rather than a lock, because every bump happens on a hot path and
4//! must cost nothing measurable. Every bump also happens at a **call site**,
5//! never inside a conditionally-entered handler: a counter sited above an early
6//! return is a landmine, and the natural future optimization — hoisting the
7//! guard to the caller — silently zeroes it for exactly the users who do not
8//! use that feature.
9
10use std::sync::Mutex;
11use std::sync::atomic::{AtomicU32, Ordering};
12
13use crate::event::{Counters, Errors, TurnWall};
14
15/// One session's accumulated counts.
16#[derive(Debug, Default)]
17pub struct SessionCounters {
18    turns: AtomicU32,
19    tool_calls: AtomicU32,
20    fleet_dispatch: AtomicU32,
21    workflow_run: AtomicU32,
22    subagent_spawn: AtomicU32,
23    mcp_server_connected: AtomicU32,
24    memory_search: AtomicU32,
25    approval_modal_shown: AtomicU32,
26    approval_auto_allowed: AtomicU32,
27    command_palette_open: AtomicU32,
28
29    auth_preflight_failed: AtomicU32,
30    provider_http_4xx: AtomicU32,
31    provider_http_5xx: AtomicU32,
32    tool_denied_by_policy: AtomicU32,
33    tool_timeout: AtomicU32,
34    network_error: AtomicU32,
35
36    turn_wall: Mutex<TurnWall>,
37    /// Sorted, deduplicated `ProviderKind::as_str()` values. `&'static str`
38    /// only: a `String` here would be the seam through which a customer's
39    /// `[providers.<name>]` table key reaches the wire.
40    providers: Mutex<Vec<&'static str>>,
41}
42
43/// Which counter to bump. A closed enum so a call site cannot invent a key.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Counter {
46    /// A model turn completed.
47    Turns,
48    /// A tool call was executed.
49    ToolCalls,
50    /// A fleet dispatch started.
51    FleetDispatch,
52    /// A `workflow_run` action was parsed and executed.
53    WorkflowRun,
54    /// A sub-agent was spawned.
55    SubagentSpawn,
56    /// An MCP server reached `connected`.
57    McpServerConnected,
58    /// A native-memory search ran.
59    MemorySearch,
60    /// An approval modal was shown.
61    ApprovalModalShown,
62    /// An approval was granted by an auto-allow rule.
63    ApprovalAutoAllowed,
64    /// The command palette was opened.
65    CommandPaletteOpen,
66}
67
68/// Which error counter to bump.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ErrorCounter {
71    /// Credential preflight rejected the route.
72    AuthPreflightFailed,
73    /// Provider responded 4xx.
74    ProviderHttp4xx,
75    /// Provider responded 5xx.
76    ProviderHttp5xx,
77    /// A tool call was denied by policy.
78    ToolDeniedByPolicy,
79    /// A tool call timed out.
80    ToolTimeout,
81    /// A request failed below HTTP.
82    NetworkError,
83}
84
85impl SessionCounters {
86    /// Bump a feature counter by one.
87    pub fn bump(&self, counter: Counter) {
88        self.slot(counter).fetch_add(1, Ordering::Relaxed);
89    }
90
91    /// Bump an error counter by one.
92    pub fn bump_error(&self, counter: ErrorCounter) {
93        self.error_slot(counter).fetch_add(1, Ordering::Relaxed);
94    }
95
96    /// Record one turn's wall-clock time in the histogram.
97    pub fn observe_turn_secs(&self, secs: u64) {
98        if let Ok(mut wall) = self.turn_wall.lock() {
99            wall.observe_secs(secs);
100        }
101    }
102
103    /// Record that a provider was routed to.
104    ///
105    /// Takes a `ProviderKind` **by value**, never a `&str`: the four
106    /// persistence and label accessors that look like the natural seam all
107    /// return the customer's own `[providers.<name>]` table key when the route
108    /// is custom, and `/status` already prints it. `ProviderKind::Custom`
109    /// yields the literal `"custom"` and nothing else.
110    pub fn record_provider(&self, provider: codewhale_config::ProviderKind) {
111        let name = provider.as_str();
112        if let Ok(mut providers) = self.providers.lock()
113            && !providers.contains(&name)
114        {
115            providers.push(name);
116            providers.sort_unstable();
117        }
118    }
119
120    /// Snapshot the feature counters.
121    #[must_use]
122    pub fn counters(&self) -> Counters {
123        Counters {
124            turns: self.turns.load(Ordering::Relaxed),
125            tool_calls: self.tool_calls.load(Ordering::Relaxed),
126            fleet_dispatch: self.fleet_dispatch.load(Ordering::Relaxed),
127            workflow_run: self.workflow_run.load(Ordering::Relaxed),
128            subagent_spawn: self.subagent_spawn.load(Ordering::Relaxed),
129            mcp_server_connected: self.mcp_server_connected.load(Ordering::Relaxed),
130            memory_search: self.memory_search.load(Ordering::Relaxed),
131            approval_modal_shown: self.approval_modal_shown.load(Ordering::Relaxed),
132            approval_auto_allowed: self.approval_auto_allowed.load(Ordering::Relaxed),
133            command_palette_open: self.command_palette_open.load(Ordering::Relaxed),
134        }
135    }
136
137    /// Snapshot the error counters.
138    #[must_use]
139    pub fn errors(&self) -> Errors {
140        Errors {
141            auth_preflight_failed: self.auth_preflight_failed.load(Ordering::Relaxed),
142            provider_http_4xx: self.provider_http_4xx.load(Ordering::Relaxed),
143            provider_http_5xx: self.provider_http_5xx.load(Ordering::Relaxed),
144            tool_denied_by_policy: self.tool_denied_by_policy.load(Ordering::Relaxed),
145            tool_timeout: self.tool_timeout.load(Ordering::Relaxed),
146            network_error: self.network_error.load(Ordering::Relaxed),
147        }
148    }
149
150    /// Snapshot the turn wall-clock histogram.
151    #[must_use]
152    pub fn turn_wall(&self) -> TurnWall {
153        self.turn_wall.lock().map(|wall| *wall).unwrap_or_default()
154    }
155
156    /// Snapshot the sorted, deduplicated provider set.
157    #[must_use]
158    pub fn providers(&self) -> Vec<String> {
159        self.providers
160            .lock()
161            .map(|providers| providers.iter().map(|name| (*name).to_string()).collect())
162            .unwrap_or_default()
163    }
164
165    fn slot(&self, counter: Counter) -> &AtomicU32 {
166        match counter {
167            Counter::Turns => &self.turns,
168            Counter::ToolCalls => &self.tool_calls,
169            Counter::FleetDispatch => &self.fleet_dispatch,
170            Counter::WorkflowRun => &self.workflow_run,
171            Counter::SubagentSpawn => &self.subagent_spawn,
172            Counter::McpServerConnected => &self.mcp_server_connected,
173            Counter::MemorySearch => &self.memory_search,
174            Counter::ApprovalModalShown => &self.approval_modal_shown,
175            Counter::ApprovalAutoAllowed => &self.approval_auto_allowed,
176            Counter::CommandPaletteOpen => &self.command_palette_open,
177        }
178    }
179
180    fn error_slot(&self, counter: ErrorCounter) -> &AtomicU32 {
181        match counter {
182            ErrorCounter::AuthPreflightFailed => &self.auth_preflight_failed,
183            ErrorCounter::ProviderHttp4xx => &self.provider_http_4xx,
184            ErrorCounter::ProviderHttp5xx => &self.provider_http_5xx,
185            ErrorCounter::ToolDeniedByPolicy => &self.tool_denied_by_policy,
186            ErrorCounter::ToolTimeout => &self.tool_timeout,
187            ErrorCounter::NetworkError => &self.network_error,
188        }
189    }
190}
191
192/// Classify a provider HTTP status into the 4xx or 5xx counter, if either
193/// applies. Captured from `status.as_u16() / 100` **before** the error is
194/// built — every `LlmError` variant carries the raw provider body verbatim.
195#[must_use]
196pub fn http_status_counter(status: u16) -> Option<ErrorCounter> {
197    match status / 100 {
198        4 => Some(ErrorCounter::ProviderHttp4xx),
199        5 => Some(ErrorCounter::ProviderHttp5xx),
200        _ => None,
201    }
202}