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 all 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,6 @@
"rerun_py/pyproject.toml"
],
"cmake.buildDirectory": "${workspaceRoot}/build/",
"C_Cpp.autoAddFileAssociations": false
"C_Cpp.autoAddFileAssociations": false,
"rust-analyzer.showUnlinkedFileNotification": false
}
6 changes: 3 additions & 3 deletions crates/re_analytics/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pub enum CliError {
}

pub fn clear() -> Result<(), CliError> {
let config = Config::load()?;
let config = Config::load_or_default()?;

fn delete_dir(dir: &Path) -> Result<(), CliError> {
eprint!("Are you sure you want to delete directory {dir:?}? [y/N]: ",);
Expand Down Expand Up @@ -50,13 +50,13 @@ pub fn clear() -> Result<(), CliError> {
}

pub fn set(props: impl IntoIterator<Item = (String, Property)>) -> Result<(), CliError> {
let mut config = Config::load()?;
let mut config = Config::load_or_default()?;
config.opt_in_metadata.extend(props);
config.save().map_err(Into::into)
}

pub fn opt(enabled: bool) -> Result<(), CliError> {
let mut config = Config::load()?;
let mut config = Config::load_or_default()?;
config.analytics_enabled = enabled;
config.save()?;

Expand Down
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,
}
}
}
57 changes: 50 additions & 7 deletions crates/re_analytics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(not(target_arch = "wasm32"))]
use native::{Config, ConfigError, Pipeline, PipelineError};
pub use native::{Config, ConfigError};
#[cfg(not(target_arch = "wasm32"))]
use native::{Pipeline, PipelineError};

#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
use web::{Config, ConfigError, Pipeline, PipelineError};
pub use web::{Config, ConfigError};
#[cfg(target_arch = "wasm32")]
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 @@ -185,6 +192,7 @@ impl From<&str> for Property {

// ---

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

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

impl Analytics {
pub fn new(tick: Duration) -> Result<Self, AnalyticsError> {
let config = Config::load()?;
re_log::trace!(?config, ?tick, "loaded analytics config");
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
}
};

if let Some(config) = config {
re_log::trace!(?config, "loaded analytics config");

Ok(config)
} else {
re_log::trace!(?config, "initialized 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
46 changes: 28 additions & 18 deletions crates/re_analytics/src/native/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ use uuid::Uuid;

use crate::Property;

// ---

#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("Couldn't compute config location")]
Expand Down Expand Up @@ -61,28 +59,40 @@ pub struct Config {
}

impl Config {
pub fn load() -> Result<Config, ConfigError> {
pub fn new() -> Result<Self, ConfigError> {
let dirs = Self::project_dirs()?;
let config_path = dirs.config_dir().join("analytics.json");
let data_path = dirs.data_local_dir().join("analytics");
let config = match File::open(&config_path) {
Ok(Self {
analytics_id: Uuid::new_v4().to_string(),
analytics_enabled: true,
opt_in_metadata: Default::default(),
session_id: Uuid::new_v4(),
is_first_run: true,
config_file_path: config_path,
data_dir_path: data_path,
})
}

pub fn load_or_default() -> Result<Config, ConfigError> {
match Self::load()? {
Some(config) => Ok(config),
None => Config::new(),
}
}

pub fn load() -> Result<Option<Config>, ConfigError> {
let dirs = Self::project_dirs()?;
let config_path = dirs.config_dir().join("analytics.json");
match File::open(config_path) {
Ok(file) => {
let reader = BufReader::new(file);
serde_json::from_reader(reader)?
let config = serde_json::from_reader(reader)?;
Ok(Some(config))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config {
analytics_id: Uuid::new_v4().to_string(),
analytics_enabled: true,
opt_in_metadata: Default::default(),
session_id: Uuid::new_v4(),
is_first_run: true,
config_file_path: config_path,
data_dir_path: data_path,
},
Err(err) => return Err(ConfigError::Io(err)),
};

Ok(config)
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(ConfigError::Io(err)),
}
}

pub fn save(&self) -> Result<(), ConfigError> {
Expand Down
5 changes: 0 additions & 5 deletions crates/re_analytics/src/native/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ use super::sink::PostHogSink;
use super::AbortSignal;
use crate::{Config, Event};

// TODO(cmc): abstract away the concept of a `Pipeline` behind an actual trait when comes the time
// to support more than just PostHog.

// ---

#[derive(thiserror::Error, Debug)]
pub enum PipelineError {
#[error(transparent)]
Expand Down
95 changes: 1 addition & 94 deletions crates/re_analytics/src/native/sink.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;

use time::OffsetDateTime;

use super::AbortSignal;
use crate::{Event, Property};

// TODO(cmc): abstract away the concept of a `Sink` behind an actual trait when comes the time to
// support more than just PostHog.

const PUBLIC_POSTHOG_PROJECT_KEY: &str = "phc_sgKidIE4WYYFSJHd8LEYY1UZqASpnfQKeMqlJfSXwqg";

// ---
use crate::{Event, PostHogBatch, PostHogEvent};

#[derive(Debug, Clone)]
struct Url(String);
Expand Down Expand Up @@ -78,86 +68,3 @@ impl PostHogSink {
ehttp::fetch(ehttp::Request::post(Self::URL, json.into_bytes()), on_done);
}
}

// ---

// TODO(cmc): support PostHog's view event

#[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,
}
}
}
Loading
Loading