diff --git a/rust/android-client-ffi/src/lib.rs b/rust/android-client-ffi/src/lib.rs index 008fe8747..1d47fa42f 100644 --- a/rust/android-client-ffi/src/lib.rs +++ b/rust/android-client-ffi/src/lib.rs @@ -12,7 +12,7 @@ use client_shared::{DisconnectError, Session, V4RouteList, V6RouteList}; use connlib_model::ResourceView; use dns_types::DomainName; use firezone_logging::{err_with_src, sentry_layer}; -use firezone_telemetry::{ANDROID_DSN, Telemetry}; +use firezone_telemetry::{ANDROID_DSN, Telemetry, analytics}; use ip_network::{Ipv4Network, Ipv6Network}; use jni::{ JNIEnv, JavaVM, @@ -345,6 +345,8 @@ fn connect( telemetry.start(&api_url, RELEASE, ANDROID_DSN); Telemetry::set_firezone_id(device_id.clone()); + analytics::identify(device_id.clone(), api_url.to_string(), RELEASE.to_owned()); + init_logging(&PathBuf::from(log_dir), log_filter)?; install_rustls_crypto_provider(); @@ -356,7 +358,7 @@ fn connect( let url = LoginUrl::client( api_url.as_str(), &secret, - device_id, + device_id.clone(), Some(device_name), device_info, )?; @@ -389,6 +391,8 @@ fn connect( runtime.handle().clone(), ); + analytics::new_session(device_id, api_url.to_string()); + runtime.spawn(async move { while let Some(event) = event_stream.next().await { match event { diff --git a/rust/apple-client-ffi/src/lib.rs b/rust/apple-client-ffi/src/lib.rs index 6ff2e5662..3538bad17 100644 --- a/rust/apple-client-ffi/src/lib.rs +++ b/rust/apple-client-ffi/src/lib.rs @@ -15,6 +15,7 @@ use firezone_logging::err_with_src; use firezone_logging::sentry_layer; use firezone_telemetry::APPLE_DSN; use firezone_telemetry::Telemetry; +use firezone_telemetry::analytics; use ip_network::{Ipv4Network, Ipv6Network}; use phoenix_channel::LoginUrl; use phoenix_channel::PhoenixChannel; @@ -258,6 +259,8 @@ impl WrappedSession { Telemetry::set_firezone_id(device_id.clone()); Telemetry::set_account_slug(account_slug); + analytics::identify(device_id.clone(), api_url.to_string(), RELEASE.to_owned()); + init_logging(log_dir.into(), log_filter)?; install_rustls_crypto_provider(); @@ -268,7 +271,7 @@ impl WrappedSession { let url = LoginUrl::client( api_url.as_str(), &secret, - device_id, + device_id.clone(), device_name_override, device_info, )?; @@ -300,6 +303,8 @@ impl WrappedSession { ); session.set_tun(Box::new(Tun::new()?)); + analytics::new_session(device_id, api_url.to_string()); + runtime.spawn(async move { let callback_handler = CallbackHandler { inner: callback_handler, diff --git a/rust/gui-client/src-tauri/src/gui.rs b/rust/gui-client/src-tauri/src/gui.rs index 95b8a2276..5aa81eec9 100644 --- a/rust/gui-client/src-tauri/src/gui.rs +++ b/rust/gui-client/src-tauri/src/gui.rs @@ -16,7 +16,7 @@ use crate::{ }; use anyhow::{Context, Result, bail}; use firezone_logging::err_with_src; -use firezone_telemetry::Telemetry; +use firezone_telemetry::{Telemetry, analytics}; use futures::SinkExt as _; use std::time::Duration; use tauri::{Emitter, Manager}; @@ -226,12 +226,13 @@ pub fn run( .inspect_err(|e| tracing::debug!("Failed to load MDM settings {e:#}")) .unwrap_or_default(); + let api_url = mdm_settings + .api_url + .as_ref() + .unwrap_or(&advanced_settings.api_url); + telemetry.start( - mdm_settings - .api_url - .as_ref() - .unwrap_or(&advanced_settings.api_url) - .as_str(), + api_url.as_str(), crate::RELEASE, firezone_telemetry::GUI_DSN, ); @@ -239,7 +240,9 @@ pub fn run( // Get the device ID before starting Tokio, so that all the worker threads will inherit the correct scope. // Technically this means we can fail to get the device ID on a newly-installed system, since the Tunnel service may not have fully started up when the GUI process reaches this point, but in practice it's unlikely. if let Ok(id) = firezone_bin_shared::device_id::get() { - Telemetry::set_firezone_id(id.id); + Telemetry::set_firezone_id(id.id.clone()); + + analytics::identify(id.id, api_url.to_string(), crate::RELEASE.to_owned()); } // Needed for the deep link server diff --git a/rust/gui-client/src-tauri/src/service.rs b/rust/gui-client/src-tauri/src/service.rs index ff310f721..49f314ee6 100644 --- a/rust/gui-client/src-tauri/src/service.rs +++ b/rust/gui-client/src-tauri/src/service.rs @@ -14,7 +14,7 @@ use firezone_bin_shared::{ signals, }; use firezone_logging::{FilterReloadHandle, err_with_src, telemetry_span}; -use firezone_telemetry::Telemetry; +use firezone_telemetry::{Telemetry, analytics}; use futures::{ Future as _, SinkExt as _, Stream as _, future::poll_fn, @@ -456,7 +456,7 @@ impl<'a> Handler<'a> { let url = LoginUrl::client( Url::parse(api_url).context("Failed to parse URL")?, &token, - device_id.id, + device_id.id.clone(), None, DeviceInfo { device_serial: device_info::serial(), @@ -490,6 +490,9 @@ impl<'a> Handler<'a> { portal, tokio::runtime::Handle::current(), ); + + analytics::new_session(device_id.id, api_url.to_string()); + // Call `set_dns` before `set_tun` so that the tunnel starts up with a valid list of resolvers. tracing::debug!(?dns, "Calling `set_dns`..."); connlib.set_dns(dns); diff --git a/rust/headless-client/src/main.rs b/rust/headless-client/src/main.rs index 6f0a8185c..2d20ed0d0 100644 --- a/rust/headless-client/src/main.rs +++ b/rust/headless-client/src/main.rs @@ -12,8 +12,7 @@ use firezone_bin_shared::{ signals, }; use firezone_logging::telemetry_span; -use firezone_telemetry::Telemetry; -use firezone_telemetry::otel; +use firezone_telemetry::{Telemetry, analytics, otel}; use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider}; use phoenix_channel::PhoenixChannel; use phoenix_channel::get_user_agent; @@ -129,6 +128,7 @@ enum Cmd { } const VERSION: &str = env!("CARGO_PKG_VERSION"); +const RELEASE: &str = concat!("headless-client@", env!("CARGO_PKG_VERSION")); fn main() -> Result<()> { rustls::crypto::ring::default_provider() @@ -174,7 +174,7 @@ fn main() -> Result<()> { if cli.is_telemetry_allowed() { telemetry.start( cli.api_url.as_ref(), - &format!("headless-client@{VERSION}"), + RELEASE, firezone_telemetry::HEADLESS_DSN, ); } @@ -201,8 +201,14 @@ fn main() -> Result<()> { }; Telemetry::set_firezone_id(firezone_id.clone()); + analytics::identify( + firezone_id.clone(), + cli.api_url.to_string(), + RELEASE.to_owned(), + ); + let url = LoginUrl::client( - cli.api_url, + cli.api_url.clone(), &token, firezone_id.clone(), cli.firezone_name, @@ -230,7 +236,7 @@ fn main() -> Result<()> { .with_resource(otel::default_resource_with([ otel::attr::service_name!(), otel::attr::service_version!(), - otel::attr::service_instance_id(firezone_id), + otel::attr::service_instance_id(firezone_id.clone()), ])) .build(); @@ -263,6 +269,8 @@ fn main() -> Result<()> { rt.handle().clone(), ); + analytics::new_session(firezone_id.clone(), cli.api_url.to_string()); + let mut terminate = signals::Terminate::new()?; let mut hangup = signals::Hangup::new()?; diff --git a/rust/telemetry/src/analytics.rs b/rust/telemetry/src/analytics.rs new file mode 100644 index 000000000..d4c87851e --- /dev/null +++ b/rust/telemetry/src/analytics.rs @@ -0,0 +1,103 @@ +use anyhow::{Context as _, Result, bail}; +use serde::Serialize; + +use crate::{Env, posthog::RUNTIME}; + +pub fn new_session(distinct_id: String, api_url: String) { + RUNTIME.spawn(async move { + if let Err(e) = capture( + "new_session", + distinct_id, + api_url.clone(), + NewSessionProperties { api_url }, + ) + .await + { + tracing::debug!("Failed to log `new_session` event: {e:#}"); + } + }); +} + +/// Associate several properties with a particular "distinct_id" in PostHog. +pub fn identify(distinct_id: String, api_url: String, release: String) { + RUNTIME.spawn(async move { + if let Err(e) = capture( + "$identify", + distinct_id, + api_url, + IdentifyProperties { + set: PersonProperties { + release, + os: std::env::consts::OS.to_owned(), + }, + }, + ) + .await + { + tracing::debug!("Failed to log `$identify` event: {e:#}"); + } + }); +} + +async fn capture

( + event: impl Into, + distinct_id: String, + api_url: String, + properties: P, +) -> Result<()> +where + P: Serialize, +{ + let env = Env::from_api_url(&api_url); + let api_key = crate::posthog::api_key_for_env(env); + + let response = reqwest::ClientBuilder::new() + .connection_verbose(true) + .build()? + .post("https://us.i.posthog.com/i/v0/e/") + .json(&CaptureRequest { + api_key: api_key.to_string(), + distinct_id, + event: event.into(), + properties, + }) + .send() + .await + .context("Failed to send POST request")?; + + let status = response.status(); + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + + bail!("Failed to capture event; status={status}, body={body}") + } + + Ok(()) +} + +#[derive(serde::Serialize)] +struct CaptureRequest

{ + event: String, + distinct_id: String, + api_key: String, + properties: P, +} + +#[derive(serde::Serialize)] +struct NewSessionProperties { + api_url: String, +} + +#[derive(serde::Serialize)] +struct IdentifyProperties { + #[serde(rename = "$set")] + set: PersonProperties, +} + +#[derive(serde::Serialize)] +struct PersonProperties { + release: String, + #[serde(rename = "$os")] + os: String, +} diff --git a/rust/telemetry/src/feature_flags.rs b/rust/telemetry/src/feature_flags.rs index 3643a951c..1aaeeb6e8 100644 --- a/rust/telemetry/src/feature_flags.rs +++ b/rust/telemetry/src/feature_flags.rs @@ -3,13 +3,13 @@ use std::{sync::LazyLock, time::Duration}; use anyhow::{Context as _, Result, bail}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; -use tokio::runtime::Runtime; -const POSTHOG_API_KEY_PROD: &str = "phc_uXXl56plyvIBHj81WwXBLtdPElIRbm7keRTdUCmk8ll"; -const POSTHOG_API_KEY_STAGING: &str = "phc_tHOVtq183RpfKmzadJb4bxNpLM5jzeeb1Gu8YSH3nsK"; -const RE_EVAL_DURATION: Duration = Duration::from_secs(5 * 60); +use crate::{ + Env, + posthog::{POSTHOG_API_KEY_PROD, POSTHOG_API_KEY_STAGING, RUNTIME}, +}; -static RUNTIME: LazyLock = LazyLock::new(init_runtime); +pub(crate) const RE_EVAL_DURATION: Duration = Duration::from_secs(5 * 60); // Process-wide storage of enabled feature flags. // @@ -25,10 +25,10 @@ pub fn drop_llmnr_nxdomain_responses() -> bool { } pub(crate) fn reevaluate(user_id: String, env: &str) { - let api_key = match env { - crate::env::PRODUCTION => POSTHOG_API_KEY_PROD, - crate::env::STAGING => POSTHOG_API_KEY_STAGING, - _ => return, + let api_key = match env.parse() { + Ok(Env::Production) => POSTHOG_API_KEY_PROD, + Ok(Env::Staging) => POSTHOG_API_KEY_STAGING, + Ok(Env::OnPrem) | Err(_) => return, }; RUNTIME.spawn(async move { @@ -47,39 +47,26 @@ pub(crate) fn reevaluate(user_id: String, env: &str) { }); } -/// Initialize the runtime to use for evaluating feature flags. -fn init_runtime() -> Runtime { - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) // We only need 1 worker thread. - .thread_name("feature-flag-worker") - .enable_io() - .enable_time() - .build() - .expect("to be able to build runtime"); +pub(crate) async fn reeval_timer() { + loop { + tokio::time::sleep(RE_EVAL_DURATION).await; - runtime.spawn(async move { - loop { - tokio::time::sleep(RE_EVAL_DURATION).await; + let Some(client) = sentry::Hub::main().client() else { + continue; + }; - let Some(client) = sentry::Hub::main().client() else { - continue; - }; + let Some(env) = client.options().environment.as_ref() else { + continue; // Nothing to do if we don't have an environment set. + }; - let Some(env) = client.options().environment.as_ref() else { - continue; // Nothing to do if we don't have an environment set. - }; + let Some(user_id) = + sentry::Hub::main().configure_scope(|scope| scope.user().and_then(|u| u.id.clone())) + else { + continue; // Nothing to do if we don't have a user-id set. + }; - let Some(user_id) = sentry::Hub::main() - .configure_scope(|scope| scope.user().and_then(|u| u.id.clone())) - else { - continue; // Nothing to do if we don't have a user-id set. - }; - - reevaluate(user_id, env); - } - }); - - runtime + reevaluate(user_id, env); + } } async fn decide(distinct_id: String, api_key: String) -> Result { diff --git a/rust/telemetry/src/lib.rs b/rust/telemetry/src/lib.rs index f9fbb3625..ff08d5519 100644 --- a/rust/telemetry/src/lib.rs +++ b/rust/telemetry/src/lib.rs @@ -1,13 +1,16 @@ #![cfg_attr(test, allow(clippy::unwrap_used))] -use std::{borrow::Cow, sync::Arc, time::Duration}; +use std::{borrow::Cow, fmt, str::FromStr, sync::Arc, time::Duration}; -use env::ON_PREM; +use anyhow::bail; use sentry::protocol::SessionStatus; +pub mod analytics; pub mod feature_flags; pub mod otel; +mod posthog; + pub struct Dsn(&'static str); // TODO: Dynamic DSN @@ -37,10 +40,48 @@ pub const TESTING: Dsn = Dsn( "https://55ef451fca9054179a11f5d132c02f45@o4507971108339712.ingest.us.sentry.io/4508792604852224", ); -mod env { - pub const PRODUCTION: &str = "production"; - pub const STAGING: &str = "staging"; - pub const ON_PREM: &str = "on-prem"; +#[derive(Debug, PartialEq, Clone, Copy)] +pub(crate) enum Env { + Production, + Staging, + OnPrem, +} + +impl Env { + pub(crate) fn from_api_url(api_url: &str) -> Self { + match api_url { + "wss://api.firezone.dev" | "wss://api.firezone.dev/" => Self::Production, + "wss://api.firez.one" | "wss://api.firez.one/" => Self::Staging, + _ => Self::OnPrem, + } + } + + pub(crate) fn as_str(&self) -> &'static str { + match self { + Env::Production => "production", + Env::Staging => "staging", + Env::OnPrem => "on-prem", + } + } +} + +impl FromStr for Env { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "production" => Ok(Self::Production), + "staging" => Ok(Self::Staging), + "on-prem" => Ok(Self::OnPrem), + other => bail!("Unknown env `{other}`"), + } + } +} + +impl fmt::Display for Env { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_str().fmt(f) + } } #[derive(Default)] @@ -63,17 +104,13 @@ impl Telemetry { pub fn start(&mut self, api_url: &str, release: &str, dsn: Dsn) { // Can't use URLs as `environment` directly, because Sentry doesn't allow slashes in environments. // - let environment = match api_url { - "wss://api.firezone.dev" | "wss://api.firezone.dev/" => env::PRODUCTION, - "wss://api.firez.one" | "wss://api.firez.one/" => env::STAGING, - _ => env::ON_PREM, - }; + let environment = Env::from_api_url(api_url); if self .inner .as_ref() .and_then(|i| i.options().environment.as_ref()) - .is_some_and(|env| env == environment) + .is_some_and(|env| env == environment.as_str()) { tracing::debug!(%environment, "Telemetry already initialised"); @@ -90,7 +127,7 @@ impl Telemetry { set_current_user(None); } - if environment == ON_PREM { + if environment == Env::OnPrem { tracing::debug!(%api_url, "Telemetry won't start in unofficial environment"); return; } @@ -100,7 +137,7 @@ impl Telemetry { let inner = sentry::init(( dsn.0, sentry::ClientOptions { - environment: Some(Cow::Borrowed(environment)), + environment: Some(Cow::Borrowed(environment.as_str())), // We can't get the release number ourselves because we don't know if we're embedded in a GUI Client or a Headless Client. release: Some(release.to_owned().into()), traces_sampler: Some(Arc::new(|tx| { diff --git a/rust/telemetry/src/posthog.rs b/rust/telemetry/src/posthog.rs new file mode 100644 index 000000000..2fd29bcf4 --- /dev/null +++ b/rust/telemetry/src/posthog.rs @@ -0,0 +1,33 @@ +use std::sync::LazyLock; +use tokio::runtime::Runtime; + +use crate::Env; + +pub(crate) const POSTHOG_API_KEY_PROD: &str = "phc_uXXl56plyvIBHj81WwXBLtdPElIRbm7keRTdUCmk8ll"; +pub(crate) const POSTHOG_API_KEY_STAGING: &str = "phc_tHOVtq183RpfKmzadJb4bxNpLM5jzeeb1Gu8YSH3nsK"; +pub(crate) const POSTHOG_API_KEY_ON_PREM: &str = "phc_4R9Ii6q4SEofVkH7LvajwuJ3nsGFhCj0ZlfysS2FNc"; + +pub(crate) static RUNTIME: LazyLock = LazyLock::new(init_runtime); + +pub(crate) fn api_key_for_env(env: Env) -> &'static str { + match env { + Env::Production => POSTHOG_API_KEY_PROD, + Env::Staging => POSTHOG_API_KEY_STAGING, + Env::OnPrem => POSTHOG_API_KEY_ON_PREM, + } +} + +/// Initialize the runtime to use for evaluating feature flags. +fn init_runtime() -> Runtime { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) // We only need 1 worker thread. + .thread_name("posthog-worker") + .enable_io() + .enable_time() + .build() + .expect("to be able to build runtime"); + + runtime.spawn(crate::feature_flags::reeval_timer()); + + runtime +}