diff --git a/crates/utils/re_analytics/src/event.rs b/crates/utils/re_analytics/src/event.rs index 2afe36f85891..1c3b2b4265c9 100644 --- a/crates/utils/re_analytics/src/event.rs +++ b/crates/utils/re_analytics/src/event.rs @@ -346,6 +346,8 @@ pub struct OpenRecording { pub store_info: Option, + pub total_open_recordings: usize, + /// How data is being loaded into the viewer. pub data_source: Option<&'static str>, } @@ -360,6 +362,7 @@ impl Properties for OpenRecording { url, app_env, store_info, + total_open_recordings, data_source, } = self; @@ -384,6 +387,7 @@ impl Properties for OpenRecording { event.insert("recording_id", recording_id); event.insert("store_source", store_source); event.insert("store_version", store_version); + event.insert("total_open_recordings", total_open_recordings as i64); event.insert_opt("rust_version", rust_version); event.insert_opt("llvm_version", llvm_version); event.insert_opt("python_version", python_version); @@ -401,6 +405,51 @@ impl Properties for OpenRecording { // ----------------------------------------------- +/// Sent when user switches between existing recordings. +/// +/// Used in `re_viewer`. +pub struct SwitchRecording { + /// The URL on which the web viewer is running. + /// + /// This will be used to populate `hashed_root_domain` property for all urls. + /// This will also populate `rerun_url` property if the url root domain is `rerun.io`. + pub url: Option, + + /// The environment in which the viewer is running. + pub app_env: &'static str, + + /// The recording we're switching from (hashed if not official example). + pub previous_recording_id: Option, + + /// The recording we're switching to (hashed if not official example). + pub new_recording_id: Id, + + /// How the switch was initiated. + pub switch_method: &'static str, +} + +impl Event for SwitchRecording { + const NAME: &'static str = "switch_recording"; +} + +impl Properties for SwitchRecording { + fn serialize(self, event: &mut AnalyticsEvent) { + let Self { + url, + app_env, + previous_recording_id, + new_recording_id, + switch_method, + } = self; + + add_sanitized_url_properties(event, url); + event.insert("app_env", app_env); + event.insert_opt("previous_recording_id", previous_recording_id); + event.insert("new_recording_id", new_recording_id); + event.insert("switch_method", switch_method); + } +} + // ----------------------------------------------- /// Sent the first time a `?` help button is clicked. @@ -420,6 +469,150 @@ impl Properties for HelpButtonFirstClicked { // ----------------------------------------------- +/// Tracks how much timeline content was played back. +/// +/// Emitted when playback stops for any reason. +pub struct TimelineSecondsPlayed { + pub build_info: BuildInfo, + + /// Name of the timeline (e.g. "log_time", "sim_time") + pub timeline_name: String, + + /// Playback speed during this session (1.0 = normal, 2.0 = 2x speed) + pub playback_speed: f32, + + /// Timeline duration advanced during playback (in seconds) + pub timeline_seconds_played: f64, + + /// Real-world time elapsed during playback (in seconds) + pub wall_clock_seconds: f64, + + /// Why playback stopped + pub stop_reason: PlaybackStopReason, + + /// Which recording was being played (hashed if not official example) + pub recording_id: Id, +} + +/// Reason why a playback session ended +#[derive(Clone, Debug)] +pub enum PlaybackStopReason { + /// User manually paused/stopped playback + UserStopped, + /// Playback speed changed (starts new session) + SpeedChanged, + /// Switched to different recording + RecordingSwitched, + /// Recording was closed + RecordingClosed, + /// App is exiting + AppExited, + /// Switched to different timeline + TimelineChanged, +} + +impl Event for TimelineSecondsPlayed { + const NAME: &'static str = "timeline_seconds_played"; +} + +impl Properties for TimelineSecondsPlayed { + fn serialize(self, event: &mut AnalyticsEvent) { + let Self { + build_info, + timeline_name, + playback_speed, + timeline_seconds_played, + wall_clock_seconds, + stop_reason, + recording_id, + } = self; + + build_info.serialize(event); + event.insert("timeline_name", timeline_name); + event.insert("playback_speed", playback_speed); + event.insert("timeline_seconds_played", timeline_seconds_played); + event.insert("wall_clock_seconds", wall_clock_seconds); + event.insert("stop_reason", format!("{:?}", stop_reason)); + event.insert("recording_id", recording_id); + } +} + +// ----------------------------------------------- + +/// Tracks user interaction with timeline viewing, including playback and scrubbing behavior. +/// +/// Emitted when a viewing session ends (playback stops, scrubbing session ends, etc). +pub struct PlaybackSession { + pub build_info: BuildInfo, + + /// Name of the timeline (e.g. "log_time", "sim_time") + pub timeline_name: String, + + /// Time spent in this viewing session (in seconds) + pub wall_clock_seconds: f64, + + /// Type of viewing behavior in this session + pub session_type: PlaybackSessionType, + + /// Total timeline distance traveled (sum of all movements, including backwards) + pub total_time_traveled: f64, + + /// Covered timeline distance (max - min of time range visited) + pub covered_time_distance: f64, + + /// Unit of the timeline measurements ("seconds" or "frames") + pub time_unit: String, + + /// Why the session ended + pub end_reason: PlaybackStopReason, + + /// Which recording was being viewed (hashed if not official example) + pub recording_id: Id, +} + +/// Type of playback/viewing session +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PlaybackSessionType { + /// Continuous playback at a consistent speed + Playback, + /// User manually scrubbing through the timeline + Scrubbing, + /// Mixed session with both playback and scrubbing + Mixed, +} + +impl Event for PlaybackSession { + const NAME: &'static str = "playback_session"; +} + +impl Properties for PlaybackSession { + fn serialize(self, event: &mut AnalyticsEvent) { + let Self { + build_info, + timeline_name, + wall_clock_seconds, + session_type, + total_time_traveled, + covered_time_distance, + time_unit, + end_reason, + recording_id, + } = self; + + build_info.serialize(event); + event.insert("timeline_name", timeline_name); + event.insert("wall_clock_seconds", wall_clock_seconds); + event.insert("session_type", format!("{:?}", session_type)); + event.insert("total_time_traveled", total_time_traveled); + event.insert("covered_time_distance", covered_time_distance); + event.insert("time_unit", time_unit); + event.insert("end_reason", format!("{:?}", end_reason)); + event.insert("recording_id", recording_id); + } +} + +// ----------------------------------------------- + /// The user opened the settings screen. pub struct SettingsOpened {} diff --git a/crates/viewer/re_data_ui/src/item_ui.rs b/crates/viewer/re_data_ui/src/item_ui.rs index 27ded4eefe1f..4993a6e75695 100644 --- a/crates/viewer/re_data_ui/src/item_ui.rs +++ b/crates/viewer/re_data_ui/src/item_ui.rs @@ -14,7 +14,7 @@ use re_ui::list_item::ListItemContentButtonsExt as _; use re_ui::{SyntaxHighlighting as _, UiExt as _, icons, list_item}; use re_viewer_context::open_url::ViewerOpenUrl; use re_viewer_context::{ - HoverHighlight, Item, SystemCommand, SystemCommandSender as _, UiLayout, ViewId, ViewerContext, + HoverHighlight, Item, UiLayout, ViewId, ViewerContext, }; use super::DataUi as _; @@ -687,7 +687,7 @@ pub fn entity_db_button_ui( include_app_id: bool, ) { use re_byte_size::SizeBytes as _; - use re_viewer_context::{SystemCommand, SystemCommandSender as _}; + use re_viewer_context::{ActivationSource, SystemCommand, SystemCommandSender as _}; let app_id_prefix = if include_app_id { format!("{} - ", entity_db.application_id()) @@ -807,7 +807,10 @@ pub fn entity_db_button_ui( // for the blueprint. if store_id.is_recording() { ctx.command_sender() - .send_system(SystemCommand::ActivateRecordingOrTable(new_entry)); + .send_system(SystemCommand::ActivateRecordingOrTable { + entry: new_entry, + source: ActivationSource::UiClick, + }); } } @@ -820,6 +823,7 @@ pub fn table_id_button_ui( table_id: &TableId, ui_layout: UiLayout, ) { + use re_viewer_context::{ActivationSource, SystemCommand, SystemCommandSender as _}; let item = re_viewer_context::Item::TableId(table_id.clone()); let mut item_content = list_item::LabelContent::new(table_id.as_str()).with_icon(&icons::TABLE); @@ -862,9 +866,10 @@ pub fn table_id_button_ui( if response.clicked() { ctx.command_sender() - .send_system(SystemCommand::ActivateRecordingOrTable( - table_id.clone().into(), - )); + .send_system(SystemCommand::ActivateRecordingOrTable { + entry: table_id.clone().into(), + source: ActivationSource::TableClick, + }); } ctx.handle_select_hover_drag_interactions(&response, item, false); } diff --git a/crates/viewer/re_global_context/src/command_sender.rs b/crates/viewer/re_global_context/src/command_sender.rs index 90399f638eb2..b3c187622de2 100644 --- a/crates/viewer/re_global_context/src/command_sender.rs +++ b/crates/viewer/re_global_context/src/command_sender.rs @@ -8,6 +8,29 @@ use crate::RecordingOrTable; // ---------------------------------------------------------------------------- +/// How a recording was activated. +#[derive(Clone, Debug)] +pub enum ActivationSource { + /// User clicked in the UI + UiClick, + /// Keyboard shortcut + Hotkey(String), + /// Via URL parameter or fragment + Url, + /// File was opened by the viewer (either drag + drop, or File > Open) + FileOpen, + /// CLI argument, e.g. `rerun dna.rrd` + CliArgument, + /// Clicked from table view + TableClick, + /// Auto-activation (first recording, only available recording, etc.) + Auto, + /// Programmatic activation + Api, +} + +// ---------------------------------------------------------------------------- + /// Commands used by internal system components // TODO(jleibs): Is there a better crate for this? #[derive(strum_macros::IntoStaticStr)] @@ -56,7 +79,10 @@ pub enum SystemCommand { ClearActiveBlueprintAndEnableHeuristics, /// Switch to this [`RecordingOrTable`]. - ActivateRecordingOrTable(RecordingOrTable), + ActivateRecordingOrTable { + entry: RecordingOrTable, + source: ActivationSource, + }, /// Close an [`RecordingOrTable`] and free its memory. CloseRecordingOrTable(RecordingOrTable), diff --git a/crates/viewer/re_global_context/src/lib.rs b/crates/viewer/re_global_context/src/lib.rs index b1906b95aa46..da33b419094e 100644 --- a/crates/viewer/re_global_context/src/lib.rs +++ b/crates/viewer/re_global_context/src/lib.rs @@ -15,7 +15,7 @@ pub use self::{ app_options::AppOptions, blueprint_id::{BlueprintId, BlueprintIdRegistry, ContainerId, ViewId}, command_sender::{ - CommandReceiver, CommandSender, SystemCommand, SystemCommandSender, command_channel, + ActivationSource, CommandReceiver, CommandSender, SystemCommand, SystemCommandSender, command_channel, }, contents::{Contents, ContentsName, blueprint_id_to_tile_id}, file_dialog::santitize_file_name, diff --git a/crates/viewer/re_test_context/src/lib.rs b/crates/viewer/re_test_context/src/lib.rs index 56f0f7df6347..3341a0158641 100644 --- a/crates/viewer/re_test_context/src/lib.rs +++ b/crates/viewer/re_test_context/src/lib.rs @@ -637,7 +637,7 @@ impl TestContext { // not implemented SystemCommand::ActivateApp(_) - | SystemCommand::ActivateRecordingOrTable(_) + | SystemCommand::ActivateRecordingOrTable { .. } | SystemCommand::CloseApp(_) | SystemCommand::CloseRecordingOrTable(_) | SystemCommand::LoadDataSource(_) diff --git a/crates/viewer/re_viewer/src/app.rs b/crates/viewer/re_viewer/src/app.rs index cd1ba4c3387b..6f93a2670080 100644 --- a/crates/viewer/re_viewer/src/app.rs +++ b/crates/viewer/re_viewer/src/app.rs @@ -148,6 +148,10 @@ pub struct App { /// * we want the user to have full control over the runtime, /// and not expect that a global runtime exists. async_runtime: AsyncRuntimeHandle, + + /// Tracks playback sessions for analytics + #[cfg(feature = "analytics")] + playback_session_tracker: crate::playback_session_tracker::PlaybackSessionTracker, } impl App { @@ -396,6 +400,8 @@ impl App { connection_registry, async_runtime: tokio_runtime, + #[cfg(feature = "analytics")] + playback_session_tracker: crate::playback_session_tracker::PlaybackSessionTracker::new(), } } @@ -661,10 +667,50 @@ impl App { store_hub.close_app(&app_id); } - SystemCommand::ActivateRecordingOrTable(entry) => { + SystemCommand::ActivateRecordingOrTable { entry, source } => { + // Get the previous recording before switching for analytics + let previous_recording_id = store_hub.active_store_id().cloned(); + match &entry { RecordingOrTable::Recording { store_id } => { store_hub.set_active_recording_id(store_id.clone()); + + // Analytics: only when switching between recordings + #[cfg(feature = "analytics")] + if let (Some(analytics), Some(previous_id)) = ( + re_analytics::Analytics::global_or_init(), + previous_recording_id.as_ref(), + ) { + // Only log if we're actually switching to a different recording + if previous_id != store_id { + let switch_method = match source { + re_viewer_context::ActivationSource::UiClick => "ui_click", + re_viewer_context::ActivationSource::TableClick => "table_click", + re_viewer_context::ActivationSource::Hotkey(_) => "hotkey", + re_viewer_context::ActivationSource::Url => "url", + re_viewer_context::ActivationSource::FileOpen => "file_open", + re_viewer_context::ActivationSource::CliArgument => "cli_argument", + re_viewer_context::ActivationSource::Auto => "auto", + re_viewer_context::ActivationSource::Api => "api", + }; + + // End any active playback session on the previous recording + self.playback_session_tracker.end_session( + previous_id, + store_hub.entity_db_mut(previous_id), + re_analytics::event::PlaybackStopReason::RecordingSwitched, + self.build_info.clone(), + ); + + let event = crate::viewer_analytics::event::switch_recording( + &self.app_env, + Some(previous_id), + store_id, + switch_method, + ); + analytics.record(event); + } + } } RecordingOrTable::Table { .. } => {} } @@ -707,10 +753,28 @@ impl App { } } + // End any active playback session before removing the recording + #[cfg(feature = "analytics")] + if let RecordingOrTable::Recording { store_id } = &entry { + self.playback_session_tracker.end_session( + store_id, + store_hub.entity_db_mut(store_id), + re_analytics::event::PlaybackStopReason::RecordingClosed, + self.build_info.clone(), + ); + } + store_hub.remove(&entry); } SystemCommand::CloseAllEntries => { + // End all active playback sessions + #[cfg(feature = "analytics")] + self.playback_session_tracker.end_all_sessions( + re_analytics::event::PlaybackStopReason::RecordingClosed, + self.build_info.clone(), + ); + self.state.navigation.push_start_mode(); store_hub.clear_entries(); @@ -879,7 +943,13 @@ impl App { self.state .navigation .replace(DisplayMode::LocalRecordings(store_id.clone())); - store_hub.set_active_recording_id(store_id.clone()); + self.command_sender + .send_system(SystemCommand::ActivateRecordingOrTable { + entry: re_viewer_context::RecordingOrTable::Recording { + store_id: store_id.clone(), + }, + source: re_viewer_context::ActivationSource::UiClick, + }); } Item::AppId(_) @@ -1003,7 +1073,7 @@ impl App { let store_id = entity_db.store_id().clone(); debug_assert!(store_id.is_recording()); // `find_recording_store_by_source` should have filtered for recordings rather than blueprints. drop(all_sources); - self.make_store_active_and_highlight(store_hub, egui_ctx, &store_id); + self.make_store_active_and_highlight(store_hub, egui_ctx, &store_id, re_viewer_context::ActivationSource::Url); } return; } @@ -1049,7 +1119,7 @@ impl App { // `go_to_dataset_data` may override the selection again, but this is important regardless, // since `go_to_dataset_data` does not change the active recording. drop(all_sources); - self.make_store_active_and_highlight(store_hub, egui_ctx, &uri.store_id()); + self.make_store_active_and_highlight(store_hub, egui_ctx, &uri.store_id(), re_viewer_context::ActivationSource::Url); } // Note that applying the fragment changes the per-recording settings like the active time cursor. @@ -1676,6 +1746,10 @@ impl App { let times_per_timeline = entity_db.times_per_timeline(); + // Track playback state changes for analytics + #[cfg(feature = "analytics")] + let prev_play_state = time_ctrl.play_state(); + match command { TimeControlCommand::TogglePlayPause => { time_ctrl.toggle_play_pause(times_per_timeline); @@ -1693,6 +1767,61 @@ impl App { time_ctrl.restart(times_per_timeline); } } + + // Track playback sessions for analytics + #[cfg(feature = "analytics")] + { + let current_play_state = time_ctrl.play_state(); + let timeline = *time_ctrl.timeline(); + let current_time = time_ctrl.time().unwrap_or(re_log_types::TimeReal::MIN); + let store_id = entity_db.store_id(); + + match (prev_play_state, current_play_state, command) { + // Starting playback + (PlayState::Paused, PlayState::Playing, _) | + (PlayState::Paused, PlayState::Following, _) | + (_, PlayState::Playing, TimeControlCommand::Restart) => { + self.playback_session_tracker.on_playback_interaction( + store_id, + timeline, + current_time, + re_analytics::event::PlaybackSessionType::Playback, + ); + } + // Stopping playback + (PlayState::Playing, PlayState::Paused, _) | + (PlayState::Following, PlayState::Paused, _) => { + self.playback_session_tracker.end_session( + store_id, + entity_db, + re_analytics::event::PlaybackStopReason::UserStopped, + self.build_info.clone(), + ); + } + // Scrubbing (stepping) + (_, PlayState::Paused, TimeControlCommand::StepBack) | + (_, PlayState::Paused, TimeControlCommand::StepForward) => { + self.playback_session_tracker.on_playback_interaction( + store_id, + timeline, + current_time, + re_analytics::event::PlaybackSessionType::Scrubbing, + ); + } + // Timeline or state changes during playback + (PlayState::Playing, PlayState::Playing, _) | + (PlayState::Following, PlayState::Following, _) => { + // Continue existing session with updated position + self.playback_session_tracker.on_playback_interaction( + store_id, + timeline, + current_time, + re_analytics::event::PlaybackSessionType::Playback, + ); + } + _ => {} + } + } } fn run_copy_link_command(&mut self, content_url: &ViewerOpenUrl) { @@ -2027,7 +2156,7 @@ impl App { match store_id.kind() { StoreKind::Recording => { re_log::trace!("Opening a new recording: '{store_id:?}'"); - self.make_store_active_and_highlight(store_hub, egui_ctx, store_id); + self.make_store_active_and_highlight(store_hub, egui_ctx, store_id, re_viewer_context::ActivationSource::Auto); } StoreKind::Blueprint => { // We wait with activating blueprints until they are fully loaded, @@ -2112,14 +2241,19 @@ impl App { // Do analytics/events after ingesting the new message, // because `entity_db.store_info` needs to be set. + let num_recordings = store_hub.store_bundle().recordings().count(); let entity_db = store_hub.entity_db_mut(store_id); if msg_will_add_new_store && entity_db.store_kind() == StoreKind::Recording { #[cfg(feature = "analytics")] - if let Some(analytics) = re_analytics::Analytics::global_or_init() - && let Some(event) = - crate::viewer_analytics::event::open_recording(&self.app_env, entity_db) - { - analytics.record(event); + if let Some(analytics) = re_analytics::Analytics::global_or_init() { + // Calculate count before any further borrows + if let Some(event) = crate::viewer_analytics::event::open_recording( + &self.app_env, + entity_db, + num_recordings, + ) { + analytics.record(event); + } } if let Some(event_dispatcher) = self.event_dispatcher.as_ref() { @@ -2144,16 +2278,17 @@ impl App { if let Some(entity_db) = store_hub.find_recording_store_by_source(new_source) { let store_id = entity_db.store_id().clone(); debug_assert!(store_id.is_recording()); // `find_recording_store_by_source` should have filtered for recordings rather than blueprints. - self.make_store_active_and_highlight(store_hub, egui_ctx, &store_id); + self.make_store_active_and_highlight(store_hub, egui_ctx, &store_id, re_viewer_context::ActivationSource::FileOpen); } } /// Makes the given store active and request user attention if Rerun in the background. fn make_store_active_and_highlight( &self, - store_hub: &mut StoreHub, + _store_hub: &mut StoreHub, egui_ctx: &egui::Context, store_id: &StoreId, + activation_source: re_viewer_context::ActivationSource, ) { if store_id.is_blueprint() { re_log::warn!( @@ -2162,7 +2297,14 @@ impl App { return; } - store_hub.set_active_recording_id(store_id.clone()); + // Use the system command to properly trigger analytics + self.command_sender + .send_system(SystemCommand::ActivateRecordingOrTable { + entry: re_viewer_context::RecordingOrTable::Recording { + store_id: store_id.clone(), + }, + source: activation_source, + }); // Also select the new recording: self.command_sender.send_system(SystemCommand::SetSelection( @@ -2536,6 +2678,17 @@ impl App { } } +impl Drop for App { + fn drop(&mut self) { + // End all active playback sessions when the app shuts down + #[cfg(feature = "analytics")] + self.playback_session_tracker.end_all_sessions( + re_analytics::event::PlaybackStopReason::AppExited, + self.build_info.clone(), + ); + } +} + #[cfg(target_arch = "wasm32")] fn blueprint_loader() -> BlueprintPersistence { // TODO(#2579): implement persistence for web diff --git a/crates/viewer/re_viewer/src/lib.rs b/crates/viewer/re_viewer/src/lib.rs index 4f3783f7e9c4..eae1d65dd2c9 100644 --- a/crates/viewer/re_viewer/src/lib.rs +++ b/crates/viewer/re_viewer/src/lib.rs @@ -15,6 +15,7 @@ pub mod env_vars; pub mod event; mod navigation; mod open_url_description; +mod playback_session_tracker; mod saving; mod screenshotter; mod startup_options; diff --git a/crates/viewer/re_viewer/src/playback_session_tracker.rs b/crates/viewer/re_viewer/src/playback_session_tracker.rs new file mode 100644 index 000000000000..3d2b716b65a2 --- /dev/null +++ b/crates/viewer/re_viewer/src/playback_session_tracker.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; + +use re_entity_db::EntityDb; +use re_log_types::{StoreId, TimeReal, Timeline}; +use re_analytics::event::{PlaybackSessionType, PlaybackStopReason}; + +use crate::viewer_analytics; + +/// Tracks playback sessions to generate analytics events +#[derive(Default)] +pub struct PlaybackSessionTracker { + /// Active sessions keyed by recording id + active_sessions: HashMap, +} + +struct ActiveSession { + start_time: web_time::Instant, + timeline: Timeline, + session_type: PlaybackSessionType, + + /// Track positions visited during the session + min_time_visited: TimeReal, + max_time_visited: TimeReal, + + /// Sum of absolute distances traveled + total_distance_traveled: f64, + + /// Last known time position + last_time_position: TimeReal, +} + +impl PlaybackSessionTracker { + pub fn new() -> Self { + Self::default() + } + + /// Start or continue a session when playback begins or user interacts with timeline + pub fn on_playback_interaction( + &mut self, + recording_id: &StoreId, + timeline: Timeline, + current_time: TimeReal, + interaction_type: PlaybackSessionType, + ) { + let session = self.active_sessions.entry(recording_id.clone()).or_insert_with(|| { + ActiveSession { + start_time: web_time::Instant::now(), + timeline, + session_type: interaction_type, + min_time_visited: current_time, + max_time_visited: current_time, + total_distance_traveled: 0.0, + last_time_position: current_time, + } + }); + + // Update session type if it changes (e.g., from playback to scrubbing) + if session.session_type != interaction_type { + session.session_type = match (&session.session_type, &interaction_type) { + (PlaybackSessionType::Playback, PlaybackSessionType::Scrubbing) | + (PlaybackSessionType::Scrubbing, PlaybackSessionType::Playback) => PlaybackSessionType::Mixed, + _ => interaction_type, + }; + } + + // Update time tracking + let time_distance = (current_time.as_f64() - session.last_time_position.as_f64()).abs(); + session.total_distance_traveled += time_distance; + + session.min_time_visited = session.min_time_visited.min(current_time); + session.max_time_visited = session.max_time_visited.max(current_time); + session.last_time_position = current_time; + } + + /// End a session and emit analytics event + pub fn end_session( + &mut self, + recording_id: &StoreId, + _entity_db: &EntityDb, + reason: PlaybackStopReason, + build_info: re_build_info::BuildInfo, + ) { + if let Some(session) = self.active_sessions.remove(recording_id) { + let wall_clock_seconds = session.start_time.elapsed().as_secs_f64(); + + let time_unit = match session.timeline.typ() { + re_log_types::TimeType::Sequence => "frames".to_string(), + _ => "seconds".to_string(), + }; + + // Convert timeline units to appropriate measurements + let (total_time_traveled, covered_time_distance) = match session.timeline.typ() { + re_log_types::TimeType::Sequence => { + // For sequences, distance is in frame counts + (session.total_distance_traveled, + (session.max_time_visited.as_f64() - session.min_time_visited.as_f64())) + } + _ => { + // For time timelines, convert nanoseconds to seconds + (session.total_distance_traveled / 1e9, + (session.max_time_visited.as_f64() - session.min_time_visited.as_f64()) / 1e9) + } + }; + + let event = viewer_analytics::event::playback_session( + build_info, + session.timeline.name().to_string(), + wall_clock_seconds, + session.session_type, + total_time_traveled, + covered_time_distance.max(0.0), // Ensure non-negative + time_unit, + reason, + recording_id, + ); + + #[cfg(feature = "analytics")] + if let Some(analytics) = re_analytics::Analytics::global_or_init() { + analytics.record(event); + } + } + } + + /// End all active sessions (e.g., when app is shutting down) + pub fn end_all_sessions(&mut self, reason: PlaybackStopReason, build_info: re_build_info::BuildInfo) { + let session_ids: Vec = self.active_sessions.keys().cloned().collect(); + for store_id in session_ids { + // We don't have EntityDb here, but we still want to end sessions + if let Some(session) = self.active_sessions.remove(&store_id) { + let wall_clock_seconds = session.start_time.elapsed().as_secs_f64(); + + let time_unit = match session.timeline.typ() { + re_log_types::TimeType::Sequence => "frames".to_string(), + _ => "seconds".to_string(), + }; + + let (total_time_traveled, covered_time_distance) = match session.timeline.typ() { + re_log_types::TimeType::Sequence => { + (session.total_distance_traveled, + (session.max_time_visited.as_f64() - session.min_time_visited.as_f64())) + } + _ => { + (session.total_distance_traveled / 1e9, + (session.max_time_visited.as_f64() - session.min_time_visited.as_f64()) / 1e9) + } + }; + + let event = viewer_analytics::event::playback_session( + build_info.clone(), + session.timeline.name().to_string(), + wall_clock_seconds, + session.session_type, + total_time_traveled, + covered_time_distance.max(0.0), + time_unit, + reason.clone(), + &store_id, + ); + + #[cfg(feature = "analytics")] + if let Some(analytics) = re_analytics::Analytics::global_or_init() { + analytics.record(event); + } + } + } + } +} \ No newline at end of file diff --git a/crates/viewer/re_viewer/src/viewer_analytics/event.rs b/crates/viewer/re_viewer/src/viewer_analytics/event.rs index 75832cf38867..cb21ed5500ba 100644 --- a/crates/viewer/re_viewer/src/viewer_analytics/event.rs +++ b/crates/viewer/re_viewer/src/viewer_analytics/event.rs @@ -2,7 +2,10 @@ use crate::AppEnvironment; use re_analytics::{ Config, Property, - event::{Id, Identify, OpenRecording, StoreInfo, ViewerRuntimeInformation, ViewerStarted}, + event::{ + Id, Identify, OpenRecording, PlaybackSession, PlaybackSessionType, PlaybackStopReason, + StoreInfo, SwitchRecording, ViewerRuntimeInformation, ViewerStarted, + }, }; pub fn identify( @@ -64,6 +67,7 @@ pub fn viewer_started( pub fn open_recording( app_env: &AppEnvironment, entity_db: &re_entity_db::EntityDb, + total_open_recordings: usize, ) -> Option { let store_info = entity_db.store_info().map(|store_info| { let re_log_types::StoreInfo { @@ -166,6 +170,77 @@ pub fn open_recording( url: app_env.url().cloned(), app_env: app_env.name(), store_info, + total_open_recordings, data_source, }) } + +pub fn switch_recording( + app_env: &AppEnvironment, + previous_recording_id: Option<&re_log_types::StoreId>, + new_recording_id: &re_log_types::StoreId, + switch_method: &'static str, +) -> SwitchRecording { + let previous_id = previous_recording_id.map(|store_id| { + let application_id = store_id.application_id(); + let recording_id = store_id.recording_id(); + + if application_id.as_str().starts_with("rerun_example") { + Id::Official(recording_id.to_string()) + } else { + Id::Hashed(Property::from(recording_id.as_str()).hashed()) + } + }); + + let new_id = { + let application_id = new_recording_id.application_id(); + let recording_id = new_recording_id.recording_id(); + + if application_id.as_str().starts_with("rerun_example") { + Id::Official(recording_id.to_string()) + } else { + Id::Hashed(Property::from(recording_id.as_str()).hashed()) + } + }; + + SwitchRecording { + url: app_env.url().cloned(), + app_env: app_env.name(), + previous_recording_id: previous_id, + new_recording_id: new_id, + switch_method, + } +} + +pub fn playback_session( + build_info: re_build_info::BuildInfo, + timeline_name: String, + wall_clock_seconds: f64, + session_type: PlaybackSessionType, + total_time_traveled: f64, + covered_time_distance: f64, + time_unit: String, + end_reason: PlaybackStopReason, + recording_id: &re_log_types::StoreId, +) -> PlaybackSession { + let application_id = recording_id.application_id(); + let recording_id_str = recording_id.recording_id(); + + let recording_id_processed = if application_id.as_str().starts_with("rerun_example") { + Id::Official(recording_id_str.to_string()) + } else { + Id::Hashed(Property::from(recording_id_str.as_str()).hashed()) + }; + + PlaybackSession { + build_info, + timeline_name, + wall_clock_seconds, + session_type, + total_time_traveled, + covered_time_distance, + time_unit, + end_reason, + recording_id: recording_id_processed, + } +}