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()
}
const MAX_BODY_BYTES: usize = 24_576;
fn clamp(mut s: String) -> String {
if s.len() > MAX_BODY_BYTES {
let mut cut = MAX_BODY_BYTES;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
s.push_str("\n…(truncated)");
}
s
}
async fn post(kind: String, title: String, signature: String, body: String) {
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": title,
"signature": signature,
"body": 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) async fn report_event(
kind: String,
code: Option<u16>,
title: String,
signature: String,
freeform: String,
raw_trailer: String,
) {
let (title, signature) = match code {
Some(c) => {
let label = crate::error_codes::fmt_label(c);
(format!("{label} {title}"), format!("{label}-{signature}"))
}
None => (title, signature),
};
if !SENT.with(|s| s.borrow_mut().insert(signature.clone())) {
return; }
let mut body = redact(&freeform);
let convo = recent_conversation();
if !convo.trim().is_empty() {
body.push_str("\n\nrecent conversation:\n");
body.push_str(&redact(&convo));
}
if !raw_trailer.trim().is_empty() {
body.push_str("\n\n");
body.push_str(&raw_trailer);
}
body.push_str("\n\n");
body.push_str(&context_block().await);
post(kind, redact(&title), signature, clamp(body)).await;
}
pub(crate) fn signature_for(agent: &str, context: &str, err: &str) -> String {
let fp: String = err
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(40)
.collect();
format!("{agent}-{context}-{fp}")
}
pub(crate) async fn report_feedback(agent: String, tx_hash: Option<String>, feedback: 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 raw_trailer = match tx_hash {
Some(tx) if !tx.trim().is_empty() => format!("on-chain tx: {tx}"),
_ => String::new(),
};
report_event("feedback".to_string(), None, title, signature, feedback, raw_trailer).await;
}
pub(crate) fn recent_conversation() -> String {
const MAX_TURNS: usize = 12;
const MAX_CHARS_PER_TURN: usize = 400;
let entries = crate::app::APP
.with(|cell| cell.borrow().agent.as_ref().map(|a| a.transcript()))
.unwrap_or_default();
let start = entries.len().saturating_sub(MAX_TURNS);
entries[start..]
.iter()
.filter(|e| !e.text.trim().is_empty())
.map(|e| {
let mut t: String = e.text.trim().chars().take(MAX_CHARS_PER_TURN).collect();
if e.text.trim().chars().count() > MAX_CHARS_PER_TURN {
t.push('…');
}
format!("{}: {}", e.role.as_str(), t)
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) async fn context_block() -> String {
let model = crate::app::model::load().await;
let agent = crate::app::tenant::current_name().unwrap_or_else(|| "apex".to_string());
let address = crate::app::APP
.with(|cell| {
use crate::app::VerifyState;
match &cell.borrow().verify_state {
VerifyState::Verified { address } => Some(address.clone()),
VerifyState::Visitor { visitor_address, .. } => Some(visitor_address.clone()),
_ => cell.borrow().wallet.as_ref().map(|w| w.address_hex()),
}
})
.unwrap_or_else(|| "—".to_string());
let chain = if crate::registry::is_mainnet() { "mainnet" } else { "testnet" };
let win = web_sys::window();
let nav = win.as_ref().map(|w| w.navigator());
let ua = nav
.as_ref()
.and_then(|n| n.user_agent().ok())
.unwrap_or_default();
let lang = nav.as_ref().and_then(|n| n.language()).unwrap_or_default();
let vw = win
.as_ref()
.and_then(|w| w.inner_width().ok())
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as i32;
let vh = win
.as_ref()
.and_then(|w| w.inner_height().ok())
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as i32;
let url_q = win
.as_ref()
.and_then(|w| w.location().search().ok())
.unwrap_or_default();
let lower_ua = ua.to_lowercase();
let mobile = lower_ua.contains("mobi") || lower_ua.contains("android") || lower_ua.contains("iphone");
let form = if mobile { "mobile" } else { "desktop" };
let ls = win.as_ref().and_then(|w| w.local_storage().ok().flatten());
let get = |k: &str| ls.as_ref().and_then(|s| s.get_item(k).ok().flatten());
let byok = get("lh_model_access").map(|v| v == "byok").unwrap_or(false);
let key_present = get("gemini_api_key").is_some();
let theme = get("lh-theme").unwrap_or_else(|| "dark".to_string());
format!(
"---\ncontext:\n agent: {agent}\n identity: {address}\n model: {model}\n \
chain: {chain}\n app: v{ver}\n device: {form} · {ua}\n viewport: {vw}x{vh}\n \
lang: {lang}\n url: {url_q}\n settings: byok={byok} key_present={key_present} \
theme={theme} telemetry={tele} feedback_onchain={fonchain}",
ver = env!("CARGO_PKG_VERSION"),
tele = enabled(),
fonchain = crate::app::feedback::feedback_onchain_enabled(),
)
}