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 dd75f4fd17..18d1195825 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 @@ -2,6 +2,8 @@ -- noinspection SqlNoDataSourceInspectionForFile -- noinspection SqlResolveForFile +-- Track user activity over time +-- User clients ping the endpoint that writes this data. CREATE TABLE analytics_app_active_users ( -- Not used for anything except replication. id BIGINT(20) NOT NULL AUTO_INCREMENT, diff --git a/_sql/migrations/2025-09-27-211355_analytics_app_sessions/down.sql b/_sql/migrations/2025-09-27-211355_analytics_app_sessions/down.sql new file mode 100644 index 0000000000..737c2eadbb --- /dev/null +++ b/_sql/migrations/2025-09-27-211355_analytics_app_sessions/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +drop table analytics_app_sessions; diff --git a/_sql/migrations/2025-09-27-211355_analytics_app_sessions/up.sql b/_sql/migrations/2025-09-27-211355_analytics_app_sessions/up.sql new file mode 100644 index 0000000000..229135b0b3 --- /dev/null +++ b/_sql/migrations/2025-09-27-211355_analytics_app_sessions/up.sql @@ -0,0 +1,65 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- Track user session durations +-- User clients ping the endpoint that writes this data. +CREATE TABLE analytics_app_sessions ( + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Generated client-side + -- Wide enough for a UUID, but typically one of our internal tokens. + session_token VARCHAR(36) NOT NULL, + + -- If we host multiple sites with distinct categories, this will enable us to + -- segregate analytics. + app_namespace VARCHAR(32) NOT NULL, + + -- 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. + -- The OS platform for the client. + os_platform VARCHAR(16) DEFAULT NULL, + + -- Updated on every write. + -- The OS platform version for the client. + os_version VARCHAR(64) 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. + -- 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 ========== + + -- 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 (session_token), + INDEX idx_app_namespace (app_namespace), + INDEX idx_user_token (user_token), + INDEX idx_os_platform (os_platform), + INDEX idx_os_version (os_version), + INDEX idx_app_version (app_version), + INDEX idx_last_active_at (last_active_at) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/_sql/migrations_squashed/analytics_totals.sql b/_sql/migrations_squashed/analytics/analytics_app_active_users.sql similarity index 100% rename from _sql/migrations_squashed/analytics_totals.sql rename to _sql/migrations_squashed/analytics/analytics_app_active_users.sql diff --git a/_sql/migrations_squashed/analytics/analytics_app_sessions.sql b/_sql/migrations_squashed/analytics/analytics_app_sessions.sql new file mode 100644 index 0000000000..229135b0b3 --- /dev/null +++ b/_sql/migrations_squashed/analytics/analytics_app_sessions.sql @@ -0,0 +1,65 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- Track user session durations +-- User clients ping the endpoint that writes this data. +CREATE TABLE analytics_app_sessions ( + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Generated client-side + -- Wide enough for a UUID, but typically one of our internal tokens. + session_token VARCHAR(36) NOT NULL, + + -- If we host multiple sites with distinct categories, this will enable us to + -- segregate analytics. + app_namespace VARCHAR(32) NOT NULL, + + -- 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. + -- The OS platform for the client. + os_platform VARCHAR(16) DEFAULT NULL, + + -- Updated on every write. + -- The OS platform version for the client. + os_version VARCHAR(64) 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. + -- 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 ========== + + -- 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 (session_token), + INDEX idx_app_namespace (app_namespace), + INDEX idx_user_token (user_token), + INDEX idx_os_platform (os_platform), + INDEX idx_os_version (os_version), + INDEX idx_app_version (app_version), + 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 6fc0e4e7c8..f50148f039 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 @@ -1,10 +1,15 @@ use serde_derive::{Deserialize, Serialize}; +use tokens::tokens::app_session::AppSessionToken; use utoipa::ToSchema; pub const LOG_ACTIVE_USER_PATH: &str = "/v1/analytics/active_user"; #[derive(Serialize, Deserialize, ToSchema, Debug)] pub struct LogAppActiveUserRequest { + /// Clientside-generated session token. + /// If present, it'll be validated against an expected format. + pub maybe_app_session_token: Option, + /// An override for the application platform/OS (windows, mac, linux). pub maybe_os_platform: Option, diff --git a/crates/api_clients/storyteller_client/src/endpoints/analytics/log_active_user.rs b/crates/api_clients/storyteller_client/src/endpoints/analytics/log_active_user.rs index 29f5165689..e356f9b571 100644 --- a/crates/api_clients/storyteller_client/src/endpoints/analytics/log_active_user.rs +++ b/crates/api_clients/storyteller_client/src/endpoints/analytics/log_active_user.rs @@ -15,6 +15,10 @@ pub async fn log_active_user( let mut query = HashMap::new(); + if let Some(token) = request.maybe_app_session_token { + query.insert("maybe_app_session_token".to_string(), token.to_string()); + } + if let Some(os_platform) = request.maybe_os_platform { query.insert("maybe_os_platform".to_string(), os_platform); } diff --git a/crates/desktop/artcraft/src/services/fal/threads/fal_task_polling_thread.rs b/crates/desktop/artcraft/src/services/fal/threads/fal_task_polling_thread.rs index 70a2f2270e..a76f9ba551 100644 --- a/crates/desktop/artcraft/src/services/fal/threads/fal_task_polling_thread.rs +++ b/crates/desktop/artcraft/src/services/fal/threads/fal_task_polling_thread.rs @@ -1,6 +1,7 @@ use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; use crate::core::events::generation_events::common::GenerationServiceProvider; use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; use crate::core::state::data_dir::app_data_root::AppDataRoot; use crate::core::utils::download_url_to_temp_dir::download_url_to_temp_dir; use crate::services::fal::state::fal_credential_manager::FalCredentialManager; @@ -15,7 +16,6 @@ use storyteller_client::recipes::upload_media_file_from_file::upload_media_file_ use storyteller_client::utils::api_host::ApiHost; use tauri::AppHandle; use tokens::tokens::media_files::MediaFileToken; -use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; pub async fn fal_task_polling_thread( app_handle: AppHandle, 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 f164e7f741..be57c5e5a1 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 @@ -14,7 +14,7 @@ use enums::common::job_status_plus::JobStatusPlus; use enums::tauri::tasks::task_status::TaskStatus; use enums::tauri::tasks::task_type::TaskType; use errors::AnyhowResult; -use log::{error, info}; +use log::{debug, error, info}; use os_info::Info; use reqwest::Url; use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs, Task}; @@ -27,6 +27,7 @@ use storyteller_client::error::storyteller_error::StorytellerError; use storyteller_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States}; use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file; use tauri::AppHandle; +use tokens::tokens::app_session::AppSessionToken; // TODO: Configure this with the build and increment. const CLIENT_NAME : &str = "artcraft"; @@ -40,12 +41,17 @@ pub async fn storyteller_activity_thread( storyteller_creds_manager: StorytellerCredentialManager, ) -> ! { let startup = Instant::now(); + let app_session_token = AppSessionToken::generate(); + + info!("Session started at {:?} with token: {:?}", startup, app_session_token); + loop { let res = polling_loop( &app_env_configs, &storyteller_creds_manager, startup, &artcraft_platform_info, + &app_session_token, ).await; if let Err(err) = res { error!("An error occurred: {:?}", err); @@ -60,6 +66,7 @@ async fn polling_loop( storyteller_creds_manager: &StorytellerCredentialManager, startup: Instant, artcraft_platform_info: &ArtcraftPlatformInfo, + app_session_token: &AppSessionToken, ) -> AnyhowResult<()> { loop { let creds = storyteller_creds_manager.get_credentials()?; @@ -81,6 +88,7 @@ async fn polling_loop( let time_since_startup = Instant::now().duration_since(startup); let request = LogAppActiveUserRequest { + maybe_app_session_token: Some(app_session_token.clone()), maybe_app_name: Some(CLIENT_NAME.to_string()), maybe_app_version: Some(artcraft_platform_info.artcraft_version.clone()), maybe_os_platform: Some(artcraft_platform_info.os_platform.as_str().to_owned()), @@ -88,7 +96,7 @@ async fn polling_loop( maybe_session_duration_seconds: Some(time_since_startup.as_secs()), }; - info!("Logging active user with storyteller."); + debug!("Logging active user with storyteller."); let result = log_active_user( &app_env_configs.storyteller_host, diff --git a/crates/schema/database/mysql_queries/src/queries/analytics_active_users/mod.rs b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/mod.rs index dc1f3e518a..5e1ea03d42 100644 --- a/crates/schema/database/mysql_queries/src/queries/analytics_active_users/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/mod.rs @@ -1 +1,2 @@ -pub mod upsert_analytics_app_active_user; \ No newline at end of file +pub mod upsert_analytics_app_active_user; +pub mod upsert_analytics_app_session; \ No newline at end of file diff --git a/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_session.rs b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_session.rs new file mode 100644 index 0000000000..283f303cad --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_session.rs @@ -0,0 +1,80 @@ +use crate::errors::mysql_error::MysqlError; +use crate::errors::subtypes::upsert_error::UpsertError; +use enums::common::payments_namespace::PaymentsNamespace; +use sqlx::mysql::MySqlArguments; +use sqlx::pool::PoolConnection; +use sqlx::query::Query; +use sqlx::MySql; +use tokens::tokens::app_session::AppSessionToken; +use tokens::tokens::users::UserToken; + +pub struct UpsertAnalyticsAppSession<'a> { + pub app_session_token: &'a AppSessionToken, + // 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: Option<&'a str>, + pub os_platform: Option<&'a str>, + pub os_version: Option<&'a str>, + pub session_duration_seconds: Option, +} + +impl <'a> UpsertAnalyticsAppSession<'a> { + fn query(&self) -> Query { + sqlx::query!( + r#" +INSERT INTO analytics_app_sessions +SET + session_token = ?, + app_namespace = ?, + user_token = ?, + app_version = ?, + os_platform = ?, + os_version = ?, + session_duration_seconds = ?, + ip_address = ?, + measurement_count = measurement_count + 1, + first_active_at = NOW(), + last_active_at = NOW() +ON DUPLICATE KEY UPDATE + app_namespace = ?, + user_token = ?, + app_version = ?, + os_platform = ?, + os_version = ?, + session_duration_seconds = ?, + ip_address = ?, + measurement_count = measurement_count + 1, + last_active_at = NOW() + "#, + // Insert case + self.app_session_token.as_str(), + self.namespace.to_str(), + self.user_token.as_str(), + self.app_version, + self.os_platform, + self.os_version, + self.session_duration_seconds, + self.ip_address, + // Update case + self.namespace.to_str(), + self.user_token.as_str(), + self.app_version, + self.os_platform, + self.os_version, + self.session_duration_seconds, + self.ip_address, + ) + } + + pub async fn upsert_with_connection( + &self, + mysql_connection: &mut PoolConnection + ) -> Result<(), MysqlError> { + let _query_result = self.query() + .execute(&mut **mysql_connection) + .await?; + Ok(()) + } +} diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index f2c5f478c1..173b333b70 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -23,6 +23,7 @@ use strum::EnumIter; #[cfg_attr(test, derive(EnumIter, EnumCount))] pub(crate) enum TokenPrefix { AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables. + AppSession, // NB: These are generated client-side (!) AuditLog, BatchGeneration, BetaKey, @@ -105,6 +106,7 @@ impl PrefixGenerator for TokenPrefix { fn prefix(self) -> &'static str { match self { Self::AnonymousVisitorTracking => "avt_", + Self::AppSession => "app_session_", Self::AuditLog => "audit_", Self::BatchGeneration => "batch_g_", Self::BetaKey => "beta_key_", diff --git a/crates/schema/public/tokens/src/tokens/app_session.rs b/crates/schema/public/tokens/src/tokens/app_session.rs new file mode 100644 index 0000000000..bf2a7c5a38 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/app_session.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for users. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct AppSessionToken(pub String); + +impl_mysql_token_from_row!(AppSessionToken); +impl_string_token!(AppSessionToken); +impl_crockford_generator!(AppSessionToken, 32usize, TokenPrefix::AppSession, CrockfordMixed); diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs index 5b8b621653..580677f004 100644 --- a/crates/schema/public/tokens/src/tokens/mod.rs +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -1,6 +1,7 @@ pub mod anonymous_visitor_tracking; pub mod api_tokens_external; pub mod api_tokens_internal; +pub mod app_session; pub mod audit_logs; pub mod batch_generations; pub mod beta_keys; 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 e52d2a0be7..e112601fa5 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 @@ -5,11 +5,14 @@ use crate::state::server_state::ServerState; use actix_helpers::extractors::get_request_user_agent::get_request_user_agent; use actix_web::web::{Json, Query}; use actix_web::{web, HttpRequest}; -use log::info; use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LogAppActiveUserResponse}; use enums::common::payments_namespace::PaymentsNamespace; +use errors::AnyhowResult; use http_server_common::request::get_request_ip::get_request_ip; +use log::{info, warn}; use mysql_queries::queries::analytics_active_users::upsert_analytics_app_active_user::UpsertAnalyticsAppActiveUser; +use mysql_queries::queries::analytics_active_users::upsert_analytics_app_session::UpsertAnalyticsAppSession; +use tokens::tokens::app_session::AppSessionToken; use utoipa::ToSchema; const CLIENT_WAIT_FOR_RETRY_MILLIS: u64 = 1_000 * 60 * 1; // Every minute @@ -100,6 +103,23 @@ pub async fn log_app_active_user_handler( }; upsert.upsert_with_connection(&mut mysql_connection).await?; + + if let Some(token) = request.maybe_app_session_token.as_ref() { + validate_app_session_token_format(token)?; + + let upsert = UpsertAnalyticsAppSession{ + app_session_token: token, + namespace: PaymentsNamespace::Artcraft, + user_token: &user_token, + ip_address: &ip_address, + app_version: app_version.as_deref(), + os_platform: request.maybe_os_platform.as_deref(), + os_version: request.maybe_os_version.as_deref(), + session_duration_seconds: request.maybe_session_duration_seconds, + }; + + upsert.upsert_with_connection(&mut mysql_connection).await?; + } Ok(Json(LogAppActiveUserResponse { success: true, @@ -107,3 +127,11 @@ pub async fn log_app_active_user_handler( })) } +fn validate_app_session_token_format(app_session_token: &AppSessionToken) -> Result<(), CommonWebError> { + if !app_session_token.as_str().starts_with(AppSessionToken::token_prefix()) { + warn!("App session token has invalid prefix: {}", app_session_token.as_str()); + return Err(CommonWebError::BadInputWithSimpleMessage("Invalid app session token format".to_string())); + } + + Ok(()) +}