use std::time::Duration;
use std::{fs, io::Write};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Copy)]
pub enum Tool {
SearchKnowledge,
GetEntity,
GetNeighbors,
FindPath,
AnalyzeCode,
SuggestRefactorings,
AddInsight,
ConfirmLinks,
SearchInsights,
}
impl Tool {
fn as_str(self) -> &'static str {
match self {
Self::SearchKnowledge => "search_knowledge",
Self::GetEntity => "get_entity",
Self::GetNeighbors => "get_neighbors",
Self::FindPath => "find_path",
Self::AnalyzeCode => "analyze_code",
Self::SuggestRefactorings => "suggest_refactorings",
Self::AddInsight => "add_insight",
Self::ConfirmLinks => "confirm_links",
Self::SearchInsights => "search_insights",
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum Command {
Install,
Build,
Analyze,
Infer,
Explore,
Api,
Mcp,
Service,
Telemetry,
}
impl Command {
fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Build => "build",
Self::Analyze => "analyze",
Self::Infer => "infer",
Self::Explore => "explore",
Self::Api => "api",
Self::Mcp => "mcp",
Self::Service => "service",
Self::Telemetry => "telemetry",
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum FailureClass {
GraphLoadError,
EmbeddingError,
DatabaseError,
PermissionDenied,
NetworkError,
Timeout,
Unknown,
}
impl FailureClass {
fn as_str(self) -> &'static str {
match self {
Self::GraphLoadError => "graph_load_error",
Self::EmbeddingError => "embedding_error",
Self::DatabaseError => "database_error",
Self::PermissionDenied => "permission_denied",
Self::NetworkError => "network_error",
Self::Timeout => "timeout",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ResultSizeBucket {
Empty,
Lt5,
Lt20,
Gte20,
}
impl ResultSizeBucket {
fn as_str(self) -> &'static str {
match self {
Self::Empty => "empty",
Self::Lt5 => "<5",
Self::Lt20 => "<20",
Self::Gte20 => ">=20",
}
}
}
impl From<usize> for ResultSizeBucket {
fn from(value: usize) -> Self {
if value == 0 {
Self::Empty
} else if value < 5 {
Self::Lt5
} else if value < 20 {
Self::Lt20
} else {
Self::Gte20
}
}
}
#[derive(Clone)]
pub struct Telemetry {
enabled: bool,
sender: Option<mpsc::UnboundedSender<TelemetryEvent>>,
}
#[derive(Debug, Clone)]
struct TelemetryEvent {
event: String,
properties: serde_json::Value,
}
impl Telemetry {
pub fn new(
enabled: bool,
posthog_api_key: String,
posthog_host: String,
sentry_dsn: String,
) -> Self {
if !enabled {
return Self {
enabled: false,
sender: None,
};
}
let (tx, mut rx) = mpsc::unbounded_channel::<TelemetryEvent>();
let ph_key = posthog_api_key.clone();
let ph_host = posthog_host.clone();
let sentry = sentry_dsn.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("telemetry runtime");
rt.block_on(async move {
let client = reqwest::Client::new();
while let Some(ev) = rx.recv().await {
let _ = send_with_retry(&client, &ph_key, &ph_host, &sentry, &ev).await;
}
});
});
Self {
enabled,
sender: Some(tx),
}
}
pub fn disabled() -> Self {
Self::new(false, String::new(), String::new(), String::new())
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn track_event(&self, event: &'static str, properties: serde_json::Value) {
if !self.enabled || !read_consent() {
return;
}
let Some(tx) = &self.sender else {
return;
};
let _ = tx.send(TelemetryEvent {
event: event.to_owned(),
properties: serde_json::json!({
"install_id": load_or_create_install_id(),
"payload": properties,
}),
});
}
}
fn consent_file() -> std::path::PathBuf {
crate::adapters::paths::episteme_home().join("telemetry-consent")
}
fn install_id_file() -> std::path::PathBuf {
crate::adapters::paths::episteme_home().join("install-id")
}
pub fn read_consent_raw() -> Option<bool> {
let path = consent_file();
let text = fs::read_to_string(path).ok()?;
match text.trim() {
"on" | "true" | "1" => Some(true),
"off" | "false" | "0" => Some(false),
_ => None,
}
}
pub fn read_consent() -> bool {
read_consent_raw().unwrap_or(true)
}
pub fn write_consent(enabled: bool) -> Result<(), String> {
let home = crate::adapters::paths::episteme_home();
fs::create_dir_all(&home).map_err(|e| e.to_string())?;
fs::write(consent_file(), if enabled { "on" } else { "off" }).map_err(|e| e.to_string())
}
pub fn ensure_consent_or_set_default() -> Result<(), String> {
if read_consent_raw().is_none() {
write_consent(true)?;
eprintln!("[episteme] Telemetry enabled (anonymous install ID).");
eprintln!("[episteme] To opt out: epis telemetry off");
eprintln!("[episteme] Details: https://github.com/epicsagas/Episteme#telemetry");
}
Ok(())
}
pub fn prompt_consent_interactive() -> bool {
println!();
println!(" ┌─ Telemetry ──────────────────────────────────────────────────────────┐");
println!(" │ Episteme collects anonymous usage data to improve detection quality. │");
println!(" │ │");
println!(" │ What we send: tool name, duration, outcome, version, OS │");
println!(" │ What we never: code content, file paths, search queries │");
println!(" │ Identifier: random install ID (not linked to you or machine) │");
println!(" │ Opt out anytime: epis telemetry off │");
println!(" └──────────────────────────────────────────────────────────────────────┘");
println!();
print!(" Enable telemetry? [Y/n]: ");
let _ = std::io::stdout().flush();
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err() {
return true;
}
let ans = line.trim().to_lowercase();
!(ans == "n" || ans == "no")
}
pub fn load_or_create_install_id() -> String {
let path = install_id_file();
if let Ok(s) = fs::read_to_string(&path) {
let v = s.trim();
if !v.is_empty() {
return v.to_owned();
}
}
let id = uuid::Uuid::new_v4().to_string();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(mut f) = fs::File::create(&path) {
let _ = f.write_all(id.as_bytes());
}
id
}
async fn send_with_retry(
client: &reqwest::Client,
posthog_api_key: &str,
posthog_host: &str,
sentry_dsn: &str,
ev: &TelemetryEvent,
) -> Result<(), ()> {
const MAX_RETRIES: usize = 3;
let mut attempt = 0usize;
loop {
let mut ok = true;
if !posthog_api_key.is_empty() {
let posthog_url = format!("{}/capture/", posthog_host.trim_end_matches('/'));
let res = client
.post(posthog_url)
.json(&serde_json::json!({
"api_key": posthog_api_key,
"event": ev.event,
"distinct_id": "episteme-server",
"properties": ev.properties,
}))
.send()
.await;
ok &= res
.as_ref()
.map(|r| r.status().is_success())
.unwrap_or(false);
}
if !sentry_dsn.is_empty() {
let res = client
.post(sentry_dsn)
.json(&serde_json::json!({
"message": ev.event,
"level": "info",
"extra": ev.properties,
}))
.send()
.await;
ok &= res
.as_ref()
.map(|r| r.status().is_success())
.unwrap_or(false);
}
if ok {
return Ok(());
}
attempt += 1;
if attempt >= MAX_RETRIES {
tracing::warn!(event = ev.event, "telemetry delivery failed after retries");
return Err(());
}
tokio::time::sleep(Duration::from_millis(250 * (1 << attempt))).await;
}
}
fn telemetry_client() -> Option<Telemetry> {
let cfg = crate::adapters::config::EpistemeConfig::load().ok()?;
if !cfg.telemetry_enabled {
return None;
}
Some(Telemetry::new(
true,
cfg.posthog_api_key,
cfg.posthog_host,
cfg.sentry_dsn,
))
}
pub fn track_session_started() {
if let Some(t) = telemetry_client() {
t.track_event("session_started", serde_json::json!({}));
}
}
pub fn track_command_invoked(command: Command) {
if let Some(t) = telemetry_client() {
t.track_event(
"command_invoked",
serde_json::json!({"command": command.as_str()}),
);
}
}
pub fn track_command_completed(command: Command, duration_ms: u128) {
if let Some(t) = telemetry_client() {
t.track_event(
"command_completed",
serde_json::json!({"command": command.as_str(), "duration_ms": duration_ms.to_string()}),
);
}
}
pub fn track_command_failed(command: Command, failure: FailureClass) {
if let Some(t) = telemetry_client() {
t.track_event(
"command_failed",
serde_json::json!({"command": command.as_str(), "failure_class": failure.as_str()}),
);
}
}
pub fn track_tool_called(tool: Tool) {
if let Some(t) = telemetry_client() {
t.track_event("tool_called", serde_json::json!({"tool": tool.as_str()}));
}
}
pub fn track_tool_completed(tool: Tool, duration_ms: u128, result_size: ResultSizeBucket) {
if let Some(t) = telemetry_client() {
t.track_event(
"tool_completed",
serde_json::json!({
"tool": tool.as_str(),
"duration_ms": duration_ms.to_string(),
"result_size": result_size.as_str()
}),
);
}
}
pub fn track_tool_failed(tool: Tool, failure: FailureClass) {
if let Some(t) = telemetry_client() {
t.track_event(
"tool_failed",
serde_json::json!({"tool": tool.as_str(), "failure_class": failure.as_str()}),
);
}
}
pub fn track_install_completed(tool_count: usize) {
if let Some(t) = telemetry_client() {
t.track_event(
"install_completed",
serde_json::json!({"tool_count": tool_count.to_string()}),
);
}
}