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 d1c1f7cc6..8eb62448b 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -88,7 +88,19 @@ pub(crate) fn push_usage_filter( query.push(format!(" escape '\\' or {alias}.{column} like ")); query.push_bind(pattern.clone()); } - query.push(" escape '\\')"); + if value.starts_with("sk_") { + query.push(format!( + " escape '\\' or exists ( + select 1 from client_api_keys searched_client_key + where searched_client_key.id = {alias}.client_api_key_ref + and searched_client_key.key like " + )); + query.push_bind(pattern); + query.push(" escape '\\')"); + } else { + query.push(" escape '\\'"); + } + query.push(")"); } } diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index 978e1e855..14daeedce 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -186,6 +186,48 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() { database.close().await; } +#[tokio::test] +async fn usage_search_should_match_client_api_key_prefix() { + let Some(database) = TestDatabase::create("usage_client_api_key_search").await else { + return; + }; + let now = Utc::now(); + seed_observability_facts(&database.pool, now) + .await + .expect("seed observability facts"); + let plaintext_key = format!("sk_{}", "K".repeat(43)); + sqlx::query( + "insert into client_api_keys (id, name, key, enabled, created_at, updated_at) + values ('key_observe', 'usage search', $1, true, $2, $2)", + ) + .bind(&plaintext_key) + .bind(now) + .execute(&database.pool) + .await + .expect("seed searchable client API key"); + let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)) + .expect("observability range"); + + for search in [plaintext_key.as_str(), &plaintext_key[..10]] { + let page = observability_repository(&database.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 client API key"); + + assert_eq!(page.total, 1); + assert_eq!(page.items[0].id, "req_observe_success"); + } + database.close().await; +} + #[tokio::test] async fn ops_search_should_treat_sql_wildcards_as_literals() { let Some(database) = TestDatabase::create("ops_literal_wildcard_search").await else { diff --git a/frontend/src/views/usage/components/UsageFilters.vue b/frontend/src/views/usage/components/UsageFilters.vue index 23d16abbb..e58eb4b21 100644 --- a/frontend/src/views/usage/components/UsageFilters.vue +++ b/frontend/src/views/usage/components/UsageFilters.vue @@ -19,7 +19,7 @@ const search = defineModel('search', { required: true })