Compare commits

...
Author SHA1 Message Date
Brandon Thomas ef5df7b206 analytics enhancements - track individual sessions 2025-09-27 17:51:49 -04:00
15 changed files with 288 additions and 5 deletions
@@ -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,
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
drop table analytics_app_sessions;
@@ -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;
@@ -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;
@@ -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<AppSessionToken>,
/// An override for the application platform/OS (windows, mac, linux).
pub maybe_os_platform: Option<String>,
@@ -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);
}
@@ -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,
@@ -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,
@@ -1 +1,2 @@
pub mod upsert_analytics_app_active_user;
pub mod upsert_analytics_app_active_user;
pub mod upsert_analytics_app_session;
@@ -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<u64>,
}
impl <'a> UpsertAnalyticsAppSession<'a> {
fn query(&self) -> Query<MySql, MySqlArguments> {
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<MySql>
) -> Result<(), MysqlError<UpsertError>> {
let _query_result = self.query()
.execute(&mut **mysql_connection)
.await?;
Ok(())
}
}
@@ -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_",
@@ -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);
@@ -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;
@@ -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(())
}