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("", json!({}));
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, command_name: &str, data: Value) -> Result<()> {
135 let payload = generate_payload(command_name, data);
136
137 let res = tel.send_json(payload).await;
138 log::debug!("send_cli_used result: {:?}", res);
139
140 res
141}
142
143#[derive(PartialEq, Serialize, Deserialize, Debug)]
144struct OptOut {
145 version: String,
147}
148
149#[derive(PartialEq, Serialize, Deserialize, Debug)]
152pub struct Config {
153 opt_out: OptOut,
154}
155
156pub fn config_file_path() -> Result<PathBuf> {
158 let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
159 create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
161 Ok(config_path.join("config.json"))
162}
163
164pub fn write_config_opt_out(config_path: &PathBuf) -> Result<()> {
170 let config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
171
172 let config_json = serde_json::to_string_pretty(&config)
173 .map_err(|err| TelemetryError::SerializeFailed(err.to_string()))?;
174
175 let mut file = File::create(config_path).map_err(TelemetryError::IO)?;
177 file.write_all(config_json.as_bytes()).map_err(TelemetryError::IO)?;
178
179 Ok(())
180}
181
182fn read_json_file<T>(file_path: &PathBuf) -> std::result::Result<T, io::Error>
183where
184 T: DeserializeOwned,
185{
186 let mut file = File::open(file_path)?;
187
188 let mut json = String::new();
189 file.read_to_string(&mut json)?;
190
191 let deserialized: T = serde_json::from_str(&json)?;
192
193 Ok(deserialized)
194}
195
196fn generate_payload(event_name: &str, data: Value) -> Value {
197 json!({
198 "payload": {
199 "hostname": "cli",
200 "language": "en-US",
201 "referrer": "",
202 "screen": "1920x1080",
203 "title": CARGO_PKG_VERSION,
204 "url": "/",
205 "website": WEBSITE_ID,
206 "name": event_name,
207 "data": data
208 },
209 "type": "event"
210 })
211}
212
213#[cfg(test)]
214mod tests {
215
216 use super::*;
217 use mockito::{Matcher, Mock, Server};
218 use serde_json::json;
219 use tempfile::TempDir;
220
221 fn create_temp_config(temp_dir: &TempDir) -> Result<PathBuf> {
222 let config_path = temp_dir.path().join("config.json");
223 write_config_opt_out(&config_path)?;
224 Ok(config_path)
225 }
226 async fn default_mock(mock_server: &mut Server, payload: String) -> Mock {
227 mock_server
228 .mock("POST", "/api/send")
229 .match_header("content-type", "application/json")
230 .match_header("accept", "*/*")
231 .match_body(Matcher::JsonString(payload.clone()))
232 .match_header("content-length", payload.len().to_string().as_str())
233 .match_header("host", mock_server.host_with_port().trim())
234 .create_async()
235 .await
236 }
237
238 #[tokio::test]
239 async fn write_config_opt_out_works() -> Result<()> {
240 let temp_dir = TempDir::new().unwrap();
242 let config_path = create_temp_config(&temp_dir)?;
243
244 let actual_config: Config = read_json_file(&config_path).unwrap();
245 let expected_config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
246
247 assert_eq!(actual_config, expected_config);
248 Ok(())
249 }
250
251 #[tokio::test]
252 async fn new_telemetry_works() -> Result<()> {
253 let _ = env_logger::try_init();
254
255 let temp_dir = TempDir::new().unwrap();
257 let config_path = create_temp_config(&temp_dir)?;
259
260 let _: Config = read_json_file(&config_path).unwrap();
261
262 let tel = Telemetry::init("127.0.0.1".to_string(), &config_path);
263 let expected_telemetry = Telemetry {
264 endpoint: "127.0.0.1".to_string(),
265 opt_out: true,
266 client: Default::default(),
267 };
268
269 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
270 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
271
272 let tel = Telemetry::new(&config_path);
273
274 let expected_telemetry =
275 Telemetry { endpoint: ENDPOINT.to_string(), opt_out: true, client: Default::default() };
276
277 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
278 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
279 Ok(())
280 }
281
282 #[test]
283 fn new_telemetry_env_vars_works() {
284 let _ = env_logger::try_init();
285
286 env::remove_var("DO_NOT_TRACK");
288 env::set_var("CI", "false");
289 assert!(!Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
290
291 env::set_var("DO_NOT_TRACK", "true");
293 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
294 env::remove_var("DO_NOT_TRACK");
295
296 env::set_var("CI", "true");
298 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
299 env::remove_var("CI");
300 }
301
302 #[tokio::test]
303 async fn test_record_cli_used() -> Result<()> {
304 let _ = env_logger::try_init();
305 let mut mock_server = Server::new_async().await;
306
307 let mut endpoint = mock_server.url();
308 endpoint.push_str("/api/send");
309
310 let temp_dir = TempDir::new().unwrap();
312 let config_path = temp_dir.path().join("config.json");
313
314 let expected_payload = generate_payload("", json!({})).to_string();
315
316 let mock = default_mock(&mut mock_server, expected_payload).await;
317
318 let mut tel = Telemetry::init(endpoint.clone(), &config_path);
319 tel.opt_out = false; record_cli_used(tel).await?;
322 mock.assert_async().await;
323 Ok(())
324 }
325
326 #[tokio::test]
327 async fn test_record_cli_command() -> Result<()> {
328 let _ = env_logger::try_init();
329 let mut mock_server = Server::new_async().await;
330
331 let mut endpoint = mock_server.url();
332 endpoint.push_str("/api/send");
333
334 let temp_dir = TempDir::new().unwrap();
336
337 let config_path = temp_dir.path().join("config.json");
338
339 let expected_payload = generate_payload("new", json!("parachain")).to_string();
340
341 let mock = default_mock(&mut mock_server, expected_payload).await;
342
343 let mut tel = Telemetry::init(endpoint.clone(), &config_path);
344 tel.opt_out = false; record_cli_command(tel, "new", json!("parachain")).await?;
347 mock.assert_async().await;
348 Ok(())
349 }
350
351 #[tokio::test]
352 async fn opt_out_set_fails() {
353 let _ = env_logger::try_init();
354 let mut mock_server = Server::new_async().await;
355
356 let endpoint = mock_server.url();
357
358 let mock = mock_server.mock("POST", "/").create_async().await;
359 let mock = mock.expect_at_most(0);
360
361 let mut tel = Telemetry::init(endpoint.clone(), &PathBuf::new());
362 tel.opt_out = true;
363
364 assert!(matches!(tel.send_json(Value::Null).await, Err(TelemetryError::OptedOut)));
365 assert!(matches!(record_cli_used(tel.clone()).await, Err(TelemetryError::OptedOut)));
366 assert!(matches!(
367 record_cli_command(tel.clone(), "foo", Value::Null).await,
368 Err(TelemetryError::OptedOut)
369 ));
370 mock.assert_async().await;
371 }
372}