mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e17beb58 | ||
|
|
e4f37900e2 | ||
|
|
7b89ad7c2f | ||
|
|
0ee106b3d7 | ||
|
|
76134d106a | ||
|
|
c59e4bcbd4 | ||
|
|
33f659a1ff | ||
|
|
1fbbbc5d7d | ||
|
|
87b4cfb3f2 | ||
|
|
620a4604b0 | ||
|
|
61bbf6d32a | ||
|
|
054eaba609 |
+12
@@ -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"
|
||||
}
|
||||
Generated
+8
@@ -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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
drop table analytics_app_active_users;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// How long the user has been active in the app, in seconds.
|
||||
pub maybe_session_duration_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod log_active_user;
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod analytics;
|
||||
pub mod common;
|
||||
pub mod credits;
|
||||
pub mod generate;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 {
|
||||
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?)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod log_active_user;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Res: DeserializeOwned>(
|
||||
api_host: &ApiHost,
|
||||
route_path: &str,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
query_params: &HashMap<String, String>,
|
||||
) -> Result<Res, StorytellerError> {
|
||||
|
||||
// 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, String>) -> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+7
-1
@@ -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(())
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod events;
|
||||
pub mod storyteller_activity_thread;
|
||||
pub mod storyteller_task_polling_thread;
|
||||
+110
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Option<&str>, 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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
pub mod select_optional_record_error;
|
||||
pub mod subtypes;
|
||||
@@ -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<T> for each subtype:
|
||||
/// 1. From<sqlx::Error> for MysqlCrateErrorSubtype
|
||||
/// 2. From<sqlx::Error> for MysqlError<MysqlCrateErrorSubtype>
|
||||
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<E: MysqlCrateErrorSubtype> {
|
||||
/// Inner error type
|
||||
pub inner: E
|
||||
}
|
||||
|
||||
impl <E> Error for MysqlError <E> where E: MysqlCrateErrorSubtype {}
|
||||
|
||||
impl <E> Display for MysqlError<E> where E: MysqlCrateErrorSubtype
|
||||
{
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "MysqlError: {}", self.inner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod upsert_error;
|
||||
@@ -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<sqlx::Error> 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<sqlx::Error> for MysqlError<UpsertError> {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
let inner = UpsertError::from(err);
|
||||
MysqlError { inner }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod upsert_analytics_app_active_user;
|
||||
+62
@@ -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<u64>,
|
||||
}
|
||||
|
||||
impl <'a> UpsertAnalyticsAppActiveUser<'a> {
|
||||
fn query(&self) -> Query<MySql, MySqlArguments> {
|
||||
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<MySql>
|
||||
) -> Result<(), MysqlError<UpsertError>> {
|
||||
let _query_result = self.query()
|
||||
.execute(&mut **mysql_connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+16
-1
@@ -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<sqlx::Error> for CommonWebError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for CommonWebError {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
error!("Uncaught anyhow::Error being coerced to CommonWebError : {:?}", value);
|
||||
CommonWebError::ServerError
|
||||
}
|
||||
}
|
||||
|
||||
impl <T: MysqlCrateErrorSubtype> From<MysqlError<T>> for CommonWebError {
|
||||
fn from(value: MysqlError<T>) -> Self {
|
||||
error!("MysqlError being coerced to CommonWebError : {:?}", value);
|
||||
CommonWebError::ServerError
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+107
@@ -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<LogAppActiveUserRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<LogAppActiveUserResponse>, 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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod log_browser_session_handler;
|
||||
pub mod log_browser_session_handler;
|
||||
pub mod log_app_active_user_handler;
|
||||
+5
-1
@@ -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<T, B> (app: App<T>) -> App<T>
|
||||
@@ -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/...
|
||||
|
||||
+25
@@ -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<T, B> (app: App<T>) -> App<T>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
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()))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -16,4 +16,5 @@ mod voice_conversion_routes;
|
||||
mod webhook_routes;
|
||||
mod weights_routes;
|
||||
pub (super) mod add_application_routes;
|
||||
mod prompts_routes;
|
||||
mod prompts_routes;
|
||||
mod analytics_routes;
|
||||
Reference in New Issue
Block a user