pop_telemetry/
lib.rs

1// SPDX-License-Identifier: GPL-3.0
2
3#![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/// A telemetry error.
22#[derive(Error, Debug)]
23pub enum TelemetryError {
24	/// A network error occurred.
25	#[error("a reqwest error occurred: {0}")]
26	NetworkError(reqwest::Error),
27	/// An IO error occurred.
28	#[error("io error occurred: {0}")]
29	IO(io::Error),
30	/// The user has opted out and metrics cannot be reported.
31	#[error("opt-out has been set, can not report metrics")]
32	OptedOut,
33	/// The configuration file cannot be found.
34	#[error("unable to find config file")]
35	ConfigFileNotFound,
36	/// The configuration could not be serialized.
37	#[error("serialization failed: {0}")]
38	SerializeFailed(String),
39}
40
41/// A result that represents either success ([`Ok`]) or failure ([`TelemetryError`]).
42pub type Result<T> = std::result::Result<T, TelemetryError>;
43
44/// Anonymous collection of usage metrics.
45#[derive(Debug, Clone)]
46pub struct Telemetry {
47	// Endpoint to the telemetry API.
48	// This should include the domain and api path (e.g. localhost:3000/api/send)
49	endpoint: String,
50	// Has the user opted-out to anonymous telemetry
51	opt_out: bool,
52	// Reqwest client
53	client: Client,
54}
55
56impl Telemetry {
57	/// Create a new [Telemetry] instance.
58	///
59	/// parameters:
60	/// `config_path`: the path to the configuration file (used for opt-out checks)
61	pub fn new(config_path: &PathBuf) -> Self {
62		Self::init(ENDPOINT.to_string(), config_path)
63	}
64
65	/// Initialize a new [Telemetry] instance with parameters.
66	/// Can be used in tests to provide mock endpoints.
67	/// parameters:
68	/// `endpoint`: the API endpoint that telemetry will call
69	/// `config_path`: the path to the configuration file (used for opt-out checks)
70	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		// if the version is empty, then the user has not opted out
86		!config.opt_out.version.is_empty()
87	}
88
89	// Checks two env variables, CI & DO_NOT_TRACK. If either are set to true, disable telemetry
90	fn is_opt_out_from_env() -> bool {
91		// CI first as it is more likely to be set
92		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	/// Check if the user has opted out of telemetry through two methods:
98	/// 1. Check environment variable DO_NOT_TRACK. If not set check...
99	/// 2. Configuration file
100	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	/// Send JSON payload to saved api endpoint.
105	/// Returns error and will not send anything if opt-out is true.
106	/// Returns error from reqwest if the sending fails.
107	/// It sends message only once as "best effort". There is no retry on error
108	/// in order to keep overhead to a minimal.
109	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
141/// Generically reports that the CLI was used to the telemetry endpoint.
142/// There is explicitly no reqwest retries on failure to ensure overhead
143/// stays to a minimum.
144pub async fn record_cli_used(tel: Telemetry) -> Result<()> {
145	let payload = generate_payload("init", json!({}));
146	tel.send_json(payload).await
147}
148
149/// Reports what CLI command was called to telemetry.
150///
151/// parameters:
152/// `event`: the name of the event to record (new, up, build, etc)
153/// `data`: additional data to record.
154pub 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	// what telemetry version did they opt-out for
162	version: String,
163}
164
165/// Type to represent pop cli configuration.
166/// This will be written as json to a config.json file.
167#[derive(PartialEq, Serialize, Deserialize, Debug)]
168pub struct Config {
169	opt_out: OptOut,
170}
171
172/// Returns the configuration file path based on OS's default config directory.
173pub fn config_file_path() -> Result<PathBuf> {
174	let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
175	// Creates pop dir if needed
176	create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
177	Ok(config_path.join("config.json"))
178}
179
180/// Writes opt-out to the configuration file at the specified path.
181/// opt-out is currently the only config type. Hence, if the file exists, it will be overwritten.
182///
183/// parameters:
184/// `config_path`: the path to write the config file to
185pub 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	// overwrites file if it exists
192	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		// Mock config file path function to return a temporary path
256		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		// Mock config file path function to return a temporary path
271		let temp_dir = TempDir::new().unwrap();
272		// write a config file with opt-out set
273		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		// assert that no config file, and env vars not existing sets opt-out to false
302		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		// assert that if DO_NOT_TRACK env var is set, opt-out is true
309		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		// assert that if CI env var is set, opt-out is true
318		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		// Mock config file path function to return a temporary path
336		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; // override as endpoint is mocked
343
344		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		// Mock config file path function to return a temporary path
358		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; // override as endpoint is mocked
368
369		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}