Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web analytics #3166

Merged
merged 27 commits into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ace8049
implement web analytics pipeline
jprochazk Aug 31, 2023
be7570c
enable analytics in the web viewer
jprochazk Aug 31, 2023
541bea6
fix build without `analytics` feature
jprochazk Aug 31, 2023
8c1ef8f
`WASM` -> `Wasm`
jprochazk Aug 31, 2023
bc10af2
fix wasm lints
jprochazk Aug 31, 2023
49a4425
fix more lints
jprochazk Aug 31, 2023
23720ed
make working with wasm less annoying
jprochazk Sep 1, 2023
e7fda64
disable analytics if we're in a notebook
jprochazk Sep 1, 2023
21d65b0
Merge branch 'main' into jan/web-analytics
jprochazk Sep 1, 2023
ae66e5b
expose `set_email` from wasm
jprochazk Sep 1, 2023
b390c71
fix lint
jprochazk Sep 1, 2023
9842533
fix the same lint differently
jprochazk Sep 1, 2023
6bf93c5
set url in analytics when running on web
jprochazk Sep 1, 2023
eeab230
Merge branch 'main' into jan/web-analytics
jprochazk Sep 1, 2023
0cd858f
fix lints
jprochazk Sep 1, 2023
a2647c2
Merge branch 'main' into jan/web-analytics
jprochazk Sep 1, 2023
0e6406d
explain project key
jprochazk Sep 1, 2023
717a2a0
move posthog events into their own module
jprochazk Sep 1, 2023
54a5d93
unshorten analytics config keys
jprochazk Sep 1, 2023
ace0a61
warn if config failed to load
jprochazk Sep 1, 2023
1b7679e
refactor analytics config load
jprochazk Sep 1, 2023
1f58135
use `{:?}` instead of manually quoting
jprochazk Sep 1, 2023
0c00f58
only set url property if we're on a `rerun.io` domain
jprochazk Sep 1, 2023
efb538b
document `set_email`
jprochazk Sep 1, 2023
8570e92
Merge branch 'main' into jan/web-analytics
jprochazk Sep 1, 2023
ac1c3d3
Merge branch 'main' into jan/web-analytics
jprochazk Sep 4, 2023
f03ef1a
add comments + stricter hostname check
jprochazk Sep 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
88 changes: 88 additions & 0 deletions crates/re_analytics/src/event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use crate::{Event, Property};
use std::collections::HashMap;

use time::OffsetDateTime;

/// The "public" API key can be obtained at <https://eu.posthog.com/project/settings#project-api-key>.
/// Make sure you are logged in to the right organization and have the correct project open.
/// Unfortunately that stuff is client-side routed, and there's no way to link directly to the right place.
pub const PUBLIC_POSTHOG_PROJECT_KEY: &str = "phc_sgKidIE4WYYFSJHd8LEYY1UZqASpnfQKeMqlJfSXwqg";

#[derive(Debug, serde::Serialize)]
#[serde(untagged)]
pub enum PostHogEvent<'a> {
Capture(PostHogCaptureEvent<'a>),
Identify(PostHogIdentifyEvent<'a>),
}

impl<'a> PostHogEvent<'a> {
pub fn from_event(analytics_id: &'a str, session_id: &'a str, event: &'a Event) -> Self {
let properties = event.props.iter().map(|(name, value)| {
(
name.as_ref(),
match value {
&Property::Integer(v) => v.into(),
&Property::Float(v) => v.into(),
Property::String(v) => v.as_str().into(),
&Property::Bool(v) => v.into(),
},
)
});

match event.kind {
crate::EventKind::Append => Self::Capture(PostHogCaptureEvent {
timestamp: event.time_utc,
event: event.name.as_ref(),
distinct_id: analytics_id,
properties: properties
.chain([("session_id", session_id.into())])
.collect(),
}),
crate::EventKind::Update => Self::Identify(PostHogIdentifyEvent {
timestamp: event.time_utc,
event: "$identify",
distinct_id: analytics_id,
properties: [("session_id", session_id.into())].into(),
set: properties.collect(),
}),
}
}
}

// See https://posthog.com/docs/api/post-only-endpoints#capture.
#[derive(Debug, serde::Serialize)]
pub struct PostHogCaptureEvent<'a> {
#[serde(with = "::time::serde::rfc3339")]
timestamp: OffsetDateTime,
event: &'a str,
distinct_id: &'a str,
properties: HashMap<&'a str, serde_json::Value>,
}

