Skip to main content

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	// Unami Website ID for telemetry tracking
51	website_id: String,
52	// Has the user opted-out to anonymous telemetry
53	opt_out: bool,
54	// Reqwest client
55	client: Client,
56}
57
58impl Telemetry {
59	/// Create a new [Telemetry] instance.
60	///
61	/// parameters:
62	/// `config_path`: the path to the configuration file (used for opt-out checks)
63	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	/// Initialize a new [Telemetry] instance with a custom endpoint.
68	/// Uses the default WEBSITE_ID constant.
69	/// Can be used in tests to provide mock endpoints.
70	/// parameters:
71	/// `endpoint`: the API endpoint that telemetry will call
72	/// `config_path`: the path to the configuration file (used for opt-out checks)
73	#[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	/// Initialize a new [Telemetry] instance with custom endpoint and website_id.
79	/// Can be used in tests to provide mock endpoints and website IDs.
80	/// in addition to making this crate useful to other projects.
81	/// parameters:
82	/// `endpoint`: the API endpoint that telemetry will call
83	/// `website_id`: the website ID for telemetry tracking
84	/// `config_path`: the path to the configuration file (used for opt-out checks)
85	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		// if the version is empty, then the user has not opted out
105		!config.opt_out.version.is_empty()
106	}
107
108	// Checks two env variables, CI & DO_NOT_TRACK. If either are set to true, disable telemetry
109	fn is_opt_out_from_env() -> bool {
110		// CI first as it is more likely to be set
111		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	/// Check if the user has opted out of telemetry through two methods:
117	/// 1. Check environment variable DO_NOT_TRACK. If not set check...
118	/// 2. Configuration file
119	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	/// Send JSON payload to saved api endpoint.
124	/// Returns error and will not send anything if opt-out is true.
125	/// Returns error from reqwest if the sending fails.
126	/// It sends message only once as "best effort". There is no retry on error
127	/// in order to keep overhead to a minimal.
128	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
160/// Generically reports that the CLI was used to the telemetry endpoint.
161/// There is explicitly no reqwest retries on failure to ensure overhead
162/// stays to a minimum.
163pub 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
168/// Reports what CLI command was called to telemetry.
169///
170/// parameters:
171/// `event`: the name of the event to record (new, up, build, etc)
172/// `data`: additional data to record.
173pub 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	// what telemetry version did they opt-out for
181	version: String,
182}
183
184/// Type to represent pop cli configuration.
185/// This will be written as json to a config.json file.
186#[derive(PartialEq, Serialize, Deserialize, Debug)]
187pub struct Config {
188	opt_out: OptOut,
189}
190
191/// Returns the configuration file path based on OS's default config directory.
192pub fn config_file_path() -> Result<PathBuf> {
193	let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
194	// Creates pop dir if needed
195	create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
196	Ok(config_path.join("config.json"))
197}
198
199/// Writes opt-out to the configuration file at the specified path.
200/// opt-out is currently the only config type. Hence, if the file exists, it will be overwritten.
201///
202/// parameters:
203/// `config_path`: the path to write the config file to
204pub 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	// overwrites file if it exists
211	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		// Mock config file path function to return a temporary path
275		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		// Mock config file path function to return a temporary path
290		let temp_dir = TempDir::new().unwrap();
291		// write a config file with opt-out set
292		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		// assert that no config file, and env vars not existing sets opt-out to false
332		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		// assert that if DO_NOT_TRACK env var is set, opt-out is true
339		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		// assert that if CI env var is set, opt-out is true
348		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		// Mock config file path function to return a temporary path
366		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; // override as endpoint is mocked
373
374		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		// Mock config file path function to return a temporary path
388		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; // override as endpoint is mocked
399
400		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}