feat(guardrails): per-execution latency histogram aisix_guardrail_latency_seconds - #798
Conversation
…ency_seconds
Guardrail observability so far was two request-level aggregate counters
(blocks_total with no labels, bypasses_total{reason}, chat-only): no
per-guardrail attribution, no phase/result split, and no latency signal
at all (AISIX-Cloud#1076).
Add aisix_guardrail_latency_seconds, a bucketed histogram recorded once
per consulted guardrail per hook pass, with labels {env_id, guardrail,
kind, phase, result, error_type}:
- Recording lives in the GuardrailChain folds — the one place every
handler's guardrail evaluation converges (chat, messages, responses,
completions, embeddings, streaming end-of-stream/window scans,
cache-hit output checks, and the segment moderation pass) — so all
endpoints are covered with zero handler churn.
- aisix-guardrails stays free of a metrics dependency: the chain records
through a GuardrailMetricsSink trait defined in aisix-core (next to
the existing telemetry types); aisix-obs implements it on Metrics and
the proxy/server bootstrap injects it via
LiveGuardrailIndex::new_with_sink.
- result is the ENFORCED outcome: allowed / blocked / masked (segment
pass) / bypassed (remote failure + fail_open, with the bounded
per-kind failure tag as error_type) / would_block / would_mask
(monitor mode). kind separates local (keyword, pii) from remote
detection. Non-segment folds skip segment-moderating members so a
member is recorded once per pass, on the pass that consulted it.
- Buckets 1ms..30s (registered like the SLO histograms; unregistered
histograms render as summaries), so P50/P95/P99 aggregate across
instances and the _count series doubles as an execution/block/bypass
counter — no separate requests_total/errors_total needed.
Known limits, deliberate: the sync per-field redactors (local pii mask
rewrites, microsecond-scale) are not timed; a fail-closed remote failure
records as blocked with error_type=none (the structured tag only exists
on Bypass verdicts).
Fixes api7/AISIX-Cloud#1076
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
📝 WalkthroughWalkthroughAdds per-guardrail execution telemetry with bounded labels, outcome classification, elapsed timing, optional sink propagation, Prometheus histogram export, application wiring, and unit/E2E coverage. ChangesGuardrail latency observability
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
What
Implements the guardrail-observability-metrics part of api7/AISIX-Cloud#1076 (scope agreed in the issue comments: align capability with LiteLLM/Kong; the offline precision/recall eval set is out of scope per the Partner review note).
Today guardrail observability is two request-level aggregate counters —
aisix_guardrail_blocks_total(no labels) andaisix_guardrail_bypasses_total{reason}, chat-completions only. There is no per-guardrail attribution, no phase/result split, and no latency measurement at all.This PR adds
aisix_guardrail_latency_seconds— a real bucketed histogram recorded once per consulted guardrail per hook pass, on every endpoint:env_idguardrailkindkeyword/pii(local, in-process) vsbedrock/lakera/presidio/ … (remote)phaseinput/outputresultallowed/blocked/masked/bypassed/would_block/would_maskerror_typelakera_timeout,openai_moderation_5xx, …) whenresult="bypassed", elsenoneBuckets span 1 ms – 30 s (guardrail-scale, below the SLO buckets), so
histogram_quantileyields P50/P95/P99 per guardrail/phase/result across instances, and the_countseries doubles as an execution/block/bypass counter.How
GuardrailChainfolds — the single point every handler's guardrail evaluation converges (chat, messages, responses, completions, embeddings, audio, streaming end-of-stream and window scans, cache-hit output checks, and the segment moderation pass). All endpoints are covered with zero handler churn, and the existing chat-only limitation of the aggregate counters does not carry over.aisix-guardrailsrecords through aGuardrailMetricsSinktrait defined inaisix-core(beside the existingGuardrailMonitorHit/AppliedGuardrailtelemetry types).aisix-obsimplements it onMetrics; the proxy/server bootstrap injects it viaLiveGuardrailIndex::new_with_sink, andresolve()attaches it to every resolved chain.resultis the enforced outcome: a monitor-mode member's suppressed decision records aswould_block/would_mask(from its monitor hits) while the request proceeds — so a staged policy's hit rate and latency are measurable in Prometheus before flipping it toblock. Non-segment folds skip segment-moderating members (they are consulted — and timed — via the segment pass), so each member records exactly once per pass.LiteLLM comparison (baseline per repo policy, checked at BerriAI/litellm@5d4c4d0fce)
LiteLLM exposes
litellm_guardrail_latency_seconds{guardrail_name, status, error_type, hook_type}pluslitellm_guardrail_requests_total/litellm_guardrail_errors_total. Capability is matched or exceeded; the shape intentionally differs in three ways:_countsubsumes both counters (documented PromQL equivalents in the paired docs PR).status="error"(only its logging layer distinguishesguardrail_intervenedfromguardrail_failed_to_respond). We follow the intent of its logging-layer taxonomy instead:blockedvsbypassed(error_type)are first-class results.bypassedwith the bounded failure tag (consistent with the existingaisix_guardrail_bypasses_total{reason}values).Kong (the other alignment target) records per-plugin input/output processing latency and block reasons in audit logs only, with no dedicated guardrail Prometheus histogram — covered by this metric plus the existing usage-event fields.
Known limits (deliberate)
piimask rewrites, microsecond-scale) are not timed — their per-field call pattern would skew per-execution counts; detection/scan time for those kinds is still recorded via the check folds.blockedwitherror_type="none": the structured failure tag only exists onBypassverdicts, and threading it throughBlockwould touch ~65 construction/destructure sites for a rare path. Its latency signature (clustering at the provider timeout) remains visible.No config/schema surface is added (metrics are emitted unconditionally, gated only by the existing
observability.metrics.prometheus.enabled), so no CP-side wiring is required.Tests
chain.rsunit tests: per-member recording with name/kind/phase, enforced-result classification (allowed/blocked/bypassed+tag), segment-passmasked, non-segment skip of segment members, no-sink no-op.build.rsunit tests: sink injection throughLiveGuardrailIndex::new_with_sink→ resolved chains; monitor-modewould_block/would_maskrecording end-to-end through the real decorators.metrics.rsunit test: rendered exposition has_bucket{le=...}series (not a summary) with the full label set anderror_type="none"defaulting.guardrail-metrics-e2e.test.ts(realaisix+ etcd + mock moderation endpoint + two mock upstreams): assertsallowed, localblocked, remoteblocked,bypassedwitherror_type="openai_moderation_5xx", monitor-modewould_block, and output-phaseblockedseries with correct counts, plus bucketed rendering andenv_id/kindlabels.Fixes api7/AISIX-Cloud#1076
Summary by CodeRabbit
New Features
Tests