// See https://posthog.com/docs/api/post-only-endpoints#identify.
#[derive(Debug, serde::Serialize)]
pub struct PostHogIdentifyEvent<'a> {
#[serde(with = "::time::serde::rfc3339")]
timestamp: OffsetDateTime,
event: &'a str,
distinct_id: &'a str,
properties: HashMap<&'a str, serde_json::Value>,
#[serde(rename = "$set")]
set: HashMap<&'a str, serde_json::Value>,
}

// See https://posthog.com/docs/api/post-only-endpoints#batch-events.
#[derive(Debug, serde::Serialize)]
pub struct PostHogBatch<'a> {
api_key: &'static str,
batch: &'a [PostHogEvent<'a>],
}

impl<'a> PostHogBatch<'a> {
pub fn from_events(events: &'a [PostHogEvent<'a>]) -> Self {
Self {
api_key: PUBLIC_POSTHOG_PROJECT_KEY,
batch: events,
}
}
}
141 changes: 41 additions & 100 deletions crates/re_analytics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ use web::{Pipeline, PipelineError};
#[cfg(not(target_arch = "wasm32"))]
pub mod cli;

mod event;
use event::{PostHogBatch, PostHogEvent};

// ----------------------------------------------------------------------------

use std::borrow::Cow;
Expand Down Expand Up @@ -189,6 +192,7 @@ impl From<&str> for Property {

// ---

#[cfg(not(target_arch = "wasm32"))]
const DISCLAIMER: &str = "
Welcome to Rerun!

Expand Down Expand Up @@ -235,36 +239,54 @@ pub struct Analytics {
event_id: AtomicU64,
}

impl Analytics {
pub fn new(tick: Duration) -> Result<Self, AnalyticsError> {
let config = Config::load()?;
fn load_config() -> Result<Config, ConfigError> {
let config = match Config::load() {
Ok(config) => config,
#[allow(unused_variables)]
Err(err) => {
// NOTE: This will cause the first run disclaimer to show up again on native,
// and analytics will be disabled for the rest of the session.
#[cfg(not(target_arch = "wasm32"))]
re_log::warn!("failed to load analytics config file: {err}");
None
}
};

#[cfg(target_arch = "wasm32")]
let config = if let Some(config) = config {
config
} else {
// the config doesnt exist in local storage yet, save it
let config = Config::default();
config.save()?;
config
};
if let Some(config) = config {
re_log::trace!(?config, "loaded analytics config");

#[cfg(not(target_arch = "wasm32"))]
let config = match config {
Some(config) => config,
None => Config::new()?,
};
Ok(config)
} else {
re_log::trace!(?config, "initialized analytics config");

re_log::trace!(?config, ?tick, "loaded analytics config");
// NOTE: If this fails, we give up, because we can't produce
// a config on native any other way.
let config = Config::new()?;

#[cfg(not(target_arch = "wasm32"))]
if config.is_first_run() {
eprintln!("{DISCLAIMER}");

config.save()?;
re_log::trace!(?config, ?tick, "saved analytics config");
re_log::trace!(?config, "saved analytics config");
}

#[cfg(target_arch = "wasm32")]
{
// always save the config on web, without printing a disclaimer.
config.save()?;
re_log::trace!(?config, "saved analytics config");
}

Ok(config)
}
}

impl Analytics {
pub fn new(tick: Duration) -> Result<Self, AnalyticsError> {
let config = load_config()?;
let pipeline = Pipeline::new(&config, tick)?;
re_log::trace!("initialized analytics pipeline");

Ok(Self {
config,
Expand Down Expand Up @@ -309,84 +331,3 @@ impl Analytics {
}
}
}

const PUBLIC_POSTHOG_PROJECT_KEY: &str = "phc_sgKidIE4WYYFSJHd8LEYY1UZqASpnfQKeMqlJfSXwqg";

#[derive(Debug, serde::Serialize)]
#[serde(untagged)]
enum PostHogEvent<'a> {
Capture(PostHogCaptureEvent<'a>),
Identify(PostHogIdentifyEvent<'a>),
}

impl<'a> PostHogEvent<'a> {
fn from_event(analytics_id: &'a str, session_id: &'a str, event: &'a Event) -> Self {
let properties = event.props.iter().map(|(name, value)| {
(
name.as_ref(),
match value {
&Property::Integer(v) => v.into(),
&Property::Float(v) => v.into(),
Property::String(v) => v.as_str().into(),
&Property::Bool(v) => v.into(),
},
)
});

match event.kind {
crate::EventKind::Append => Self::Capture(PostHogCaptureEvent {
timestamp: event.time_utc,
event: event.name.as_ref(),
distinct_id: analytics_id,
properties: properties
.chain([("session_id", session_id.into())])
.collect(),
}),
crate::EventKind::Update => Self::Identify(PostHogIdentifyEvent {
timestamp: event.time_utc,
event: "$identify",
distinct_id: analytics_id,
properties: [("session_id", session_id.into())].into(),
set: properties.collect(),
}),
}
}
}

// See https://posthog.com/docs/api/post-only-endpoints#capture.
#[derive(Debug, serde::Serialize)]
struct PostHogCaptureEvent<'a> {
#[serde(with = "::time::serde::rfc3339")]
timestamp: OffsetDateTime,
event: &'a str,
distinct_id: &'a str,
properties: HashMap<&'a str, serde_json::Value>,
}

// See https://posthog.com/docs/api/post-only-endpoints#identify.
#[derive(Debug, serde::Serialize)]
struct PostHogIdentifyEvent<'a> {
#[serde(with = "::time::serde::rfc3339")]
timestamp: OffsetDateTime,
event: &'a str,
distinct_id: &'a str,
properties: HashMap<&'a str, serde_json::Value>,
#[serde(rename = "$set")]
set: HashMap<&'a str, serde_json::Value>,
}

// See https://posthog.com/docs/api/post-only-endpoints#batch-events.
#[derive(Debug, serde::Serialize)]
struct PostHogBatch<'a> {
api_key: &'static str,
batch: &'a [PostHogEvent<'a>],
}

impl<'a> PostHogBatch<'a> {
fn from_events(events: &'a [PostHogEvent<'a>]) -> Self {
Self {
api_key: PUBLIC_POSTHOG_PROJECT_KEY,
batch: events,
}
}
}
8 changes: 4 additions & 4 deletions crates/re_analytics/src/web/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ pub enum ConfigError {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Config {
// NOTE: not a UUID on purpose, it is sometimes useful to use handcrafted IDs.
#[serde(rename = "i")]
#[serde(rename = "analytics_id")]
pub analytics_id: String,

/// A unique ID for this session.
#[serde(skip, default = "::uuid::Uuid::new_v4")]
pub session_id: Uuid,

#[serde(rename = "m", default)]
#[serde(rename = "metadata", default)]
pub opt_in_metadata: HashMap<String, Property>,
}

Expand All @@ -57,7 +57,7 @@ impl Config {
let storage = get_local_storage()?;
let value = storage
.get_item(Self::STORAGE_KEY)
.map_err(|_| ConfigError::Storage(format!("failed to get `{}`", Self::STORAGE_KEY)))?;
.map_err(|_| ConfigError::Storage(format!("failed to get {:?}", Self::STORAGE_KEY)))?;
match value {
Some(value) => Ok(Some(serde_json::from_str(&value)?)),
None => Ok(None),
Expand All @@ -77,7 +77,7 @@ impl Config {
let string = serde_json::to_string(self)?;
storage
.set_item(Self::STORAGE_KEY, &string)
.map_err(|_| ConfigError::Storage(format!("failed to set `{}`", Self::STORAGE_KEY)))
.map_err(|_| ConfigError::Storage(format!("failed to set {:?}", Self::STORAGE_KEY)))
}

pub fn is_first_run(&self) -> bool {
Expand Down
10 changes: 3 additions & 7 deletions crates/re_viewer/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub struct StartupOptions {

/// Set to identify the web page the viewer is running on.
#[cfg(target_arch = "wasm32")]
pub analytics_url: Option<String>,
pub location: Option<eframe::Location>,

/// Take a screenshot of the app and quit.
/// We use this to generate screenshots of our exmples.
Expand All @@ -67,7 +67,7 @@ impl Default for StartupOptions {
is_in_notebook: false,

#[cfg(target_arch = "wasm32")]
analytics_url: None,
location: None,

#[cfg(not(target_arch = "wasm32"))]
screenshot_to_path_then_quit: None,
Expand Down Expand Up @@ -179,11 +179,7 @@ impl App {
AppState::default()
};

let mut analytics = ViewerAnalytics::new(
startup_options.is_in_notebook,
#[cfg(target_arch = "wasm32")]
startup_options.analytics_url.clone(),
);
let mut analytics = ViewerAnalytics::new(&startup_options);
analytics.on_viewer_started(&build_info, app_env);

let mut space_view_class_registry = SpaceViewClassRegistry::default();
Expand Down
22 changes: 10 additions & 12 deletions crates/re_viewer/src/viewer_analytics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
use re_analytics::{Analytics, Event, Property};
use re_log_types::StoreSource;

use crate::StartupOptions;

pub struct ViewerAnalytics {
// NOTE: Optional because it is possible to have the `analytics` feature flag enabled
// while at the same time opting-out of analytics at run-time.
Expand All @@ -23,11 +25,8 @@ pub struct ViewerAnalytics {
#[cfg(feature = "analytics")]
impl ViewerAnalytics {
#[allow(unused_mut, clippy::let_and_return)]
pub fn new(
disabled: bool,
#[cfg(target_arch = "wasm32")] analytics_url: Option<String>,
) -> Self {
if disabled {
pub fn new(startup_options: &StartupOptions) -> Self {
if startup_options.is_in_notebook {
return Self { analytics: None };
jprochazk marked this conversation as resolved.
Show resolved Hide resolved
}

Expand All @@ -42,8 +41,10 @@ impl ViewerAnalytics {
let mut analytics = Self { analytics };

#[cfg(target_arch = "wasm32")]
if let Some(url) = analytics_url {
analytics.register("url", url);
if let Some(location) = startup_options.location.as_ref() {
if location.hostname.contains("rerun.io") {
jprochazk marked this conversation as resolved.
Show resolved Hide resolved
analytics.register("url", location.url.clone());
}
}

analytics
Expand Down Expand Up @@ -229,10 +230,7 @@ impl ViewerAnalytics {

#[cfg(not(feature = "analytics"))]
impl ViewerAnalytics {
pub fn new(
_disabled: bool,
#[cfg(target_arch = "wasm32")] _analytics_url: Option<String>,
) -> Self {
pub fn new(_startup_options: &StartupOptions) -> Self {
Self {}
}

Expand All @@ -243,5 +241,5 @@ impl ViewerAnalytics {
) {
}

pub fn on_open_recording(&mut self, store_db: &re_data_store::StoreDb) {}
pub fn on_open_recording(&mut self, _store_db: &re_data_store::StoreDb) {}
}