diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index 62109096..4a20d9ca 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -32,7 +32,7 @@ pub use error::{ pub use models::{ validate_apikey, validate_cache_policy, validate_guardrail, validate_model, validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, Adapter, - AisixSnapshot, ApiKey, CachePolicy, CooldownConfig, ExporterKind, Guardrail, + AisixSnapshot, ApiKey, AppliedGuardrail, CachePolicy, CooldownConfig, ExporterKind, Guardrail, GuardrailHookPoint, GuardrailKind, KeywordConfig, KeywordPattern, Model, ObservabilityExporter, OnAllFilteredPolicy, ParamConstraints, ProviderKey, RateLimit, RateLimitPolicy, RequestOverrides, ResponseOverrides, Routing, RoutingStrategy, RoutingTarget, SchemaError, diff --git a/crates/aisix-core/src/models/guardrail.rs b/crates/aisix-core/src/models/guardrail.rs index f3214e37..179a582d 100644 --- a/crates/aisix-core/src/models/guardrail.rs +++ b/crates/aisix-core/src/models/guardrail.rs @@ -381,6 +381,44 @@ pub enum GuardrailKind { AliyunTextModeration(AliyunTextModerationConfig), } +impl GuardrailKind { + /// The wire `kind` discriminator string — matches the cp-api kind enum + /// and the dashboard. Used for applied-guardrail telemetry. + pub fn kind_str(&self) -> &'static str { + match self { + GuardrailKind::Keyword(_) => "keyword", + GuardrailKind::Bedrock(_) => "bedrock", + GuardrailKind::AzureContentSafety(_) => "azure_content_safety", + GuardrailKind::AzureContentSafetyTextModeration(_) => { + "azure_content_safety_text_moderation" + } + GuardrailKind::AliyunTextModeration(_) => "aliyun_text_moderation", + } + } +} + +impl GuardrailHookPoint { + /// Lowercase wire string: "input" / "output" / "both". + pub fn as_str(&self) -> &'static str { + match self { + GuardrailHookPoint::Input => "input", + GuardrailHookPoint::Output => "output", + GuardrailHookPoint::Both => "both", + } + } +} + +/// One guardrail that applied to a request, captured at chain-build time: +/// the guardrail `kind` and the `hook` it's configured for. Carried on the +/// telemetry UsageEvent so the dashboard can show which guardrails governed a +/// request (#379 observability). v1 records the attached set (kind + hook), +/// not per-guardrail verdicts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppliedGuardrail { + pub kind: String, + pub hook: String, +} + /// Top-level `Guardrail` resource shape. Mirrors what cp-api writes /// to kine at `/aisix//guardrails/`. /// diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index a82accd9..29ccb169 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -30,9 +30,10 @@ pub mod snapshot; pub use apikey::ApiKey; pub use cache_policy::{AppliesTo, CacheBackend, CachePolicy}; pub use guardrail::{ - AliyunTextModerationConfig, AzureContentSafetyConfig, AzureContentSafetyTextModerationConfig, - BedrockAWSCredentials, BedrockConfig, BedrockLatencyMode, Guardrail, GuardrailAttachment, - GuardrailHookPoint, GuardrailKind, GuardrailScopeType, KeywordConfig, KeywordPattern, + AliyunTextModerationConfig, AppliedGuardrail, AzureContentSafetyConfig, + AzureContentSafetyTextModerationConfig, BedrockAWSCredentials, BedrockConfig, + BedrockLatencyMode, Guardrail, GuardrailAttachment, GuardrailHookPoint, GuardrailKind, + GuardrailScopeType, KeywordConfig, KeywordPattern, }; pub use model::{ Adapter, BackgroundModelCheck, CooldownConfig, Model, DEFAULT_COOLDOWN_TRIGGER_STATUSES, diff --git a/crates/aisix-guardrails/src/build.rs b/crates/aisix-guardrails/src/build.rs index c3418b09..a5bcbb12 100644 --- a/crates/aisix-guardrails/src/build.rs +++ b/crates/aisix-guardrails/src/build.rs @@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex}; use aisix_core::models::{ - AisixSnapshot, Guardrail as DomainGuardrail, GuardrailAttachment, GuardrailHookPoint, - GuardrailKind, GuardrailScopeType, KeywordPattern, + AisixSnapshot, AppliedGuardrail, Guardrail as DomainGuardrail, GuardrailAttachment, + GuardrailHookPoint, GuardrailKind, GuardrailScopeType, KeywordPattern, }; use aisix_core::snapshot::ResourceTable; use aisix_core::SnapshotHandle; @@ -43,6 +43,11 @@ pub fn build_chain_from_snapshot( bedrock_endpoint_url: Option<&str>, ) -> GuardrailChain { let mut chain: Vec> = Vec::new(); + // `applied` mirrors `chain` 1:1 — the `{kind, hook}` of each member that + // actually materialised, for applied-guardrail telemetry (#379). Pushed + // only on the `Ok(Some)` path so inert/invalid rows (which never join the + // chain) never show up as "governed this request". + let mut applied: Vec = Vec::new(); let entries = table.entries(); for entry in entries.iter() { @@ -51,7 +56,10 @@ pub fn build_chain_from_snapshot( continue; } match build_one(row, bedrock_endpoint_url) { - Ok(Some(g)) => chain.push(g), + Ok(Some(g)) => { + chain.push(g); + applied.push(applied_for(row)); + } Ok(None) => { // Rule was technically valid but inert (e.g. empty // keyword list). Skip silently — operators see this @@ -68,7 +76,19 @@ pub fn build_chain_from_snapshot( } } - GuardrailChain::new(chain) + GuardrailChain::new_with_applied(chain, applied) +} + +/// The `{kind, hook}` telemetry descriptor for a guardrail row that +/// materialised into a chain (#379). Captured here — the build points are the +/// only place the domain row's `kind` + `hook_point` are in scope alongside +/// the runtime guardrail. `hook` is the configured hook_point, not a +/// per-request verdict (v1 records the attached set, not which side fired). +fn applied_for(row: &DomainGuardrail) -> AppliedGuardrail { + AppliedGuardrail { + kind: row.config.kind_str().to_owned(), + hook: row.hook_point.as_str().to_owned(), + } } fn build_one( @@ -397,6 +417,7 @@ pub fn build_index_from_snapshot( attachment.scope_id.clone(), attachment.priority, runtime_guardrail, + applied_for(row), )); } @@ -432,6 +453,7 @@ pub fn build_index_from_snapshot( None, 0, g, + applied_for(row), )); } Ok(None) => {} @@ -1065,4 +1087,196 @@ mod tests { }; assert!(!chain.check_output(&resp).await.is_block()); } + + // ----------------------------------------------------------------------- + // applied-guardrails capture (#379 A1) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn build_chain_reports_applied_kind_and_hook() { + // build_chain_from_snapshot is one of the two capture points: the + // resulting chain must report each materialised row's kind + hook, + // in the table's id-sorted iteration order. + let table: ResourceTable = ResourceTable::default(); + table.insert(entry( + "kw-input", + "g-1", + parse( + r#"{ + "name": "kw-input", + "kind": "keyword", + "hook_point": "input", + "patterns": [{ "kind": "literal", "value": "AKIA" }] + }"#, + ), + )); + table.insert(entry( + "kw-output", + "g-2", + parse( + r#"{ + "name": "kw-output", + "kind": "keyword", + "hook_point": "output", + "patterns": [{ "kind": "literal", "value": "secret" }] + }"#, + ), + )); + + let chain = build_chain_from_snapshot(&table, None); + // `applied` mirrors the chain 1:1 (pushed in lockstep); the absolute + // member order is a `ResourceTable::entries()` concern tested + // elsewhere, so sort by hook before comparing to pin only that BOTH + // rows are captured with the right kind + hook. + let mut applied = chain.applied().to_vec(); + applied.sort_by(|a, b| a.hook.cmp(&b.hook)); + assert_eq!( + applied, + vec![ + AppliedGuardrail { + kind: "keyword".to_owned(), + hook: "input".to_owned(), + }, + AppliedGuardrail { + kind: "keyword".to_owned(), + hook: "output".to_owned(), + }, + ], + ); + } + + #[tokio::test] + async fn applied_excludes_inert_and_disabled_rows() { + // `applied` is pushed only on Ok(Some) — it records what actually + // governs the request. An empty keyword list (inert / Ok(None)) and a + // disabled row (dropped) must not appear, so `applied` never claims a + // guardrail ran that didn't. + let table: ResourceTable = ResourceTable::default(); + table.insert(entry( + "inert", + "g-1", + parse(r#"{ "name": "inert", "kind": "keyword", "patterns": [] }"#), + )); + table.insert(entry( + "off", + "g-2", + parse( + r#"{ + "name": "off", + "enabled": false, + "kind": "keyword", + "patterns": [{ "kind": "literal", "value": "x" }] + }"#, + ), + )); + table.insert(entry( + "live", + "g-3", + parse( + r#"{ + "name": "live", + "kind": "keyword", + "patterns": [{ "kind": "literal", "value": "AKIA" }] + }"#, + ), + )); + + let chain = build_chain_from_snapshot(&table, None); + assert_eq!(chain.len(), 1, "only the live row materialises"); + assert_eq!( + chain.applied(), + &[AppliedGuardrail { + kind: "keyword".to_owned(), + hook: "both".to_owned(), + }], + "applied reports only the row that actually governs the request", + ); + } + + #[tokio::test] + async fn resolved_chain_reports_applied_and_mirrors_dedup() { + // The per-request path (index.resolve, the capture point the proxy + // actually uses): the resolved chain reports each member's kind + hook, + // and `applied` mirrors the deduplicated chain 1:1 — a guardrail + // attached via two scopes still appears exactly once. + let guardrails: ResourceTable = ResourceTable::default(); + guardrails.insert(entry( + "kw", + "g-1", + parse( + r#"{ + "name": "kw", + "kind": "keyword", + "hook_point": "input", + "patterns": [{ "kind": "literal", "value": "AKIA" }] + }"#, + ), + )); + let attachments: ResourceTable = ResourceTable::default(); + attachments.insert(attachment_entry( + "a-env", + parse_attachment(r#"{ "guardrail_id": "g-1", "scope_type": "env", "priority": 50 }"#), + )); + attachments.insert(attachment_entry( + "a-model", + parse_attachment( + r#"{ "guardrail_id": "g-1", "scope_type": "model", "scope_id": "m-A", "priority": 100 }"#, + ), + )); + + let index = build_index_from_snapshot(&guardrails, &attachments, None); + let chain = index.resolve(&RequestContext { + model_id: "m-A", + api_key_id: "k", + team_id: None, + }); + assert_eq!(chain.len(), 1, "dedup keeps a single runtime guardrail"); + assert_eq!( + chain.applied(), + &[AppliedGuardrail { + kind: "keyword".to_owned(), + hook: "input".to_owned(), + }], + "applied mirrors the deduplicated chain, not the raw entry count", + ); + } + + #[tokio::test] + async fn resolved_chain_applied_empty_when_no_attachment_matches() { + // A model-scoped attachment that doesn't match the request resolves to + // an empty chain — and `applied` must be empty too, so the telemetry + // event never claims a guardrail governed a request it didn't. + let guardrails: ResourceTable = ResourceTable::default(); + guardrails.insert(entry( + "kw", + "g-1", + parse( + r#"{ + "name": "kw", + "kind": "keyword", + "hook_point": "output", + "patterns": [{ "kind": "literal", "value": "x" }] + }"#, + ), + )); + let attachments: ResourceTable = ResourceTable::default(); + attachments.insert(attachment_entry( + "a-model", + parse_attachment( + r#"{ "guardrail_id": "g-1", "scope_type": "model", "scope_id": "m-A", "priority": 10 }"#, + ), + )); + + let index = build_index_from_snapshot(&guardrails, &attachments, None); + let chain = index.resolve(&RequestContext { + model_id: "m-OTHER", + api_key_id: "k", + team_id: None, + }); + assert!(chain.is_empty()); + assert!( + chain.applied().is_empty(), + "no matching attachment → empty applied set", + ); + } } diff --git a/crates/aisix-guardrails/src/chain.rs b/crates/aisix-guardrails/src/chain.rs index 93b7ab3c..586aa7e1 100644 --- a/crates/aisix-guardrails/src/chain.rs +++ b/crates/aisix-guardrails/src/chain.rs @@ -9,6 +9,7 @@ use std::borrow::Cow; use std::sync::Arc; +use aisix_core::AppliedGuardrail; use aisix_gateway::{ChatFormat, ChatResponse}; use async_trait::async_trait; @@ -17,6 +18,13 @@ use crate::{Guardrail, GuardrailVerdict, StreamOutputPolicy}; #[derive(Clone)] pub struct GuardrailChain { guardrails: Vec>, + /// The `{kind, hook}` of each guardrail that materialised into this + /// chain, captured at build time. Carried onto the telemetry + /// `UsageEvent` so the dashboard can show which guardrails governed a + /// request (#379). Empty for chains built via [`GuardrailChain::new`] + /// (the in-memory test path); populated by the snapshot build points + /// (`build_chain_from_snapshot` and `GuardrailIndex::resolve`). + applied: Vec, } impl std::fmt::Debug for GuardrailChain { @@ -30,7 +38,32 @@ impl std::fmt::Debug for GuardrailChain { impl GuardrailChain { pub fn new(guardrails: Vec>) -> Self { - Self { guardrails } + Self { + guardrails, + applied: Vec::new(), + } + } + + /// Build a chain that also carries the `{kind, hook}` of each member + /// for applied-guardrail telemetry (#379). Used by the snapshot build + /// points; `applied` is expected to line up 1:1 with the materialised + /// `guardrails`, but the chain's runtime behaviour does not depend on + /// that — `applied` is telemetry-only. + pub fn new_with_applied( + guardrails: Vec>, + applied: Vec, + ) -> Self { + Self { + guardrails, + applied, + } + } + + /// The `{kind, hook}` set of guardrails that governed this request, + /// in chain order. Empty when the chain was built without applied + /// metadata (e.g. [`GuardrailChain::new`]). + pub fn applied(&self) -> &[AppliedGuardrail] { + &self.applied } pub fn empty() -> Self { @@ -323,4 +356,24 @@ mod tests { assert!(!empty.runs_on_output()); assert!(!empty.stream_output_policy().holds_back()); } + + #[test] + fn new_has_empty_applied_and_new_with_applied_reports_it() { + // `new` (the in-memory/test constructor) carries no applied metadata; + // `new_with_applied` (the snapshot build points) reports it verbatim. + assert!(GuardrailChain::new(vec![]).applied().is_empty()); + + let applied = vec![ + AppliedGuardrail { + kind: "keyword".to_owned(), + hook: "input".to_owned(), + }, + AppliedGuardrail { + kind: "aliyun_text_moderation".to_owned(), + hook: "both".to_owned(), + }, + ]; + let chain = GuardrailChain::new_with_applied(vec![], applied.clone()); + assert_eq!(chain.applied(), applied.as_slice()); + } } diff --git a/crates/aisix-guardrails/src/index.rs b/crates/aisix-guardrails/src/index.rs index 77c2c1d4..b12995a3 100644 --- a/crates/aisix-guardrails/src/index.rs +++ b/crates/aisix-guardrails/src/index.rs @@ -28,6 +28,8 @@ use std::collections::HashSet; use std::sync::Arc; +use aisix_core::AppliedGuardrail; + use crate::{Guardrail, GuardrailChain}; /// Which scope dimension an attachment covers. @@ -50,6 +52,11 @@ pub(crate) struct IndexEntry { /// Higher = higher precedence. Entries are pre-sorted descending. priority: i32, guardrail: Arc, + /// The `{kind, hook}` of this entry's guardrail, captured at index-build + /// time (the only place the domain row's `kind` + `hook_point` are in + /// scope). `resolve` collects these from the entries it keeps so the + /// returned chain can report which guardrails governed the request (#379). + applied: AppliedGuardrail, } impl std::fmt::Debug for IndexEntry { @@ -145,6 +152,10 @@ impl GuardrailIndex { pub fn resolve(&self, ctx: &RequestContext<'_>) -> GuardrailChain { let mut seen: HashSet<&str> = HashSet::new(); let mut chain: Vec> = Vec::new(); + // `applied` mirrors `chain` 1:1 — the `{kind, hook}` of each member + // we keep, for applied-guardrail telemetry (#379). Pushed on the same + // (matched + not-deduplicated) path so it never drifts from `chain`. + let mut applied: Vec = Vec::new(); for entry in &self.entries { if !entry.applies_to(ctx) { @@ -155,9 +166,10 @@ impl GuardrailIndex { } seen.insert(entry.guardrail_id.as_str()); chain.push(Arc::clone(&entry.guardrail)); + applied.push(entry.applied.clone()); } - GuardrailChain::new(chain) + GuardrailChain::new_with_applied(chain, applied) } } @@ -172,6 +184,7 @@ impl GuardrailIndex { scope_id: Option, priority: i32, guardrail: Arc, + applied: AppliedGuardrail, ) -> IndexEntry { IndexEntry { guardrail_id: guardrail_id.into(), @@ -179,6 +192,7 @@ impl GuardrailIndex { scope_id, priority, guardrail, + applied, } } @@ -223,7 +237,20 @@ mod tests { priority: i32, g: Arc, ) -> IndexEntry { - GuardrailIndex::push_entry(gid, scope, sid.map(str::to_owned), priority, g) + // These resolution tests build keyword guardrails via `kw`; the + // applied descriptor is documentary here (the dedicated applied + // tests live in build.rs against the real snapshot build path). + GuardrailIndex::push_entry( + gid, + scope, + sid.map(str::to_owned), + priority, + g, + AppliedGuardrail { + kind: "keyword".to_owned(), + hook: "both".to_owned(), + }, + ) } // 1. Empty index allows everything. diff --git a/crates/aisix-obs/src/usage.rs b/crates/aisix-obs/src/usage.rs index 5ec9bfc6..aa7973da 100644 --- a/crates/aisix-obs/src/usage.rs +++ b/crates/aisix-obs/src/usage.rs @@ -27,6 +27,7 @@ //! batch contract (5s interval / 100-event ceiling) lives in the worker //! (aisix-server), not here. +use aisix_core::AppliedGuardrail; use serde::Serialize; /// One upstream attempt made while serving a routing-model request. @@ -152,6 +153,21 @@ pub struct UsageEvent { #[serde(default, skip_serializing_if = "String::is_empty")] pub guardrail_bypassed_reason: String, + /// The guardrails that governed this request, captured at chain-build + /// time: each entry is the guardrail `kind` (e.g. `keyword`, + /// `aliyun_text_moderation`) plus the `hook` it's configured for + /// (`input` / `output` / `both`). Lets the dashboard show *which* + /// guardrails ran — not just the boolean `guardrail_blocked`. v1 records + /// the attached set, not per-guardrail verdicts (#379). + /// + /// Empty (the dominant guardrail-free deployment, or a request rejected + /// before guardrail resolution) is omitted from the wire via + /// `skip_serializing_if`; cp-api stores absent as an empty set. cp-api's + /// `/dp/telemetry` binds JSON leniently, so older CP images that don't + /// know this field ignore it — the DP can ship it ahead of the CP. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub applied_guardrails: Vec, + /// Cache outcome on this request. One of: /// /// - `"hit"` — cached response served the request without a @@ -737,6 +753,41 @@ mod tests { // resolve a peer / the client sent no User-Agent. assert!(!json.contains("client_source_ip")); assert!(!json.contains("client_user_agent")); + // Applied guardrails (#379): absent when no guardrail governed the + // request (the dominant guardrail-free deployment). Empty must not + // appear on the wire — cp-api treats absent as the empty set. + assert!(!json.contains("applied_guardrails")); + } + + #[test] + fn applied_guardrails_serialise_when_set() { + // #379: a request governed by guardrails carries the attached set + // (kind + hook) so the dashboard can show which guardrails ran. + let ev = UsageEvent { + request_id: "req-guarded".into(), + guardrail_blocked: true, + applied_guardrails: vec![ + AppliedGuardrail { + kind: "keyword".into(), + hook: "input".into(), + }, + AppliedGuardrail { + kind: "aliyun_text_moderation".into(), + hook: "both".into(), + }, + ], + ..Default::default() + }; + let json = serde_json::to_string(&ev).unwrap(); + assert!(json.contains(r#""applied_guardrails""#)); + assert!(json.contains(r#""kind":"keyword""#)); + assert!(json.contains(r#""hook":"input""#)); + assert!(json.contains(r#""kind":"aliyun_text_moderation""#)); + assert!(json.contains(r#""hook":"both""#)); + + // Empty set stays off the wire entirely. + let empty = serde_json::to_string(&UsageEvent::default()).unwrap(); + assert!(!empty.contains("applied_guardrails")); } #[test] diff --git a/crates/aisix-proxy/src/chat.rs b/crates/aisix-proxy/src/chat.rs index fa71a1eb..49c4562d 100644 --- a/crates/aisix-proxy/src/chat.rs +++ b/crates/aisix-proxy/src/chat.rs @@ -17,6 +17,7 @@ //! status, error type, and (for rate-limits) Retry-After. use aisix_cache::CacheKey; +use aisix_core::AppliedGuardrail; use aisix_gateway::{BridgeContext, BridgeError, ChatFormat}; use aisix_guardrails::GuardrailVerdict; use aisix_obs::{ @@ -161,7 +162,20 @@ pub async fn chat_completions( let api_key_id = auth.entry.id.clone(); let model_name = req.model.clone(); - let outcome = dispatch(&state, &auth, &req, &request_id, started, &client).await; + // Filled by `dispatch` once the per-request guardrail chain resolves; + // read below to attach `applied_guardrails` to the telemetry event on + // both the success and failure (guardrail-block) paths (#379). + let mut applied_guardrails: Vec = Vec::new(); + let outcome = dispatch( + &state, + &auth, + &req, + &request_id, + started, + &client, + &mut applied_guardrails, + ) + .await; match outcome { Ok(mut success) => { @@ -224,6 +238,7 @@ pub async fn chat_completions( cache_hit_saved_output_tokens: success.cache_hit_saved_output_tokens, ttft_ms: 0, routing: success.routing.clone(), + applied_guardrails: applied_guardrails.clone(), provider_key_id: success.provider_key_id.clone(), }, success.cost_usd, @@ -355,12 +370,21 @@ pub async fn chat_completions( cache_hit_saved_output_tokens: 0, ttft_ms: 0, routing: c.routing, + // Same applied set on the output-block path — the chain + // governed the request even though it ultimately blocked. + applied_guardrails: applied_guardrails.clone(), provider_key_id: c.provider_key_id, }, ), None => { + // Pre-upstream failures, incl. the input guardrail block: + // `applied_guardrails` is populated by `dispatch` once the + // chain resolved, so an input-blocked request still records + // which guardrails governed it (empty for errors that fire + // before resolution). let extras = UsageExtras { routing, + applied_guardrails: applied_guardrails.clone(), ..UsageExtras::default() }; (0, 0, extras) @@ -613,6 +637,12 @@ async fn dispatch( request_id: &str, started: Instant, client: &ClientContext, + // Out-param: filled with the resolved chain's `{kind, hook}` set as soon + // as the guardrail chain is resolved, so the caller can attach it to the + // telemetry event on BOTH the success and error (guardrail-block) paths + // without threading a field through every `Success`/`DispatchFailure` + // construction site. Stays empty for requests rejected before resolution. + applied_out: &mut Vec, ) -> Result { if req.messages.is_empty() { return Err(DispatchFailure::new( @@ -650,8 +680,15 @@ async fn dispatch( api_key_id: &auth.entry.id, team_id: auth.key().team_id.as_deref(), }; + let resolved = state.guardrail_index.resolve(&guardrail_ctx); + // Capture the applied `{kind, hook}` set before the concrete chain is + // erased to `Arc` (the trait has no `applied()`). Fill the + // caller's out-param so the failure path can surface it too, and keep a + // local copy for the streaming on_complete closure below. + let applied_guardrails = resolved.applied().to_vec(); + *applied_out = applied_guardrails.clone(); let resolved_chain: std::sync::Arc = - std::sync::Arc::new(state.guardrail_index.resolve(&guardrail_ctx)); + std::sync::Arc::new(resolved); // Input guardrails. Run before reservation so a blocked prompt // doesn't burn an RPM slot — content-policy refusals shouldn't @@ -825,6 +862,9 @@ async fn dispatch( let provider_key_id_for_telem = pk_entry.id.clone(); let upstream_model_for_metrics = model.upstream_model().unwrap_or("unknown").to_string(); let bypass_reason_for_telem = bypass_reason.clone().unwrap_or_default(); + // Applied guardrail set (#379), owned for the move into on_complete so + // the streamed-response telemetry event records which guardrails ran. + let applied_guardrails_for_telem = applied_guardrails.clone(); // Downstream client attribution (#492) moved into the on_complete // closure so streamed responses log the same IP/UA as non-streaming. let client_for_telem = client.clone(); @@ -916,6 +956,7 @@ async fn dispatch( cache_hit_saved_output_tokens: 0, ttft_ms: comp.ttft_ms, routing: stream_routing_for_telem.clone(), + applied_guardrails: applied_guardrails_for_telem.clone(), provider_key_id: provider_key_id_for_telem.clone(), }, /* cost_usd */ 0.0, @@ -1622,6 +1663,7 @@ fn emit_usage_event( cost_usd, guardrail_blocked, guardrail_bypassed_reason: extras.bypass_reason, + applied_guardrails: extras.applied_guardrails, cache_status: extras.cache_status, cache_hit_saved_input_tokens: extras.cache_hit_saved_input_tokens, cache_hit_saved_output_tokens: extras.cache_hit_saved_output_tokens, @@ -1735,6 +1777,12 @@ struct UsageExtras { cache_hit_saved_output_tokens: u32, ttft_ms: u32, routing: RoutingTelemetry, + /// The `{kind, hook}` set of guardrails that governed this request, + /// captured at chain-resolve time. Lands on + /// `dpmgr_usage_events.applied_guardrails` so the dashboard can show + /// which guardrails ran (#379). Empty for the guardrail-free path and + /// for requests rejected before resolution. + applied_guardrails: Vec, /// UUID of the resolved ProviderKey. Used at emit time to look up /// `telemetry_tags` from the snapshot and populate UsageEvent's /// per-PK attribution fields (`provider_kind` / `provider_featured` diff --git a/crates/aisix-proxy/src/messages.rs b/crates/aisix-proxy/src/messages.rs index 9f7b5583..50e8d524 100644 --- a/crates/aisix-proxy/src/messages.rs +++ b/crates/aisix-proxy/src/messages.rs @@ -33,6 +33,7 @@ //! status-to-type mapping. (`/v1/chat/completions` continues to emit //! the OpenAI-shape envelope with its DP-stable taxonomy.) +use aisix_core::AppliedGuardrail; use aisix_obs::{AccessLog, LlmUsage, RequestLabels, RequestOutcome, UsageEvent, UsageLabels}; use axum::extract::State; use axum::http::{HeaderName, HeaderValue}; @@ -108,7 +109,21 @@ pub async fn messages( .unwrap_or_default(); drop(snapshot); - match dispatch(&state, &auth, &mut body, &request_id, started, &client).await { + // Filled by `dispatch` once the per-request guardrail chain resolves; + // read below to attach `applied_guardrails` to the telemetry event on both + // the success and failure (input-block) paths (#379). + let mut applied_guardrails: Vec = Vec::new(); + match dispatch( + &state, + &auth, + &mut body, + &request_id, + started, + &client, + &mut applied_guardrails, + ) + .await + { Ok(DispatchOutcome { response, provider_label, @@ -166,6 +181,7 @@ pub async fn messages( elapsed, metrics, &client, + applied_guardrails.clone(), ); } response @@ -207,6 +223,7 @@ pub async fn messages( elapsed, AnthropicUsageMetrics::default(), &client, + applied_guardrails.clone(), ); // /v1/messages must return Anthropic-shape error envelope // `{type:"error", error:{type, message}}` so Claude SDKs @@ -225,6 +242,12 @@ async fn dispatch( request_id: &str, started: Instant, client: &ClientContext, + // Out-param: filled with the resolved chain's `{kind, hook}` set as soon as + // the guardrail chain resolves, so `messages()` can attach it to telemetry + // on both the success and error (input-block) paths. Empty for requests + // rejected before resolution. The streaming paths capture the same set + // directly from `resolved_chain` for their end-of-stream emit. + applied_out: &mut Vec, ) -> Result { let snapshot = state.snapshot.load(); @@ -263,6 +286,10 @@ async fn dispatch( // Arc so the chain can be cloned into the streaming-response body // (which outlives this handler) for end-of-stream output guardrails. let resolved_chain = std::sync::Arc::new(state.guardrail_index.resolve(&guardrail_ctx)); + // Surface the applied `{kind, hook}` set to the caller so the telemetry + // event records which guardrails governed the request even when the input + // check below blocks it (#379 / closes the anthropic gap in #519). + *applied_out = resolved_chain.applied().to_vec(); if !resolved_chain.is_empty() { if let Ok(chat) = aisix_provider_anthropic::parse_inbound_request(body) { if let aisix_guardrails::GuardrailVerdict::Block { reason } = @@ -612,6 +639,9 @@ async fn anthropic_passthrough_dispatch( // #492: log the same client IP/UA on streamed responses. let client_ctx_c = client_ctx.clone(); + // Applied guardrail set (#379), owned for the move into the + // end-of-stream telemetry closure. + let applied_guardrails_c = resolved_chain.applied().to_vec(); let stream_guardrail = if resolved_chain.is_empty() { None } else { @@ -651,6 +681,7 @@ async fn anthropic_passthrough_dispatch( started.elapsed(), metrics, &client_ctx_c, + applied_guardrails_c.clone(), ); }, ); @@ -921,6 +952,9 @@ async fn cross_provider_dispatch( let started_for_telem = started; // #492: log the same client IP/UA on streamed responses. let client_for_telem = client.clone(); + // Applied guardrail set (#379), owned for the move into the + // end-of-stream telemetry closure. + let applied_guardrails_for_telem = resolved_chain.applied().to_vec(); let stream_guardrail = if resolved_chain.is_empty() { None } else { @@ -958,6 +992,7 @@ async fn cross_provider_dispatch( started_for_telem.elapsed(), metrics, &client_for_telem, + applied_guardrails_for_telem.clone(), ); }, ); @@ -1265,6 +1300,9 @@ fn emit_anthropic_usage_event( elapsed: Duration, metrics: AnthropicUsageMetrics, client: &ClientContext, + // The `{kind, hook}` set of guardrails that governed this request (#379). + // Empty for the guardrail-free path and pre-resolution failures. + applied_guardrails: Vec, ) { // Per-PK telemetry attribution (#302 M17 / AISIX-Cloud#436). // Same shape as chat.rs's emit_usage_event — look up the @@ -1303,6 +1341,7 @@ fn emit_anthropic_usage_event( byo_label: sanitize_tag(tags.byo_label.unwrap_or_default()), client_source_ip: client.source_ip.clone(), client_user_agent: client.user_agent.clone(), + applied_guardrails, ..Default::default() }; // Handler label "messages" — Anthropic /v1/messages inbound