1#![doc = include_str!("../README.md")]
4
5use reqwest::Client;
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7use serde_json::{Value, json};
8use std::{
9 env,
10 fs::{File, create_dir_all},
11 io,
12 io::{Read, Write},
13 path::PathBuf,
14};
15use thiserror::Error;
16
17const ENDPOINT: &str = "https://insights.onpop.io/api/send";
18const WEBSITE_ID: &str = "0cbea0ba-4752-45aa-b3cd-8fd11fa722f7";
19const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
20
21#[derive(Error, Debug)]
23pub enum TelemetryError {
24 #[error("a reqwest error occurred: {0}")]
26 NetworkError(reqwest::Error),
27 #[error("io error occurred: {0}")]
29 IO(io::Error),
30 #[error("opt-out has been set, can not report metrics")]
32 OptedOut,
33 #[error("unable to find config file")]
35 ConfigFileNotFound,
36 #[error("serialization failed: {0}")]
38 SerializeFailed(String),
39}
40
41pub type Result<T> = std::result::Result<T, TelemetryError>;
43
44#[derive(Debug, Clone)]
46pub struct Telemetry {
47 endpoint: String,
50 website_id: String,
52 opt_out: bool,
54 client: Client,
56}
57
58impl Telemetry {
59 pub fn new(config_path: &PathBuf) -> Self {
64 Self::init_with_website_id(ENDPOINT.to_string(), WEBSITE_ID.to_string(), config_path)
65 }
66
67 #[allow(dead_code)]
74 fn init(endpoint: String, config_path: &PathBuf) -> Self {
75 Self::init_with_website_id(endpoint, WEBSITE_ID.to_string(), config_path)
76 }
77
78 pub fn init_with_website_id(
86 endpoint: String,
87 website_id: String,
88 config_path: &PathBuf,
89 ) -> Self {
90 let opt_out = Self::is_opt_out(config_path);
91
92 Telemetry { endpoint, website_id, opt_out, client: Client::new() }
93 }
94
95 fn is_opt_out_from_config(config_file_path: &PathBuf) -> bool {
96 let config: Config = match read_json_file(config_file_path) {
97 Ok(config) => config,
98 Err(err) => {
99 log::debug!("{:?}", err.to_string());
100 return false;
101 },
102 };
103
104 !config.opt_out.version.is_empty()
106 }
107
108 fn is_opt_out_from_env() -> bool {
110 let ci = env::var("CI").unwrap_or_default();
112 let do_not_track = env::var("DO_NOT_TRACK").unwrap_or_default();
113 ci == "true" || ci == "1" || do_not_track == "true" || do_not_track == "1"
114 }
115
116 fn is_opt_out(config_file_path: &PathBuf) -> bool {
120 Self::is_opt_out_from_env() || Self::is_opt_out_from_config(config_file_path)
121 }
122
123 async fn send_json(&self, payload: Value) -> Result<()> {
129 if self.opt_out {
130 return Err(TelemetryError::OptedOut);
131 }
132
133 let request_builder = self.client.post(&self.endpoint);
134
135 log::debug!("send_json payload: {:?}", payload);
136 match request_builder
137 .json(&payload)
138 .send()
139 .await
140 .map_err(TelemetryError::NetworkError)
141 {
142 Ok(res) => match res.error_for_status() {
143 Ok(res) => {
144 let text = res.text().await.unwrap_or_default();
145 log::debug!("send_json response: {}", text);
146 },
147 Err(e) => {
148 log::debug!("send_json server error: {:?}", e);
149 },
150 },
151 Err(e) => {
152 log::debug!("send_json network error: {:?}", e);
153 },
154 }
155
156 Ok(())
157 }
158}
159
160pub async fn record_cli_used(tel: Telemetry) -> Result<()> {
164 let payload = generate_payload("init", json!({}), &tel.website_id);
165 tel.send_json(payload).await
166}
167
168pub async fn record_cli_command(tel: Telemetry, event: &str, data: Value) -> Result<()> {
174 let payload = generate_payload(event, data, &tel.website_id);
175 tel.send_json(payload).await
176}
177
178#[derive(PartialEq, Serialize, Deserialize, Debug)]
179struct OptOut {
180 version: String,
182}
183
184#[derive(PartialEq, Serialize, Deserialize, Debug)]
187pub struct Config {
188 opt_out: OptOut,
189}
190
191pub fn config_file_path() -> Result<PathBuf> {
193 let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
194 create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
196 Ok(config_path.join("config.json"))
197}
198
199pub fn write_config_opt_out(config_path: &PathBuf) -> Result<()> {
205 let config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
206
207 let config_json = serde_json::to_string_pretty(&config)
208 .map_err(|err| TelemetryError::SerializeFailed(err.to_string()))?;
209
210 let mut file = File::create(config_path).map_err(TelemetryError::IO)?;
212 file.write_all(config_json.as_bytes()).map_err(TelemetryError::IO)?;
213
214 Ok(())
215}
216
217fn read_json_file<T>(file_path: &PathBuf) -> std::result::Result<T, io::Error>
218where
219 T: DeserializeOwned,
220{
221 let mut file = File::open(file_path)?;
222
223 let mut json = String::new();
224 file.read_to_string(&mut json)?;
225
226 let deserialized: T = serde_json::from_str(&json)?;
227
228 Ok(deserialized)
229}
230
231fn generate_payload(event: &str, data: Value, website_id: &str) -> Value {
232 json!({
233 "payload": {
234 "hostname": "cli",
235 "language": "en-US",
236 "referrer": "",
237 "screen": "1920x1080",
238 "title": CARGO_PKG_VERSION,
239 "url": "/",
240 "website": website_id,
241 "name": event,
242 "data": data,
243 },
244 "type": "event"
245 })
246}
247
248#[cfg(test)]
249mod tests {
250
251 use super::*;
252 use mockito::{Matcher, Mock, Server};
253 use tempfile::TempDir;
254
255 fn create_temp_config(temp_dir: &TempDir) -> Result<PathBuf> {
256 let config_path = temp_dir.path().join("config.json");
257 write_config_opt_out(&config_path)?;
258 Ok(config_path)
259 }
260 async fn default_mock(mock_server: &mut Server, payload: String) -> Mock {
261 mock_server
262 .mock("POST", "/api/send")
263 .match_header("content-type", "application/json")
264 .match_header("accept", "*/*")
265 .match_body(Matcher::JsonString(payload.clone()))
266 .match_header("content-length", payload.len().to_string().as_str())
267 .match_header("host", mock_server.host_with_port().trim())
268 .create_async()
269 .await
270 }
271
272 #[tokio::test]
273 async fn write_config_opt_out_works() -> Result<()> {
274 let temp_dir = TempDir::new().unwrap();
276 let config_path = create_temp_config(&temp_dir)?;
277
278 let actual_config: Config = read_json_file(&config_path).unwrap();
279 let expected_config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
280
281 assert_eq!(actual_config, expected_config);
282 Ok(())
283 }
284
285 #[tokio::test]
286 async fn new_telemetry_works() -> Result<()> {
287 let _ = env_logger::try_init();
288
289 let temp_dir = TempDir::new().unwrap();
291 let config_path = create_temp_config(&temp_dir)?;
293
294 let _: Config = read_json_file(&config_path).unwrap();
295
296 let tel = Telemetry::init_with_website_id(
297 "127.0.0.1".to_string(),
298 "test-website-id".to_string(),
299 &config_path,
300 );
301 let expected_telemetry = Telemetry {
302 endpoint: "127.0.0.1".to_string(),
303 website_id: "test-website-id".to_string(),
304 opt_out: true,
305 client: Default::default(),
306 };
307
308 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
309 assert_eq!(tel.website_id, expected_telemetry.website_id);
310 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
311
312 let tel = Telemetry::new(&config_path);
313
314 let expected_telemetry = Telemetry {
315 endpoint: ENDPOINT.to_string(),
316 website_id: WEBSITE_ID.to_string(),
317 opt_out: true,
318 client: Default::default(),
319 };
320
321 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
322 assert_eq!(tel.website_id, expected_telemetry.website_id);
323 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
324 Ok(())
325 }
326
327 #[test]
328 fn new_telemetry_env_vars_works() {
329 let _ = env_logger::try_init();
330
331 unsafe {
333 env::remove_var("DO_NOT_TRACK");
334 env::set_var("CI", "false");
335 }
336 assert!(!Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
337
338 unsafe {
340 env::set_var("DO_NOT_TRACK", "true");
341 }
342 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
343 unsafe {
344 env::remove_var("DO_NOT_TRACK");
345 }
346
347 unsafe {
349 env::set_var("CI", "true");
350 }
351 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
352 unsafe {
353 env::remove_var("CI");
354 }
355 }
356
357 #[tokio::test]
358 async fn test_record_cli_used() -> Result<()> {
359 let _ = env_logger::try_init();
360 let mut mock_server = Server::new_async().await;
361
362 let mut endpoint = mock_server.url();
363 endpoint.push_str("/api/send");
364
365 let temp_dir = TempDir::new().unwrap();
367 let config_path = temp_dir.path().join("config.json");
368 let expected_payload = generate_payload("init", json!({}), WEBSITE_ID).to_string();
369 let mock = default_mock(&mut mock_server, expected_payload).await;
370
371 let mut tel = Telemetry::init(endpoint.clone(), &config_path);
372 tel.opt_out = false; record_cli_used(tel).await?;
375 mock.assert_async().await;
376 Ok(())
377 }
378
379 #[tokio::test]
380 async fn test_record_cli_command() -> Result<()> {
381 let _ = env_logger::try_init();
382 let mut mock_server = Server::new_async().await;
383
384 let mut endpoint = mock_server.url();
385 endpoint.push_str("/api/send");
386
387 let temp_dir = TempDir::new().unwrap();
389
390 let config_path = temp_dir.path().join("config.json");
391
392 let expected_payload =
393 generate_payload("new", json!({"command": "chain"}), WEBSITE_ID).to_string();
394
395 let mock = default_mock(&mut mock_server, expected_payload).await;
396
397 let mut tel = Telemetry::init(endpoint.clone(), &config_path);
398 tel.opt_out = false; record_cli_command(
401 tel,
402 "new",
403 json!({
404 "command": "chain"
405 }),
406 )
407 .await?;
408 mock.assert_async().await;
409 Ok(())
410 }
411
412 #[tokio::test]
413 async fn opt_out_set_fails() {
414 let _ = env_logger::try_init();
415 let mut mock_server = Server::new_async().await;
416
417 let endpoint = mock_server.url();
418
419 let mock = mock_server.mock("POST", "/").create_async().await;
420 let mock = mock.expect_at_most(0);
421
422 let mut tel = Telemetry::init(endpoint.clone(), &PathBuf::new());
423 tel.opt_out = true;
424
425 assert!(matches!(tel.send_json(Value::Null).await, Err(TelemetryError::OptedOut)));
426 assert!(matches!(record_cli_used(tel.clone()).await, Err(TelemetryError::OptedOut)));
427 assert!(matches!(
428 record_cli_command(tel, "foo", json!({})).await,
429 Err(TelemetryError::OptedOut)
430 ));
431 mock.assert_async().await;
432 }
433}