blob: f513823b530a31155569694df51b86dfe1fa487c [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 std::time::{Duration, SystemTime};
use anyhow::Result;
use fuchsia_hyper::{new_https_client, HttpsClient};
use hyper::body::HttpBody;
use hyper::{Body, Method, Request};
use std::collections::{BTreeMap, HashMap};
use crate::env_info::{get_arch, get_os};
use crate::ga4_event::*;
use crate::metrics_state::*;
use crate::notice::{BRIEF_NOTICE, FULL_NOTICE};
const DOMAIN: &str = "www.google-analytics.com";
const ENDPOINT: &str = "/mp/collect";
/// The implementation of the GA4 Measurement Protocol metrics public api.
#[derive(Clone)]
pub struct GA4MetricsService {
state: MetricsState,
client: HttpsClient,
post: Post,
}
impl GA4MetricsService {
pub(crate) fn new(state: MetricsState) -> Self {
let mut svc =
GA4MetricsService { state: state, client: new_https_client(), post: Post::default() };
svc.init_post();
svc
}
/// Returns Analytics disclosure notice according to PDD rules.
pub fn get_notice(&self) -> Option<String> {
match self.state.status {
MetricsStatus::NewUser => Some(FULL_NOTICE.to_string()),
MetricsStatus::NewToTool => Some(BRIEF_NOTICE.to_string()),
_ => None,
}
}
/// Records Analytics participation status.
pub fn set_opt_in_status(&mut self, enabled: bool) -> Result<()> {
self.state.set_opt_in_status(enabled)
}
/// Returns Analytics participation status.
pub fn is_opted_in(&self) -> bool {
self.state.is_opted_in()
}
/// Disables analytics for this invocation only.
/// This does not affect the global analytics state.
pub fn opt_out_for_this_invocation(&mut self) -> Result<()> {
self.state.opt_out_for_this_invocation()
}
/// Adds a launch event to the Post
pub async fn add_launch_event(&mut self, args: Option<&str>) -> Result<()> {
self.add_custom_event(None, args, args, BTreeMap::new(), Some("launch")).await
}
/// Adds an event to the post with open-ended parameters
/// while still honoring the UA Event parameters already
/// in use.
pub async fn add_custom_event(
&mut self,
category: Option<&str>,
action: Option<&str>,
label: Option<&str>,
custom_dimensions: BTreeMap<&str, GA4Value>,
event_name: Option<&str>,
) -> Result<()> {
if !self.is_opted_in() {
return Ok(());
}
let ga4_event = make_ga4_event(
category,
action,
label,
custom_dimensions,
self.state.invoker.as_deref(),
event_name,
);
self.post.add_event(ga4_event);
Ok(())
}
/// Adds a crash/exception event to the post
/// conforming to the UA Event parameters already
/// in use.
// TODO With GA4's flexibility, rework exception reporting to be more informative
pub async fn add_crash_event(&mut self, description: &str, fatal: Option<&bool>) -> Result<()> {
if !self.is_opted_in() {
return Ok(());
}
let ga4_event = make_ga4_crash_event(description, fatal, self.state.invoker.as_deref());
self.post.add_event(ga4_event);
Ok(())
}
/// Records a timing event from the app.
pub async fn add_timing_event(
&mut self,
category: Option<&str>,
time: u64,
variable: Option<&str>,
label: Option<&str>,
custom_dimensions: BTreeMap<&str, GA4Value>,
) -> Result<()> {
if !self.is_opted_in() {
return Ok(());
}
let ga4_event = make_ga4_timing_event(
category,
time,
variable,
label,
custom_dimensions,
self.state.invoker.as_deref(),
);
self.post.add_event(ga4_event);
Ok(())
}
/// Sends the Post, with all accumulated events
/// to the Google Analytics service.
pub async fn send_events(&mut self) -> Result<()> {
if !self.is_opted_in() {
return Ok(());
}
self.rewrite_ua_ffx_known_batch_to_ga4();
let _ = self.post.validate()?;
let post_body = self.post.to_json();
let url = self.get_url();
tracing::debug!(%url, %post_body, "POSTING GA4 ANALYTICS");
let req = Request::builder()
.method(Method::POST)
.uri(url)
.header("Content-Type", "application/json")
.body(Body::from(post_body))?;
let res = self.client.request(req).await;
Ok(match res {
Ok(mut res) => {
tracing::debug!("GA 4 Analytics response: {}", res.status());
while let Some(chunk) = res.body_mut().data().await {
tracing::trace!(?chunk);
}
}
Err(e) => tracing::debug!("Error posting GA 4 analytics: {}", e),
})
}
/// Rewrites the batch call from ffx invoke under UA analytics
/// to a single event under GA4 Analytics.
/// TODO Remove this once we remove UA analtyics and the ffx client has been updated
/// to speak to the GA4 Metrics Service.
fn rewrite_ua_ffx_known_batch_to_ga4(&mut self) {
if self.post.events.len() == 2
&& self.post.events[0].name.eq_ignore_ascii_case("invoke")
&& self.post.events[1].name.eq_ignore_ascii_case("timing")
{
tracing::debug!("Rewriting ffx batch invoke to ga4 post invoke");
let events = &mut self.post.events;
let invoke_event = &mut events[0].clone();
let timing_event = events.remove(1);
if let Some(params) = timing_event.params {
if params.params.contains_key("time") {
let time = params.params["time"].clone();
invoke_event.add_param("timing", time);
}
self.post.events = vec![invoke_event.to_owned()];
}
}
}
fn uuid_as_str(&self) -> String {
self.state.uuid.map_or("No uuid".to_string(), |u| u.to_string())
}
fn user_first_touch_timestamp_micros(&self) -> String {
self.state
.user_first_touch_timestamp
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or(Duration::from_micros(0))
.as_micros()
.to_string()
}
/// Create the GA4 Post object that will be sent to Google Analytics.
fn init_post(&mut self) {
self.post = Post::new(
self.uuid_as_str(),
None,
self.user_first_touch_timestamp_micros(),
Some(self.make_user_properties()),
vec![],
);
}
/// Initialize the UserProperties to be sent to GA4 with events.
fn make_user_properties(&self) -> HashMap<String, ValueObject> {
HashMap::from([
(
"build_version".into(),
ValueObject { value: self.state.build_version.clone().into() },
),
("os".into(), ValueObject { value: get_os().into() }),
("arch".into(), ValueObject { value: get_arch().into() }),
("sdk_version".into(), ValueObject { value: self.state.sdk_version.clone().into() }),
])
}
fn get_url(&self) -> String {
format!(
"https://{}{}?api_secret={}&measurement_id={}",
DOMAIN, ENDPOINT, self.state.ga4_key, self.state.ga4_product_code
)
}
}
impl Default for GA4MetricsService {
fn default() -> Self {
Self { state: MetricsState::default(), client: new_https_client(), post: Post::default() }
}
}
#[cfg(test)]
mod tests {
use super::*;
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 SDK_VERSION: &str = "99.99.99.99.1";
// const LAUNCH_ARGS: &str = "config analytics enable";
fn test_metrics_svc(
app_support_dir_path: &PathBuf,
app_name: String,
build_version: String,
sdk_version: String,
ga_product_code: String,
ga4_product_code: String,
ga4_key: String,
disabled: bool,
) -> GA4MetricsService {
GA4MetricsService {
state: MetricsState::from_config(
app_support_dir_path,
app_name,
build_version,
sdk_version,
ga_product_code,
ga4_product_code,
ga4_key,
disabled,
None,
),
client: new_https_client(),
post: Post::default(),
}
}
#[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),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
);
assert_eq!(ms.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),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
);
assert_eq!(ms.state.status, MetricsStatus::NewToTool);
assert_eq!(ms.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),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
);
assert_eq!(ms.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),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
);
assert_eq!(ms.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),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
true,
);
assert_eq!(ms.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),
String::from(SDK_VERSION),
UNKNOWN_PROPERTY_ID.to_string(),
UNKNOWN_GA4_PRODUCT_CODE.to_string(),
UNKNOWN_GA4_KEY.to_string(),
false,
);
assert_eq!(ms.state.status, MetricsStatus::NewUser);
let _res = ms.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())
}
}