mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(connlib): add basic analytics about new sessions (#9379)
This PR adds basic analytics to `connlib` by sending two events to PostHog: 1. `new_session` which is sent every time we establish a new session with a Firezone backend. This could be our production or staging instance but also a session to an on-premise installation of Firezone. We include the API URL in the event payload to further distinguish these. 2. `$identify` to link the client + version as well as the operating system to the user. The user is identified by the Firezone ID. --------- Signed-off-by: Thomas Eizinger <thomas@eizinger.io> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
103
rust/telemetry/src/analytics.rs
Normal file
103
rust/telemetry/src/analytics.rs
Normal file
@@ -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<P>(
|
||||
event: impl Into<String>,
|
||||
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<P> {
|
||||
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,
|
||||
}
|
||||
@@ -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<Runtime> = 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<FeatureFlags> {
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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.
|
||||
// <https://docs.sentry.io/platforms/rust/configuration/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| {
|
||||
|
||||
33
rust/telemetry/src/posthog.rs
Normal file
33
rust/telemetry/src/posthog.rs
Normal file
@@ -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<Runtime> = 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
|
||||
}
|
||||
Reference in New Issue
Block a user