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