From d3a4b92d3f5005bebf93a9e1e1fcfdef6b94e7aa Mon Sep 17 00:00:00 2001
From: zyycn
Date: Sun, 28 Jun 2026 23:13:17 +0800
Subject: [PATCH] feat: add usage records analytics
Co-authored-by: Codex
---
README.md | 12 +-
src/admin/accounts/service/mod.rs | 17 +-
src/admin/accounts/service/testing.rs | 2 +
src/admin/monitoring/billing.rs | 60 +-
src/admin/monitoring/dashboard.rs | 64 +-
src/admin/monitoring/event_store.rs | 743 ----------
src/admin/monitoring/logs.rs | 216 ---
src/admin/monitoring/mod.rs | 6 +-
.../monitoring/{events.rs => usage_record.rs} | 32 +-
src/admin/monitoring/usage_record_store.rs | 1264 +++++++++++++++++
src/admin/monitoring/usage_records.rs | 828 +++++++++++
src/admin/router.rs | 19 +-
src/admin/settings/routes.rs | 6 +
src/config/settings.rs | 34 +-
src/http/middleware/request_id.rs | 29 +-
src/infra/schema.sql | 30 +-
src/proxy/dispatch/chat.rs | 105 +-
src/proxy/dispatch/responses.rs | 281 +++-
src/proxy/openai/chat.rs | 1 +
src/proxy/openai/responses.rs | 69 +-
src/runtime/bootstrap.rs | 13 +-
src/runtime/services.rs | 61 +-
src/runtime/tasks/coordinator.rs | 10 +-
src/runtime/tasks/token_refresh.rs | 6 +-
src/upstream/accounts/token_refresh/mod.rs | 81 +-
.../accounts/token_refresh/runtime.rs | 24 +-
src/upstream/models/mod.rs | 39 +-
src/upstream/protocol/responses.rs | 14 +
tests/admin/accounts/importing.rs | 6 +-
tests/admin/accounts/lifecycle.rs | 6 +-
tests/admin/accounts/list.rs | 2 +-
tests/admin/accounts/mod.rs | 4 +-
tests/admin/auth/session.rs | 20 +-
tests/admin/keys/mod.rs | 4 +-
tests/admin/monitoring/dashboard.rs | 78 +-
tests/admin/monitoring/logs_routes.rs | 274 ----
tests/admin/monitoring/mod.rs | 4 +-
...{events_store.rs => usage_record_store.rs} | 60 +-
.../admin/monitoring/usage_records_routes.rs | 601 ++++++++
tests/admin/settings/routes.rs | 158 ++-
tests/http/trace_middleware/mod.rs | 60 +
tests/http/web_assets/mod.rs | 7 +-
tests/infra/storage_schema/mod.rs | 22 +-
.../dispatch/chat_upstream/chat_routes.rs | 6 +-
.../dispatch/chat_upstream/compact_routes.rs | 10 +-
tests/proxy/dispatch/chat_upstream/mod.rs | 66 +-
.../dispatch/chat_upstream/responses_http.rs | 2 +-
.../chat_upstream/responses_recovery.rs | 4 +-
.../chat_upstream/responses_websocket.rs | 2 +-
.../dispatch/chat_upstream/usage_logging.rs | 71 +-
tests/proxy/openai/models_routes.rs | 6 +-
tests/proxy/openai/responses_routes.rs | 4 +-
tests/runtime/services.rs | 4 +-
tests/upstream/transport/headers.rs | 2 +
web/docs/frontend-optimization-plan.md | 120 --
web/src/api/index.ts | 4 +-
web/src/api/modules/logs.ts | 24 -
web/src/api/modules/usage.ts | 33 +
web/src/components/base/BasePopover.vue | 236 ++-
web/src/components/base/BaseScrollbar.vue | 177 ++-
web/src/components/base/BaseTable/columns.ts | 2 +-
web/src/components/base/BaseTable/index.vue | 33 +-
.../components/base/BaseTable/pagination.ts | 6 +-
web/src/components/charts/BaseChart.vue | 34 +-
web/src/layout/components/AppSidebar.vue | 4 +-
web/src/plugins/echarts.ts | 6 +-
web/src/router/routes.ts | 8 +-
web/src/stores/modules/ui.ts | 3 +
web/src/views/accounts/index.vue | 2 +-
web/src/views/api-keys/index.vue | 5 +-
.../dashboard/components/EventLogCard.vue | 116 --
.../dashboard/components/RequestTrendCard.vue | 5 +-
.../dashboard/components/UsageRecordCard.vue | 102 ++
.../dashboard/composables/useDashboard.ts | 41 +-
web/src/views/dashboard/index.vue | 6 +-
.../views/logs/components/LogDetailModal.vue | 86 --
.../views/logs/composables/useLogsTable.ts | 90 --
web/src/views/logs/constants.ts | 77 -
web/src/views/logs/index.vue | 136 --
.../usage/components/UsageClientIpCell.vue | 22 +
.../views/usage/components/UsageCostCell.vue | 88 ++
.../components/UsageDistributionPanel.vue | 230 +++
.../components/UsageFilters.vue} | 29 +-
.../usage/components/UsageInsightsGrid.vue | 371 +++++
.../components/UsageLevelBadge.vue} | 0
.../views/usage/components/UsageModelCell.vue | 31 +
.../components/UsageRecordDetailModal.vue | 422 ++++++
.../components/UsageStatusCodeBadge.vue} | 0
.../usage/components/UsageSummaryCards.vue | 90 ++
.../views/usage/components/UsageTokenCell.vue | 75 +
.../composables/useUsageFilters.ts} | 32 +-
.../composables/useUsageRecordDetail.ts} | 14 +-
.../usage/composables/useUsageRecordsTable.ts | 131 ++
web/src/views/usage/constants.ts | 334 +++++
web/src/views/usage/index.vue | 221 +++
web/vite.config.ts | 9 +-
96 files changed, 6564 insertions(+), 2440 deletions(-)
delete mode 100644 src/admin/monitoring/event_store.rs
delete mode 100644 src/admin/monitoring/logs.rs
rename src/admin/monitoring/{events.rs => usage_record.rs} (77%)
create mode 100644 src/admin/monitoring/usage_record_store.rs
create mode 100644 src/admin/monitoring/usage_records.rs
delete mode 100644 tests/admin/monitoring/logs_routes.rs
rename tests/admin/monitoring/{events_store.rs => usage_record_store.rs} (56%)
create mode 100644 tests/admin/monitoring/usage_records_routes.rs
delete mode 100644 web/docs/frontend-optimization-plan.md
delete mode 100644 web/src/api/modules/logs.ts
create mode 100644 web/src/api/modules/usage.ts
delete mode 100644 web/src/views/dashboard/components/EventLogCard.vue
create mode 100644 web/src/views/dashboard/components/UsageRecordCard.vue
delete mode 100644 web/src/views/logs/components/LogDetailModal.vue
delete mode 100644 web/src/views/logs/composables/useLogsTable.ts
delete mode 100644 web/src/views/logs/constants.ts
delete mode 100644 web/src/views/logs/index.vue
create mode 100644 web/src/views/usage/components/UsageClientIpCell.vue
create mode 100644 web/src/views/usage/components/UsageCostCell.vue
create mode 100644 web/src/views/usage/components/UsageDistributionPanel.vue
rename web/src/views/{logs/components/LogFilters.vue => usage/components/UsageFilters.vue} (66%)
create mode 100644 web/src/views/usage/components/UsageInsightsGrid.vue
rename web/src/views/{logs/components/LogLevelBadge.vue => usage/components/UsageLevelBadge.vue} (100%)
create mode 100644 web/src/views/usage/components/UsageModelCell.vue
create mode 100644 web/src/views/usage/components/UsageRecordDetailModal.vue
rename web/src/views/{logs/components/LogStatusCodeBadge.vue => usage/components/UsageStatusCodeBadge.vue} (100%)
create mode 100644 web/src/views/usage/components/UsageSummaryCards.vue
create mode 100644 web/src/views/usage/components/UsageTokenCell.vue
rename web/src/views/{logs/composables/useLogFilters.ts => usage/composables/useUsageFilters.ts} (51%)
rename web/src/views/{logs/composables/useLogDetail.ts => usage/composables/useUsageRecordDetail.ts} (50%)
create mode 100644 web/src/views/usage/composables/useUsageRecordsTable.ts
create mode 100644 web/src/views/usage/constants.ts
create mode 100644 web/src/views/usage/index.vue
diff --git a/README.md b/README.md
index fa7737cd5..dc8d05dbd 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
/v1/responses · /v1/chat/completions · /v1/models · 管理控制台
-Codex Proxy RS 是一个面向 ChatGPT/Codex 账号池的 Rust 模型网关。它把上游 Codex 能力整理成 OpenAI 兼容接口,同时提供账号调度、配额感知、请求追踪、事件日志和管理控制台。
+Codex Proxy RS 是一个面向 ChatGPT/Codex 账号池的 Rust 模型网关。它把上游 Codex 能力整理成 OpenAI 兼容接口,同时提供账号调度、配额感知、使用记录和管理控制台。
它不是一个臃肿的平台,也不是只做转发的薄代理。它更像一层安静但可靠的网关:把账号、模型、配额、Cookie、WebSocket、SSE 和错误恢复放在后端统一处理,让调用方只需要面对稳定、清晰的 OpenAI 风格接口。
@@ -26,7 +26,7 @@ Codex Proxy RS 关注这些细节:
- 请求应该落到哪个账号,是否要保持会话亲和性
- 上游返回的是 HTTP SSE、WebSocket 还是错误事件
- Cookie、fingerprint、headers 和 Codex Desktop 行为是否足够接近
-- 管理端能否看清请求、用量、事件、延迟和失败原因
+- 管理端能否看清请求、用量、延迟和失败原因
## 核心能力
@@ -51,9 +51,9 @@ Codex Proxy RS 关注这些细节:
**可观测与管理**
-- 管理控制台内置账号、密钥、事件日志、用量统计和系统设置
+- 管理控制台内置账号、密钥、使用记录、用量统计和系统设置
- SQLite 持久化,账号、Cookie 与访问密钥采用统一的本地存储策略
-- 结构化事件日志,便于定位请求失败、上游状态和调度路径
+- 结构化使用记录,便于定位请求失败、上游状态和调度路径
## 快速开始
@@ -80,7 +80,7 @@ admin:
## 管理控制台
-前端位于 `web/`,用于账号管理、密钥管理、事件日志、仪表盘和系统设置。
+前端位于 `web/`,用于账号管理、密钥管理、使用记录、仪表盘和系统设置。
开发模式:
@@ -143,7 +143,7 @@ flowchart LR
http --> admin[admin
管理端 API]
admin --> runtime[runtime
启动装配、状态和后台任务]
runtime --> infra[infra
SQLite、路径和日志]
- proxy --> telemetry[telemetry
事件日志和用量记录]
+ proxy --> telemetry[telemetry
使用记录和用量统计]
admin --> telemetry
http --> web[web
管理控制台静态资源]
```
diff --git a/src/admin/accounts/service/mod.rs b/src/admin/accounts/service/mod.rs
index 1f5836a44..33ae02be4 100644
--- a/src/admin/accounts/service/mod.rs
+++ b/src/admin/accounts/service/mod.rs
@@ -15,8 +15,10 @@ use chrono::{DateTime, Duration};
use crate::{
upstream::accounts::{
- cookies::SqliteCookieStore, pool::RuntimeAccountPoolService, store::SqliteAccountStore,
- token_refresh::TokenRefresher,
+ cookies::SqliteCookieStore,
+ pool::RuntimeAccountPoolService,
+ store::SqliteAccountStore,
+ token_refresh::{RuntimeRefreshPolicy, TokenRefresher},
},
upstream::transport::CodexBackendClient,
};
@@ -35,7 +37,7 @@ pub struct AdminAccountService {
pub(crate) account_pool: StdArc,
pub(crate) token_refresher: StdArc,
pub(crate) oauth: oauth::AccountOAuthService,
- pub(crate) refresh_margin_seconds: u64,
+ pub(crate) refresh_policy: RuntimeRefreshPolicy,
pub(crate) installation_id: Option,
}
@@ -46,7 +48,7 @@ pub struct AdminAccountServiceParts {
pub account_pool: StdArc,
pub token_refresher: StdArc,
pub oauth: oauth::AccountOAuthService,
- pub refresh_margin_seconds: u64,
+ pub refresh_policy: RuntimeRefreshPolicy,
pub installation_id: Option,
}
@@ -59,7 +61,7 @@ impl AdminAccountService {
account_pool: parts.account_pool,
token_refresher: parts.token_refresher,
oauth: parts.oauth,
- refresh_margin_seconds: parts.refresh_margin_seconds,
+ refresh_policy: parts.refresh_policy,
installation_id: parts.installation_id,
}
}
@@ -68,7 +70,10 @@ impl AdminAccountService {
&self,
expires_at: DateTime,
) -> DateTime {
- let margin_seconds = self.refresh_margin_seconds.min(i64::MAX as u64) as i64;
+ let margin_seconds = self
+ .refresh_policy
+ .refresh_margin_seconds()
+ .min(i64::MAX as u64) as i64;
expires_at - Duration::seconds(margin_seconds)
}
diff --git a/src/admin/accounts/service/testing.rs b/src/admin/accounts/service/testing.rs
index 276c5e788..c48d2785d 100644
--- a/src/admin/accounts/service/testing.rs
+++ b/src/admin/accounts/service/testing.rs
@@ -204,6 +204,8 @@ fn test_responses_request(model: String) -> CodexResponsesRequest {
variant_identity: None,
include: None,
client_metadata: None,
+ client_ip: None,
+ client_user_agent: None,
use_websocket: false,
force_http_sse: true,
turn_state: None,
diff --git a/src/admin/monitoring/billing.rs b/src/admin/monitoring/billing.rs
index 6dbb1f413..8593a248c 100644
--- a/src/admin/monitoring/billing.rs
+++ b/src/admin/monitoring/billing.rs
@@ -150,14 +150,37 @@ const MODEL_PRICING_RULES: &[ModelPricingRule] = &[
},
];
-/// 按 codex2api 的 billing 引擎口径计算美元计费。
-pub(in crate::admin) fn calculate_cost(
+/// 单次请求计费明细。
+#[derive(Debug, Clone, PartialEq)]
+pub(in crate::admin) struct CostBreakdown {
+ /// 输入成本。
+ pub input_cost: f64,
+ /// 输出成本。
+ pub output_cost: f64,
+ /// 缓存读取成本。
+ pub cache_read_cost: f64,
+ /// 总成本。
+ pub total_cost: f64,
+ /// 输入单价,美元 / 1M token。
+ pub input_price_per_mtoken: f64,
+ /// 输出单价,美元 / 1M token。
+ pub output_price_per_mtoken: f64,
+ /// 缓存读取单价,美元 / 1M token。
+ pub cache_read_price_per_mtoken: f64,
+ /// 服务档位。
+ pub service_tier: Option,
+ /// 服务档位倍率。
+ pub tier_multiplier: f64,
+}
+
+/// 按 codex2api 的 billing 引擎口径计算美元计费明细。
+pub(in crate::admin) fn calculate_cost_breakdown(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
model: &str,
service_tier: Option<&str>,
-) -> f64 {
+) -> CostBreakdown {
let pricing = model_pricing(model);
let is_long = input_tokens > LONG_CONTEXT_THRESHOLD;
@@ -202,8 +225,37 @@ pub(in crate::admin) fn calculate_cost(
let input_cost = uncached_input_tokens as f64 / 1_000_000.0 * input_price;
let cache_read_cost = cached_tokens as f64 / 1_000_000.0 * cache_read_price;
let output_cost = output_tokens as f64 / 1_000_000.0 * output_price;
+ let total_cost = (input_cost + cache_read_cost + output_cost) * tier_multiplier;
- (input_cost + cache_read_cost + output_cost) * tier_multiplier
+ CostBreakdown {
+ input_cost,
+ output_cost,
+ cache_read_cost,
+ total_cost,
+ input_price_per_mtoken: input_price,
+ output_price_per_mtoken: output_price,
+ cache_read_price_per_mtoken: cache_read_price,
+ service_tier: normalize_service_tier(service_tier),
+ tier_multiplier,
+ }
+}
+
+/// 按 codex2api 的 billing 引擎口径计算美元计费。
+pub(in crate::admin) fn calculate_cost(
+ input_tokens: u64,
+ output_tokens: u64,
+ cached_tokens: u64,
+ model: &str,
+ service_tier: Option<&str>,
+) -> f64 {
+ calculate_cost_breakdown(
+ input_tokens,
+ output_tokens,
+ cached_tokens,
+ model,
+ service_tier,
+ )
+ .total_cost
}
fn model_pricing(model: &str) -> ModelPricing {
diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs
index 2d7891759..840d93487 100644
--- a/src/admin/monitoring/dashboard.rs
+++ b/src/admin/monitoring/dashboard.rs
@@ -19,21 +19,21 @@ use crate::{
diagnostics::{
fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics,
},
- event_store::AdminLogFilter,
- events::{EventLevel, EventLog},
service::{AdminUsageRecord, AdminUsageSummary, AdminUsageTimeBucketRecord},
+ usage_record_store::AdminUsageRecordFilter,
+ usage_records::{account_email_map, usage_record_items, UsageRecordData},
},
response::{AdminEnvelope, AdminError, AdminResponse},
},
infra::time::{
china_datetime, china_datetime_rfc3339_str, china_day_start, china_hour, china_hour_start,
- china_quarter_hour_start, china_relative_time, china_time,
+ china_quarter_hour_start, china_relative_time,
},
runtime::state::AppState,
upstream::accounts::model::{Account, AccountStatus},
};
-const DASHBOARD_LOG_LIMIT: u32 = 1000;
+const DASHBOARD_USAGE_RECORD_LIMIT: u32 = 1000;
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4;
@@ -60,7 +60,7 @@ struct DashboardSummaryData {
health_timeline: DashboardHealthTimelineData,
account_usage: Vec,
service_statuses: Vec,
- event_logs: Vec,
+ usage_records: Vec,
pool_summary: AccountPoolDiagnostics,
capacity_info: AccountCapacityDiagnostics,
rotation_strategy: Option,
@@ -171,19 +171,6 @@ struct DashboardAccountUsageData {
status: String,
}
-#[derive(Debug, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct DashboardEventLogData {
- id: String,
- time: String,
- level: EventLevel,
- request_id: Option,
- route: Option,
- model: Option,
- status_code: Option,
- latency_ms: Option,
-}
-
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DashboardServiceStatusData {
@@ -258,10 +245,14 @@ pub(crate) async fn dashboard_summary(
.await
.map(|page| page.items)
.unwrap_or_default();
- let logs = state
+ let recent_events = state
.services
- .logs
- .list(None, DASHBOARD_LOG_LIMIT, AdminLogFilter::default())
+ .usage_records
+ .list(
+ None,
+ DASHBOARD_USAGE_RECORD_LIMIT,
+ AdminUsageRecordFilter::default(),
+ )
.await
.map(|page| page.items)
.unwrap_or_default();
@@ -269,10 +260,16 @@ pub(crate) async fn dashboard_summary(
let now = Utc::now();
let time_buckets = dashboard_time_buckets(&state, now).await;
let pool_summary = account_pool_summary(&accounts);
- let dashboard_logs = logs.iter().take(10).map(dashboard_event_log_data).collect();
let trend = dashboard_trend_data(&time_buckets, DashboardTrendKind::Usage);
let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await;
let settings = state.services.settings.current();
+ let recent_usage_records = recent_events.into_iter().take(10).collect::>();
+ let account_emails = account_email_map(&state, &recent_usage_records).await?;
+ let dashboard_usage_records = usage_record_items(
+ recent_usage_records,
+ &account_emails,
+ &settings.model_aliases,
+ );
Ok(AdminResponse::new(
StatusCode::OK,
@@ -282,7 +279,7 @@ pub(crate) async fn dashboard_summary(
health_timeline: dashboard_health_timeline_data(&time_buckets),
account_usage: account_usage_data(&accounts, &usage_records, "a_used_by_account),
service_statuses: service_status_data(&state),
- event_logs: dashboard_logs,
+ usage_records: dashboard_usage_records,
pool_summary,
capacity_info: AccountCapacityDiagnostics::from(capacity),
rotation_strategy: Some(settings.auth.rotation_strategy.clone()),
@@ -421,10 +418,10 @@ fn dashboard_trend_data_at(
if record.bucket_start < start || record.bucket_start > now {
continue;
}
- let log_hour = china_hour_start(record.bucket_start);
+ let record_hour = china_hour_start(record.bucket_start);
if let Some((_, bucket)) = buckets
.iter_mut()
- .find(|(bucket_start, _)| *bucket_start == log_hour)
+ .find(|(bucket_start, _)| *bucket_start == record_hour)
{
apply_bucket(bucket, record);
}
@@ -487,10 +484,10 @@ fn dashboard_health_timeline_data_at(
if record.bucket_start < start || record.bucket_start > now {
continue;
}
- let log_slot = china_quarter_hour_start(record.bucket_start);
+ let record_slot = china_quarter_hour_start(record.bucket_start);
if let Some((_, bucket)) = buckets
.iter_mut()
- .find(|(bucket_start, _)| *bucket_start == log_slot)
+ .find(|(bucket_start, _)| *bucket_start == record_slot)
{
apply_bucket(bucket, record);
}
@@ -798,19 +795,6 @@ fn account_usage_data(
rows
}
-fn dashboard_event_log_data(log: &EventLog) -> DashboardEventLogData {
- DashboardEventLogData {
- id: log.id.clone(),
- time: china_time(&log.created_at),
- level: log.level,
- request_id: log.request_id.clone(),
- route: log.route.clone(),
- model: log.model.clone(),
- status_code: log.status_code,
- latency_ms: log.latency_ms,
- }
-}
-
async fn account_quota_used_percent_by_id(
state: &AppState,
usage_records: &[AdminUsageRecord],
diff --git a/src/admin/monitoring/event_store.rs b/src/admin/monitoring/event_store.rs
deleted file mode 100644
index ee47eb498..000000000
--- a/src/admin/monitoring/event_store.rs
+++ /dev/null
@@ -1,743 +0,0 @@
-//! 事件日志存储实现(SQLite)。
-
-use chrono::{DateTime, Utc};
-use serde_json::Value;
-use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
-use thiserror::Error;
-
-use crate::admin::monitoring::events::{EventLevel, EventLog};
-use crate::infra::json::{decode_cursor, encode_cursor, page_offset, NumberedPage, Page};
-use crate::infra::time::china_quarter_hour_start;
-
-/// 默认保留最新事件日志数量。
-pub const DEFAULT_EVENT_LOG_CAPACITY: u32 = 2_000;
-/// 默认不保存请求/响应体,避免把用户内容写入诊断日志。
-pub const DEFAULT_EVENT_LOG_CAPTURE_BODY: bool = false;
-
-/// SQLite 事件日志错误。
-#[derive(Debug, Error)]
-pub enum SqliteEventLogStoreError {
- /// 数据库错误。
- #[error("sqlite event log database error: {0}")]
- Database(#[from] sqlx::Error),
- /// JSON 错误。
- #[error("sqlite event log json error: {0}")]
- Json(#[from] serde_json::Error),
- /// 时间格式错误。
- #[error("sqlite event log timestamp error: {0}")]
- Timestamp(#[from] chrono::ParseError),
- /// 事件等级非法。
- #[error("invalid event level: {0}")]
- InvalidLevel(String),
- /// 分页游标非法。
- #[error("invalid event log pagination cursor")]
- InvalidCursor,
-}
-
-/// SQLite 事件日志结果。
-pub type SqliteEventLogStoreResult = Result;
-
-/// 事件日志查询过滤器。
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct EventLogFilter {
- /// 事件类别。
- pub kind: Option,
- /// 事件等级。
- pub level: Option,
- /// 请求 ID。
- pub request_id: Option,
- /// 账号 ID。
- pub account_id: Option,
- /// 路由。
- pub route: Option,
- /// 模型。
- pub model: Option,
- /// HTTP 状态码。
- pub status_code: Option,
- /// 上游传输方式。
- pub transport: Option,
- /// 同一请求内的上游尝试序号。
- pub attempt_index: Option,
- /// 上游 HTTP 状态码。
- pub upstream_status_code: Option,
- /// 失败分类。
- pub failure_class: Option,
- /// 上游响应 ID。
- pub response_id: Option,
- /// 上游请求 ID。
- pub upstream_request_id: Option,
- /// 搜索关键词。
- pub search: Option,
-}
-
-/// SQLite 事件日志存储。
-#[derive(Clone)]
-pub struct SqliteEventLogStore {
- pool: SqlitePool,
-}
-
-impl SqliteEventLogStore {
- /// 构造存储。
- pub fn new(pool: SqlitePool) -> Self {
- Self { pool }
- }
-
- /// 写入事件日志。
- pub async fn append(&self, event: &EventLog) -> SqliteEventLogStoreResult<()> {
- append_event(&self.pool, event).await
- }
-
- /// 保留最新的指定数量事件日志。
- pub async fn trim_to_capacity(&self, capacity: u32) -> SqliteEventLogStoreResult {
- trim_to_capacity(&self.pool, capacity).await
- }
-
- /// 分页查询事件日志。
- pub async fn list(
- &self,
- filter: EventLogFilter,
- cursor: Option,
- limit: u32,
- ) -> SqliteEventLogStoreResult> {
- let fetch_limit = i64::from(limit) + 1;
- let mut builder = QueryBuilder::::new(
- "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from event_logs",
- );
- push_filter(&mut builder, &filter, cursor.as_deref())?;
- builder.push(" order by created_at desc, id desc limit ");
- builder.push_bind(fetch_limit);
-
- let rows = builder.build().fetch_all(&self.pool).await?;
- let has_next = rows.len() > limit as usize;
- let take_count = rows.len().min(limit as usize);
- let items = rows
- .into_iter()
- .take(take_count)
- .map(|row| event_from_row(&row))
- .collect::>>()?;
- let next_cursor = if has_next {
- items
- .last()
- .map(|event| encode_cursor(&event.created_at.to_rfc3339(), &event.id))
- } else {
- None
- };
-
- Ok(Page { items, next_cursor })
- }
-
- /// 按页码查询事件日志。
- pub async fn list_page(
- &self,
- filter: EventLogFilter,
- page: u32,
- page_size: u32,
- ) -> SqliteEventLogStoreResult> {
- let page_size = page_size.clamp(1, 200);
- let total = count_events(&self.pool, &filter).await?;
- let offset = page_offset(page, page_size);
- let mut builder = QueryBuilder::::new(
- "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from event_logs",
- );
- push_filter(&mut builder, &filter, None)?;
- builder.push(" order by created_at desc, id desc limit ");
- builder.push_bind(i64::from(page_size));
- builder.push(" offset ");
- builder.push_bind(offset.min(i64::MAX as u64) as i64);
-
- let rows = builder.build().fetch_all(&self.pool).await?;
- let items = rows
- .iter()
- .map(event_from_row)
- .collect::>>()?;
-
- Ok(NumberedPage {
- items,
- total,
- page: page.max(1),
- page_size,
- })
- }
-
- /// 按 ID 读取事件日志。
- pub async fn get(&self, id: &str) -> SqliteEventLogStoreResult