alert when claude usage runs ahead of the clock, per account
This commit is contained in:
parent
dab7768869
commit
319dd6a808
2 changed files with 191 additions and 28 deletions
|
|
@ -27,6 +27,8 @@ const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
|||
const OAUTH_BETA: &str = "oauth-2025-04-20";
|
||||
/// The credentials file `claude` itself reads its OAuth session from.
|
||||
const CREDENTIALS_FILE: &str = ".credentials.json";
|
||||
/// claude's config file, beside the credentials directory.
|
||||
const CONFIG_FILE: &str = ".claude.json";
|
||||
|
||||
/// No `Debug`/`Display`, so the token cannot end up in a log line by
|
||||
/// accident.
|
||||
|
|
@ -64,6 +66,33 @@ struct Window {
|
|||
resets_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// The opaque id of the claude login from the claude config file
|
||||
/// (`oauthAccount.accountUuid`). Only that one field is deserialised: the
|
||||
/// same object holds the email address, which must never reach a metric.
|
||||
fn account_from(bytes: &[u8]) -> Option<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Config {
|
||||
#[serde(rename = "oauthAccount")]
|
||||
account: Option<Account>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Account {
|
||||
#[serde(rename = "accountUuid")]
|
||||
uuid: Option<String>,
|
||||
}
|
||||
let config = serde_json::from_slice::<Config>(bytes).ok()?;
|
||||
config.account?.uuid.filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// `claude.json` sits next to the `.claude` directory. Read on every tick so
|
||||
/// a re-login is followed.
|
||||
async fn read_account(claude_dir: &Path) -> Option<String> {
|
||||
let bytes = tokio::fs::read(claude_dir.with_file_name(CONFIG_FILE))
|
||||
.await
|
||||
.ok()?;
|
||||
account_from(&bytes)
|
||||
}
|
||||
|
||||
/// Background loop. Detached task — runs for the harness's lifetime; every
|
||||
/// failure is logged and retried on the next tick, never fatal.
|
||||
pub async fn run() {
|
||||
|
|
@ -81,7 +110,8 @@ pub async fn run() {
|
|||
return;
|
||||
}
|
||||
};
|
||||
let creds = crate::paths::claude_dir().join(CREDENTIALS_FILE);
|
||||
let claude_dir = crate::paths::claude_dir();
|
||||
let creds = claude_dir.join(CREDENTIALS_FILE);
|
||||
let mut skip_logged = false;
|
||||
// Separate streak from `skip_logged`: a token can be usable while the
|
||||
// response still carries no recognisable window (endpoint change,
|
||||
|
|
@ -115,8 +145,14 @@ pub async fn run() {
|
|||
"claude usage watch: usage response had no recognisable windows"
|
||||
);
|
||||
}
|
||||
let account = read_account(&claude_dir).await;
|
||||
for w in windows {
|
||||
crate::otel_turn_metrics::record_claude_usage(&w.name, w.percent, w.resets_at);
|
||||
crate::otel_turn_metrics::record_claude_usage(
|
||||
account.as_deref(),
|
||||
&w.name,
|
||||
w.percent,
|
||||
w.resets_at,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("claude usage watch: {e}"),
|
||||
|
|
@ -250,6 +286,28 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
const CONFIG: &str = r#"{
|
||||
"oauthAccount": { "accountUuid": "0a1b-uuid", "emailAddress": "someone@example.com" },
|
||||
"projects": {}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn account_is_the_uuid_and_never_the_email() {
|
||||
let id = account_from(CONFIG.as_bytes()).expect("account");
|
||||
assert_eq!(id, "0a1b-uuid");
|
||||
assert!(!id.contains('@'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_account_is_none() {
|
||||
assert_eq!(account_from(b"{}"), None);
|
||||
assert_eq!(
|
||||
account_from(br#"{"oauthAccount":{"accountUuid":""}}"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(account_from(b"not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_object_body_yields_nothing() {
|
||||
assert!(windows(&Value::Null).is_empty());
|
||||
|
|
|
|||
|
|
@ -224,14 +224,30 @@ pub fn record_open_todos(count: u64) {
|
|||
inst.open_todos.record(count, &[]);
|
||||
}
|
||||
|
||||
/// Attributes of a usage series: `window`, plus `account` when the login is
|
||||
/// known. An unknown account omits the label rather than sending an empty one.
|
||||
fn claude_usage_attrs(account: Option<&str>, window: &str) -> Vec<KeyValue> {
|
||||
let mut attrs = vec![KeyValue::new("window", window.to_owned())];
|
||||
if let Some(account) = account.filter(|a| !a.is_empty()) {
|
||||
attrs.push(KeyValue::new("account", account.to_owned()));
|
||||
}
|
||||
attrs
|
||||
}
|
||||
|
||||
/// Record one subscription usage window (`five_hour`, `seven_day`, …):
|
||||
/// `percent` is 0-100, `resets_at` unix seconds. No-op when OTEL isn't
|
||||
/// configured, same as [`record`].
|
||||
pub fn record_claude_usage(window: &str, percent: f64, resets_at: Option<u64>) {
|
||||
/// `percent` is 0-100, `resets_at` unix seconds. `account` is the opaque
|
||||
/// id of the claude login, shared by every agent on that login. No-op when
|
||||
/// OTEL isn't configured, same as [`record`].
|
||||
pub fn record_claude_usage(
|
||||
account: Option<&str>,
|
||||
window: &str,
|
||||
percent: f64,
|
||||
resets_at: Option<u64>,
|
||||
) {
|
||||
let Some(inst) = INSTRUMENTS.get_or_init(build).as_ref() else {
|
||||
return;
|
||||
};
|
||||
let attrs = [KeyValue::new("window", window.to_owned())];
|
||||
let attrs = claude_usage_attrs(account, window);
|
||||
inst.claude_usage_percent.record(percent, &attrs);
|
||||
if let Some(resets_at) = resets_at {
|
||||
inst.claude_usage_resets_at.record(resets_at, &attrs);
|
||||
|
|
@ -382,7 +398,9 @@ pub fn enabled() -> bool {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TURN_DURATION_BOUNDS_MS, tool_call_counts};
|
||||
use opentelemetry::KeyValue;
|
||||
|
||||
use super::{TURN_DURATION_BOUNDS_MS, claude_usage_attrs, tool_call_counts};
|
||||
|
||||
#[test]
|
||||
fn a_turns_stored_tool_breakdown_becomes_per_tool_counts() {
|
||||
|
|
@ -411,4 +429,18 @@ mod tests {
|
|||
.is_some_and(|&b| b > 10_000.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_attrs_carry_account_only_when_known() {
|
||||
let has = |attrs: &[KeyValue], k: &str| attrs.iter().any(|kv| kv.key.as_str() == k);
|
||||
let known = claude_usage_attrs(Some("acct-1"), "seven_day");
|
||||
assert!(has(&known, "window"));
|
||||
assert!(
|
||||
known
|
||||
.iter()
|
||||
.any(|kv| kv.key.as_str() == "account" && kv.value.as_str() == "acct-1")
|
||||
);
|
||||
assert!(!has(&claude_usage_attrs(None, "seven_day"), "account"));
|
||||
assert!(!has(&claude_usage_attrs(Some(""), "seven_day"), "account"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue