From fa04e633fe4153c139cb4f2f33d3a3560e20be6e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 24 Sep 2025 22:07:52 -0400 Subject: [PATCH] artcraft activity analytics (#848) we need to know if anyone is using the app --- ...98c9ca011ac445302b7f462f265fe5f296a8c.json | 12 ++ Cargo.lock | 8 ++ .../down.sql | 5 + .../up.sql | 46 +++++++ _sql/migrations_squashed/analytics_totals.sql | 46 +++++++ .../src/analytics/log_active_user.rs | 29 ++++ .../artcraft_api_defs/src/analytics/mod.rs | 1 + .../api_clients/artcraft_api_defs/src/lib.rs | 1 + .../api_clients/storyteller_client/Cargo.toml | 7 +- .../src/analytics/log_active_user.rs | 36 +++++ .../storyteller_client/src/analytics/mod.rs | 1 + .../api_clients/storyteller_client/src/lib.rs | 3 +- .../storyteller_client/src/utils/api_host.rs | 12 ++ .../utils/basic_query_string_post_request.rs | 127 ++++++++++++++++++ .../storyteller_client/src/utils/mod.rs | 1 + .../lifecycle/startup/handle_tauri_startup.rs | 4 +- .../src/core/lifecycle/startup/tasks/mod.rs | 2 +- ...thread.rs => spawn_storyteller_threads.rs} | 8 +- .../src/services/storyteller/threads/mod.rs | 1 + .../threads/storyteller_activity_thread.rs | 110 +++++++++++++++ crates/lib/actix_helpers/Cargo.toml | 17 ++- .../src/extractors/get_request_user_agent.rs | 69 ++++++++++ .../lib/actix_helpers/src/extractors/mod.rs | 1 + .../database/mysql_queries/src/errors/mod.rs | 4 +- .../mysql_queries/src/errors/mysql_error.rs | 27 ++++ .../mysql_queries/src/errors/subtypes/mod.rs | 1 + .../src/errors/subtypes/upsert_error.rs | 51 +++++++ .../src/queries/analytics_active_users/mod.rs | 1 + .../upsert_analytics_app_active_user.rs | 62 +++++++++ .../database/mysql_queries/src/queries/mod.rs | 6 +- .../common_responses/common_web_error.rs | 17 ++- .../analytics/log_app_active_user_handler.rs | 107 +++++++++++++++ .../http_server/endpoints/analytics/mod.rs | 3 +- .../add_application_routes.rs | 6 +- .../application_routes/analytics_routes.rs | 25 ++++ .../routes/application_routes/mod.rs | 3 +- 36 files changed, 839 insertions(+), 21 deletions(-) create mode 100644 .sqlx/query-4d4e7074bb73d1480d6f1a07f3698c9ca011ac445302b7f462f265fe5f296a8c.json create mode 100644 _sql/migrations/2025-09-24-055943_analytics_app_active_users/down.sql create mode 100644 _sql/migrations/2025-09-24-055943_analytics_app_active_users/up.sql create mode 100644 _sql/migrations_squashed/analytics_totals.sql create mode 100644 crates/api_clients/artcraft_api_defs/src/analytics/log_active_user.rs create mode 100644 crates/api_clients/artcraft_api_defs/src/analytics/mod.rs create mode 100644 crates/api_clients/storyteller_client/src/analytics/log_active_user.rs create mode 100644 crates/api_clients/storyteller_client/src/analytics/mod.rs create mode 100644 crates/api_clients/storyteller_client/src/utils/basic_query_string_post_request.rs rename crates/desktop/artcraft/src/core/lifecycle/startup/tasks/{spawn_storytoller_task_polling_thread.rs => spawn_storyteller_threads.rs} (74%) create mode 100644 crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs create mode 100644 crates/lib/actix_helpers/src/extractors/get_request_user_agent.rs create mode 100644 crates/schema/database/mysql_queries/src/errors/mysql_error.rs create mode 100644 crates/schema/database/mysql_queries/src/errors/subtypes/mod.rs create mode 100644 crates/schema/database/mysql_queries/src/errors/subtypes/upsert_error.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/analytics_active_users/mod.rs create mode 100644 crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_active_user.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_app_active_user_handler.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/routes/application_routes/analytics_routes.rs diff --git a/.sqlx/query-4d4e7074bb73d1480d6f1a07f3698c9ca011ac445302b7f462f265fe5f296a8c.json b/.sqlx/query-4d4e7074bb73d1480d6f1a07f3698c9ca011ac445302b7f462f265fe5f296a8c.json new file mode 100644 index 0000000000..ae2cfda571 --- /dev/null +++ b/.sqlx/query-4d4e7074bb73d1480d6f1a07f3698c9ca011ac445302b7f462f265fe5f296a8c.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nINSERT INTO analytics_app_active_users\nSET\n app_namespace = ?,\n user_token = ?,\n app_version = ?,\n session_duration_seconds = ?,\n ip_address = ?,\n measurement_count = measurement_count + 1,\n first_active_at = NOW(),\n last_active_at = NOW()\nON DUPLICATE KEY UPDATE\n app_version = ?,\n session_duration_seconds = ?,\n ip_address = ?,\n measurement_count = measurement_count + 1,\n last_active_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "4d4e7074bb73d1480d6f1a07f3698c9ca011ac445302b7f462f265fe5f296a8c" +} diff --git a/Cargo.lock b/Cargo.lock index 7ba6cd13fb..d19a448cff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -366,6 +366,7 @@ dependencies = [ "cidr-utils", "errors", "futures-util", + "log", "num-traits", "serde", "serde_json", @@ -10661,6 +10662,7 @@ dependencies = [ "tokens", "tokio", "url", + "url-builder", "uuid 1.8.0", "workspace-build-acceleration", ] @@ -12434,6 +12436,12 @@ dependencies = [ "serde", ] +[[package]] +name = "url-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c560d19163937c53680c515b82a9311c5a796691c67890b8ad579423123e194" + [[package]] name = "url_config" version = "0.0.1" diff --git a/_sql/migrations/2025-09-24-055943_analytics_app_active_users/down.sql b/_sql/migrations/2025-09-24-055943_analytics_app_active_users/down.sql new file mode 100644 index 0000000000..74887727e0 --- /dev/null +++ b/_sql/migrations/2025-09-24-055943_analytics_app_active_users/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +drop table analytics_app_active_users; 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 new file mode 100644 index 0000000000..85daa6f2fe --- /dev/null +++ b/_sql/migrations/2025-09-24-055943_analytics_app_active_users/up.sql @@ -0,0 +1,46 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE analytics_app_active_users ( + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- 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. + -- 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 (app_namespace, user_token), -- For now + 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_totals.sql new file mode 100644 index 0000000000..85daa6f2fe --- /dev/null +++ b/_sql/migrations_squashed/analytics_totals.sql @@ -0,0 +1,46 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE analytics_app_active_users ( + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- 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. + -- 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 (app_namespace, user_token), -- For now + 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 new file mode 100644 index 0000000000..efa9a40187 --- /dev/null +++ b/crates/api_clients/artcraft_api_defs/src/analytics/log_active_user.rs @@ -0,0 +1,29 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const LOG_ACTIVE_USER_PATH: &str = "/v1/analytics/active_user"; + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct LogAppActiveUserRequest { + /// An override for the application name. + /// If set together with `maybe_app_version`, the two will be + /// concatenated as `{maybe_app_name}/{maybe_app_version}`. + pub maybe_app_name: Option, + + /// An override for the application version. + /// 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)] +pub struct LogAppActiveUserResponse { + pub success: bool, + + /// How long to wait until the next analytics event, in milliseconds. + /// The client should honor this and is free to add jitter. + pub wait_for_retry_millis: u64, +} diff --git a/crates/api_clients/artcraft_api_defs/src/analytics/mod.rs b/crates/api_clients/artcraft_api_defs/src/analytics/mod.rs new file mode 100644 index 0000000000..8cb3740b95 --- /dev/null +++ b/crates/api_clients/artcraft_api_defs/src/analytics/mod.rs @@ -0,0 +1 @@ +pub mod log_active_user; \ No newline at end of file diff --git a/crates/api_clients/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft_api_defs/src/lib.rs index 932f093233..e8b4539dbf 100644 --- a/crates/api_clients/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft_api_defs/src/lib.rs @@ -1,3 +1,4 @@ +pub mod analytics; pub mod common; pub mod credits; pub mod generate; diff --git a/crates/api_clients/storyteller_client/Cargo.toml b/crates/api_clients/storyteller_client/Cargo.toml index 3564de831b..fdcfc9af0a 100644 --- a/crates/api_clients/storyteller_client/Cargo.toml +++ b/crates/api_clients/storyteller_client/Cargo.toml @@ -46,8 +46,11 @@ serde_derive.workspace = true serde_json.workspace = true # HTTP -backoff = { version = "0.4.0", features = ["futures"] } -uuid = { version = "1.8.0", features = ["v4"] } # Exponential backoff retry +backoff = { version = "0.4.0", features = ["futures"] } # Exponential backoff retry +uuid = { version = "1.8.0", features = ["v4"] } + +# TODO: This is not a very mature, safe, or intuitive URL builder library. Please replace it! +url-builder = "0.1.1" [dev-dependencies] tokio.workspace = true 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 new file mode 100644 index 0000000000..159656f6db --- /dev/null +++ b/crates/api_clients/storyteller_client/src/analytics/log_active_user.rs @@ -0,0 +1,36 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::basic_query_string_post_request::basic_query_string_post_request; +use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LogAppActiveUserResponse, LOG_ACTIVE_USER_PATH}; +use std::collections::HashMap; + +pub async fn log_active_user( + api_host: &ApiHost, + maybe_creds: Option<&StorytellerCredentialSet>, + 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 { + query.insert("maybe_app_name".to_string(), app_name); + } + + 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, + LOG_ACTIVE_USER_PATH, + maybe_creds, + &query, + ).await?) +} diff --git a/crates/api_clients/storyteller_client/src/analytics/mod.rs b/crates/api_clients/storyteller_client/src/analytics/mod.rs new file mode 100644 index 0000000000..8cb3740b95 --- /dev/null +++ b/crates/api_clients/storyteller_client/src/analytics/mod.rs @@ -0,0 +1 @@ +pub mod log_active_user; \ No newline at end of file diff --git a/crates/api_clients/storyteller_client/src/lib.rs b/crates/api_clients/storyteller_client/src/lib.rs index 9b85d4a36e..a6f29937e1 100644 --- a/crates/api_clients/storyteller_client/src/lib.rs +++ b/crates/api_clients/storyteller_client/src/lib.rs @@ -1,5 +1,6 @@ -//! An API client for Storyteller / FakeYou +//! An API client for Artcraft / Storyteller / FakeYou +pub mod analytics; pub mod credentials; pub mod credits; pub mod error; diff --git a/crates/api_clients/storyteller_client/src/utils/api_host.rs b/crates/api_clients/storyteller_client/src/utils/api_host.rs index ea45493b82..f8fd711af3 100644 --- a/crates/api_clients/storyteller_client/src/utils/api_host.rs +++ b/crates/api_clients/storyteller_client/src/utils/api_host.rs @@ -1,4 +1,8 @@ +const HTTP_SCHEME: &str = "http"; + +const HTTPS_SCHEME: &str = "https"; + #[derive(Clone, Debug)] pub enum ApiHost { Storyteller, @@ -22,4 +26,12 @@ impl ApiHost { ApiHost::Localhost { port } => format!("http://localhost:{}", port), } } + + pub fn scheme(&self) -> &'static str { + match self { + ApiHost::Storyteller => HTTPS_SCHEME, + ApiHost::FakeYou => HTTPS_SCHEME, + ApiHost::Localhost { .. } => HTTP_SCHEME, + } + } } diff --git a/crates/api_clients/storyteller_client/src/utils/basic_query_string_post_request.rs b/crates/api_clients/storyteller_client/src/utils/basic_query_string_post_request.rs new file mode 100644 index 0000000000..1c25fb1d46 --- /dev/null +++ b/crates/api_clients/storyteller_client/src/utils/basic_query_string_post_request.rs @@ -0,0 +1,127 @@ +use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; +use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::filter_bad_response::filter_bad_response; +use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageRequest, GenerateFlux1SchnellTextToImageResponse, GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH}; +use errors::AnyhowResult; +use log::{debug, info}; +use reqwest::Client; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use url::Url; +use url_builder::URLBuilder; + +pub async fn basic_query_string_post_request( + api_host: &ApiHost, + route_path: &str, + maybe_creds: Option<&StorytellerCredentialSet>, + query_params: &HashMap, +) -> Result { + + // TODO: Please stop using this URL Builder library. It's not very safe or intuitive. + let url = get_route(api_host, route_path, query_params); + + info!("Requesting {:?}", &url); + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; + + let mut request_builder = client.post(url) + .header("User-Agent", USER_AGENT) + .header("Accept", APPLICATION_JSON); + + if let Some(creds) = maybe_creds { + if let Some(header) = &creds.maybe_as_cookie_header() { + request_builder = request_builder.header("Cookie", header); + } + } + + let response = request_builder + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response = filter_bad_response(response) + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + let response_body = &response.text() + .await + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + debug!("Response body: {:?}", response_body); + + let response = serde_json::from_str(&response_body) + .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; + + Ok(response) +} + +// TODO: Please stop using this URL Builder library. It's not very safe or intuitive. +fn get_route(api_host: &ApiHost, route_path: &str, query_params: &HashMap) -> String { + let mut builder = URLBuilder::new(); + + builder.set_protocol(api_host.scheme()) + .set_host(&api_host.to_api_hostname()); + + // NB: This stupid library auto-inserts a starting slash, so we need to remove it. + // This is a bad code smell. I shouldn't be using this thing. + match route_path.split_once("/") { + None => { + builder.add_route(route_path); + } + Some((_slash, rest)) => { + builder.add_route(rest); + } + } + + // NB: This is not safe. It doesn't handle URL encoding, "?", "&", etc. + for (key, value) in query_params { + builder.add_param(key, value); + } + + // NB: This isn't very safe. + builder.build() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_get_route() { + let api_host = ApiHost::Storyteller; + let route_path = "/v1/test"; + let mut query_params = HashMap::new(); + query_params.insert("param1".to_string(), "value1".to_string()); + query_params.insert("param2".to_string(), "value2".to_string()); + + let url = get_route(&api_host, route_path, &query_params); + + assert!(url.starts_with("https://api.storyteller.ai/v1/test?")); + assert!(url.contains("param1=value1")); + assert!(url.contains("param2=value2")); + } + + #[test] + fn test_get_localhost_route() { + let api_host = ApiHost::Localhost { port: 12345 }; + let route_path = "/v1/test"; + let mut query_params = HashMap::new(); + query_params.insert("param1".to_string(), "value1".to_string()); + query_params.insert("param2".to_string(), "value2".to_string()); + + let url = get_route(&api_host, route_path, &query_params); + + assert!(url.starts_with("http://localhost:12345/v1/test?")); + assert!(url.contains("param1=value1")); + assert!(url.contains("param2=value2")); + } +} \ No newline at end of file diff --git a/crates/api_clients/storyteller_client/src/utils/mod.rs b/crates/api_clients/storyteller_client/src/utils/mod.rs index 2211888f7f..b7bc167ccf 100644 --- a/crates/api_clients/storyteller_client/src/utils/mod.rs +++ b/crates/api_clients/storyteller_client/src/utils/mod.rs @@ -1,6 +1,7 @@ pub mod api_host; pub mod basic_json_get_request; pub mod basic_json_post_request; +pub mod basic_query_string_post_request; pub mod constants; pub mod filter_bad_response; pub mod http_get_anonymous; diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs index fcaef9e389..f60cc3e55f 100644 --- a/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/handle_tauri_startup.rs @@ -6,7 +6,7 @@ use crate::core::lifecycle::startup::tasks::spawn_discord_presence_thread::spawn use crate::core::lifecycle::startup::tasks::spawn_fal_task_polling_thread::spawn_fal_task_polling_thread; use crate::core::lifecycle::startup::tasks::spawn_main_window_thread::spawn_main_window_thread; use crate::core::lifecycle::startup::tasks::spawn_sora_task_polling_thread::spawn_sora_task_polling_thread; -use crate::core::lifecycle::startup::tasks::spawn_storytoller_task_polling_thread::spawn_storyteller_task_polling_thread; +use crate::core::lifecycle::startup::tasks::spawn_storyteller_threads::spawn_storyteller_threads; use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; use crate::core::state::data_dir::app_data_root::AppDataRoot; use crate::services::fal::state::fal_credential_manager::FalCredentialManager; @@ -50,7 +50,7 @@ pub async fn handle_tauri_startup( &storyteller_creds_manager, )?; - spawn_storyteller_task_polling_thread( + spawn_storyteller_threads( &app, &app_env_configs, &task_database, diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/mod.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/mod.rs index 27cc646fb6..ec3eef73c1 100644 --- a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/mod.rs +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/mod.rs @@ -5,5 +5,5 @@ pub (super) mod spawn_discord_presence_thread; pub (super) mod spawn_fal_task_polling_thread; pub (super) mod spawn_main_window_thread; pub (super) mod spawn_sora_task_polling_thread; -pub (super) mod spawn_storytoller_task_polling_thread; +pub (super) mod spawn_storyteller_threads; pub (super) mod load_provider_priority_state; diff --git a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storytoller_task_polling_thread.rs b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storyteller_threads.rs similarity index 74% rename from crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storytoller_task_polling_thread.rs rename to crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storyteller_threads.rs index 609164eafd..ad18970ecd 100644 --- a/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storytoller_task_polling_thread.rs +++ b/crates/desktop/artcraft/src/core/lifecycle/startup/tasks/spawn_storyteller_threads.rs @@ -1,11 +1,12 @@ use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; use crate::core::state::task_database::TaskDatabase; use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use crate::services::storyteller::threads::storyteller_activity_thread::storyteller_activity_thread; use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread; use errors::AnyhowResult; use tauri::{AppHandle, Manager}; -pub fn spawn_storyteller_task_polling_thread( +pub fn spawn_storyteller_threads( app: &AppHandle, app_env_configs: &AppEnvConfigs, task_database: &TaskDatabase, @@ -18,6 +19,11 @@ pub fn spawn_storyteller_task_polling_thread( task_database.clone(), storyteller_creds_manager.clone(), )); + + tauri::async_runtime::spawn(storyteller_activity_thread( + app_env_configs.clone(), + storyteller_creds_manager.clone(), + )); Ok(()) } diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/mod.rs b/crates/desktop/artcraft/src/services/storyteller/threads/mod.rs index 6b38b6940d..eb30b006da 100644 --- a/crates/desktop/artcraft/src/services/storyteller/threads/mod.rs +++ b/crates/desktop/artcraft/src/services/storyteller/threads/mod.rs @@ -1,2 +1,3 @@ pub mod events; +pub mod storyteller_activity_thread; pub mod storyteller_task_polling_thread; \ No newline at end of file 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 new file mode 100644 index 0000000000..dc6527a4f0 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs @@ -0,0 +1,110 @@ +use crate::core::events::basic_sendable_event_trait::BasicSendableEvent; +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use crate::core::state::data_dir::trait_data_subdir::DataSubdir; +use crate::core::state::task_database::TaskDatabase; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; +use anyhow::anyhow; +use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest; +use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem; +use enums::common::generation_provider::GenerationProvider; +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 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; +use storyteller_client::error::storyteller_error::StorytellerError; +use storyteller_client::jobs::list_session_jobs::{list_session_jobs, States}; +use storyteller_client::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file; +use tauri::AppHandle; + +// TODO: Configure this with the build and increment. +const CLIENT_NAME : &str = "artcraft"; +const CLIENT_VERSION : &str = "0.0.1"; + +const ERROR_SLEEP_MILLIS : u64 = 1_000 * 60 * 3; // 3 minutes; + +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); + } + // NB: Sleep if an error occurs. + tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await; + } +} + +async fn polling_loop( + app_env_configs: &AppEnvConfigs, + storyteller_creds_manager: &StorytellerCredentialManager, + startup: Instant, +) -> AnyhowResult<()> { + loop { + let creds = storyteller_creds_manager.get_credentials()?; + + let creds = match creds { + None => { + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + Some(creds) => { + if creds.is_empty() { + tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; + continue; + } + creds + }, + }; + + 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), + request, + ).await; + + let wait_millis = match result { + Ok(result) => result.wait_for_retry_millis, + Err(err) => { + match &err { + StorytellerError::Api(ApiError::TooManyRequests(message)) => { + error!("Too many requests (sleeping): {:?}", message); + tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await; + continue; + } + _ => {} + } + return Err(anyhow!(err)); + } + }; + + // Wait at least a minute, no matter what the server tells us. + let wait_millis = std::cmp::min(wait_millis, 60_000); + tokio::time::sleep(std::time::Duration::from_millis(wait_millis)).await; + } +} diff --git a/crates/lib/actix_helpers/Cargo.toml b/crates/lib/actix_helpers/Cargo.toml index 5d3d0a26b2..9537348a01 100644 --- a/crates/lib/actix_helpers/Cargo.toml +++ b/crates/lib/actix_helpers/Cargo.toml @@ -18,15 +18,18 @@ normal = ["workspace-build-acceleration"] [dependencies] # Workspace hack (faster compile times) -workspace-build-acceleration = { workspace = true } +workspace-build-acceleration.workspace = true # Internal -errors = { workspace = true } +errors.workspace = true + +# External +log.workspace = true # Actix -actix-http = { workspace = true } -actix-service = { workspace = true } -actix-web = { workspace = true } +actix-http.workspace = true +actix-service.workspace = true +actix-web.workspace = true # IPv4 and v6 CIDRs (crate has few dependencies) cidr-utils = "0.5.11" @@ -36,8 +39,8 @@ num-traits = "0.2.17" # for cidr-utils futures-util = "0.3.29" # Serialization -serde = { workspace = true } -serde_json = { workspace = true } +serde.workspace = true +serde_json.workspace = true [dev-dependencies] # None yet diff --git a/crates/lib/actix_helpers/src/extractors/get_request_user_agent.rs b/crates/lib/actix_helpers/src/extractors/get_request_user_agent.rs new file mode 100644 index 0000000000..9abfc467d5 --- /dev/null +++ b/crates/lib/actix_helpers/src/extractors/get_request_user_agent.rs @@ -0,0 +1,69 @@ +use actix_web::http::header::{ToStrError, USER_AGENT}; +use actix_web::HttpRequest; +use log::warn; + +pub fn get_request_user_agent(request: &HttpRequest) -> Option<&str> { + let result = try_get_request_user_agent(request); + result.unwrap_or_else(|err| { + warn!("Error parsing User-Agent header: {}", err); + None + }) +} + +fn try_get_request_user_agent(request: &HttpRequest) -> Result, ToStrError> { + Ok(request.headers() + .get(USER_AGENT) + .map(|value| value.to_str()) + .transpose()? + .filter(|value| !value.is_empty())) +} + +#[cfg(test)] +mod tests { + use actix_web::test::TestRequest; + + use crate::extractors::get_request_user_agent::get_request_user_agent; + + #[test] + fn missing_user_agent() { + let request = TestRequest::default() + .to_http_request(); + + let origin = get_request_user_agent(&request); + + assert_eq!(origin, None); + } + + #[test] + fn empty_string_user_agent() { + let request = TestRequest::default() + .insert_header(("User-Agent", "")) + .to_http_request(); + + let origin = get_request_user_agent(&request); + + assert_eq!(origin, None); + } + + #[test] + fn user_agent() { + let request = TestRequest::default() + .insert_header(("User-Agent", "Blah Blah")) + .to_http_request(); + + let origin = get_request_user_agent(&request); + + assert_eq!(origin, Some("Blah Blah")); + } + + #[test] + fn user_agent_lower_case() { + let request = TestRequest::default() + .insert_header(("user-agent", "foo/1.0")) + .to_http_request(); + + let origin = get_request_user_agent(&request); + + assert_eq!(origin, Some("foo/1.0")); + } +} diff --git a/crates/lib/actix_helpers/src/extractors/mod.rs b/crates/lib/actix_helpers/src/extractors/mod.rs index 85db420fae..a0820b3c58 100644 --- a/crates/lib/actix_helpers/src/extractors/mod.rs +++ b/crates/lib/actix_helpers/src/extractors/mod.rs @@ -1,5 +1,6 @@ pub mod get_request_host; pub mod get_request_origin_uri; +pub mod get_request_user_agent; pub mod get_service_request_ip_address; // Utility diff --git a/crates/schema/database/mysql_queries/src/errors/mod.rs b/crates/schema/database/mysql_queries/src/errors/mod.rs index e907dec176..f9e6852c0f 100644 --- a/crates/schema/database/mysql_queries/src/errors/mod.rs +++ b/crates/schema/database/mysql_queries/src/errors/mod.rs @@ -1,4 +1,6 @@ pub mod database_insert_error; pub mod database_query_error; +pub mod mysql_error; pub mod select_exactly_one_error; -pub mod select_optional_record_error; \ No newline at end of file +pub mod select_optional_record_error; +pub mod subtypes; \ No newline at end of file diff --git a/crates/schema/database/mysql_queries/src/errors/mysql_error.rs b/crates/schema/database/mysql_queries/src/errors/mysql_error.rs new file mode 100644 index 0000000000..e381bb0c4d --- /dev/null +++ b/crates/schema/database/mysql_queries/src/errors/mysql_error.rs @@ -0,0 +1,27 @@ +use std::error::Error; +use std::fmt::{Debug, Display, Formatter}; + +/// Implement this marker trait for error subtypes. +/// It'll then be useful to implement two From for each subtype: +/// 1. From for MysqlCrateErrorSubtype +/// 2. From for MysqlError +pub trait MysqlCrateErrorSubtype: Error + Debug + Display {} + +/// All crate errors can be wrapped in this type. +/// Query-specific errors can be handled by the subtype. +/// Consumers can match on the wrapper type (eg. for error type +/// conversion) or subtype (for handling specific error cases). +#[derive(Debug)] +pub struct MysqlError { + /// Inner error type + pub inner: E +} + +impl Error for MysqlError where E: MysqlCrateErrorSubtype {} + +impl Display for MysqlError where E: MysqlCrateErrorSubtype +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "MysqlError: {}", self.inner) + } +} diff --git a/crates/schema/database/mysql_queries/src/errors/subtypes/mod.rs b/crates/schema/database/mysql_queries/src/errors/subtypes/mod.rs new file mode 100644 index 0000000000..0a5de28d1d --- /dev/null +++ b/crates/schema/database/mysql_queries/src/errors/subtypes/mod.rs @@ -0,0 +1 @@ +pub mod upsert_error; \ No newline at end of file diff --git a/crates/schema/database/mysql_queries/src/errors/subtypes/upsert_error.rs b/crates/schema/database/mysql_queries/src/errors/subtypes/upsert_error.rs new file mode 100644 index 0000000000..9f5d835427 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/errors/subtypes/upsert_error.rs @@ -0,0 +1,51 @@ +use crate::errors::mysql_error::{MysqlCrateErrorSubtype, MysqlError}; +use log::error; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum UpsertError { + /// A duplicate key error occurred. + DuplicateKeyError(String), + + /// An uncategorized error occurred. + SqlxError(sqlx::Error), +} + +impl Error for UpsertError {} + +impl MysqlCrateErrorSubtype for UpsertError {} + +impl Display for UpsertError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + UpsertError::DuplicateKeyError(reason) => write!(f, "DuplicateKeyError: {}", reason), + UpsertError::SqlxError(e) => write!(f, "SQLx error: {:?}", e), + } + } +} + +impl From for UpsertError { + fn from(err: sqlx::Error) -> Self { + if let Some(db_err) = err.as_database_error() { + // NB: SQLSTATE[23000]: Integrity constraint violation + // NB: MySQL Error Code 1062: Duplicate key insertion (this is harder to access) + let is_integrity_violation = db_err.code().as_deref() == Some("23000"); + let is_duplicate_key = db_err.message().contains("Duplicate entry"); + + if is_integrity_violation && is_duplicate_key { + error!("Detected duplicate key error: {:?}", db_err); + return Self::DuplicateKeyError(db_err.message().to_string()); + } + } + + Self::SqlxError(err) + } +} + +impl From for MysqlError { + fn from(err: sqlx::Error) -> Self { + let inner = UpsertError::from(err); + MysqlError { inner } + } +} 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 new file mode 100644 index 0000000000..dc1f3e518a --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/mod.rs @@ -0,0 +1 @@ +pub mod upsert_analytics_app_active_user; \ No newline at end of file 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 new file mode 100644 index 0000000000..61d1a41a77 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/analytics_active_users/upsert_analytics_app_active_user.rs @@ -0,0 +1,62 @@ +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::users::UserToken; + +pub struct UpsertAnalyticsAppActiveUser<'a> { + // 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 session_duration_seconds: Option, +} + +impl <'a> UpsertAnalyticsAppActiveUser<'a> { + fn query(&self) -> Query { + sqlx::query!( + r#" +INSERT INTO analytics_app_active_users +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() + "#, + // Insert case + 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, + mysql_connection: &mut PoolConnection + ) -> Result<(), MysqlError> { + let _query_result = self.query() + .execute(&mut **mysql_connection) + .await?; + Ok(()) + } +} diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index 252bbdcd88..06aa67391b 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -1,3 +1,5 @@ + +pub mod analytics_active_users; pub mod api_tokens; pub mod audit_logs; pub mod batch_generations; @@ -36,5 +38,5 @@ pub mod voice_clone_requests; pub mod voice_conversion; pub mod voice_designer; pub mod w2l; -pub mod wallets; -mod wallet_ledger_entries; +pub mod wallet_ledger_entries; +pub mod wallets; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs b/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs index 99c7657a89..3e585a1131 100644 --- a/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs +++ b/crates/service/web/storyteller_web/src/http_server/common_responses/common_web_error.rs @@ -1,8 +1,9 @@ use actix_http::StatusCode; use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError}; +use log::error; +use mysql_queries::errors::mysql_error::{MysqlCrateErrorSubtype, MysqlError}; use std::error::Error; use std::fmt::{Display, Formatter}; -use log::error; #[derive(Debug)] pub enum CommonWebError { @@ -84,6 +85,20 @@ impl From for CommonWebError { } } +impl From for CommonWebError { + fn from(value: anyhow::Error) -> Self { + error!("Uncaught anyhow::Error being coerced to CommonWebError : {:?}", value); + CommonWebError::ServerError + } +} + +impl From> for CommonWebError { + fn from(value: MysqlError) -> Self { + error!("MysqlError being coerced to CommonWebError : {:?}", value); + CommonWebError::ServerError + } +} + #[cfg(test)] mod tests { use super::*; 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 new file mode 100644 index 0000000000..eb8fe28c20 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/log_app_active_user_handler.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +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 http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::analytics_active_users::upsert_analytics_app_active_user::UpsertAnalyticsAppActiveUser; +use utoipa::ToSchema; + +const CLIENT_WAIT_FOR_RETRY_MILLIS: u64 = 1_000 * 60 * 1; // Every minute + +/// Log an active app user - user must be logged in. +#[utoipa::path( + post, + tag = "Analytics", + path = "/v1/analytics/active_user", + responses( + (status = 200, description = "Success", body = LogAppActiveUserSuccessResponse), + ), + params( + ("request" = LogAppActiveUserRequest, description = "Payload for Request"), + ) +)] +pub async fn log_app_active_user_handler( + http_request: HttpRequest, + request: Query, + server_state: web::Data>, +) -> Result, CommonWebError> +{ + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await?; + + let user_token = maybe_user_session + .ok_or(CommonWebError::NotAuthorized)? + .user_token; + + let ip_address = get_request_ip(&http_request); + + info!("Logging active user: {:?}", request); + + let app_version = { + let user_agent = get_request_user_agent(&http_request); + + let mut maybe_recorded_version = None; + + if let Some(user_agent) = user_agent { + maybe_recorded_version = Some(user_agent.to_string()); + }; + + let pair = ( + request.maybe_app_name.as_deref(), + request.maybe_app_version.as_deref(), + ); + + match pair { + (Some(name), Some(version)) => { + let version = version.trim().to_string(); + let name = name.trim().to_string(); + if !version.is_empty() && !name.is_empty() { + maybe_recorded_version = Some(format!("{}/{}", name, version)); + } + }, + (Some(name), None) => { + let name = name.trim().to_string(); + if !name.is_empty() { + maybe_recorded_version = Some(name); + } + }, + (None, Some(version)) => { + let version = version.trim().to_string(); + if !version.is_empty() { + maybe_recorded_version = Some(version); + } + }, + _ => {} + } + + maybe_recorded_version + }; + + let upsert = UpsertAnalyticsAppActiveUser { + namespace: PaymentsNamespace::Artcraft, + user_token: &user_token, + ip_address: &ip_address, + app_version: app_version.as_deref(), + session_duration_seconds: request.maybe_session_duration_seconds, + }; + + upsert.upsert_with_connection(&mut mysql_connection).await?; + + Ok(Json(LogAppActiveUserResponse { + success: true, + wait_for_retry_millis: CLIENT_WAIT_FOR_RETRY_MILLIS, + })) +} + diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/mod.rs index cdaa741f37..4e84a818b1 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/analytics/mod.rs @@ -1 +1,2 @@ -pub mod log_browser_session_handler; \ No newline at end of file +pub mod log_browser_session_handler; +pub mod log_app_active_user_handler; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs index 9c2d1e9659..5a4493b4ed 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs @@ -1,3 +1,4 @@ +use crate::http_server::routes::application_routes::analytics_routes::add_analytics_routes; use crate::http_server::routes::application_routes::comments_routes::add_comments_routes; use crate::http_server::routes::application_routes::credits_routes::add_credits_routes; use crate::http_server::routes::application_routes::featured_item_routes::add_featured_item_routes; @@ -5,6 +6,7 @@ use crate::http_server::routes::application_routes::generate_routes::add_generat use crate::http_server::routes::application_routes::job_routes::add_job_routes; use crate::http_server::routes::application_routes::media_files_routes::add_media_file_routes; use crate::http_server::routes::application_routes::moderation_routes::add_moderator_routes; +use crate::http_server::routes::application_routes::prompts_routes::add_prompts_routes; use crate::http_server::routes::application_routes::stripe_artcraft_routes::add_stripe_artcraft_routes; use crate::http_server::routes::application_routes::subscription_routes::add_subscription_routes; use crate::http_server::routes::application_routes::tag_routes::add_tag_routes; @@ -20,7 +22,6 @@ use actix_service::ServiceFactory; use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{App, Error}; use billing_component::default_routes::add_suggested_stripe_billing_routes; -use crate::http_server::routes::application_routes::prompts_routes::add_prompts_routes; /// Add the core application routes. pub fn add_application_routes (app: App) -> App @@ -55,6 +56,9 @@ where // Job system app = add_job_routes(app); + // Other useful tools + app = add_analytics_routes(app); // /v1/analytics/... + // User and user-adjacent routes app = add_comments_routes(app); // /v1/comments/... app = add_user_bookmarks_routes(app); // /v1/user_bookmarks/... diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/analytics_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/analytics_routes.rs new file mode 100644 index 0000000000..0f209943c1 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/analytics_routes.rs @@ -0,0 +1,25 @@ +use crate::http_server::endpoints::analytics::log_app_active_user_handler::log_app_active_user_handler; +use actix_http::body::MessageBody; +use actix_service::ServiceFactory; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::error::Error; +use actix_web::{web, App, HttpResponse}; + +pub fn add_analytics_routes (app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +{ + app.service(web::scope("/v1/analytics") + .service(web::resource("/active_user") + .route(web::post().to(log_app_active_user_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs index 2580a281b3..621722a439 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs @@ -16,4 +16,5 @@ mod voice_conversion_routes; mod webhook_routes; mod weights_routes; pub (super) mod add_application_routes; -mod prompts_routes; \ No newline at end of file +mod prompts_routes; +mod analytics_routes; \ No newline at end of file