diff --git a/.vscode/settings.json b/.vscode/settings.json index 9fdafaab266f..3f5a32139b4a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -80,5 +80,6 @@ "rerun_py/pyproject.toml" ], "cmake.buildDirectory": "${workspaceRoot}/build/", - "C_Cpp.autoAddFileAssociations": false + "C_Cpp.autoAddFileAssociations": false, + "rust-analyzer.showUnlinkedFileNotification": false } diff --git a/crates/re_analytics/src/cli.rs b/crates/re_analytics/src/cli.rs index 7055563dcd92..75d8f6462e5b 100644 --- a/crates/re_analytics/src/cli.rs +++ b/crates/re_analytics/src/cli.rs @@ -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]: ",); @@ -50,13 +50,13 @@ pub fn clear() -> Result<(), CliError> { } pub fn set(props: impl IntoIterator) -> 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()?; diff --git a/crates/re_analytics/src/event.rs b/crates/re_analytics/src/event.rs new file mode 100644 index 000000000000..2b19dd875508 --- /dev/null +++ b/crates/re_analytics/src/event.rs @@ -0,0 +1,88 @@ +use crate::{Event, Property}; +use std::collections::HashMap; + +use time::OffsetDateTime; + +/// The "public" API key can be obtained at . +/// 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, + } + } +} diff --git a/crates/re_analytics/src/lib.rs b/crates/re_analytics/src/lib.rs index cabe6863fdd2..20c984cde4f8 100644 --- a/crates/re_analytics/src/lib.rs +++ b/crates/re_analytics/src/lib.rs @@ -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; @@ -185,6 +192,7 @@ impl From<&str> for Property { // --- +#[cfg(not(target_arch = "wasm32"))] const DISCLAIMER: &str = " Welcome to Rerun! @@ -231,19 +239,54 @@ pub struct Analytics { event_id: AtomicU64, } -impl Analytics { - pub fn new(tick: Duration) -> Result { - let config = Config::load()?; - re_log::trace!(?config, ?tick, "loaded analytics config"); +fn load_config() -> Result { + 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 { + let config = load_config()?; let pipeline = Pipeline::new(&config, tick)?; + re_log::trace!("initialized analytics pipeline"); Ok(Self { config, diff --git a/crates/re_analytics/src/native/config.rs b/crates/re_analytics/src/native/config.rs index e6d4da69dea0..4a00e1d807f7 100644 --- a/crates/re_analytics/src/native/config.rs +++ b/crates/re_analytics/src/native/config.rs @@ -10,8 +10,6 @@ use uuid::Uuid; use crate::Property; -// --- - #[derive(thiserror::Error, Debug)] pub enum ConfigError { #[error("Couldn't compute config location")] @@ -61,28 +59,40 @@ pub struct Config { } impl Config { - pub fn load() -> Result { + pub fn new() -> Result { 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 { + match Self::load()? { + Some(config) => Ok(config), + None => Config::new(), + } + } + + pub fn load() -> Result, 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> { diff --git a/crates/re_analytics/src/native/pipeline.rs b/crates/re_analytics/src/native/pipeline.rs index eaa95adcede1..5cfe919363f4 100644 --- a/crates/re_analytics/src/native/pipeline.rs +++ b/crates/re_analytics/src/native/pipeline.rs @@ -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)] diff --git a/crates/re_analytics/src/native/sink.rs b/crates/re_analytics/src/native/sink.rs index a30cb7968e11..17c0dfb43914 100644 --- a/crates/re_analytics/src/native/sink.rs +++ b/crates/re_analytics/src/native/sink.rs @@ -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); @@ -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, - } - } -} diff --git a/crates/re_analytics/src/web/config.rs b/crates/re_analytics/src/web/config.rs index abce44ed5721..45315b0742c4 100644 --- a/crates/re_analytics/src/web/config.rs +++ b/crates/re_analytics/src/web/config.rs @@ -3,15 +3,17 @@ use std::collections::HashMap; use uuid::Uuid; +use web_sys::Storage; use crate::Property; -// --- - #[derive(thiserror::Error, Debug)] pub enum ConfigError { - #[error("Couldn't compute config location")] - UnknownLocation, + #[error("failed to get localStorage")] + NoStorage, + + #[error("{0}")] + Storage(String), #[error(transparent)] Io(#[from] std::io::Error), @@ -20,12 +22,8 @@ pub enum ConfigError { Serde(#[from] serde_json::Error), } -// NOTE: all the `rename` clauses are to avoid a potential catastrophe :) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Config { - #[serde(rename = "analytics_enabled")] - pub analytics_enabled: bool, - // NOTE: not a UUID on purpose, it is sometimes useful to use handcrafted IDs. #[serde(rename = "analytics_id")] pub analytics_id: String, @@ -35,19 +33,66 @@ pub struct Config { pub session_id: Uuid, #[serde(rename = "metadata", default)] - pub metadata: HashMap, + pub opt_in_metadata: HashMap, +} + +fn get_local_storage() -> Result { + let window = web_sys::window().ok_or(ConfigError::NoStorage)?; + let Ok(Some(storage)) = window.local_storage() else { + return Err(ConfigError::NoStorage); + }; + Ok(storage) } impl Config { - pub fn load() -> Result { - todo!("web support") + const STORAGE_KEY: &str = "rerun_config"; + + #[allow(clippy::unnecessary_wraps)] + pub fn new() -> Result { + Ok(Self::default()) + } + + #[allow(clippy::map_err_ignore)] + pub fn load() -> Result, ConfigError> { + let storage = get_local_storage()?; + let value = storage + .get_item(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), + } + } + + pub fn load_or_default() -> Result { + match Self::load()? { + Some(config) => Ok(config), + None => Config::new(), + } } + #[allow(clippy::map_err_ignore)] pub fn save(&self) -> Result<(), ConfigError> { - todo!("web support") + let storage = get_local_storage()?; + 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))) } pub fn is_first_run(&self) -> bool { - todo!("web support") + // no first-run opt-out for web + + false + } +} + +impl Default for Config { + fn default() -> Self { + Self { + analytics_id: Uuid::new_v4().to_string(), + session_id: Uuid::new_v4(), + opt_in_metadata: HashMap::new(), + } } } diff --git a/crates/re_analytics/src/web/mod.rs b/crates/re_analytics/src/web/mod.rs index b0de016cfa56..2f4da65e81f1 100644 --- a/crates/re_analytics/src/web/mod.rs +++ b/crates/re_analytics/src/web/mod.rs @@ -1,6 +1,5 @@ mod config; mod pipeline; -mod sink; pub use config::{Config, ConfigError}; pub use pipeline::{Pipeline, PipelineError}; diff --git a/crates/re_analytics/src/web/pipeline.rs b/crates/re_analytics/src/web/pipeline.rs index 203b52ab978b..0d4b38ec9c13 100644 --- a/crates/re_analytics/src/web/pipeline.rs +++ b/crates/re_analytics/src/web/pipeline.rs @@ -4,15 +4,11 @@ clippy::unused_self )] +use std::sync::Arc; use std::time::Duration; -use super::sink::PostHogSink; -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. - -// --- +use crate::PostHogBatch; +use crate::{Config, Event, PostHogEvent}; #[derive(thiserror::Error, Debug)] pub enum PipelineError { @@ -23,17 +19,71 @@ pub enum PipelineError { Serde(#[from] serde_json::Error), } -/// An eventual, at-least-once(-ish) event pipeline, backed by a write-ahead log on the local disk. +/// Wasm event pipeline. /// -/// Flushing of the WAL is entirely left up to the OS page cache, hance the -ish. +/// Unlike the native pipeline, this one is not backed by a WAL. All events are immediately sent as they are recorded. #[derive(Debug)] -pub struct Pipeline {} +pub struct Pipeline { + analytics_id: Arc, + session_id: Arc, +} impl Pipeline { - pub(crate) fn new(_config: &Config, _tick: Duration) -> Result, PipelineError> { - let _sink = PostHogSink::default(); - Ok(None) + // NOTE: different from the native URL, this one is _specifically_ for web. + const URL: &str = "https://tel.rerun.io/api/pog"; + + pub(crate) fn new(config: &Config, _tick: Duration) -> Result, PipelineError> { + Ok(Some(Pipeline { + analytics_id: config.analytics_id.as_str().into(), + session_id: config.session_id.to_string().into(), + })) } - pub fn record(&self, _event: Event) {} + pub fn record(&self, event: Event) { + // send all events immediately, ignore all errors + + let analytics_id = self.analytics_id.clone(); + let session_id = self.session_id.clone(); + + let events = [PostHogEvent::from_event( + &self.analytics_id, + &self.session_id, + &event, + )]; + let batch = PostHogBatch::from_events(&events); + let json = match serde_json::to_string_pretty(&batch) { + Ok(json) => json, + Err(err) => { + re_log::debug_once!("failed to send event: {err}"); + return; + } + }; + re_log::trace!("Sending analytics: {json}"); + ehttp::fetch( + ehttp::Request::post(Self::URL, json.into_bytes()), + move |result| match result { + Ok(response) => { + if !response.ok { + re_log::debug_once!( + "Failed to send analytics down the sink: HTTP request failed: {} {} {}", + response.status, + response.status_text, + response.text().unwrap_or("") + ); + return; + } + + re_log::trace!( + ?response, + %analytics_id, + %session_id, + "events successfully flushed" + ); + } + Err(err) => { + re_log::debug_once!("Failed to send analytics down the sink: {err}"); + } + }, + ); + } } diff --git a/crates/re_analytics/src/web/sink.rs b/crates/re_analytics/src/web/sink.rs deleted file mode 100644 index 3b7b6d472eb0..000000000000 --- a/crates/re_analytics/src/web/sink.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[derive(Default, Debug, Clone)] -pub struct PostHogSink {} diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index 31a1327ac640..d82c5b16aae4 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -40,6 +40,13 @@ pub struct StartupOptions { pub persist_state: bool, + /// Whether or not the app is running in the context of a Jupyter Notebook. + pub is_in_notebook: bool, + + /// Set to identify the web page the viewer is running on. + #[cfg(target_arch = "wasm32")] + pub location: Option, + /// Take a screenshot of the app and quit. /// We use this to generate screenshots of our exmples. #[cfg(not(target_arch = "wasm32"))] @@ -57,6 +64,10 @@ impl Default for StartupOptions { Self { memory_limit: re_memory::MemoryLimit::default(), persist_state: true, + is_in_notebook: false, + + #[cfg(target_arch = "wasm32")] + location: None, #[cfg(not(target_arch = "wasm32"))] screenshot_to_path_then_quit: None, @@ -168,7 +179,7 @@ impl App { AppState::default() }; - let mut analytics = ViewerAnalytics::new(); + let mut analytics = ViewerAnalytics::new(&startup_options); analytics.on_viewer_started(&build_info, app_env); let mut space_view_class_registry = SpaceViewClassRegistry::default(); diff --git a/crates/re_viewer/src/viewer_analytics.rs b/crates/re_viewer/src/viewer_analytics.rs index 647e41a393e0..a9fe1c7bf791 100644 --- a/crates/re_viewer/src/viewer_analytics.rs +++ b/crates/re_viewer/src/viewer_analytics.rs @@ -9,22 +9,29 @@ //! //! DO NOT MOVE THIS FILE without updating all the docs pointing to it! -#[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] +#[cfg(feature = "analytics")] use re_analytics::{Analytics, Event, Property}; - -#[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] 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. - #[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] + #[cfg(feature = "analytics")] analytics: Option, } +#[cfg(feature = "analytics")] impl ViewerAnalytics { - #[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] - pub fn new() -> Self { + #[allow(unused_mut, clippy::let_and_return)] + pub fn new(startup_options: &StartupOptions) -> Self { + // We only want to have analytics on `*.rerun.io`, + // so we early-out if we detect we're running in a notebook. + if startup_options.is_in_notebook { + return Self { analytics: None }; + } + let analytics = match Analytics::new(std::time::Duration::from_secs(2)) { Ok(analytics) => Some(analytics), Err(err) => { @@ -33,15 +40,19 @@ impl ViewerAnalytics { } }; - Self { analytics } - } + let mut analytics = Self { analytics }; - #[cfg(not(all(not(target_arch = "wasm32"), feature = "analytics")))] - pub fn new() -> Self { - Self {} + // We only want to send `url` if we're on a `rerun.io` domain. + #[cfg(target_arch = "wasm32")] + if let Some(location) = startup_options.location.as_ref() { + if location.hostname == "rerun.io" || location.hostname.ends_with(".rerun.io") { + analytics.register("url", location.url.clone()); + } + } + + analytics } - #[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] fn record(&self, event: Event) { if let Some(analytics) = &self.analytics { analytics.record(event); @@ -49,7 +60,6 @@ impl ViewerAnalytics { } /// Register a property that will be included in all append-events. - #[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] fn register(&mut self, name: &'static str, property: impl Into) { if let Some(analytics) = &mut self.analytics { analytics.register_append_property(name, property); @@ -57,7 +67,6 @@ impl ViewerAnalytics { } /// Deregister a property. - #[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] fn deregister(&mut self, name: &'static str) { if let Some(analytics) = &mut self.analytics { analytics.deregister_append_property(name); @@ -67,8 +76,11 @@ impl ViewerAnalytics { // ---------------------------------------------------------------------------- +// TODO(jan): add URL (iff domain is `rerun.io`) +// TODO(jan): make sure analytics knows the event comes from web + /// Here follows all the analytics collected by the Rerun Viewer. -#[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] +#[cfg(feature = "analytics")] impl ViewerAnalytics { /// When the viewer is first started pub fn on_viewer_started( @@ -87,7 +99,7 @@ impl ViewerAnalytics { }; self.register("app_env", app_env_str); - #[cfg(all(not(target_arch = "wasm32"), feature = "analytics"))] + #[cfg(feature = "analytics")] if let Some(analytics) = &self.analytics { let mut event = Event::update("update_metadata").with_build_info(build_info); @@ -118,8 +130,8 @@ impl ViewerAnalytics { // In practice this is the email of Rerun employees // who register their emails with `rerun analytics email`. // This is how we filter out employees from actual users! - for (name, value) in analytics.config().opt_in_metadata.clone() { - event = event.with_prop(name, value); + for (name, value) in &analytics.config().opt_in_metadata { + event = event.with_prop(name.clone(), value.clone()); } analytics.record(event); @@ -219,18 +231,18 @@ impl ViewerAnalytics { } } -// ---------------------------------------------------------------------------- - -// When analytics are disabled: -#[cfg(not(all(not(target_arch = "wasm32"), feature = "analytics")))] +#[cfg(not(feature = "analytics"))] impl ViewerAnalytics { - #[allow(clippy::unused_self)] + pub fn new(_startup_options: &StartupOptions) -> Self { + Self {} + } + pub fn on_viewer_started( &mut self, _build_info: &re_build_info::BuildInfo, _app_env: &crate::AppEnvironment, ) { } - #[allow(clippy::unused_self)] + pub fn on_open_recording(&mut self, _store_db: &re_data_store::StoreDb) {} } diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index af9cde06d580..22d4ae3d2fe8 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -80,13 +80,14 @@ impl WebHandle { fn create_app(cc: &eframe::CreationContext<'_>, url: Option) -> crate::App { let build_info = re_build_info::build_info!(); let app_env = crate::AppEnvironment::Web; - let persist_state = get_persist_state(&cc.integration_info); let startup_options = crate::StartupOptions { memory_limit: re_memory::MemoryLimit { // On wasm32 we only have 4GB of memory to play around with. limit: Some(2_500_000_000), }, - persist_state, + location: Some(cc.integration_info.web_info.location.clone()), + persist_state: get_persist_state(&cc.integration_info), + is_in_notebook: is_in_notebook(&cc.integration_info), skip_welcome_screen: false, }; let re_ui = crate::customize_eframe(cc); @@ -143,6 +144,19 @@ fn create_app(cc: &eframe::CreationContext<'_>, url: Option) -> crate::A app } +/// Used to set the "email" property in the analytics config, +/// in the same way as `rerun analytics email YOURNAME@rerun.io`. +/// +/// This one just panics when it fails, as it's only ever really run +/// by rerun employees manually in `app.rerun.io` and `demo.rerun.io`. +#[cfg(feature = "analytics")] +#[wasm_bindgen] +pub fn set_email(email: String) { + let mut config = re_analytics::Config::load().unwrap().unwrap_or_default(); + config.opt_in_metadata.insert("email".into(), email.into()); + config.save().unwrap(); +} + #[wasm_bindgen] pub fn is_webgpu_build() -> bool { !cfg!(feature = "webgl") @@ -194,22 +208,31 @@ fn get_url(info: &eframe::IntegrationInfo) -> String { } } +fn is_in_notebook(info: &eframe::IntegrationInfo) -> bool { + get_query_bool(info, "notebook", false) +} + fn get_persist_state(info: &eframe::IntegrationInfo) -> bool { + get_query_bool(info, "persist", true) +} + +fn get_query_bool(info: &eframe::IntegrationInfo, key: &str, default: bool) -> bool { + let default_int = default as i32; match info .web_info .location .query_map - .get("persist") + .get(key) .map(String::as_str) { Some("0") => false, Some("1") => true, Some(other) => { re_log::warn!( - "Unexpected value for 'persist' query: {other:?}. Expected either '0' or '1'. Defaulting to '1'." + "Unexpected value for '{key}' query: {other:?}. Expected either '0' or '1'. Defaulting to '{default_int}'." ); - true + default } - _ => true, + _ => default, } } diff --git a/crates/rerun/src/run.rs b/crates/rerun/src/run.rs index c84d848e9cbb..126ade8fa9ec 100644 --- a/crates/rerun/src/run.rs +++ b/crates/rerun/src/run.rs @@ -417,6 +417,7 @@ async fn run_impl( memory_limit: re_memory::MemoryLimit::parse(&args.memory_limit) .unwrap_or_else(|err| panic!("Bad --memory-limit: {err}")), persist_state: args.persist_state, + is_in_notebook: false, screenshot_to_path_then_quit: args.screenshot_to.clone(), skip_welcome_screen: args.skip_welcome_screen, diff --git a/rerun_py/rerun_sdk/rerun/recording.py b/rerun_py/rerun_sdk/rerun/recording.py index 5ff52055f38b..d3224007ce5e 100644 --- a/rerun_py/rerun_sdk/rerun/recording.py +++ b/rerun_py/rerun_sdk/rerun/recording.py @@ -99,7 +99,7 @@ def as_html( }}())); """ diff --git a/scripts/ci/demo_assets/static/index.js b/scripts/ci/demo_assets/static/index.js index e1b9bf4ef8e1..01dde6e0d29b 100644 --- a/scripts/ci/demo_assets/static/index.js +++ b/scripts/ci/demo_assets/static/index.js @@ -36,7 +36,7 @@ if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(naviga

Try anyways

`); - document.querySelector('#try_anyways').addEventListener('click', function (event) { + document.querySelector("#try_anyways").addEventListener("click", function (event) { event.preventDefault(); load_wasm(); }); @@ -56,12 +56,12 @@ function load_wasm() {

`; - const status_element = document.getElementById('status'); + const status_element = document.getElementById("status"); function progress({ loaded, total_bytes }) { if (total_bytes != null) { - status_element.innerHTML = Math.round(Math.min(loaded / total_bytes * 100, 100)) + '%'; + status_element.innerHTML = Math.round(Math.min((loaded / total_bytes) * 100, 100)) + "%"; } else { - status_element.innerHTML = (loaded / (1024 * 1024)).toFixed(1) + 'MiB' + status_element.innerHTML = (loaded / (1024 * 1024)).toFixed(1) + "MiB"; } } @@ -71,55 +71,57 @@ function load_wasm() { }, 1500); async function wasm_with_progress() { - const response = await fetch('./re_viewer_bg.wasm'); + const response = await fetch("./re_viewer_bg.wasm"); // Use the uncompressed size var content_length; var content_multiplier = 1; // If the content is gzip encoded, try to get the uncompressed size. - if (response.headers.get('content-encoding') == 'gzip') { - content_length = response.headers.get('x-goog-meta-uncompressed-size'); + if (response.headers.get("content-encoding") == "gzip") { + content_length = response.headers.get("x-goog-meta-uncompressed-size"); // If the uncompressed size wasn't found 3 seems to be a very good approximation if (content_length == null) { - content_length = response.headers.get('content-length'); + content_length = response.headers.get("content-length"); content_multiplier = 3; } } else { - content_length = response.headers.get('content-length'); + content_length = response.headers.get("content-length"); } const total_bytes = parseInt(content_length, 10) * content_multiplier; let loaded = 0; - const res = new Response(new ReadableStream({ - async start(controller) { - const reader = response.body.getReader(); - for (; ;) { - const { done, value } = await reader.read(); - if (done) break; - loaded += value.byteLength; - progress({ loaded, total_bytes }) - controller.enqueue(value); - } - controller.close(); - }, - })); + const res = new Response( + new ReadableStream({ + async start(controller) { + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + loaded += value.byteLength; + progress({ loaded, total_bytes }); + controller.enqueue(value); + } + controller.close(); + }, + }) + ); const wasm = await res.blob(); // Don't fade in the progress bar if we haven't hit it already. clearTimeout(timeoutId); - wasm_bindgen(URL.createObjectURL(wasm)) - .then(on_wasm_loaded) - .catch(on_wasm_error); + wasm_bindgen(URL.createObjectURL(wasm)).then(on_wasm_loaded).catch(on_wasm_error); } wasm_with_progress(); } function on_wasm_loaded() { + window.set_email = (value) => wasm_bindgen.set_email(value); + // WebGPU version is currently only supported on browsers with WebGPU support, there is no dynamic fallback to WebGL. - if (wasm_bindgen.is_webgpu_build() && typeof navigator.gpu === 'undefined') { + if (wasm_bindgen.is_webgpu_build() && typeof navigator.gpu === "undefined") { console.debug("`navigator.gpu` is undefined. This indicates lack of WebGPU support."); show_center_html(`

@@ -174,8 +176,6 @@ function on_app_started(handle) { hide_center_html(); show_canvas(); - - if (window.location !== window.parent.location) { window.parent.postMessage("READY", "*"); } diff --git a/web_viewer/index.html b/web_viewer/index.html index cc140fe1f0d6..aab8ea13ac31 100644 --- a/web_viewer/index.html +++ b/web_viewer/index.html @@ -280,6 +280,8 @@ function on_wasm_loaded() { + window.set_email = (value) => wasm_bindgen.set_email(value); + // WebGPU version is currently only supported on browsers with WebGPU support, there is no dynamic fallback to WebGL. if (wasm_bindgen.is_webgpu_build() && typeof navigator.gpu === 'undefined') { console.debug("`navigator.gpu` is undefined. This indicates lack of WebGPU support."); diff --git a/web_viewer/index_bundled.html b/web_viewer/index_bundled.html index b034819c531f..26fd463e44f7 100644 --- a/web_viewer/index_bundled.html +++ b/web_viewer/index_bundled.html @@ -277,6 +277,8 @@ } function on_wasm_loaded() { + window.set_email = (value) => wasm_bindgen.set_email(value); + // WebGPU version is currently only supported on browsers with WebGPU support, there is no dynamic fallback to WebGL. if (wasm_bindgen.is_webgpu_build() && typeof navigator.gpu === 'undefined') { console.debug("`navigator.gpu` is undefined. This indicates lack of WebGPU support.");