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 opt_out: bool,
52 client: Client,
54}
55
56impl Telemetry {
57 pub fn new(config_path: &PathBuf) -> Self {
62 Self::init(ENDPOINT.to_string(), config_path)
63 }
64
65 fn init(endpoint: String, config_path: &PathBuf) -> Self {
71 let opt_out = Self::is_opt_out(config_path);
72
73 Telemetry { endpoint, opt_out, client: Client::new() }
74 }
75
76 fn is_opt_out_from_config(config_file_path: &PathBuf) -> bool {
77 let config: Config = match read_json_file(config_file_path) {
78 Ok(config) => config,
79 Err(err) => {
80 log::debug!("{:?}", err.to_string());
81 return false;
82 },
83 };
84
85 !config.opt_out.version.is_empty()
87 }
88
89 fn is_opt_out_from_env() -> bool {
91 let ci = env::var("CI").unwrap_or_default();
93 let do_not_track = env::var("DO_NOT_TRACK").unwrap_or_default();
94 ci == "true" || ci == "1" || do_not_track == "true" || do_not_track == "1"
95 }
96
97 fn is_opt_out(config_file_path: &PathBuf) -> bool {
101 Self::is_opt_out_from_env() || Self::is_opt_out_from_config(config_file_path)
102 }
103
104 async fn send_json(&self, payload: Value) -> Result<()> {
110 if self.opt_out {
111 return Err(TelemetryError::OptedOut);
112 }
113
114 let request_builder = self.client.post(&self.endpoint);
115
116 log::debug!("send_json payload: {:?}", payload);
117 match request_builder
118 .json(&payload)
119 .send()
120 .await
121 .map_err(TelemetryError::NetworkError)
122 {
123 Ok(res) => match res.error_for_status() {
124 Ok(res) => {
125 let text = res.text().await.unwrap_or_default();
126 log::debug!("send_json response: {}", text);
127 },
128 Err(e) => {
129 log::debug!("send_json server error: {:?}", e);
130 },
131 },
132 Err(e) => {
133 log::debug!("send_json network error: {:?}", e);
134 },
135 }
136
137 Ok(())
138 }
139}
140
141pub async fn record_cli_used(tel: Telemetry) -> Result<()> {
145 let payload = generate_payload("init", json!({}));
146 tel.send_json(payload).await
147}
148
149pub async fn record_cli_command(tel: Telemetry, event: &str, data: Value) -> Result<()> {
155 let payload = generate_payload(event, data);
156 tel.send_json(payload).await
157}
158
159#[derive(PartialEq, Serialize, Deserialize, Debug)]
160struct OptOut {
161 version: String,
163}
164
165#[derive(PartialEq, Serialize, Deserialize, Debug)]
168pub struct Config {
169 opt_out: OptOut,
170}
171
172pub fn config_file_path() -> Result<PathBuf> {
174 let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
175 create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
177 Ok(config_path.join("config.json"))
178}
179
180pub fn write_config_opt_out(config_path: &PathBuf) -> Result<()> {
186 let config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
187
188 let config_json = serde_json::to_string_pretty(&config)
189 .map_err(|err| TelemetryError::SerializeFailed(err.to_string()))?;
190
191 let mut file = File::create(config_path).map_err(TelemetryError::IO)?;
193 file.write_all(config_json.as_bytes()).map_err(TelemetryError::IO)?;
194
195 Ok(())
196}
197
198fn read_json_file<T>(file_path: &PathBuf) -> std::result::Result<T, io::Error>
199where
200 T: DeserializeOwned,
201{
202 let mut file = File::open(file_path)?;
203
204 let mut json = String::new();
205 file.read_to_string(&mut json)?;
206
207 let deserialized: T = serde_json::from_str(&json)?;
208
209 Ok(deserialized)
210}
211
212fn generate_payload(event: &str, data: Value) -> Value {
213 json!({
214 "payload": {
215 "hostname": "cli",
216 "language": "en-US",
217 "referrer": "",
218 "screen": "1920x1080",
219 "title": CARGO_PKG_VERSION,
220 "url": "/",
221 "website": WEBSITE_ID,
222 "name": event,
223 "data": data,
224 },
225 "type": "event"
226 })
227}
228
229#[cfg(test)]
230mod tests {
231
232 use super::*;
233 use mockito::{Matcher, Mock, Server};
234 use tempfile::TempDir;
235
236 fn create_temp_config(temp_dir: &TempDir) -> Result<PathBuf> {
237 let config_path = temp_dir.path().join("config.json");
238 write_config_opt_out(&config_path)?;
239 Ok(config_path)
240 }
241 async fn default_mock(mock_server: &mut Server, payload: String) -> Mock {
242 mock_server
243 .mock("POST", "/api/send")
244 .match_header("content-type", "application/json")
245 .match_header("accept", "*/*")
246 .match_body(Matcher::JsonString(payload.clone()))
247 .match_header("content-length", payload.len().to_string().as_str())
248 .match_header("host", mock_server.host_with_port().trim())
249 .create_async()
250 .await
251 }
252
253 #[tokio::test]
254 async fn write_config_opt_out_works() -> Result<()> {
255 let temp_dir = TempDir::new().unwrap();
257 let config_path = create_temp_config(&temp_dir)?;
258
259 let actual_config: Config = read_json_file(&config_path).unwrap();
260 let expected_config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
261
262 assert_eq!(actual_config, expected_config);
263 Ok(())
264 }
265
266 #[tokio::test]
267 async fn new_telemetry_works() -> Result<()> {
268 let _ = env_logger::try_init();
269
270 let temp_dir = TempDir::new().unwrap();
272 let config_path = create_temp_config(&temp_dir)?;
274
275 let _: Config = read_json_file(&config_path).unwrap();
276
277 let tel = Telemetry::init("127.0.0.1".to_string(), &config_path);
278 let expected_telemetry = Telemetry {
279 endpoint: "127.0.0.1".to_string(),
280 opt_out: true,
281 client: Default::default(),
282 };
283
284 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
285 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
286
287 let tel = Telemetry::new(&config_path);
288
289 let expected_telemetry =
290 Telemetry { endpoint: ENDPOINT.to_string(), opt_out: true, client: Default::default() };
291
292 assert_eq!(tel.endpoint, expected_telemetry.endpoint);
293 assert_eq!(tel.opt_out, expected_telemetry.opt_out);
294 Ok(())
295 }
296
297 #[test]
298 fn new_telemetry_env_vars_works() {
299 let _ = env_logger::try_init();
300
301 unsafe {
303 env::remove_var("DO_NOT_TRACK");
304 env::set_var("CI", "false");
305 }
306 assert!(!Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
307
308 unsafe {
310 env::set_var("DO_NOT_TRACK", "true");
311 }
312 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
313 unsafe {
314 env::remove_var("DO_NOT_TRACK");
315 }
316
317 unsafe {
319 env::set_var("CI", "true");
320 }
321 assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
322 unsafe {
323 env::remove_var("CI");
324 }
325 }
326
327 #[tokio::test]
328 async fn test_record_cli_used() -> Result<()> {
329 let _ = env_logger::try_init();
330 let mut mock_server = Server::new_async().await;
331
332 let mut endpoint = mock_server.url();
333 endpoint.push_str("/api/send");
334
335 let temp_dir = TempDir::new().unwrap();
337 let config_path = temp_dir.path().join("config.json");
338 let expected_payload = generate_payload("init", json!({})).to_string();
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_used(tel).await?;
345 mock.assert_async().await;
346 Ok(())
347 }
348
349 #[tokio::test]
350 async fn test_record_cli_command() -> Result<()> {
351 let _ = env_logger::try_init();
352 let mut mock_server = Server::new_async().await;
353
354 let mut endpoint = mock_server.url();
355 endpoint.push_str("/api/send");
356
357 let temp_dir = TempDir::new().unwrap();
359
360 let config_path = temp_dir.path().join("config.json");
361
362 let expected_payload = generate_payload("new", json!({"command": "chain"})).to_string();
363
364 let mock = default_mock(&mut mock_server, expected_payload).await;
365
366 let mut tel = Telemetry::init(endpoint.clone(), &config_path);
367 tel.opt_out = false; record_cli_command(
370 tel,
371 "new",
372 json!({
373 "command": "chain"
374 }),
375 )
376 .await?;
377 mock.assert_async().await;
378 Ok(())
379 }
380
381 #[tokio::test]
382 async fn opt_out_set_fails() {
383 let _ = env_logger::try_init();
384 let mut mock_server = Server::new_async().await;
385
386 let endpoint = mock_server.url();
387
388 let mock = mock_server.mock("POST", "/").create_async().await;
389 let mock = mock.expect_at_most(0);
390
391 let mut tel = Telemetry::init(endpoint.clone(), &PathBuf::new());
392 tel.opt_out = true;
393
394 assert!(matches!(tel.send_json(Value::Null).await, Err(TelemetryError::OptedOut)));
395 assert!(matches!(record_cli_used(tel.clone()).await, Err(TelemetryError::OptedOut)));
396 assert!(matches!(
397 record_cli_command(tel, "foo", json!({})).await,
398 Err(TelemetryError::OptedOut)
399 ));
400 mock.assert_async().await;
401 }
402}