use std::cell::RefCell;
use std::collections::HashSet;
thread_local! {
static SENT: RefCell<HashSet<String>> = RefCell::new(HashSet::new());
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
pub(crate) fn enabled() -> bool {
local_storage()
.and_then(|s| s.get_item("lh_telemetry").ok().flatten())
.map(|v| v != "off")
.unwrap_or(true)
}
pub(crate) fn set_enabled(on: bool) {
if let Some(s) = local_storage() {
let _ = s.set_item("lh_telemetry", if on { "on" } else { "off" });
}
}
pub(crate) fn redact(s: &str) -> String {
s.split_inclusive(char::is_whitespace)
.map(|tok| {
let t = tok.trim();
let hex = t.trim_start_matches("0x");
let secret = (t.len() >= 20 && (t.starts_with("sk-") || t.starts_with("AIza")))
|| (hex.len() >= 64 && hex.chars().all(|c| c.is_ascii_hexdigit()));
if secret {
tok.replace(t, "[redacted]")
} else {
tok.to_string()
}
})
.collect()
}
pub(crate) async fn report(kind: String, title: String, signature: String, body: String) {
if !enabled() {
return;
}
let fresh = SENT.with(|s| s.borrow_mut().insert(signature.clone()));
if !fresh {
return; }
let Some((signer, _addr)) = crate::app::chat::credit_signer().await else {
return; };
let now = (js_sys::Date::now() / 1000.0) as u64;
let token = crate::registry::proxy_auth_token(&signer, now);
let endpoint = format!(
"{}/api/telemetry",
crate::registry::CREDIT_PROXY_URL.trim_end_matches('/')
);
let payload = serde_json::json!({
"kind": kind,
"title": redact(&title),
"signature": signature,
"body": redact(&body),
});
let _ = crate::app::net::with_timeout(8000, async {
let _ = reqwest::Client::new()
.post(&endpoint)
.header("content-type", "application/json")
.header("x-goog-api-key", token)
.json(&payload)
.send()
.await;
Ok::<(), String>(())
})
.await;
}
pub(crate) fn signature_for(agent: &str, model: &str, err: &str) -> String {
let fp: String = err
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(40)
.collect();
format!("{agent}-{model}-{fp}")
}
pub(crate) async fn report_feedback(
agent: String,
model: String,
tx_hash: String,
feedback: String,
context: String,
) {
let summary: String = feedback
.split(['\n', '.'])
.next()
.unwrap_or(&feedback)
.trim()
.chars()
.take(100)
.collect();
let title = format!("feedback ({agent}): {summary}");
let fp: String = feedback
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(48)
.collect();
let signature = format!("feedback-{agent}-{fp}");
let mut body = format!(
"agent: {agent}\nmodel: {model}\non-chain tx: {tx_hash}\n\nfeedback:\n{feedback}\n"
);
if context.trim().is_empty() {
body.push_str("\n(recent conversation context unavailable — follow up off the on-chain record above.)\n");
} else {
body.push_str("\nrecent conversation:\n");
body.push_str(&context);
body.push('\n');
}
report("feedback".to_string(), title, signature, body).await;
}