feat(telemetry): grab env and distinct_id from Sentry session (#9801)

At present, our primary indicator as to whether telemetry is active is
whether we have a Sentry session. For our analytics events however, we
currently require passing in the Firezone ID and API url again. This
makes it difficult to send analytics events from areas of the code that
don't have this information available.

To still allow for that, we integrate the `analytics` module more
tightly with the Sentry session. This allows us to drop two parameters
from the `$identify` event and also means we now respect the
`NO_TELEMETRY` setting for these events except for `new_session`. This
event is sent regardless because it allows us to track, how many on-prem
installations of Firezone are out there.
This commit is contained in:
Thomas Eizinger
2025-07-10 22:05:08 +02:00
committed by GitHub
parent 704ff9fd7a
commit 04499da11e
9 changed files with 84 additions and 102 deletions

View File

@@ -264,12 +264,7 @@ impl WrappedSession {
runtime.block_on(telemetry.start(&api_url, RELEASE, APPLE_DSN, device_id.clone()));
Telemetry::set_account_slug(account_slug.clone());
analytics::identify(
device_id.clone(),
api_url.to_string(),
RELEASE.to_owned(),
Some(account_slug),
);
analytics::identify(RELEASE.to_owned(), Some(account_slug));
init_logging(log_dir.into(), log_filter)?;
install_rustls_crypto_provider();

View File

@@ -237,12 +237,7 @@ fn connect(
runtime.block_on(telemetry.start(&api_url, RELEASE, platform::DSN, device_id.clone()));
Telemetry::set_account_slug(account_slug.clone());
analytics::identify(
device_id.clone(),
api_url.to_string(),
RELEASE.to_owned(),
Some(account_slug),
);
analytics::identify(RELEASE.to_owned(), Some(account_slug));
init_logging(&PathBuf::from(log_dir), log_filter)?;
install_rustls_crypto_provider();

View File

@@ -52,7 +52,6 @@ pub struct Eventloop {
tunnel: GatewayTunnel,
portal: PhoenixChannel<(), IngressMessages, (), PublicKeyParam>,
tun_device_manager: Arc<Mutex<TunDeviceManager>>,
firezone_id: String,
resolve_tasks:
futures_bounded::FuturesTupleSet<Result<Vec<IpAddr>, Arc<anyhow::Error>>, ResolveTrigger>,
@@ -68,14 +67,12 @@ impl Eventloop {
tunnel: GatewayTunnel,
mut portal: PhoenixChannel<(), IngressMessages, (), PublicKeyParam>,
tun_device_manager: TunDeviceManager,
firezone_id: String,
) -> Self {
portal.connect(PublicKeyParam(tunnel.public_key().to_bytes()));
Self {
tunnel,
portal,
firezone_id,
tun_device_manager: Arc::new(Mutex::new(tun_device_manager)),
resolve_tasks: futures_bounded::FuturesTupleSet::new(DNS_RESOLUTION_TIMEOUT, 1000),
set_interface_tasks: futures_bounded::FuturesSet::new(Duration::from_secs(5), 10),
@@ -385,12 +382,7 @@ impl Eventloop {
if let Some(account_slug) = init.account_slug {
Telemetry::set_account_slug(account_slug.clone());
analytics::identify(
self.firezone_id.clone(),
self.portal.url(),
RELEASE.to_owned(),
Some(account_slug),
)
analytics::identify(RELEASE.to_owned(), Some(account_slug))
}
self.tunnel.state_mut().update_relays(

View File

@@ -134,13 +134,8 @@ async fn try_main(cli: Cli, telemetry: &mut Telemetry) -> Result<()> {
opentelemetry::global::set_meter_provider(provider);
}
let login = LoginUrl::gateway(
cli.api_url,
&cli.token,
firezone_id.clone(),
cli.firezone_name,
)
.context("Failed to construct URL for logging into portal")?;
let login = LoginUrl::gateway(cli.api_url, &cli.token, firezone_id, cli.firezone_name)
.context("Failed to construct URL for logging into portal")?;
let resolv_conf = resolv_conf::Config::parse(
std::fs::read_to_string("/etc/resolv.conf").context("Failed to read /etc/resolv.conf")?,
@@ -184,7 +179,7 @@ async fn try_main(cli: Cli, telemetry: &mut Telemetry) -> Result<()> {
}
let eventloop = future::poll_fn({
let mut eventloop = Eventloop::new(tunnel, portal, tun_device_manager, firezone_id);
let mut eventloop = Eventloop::new(tunnel, portal, tun_device_manager);
move |cx| eventloop.poll(cx)
});

View File

@@ -587,18 +587,13 @@ impl<'a> Handler<'a> {
firezone_telemetry::GUI_DSN,
self.device_id.id.clone(),
)
.await
}
.await;
if let Some(account_slug) = account_slug {
Telemetry::set_account_slug(account_slug.clone());
if let Some(account_slug) = account_slug {
Telemetry::set_account_slug(account_slug.clone());
analytics::identify(
self.device_id.id.clone(),
environment,
release,
Some(account_slug),
);
analytics::identify(release, Some(account_slug));
}
}
}
}

View File

@@ -179,13 +179,6 @@ fn main() -> Result<()> {
None => device_id::get_or_create().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id,
};
analytics::identify(
firezone_id.clone(),
cli.api_url.to_string(),
RELEASE.to_owned(),
None,
);
let mut telemetry = Telemetry::default();
if cli.is_telemetry_allowed() {
rt.block_on(telemetry.start(
@@ -194,6 +187,8 @@ fn main() -> Result<()> {
firezone_telemetry::HEADLESS_DSN,
firezone_id.clone(),
));
analytics::identify(RELEASE.to_owned(), None);
}
tracing::info!(arch = std::env::consts::ARCH, version = VERSION);

View File

@@ -4,8 +4,11 @@ use anyhow::{Context as _, Result, bail};
use serde::Serialize;
use sha2::Digest as _;
use crate::{Env, posthog::RUNTIME};
use crate::{ApiUrl, Env, Telemetry, posthog::RUNTIME};
/// Records a `new_session` event for a particular user and API url.
///
/// This purposely does not use the existing telemetry session because we also want to capture sessions from self-hosted users.
pub fn new_session(maybe_legacy_id: String, api_url: String) {
let distinct_id = if uuid::Uuid::from_str(&maybe_legacy_id).is_ok() {
hex::encode(sha2::Sha256::digest(&maybe_legacy_id))
@@ -17,8 +20,10 @@ pub fn new_session(maybe_legacy_id: String, api_url: String) {
if let Err(e) = capture(
"new_session",
distinct_id,
api_url.clone(),
NewSessionProperties { api_url },
ApiUrl::new(&api_url),
NewSessionProperties {
api_url: api_url.clone(),
},
)
.await
{
@@ -27,30 +32,23 @@ pub fn new_session(maybe_legacy_id: String, api_url: String) {
});
}
/// Associate several properties with a particular "distinct_id" in PostHog.
pub fn identify(
maybe_legacy_id: String,
api_url: String,
release: String,
account_slug: Option<String>,
) {
let is_legacy_id = uuid::Uuid::from_str(&maybe_legacy_id).is_ok();
let distinct_id = if is_legacy_id {
hex::encode(sha2::Sha256::digest(&maybe_legacy_id))
} else {
maybe_legacy_id.clone()
/// Associate several properties with the current telemetry user.
pub fn identify(release: String, account_slug: Option<String>) {
let Some(env) = Telemetry::current_env() else {
tracing::debug!("Cannot send $identify: Unknown env");
return;
};
let Some(distinct_id) = Telemetry::current_user() else {
tracing::debug!("Cannot send $identify: Unknown user");
return;
};
RUNTIME.spawn({
let distinct_id = distinct_id.clone();
let api_url = api_url.clone();
async move {
if let Err(e) = capture(
"$identify",
distinct_id,
api_url,
env,
IdentifyProperties {
set: PersonProperties {
release,
@@ -65,39 +63,20 @@ pub fn identify(
}
}
});
// Create an alias ID for the user so we can also find them under the "external ID" used in the portal.
if is_legacy_id {
RUNTIME.spawn(async move {
if let Err(e) = capture(
"$create_alias",
distinct_id.clone(),
api_url,
CreateAliasProperties {
alias: maybe_legacy_id,
distinct_id,
},
)
.await
{
tracing::debug!("Failed to log `$create_alias` event: {e:#}");
}
});
}
}
async fn capture<P>(
event: impl Into<String>,
distinct_id: String,
api_url: String,
env: impl Into<Env>,
properties: P,
) -> Result<()>
where
P: Serialize,
{
let event = event.into();
let env = env.into();
let env = Env::from_api_url(&api_url);
let Some(api_key) = crate::posthog::api_key_for_env(env) else {
tracing::debug!(%event, %env, "Not sending event because we don't have an API key");
@@ -111,7 +90,7 @@ where
.json(&CaptureRequest {
api_key: api_key.to_string(),
distinct_id,
event,
event: event.clone(),
properties,
})
.send()
@@ -126,6 +105,8 @@ where
bail!("Failed to capture event; status={status}, body={body}")
}
tracing::debug!(%event);
Ok(())
}
@@ -156,9 +137,3 @@ struct PersonProperties {
#[serde(rename = "$os")]
os: String,
}
#[derive(serde::Serialize)]
struct CreateAliasProperties {
distinct_id: String,
alias: String,
}

View File

@@ -0,0 +1,25 @@
#[derive(Debug, PartialEq)]
pub(crate) struct ApiUrl<'a>(&'a str);
impl ApiUrl<'static> {
pub(crate) const PROD: Self = ApiUrl("wss://api.firezone.dev");
pub(crate) const STAGING: Self = ApiUrl("wss://api.firez.one");
pub(crate) const DOCKER_COMPOSE: Self = ApiUrl("ws://api:8081");
pub(crate) const LOCALHOST: Self = ApiUrl("ws://localhost:8081");
}
impl<'a> ApiUrl<'a> {
pub(crate) fn new(url: &'a str) -> Self {
Self(url.trim_end_matches("/"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trailing_slash_is_trimmed() {
assert_eq!(ApiUrl::new("wss://api.firezone.dev/"), ApiUrl::PROD)
}
}

View File

@@ -3,6 +3,7 @@
use std::{borrow::Cow, collections::BTreeMap, fmt, str::FromStr, sync::Arc, time::Duration};
use anyhow::{Result, bail};
use api_url::ApiUrl;
use sentry::{
BeforeCallback, User,
protocol::{Event, Log, LogAttribute, SessionStatus},
@@ -13,6 +14,7 @@ pub mod analytics;
pub mod feature_flags;
pub mod otel;
mod api_url;
mod posthog;
pub struct Dsn(&'static str);
@@ -53,17 +55,18 @@ pub(crate) enum Env {
OnPrem,
}
impl Env {
pub(crate) fn from_api_url(api_url: &str) -> Self {
match api_url.trim_end_matches('/') {
"wss://api.firezone.dev" => Self::Production,
"wss://api.firez.one" => Self::Staging,
"ws://api:8081" => Self::DockerCompose,
"ws://localhost:8081" => Self::DockerCompose,
impl From<ApiUrl<'_>> for Env {
fn from(value: ApiUrl) -> Self {
match value {
ApiUrl::PROD => Self::Production,
ApiUrl::STAGING => Self::Staging,
ApiUrl::DOCKER_COMPOSE | ApiUrl::LOCALHOST => Self::DockerCompose,
_ => Self::OnPrem,
}
}
}
impl Env {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Env::Production => "production",
@@ -116,7 +119,7 @@ impl Telemetry {
pub async fn start(&mut self, api_url: &str, release: &str, dsn: Dsn, firezone_id: String) {
// 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 = Env::from_api_url(api_url);
let environment = Env::from(ApiUrl::new(api_url));
if self
.inner
@@ -213,6 +216,18 @@ impl Telemetry {
user.other.insert("account_slug".to_owned(), slug.into());
});
}
pub(crate) fn current_env() -> Option<Env> {
let client = sentry::Hub::main().client()?;
let env = client.options().environment.as_deref()?;
let env = Env::from_str(env).ok()?;
Some(env)
}
pub(crate) fn current_user() -> Option<String> {
sentry::Hub::main().configure_scope(|s| s.user()?.id.clone())
}
}
/// Computes the [`User`] scope based on the contents of `firezone_id`.