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("", json!({}));
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/// `command_name`: the name of the command entered (new, up, build, etc)
132/// `data`: the JSON representation of subcommands. This should never include any user inputted
133/// data like a file name.
134pub 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	// what telemetry version did they opt-out for
146	version: String,
147}
148
149/// Type to represent pop cli configuration.
150/// This will be written as json to a config.json file.
151#[derive(PartialEq, Serialize, Deserialize, Debug)]
152pub struct Config {
153	opt_out: OptOut,
154}
155
156/// Returns the configuration file path based on OS's default config directory.
157pub fn config_file_path() -> Result<PathBuf> {
158	let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
159	// Creates pop dir if needed
160	create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
161	Ok(config_path.join("config.json"))
162}
163
164/// Writes opt-out to the configuration file at the specified path.
165/// opt-out is currently the only config type. Hence, if the file exists, it will be overwritten.
166///
167/// parameters:
168/// `config_path`: the path to write the config file to
169pub 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	// overwrites file if it exists
176	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		// Mock config file path function to return a temporary path
241		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		// Mock config file path function to return a temporary path
256		let temp_dir = TempDir::new().unwrap();
257		// write a config file with opt-out set
258		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		// assert that no config file, and env vars not existing sets opt-out to false
287		env::remove_var("DO_NOT_TRACK");
288		env::set_var("CI", "false");
289		assert!(!Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
290
291		// assert that if DO_NOT_TRACK env var is set, opt-out is true
292		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		// assert that if CI env var is set, opt-out is true
297		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		// Mock config file path function to return a temporary path
311		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; // override as endpoint is mocked
320
321		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		// Mock config file path function to return a temporary path
335		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; // override as endpoint is mocked
345
346		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}