pop_telemetry/
lib.rs

1// SPDX-License-Identifier: GPL-3.0
2
3use 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 to the telemetry API.
38	// This should include the domain and api path (e.g. localhost:3000/api/send)
39	endpoint: String,
40	// Has the user opted-out to anonymous telemetry
41	opt_out: bool,
42	// Reqwest client
43	client: Client,
44}
45
46impl Telemetry {
47	/// Create a new Telemetry instance.
48	///
49	/// parameters:
50	/// `config_path`: the path to the configuration file (used for opt-out checks)
51	pub fn new(config_path: &PathBuf) -> Self {
52		Self::init(ENDPOINT.to_string(), config_path)
53	}
54
55	/// Initialize a new Telemetry instance with parameters.
56	/// Can be used in tests to provide mock endpoints.
57	/// parameters:
58	/// `endpoint`: the API endpoint that telemetry will call
59	/// `config_path`: the path to the configuration file (used for opt-out checks)
60	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		// if the version is empty, then the user has not opted out
76		!config.opt_out.version.is_empty()
77	}
78
79	// Checks two env variables, CI & DO_NOT_TRACK. If either are set to true, disable telemetry
80	fn is_opt_out_from_env() -> bool {
81		// CI first as it is more likely to be set
82		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	/// Check if the user has opted out of telemetry through two methods:
88	/// 1. Check environment variable DO_NOT_TRACK. If not set check...
89	/// 2. Configuration file
90	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	/// Send JSON payload to saved api endpoint.
95	/// Returns error and will not send anything if opt-out is true.
96	/// Returns error from reqwest if the sending fails.
97	/// It sends message only once as "best effort". There is no retry on error
98	/// in order to keep overhead to a minimal.
99	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
116/// Generically reports that the CLI was used to the telemetry endpoint.
117/// There is explicitly no reqwest retries on failure to ensure overhead
118/// stays to a minimum.
119pub 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
128/// Reports what CLI command was called to telemetry.
129///
130/// parameters:
131/// `event`: the name of the event to record (new, up, build, etc)
132/// `data`: additional data to record.
133pub 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	// what telemetry version did they opt-out for
145	version: String,
146}
147
148/// Type to represent pop cli configuration.
149/// This will be written as json to a config.json file.
150#[derive(PartialEq, Serialize, Deserialize, Debug)]
151pub struct Config {
152	opt_out: OptOut,
153}
154
155/// Returns the configuration file path based on OS's default config directory.
156pub fn config_file_path() -> Result<PathBuf> {
157	let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
158	// Creates pop dir if needed
159	create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
160	Ok(config_path.join("config.json"))
161}
162
163/// Writes opt-out to the configuration file at the specified path.
164/// opt-out is currently the only config type. Hence, if the file exists, it will be overwritten.
165///
166/// parameters:
167/// `config_path`: the path to write the config file to
168pub 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	// overwrites file if it exists
175	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		// Mock config file path function to return a temporary path
239		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		// Mock config file path function to return a temporary path
254		let temp_dir = TempDir::new().unwrap();
255		// write a config file with opt-out set
256		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		// assert that no config file, and env vars not existing sets opt-out to false
285		env::remove_var("DO_NOT_TRACK");
286		env::set_var("CI", "false");
287		assert!(!Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
288
289		// assert that if DO_NOT_TRACK env var is set, opt-out is true
290		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		// assert that if CI env var is set, opt-out is true
295		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		// Mock config file path function to return a temporary path
309		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; // override as endpoint is mocked
318
319		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		// Mock config file path function to return a temporary path
333		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; // override as endpoint is mocked
343
344		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}