feat: sync upstream analytics schema support
This commit is contained in:
@@ -8,6 +8,7 @@ use sqlx::{
|
|||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod schema_compat;
|
||||||
pub mod sqlite_to_postgres;
|
pub mod sqlite_to_postgres;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"sites",
|
||||||
|
&[
|
||||||
|
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
|
||||||
|
("post_refresh_probe_model", "TEXT DEFAULT ''"),
|
||||||
|
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
|
||||||
|
(
|
||||||
|
"post_refresh_probe_latency_threshold_ms",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"downstream_api_keys",
|
||||||
|
&[
|
||||||
|
("excluded_site_ids", "TEXT"),
|
||||||
|
("excluded_credential_refs", "TEXT"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"route_channels",
|
||||||
|
&[("oauth_route_unit_id", "INTEGER")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"proxy_logs",
|
||||||
|
&[
|
||||||
|
("client_family", "TEXT"),
|
||||||
|
("client_app_id", "TEXT"),
|
||||||
|
("client_app_name", "TEXT"),
|
||||||
|
("client_confidence", "TEXT"),
|
||||||
|
("is_stream", "INTEGER"),
|
||||||
|
("first_byte_latency_ms", "INTEGER"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
run_compat_ddl(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_columns(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
table: &str,
|
||||||
|
columns: &[(&str, &str)],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let existing = table_columns(pool, table).await?;
|
||||||
|
if existing.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, definition) in columns {
|
||||||
|
if existing.contains(*name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("add column {table}.{name}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
|
||||||
|
let sql = format!("PRAGMA table_info({table})");
|
||||||
|
let rows = sqlx::query(&sql).fetch_all(pool).await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| row.try_get::<String, _>("name").ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
for statement in SQLITE_COMPAT_DDL {
|
||||||
|
sqlx::query(statement)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQLITE_COMPAT_DDL: &[&str] = &[
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
|
||||||
|
ON proxy_logs (client_app_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
|
||||||
|
ON proxy_logs (client_family, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
|
||||||
|
ON route_channels (oauth_route_unit_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT true,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
cooldown_until TEXT,
|
||||||
|
last_selected_at TEXT,
|
||||||
|
last_failed_at TEXT,
|
||||||
|
last_failure_reason TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
|
||||||
|
ON site_api_endpoints (site_id, url)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
|
||||||
|
ON site_api_endpoints (site_id, enabled, sort_order)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
|
||||||
|
ON site_api_endpoints (site_id, cooldown_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
downstream_path TEXT NOT NULL,
|
||||||
|
client_kind TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
trace_hint TEXT,
|
||||||
|
requested_model TEXT,
|
||||||
|
downstream_api_key_id INTEGER,
|
||||||
|
request_headers_json TEXT,
|
||||||
|
request_body_json TEXT,
|
||||||
|
sticky_session_key TEXT,
|
||||||
|
sticky_hit_channel_id INTEGER,
|
||||||
|
selected_channel_id INTEGER,
|
||||||
|
selected_route_id INTEGER,
|
||||||
|
selected_account_id INTEGER,
|
||||||
|
selected_site_id INTEGER,
|
||||||
|
selected_site_platform TEXT,
|
||||||
|
endpoint_candidates_json TEXT,
|
||||||
|
endpoint_runtime_state_json TEXT,
|
||||||
|
decision_summary_json TEXT,
|
||||||
|
final_status TEXT,
|
||||||
|
final_http_status INTEGER,
|
||||||
|
final_upstream_path TEXT,
|
||||||
|
final_response_headers_json TEXT,
|
||||||
|
final_response_body_json TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
|
||||||
|
ON proxy_debug_traces (created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
|
||||||
|
ON proxy_debug_traces (session_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
|
||||||
|
ON proxy_debug_traces (requested_model, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
|
||||||
|
ON proxy_debug_traces (final_status, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
trace_id INTEGER NOT NULL,
|
||||||
|
attempt_index INTEGER NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
request_path TEXT NOT NULL,
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
runtime_executor TEXT,
|
||||||
|
request_headers_json TEXT,
|
||||||
|
request_body_json TEXT,
|
||||||
|
response_status INTEGER,
|
||||||
|
response_headers_json TEXT,
|
||||||
|
response_body_json TEXT,
|
||||||
|
raw_error_text TEXT,
|
||||||
|
recover_applied INTEGER DEFAULT false,
|
||||||
|
downgrade_decision INTEGER DEFAULT false,
|
||||||
|
downgrade_reason TEXT,
|
||||||
|
memory_write_json TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
|
||||||
|
ON proxy_debug_attempts (trace_id, attempt_index)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
|
||||||
|
ON proxy_debug_attempts (trace_id, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS oauth_route_units (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
strategy TEXT NOT NULL DEFAULT 'round_robin',
|
||||||
|
enabled INTEGER DEFAULT true,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
|
||||||
|
ON oauth_route_units (site_id, provider)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
|
||||||
|
ON oauth_route_units (enabled)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
unit_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
success_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER DEFAULT 0,
|
||||||
|
total_cost REAL DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
last_selected_at TEXT,
|
||||||
|
last_fail_at TEXT,
|
||||||
|
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cooldown_level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cooldown_until TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
|
||||||
|
ON oauth_route_unit_members (unit_id, account_id)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
|
||||||
|
ON oauth_route_unit_members (account_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
|
||||||
|
ON oauth_route_unit_members (unit_id, sort_order)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
|
||||||
|
ON oauth_route_unit_members (unit_id, cooldown_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS admin_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
snapshot_key TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
stale_until TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
|
||||||
|
ON admin_snapshots (namespace, snapshot_key)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
|
||||||
|
ON admin_snapshots (expires_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
|
||||||
|
ON admin_snapshots (stale_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
|
||||||
|
projector_key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL DEFAULT 'Local',
|
||||||
|
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
watermark_created_at TEXT,
|
||||||
|
recompute_from_id INTEGER,
|
||||||
|
recompute_requested_at TEXT,
|
||||||
|
recompute_reason TEXT,
|
||||||
|
recompute_started_at TEXT,
|
||||||
|
recompute_completed_at TEXT,
|
||||||
|
last_projected_at TEXT,
|
||||||
|
last_successful_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_token TEXT,
|
||||||
|
lease_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
|
||||||
|
ON analytics_projection_checkpoints (recompute_from_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
|
||||||
|
ON analytics_projection_checkpoints (lease_expires_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_spend >= 0
|
||||||
|
AND total_latency_ms >= 0 AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
|
||||||
|
ON model_day_usage (local_day, site_id, model)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
|
||||||
|
ON model_day_usage (local_day)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
|
||||||
|
ON model_day_usage (site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
|
||||||
|
ON model_day_usage (model)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_summary_spend >= 0
|
||||||
|
AND total_site_spend >= 0 AND total_latency_ms >= 0
|
||||||
|
AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
|
||||||
|
ON site_day_usage (local_day, site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
|
||||||
|
ON site_day_usage (local_day)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
|
||||||
|
ON site_day_usage (site_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_hour_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
bucket_start_utc TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_summary_spend >= 0
|
||||||
|
AND total_site_spend >= 0 AND total_latency_ms >= 0
|
||||||
|
AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
|
||||||
|
ON site_hour_usage (bucket_start_utc, site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
|
||||||
|
ON site_hour_usage (bucket_start_utc)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
|
||||||
|
ON site_hour_usage (site_id)",
|
||||||
|
];
|
||||||
@@ -299,6 +299,200 @@ const TABLES: &[TableSpec] = &[
|
|||||||
col!("read_at", text),
|
col!("read_at", text),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_api_endpoints",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("url", text),
|
||||||
|
col!("enabled", bool),
|
||||||
|
col!("sort_order", i64),
|
||||||
|
col!("cooldown_until", text),
|
||||||
|
col!("last_selected_at", text),
|
||||||
|
col!("last_failed_at", text),
|
||||||
|
col!("last_failure_reason", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "oauth_route_units",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("provider", text),
|
||||||
|
col!("name", text),
|
||||||
|
col!("strategy", text),
|
||||||
|
col!("enabled", bool),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "oauth_route_unit_members",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("unit_id", i64),
|
||||||
|
col!("account_id", i64),
|
||||||
|
col!("sort_order", i64),
|
||||||
|
col!("success_count", i64),
|
||||||
|
col!("fail_count", i64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("total_cost", f64),
|
||||||
|
col!("last_used_at", text),
|
||||||
|
col!("last_selected_at", text),
|
||||||
|
col!("last_fail_at", text),
|
||||||
|
col!("consecutive_fail_count", i64),
|
||||||
|
col!("cooldown_level", i64),
|
||||||
|
col!("cooldown_until", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "proxy_debug_traces",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("downstream_path", text),
|
||||||
|
col!("client_kind", text),
|
||||||
|
col!("session_id", text),
|
||||||
|
col!("trace_hint", text),
|
||||||
|
col!("requested_model", text),
|
||||||
|
col!("downstream_api_key_id", i64),
|
||||||
|
col!("request_headers_json", text),
|
||||||
|
col!("request_body_json", text),
|
||||||
|
col!("sticky_session_key", text),
|
||||||
|
col!("sticky_hit_channel_id", i64),
|
||||||
|
col!("selected_channel_id", i64),
|
||||||
|
col!("selected_route_id", i64),
|
||||||
|
col!("selected_account_id", i64),
|
||||||
|
col!("selected_site_id", i64),
|
||||||
|
col!("selected_site_platform", text),
|
||||||
|
col!("endpoint_candidates_json", text),
|
||||||
|
col!("endpoint_runtime_state_json", text),
|
||||||
|
col!("decision_summary_json", text),
|
||||||
|
col!("final_status", text),
|
||||||
|
col!("final_http_status", i64),
|
||||||
|
col!("final_upstream_path", text),
|
||||||
|
col!("final_response_headers_json", text),
|
||||||
|
col!("final_response_body_json", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "proxy_debug_attempts",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("trace_id", i64),
|
||||||
|
col!("attempt_index", i64),
|
||||||
|
col!("endpoint", text),
|
||||||
|
col!("request_path", text),
|
||||||
|
col!("target_url", text),
|
||||||
|
col!("runtime_executor", text),
|
||||||
|
col!("request_headers_json", text),
|
||||||
|
col!("request_body_json", text),
|
||||||
|
col!("response_status", i64),
|
||||||
|
col!("response_headers_json", text),
|
||||||
|
col!("response_body_json", text),
|
||||||
|
col!("raw_error_text", text),
|
||||||
|
col!("recover_applied", bool),
|
||||||
|
col!("downgrade_decision", bool),
|
||||||
|
col!("downgrade_reason", text),
|
||||||
|
col!("memory_write_json", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "admin_snapshots",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("namespace", text),
|
||||||
|
col!("snapshot_key", text),
|
||||||
|
col!("payload", text),
|
||||||
|
col!("generated_at", text),
|
||||||
|
col!("expires_at", text),
|
||||||
|
col!("stale_until", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "analytics_projection_checkpoints",
|
||||||
|
columns: &[
|
||||||
|
col!("projector_key", text),
|
||||||
|
col!("time_zone", text),
|
||||||
|
col!("last_proxy_log_id", i64),
|
||||||
|
col!("watermark_created_at", text),
|
||||||
|
col!("recompute_from_id", i64),
|
||||||
|
col!("recompute_requested_at", text),
|
||||||
|
col!("recompute_reason", text),
|
||||||
|
col!("recompute_started_at", text),
|
||||||
|
col!("recompute_completed_at", text),
|
||||||
|
col!("last_projected_at", text),
|
||||||
|
col!("last_successful_at", text),
|
||||||
|
col!("last_error", text),
|
||||||
|
col!("lease_owner", text),
|
||||||
|
col!("lease_token", text),
|
||||||
|
col!("lease_expires_at", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "model_day_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("local_day", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("model", text),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_day_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("local_day", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_summary_spend", f64),
|
||||||
|
col!("total_site_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_hour_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("bucket_start_utc", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_summary_spend", f64),
|
||||||
|
col!("total_site_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub async fn migrate(
|
pub async fn migrate(
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
db::run_migrations(db_kind, Some(&pool), None).await?;
|
db::run_migrations(db_kind, Some(&pool), None).await?;
|
||||||
|
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
|
||||||
tracing::info!("Database migrations applied");
|
tracing::info!("Database migrations applied");
|
||||||
|
|
||||||
if args.migrate_only {
|
if args.migrate_only {
|
||||||
|
|||||||
+214
-4
@@ -25,6 +25,15 @@ pub struct SiteDistributionQuery {
|
|||||||
_refresh: Option<String>,
|
_refresh: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ProxyDebugTraceQuery {
|
||||||
|
limit: Option<i64>,
|
||||||
|
offset: Option<i64>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/stats/dashboard", get(dashboard))
|
.route("/api/stats/dashboard", get(dashboard))
|
||||||
@@ -523,14 +532,215 @@ async fn proxy_log_detail(
|
|||||||
Ok(Json(json!({ "item": item, "data": item })))
|
Ok(Json(json!({ "item": item, "data": item })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy_debug_traces() -> Json<Value> {
|
async fn proxy_debug_traces(
|
||||||
Json(json!({ "items": [], "data": [], "total": 0 }))
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<ProxyDebugTraceQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let limit = q.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let offset = q.offset.unwrap_or(0).max(0);
|
||||||
|
let mut where_sql = Vec::new();
|
||||||
|
let mut bind_values = Vec::new();
|
||||||
|
|
||||||
|
if let Some(session_id) = q
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
|
where_sql.push("session_id = ?");
|
||||||
|
bind_values.push(session_id.to_string());
|
||||||
|
}
|
||||||
|
if let Some(model) = q.model.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
where_sql.push("requested_model LIKE ?");
|
||||||
|
bind_values.push(format!("%{model}%"));
|
||||||
|
}
|
||||||
|
if let Some(status) = q.status.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
where_sql.push("final_status = ?");
|
||||||
|
bind_values.push(status.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = if where_sql.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" WHERE {}", where_sql.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let list_sql = format!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, downstream_path, client_kind, session_id, trace_hint,
|
||||||
|
requested_model, downstream_api_key_id, sticky_session_key,
|
||||||
|
sticky_hit_channel_id, selected_channel_id, selected_route_id,
|
||||||
|
selected_account_id, selected_site_id, selected_site_platform,
|
||||||
|
final_status, final_http_status, final_upstream_path,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM proxy_debug_traces
|
||||||
|
{filter}
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let mut list_query = sqlx::query_as::<_, ProxyDebugTraceRow>(&list_sql);
|
||||||
|
for value in &bind_values {
|
||||||
|
list_query = list_query.bind(value);
|
||||||
|
}
|
||||||
|
let items: Vec<Value> = list_query
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_sql = format!("SELECT COUNT(*) FROM proxy_debug_traces{filter}");
|
||||||
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
|
for value in &bind_values {
|
||||||
|
count_query = count_query.bind(value);
|
||||||
|
}
|
||||||
|
let total = count_query.fetch_one(&*state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"items": items,
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
|
"page": (offset / limit) + 1,
|
||||||
|
"pageSize": limit,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy_debug_trace_detail(
|
async fn proxy_debug_trace_detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
axum::extract::Path(id): axum::extract::Path<i64>,
|
axum::extract::Path(id): axum::extract::Path<i64>,
|
||||||
) -> Json<Value> {
|
) -> AppResult<Json<Value>> {
|
||||||
Json(json!({ "id": id, "events": [], "data": null }))
|
let trace: Option<ProxyDebugTraceDetailRow> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, downstream_path, client_kind, session_id, trace_hint,
|
||||||
|
requested_model, downstream_api_key_id, request_headers_json,
|
||||||
|
request_body_json, sticky_session_key, sticky_hit_channel_id,
|
||||||
|
selected_channel_id, selected_route_id, selected_account_id,
|
||||||
|
selected_site_id, selected_site_platform, endpoint_candidates_json,
|
||||||
|
endpoint_runtime_state_json, decision_summary_json, final_status,
|
||||||
|
final_http_status, final_upstream_path, final_response_headers_json,
|
||||||
|
final_response_body_json, created_at, updated_at
|
||||||
|
FROM proxy_debug_traces
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(trace) = trace else {
|
||||||
|
return Err(crate::error::AppError::NotFound(
|
||||||
|
"proxy debug trace not found".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let attempts: Vec<Value> = sqlx::query_as::<_, ProxyDebugAttemptRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, trace_id, attempt_index, endpoint, request_path, target_url,
|
||||||
|
runtime_executor, request_headers_json, request_body_json,
|
||||||
|
response_status, response_headers_json, response_body_json,
|
||||||
|
raw_error_text, recover_applied, downgrade_decision,
|
||||||
|
downgrade_reason, memory_write_json, created_at
|
||||||
|
FROM proxy_debug_attempts
|
||||||
|
WHERE trace_id = ?
|
||||||
|
ORDER BY attempt_index ASC, id ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let data = serde_json::to_value(trace).unwrap_or_else(|_| json!({}));
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": id,
|
||||||
|
"item": data,
|
||||||
|
"data": data,
|
||||||
|
"attempts": attempts,
|
||||||
|
"events": attempts,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ProxyDebugTraceRow {
|
||||||
|
id: i64,
|
||||||
|
downstream_path: String,
|
||||||
|
client_kind: Option<String>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
trace_hint: Option<String>,
|
||||||
|
requested_model: Option<String>,
|
||||||
|
downstream_api_key_id: Option<i64>,
|
||||||
|
sticky_session_key: Option<String>,
|
||||||
|
sticky_hit_channel_id: Option<i64>,
|
||||||
|
selected_channel_id: Option<i64>,
|
||||||
|
selected_route_id: Option<i64>,
|
||||||
|
selected_account_id: Option<i64>,
|
||||||
|
selected_site_id: Option<i64>,
|
||||||
|
selected_site_platform: Option<String>,
|
||||||
|
final_status: Option<String>,
|
||||||
|
final_http_status: Option<i64>,
|
||||||
|
final_upstream_path: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ProxyDebugTraceDetailRow {
|
||||||
|
id: i64,
|
||||||
|
downstream_path: String,
|
||||||
|
client_kind: Option<String>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
trace_hint: Option<String>,
|
||||||
|
requested_model: Option<String>,
|
||||||
|
downstream_api_key_id: Option<i64>,
|
||||||
|
request_headers_json: Option<String>,
|
||||||
|
request_body_json: Option<String>,
|
||||||
|
sticky_session_key: Option<String>,
|
||||||
|
sticky_hit_channel_id: Option<i64>,
|
||||||
|
selected_channel_id: Option<i64>,
|
||||||
|
selected_route_id: Option<i64>,
|
||||||
|
selected_account_id: Option<i64>,
|
||||||
|
selected_site_id: Option<i64>,
|
||||||
|
selected_site_platform: Option<String>,
|
||||||
|
endpoint_candidates_json: Option<String>,
|
||||||
|
endpoint_runtime_state_json: Option<String>,
|
||||||
|
decision_summary_json: Option<String>,
|
||||||
|
final_status: Option<String>,
|
||||||
|
final_http_status: Option<i64>,
|
||||||
|
final_upstream_path: Option<String>,
|
||||||
|
final_response_headers_json: Option<String>,
|
||||||
|
final_response_body_json: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ProxyDebugAttemptRow {
|
||||||
|
id: i64,
|
||||||
|
trace_id: i64,
|
||||||
|
attempt_index: i64,
|
||||||
|
endpoint: String,
|
||||||
|
request_path: String,
|
||||||
|
target_url: String,
|
||||||
|
runtime_executor: Option<String>,
|
||||||
|
request_headers_json: Option<String>,
|
||||||
|
request_body_json: Option<String>,
|
||||||
|
response_status: Option<i64>,
|
||||||
|
response_headers_json: Option<String>,
|
||||||
|
response_body_json: Option<String>,
|
||||||
|
raw_error_text: Option<String>,
|
||||||
|
recover_applied: Option<bool>,
|
||||||
|
downgrade_decision: Option<bool>,
|
||||||
|
downgrade_reason: Option<String>,
|
||||||
|
memory_write_json: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/stats/model-by-site?siteId=...&days=...
|
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||||
|
|||||||
+468
-1
@@ -2,8 +2,9 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
services::shared::{enforce_key_request_limit, increment_key_usage},
|
services::shared::{enforce_key_request_limit, increment_key_usage},
|
||||||
};
|
};
|
||||||
|
use chrono::Timelike;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::{FromRow, Row, SqlitePool};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -152,6 +153,215 @@ impl UsageTracker {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn project_proxy_usage_for_db(db: &SqlitePool) -> AppResult<i64> {
|
||||||
|
Self::ensure_schema(db).await?;
|
||||||
|
let last_id: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(last_proxy_log_id, 0)
|
||||||
|
FROM analytics_projection_checkpoints
|
||||||
|
WHERE projector_key = 'usage-aggregates-v1'",
|
||||||
|
)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.created_at,
|
||||||
|
l.status,
|
||||||
|
l.latency_ms,
|
||||||
|
l.total_tokens,
|
||||||
|
l.estimated_cost,
|
||||||
|
COALESCE(NULLIF(l.model_actual, ''), NULLIF(l.model_requested, ''), 'unknown') AS model,
|
||||||
|
a.site_id,
|
||||||
|
s.platform
|
||||||
|
FROM proxy_logs l
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE l.id > ?
|
||||||
|
ORDER BY l.id ASC
|
||||||
|
LIMIT 5000
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(last_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
touch_projection_checkpoint(db, last_id, None).await?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut max_id = last_id;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let id: i64 = row.try_get("id").unwrap_or(0);
|
||||||
|
max_id = max_id.max(id);
|
||||||
|
let site_id: Option<i64> = row.try_get("site_id").ok().flatten();
|
||||||
|
let Some(site_id) = site_id.filter(|value| *value > 0) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let created_at: Option<String> = row.try_get("created_at").ok().flatten();
|
||||||
|
let Some(created_at) = created_at else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let local_day = sqlite_day_key(&created_at);
|
||||||
|
let bucket_start_utc = sqlite_hour_key(&created_at);
|
||||||
|
let status: Option<String> = row.try_get("status").ok().flatten();
|
||||||
|
let is_success = status
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.eq_ignore_ascii_case("success");
|
||||||
|
let latency_ms = non_negative_i64(row.try_get("latency_ms").ok().flatten());
|
||||||
|
let latency_count = i64::from(latency_ms > 0);
|
||||||
|
let total_tokens = non_negative_i64(row.try_get("total_tokens").ok().flatten());
|
||||||
|
let estimated_cost = non_negative_f64(row.try_get("estimated_cost").ok().flatten());
|
||||||
|
let platform: Option<String> = row.try_get("platform").ok().flatten();
|
||||||
|
let model: String = row
|
||||||
|
.try_get::<Option<String>, _>("model")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let summary_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else if platform
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("veloera")
|
||||||
|
{
|
||||||
|
total_tokens as f64 / 1_000_000.0
|
||||||
|
} else {
|
||||||
|
total_tokens as f64 / 500_000.0
|
||||||
|
};
|
||||||
|
let site_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else {
|
||||||
|
fallback_token_cost(total_tokens, platform.as_deref())
|
||||||
|
};
|
||||||
|
let model_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else {
|
||||||
|
total_tokens as f64 / 500_000.0
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO site_day_usage
|
||||||
|
(local_day, site_id, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_summary_spend, total_site_spend,
|
||||||
|
total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, site_id) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
|
||||||
|
total_site_spend = total_site_spend + excluded.total_site_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&local_day)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(summary_spend)
|
||||||
|
.bind(site_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO site_hour_usage
|
||||||
|
(bucket_start_utc, site_id, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_summary_spend, total_site_spend,
|
||||||
|
total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(bucket_start_utc, site_id) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
|
||||||
|
total_site_spend = total_site_spend + excluded.total_site_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&bucket_start_utc)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(summary_spend)
|
||||||
|
.bind(site_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO model_day_usage
|
||||||
|
(local_day, site_id, model, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_spend, total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, site_id, model) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_spend = total_spend + excluded.total_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&local_day)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(&model)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(model_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO analytics_projection_checkpoints
|
||||||
|
(projector_key, time_zone, last_proxy_log_id, watermark_created_at,
|
||||||
|
last_projected_at, last_successful_at, last_error, updated_at)
|
||||||
|
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), datetime('now'), NULL, datetime('now'))
|
||||||
|
ON CONFLICT(projector_key) DO UPDATE SET
|
||||||
|
last_proxy_log_id = excluded.last_proxy_log_id,
|
||||||
|
watermark_created_at = excluded.watermark_created_at,
|
||||||
|
last_projected_at = excluded.last_projected_at,
|
||||||
|
last_successful_at = excluded.last_successful_at,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(max_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(max_id - last_id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS usage_logs (
|
"CREATE TABLE IF NOT EXISTS usage_logs (
|
||||||
@@ -190,10 +400,179 @@ impl UsageTracker {
|
|||||||
)
|
)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
|
||||||
|
projector_key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL DEFAULT 'Local',
|
||||||
|
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
watermark_created_at TEXT,
|
||||||
|
recompute_from_id INTEGER,
|
||||||
|
recompute_requested_at TEXT,
|
||||||
|
recompute_reason TEXT,
|
||||||
|
recompute_started_at TEXT,
|
||||||
|
recompute_completed_at TEXT,
|
||||||
|
last_projected_at TEXT,
|
||||||
|
last_successful_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_token TEXT,
|
||||||
|
lease_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, site_id, model)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, site_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_hour_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
bucket_start_utc TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(bucket_start_utc, site_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn touch_projection_checkpoint(
|
||||||
|
db: &SqlitePool,
|
||||||
|
last_proxy_log_id: i64,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO analytics_projection_checkpoints
|
||||||
|
(projector_key, time_zone, last_proxy_log_id, last_projected_at,
|
||||||
|
last_successful_at, last_error, updated_at)
|
||||||
|
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), ?, datetime('now'))
|
||||||
|
ON CONFLICT(projector_key) DO UPDATE SET
|
||||||
|
last_projected_at = datetime('now'),
|
||||||
|
last_successful_at = CASE WHEN ? IS NULL THEN datetime('now') ELSE last_successful_at END,
|
||||||
|
last_error = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(last_proxy_log_id)
|
||||||
|
.bind(error)
|
||||||
|
.bind(error)
|
||||||
|
.bind(error)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_negative_i64(value: Option<i64>) -> i64 {
|
||||||
|
value.unwrap_or(0).max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_negative_f64(value: Option<f64>) -> f64 {
|
||||||
|
value.unwrap_or(0.0).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_day_key(created_at: &str) -> String {
|
||||||
|
parse_sqlite_datetime(created_at)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| created_at.chars().take(10).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_hour_key(created_at: &str) -> String {
|
||||||
|
parse_sqlite_datetime(created_at)
|
||||||
|
.map(|dt| {
|
||||||
|
dt.with_minute(0)
|
||||||
|
.and_then(|dt| dt.with_second(0))
|
||||||
|
.and_then(|dt| dt.with_nanosecond(0))
|
||||||
|
.unwrap_or(dt)
|
||||||
|
.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let prefix: String = created_at.chars().take(13).collect();
|
||||||
|
if prefix.len() == 13 {
|
||||||
|
format!("{prefix}:00:00")
|
||||||
|
} else {
|
||||||
|
created_at.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sqlite_datetime(value: &str) -> Option<chrono::NaiveDateTime> {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(value)
|
||||||
|
.ok()
|
||||||
|
.map(|dt| dt.naive_utc())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_token_cost(tokens: i64, platform: Option<&str>) -> f64 {
|
||||||
|
if tokens <= 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
match platform
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"veloera" => tokens as f64 / 1_000_000.0,
|
||||||
|
_ => tokens as f64 / 500_000.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -282,4 +661,92 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn projects_proxy_logs_to_site_and_model_usage() {
|
||||||
|
let pool = pool().await;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE sites (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
platform TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE proxy_logs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER,
|
||||||
|
model_requested TEXT,
|
||||||
|
model_actual TEXT,
|
||||||
|
status TEXT,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
estimated_cost REAL,
|
||||||
|
created_at TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO sites (id, name, platform) VALUES (10, 'Site', 'new-api')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO accounts (id, site_id) VALUES (20, 10)")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proxy_logs
|
||||||
|
(id, account_id, model_requested, model_actual, status, latency_ms, total_tokens, estimated_cost, created_at)
|
||||||
|
VALUES
|
||||||
|
(1, 20, 'gpt-test', NULL, 'success', 120, 1000, 0.12, '2026-06-06 08:12:30')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let projected = UsageTracker::project_proxy_usage_for_db(&pool).await.unwrap();
|
||||||
|
assert_eq!(projected, 1);
|
||||||
|
|
||||||
|
let model_usage: (i64, i64, f64, i64) = sqlx::query_as(
|
||||||
|
"SELECT total_calls, total_tokens, total_spend, total_latency_ms
|
||||||
|
FROM model_day_usage
|
||||||
|
WHERE site_id = 10 AND model = 'gpt-test'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(model_usage, (1, 1000, 0.12, 120));
|
||||||
|
|
||||||
|
let site_usage: (i64, i64, f64) = sqlx::query_as(
|
||||||
|
"SELECT total_calls, success_calls, total_site_spend
|
||||||
|
FROM site_day_usage
|
||||||
|
WHERE site_id = 10 AND local_day = '2026-06-06'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(site_usage, (1, 1, 0.12));
|
||||||
|
|
||||||
|
let hour_bucket: String = sqlx::query_scalar(
|
||||||
|
"SELECT bucket_start_utc FROM site_hour_usage WHERE site_id = 10",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hour_bucket, "2026-06-06 08:00:00");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ impl CronScheduler {
|
|||||||
|
|
||||||
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
||||||
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
|
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
|
||||||
|
let projected = UsageTracker::project_proxy_usage_for_db(&db).await?;
|
||||||
|
if projected > 0 {
|
||||||
|
tracing::debug!(projected, "usage aggregates projected");
|
||||||
|
}
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE downstream_api_keys
|
"UPDATE downstream_api_keys
|
||||||
SET updated_at = datetime('now')
|
SET updated_at = datetime('now')
|
||||||
|
|||||||
Reference in New Issue
Block a user