use reqwest::Client;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
env,
fs::{create_dir_all, File},
io,
io::{Read, Write},
path::PathBuf,
};
use thiserror::Error;
const ENDPOINT: &str = "https://insights.onpop.io/api/send";
const WEBSITE_ID: &str = "0cbea0ba-4752-45aa-b3cd-8fd11fa722f7";
const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Error, Debug)]
pub enum TelemetryError {
#[error("a reqwest error occurred: {0}")]
NetworkError(reqwest::Error),
#[error("io error occurred: {0}")]
IO(io::Error),
#[error("opt-out has been set, can not report metrics")]
OptedOut,
#[error("unable to find config file")]
ConfigFileNotFound,
#[error("serialization failed: {0}")]
SerializeFailed(String),
}
pub type Result<T> = std::result::Result<T, TelemetryError>;
#[derive(Debug, Clone)]
pub struct Telemetry {
endpoint: String,
opt_out: bool,
client: Client,
}
impl Telemetry {
pub fn new(config_path: &PathBuf) -> Self {
Self::init(ENDPOINT.to_string(), config_path)
}
fn init(endpoint: String, config_path: &PathBuf) -> Self {
let opt_out = Self::is_opt_out(config_path);
Telemetry { endpoint, opt_out, client: Client::new() }
}
fn is_opt_out_from_config(config_file_path: &PathBuf) -> bool {
let config: Config = match read_json_file(config_file_path) {
Ok(config) => config,
Err(err) => {
log::debug!("{:?}", err.to_string());
return false;
},
};
!config.opt_out.version.is_empty()
}
fn is_opt_out_from_env() -> bool {
let ci = env::var("CI").unwrap_or_default();
let do_not_track = env::var("DO_NOT_TRACK").unwrap_or_default();
ci == "true" || ci == "1" || do_not_track == "true" || do_not_track == "1"
}
fn is_opt_out(config_file_path: &PathBuf) -> bool {
Self::is_opt_out_from_env() || Self::is_opt_out_from_config(config_file_path)
}
async fn send_json(&self, payload: Value) -> Result<()> {
if self.opt_out {
return Err(TelemetryError::OptedOut);
}
let request_builder = self.client.post(&self.endpoint);
request_builder
.json(&payload)
.send()
.await
.map_err(TelemetryError::NetworkError)?;
Ok(())
}
}
pub async fn record_cli_used(tel: Telemetry) -> Result<()> {
let payload = generate_payload("", json!({}));
let res = tel.send_json(payload).await;
log::debug!("send_cli_used result: {:?}", res);
res
}
pub async fn record_cli_command(tel: Telemetry, command_name: &str, data: Value) -> Result<()> {
let payload = generate_payload(command_name, data);
let res = tel.send_json(payload).await;
log::debug!("send_cli_used result: {:?}", res);
res
}
#[derive(PartialEq, Serialize, Deserialize, Debug)]
struct OptOut {
version: String,
}
#[derive(PartialEq, Serialize, Deserialize, Debug)]
pub struct Config {
opt_out: OptOut,
}
pub fn config_file_path() -> Result<PathBuf> {
let config_path = dirs::config_dir().ok_or(TelemetryError::ConfigFileNotFound)?.join("pop");
create_dir_all(config_path.as_path()).map_err(TelemetryError::IO)?;
Ok(config_path.join("config.json"))
}
pub fn write_config_opt_out(config_path: &PathBuf) -> Result<()> {
let config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
let config_json = serde_json::to_string_pretty(&config)
.map_err(|err| TelemetryError::SerializeFailed(err.to_string()))?;
let mut file = File::create(config_path).map_err(TelemetryError::IO)?;
file.write_all(config_json.as_bytes()).map_err(TelemetryError::IO)?;
Ok(())
}
fn read_json_file<T>(file_path: &PathBuf) -> std::result::Result<T, io::Error>
where
T: DeserializeOwned,
{
let mut file = File::open(file_path)?;
let mut json = String::new();
file.read_to_string(&mut json)?;
let deserialized: T = serde_json::from_str(&json)?;
Ok(deserialized)
}
fn generate_payload(event_name: &str, data: Value) -> Value {
json!({
"payload": {
"hostname": "cli",
"language": "en-US",
"referrer": "",
"screen": "1920x1080",
"title": CARGO_PKG_VERSION,
"url": "/",
"website": WEBSITE_ID,
"name": event_name,
"data": data
},
"type": "event"
})
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::{Matcher, Mock, Server};
use serde_json::json;
use tempfile::TempDir;
fn create_temp_config(temp_dir: &TempDir) -> Result<PathBuf> {
let config_path = temp_dir.path().join("config.json");
write_config_opt_out(&config_path)?;
Ok(config_path)
}
async fn default_mock(mock_server: &mut Server, payload: String) -> Mock {
mock_server
.mock("POST", "/api/send")
.match_header("content-type", "application/json")
.match_header("accept", "*/*")
.match_body(Matcher::JsonString(payload.clone()))
.match_header("content-length", payload.len().to_string().as_str())
.match_header("host", mock_server.host_with_port().trim())
.create_async()
.await
}
#[tokio::test]
async fn write_config_opt_out_works() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let config_path = create_temp_config(&temp_dir)?;
let actual_config: Config = read_json_file(&config_path).unwrap();
let expected_config = Config { opt_out: OptOut { version: CARGO_PKG_VERSION.to_string() } };
assert_eq!(actual_config, expected_config);
Ok(())
}
#[tokio::test]
async fn new_telemetry_works() -> Result<()> {
let _ = env_logger::try_init();
let temp_dir = TempDir::new().unwrap();
let config_path = create_temp_config(&temp_dir)?;
let _: Config = read_json_file(&config_path).unwrap();
let tel = Telemetry::init("127.0.0.1".to_string(), &config_path);
let expected_telemetry = Telemetry {
endpoint: "127.0.0.1".to_string(),
opt_out: true,
client: Default::default(),
};
assert_eq!(tel.endpoint, expected_telemetry.endpoint);
assert_eq!(tel.opt_out, expected_telemetry.opt_out);
let tel = Telemetry::new(&config_path);
let expected_telemetry =
Telemetry { endpoint: ENDPOINT.to_string(), opt_out: true, client: Default::default() };
assert_eq!(tel.endpoint, expected_telemetry.endpoint);
assert_eq!(tel.opt_out, expected_telemetry.opt_out);
Ok(())
}
#[test]
fn new_telemetry_env_vars_works() {
let _ = env_logger::try_init();
env::remove_var("DO_NOT_TRACK");
env::set_var("CI", "false");
assert!(!Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
env::set_var("DO_NOT_TRACK", "true");
assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
env::remove_var("DO_NOT_TRACK");
env::set_var("CI", "true");
assert!(Telemetry::init("".to_string(), &PathBuf::new()).opt_out);
env::remove_var("CI");
}
#[tokio::test]
async fn test_record_cli_used() -> Result<()> {
let _ = env_logger::try_init();
let mut mock_server = Server::new_async().await;
let mut endpoint = mock_server.url();
endpoint.push_str("/api/send");
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.json");
let expected_payload = generate_payload("", json!({})).to_string();
let mock = default_mock(&mut mock_server, expected_payload).await;
let mut tel = Telemetry::init(endpoint.clone(), &config_path);
tel.opt_out = false; record_cli_used(tel).await?;
mock.assert_async().await;
Ok(())
}
#[tokio::test]
async fn test_record_cli_command() -> Result<()> {
let _ = env_logger::try_init();
let mut mock_server = Server::new_async().await;
let mut endpoint = mock_server.url();
endpoint.push_str("/api/send");
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.json");
let expected_payload = generate_payload("new", json!("parachain")).to_string();
let mock = default_mock(&mut mock_server, expected_payload).await;
let mut tel = Telemetry::init(endpoint.clone(), &config_path);
tel.opt_out = false; record_cli_command(tel, "new", json!("parachain")).await?;
mock.assert_async().await;
Ok(())
}
#[tokio::test]
async fn opt_out_set_fails() {
let _ = env_logger::try_init();
let mut mock_server = Server::new_async().await;
let endpoint = mock_server.url();
let mock = mock_server.mock("POST", "/").create_async().await;
let mock = mock.expect_at_most(0);
let mut tel = Telemetry::init(endpoint.clone(), &PathBuf::new());
tel.opt_out = true;
assert!(matches!(tel.send_json(Value::Null).await, Err(TelemetryError::OptedOut)));
assert!(matches!(record_cli_used(tel.clone()).await, Err(TelemetryError::OptedOut)));
assert!(matches!(
record_cli_command(tel.clone(), "foo", Value::Null).await,
Err(TelemetryError::OptedOut)
));
mock.assert_async().await;
}
}