Skip to main content

chub_core/
telemetry.rs

1use crate::config::load_config;
2use crate::identity::{detect_agent, detect_agent_version, get_or_create_client_id};
3
4const DEFAULT_TELEMETRY_URL: &str = "https://api.aichub.org/v1";
5
6pub fn is_telemetry_enabled() -> bool {
7    if let Ok(val) = std::env::var("CHUB_TELEMETRY") {
8        if val == "0" || val == "false" {
9            return false;
10        }
11    }
12    load_config().telemetry
13}
14
15pub fn is_feedback_enabled() -> bool {
16    if let Ok(val) = std::env::var("CHUB_FEEDBACK") {
17        if val == "0" || val == "false" {
18            return false;
19        }
20    }
21    load_config().feedback
22}
23
24pub fn get_telemetry_url() -> String {
25    if let Ok(url) = std::env::var("CHUB_TELEMETRY_URL") {
26        return url;
27    }
28    let config = load_config();
29    if config.telemetry_url.is_empty() {
30        DEFAULT_TELEMETRY_URL.to_string()
31    } else {
32        config.telemetry_url
33    }
34}
35
36#[derive(Debug, Clone, serde::Serialize)]
37pub struct FeedbackResult {
38    pub status: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub reason: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub feedback_id: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub code: Option<u16>,
45}
46
47#[derive(Default)]
48pub struct FeedbackOpts {
49    pub comment: Option<String>,
50    pub doc_lang: Option<String>,
51    pub doc_version: Option<String>,
52    pub target_file: Option<String>,
53    pub labels: Option<Vec<String>>,
54    pub agent: Option<String>,
55    pub model: Option<String>,
56    pub cli_version: Option<String>,
57    pub source: Option<String>,
58}
59
60/// Send feedback to the API. 3s timeout, fire-and-forget style.
61pub async fn send_feedback(
62    entry_id: &str,
63    entry_type: &str,
64    rating: &str,
65    opts: FeedbackOpts,
66) -> FeedbackResult {
67    if !is_feedback_enabled() {
68        return FeedbackResult {
69            status: "skipped".to_string(),
70            reason: Some("feedback_disabled".to_string()),
71            feedback_id: None,
72            code: None,
73        };
74    }
75
76    let client_id = get_or_create_client_id().unwrap_or_default();
77    let telemetry_url = get_telemetry_url();
78    let agent_name = opts.agent.unwrap_or_else(|| detect_agent().to_string());
79    let agent_version = detect_agent_version();
80
81    let body = serde_json::json!({
82        "entry_id": entry_id,
83        "entry_type": entry_type,
84        "rating": rating,
85        "doc_lang": opts.doc_lang,
86        "doc_version": opts.doc_version,
87        "target_file": opts.target_file,
88        "labels": opts.labels,
89        "comment": opts.comment,
90        "agent": {
91            "name": agent_name,
92            "version": agent_version,
93            "model": opts.model,
94        },
95        "cli_version": opts.cli_version,
96        "source": opts.source,
97    });
98
99    let client = match reqwest::Client::builder()
100        .timeout(std::time::Duration::from_secs(3))
101        .build()
102    {
103        Ok(c) => c,
104        Err(_) => {
105            return FeedbackResult {
106                status: "error".to_string(),
107                reason: Some("network".to_string()),
108                feedback_id: None,
109                code: None,
110            }
111        }
112    };
113
114    let send_result = client
115        .post(format!("{}/feedback", telemetry_url))
116        .header("Content-Type", "application/json")
117        .header("X-Client-ID", &client_id)
118        .json(&body)
119        .send()
120        .await;
121
122    match send_result {
123        Ok(res) => {
124            let status_code = res.status();
125            if status_code.is_success() {
126                let data: serde_json::Value = res.json().await.unwrap_or_default();
127                let feedback_id = data
128                    .get("feedback_id")
129                    .or_else(|| data.get("id"))
130                    .and_then(|v: &serde_json::Value| v.as_str())
131                    .map(|s: &str| s.to_string());
132                FeedbackResult {
133                    status: "sent".to_string(),
134                    reason: None,
135                    feedback_id,
136                    code: None,
137                }
138            } else {
139                FeedbackResult {
140                    status: "error".to_string(),
141                    reason: None,
142                    feedback_id: None,
143                    code: Some(status_code.as_u16()),
144                }
145            }
146        }
147        Err(_) => FeedbackResult {
148            status: "error".to_string(),
149            reason: Some("network".to_string()),
150            feedback_id: None,
151            code: None,
152        },
153    }
154}
155
156/// Valid feedback labels.
157pub const VALID_LABELS: &[&str] = &[
158    "accurate",
159    "well-structured",
160    "helpful",
161    "good-examples",
162    "outdated",
163    "inaccurate",
164    "incomplete",
165    "wrong-examples",
166    "wrong-version",
167    "poorly-structured",
168];