From e4f37900e2472149c8ada048e1bcd90d22b69b82 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 24 Sep 2025 22:04:49 -0400 Subject: [PATCH] add session duration --- .../up.sql | 12 ++++++--- _sql/migrations_squashed/analytics_totals.sql | 27 +++++++++++++++---- .../src/analytics/log_active_user.rs | 3 +++ .../src/analytics/log_active_user.rs | 6 +++++ .../threads/storyteller_activity_thread.rs | 9 ++++++- .../upsert_analytics_app_active_user.rs | 11 +++++--- .../analytics/log_app_active_user_handler.rs | 5 ++-- 7 files changed, 59 insertions(+), 14 deletions(-) diff --git a/_sql/migrations/2025-09-24-055943_analytics_app_active_users/up.sql b/_sql/migrations/2025-09-24-055943_analytics_app_active_users/up.sql index 6c20f9e9a1..85daa6f2fe 100644 --- a/_sql/migrations/2025-09-24-055943_analytics_app_active_users/up.sql +++ b/_sql/migrations/2025-09-24-055943_analytics_app_active_users/up.sql @@ -13,10 +13,16 @@ CREATE TABLE analytics_app_active_users ( -- Who sent the event. user_token VARCHAR(32) NOT NULL, - -- App version is updated on every write. - app_version VARCHAR(255) NOT NULL, + -- Updated on every write. + -- The "user agent" or app version string of the client. + app_version VARCHAR(255) DEFAULT NULL, - -- IP address is updated on every write. + -- Updated on every write. + -- How long the current user's session has been open + session_duration_seconds INT(10) UNSIGNED DEFAULT NULL, + + -- Updated on every write. + -- The user's last known IP address. ip_address VARCHAR(40) NOT NULL, -- Incrementing count of events. diff --git a/_sql/migrations_squashed/analytics_totals.sql b/_sql/migrations_squashed/analytics_totals.sql index caaf8b05d0..85daa6f2fe 100644 --- a/_sql/migrations_squashed/analytics_totals.sql +++ b/_sql/migrations_squashed/analytics_totals.sql @@ -2,7 +2,7 @@ -- noinspection SqlNoDataSourceInspectionForFile -- noinspection SqlResolveForFile -CREATE TABLE analytics_totals ( +CREATE TABLE analytics_app_active_users ( -- Not used for anything except replication. id BIGINT(20) NOT NULL AUTO_INCREMENT, @@ -13,17 +13,34 @@ CREATE TABLE analytics_totals ( -- Who sent the event. user_token VARCHAR(32) NOT NULL, + -- Updated on every write. + -- The "user agent" or app version string of the client. + app_version VARCHAR(255) DEFAULT NULL, + + -- Updated on every write. + -- How long the current user's session has been open + session_duration_seconds INT(10) UNSIGNED DEFAULT NULL, + + -- Updated on every write. + -- The user's last known IP address. + ip_address VARCHAR(40) NOT NULL, + -- Incrementing count of events. - event_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + -- NB: This is kind of an "hours spent" measure, but will lose information if we ever + -- change the ping cadence (eg. 1 minute pings --> 5 minute pings). + measurement_count BIGINT UNSIGNED NOT NULL DEFAULT 0, -- ========== RECORD TIMESTAMPS ========== - first_event_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_event_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + -- Written at create only. + first_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Updated on every ping. + last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- ========== INDICES ========== PRIMARY KEY (id), UNIQUE KEY (app_namespace, user_token), -- For now - INDEX idx_last_event_at (last_event_at) + INDEX idx_last_active_at (last_active_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/crates/api_clients/artcraft_api_defs/src/analytics/log_active_user.rs b/crates/api_clients/artcraft_api_defs/src/analytics/log_active_user.rs index e2c6c1522b..efa9a40187 100644 --- a/crates/api_clients/artcraft_api_defs/src/analytics/log_active_user.rs +++ b/crates/api_clients/artcraft_api_defs/src/analytics/log_active_user.rs @@ -14,6 +14,9 @@ pub struct LogAppActiveUserRequest { /// If set together with `maybe_app_name`, the two will be /// concatenated as `{maybe_app_name}/{maybe_app_version}`. pub maybe_app_version: Option, + + /// How long the user has been active in the app, in seconds. + pub maybe_session_duration_seconds: Option, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/crates/api_clients/storyteller_client/src/analytics/log_active_user.rs b/crates/api_clients/storyteller_client/src/analytics/log_active_user.rs index ea0f6f4dc9..159656f6db 100644 --- a/crates/api_clients/storyteller_client/src/analytics/log_active_user.rs +++ b/crates/api_clients/storyteller_client/src/analytics/log_active_user.rs @@ -11,6 +11,8 @@ pub async fn log_active_user( request: LogAppActiveUserRequest, ) -> Result { + // TODO(bt): This is gross and doesn't automatically handle field changes. + let mut query = HashMap::new(); if let Some(app_name) = request.maybe_app_name { @@ -20,6 +22,10 @@ pub async fn log_active_user( if let Some(app_version) = request.maybe_app_version { query.insert("maybe_app_version".to_string(), app_version); } + + if let Some(maybe_session_duration_seconds) = request.maybe_session_duration_seconds { + query.insert("maybe_session_duration_seconds".to_string(), maybe_session_duration_seconds.to_string()); + } Ok(basic_query_string_post_request( api_host, diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs index 7dbd07e36a..dc6527a4f0 100644 --- a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs @@ -16,6 +16,7 @@ use log::{error, info}; use reqwest::Url; use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs, Task}; use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs}; +use std::time::Instant; use storyteller_client::analytics::log_active_user::log_active_user; use storyteller_client::credentials::storyteller_credential_set::StorytellerCredentialSet; use storyteller_client::error::api_error::ApiError; @@ -34,10 +35,12 @@ pub async fn storyteller_activity_thread( app_env_configs: AppEnvConfigs, storyteller_creds_manager: StorytellerCredentialManager, ) -> ! { + let startup = Instant::now(); loop { let res = polling_loop( &app_env_configs, &storyteller_creds_manager, + startup, ).await; if let Err(err) = res { error!("An error occurred: {:?}", err); @@ -50,6 +53,7 @@ pub async fn storyteller_activity_thread( async fn polling_loop( app_env_configs: &AppEnvConfigs, storyteller_creds_manager: &StorytellerCredentialManager, + startup: Instant, ) -> AnyhowResult<()> { loop { let creds = storyteller_creds_manager.get_credentials()?; @@ -68,13 +72,16 @@ async fn polling_loop( }, }; + let time_since_startup = Instant::now().duration_since(startup); + let request = LogAppActiveUserRequest { maybe_app_name: Some(CLIENT_NAME.to_string()), maybe_app_version: Some(CLIENT_VERSION.to_string()), + maybe_session_duration_seconds: Some(time_since_startup.as_secs()), }; info!("Logging active user with storyteller."); - + let result = log_active_user( &app_env_configs.storyteller_host, Some(&creds), diff --git a/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_active_user.rs b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_active_user.rs index e68c726da3..61d1a41a77 100644 --- a/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_active_user.rs +++ b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_active_user.rs @@ -8,11 +8,12 @@ use sqlx::MySql; use tokens::tokens::users::UserToken; pub struct UpsertAnalyticsAppActiveUser<'a> { - // TODO: Swap PaymentsNamespace (artcraft,fakeyou) for an AnalyticsNamespace enum. + // TODO: Swap PaymentsNamespace (artcraft,fakeyou,etc.) for an AnalyticsNamespace enum. pub namespace: PaymentsNamespace, pub user_token: &'a UserToken, pub ip_address: &'a str, - pub app_version: &'a str, + pub app_version: Option<&'a str>, + pub session_duration_seconds: Option, } impl <'a> UpsertAnalyticsAppActiveUser<'a> { @@ -24,12 +25,14 @@ SET app_namespace = ?, user_token = ?, app_version = ?, + session_duration_seconds = ?, ip_address = ?, measurement_count = measurement_count + 1, first_active_at = NOW(), last_active_at = NOW() ON DUPLICATE KEY UPDATE app_version = ?, + session_duration_seconds = ?, ip_address = ?, measurement_count = measurement_count + 1, last_active_at = NOW() @@ -38,15 +41,17 @@ ON DUPLICATE KEY UPDATE self.namespace.to_str(), self.user_token.as_str(), self.app_version, + self.session_duration_seconds, self.ip_address, // Update case self.app_version, + self.session_duration_seconds, self.ip_address, ) } pub async fn upsert_with_connection( - &self, + &self, mysql_connection: &mut PoolConnection ) -> Result<(), MysqlError> { let _query_result = self.query() diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_app_active_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_app_active_user_handler.rs index 2728056b4d..eb8fe28c20 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_app_active_user_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_app_active_user_handler.rs @@ -86,14 +86,15 @@ pub async fn log_app_active_user_handler( _ => {} } - maybe_recorded_version.unwrap_or_else(|| "unknown".to_string()) + maybe_recorded_version }; let upsert = UpsertAnalyticsAppActiveUser { namespace: PaymentsNamespace::Artcraft, user_token: &user_token, ip_address: &ip_address, - app_version: &app_version, + app_version: app_version.as_deref(), + session_duration_seconds: request.maybe_session_duration_seconds, }; upsert.upsert_with_connection(&mut mysql_connection).await?;