blob: e11835f30b403f08adccecb1836b9d1124a8c479 [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::Result,
fuchsia_hyper::{new_https_client, HttpsClient},
futures::lock::Mutex,
hyper::body::HttpBody,
hyper::{Body, Method, Request},
lazy_static::lazy_static,
std::collections::BTreeMap,
std::sync::Arc,
};
use crate::ga_event::*;
use crate::metrics_state::*;
use crate::notice::{BRIEF_NOTICE, FULL_NOTICE};
use crate::MetricsEventBatch;
#[cfg(test)]
const GA_URL: &str = "https://www.google-analytics.com/debug/collect";
#[cfg(not(test))]
const GA_URL: &str = "https://www.google-analytics.com/collect";
const GA_BATCH_URL: &str = "https://www.google-analytics.com/batch";
lazy_static! {
pub(crate) static ref METRICS_SERVICE: Arc<Mutex<MetricsService>> = Arc::new(Mutex::new(MetricsService::default()));
// Note: lazy_static does not run any deconstructors.
}
/// The implementation of the metrics public api.
/// By default a new instance is uninitialized until
/// the state has been added.
pub(crate) struct MetricsService {
pub(crate) init_state: MetricsServiceInitStatus,
state: MetricsState,
client: HttpsClient,
}
#[derive(Debug, PartialEq)]
pub(crate) enum MetricsServiceInitStatus {
UNINITIALIZED,
INITIALIZED,
}
impl MetricsService {
pub(crate) fn inner_init(&mut self, state: MetricsState) {
self.init_state = MetricsServiceInitStatus::INITIALIZED;
self.state = state;
}
pub(crate) fn inner_get_notice(&self) -> Option<String> {
match self.state.status {
MetricsStatus::NewUser => {
let formatted_for_app: String =
FULL_NOTICE.replace("{app_name}", &self.state.app_name);
Some(formatted_for_app.to_owned())
}
MetricsStatus::NewToTool => {
let formatted_for_app: String =
BRIEF_NOTICE.replace("{app_name}", &self.state.app_name);
Some(formatted_for_app.to_owned())
}
_ => None,
}
}
pub(crate) fn inner_set_opt_in_status(&mut self, enabled: bool) -> Result<()> {
self.state.set_opt_in_status(enabled)
}
pub(crate) fn inner_is_opted_in(&self) -> bool {
self.state.is_opted_in()
}
// disable analytics for this invocation only
// this does not affect the global analytics state
pub fn inner_opt_out_for_this_invocation(&mut self) -> Result<()> {
self.state.opt_out_for_this_invocation()
}
pub fn uuid_as_str(&self) -> String {
self.state.uuid.map_or("No uuid".to_string(), |u| u.to_string())
}
pub(crate) async fn inner_add_launch_event(
&self,
args: Option<&str>,
batch_collector: Option<&mut MetricsEventBatch>,
) -> Result<()> {
// TODO(fxb/71580): extract param for category when requirements are clear.
// For tools with subcommands, e.g. ffx, could be subcommands for better analysis
self.inner_add_custom_event(None, args, args, BTreeMap::new(), batch_collector).await
}
pub(crate) async fn inner_add_custom_event(
&self,
category: Option<&str>,
action: Option<&str>,
label: Option<&str>,
custom_dimensions: BTreeMap<&str, String>,
batch_collector: Option<&mut MetricsEventBatch>,
) -> Result<()> {
if self.inner_is_opted_in() {
let body = make_body_with_hash(
&self.state.app_name,
Some(&self.state.build_version),
&self.state.ga_product_code,
category,
action,
label,
custom_dimensions,
self.uuid_as_str(),
self.state.invoker.as_ref(),
);
match batch_collector {
None => {
let req = Request::builder()
.method(Method::POST)
.uri(GA_URL)
.body(Body::from(body))?;
let res = self.client.request(req).await;
match res {
Ok(res) => tracing::info!("Analytics response: {}", res.status()),
Err(e) => tracing::debug!("Error posting analytics: {}", e),
}
Ok(())
}
Some(bc) => {
bc.add_event_string(body);
Ok(())
}
}
} else {
Ok(())
}
}
// TODO(fxb/70502): Add command crash
// fx exception in subcommand
// "t=event" \
// "ec=fx_exception" \
// "ea=${subcommand}" \
// "el=${args}" \
// "cd1=${exit_status}" \
// )
pub(crate) async fn inner_add_crash_event(
&self,
description: &str,
fatal: Option<&bool>,
batch_collector: Option<&mut MetricsEventBatch>,
) -> Result<()> {
if self.inner_is_opted_in() {
let body = make_crash_body_with_hash(
&self.state.app_name,
Some(&self.state.build_version),
&self.state.ga_product_code,
description,
fatal,
BTreeMap::new(),
self.uuid_as_str(),
self.state.invoker.as_ref(),
);
match batch_collector {
None => {
let req = Request::builder()
.method(Method::POST)
.uri(GA_URL)
.body(Body::from(body))?;
let res = self.client.request(req).await;
match res {
Ok(res) => tracing::info!("Analytics response: {}", res.status()),
Err(e) => tracing::debug!("Error posting analytics: {}", e),
}
Ok(())
}
Some(bc) => {
bc.add_event_string(body);
Ok(())
}
}
} else {
Ok(())
}
}
/// Records a timing event from the app.
/// Returns an error if init has not been called.
pub(crate) async fn inner_add_timing_event(
&self,
category: Option<&str>,
time: String,
variable: Option<&str>,
label: Option<&str>,
custom_dimensions: BTreeMap<&str, String>,
batch_collector: Option<&mut MetricsEventBatch>,
) -> Result<()> {
if self.inner_is_opted_in() {
let body = make_timing_body_with_hash(
&self.state.app_name,
Some(&self.state.build_version),
&self.state.ga_product_code,
category,
time,
variable,
label,
custom_dimensions,
self.uuid_as_str(),
self.state.invoker.as_ref(),
);
match batch_collector {
None => {
let req = Request::builder()
.method(Method::POST)
.uri(GA_URL)
.body(Body::from(body))?;
let res = self.client.request(req).await;
match res {
Ok(res) => tracing::info!("Analytics response: {}", res.status()),
Err(e) => tracing::debug!("Error posting analytics: {}", e),
}
Ok(())
}
Some(bc) => {
bc.add_event_string(body);
Ok(())
}
}
} else {
Ok(())
}
}
pub(crate) async fn inner_send_events(&self, body: String, batch: bool) -> Result<()> {
if self.inner_is_opted_in() {
let url = match batch {
true => GA_BATCH_URL,
false => GA_URL,
};
tracing::trace!(%url, %body, "POSTING ANALYTICS");
let req = Request::builder().method(Method::POST).uri(url).body(Body::from(body))?;
let res = self.client.request(req).await;
match res {
Ok(mut res) => {
tracing::info!("Analytics response: {}", res.status());
while let Some(chunk) = res.body_mut().data().await {
tracing::trace!(?chunk);
}
//let result = String::from_utf8(bytes.into_iter().collect()).expect("");
}
Err(e) => tracing::debug!("Error posting analytics: {}", e),
}
Ok(())
} else {
Ok(())
}
}
}
impl Default for MetricsService {
fn default() -> Self {
Self {
init_state: MetricsServiceInitStatus::UNINITIALIZED,
state: MetricsState::default(),
client: new_https_client(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metrics_state::write_app_status;
use crate::metrics_state::write_opt_in_status;
use crate::metrics_state::UNKNOWN_PROPERTY_ID;
use std::path::PathBuf;
use tempfile::tempdir;
const APP_NAME: &str = "my cool app";
const BUILD_VERSION: &str = "12/09/20 00:00:00";
// const LAUNCH_ARGS: &str = "config analytics enable";
fn test_metrics_svc(
app_support_dir_path: &PathBuf,
app_name: String,
build_version: String,
ga_product_code: String,
disabled: bool,
) -> MetricsService {
MetricsService {
init_state: MetricsServiceInitStatus::INITIALIZED,
state: MetricsState::from_config(
app_support_dir_path,
app_name,
build_version,
ga_product_code,
disabled,
None,
),
client: new_https_client(),
}
}
#[test]
fn new_user_of_any_tool() -> Result<()> {
let dir = create_tmp_metrics_dir()?;
let ms = test_metrics_svc(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
false,
);
assert_eq!(ms.inner_get_notice(), Some(FULL_NOTICE.replace("{app_name}", APP_NAME)));
drop(dir);
Ok(())
}
#[test]
fn existing_user_first_use_of_this_tool() -> Result<()> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, true)?;
let ms = test_metrics_svc(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
false,
);
assert_eq!(ms.state.status, MetricsStatus::NewToTool);
assert_eq!(ms.inner_get_notice(), Some(BRIEF_NOTICE.replace("{app_name}", APP_NAME)));
drop(dir);
Ok(())
}
#[test]
fn existing_user_of_this_tool_opted_in() -> Result<()> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, true)?;
write_app_status(&dir, &APP_NAME, true)?;
let ms = test_metrics_svc(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
false,
);
assert_eq!(ms.inner_get_notice(), None);
drop(dir);
Ok(())
}
#[test]
fn existing_user_of_this_tool_opted_out() -> Result<()> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, false)?;
write_app_status(&dir, &APP_NAME, true)?;
let ms = test_metrics_svc(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
false,
);
assert_eq!(ms.inner_get_notice(), None);
drop(dir);
Ok(())
}
#[test]
fn with_disable_env_var_set() -> Result<()> {
let dir = create_tmp_metrics_dir()?;
write_opt_in_status(&dir, true)?;
write_app_status(&dir, &APP_NAME, true)?;
let ms = test_metrics_svc(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
true,
);
assert_eq!(ms.inner_get_notice(), None);
drop(dir);
Ok(())
}
#[test]
fn opt_out_for_this_invocation() -> Result<()> {
let dir = create_tmp_metrics_dir()?;
let mut ms = test_metrics_svc(
&dir,
String::from(APP_NAME),
String::from(BUILD_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
false,
);
assert_eq!(ms.state.status, MetricsStatus::NewUser);
let _res = ms.inner_opt_out_for_this_invocation().unwrap();
assert_eq!(ms.state.status, MetricsStatus::OptedOut);
drop(dir);
Ok(())
}
pub fn create_tmp_metrics_dir() -> Result<PathBuf> {
let tmp_dir = tempdir()?;
let dir_obj = tmp_dir.path().join("fuchsia_metrics");
let dir = dir_obj.as_path();
Ok(dir.to_owned())
}
}