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
23pub 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
63pub 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 analytics::record_feedback(entry_id, rating);
81
82 let telemetry_url = match get_telemetry_url() {
83 Some(url) => url,
84 None => {
85 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
174pub 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];