add session duration

This commit is contained in:
Brandon Thomas committed 2025-09-24 22:04:49 -04:00
1 parent 7b89ad7c2f
commit e4f37900e2
7 files changed
+59 -14

No files matched your search

@@ -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.
+22 -5
View File
@@ -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;
@@ -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<String>,
/// How long the user has been active in the app, in seconds.
pub maybe_session_duration_seconds: Option<u64>,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -11,6 +11,8 @@ pub async fn log_active_user(
request: LogAppActiveUserRequest,
) -> Result<LogAppActiveUserResponse, StorytellerError> {
// 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,
@@ -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),
@@ -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<u64>,
}
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<MySql>
) -> Result<(), MysqlError<UpsertError>> {
let _query_result = self.query()
@@ -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?;