1use reqwest::Client;
4use serde::{de::DeserializeOwned, Deserialize, Serialize};
5use serde_json::{json, Value};
6use std::{
7 env,
8 fs::{create_dir_all, File},
9 io,
10 io::{Read, Write},
11 path::PathBuf,
12};
13use thiserror::Error;
14
15const ENDPOINT: &str = "https://insights.onpop.io/api/send";
16const WEBSITE_ID: &str = "0cbea0ba-4752-45aa-b3cd-8fd11fa722f7";
17const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
18
19#[derive(Error, Debug)]
20pub enum TelemetryError {
21 #[error("a reqwest error occurred: {0}")]
22 NetworkError(reqwest::Error),
23 #[error("io error occurred: {0}")]
24 IO(io::Error),
25 #[error("opt-out has been set, can not report metrics")]
26 OptedOut,
27 #[error("unable to find config file")]
28 ConfigFileNotFound,
29 #[error("serialization failed: {0}")]
30 SerializeFailed(String),
31}
32
33pub type Result<T> = std::result::Result<T, TelemetryError>;
34
35#[derive(Debug, Clone)]
36pub struct Telemetry {
37 endpoint: String,
40 opt_out: bool,
42 client: Client,
44}
45
46impl Telemetry {
47 pub fn new(config_path: &PathBuf) -> Self {
52 Self::init(ENDPOINT.to_string(), config_path)
53 }
54
55 fn init(endpoint: String, config_path: &PathBuf) -> Self {
61 let opt_out = Self::is_opt_out(config_path);
62
63 Telemetry { endpoint, opt_out, client: Client::new() }
64 }
65
66 fn is_opt_out_from_config(config_file_path: &PathBuf) -> bool {
67 let config: Config = match read_json_file(config_file_path) {
68 Ok(config) => config,
69 Err(err) => {
70 log::debug!("{:?}", err.to_string());
71 return false;
72 },
73 };
74
75 !config.opt_out.version.is_empty()
77 }
78
79 fn is_opt_out_from_env() -> bool {
81 let ci = env::var("CI").unwrap_or_default();
83 let do_not_track = env::var("DO_NOT_TRACK").unwrap_or_default();
84 ci == "true" || ci == "1" || do_not_track == "true" || do_not_track == "1"
85 }
86
87 fn is_opt_out(config_file_path: &PathBuf) -> bool {
91 Self::is_opt_out_from_env() || Self::is_opt_out_from_config(config_file_path)
92 }
93
94 async fn send_json(&self, payload: Value) -> Result<()> {
100 if self.opt_out {
101 return Err(TelemetryError::OptedOut);
102 }
103
104 let request_builder = self.client.post(&self.endpoint);
105
106 request_builder
107 .json(&payload)
108 .send()
109 .await
110 .map_err(TelemetryError::NetworkError)?;
111
112 Ok(())
113 }
114}
115
116pub async fn record_cli_used(tel: Telemetry) -> Result<()> {
120 let payload = generate_payload("", "");
121
122 let res = tel.send_json(payload).await;
123 log::debug!("send_cli_used result: {:?}", res);
124
125 res
126}
127
128pub async fn record_cli_command(tel: Telemetry, event: &str, data: &str) -> Result<()> {
134 let payload = generate_payload(event, data);
135
136 let res = tel.send_json(payload).await;
137 log::debug!("send_cli_used result: {:?}", res);
138
139 res
140}
141
142#[derive(PartialEq, Serialize, Deserialize, Debug)]
143struct OptOut {
144 version: String,
146}
147
148#[derive(PartialEq, Serialize, Deserialize, Debug)]
151pub struct Config {
152 opt_out: OptOut,
153}
154
155pub fn config_file_path() -> Result<PathBuf> {
157 let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
158 create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
160 Ok(config_path.join("config.json"))
161}
162
163pub fn write_config_opt_out(config_path: &PathBuf) -> Result<()> {
169 let config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
170
171 let config_json = serde_json::to_string_pretty(&config)
172 .map_err(|err| TelemetryError::SerializeFailed(err.to_string()))?;
173
174 let mut file = File::create(config_path).map_err(TelemetryError::IO)?;
176 file.write_all(config_json.as_bytes()).map_err(TelemetryError::IO)?;
177
178 Ok(())
179}
180
181fn read_json_file<T>(file_path: &PathBuf) -> std::result::Result<T, io::Error>
182where
183 T: DeserializeOwned,
184{
185 let mut file = File::open(file_path)?;
186
187 let mut json = String::new();
188 file.read_to_string(&mut json)?;
189
190 let deserialized: T = serde_json::from_str(&json)?;
191
192 Ok(deserialized)
193}
194
195fn generate_payload(event: &str, data: &str) -> Value {
196 json!({
197 "payload": {
198 "hostname": "cli",
199 "language": "en-US",
200 "referrer": "",
201 "screen": "1920x1080",
202 "title": CARGO_PKG_VERSION,
203 "url": "/",
204 "website": WEBSITE_ID,
205 "name": event,
206 "data": data
207 },
208 "type": "event"
209 })
210}
211
212#[cfg(test)]
213mod tests {
214
215 use super::*;
216 use mockito::{Matcher, Mock, Server};
217 use tempfile::TempDir;
218
219 fn create_temp_config(temp_dir: &TempDir) -> Result<PathBuf> {
220 let config_path = temp_dir.path().join("config.json");
221 write_config_opt_out(&config_path)?;
222 Ok(config_path)
223 }
224 async fn default_mock(mock_server: &mut Server, payload: String) -> Mock {
225 mock_server
226 .mock("POST", "/api/send")
227 .match_header("content-type", "application/json")
228 .match_header("accept", "*/*")
229 .match_body(Matcher::JsonString(payload.clone()))
230 .match_header("content-length", payload.len().to_string().as_str())
231 .match_header("host", mock_server.host_with_port().trim())
232 .create_async()
233 .await
234 }
235
236 #[tokio::test]
237 async fn write_config_opt_out_works() -> Result<()> {
238 let temp_dir = TempDir::new().unwrap();
240 let config_path = create_temp_config(&temp_dir)?;
241
242 let actual_config: Config = read_json_file(&config_path).unwrap();
243 let expected_config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
244
245 assert_eq!(actual_config, expected_config);
246 Ok(())
247 }
248
249 #[tokio::test]
250 async fn new_telemetry_works() -> Result<()> {
251 let _ = env_logger::try_init();
252
253 let temp_dir = TempDir::new().unwrap();
255 let config_path = create_temp_config(&temp_dir)?;
257
258 let _: Config = read_json_file(&config_path).unwrap();
259
260 let tel = Telemetry::init("127.0.0.1".to_string(), &config_path);
261 let expected_telemetry = Telemetry {
262 endpoint: "127.0.0.1".to_string(),
263 opt_out: true,
264 client: Default::default(),
265 };
266
267 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
268 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
269
270 let tel = Telemetry::new(&config_path);
271
272 let expected_telemetry =
273 Telemetry { endpoint: ENDPOINT.to_string(), opt_out: true, client: Default::default() };
274
275 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
276 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
277 Ok(())
278 }
279
280 #[test]
281 fn new_telemetry_env_vars_works() {
282 let _ = env_logger::try_init();
283
284 env::remove_var("DO_NOT_TRACK");
286 env::set_var("CI", "false");
287 assert!(!Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
288
289 env::set_var("DO_NOT_TRACK", "true");
291 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
292 env::remove_var("DO_NOT_TRACK");
293
294 env::set_var("CI", "true");
296 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
297 env::remove_var("CI");
298 }
299
300 #[tokio::test]
301 async fn test_record_cli_used() -> Result<()> {
302 let _ = env_logger::try_init();
303 let mut mock_server = Server::new_async().await;
304
305 let mut endpoint = mock_server.url();
306 endpoint.push_str("/api/send");
307
308 let temp_dir = TempDir::new().unwrap();
310 let config_path = temp_dir.path().join("config.json");
311
312 let expected_payload = generate_payload("", "").to_string();
313
314 let mock = default_mock(&mut mock_server, expected_payload).await;
315
316 let mut tel = Telemetry::init(endpoint.clone(), &config_path);
317 tel.opt_out = false; record_cli_used(tel).await?;
320 mock.assert_async().await;
321 Ok(())
322 }
323
324 #[tokio::test]
325 async fn test_record_cli_command() -> Result<()> {
326 let _ = env_logger::try_init();
327 let mut mock_server = Server::new_async().await;
328
329 let mut endpoint = mock_server.url();
330 endpoint.push_str("/api/send");
331
332 let temp_dir = TempDir::new().unwrap();
334
335 let config_path = temp_dir.path().join("config.json");
336
337 let expected_payload = generate_payload("new", "parachain").to_string();
338
339 let mock = default_mock(&mut mock_server, expected_payload).await;
340
341 let mut tel = Telemetry::init(endpoint.clone(), &config_path);
342 tel.opt_out = false; record_cli_command(tel, "new", "parachain").await?;
345 mock.assert_async().await;
346 Ok(())
347 }
348
349 #[tokio::test]
350 async fn opt_out_set_fails() {
351 let _ = env_logger::try_init();
352 let mut mock_server = Server::new_async().await;
353
354 let endpoint = mock_server.url();
355
356 let mock = mock_server.mock("POST", "/").create_async().await;
357 let mock = mock.expect_at_most(0);
358
359 let mut tel = Telemetry::init(endpoint.clone(), &PathBuf::new());
360 tel.opt_out = true;
361
362 assert!(matches!(tel.send_json(Value::Null).await, Err(TelemetryError::OptedOut)));
363 assert!(matches!(record_cli_used(tel.clone()).await, Err(TelemetryError::OptedOut)));
364 assert!(matches!(
365 record_cli_command(tel.clone(), "foo", "").await,
366 Err(TelemetryError::OptedOut)
367 ));
368 mock.assert_async().await;
369 }
370}