Watch
0
0
Fork
You've already forked hyperhive
0

alert when claude usage runs ahead of the clock, per account

This commit is contained in:
atlas 2026-11-07 20:32:09 +01:00 • committed by mara
commit 319dd6a808

View file

@ -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());