blob: 585bb2cf6741ee81fa8808615e7b5516992b38cb [file] [log] [blame]
// Copyright 2020 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use anyhow::{Error, Result};
use std::fs::{create_dir_all, read_to_string, remove_file, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;
const OPT_IN_STATUS_FILENAME: &str = "analytics-status";
pub const UNKNOWN_APP_NAME: &str = "unknown_app";
pub const UNKNOWN_VERSION: &str = "unknown build version";
pub const UNKNOWN_PROPERTY_ID: &str = "unknown ga property id";
pub const UNKNOWN_GA4_PRODUCT_CODE: &str = "unknown ga4 property";
pub const UNKNOWN_GA4_KEY: &str = "unknown ga4 key";
/// Maintains and memo-izes the operational state of the analytics service for the app.
/// TODO(https://fxbug.dev/42077438) Once we turn down UA analytics, ~July 2023, remove ga_product_code.
#[derive(Clone, Debug, PartialEq)]
pub struct MetricsState {
pub(crate) app_name: String,
pub(crate) build_version: String,
pub(crate) sdk_version: String,
pub(crate) ga_product_code: String,
pub(crate) ga4_product_code: String,
pub(crate) ga4_key: String,
pub(crate) status: MetricsStatus,
pub(crate) uuid: Option<Uuid>,
metrics_dir: PathBuf,
pub(crate) invoker: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum MetricsStatus {
Disabled, // the environment is set to turn off analytics
NewUser, // user has never seen the full analytics notice for the Fuchsia tools
NewToTool, // user has never seen the brief analytics notice for this tool
OptedIn, // user is allowing collection of analytics data
OptedOut, // user has opted out of collection of analytics data
}
impl MetricsState {
pub(crate) fn from_config(
metrics_dir: &PathBuf,
app_name: String,
build_version: String,
sdk_version: String,
ga_product_code: String,
ga4_product_code: String,
ga4_key: String,
disabled: bool,
invoker: Option<String>,
) -> Self {
MetricsState::new(
metrics_dir,
app_name,
build_version,
sdk_version,
ga_product_code,
ga4_product_code,
ga4_key,
disabled,
invoker,
)
}
pub(crate) fn new(
metrics_dir: &PathBuf,
app_name: String,
build_version: String,
sdk_version: String,
ga_product_code: String,
ga4_product_code: String,
ga4_key: String,
disabled: bool,
invoker: Option<String>,
) -> MetricsState {
let mut metrics = MetricsState::default();
if disabled {
metrics.status = MetricsStatus::Disabled;
return metrics;
}
metrics.app_name = app_name;
metrics.build_version = build_version;
metrics.sdk_version = sdk_version;
metrics.metrics_dir = PathBuf::from(metrics_dir);
metrics.ga_product_code = ga_product_code;
metrics.ga4_product_code = ga4_product_code;
metrics.ga4_key = ga4_key;
metrics.invoker = invoker;
match read_opt_in_status(Path::new(&metrics_dir)) {
Ok(true) => metrics.status = MetricsStatus::OptedIn,
Ok(false) => metrics.status = MetricsStatus::OptedOut,
Err(_) => {
metrics.status = MetricsStatus::NewUser;
if let Err(e) = write_opt_in_status(metrics_dir, true) {
eprintln!("Could not write opt in status {:}", e);
}
}
}
if metrics.status == MetricsStatus::OptedOut {
return metrics;
}
match read_uuid_file(metrics_dir) {
Ok(uuid) => metrics.uuid = Some(uuid),
Err(_) => {
let uuid = Uuid::new_v4();
metrics.uuid = Some(uuid);
if let Err(e) = write_uuid_file(metrics_dir, &uuid.to_string()) {
eprintln!("Could not write uuid file {:}", e);
}
}
}
if metrics.status == MetricsStatus::NewUser {
// record usage of the app on disk, but, stay 'NewUser' to prevent collection on first usage.
if let Err(e) = write_app_status(metrics_dir, &metrics.app_name, true) {
eprintln!("Could not write app file, {}, {:}", &metrics.app_name, e);
}
return metrics;
}
if let Err(_e) = read_app_status(metrics_dir, &metrics.app_name) {
metrics.status = MetricsStatus::NewToTool;
if let Err(e) = write_app_status(metrics_dir, &metrics.app_name, true) {
eprintln!("Could not write app file, {}, {:}", &metrics.app_name, e);
}
}
metrics
}
pub(crate) fn set_opt_in_status(&mut self, opt_in: bool) -> Result<(), Error> {
if self.status == MetricsStatus::Disabled {
return Ok(());
}
match opt_in {
true => self.status = MetricsStatus::OptedIn,
false => self.status = MetricsStatus::OptedOut,
}
write_opt_in_status(&self.metrics_dir, opt_in)?;
match self.status {
MetricsStatus::OptedOut => {
self.uuid = None;
delete_uuid_file(&self.metrics_dir)?;
delete_app_file(&self.metrics_dir, &self.app_name)?;
}
MetricsStatus::OptedIn => {
let uuid = Uuid::new_v4();
self.uuid = Some(uuid);
write_uuid_file(&self.metrics_dir, &uuid.to_string())?;
write_app_status(&self.metrics_dir, &self.app_name, true)?;
}
_ => (),
};
Ok(())
}
pub(crate) fn is_opted_in(&self) -> bool {
match self.status {
MetricsStatus::OptedIn | MetricsStatus::NewToTool => true,
_ => false,
}
}
// disable analytics for this invocation only
// this does not affect the global analytics state
pub fn opt_out_for_this_invocation(&mut self) -> Result<()> {
if self.status == MetricsStatus::Disabled {
return Ok(());
}
self.status = MetricsStatus::OptedOut;
Ok(())
}
}
impl Default for MetricsState {
fn default() -> Self {
MetricsState {
app_name: String::from(UNKNOWN_APP_NAME),
build_version: String::from(UNKNOWN_VERSION),
sdk_version: String::from(UNKNOWN_VERSION),
ga_product_code: UNKNOWN_PROPERTY_ID.to_string(),
ga4_product_code: UNKNOWN_GA4_PRODUCT_CODE.to_string(),
ga4_key: UNKNOWN_GA4_KEY.to_string(),
status: MetricsStatus::NewUser,
uuid: None,
metrics_dir: PathBuf::from("/tmp"),
invoker: None,
}
}
}
fn read_opt_in_status(metrics_dir: &Path) -> Result<bool, Error> {
let status_file = metrics_dir.join(OPT_IN_STATUS_FILENAME);
read_bool_from(&status_file)
}
pub(crate) fn write_opt_in_status(metrics_dir: &PathBuf, status: bool) -> Result<(), Error> {
create_dir_all(&metrics_dir)?;
let status_file = metrics_dir.join(OPT_IN_STATUS_FILENAME);
write_bool_to(&status_file, status)
}
fn read_app_status(metrics_dir: &PathBuf, app: &str) -> Result<bool, Error> {
let status_file = metrics_dir.join(app);
read_bool_from(&status_file)
}
pub fn write_app_status(metrics_dir: &PathBuf, app: &str, status: bool) -> Result<(), Error> {
create_dir_all(&metrics_dir)?;
let status_file = metrics_dir.join(app);
write_bool_to(&status_file, status)
}
fn read_bool_from(path: &PathBuf) -> Result<bool, Error> {
let result = read_to_string(path)?;
let parse = &result.trim_end().parse::<u8>()?;
Ok(*parse != 0)
}
fn write_bool_to(status_file_path: &PathBuf, state: bool) -> Result<(), Error> {
let tmp_file = File::create(status_file_path)?;
writeln!(&tmp_file, "{}", state as u8)?;
Ok(())
}
fn read_uuid_file(metrics_dir: &PathBuf) -> Result<Uuid, Error> {
let file = metrics_dir.join("uuid");
let path = file.as_path();
let result = read_to_string(path)?;
match Uuid::parse_str(result.trim_end()) {
Ok(uuid) => Ok(uuid),
Err(e) => Err(Error::from(e)),
}
}
fn delete_uuid_file(metrics_dir: &PathBuf) -> Result<(), Error> {
let file = metrics_dir.join("uuid");
let path = file.as_path();
Ok(remove_file(path)?)
}
fn delete_app_file(metrics_dir: &PathBuf, app: &str) -> Result<(), Error> {
let status_file = metrics_dir.join(app);
let path = status_file.as_path();
Ok(remove_file(path)?)
}
fn write_uuid_file(dir: &PathBuf, uuid: &str) -> Result<(), Error> {
create_dir_all(&dir)?;
let file_obj = &dir.join(&"uuid");
let uuid_file_path = file_obj.as_path();
let uuid_file = File::create(uuid_file_path)?;
writeln!(&uuid_file, "{}", uuid)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::metadata;
use tempfile::tempdir;
const APP_NAME: &str = "ffx";
const BUILD_VERSION: &str = "12/09/20 00:00:00";
const SDK_VERSION: &str = "99.99.99.99.1";
#[test]
fn new_metrics() {
let _m = MetricsState {
app_name: String::from(APP_NAME),
build_version: String::from(BUILD_VERSION),
sdk_version: String::from(SDK_VERSION),
ga_product_code: UNKNOWN_PROPERTY_ID.to_string(),
ga4_product_code: UNKNOWN_GA4_PRODUCT_CODE.to_string(),
ga4_key: UNKNOWN_GA4_KEY.to_string(),
status: MetricsStatus::NewUser,
uuid: Some(Uuid::new_v4()),
metrics_dir: PathBuf::from("/tmp"),
invoker: None,
};
}
#[test]
fn new_user_of_any_tool() -> Result<(), Error> {
let dir = create_tmp_metrics_dir()?;
let m = MetricsState::new(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
None,
);
assert_eq!(m.status, MetricsStatus::NewUser);
let result = read_uuid_file(&dir);
match result {
Ok(uuid) => {
assert_eq!(m.uuid, Some(uuid));
}
Err(_) => panic!("Could not read uuid"),
}
drop(dir);
Ok(())
}
#[test]
fn existing_user_first_use_of_this_tool() -> Result<(), Error> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, true)?;
let uuid = Uuid::default();
write_uuid_file(&dir, &uuid.to_string())?;
let m = MetricsState::new(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
None,
);
assert_ne!(Some(&m), None);
assert_eq!(m.status, MetricsStatus::NewToTool);
assert_eq!(m.uuid, Some(uuid));
let app_status_file = &dir.join(&APP_NAME);
assert!(metadata(app_status_file).is_ok(), "App status file should exist.");
drop(dir);
Ok(())
}
#[test]
fn existing_user_of_this_tool_opted_in() -> Result<(), Error> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, true)?;
write_app_status(&dir, APP_NAME, true)?;
let uuid = Uuid::default();
write_uuid_file(&dir, &uuid.to_string())?;
let m = MetricsState::new(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
None,
);
assert_ne!(Some(&m), None);
assert_eq!(m.status, MetricsStatus::OptedIn);
assert_eq!(m.uuid, Some(uuid));
drop(dir);
Ok(())
}
#[test]
fn existing_user_of_this_tool_opted_out() -> Result<(), Error> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, false)?;
write_app_status(&dir, &APP_NAME, true)?;
let m = MetricsState::new(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
None,
);
assert_ne!(Some(&m), None);
assert_eq!(m.status, MetricsStatus::OptedOut);
assert_eq!(m.uuid, None);
drop(dir);
Ok(())
}
#[test]
fn with_disable_env_var_set() -> Result<(), Error> {
let dir = create_tmp_metrics_dir()?;
let m = MetricsState::new(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
true,
None,
);
assert_eq!(m.status, MetricsStatus::Disabled);
assert_eq!(m.uuid, None);
Ok(())
}
#[test]
fn existing_user_of_this_tool_opted_in_then_out_then_in() -> Result<(), Error> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, true)?;
write_app_status(&dir, &APP_NAME, true)?;
let uuid = Uuid::default();
write_uuid_file(&dir, &uuid.to_string())?;
let mut m = MetricsState::new(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
None,
);
assert_ne!(Some(&m), None);
assert_eq!(m.status, MetricsStatus::OptedIn);
assert_eq!(m.uuid, Some(uuid));
m.set_opt_in_status(false)?;
assert_eq!(m.status, MetricsStatus::OptedOut);
assert_eq!(m.uuid, None);
let app_status_file = &dir.join(&APP_NAME);
assert!(metadata(app_status_file).is_err(), "App status file should not exist.");
m.set_opt_in_status(true)?;
assert_eq!(m.status, MetricsStatus::OptedIn);
assert_eq!(m.uuid, Some(read_uuid_file(&dir).unwrap()));
assert_eq!(true, read_app_status(&dir, &APP_NAME)?);
drop(dir);
Ok(())
}
pub fn create_tmp_metrics_dir() -> Result<PathBuf, Error> {
let tmp_dir = tempdir()?;
let dir_obj = tmp_dir.path().join("fuchsia_metrics");
let dir = dir_obj.as_path();
Ok(dir.to_owned())
}
}