crates/telemetry/src/counters.rs
Rama main · réplica de lectura del repositorio original.
//! Process-wide session accumulators.
//!
//! Atomics rather than a lock, because every bump happens on a hot path and
//! must cost nothing measurable. Every bump also happens at a **call site**,
//! never inside a conditionally-entered handler: a counter sited above an early
//! return is a landmine, and the natural future optimization — hoisting the
//! guard to the caller — silently zeroes it for exactly the users who do not
//! use that feature.
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use crate::event::{Counters, Errors, Event, ModelCounters, TurnWall};
/// One session's accumulated counts.
#[derive(Debug, Default)]
pub struct SessionCounters {
turns: AtomicU32,
tool_calls: AtomicU32,
fleet_dispatch: AtomicU32,
workflow_run: AtomicU32,
subagent_spawn: AtomicU32,
mcp_server_connected: AtomicU32,
memory_search: AtomicU32,
approval_modal_shown: AtomicU32,
approval_auto_allowed: AtomicU32,
command_palette_open: AtomicU32,
auth_preflight_failed: AtomicU32,
provider_http_4xx: AtomicU32,
provider_http_5xx: AtomicU32,
tool_denied_by_policy: AtomicU32,
tool_timeout: AtomicU32,
network_error: AtomicU32,
turn_wall: Mutex<TurnWall>,
/// Sorted, deduplicated `ProviderKind::as_str()` values. `&'static str`
/// only: a `String` here would be the seam through which a customer's
/// `[providers.<name>]` table key reaches the wire.
providers: Mutex<Vec<&'static str>>,
models: Mutex<Vec<ModelSlot>>,
tools: Mutex<Vec<(&'static str, u32, u32)>>,
}
#[derive(Debug)]
struct ModelSlot {
provider: &'static str,
model: String,
counters: ModelCounters,
turn_wall: TurnWall,
}
/// At most this many distinct provider and model pairs per session; later
/// pairs are folded into the provider's `other` row.
const MAX_MODEL_ROWS: usize = 16;
/// How one model turn ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnOutcome {
/// Completed.
Ok,
/// Failed.
Failed,
/// Interrupted by the user or the runtime.
Interrupted,
}
/// Token counts for one model turn.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TurnTokens {
/// Input tokens, including cache reads.
pub input: u64,
/// Output tokens.
pub output: u64,
/// Input tokens read from the prompt cache.
pub cache_read: u64,
/// Input tokens written to the prompt cache.
pub cache_write: u64,
}
/// Which counter to bump. A closed enum so a call site cannot invent a key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Counter {
/// A model turn completed.
Turns,
/// A tool call was executed.
ToolCalls,
/// A fleet dispatch started.
FleetDispatch,
/// A `workflow_run` action was parsed and executed.
WorkflowRun,
/// A sub-agent was spawned.
SubagentSpawn,
/// An MCP server reached `connected`.
McpServerConnected,
/// A native-memory search ran.
MemorySearch,
/// An approval modal was shown.
ApprovalModalShown,
/// An approval was granted by an auto-allow rule.
ApprovalAutoAllowed,
/// The command palette was opened.
CommandPaletteOpen,
}
/// Which error counter to bump.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCounter {
/// Credential preflight rejected the route.
AuthPreflightFailed,
/// Provider responded 4xx.
ProviderHttp4xx,
/// Provider responded 5xx.
ProviderHttp5xx,
/// A tool call was denied by policy.
ToolDeniedByPolicy,
/// A tool call timed out.
ToolTimeout,
/// A request failed below HTTP.
NetworkError,
}
impl SessionCounters {
/// Bump a feature counter by one.
pub fn bump(&self, counter: Counter) {
self.slot(counter).fetch_add(1, Ordering::Relaxed);
}
/// Bump an error counter by one.
pub fn bump_error(&self, counter: ErrorCounter) {
self.error_slot(counter).fetch_add(1, Ordering::Relaxed);
}
/// Record one turn's wall-clock time in the histogram.
pub fn observe_turn_secs(&self, secs: u64) {
if let Ok(mut wall) = self.turn_wall.lock() {
wall.observe_secs(secs);
}
}
/// Record that a provider was routed to.
///
/// Takes a `ProviderKind` **by value**, never a `&str`: the four
/// persistence and label accessors that look like the natural seam all
/// return the customer's own `[providers.<name>]` table key when the route
/// is custom, and `/status` already prints it. `ProviderKind::Custom`
/// yields the literal `"custom"` and nothing else.
pub fn record_provider(&self, provider: codewhale_config::ProviderKind) {
let name = provider.as_str();
if let Ok(mut providers) = self.providers.lock()
&& !providers.contains(&name)
{
providers.push(name);
providers.sort_unstable();
}
}
/// Record one model turn: its provider, model, outcome, tokens and wall time.
pub fn record_model_turn(
&self,
provider: codewhale_config::ProviderKind,
model: &str,
outcome: TurnOutcome,
tokens: TurnTokens,
secs: u64,
) {
let provider = provider.as_str();
let mut model = crate::event::reported_model_id(model);
let Ok(mut models) = self.models.lock() else {
return;
};
let known = models
.iter()
.any(|slot| slot.provider == provider && slot.model == model);
if !known && models.len() >= MAX_MODEL_ROWS {
model = "other".to_string();
}
let index = match models
.iter()
.position(|slot| slot.provider == provider && slot.model == model)
{
Some(index) => index,
None => {
models.push(ModelSlot {
provider,
model,
counters: ModelCounters::default(),
turn_wall: TurnWall::default(),
});
models.len() - 1
}
};
let slot = &mut models[index];
let counters = &mut slot.counters;
match outcome {
TurnOutcome::Ok => counters.turns_ok = counters.turns_ok.saturating_add(1),
TurnOutcome::Failed => counters.turns_failed = counters.turns_failed.saturating_add(1),
TurnOutcome::Interrupted => {
counters.turns_interrupted = counters.turns_interrupted.saturating_add(1);
}
}
counters.input_tokens = counters.input_tokens.saturating_add(tokens.input);
counters.output_tokens = counters.output_tokens.saturating_add(tokens.output);
counters.cache_read_tokens = counters.cache_read_tokens.saturating_add(tokens.cache_read);
counters.cache_write_tokens = counters
.cache_write_tokens
.saturating_add(tokens.cache_write);
slot.turn_wall.observe_secs(secs);
}
/// Record one tool call by name. `is_mcp` marks an MCP pool tool.
pub fn record_tool_call(&self, name: &str, is_mcp: bool, failed: bool) {
let tool = crate::event::reported_tool_name(name, is_mcp);
let Ok(mut tools) = self.tools.lock() else {
return;
};
let index = match tools.iter().position(|(known, _, _)| *known == tool) {
Some(index) => index,
None => {
tools.push((tool, 0, 0));
tools.len() - 1
}
};
let row = &mut tools[index];
row.1 = row.1.saturating_add(1);
if failed {
row.2 = row.2.saturating_add(1);
}
}
/// The `model_usage` and `tool_usage` events for this session so far.
#[must_use]
pub fn usage_events(&self) -> Vec<Event> {
let mut events = Vec::new();
if let Ok(models) = self.models.lock() {
events.extend(models.iter().map(|slot| Event::ModelUsage {
provider: slot.provider.to_string(),
model: slot.model.clone(),
counters: slot.counters,
turn_wall: slot.turn_wall,
}));
}
if let Ok(tools) = self.tools.lock() {
events.extend(tools.iter().map(|(tool, calls, errors)| Event::ToolUsage {
tool: (*tool).to_string(),
calls: *calls,
errors: *errors,
}));
}
events
}
/// Snapshot the feature counters.
#[must_use]
pub fn counters(&self) -> Counters {
Counters {
turns: self.turns.load(Ordering::Relaxed),
tool_calls: self.tool_calls.load(Ordering::Relaxed),
fleet_dispatch: self.fleet_dispatch.load(Ordering::Relaxed),
workflow_run: self.workflow_run.load(Ordering::Relaxed),
subagent_spawn: self.subagent_spawn.load(Ordering::Relaxed),
mcp_server_connected: self.mcp_server_connected.load(Ordering::Relaxed),
memory_search: self.memory_search.load(Ordering::Relaxed),
approval_modal_shown: self.approval_modal_shown.load(Ordering::Relaxed),
approval_auto_allowed: self.approval_auto_allowed.load(Ordering::Relaxed),
command_palette_open: self.command_palette_open.load(Ordering::Relaxed),
}
}
/// Snapshot the error counters.
#[must_use]
pub fn errors(&self) -> Errors {
Errors {
auth_preflight_failed: self.auth_preflight_failed.load(Ordering::Relaxed),
provider_http_4xx: self.provider_http_4xx.load(Ordering::Relaxed),
provider_http_5xx: self.provider_http_5xx.load(Ordering::Relaxed),
tool_denied_by_policy: self.tool_denied_by_policy.load(Ordering::Relaxed),
tool_timeout: self.tool_timeout.load(Ordering::Relaxed),
network_error: self.network_error.load(Ordering::Relaxed),
}
}
/// Snapshot the turn wall-clock histogram.
#[must_use]
pub fn turn_wall(&self) -> TurnWall {
self.turn_wall.lock().map(|wall| *wall).unwrap_or_default()
}
/// Snapshot the sorted, deduplicated provider set.
#[must_use]
pub fn providers(&self) -> Vec<String> {
self.providers
.lock()
.map(|providers| providers.iter().map(|name| (*name).to_string()).collect())
.unwrap_or_default()
}
fn slot(&self, counter: Counter) -> &AtomicU32 {
match counter {
Counter::Turns => &self.turns,
Counter::ToolCalls => &self.tool_calls,
Counter::FleetDispatch => &self.fleet_dispatch,
Counter::WorkflowRun => &self.workflow_run,
Counter::SubagentSpawn => &self.subagent_spawn,
Counter::McpServerConnected => &self.mcp_server_connected,
Counter::MemorySearch => &self.memory_search,
Counter::ApprovalModalShown => &self.approval_modal_shown,
Counter::ApprovalAutoAllowed => &self.approval_auto_allowed,
Counter::CommandPaletteOpen => &self.command_palette_open,
}
}
fn error_slot(&self, counter: ErrorCounter) -> &AtomicU32 {
match counter {
ErrorCounter::AuthPreflightFailed => &self.auth_preflight_failed,
ErrorCounter::ProviderHttp4xx => &self.provider_http_4xx,
ErrorCounter::ProviderHttp5xx => &self.provider_http_5xx,
ErrorCounter::ToolDeniedByPolicy => &self.tool_denied_by_policy,
ErrorCounter::ToolTimeout => &self.tool_timeout,
ErrorCounter::NetworkError => &self.network_error,
}
}
}
/// Classify a provider HTTP status into the 4xx or 5xx counter, if either
/// applies. Captured from `status.as_u16() / 100` **before** the error is
/// built — every `LlmError` variant carries the raw provider body verbatim.
#[must_use]
pub fn http_status_counter(status: u16) -> Option<ErrorCounter> {
match status / 100 {
4 => Some(ErrorCounter::ProviderHttp4xx),
5 => Some(ErrorCounter::ProviderHttp5xx),
_ => None,
}
}