mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix(observability): search usage by account email and name
Search historical account email and name snapshots while preserving literal prefix matching. Add regression coverage and document the search contract. Fixes #70 Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
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 {
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user