diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index 3f0ece0a2..ec677f3aa 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -85,6 +85,8 @@ pub(crate) fn push_usage_filter( for column in [ "client_api_key_ref", "provider_account_ref", + "provider_account_email_snapshot", + "provider_account_name_snapshot", "requested_model_id", "upstream_model_id", "upstream_request_id", diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index a905ff2f8..79d75e77e 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -187,6 +187,149 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() { database.close().await; } +#[tokio::test] +async fn usage_search_should_match_account_email_and_name_prefixes() { + let Some(database) = TestDatabase::create("usage_account_prefix").await else { + return; + }; + let now = Utc::now(); + seed_observability_facts(&database.pool, now) + .await + .expect("seed observability facts"); + let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)) + .expect("observability range"); + + for search in ["account@example.invalid", "account@", "primary", "pri"] { + assert_usage_search_ids(&database.pool, range, search, &["req_observe_success"]).await; + } + for search in ["count@example.invalid", "rimary", "missing@example.invalid"] { + assert_usage_search_ids(&database.pool, range, search, &[]).await; + } + + sqlx::query("update model_requests set provider_account_email_snapshot = null") + .execute(&database.pool) + .await + .expect("clear account email snapshots"); + assert_usage_search_ids(&database.pool, range, "primary", &["req_observe_success"]).await; + assert_usage_search_ids(&database.pool, range, "account@", &[]).await; + + sqlx::query("update model_requests set provider_account_name_snapshot = null") + .execute(&database.pool) + .await + .expect("clear account name snapshots"); + assert_usage_search_ids( + &database.pool, + range, + "acct_observe", + &["req_observe_success"], + ) + .await; + database.close().await; +} + +#[tokio::test] +async fn usage_search_should_preserve_account_snapshots_after_account_changes() { + let Some(database) = TestDatabase::create("usage_account_history").await else { + return; + }; + let now = Utc::now(); + seed_observability_facts(&database.pool, now) + .await + .expect("seed observability facts"); + let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)) + .expect("observability range"); + + // 搜索与列表都使用请求发生时的快照,不能随当前账号资料变化或删除而改变。 + for mutation in [ + "update provider_accounts set name = 'renamed', email = 'renamed@example.invalid' + where id = 'acct_observe'", + "delete from provider_accounts where id = 'acct_observe'", + ] { + sqlx::query(mutation) + .execute(&database.pool) + .await + .expect("change current account"); + for search in ["account@example.invalid", "primary"] { + assert_usage_search_ids(&database.pool, range, search, &["req_observe_success"]).await; + } + for search in ["renamed@example.invalid", "renamed"] { + assert_usage_search_ids(&database.pool, range, search, &[]).await; + } + } + database.close().await; +} + +#[tokio::test] +async fn usage_search_should_treat_account_snapshot_wildcards_as_literals() { + let Some(database) = TestDatabase::create("usage_account_literals").await else { + return; + }; + let now = Utc::now(); + seed_observability_facts(&database.pool, now) + .await + .expect("seed observability facts"); + sqlx::query( + "update model_requests + set provider_account_email_snapshot = $1, provider_account_name_snapshot = $2", + ) + .bind("account_team%tag@example.invalid") + .bind(r"primary\ops_100%") + .execute(&database.pool) + .await + .expect("set account snapshots with literal wildcard characters"); + let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)) + .expect("observability range"); + + for search in [ + "account_team%tag@example.invalid", + "account_", + "account_team%", + r"primary\", + r"primary\ops_", + r"primary\ops_100%", + ] { + assert_usage_search_ids(&database.pool, range, search, &["req_observe_success"]).await; + } + for search in ["account%", "account_team_", "primary_", r"primary\ops%"] { + assert_usage_search_ids(&database.pool, range, search, &[]).await; + } + database.close().await; +} + +async fn assert_usage_search_ids( + pool: &PgPool, + range: ObservabilityRange, + search: &str, + expected_ids: &[&str], +) { + let page = observability_repository(pool) + .list_usage_records(UsageRecordQuery { + range, + filter: UsageRecordFilter { + search: Some(search.to_owned()), + ..UsageRecordFilter::default() + }, + current_page: 1, + page_size: ObservabilityPageSize::new(10).expect("page size"), + }) + .await + .expect("search usage by account snapshot"); + + assert_eq!( + page.total, + u64::try_from(expected_ids.len()).expect("expected total"), + "search: {search}" + ); + assert_eq!( + page.items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + expected_ids, + "search: {search}" + ); +} + #[tokio::test] async fn usage_search_should_match_client_api_key_prefix() { let Some(database) = TestDatabase::create("usage_client_api_key_search").await else { diff --git a/docs/api.md b/docs/api.md index d12b60f6a..342b33a08 100644 --- a/docs/api.md +++ b/docs/api.md @@ -726,6 +726,10 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、 `apiKey`、`provider`、`transport`、`failureClass`、`status`。 +请求记录列表的 `search` 使用区分大小写的字面量前缀匹配,支持请求 ID、Client Key ID / Key 前缀、 +账号 ID、账号邮箱与名称、请求 / 上游模型 ID、上游请求 ID。账号邮箱与名称按请求记录的历史快照检索, +不随当前账号修改或删除而改变;`%`、`_` 和 `\` 均按普通字符处理,不作为搜索通配符。 + 请求 ID 和上游 ID 继续通过既有字段查询;不增加入口 ID 字段,也不扫描 trace 建立查询映射。 旧响应中只有入口 ID 时仍需结合时间与入口日志定位,不能回填不存在的关联。 管理端搜索完整 `sk_` Client Key 时仅提交其可见前缀,不将完整密钥放入 URL。