feat: add guardrail violation span attributes and fix missing spans on pre-call blocks (#28364)

- Fix missing guardrail child spans when a pre-call guardrail blocks the request before reaching the LLM provider; `async_post_call_failure_hook` now calls `_emit_guardrail_spans_from_request_data` to emit spans from `request_data["metadata"]` regardless of whether `_handle_failure` already fired
- Add `guardrail_status`, `guardrail_action`, and `guardrail_violation_categories` as queryable top-level OTEL span attributes so trace backends can filter/group by violation type without parsing the redacted `guardrail_response` blob
- Introduce `_emit_guardrail_spans_from_request_data` helper that constructs minimal kwargs from `request_data["metadata"]` and routes through `_create_guardrail_span`, sharing the same dedupe state to prevent double-emitting when both failure hooks fire
- Extend `BedrockGuardrail` with `_build_tracing_detail` and `_extract_violation_category_names` which flatten BLOCKED assessments into human-readable category labels (topic names, content-filter types, PII entity types, named regex names) before redaction, and surface Bedrock's raw `action` field via `tracing_detail`
- Security: violation category extraction deliberately omits `customWords.match` and unnamed regex `match` values because those fields carry the user-submitted content that triggered the rule; only operator-defined `name`/`type` labels are emitted
- Add `violation_categories` and `guardrail_action` fields to `StandardLoggingGuardrailInformation` and `GuardrailTracingDetail` TypedDicts to carry the pre-redaction metadata through the logging pipeline
- Add comprehensive test suite covering: guardrail span creation on failure, dedupe between `_handle_failure` and `async_post_call_failure_hook`, per-span status attributes for multi-guardrail sequences, Bedrock category extraction for all policy types, security leak prevention, and end-to-end `CustomGuardrail` violation path

Co-authored-by: Yassin Kortam <yassinkortam@g.ucla.edu>
This commit is contained in:
Yassin Kortam 2026-05-21 15:49:42 -07:00 • committed by GitHub
parent b55749248d
commit 10bd7406e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1009 additions and 0 deletions

View file

@ -726,9 +726,57 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
exception_logging_span.set_status(Status(StatusCode.ERROR))
exception_logging_span.end(end_time=self._to_ns(datetime.now()))
# Emit guardrail spans for any guardrail invocations that
# ran during this request. _handle_failure typically does this,
# but for pre-call guardrail blocks the standard_logging_object
# may not carry guardrail_information by the time _handle_failure
# fires (the data lives only in request_data["metadata"]). Pull
# directly from request_data so the span is recorded either way;
# _emit_once dedupes if _handle_failure already emitted it.
self._emit_guardrail_spans_from_request_data(
request_data=request_data,
parent_span=parent_otel_span,
)
# End Parent OTEL Sspan
parent_otel_span.end(end_time=self._to_ns(datetime.now()))
def _emit_guardrail_spans_from_request_data(
self,
request_data: dict,
parent_span: Optional[Any],
) -> None:
"""Emit ``guardrail`` spans from ``request_data["metadata"]
["standard_logging_guardrail_information"]``.
Routed through ``_create_guardrail_span`` so the dedupe state in
``_otel_internal`` is honoured — if ``_handle_failure`` already
emitted these spans for the same kwargs, this is a no-op.
"""
from opentelemetry import trace as _trace
metadata = (request_data or {}).get("metadata") or {}
guardrail_information = metadata.get("standard_logging_guardrail_information")
if not guardrail_information:
return
# _create_guardrail_span reads guardrail_information from
# kwargs["standard_logging_object"] and shares its dedupe state via
# kwargs["litellm_params"]["metadata"]["_otel_internal"]. Pass the
# SAME metadata dict the proxy populated so _handle_failure and
# this hook see the same dedupe markers.
kwargs: Dict[str, Any] = {
"litellm_params": {"metadata": metadata},
"standard_logging_object": {
"guardrail_information": guardrail_information,
"metadata": metadata,
},
}
context = (
_trace.set_span_in_context(parent_span) if parent_span is not None else None
)
self._create_guardrail_span(kwargs=kwargs, context=context)
async def async_post_call_success_hook(
self,
data: dict,
@ -1617,6 +1665,37 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
"guardrail_response", safe_dumps(guardrail_response)
)
# Surface guardrail_status (success / guardrail_intervened /
# guardrail_failed_to_respond / not_run) as a top-level span
# attribute so trace backends can filter on it without parsing
# guardrail_response.
self.safe_set_attribute(
span=guardrail_span,
key="guardrail_status",
value=guardrail_information.get("guardrail_status"),
)
# Provider's raw top-level action (e.g. Bedrock's
# ``GUARDRAIL_INTERVENED`` / ``NONE``). Populated by the provider
# hook onto StandardLoggingGuardrailInformation so this integration
# stays provider-agnostic — we only read a normalised string.
guardrail_action = guardrail_information.get("guardrail_action")
if guardrail_action:
guardrail_span.set_attribute("guardrail_action", guardrail_action)
# The provider hook (e.g. Bedrock) extracts violation_categories
# from the raw response BEFORE redaction and stamps them onto
# StandardLoggingGuardrailInformation. Surfacing them here as a
# queryable attribute lets dashboards group by violation category
# without parsing the redacted guardrail_response blob.
violation_categories = guardrail_information.get("violation_categories")
if violation_categories:
# OTel sequence attributes must be homogeneous primitives;
# serialise to JSON once so set_attribute never coerces.
guardrail_span.set_attribute(
"guardrail_violation_categories", safe_dumps(violation_categories)
)
self._set_team_attributes_from_kwargs(guardrail_span, kwargs)
guardrail_span.end(end_time=self._to_ns(end_time_datetime))

View file

@ -63,6 +63,7 @@ from litellm.types.utils import (
CallTypesLiteral,
Choices,
GuardrailStatus,
GuardrailTracingDetail,
Message,
ModelResponse,
ModelResponseStream,
@ -509,6 +510,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
# Add guardrail information to request trace
#########################################################
_json_response = httpx_response.json()
tracing_detail = self._build_tracing_detail(_json_response)
# Raw Bedrock JSON is passed here; match/regex redaction runs once inside
# CustomGuardrail.add_standard_logging_guardrail_information_to_request_data.
self.add_standard_logging_guardrail_information_to_request_data(
@ -522,6 +525,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
tracing_detail=tracing_detail or None,
)
#########################################################
if httpx_response.status_code == 200:
@ -640,6 +644,55 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
return (status_code, err)
return (status_code, message)
def _build_tracing_detail(
self, response: BedrockGuardrailResponse
) -> GuardrailTracingDetail:
"""
Build the tracing detail from the raw Bedrock response, before
redaction, so downstream loggers (OTEL, Langfuse, ...) get the
actual category names rather than the "[REDACTED]" sentinel that
replaces customWords.match later. Bedrock's top-level ``action``
field ("GUARDRAIL_INTERVENED" or "NONE") is also surfaced so the
OTEL integration can expose it as a queryable span attribute
without re-parsing the redacted guardrail_response blob.
"""
tracing_detail: GuardrailTracingDetail = {}
violation_categories = self._extract_violation_category_names(response)
if violation_categories:
tracing_detail["violation_categories"] = violation_categories
bedrock_action = response.get("action")
if isinstance(bedrock_action, str):
tracing_detail["guardrail_action"] = bedrock_action
return tracing_detail
def _extract_violation_category_names(
self, response: BedrockGuardrailResponse
) -> List[str]:
"""
Flatten the BLOCKED assessments into a list of human-readable category
names suitable for queryable OTEL / standard-logging attributes.
SECURITY: only emits the non-sensitive policy *label* (topic name,
content-filter type, PII entity type, named-regex name). The raw
``match`` field is intentionally NOT used — it carries the user's
original input that triggered the rule (e.g. a credit-card number
that hit a regex, or the literal custom word). Surfacing it to
telemetry would re-introduce the sensitive content the guardrail
was supposed to keep out. Entries that only have a ``match`` (bare
customWords, unnamed regexes) are therefore skipped — operators
can still see the count in ``_extract_blocked_assessments`` which
feeds the HTTP error detail.
"""
names: List[str] = []
for block in self._extract_blocked_assessments(response):
for match in block.get("matches", []) or []:
# Allow-list non-sensitive labels only. Never fall back to
# `match.get("match")` — that's user-submitted content.
label = match.get("name") or match.get("type")
if isinstance(label, str) and label:
names.append(label)
return names
def _extract_blocked_assessments(
self, response: BedrockGuardrailResponse
) -> List[dict]:

View file

@ -2768,6 +2768,20 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False):
risk_score: Optional[float]
"""Risk score 0-10 indicating how risky the request was (higher = riskier). Computed by the guardrail provider."""
violation_categories: Optional[List[str]]
"""Names of the policy items that intervened on this request (e.g. Bedrock
topic-policy topic names, content-policy filter types, PII entity types).
Populated by the provider hook before redaction so downstream loggers
(OTEL, Langfuse, ...) can filter by violation category without parsing
the raw guardrail_response blob. Empty/absent when the guardrail allowed
the request through."""
guardrail_action: Optional[str]
"""Provider's raw top-level action string (e.g. Bedrock's ``GUARDRAIL_INTERVENED``
or ``NONE``). Populated by the provider hook so the OTEL integration can
surface it as a queryable span attribute without parsing the raw
guardrail_response blob."""
class EvalVerdict(TypedDict, total=False):
criterion_name: str
@ -2809,6 +2823,8 @@ class GuardrailTracingDetail(TypedDict, total=False):
patterns_checked: Optional[int]
alert_recipients: Optional[List[str]]
risk_score: Optional[float]
violation_categories: Optional[List[str]]
guardrail_action: Optional[str]
StandardLoggingPayloadStatus = Literal["success", "failure"]

View file

@ -0,0 +1,641 @@
"""
Tests for guardrail OTEL spans on violation.
Two distinct gaps surface together when a pre-call guardrail blocks the
request before it reaches the LLM provider:
1. ``async_post_call_failure_hook`` (the OTEL hook that actually runs on
the proxy failure path) only stamps attributes on the proxy parent
span. It never creates the child ``guardrail`` span, even though
``request_data["metadata"]["standard_logging_guardrail_information"]``
is populated by the time the hook runs.
2. ``_create_guardrail_span`` records ``guardrail_name`` / ``guardrail_mode``
/ ``guardrail_response`` but does not surface ``guardrail_status``
(success / guardrail_intervened / guardrail_failed_to_respond /
not_run) or the violation categories (Bedrock topic policy names,
content filter types, etc.) as queryable span attributes — the data
is buried inside the serialised ``guardrail_response`` blob and cannot
be filtered on in the trace backend.
The tests below use real OTEL SDK objects (TracerProvider +
InMemorySpanExporter + a real BatchSpanProcessor-equivalent) and the
real ``OpenTelemetry`` integration. No monkey patching of the integration
under test — only the OTEL exporter is in-memory.
"""
import os
import sys
import time
import unittest
from datetime import datetime, timedelta, timezone
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import StatusCode
sys.path.insert(0, os.path.abspath("../.."))
from litellm.integrations.opentelemetry import (
LITELLM_REQUEST_SPAN_NAME,
OpenTelemetry,
)
from litellm.proxy._types import UserAPIKeyAuth
GUARDRAIL_SPAN_NAME = "guardrail"
PROXY_SPAN_NAME = "Received Proxy Server Request"
def _bedrock_block_response():
"""Realistic Bedrock ApplyGuardrail response when a topic policy fires.
Mirrors the shape in ``litellm/types/proxy/guardrails/guardrail_hooks/
bedrock_guardrails.py`` so the violation-category extraction can be
tested against the exact payload Bedrock returns.
"""
return {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": [
{
"name": "Fiduciary Advice",
"type": "DENY",
"action": "BLOCKED",
}
]
},
"contentPolicy": {
"filters": [
{
"type": "VIOLENCE",
"confidence": "HIGH",
"action": "BLOCKED",
}
]
},
"wordPolicy": {
"customWords": [{"match": "secret-codeword", "action": "BLOCKED"}],
"managedWordLists": [
{"match": "fuck", "type": "PROFANITY", "action": "BLOCKED"}
],
},
}
],
"outputs": [{"text": "Sorry, the model cannot respond to this request."}],
}
def _slg_entry(
guardrail_status,
guardrail_response,
*,
name="bedrock-test",
mode="pre_call",
provider="bedrock",
start=1.0,
end=2.0,
violation_categories=None,
guardrail_action=None,
):
"""Build a StandardLoggingGuardrailInformation entry the way
``add_standard_logging_guardrail_information_to_request_data`` does."""
entry = {
"guardrail_name": name,
"guardrail_provider": provider,
"guardrail_mode": mode,
"guardrail_response": guardrail_response,
"guardrail_status": guardrail_status,
"start_time": start,
"end_time": end,
"duration": end - start,
}
if violation_categories is not None:
entry["violation_categories"] = violation_categories
if guardrail_action is not None:
entry["guardrail_action"] = guardrail_action
return entry
def _kwargs_with_guardrail(
*,
entries,
parent_span=None,
include_exception=False,
):
"""Build the kwargs / model_call_details shape that the OTEL integration
consumes. ``litellm_params.metadata`` is the SAME dict that the proxy's
``request_data["metadata"]`` becomes after ``update_environment_variables``,
so ``_otel_internal`` dedupe state lives there too."""
metadata = {"standard_logging_guardrail_information": list(entries)}
if parent_span is not None:
metadata["litellm_parent_otel_span"] = parent_span
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"optional_params": {},
"litellm_params": {
"custom_llm_provider": "openai",
"metadata": metadata,
},
"standard_logging_object": {
"id": "test-call-id",
"call_type": "completion",
"metadata": metadata,
"hidden_params": {},
"guardrail_information": list(entries),
},
}
if include_exception:
kwargs["exception"] = Exception("guardrail blocked the request")
return kwargs
def _make_otel():
"""Spin up a real OTEL pipeline backed by an in-memory exporter."""
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
otel = OpenTelemetry(tracer_provider=provider)
otel.tracer = provider.get_tracer(__name__)
return otel, provider, exporter
def _run(coro):
"""Run a coroutine on a fresh event loop and close it — prevents the
"unclosed event loop" / ResourceWarning that you get from
asyncio.new_event_loop().run_until_complete() with no cleanup."""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def _attr(span, key):
return (span.attributes or {}).get(key)
class TestGuardrailSpanOnViolation(unittest.TestCase):
"""Bug 1: when a pre-call guardrail blocks, the guardrail span and the
litellm_request span must both appear with the correct status."""
def test_handle_failure_creates_litellm_request_and_guardrail_spans(self):
"""Driving ``_handle_failure`` with a populated
``standard_logging_object['guardrail_information']`` entry must
emit both spans, parented correctly, with ERROR on the parent."""
otel, _, exporter = _make_otel()
kwargs = _kwargs_with_guardrail(
entries=[
_slg_entry("guardrail_intervened", _bedrock_block_response()),
],
include_exception=True,
)
start = datetime.now(timezone.utc)
end = start + timedelta(milliseconds=20)
otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end)
spans = exporter.get_finished_spans()
litellm_spans = [s for s in spans if s.name == LITELLM_REQUEST_SPAN_NAME]
guardrail_spans = [s for s in spans if s.name == GUARDRAIL_SPAN_NAME]
self.assertEqual(
len(litellm_spans),
1,
"Expected exactly one litellm_request span on guardrail block",
)
self.assertEqual(litellm_spans[0].status.status_code, StatusCode.ERROR)
self.assertEqual(
len(guardrail_spans),
1,
"Expected exactly one guardrail span on guardrail block",
)
# Guardrail span must be a child of the litellm_request span
self.assertIsNotNone(
guardrail_spans[0].parent,
"Guardrail span must be parented (not a root span)",
)
self.assertEqual(
guardrail_spans[0].parent.span_id,
litellm_spans[0].context.span_id,
)
def test_async_post_call_failure_hook_emits_guardrail_span(self):
"""The production failure path on the proxy calls
``async_post_call_failure_hook`` with the (still-populated)
``request_data``. The hook currently only stamps attrs on the proxy
span; it must also emit the guardrail span so the violation is
visible in the trace."""
otel, provider, exporter = _make_otel()
parent_span = provider.get_tracer(__name__).start_span(PROXY_SPAN_NAME)
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test",
parent_otel_span=parent_span,
request_route="/chat/completions",
)
request_data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {
"standard_logging_guardrail_information": [
_slg_entry("guardrail_intervened", _bedrock_block_response())
],
},
}
_run(
otel.async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("guardrail blocked"),
user_api_key_dict=user_api_key_dict,
)
)
spans = exporter.get_finished_spans()
guardrail_spans = [s for s in spans if s.name == GUARDRAIL_SPAN_NAME]
self.assertEqual(
len(guardrail_spans),
1,
"async_post_call_failure_hook must emit the guardrail span when "
"request_data['metadata'] carries standard_logging_guardrail_information",
)
# The guardrail span must be parented to the proxy request span so
# backends correlate it with the rest of the trace.
self.assertIsNotNone(guardrail_spans[0].parent)
self.assertEqual(
guardrail_spans[0].parent.span_id,
parent_span.context.span_id,
)
def test_handle_failure_and_post_call_failure_hook_dedupe(self):
"""When _handle_failure and async_post_call_failure_hook BOTH fire
for the same request (the production flow on a guardrail block),
exactly one guardrail span must be emitted. The dedupe relies on
request_data['metadata'] and kwargs['litellm_params']['metadata']
referencing the SAME dict so _emit_once sees its earlier marker."""
otel, provider, exporter = _make_otel()
parent_span = provider.get_tracer(__name__).start_span(PROXY_SPAN_NAME)
# Shared metadata dict — same identity, mirroring how
# update_environment_variables wires them in the proxy.
shared_metadata = {
"standard_logging_guardrail_information": [
_slg_entry(
"guardrail_intervened",
_bedrock_block_response(),
violation_categories=["Fiduciary Advice"],
)
],
}
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"optional_params": {},
"litellm_params": {
"custom_llm_provider": "openai",
"metadata": shared_metadata,
},
"standard_logging_object": {
"id": "test-call-id",
"call_type": "completion",
"metadata": shared_metadata,
"hidden_params": {},
"guardrail_information": shared_metadata[
"standard_logging_guardrail_information"
],
},
"exception": Exception("guardrail blocked"),
}
request_data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": shared_metadata,
}
user_api_key_dict = UserAPIKeyAuth(
api_key="sk-test",
parent_otel_span=parent_span,
request_route="/chat/completions",
)
start = datetime.now(timezone.utc)
end = start + timedelta(milliseconds=20)
otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end)
_run(
otel.async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("guardrail blocked"),
user_api_key_dict=user_api_key_dict,
)
)
guardrail_spans = [
s for s in exporter.get_finished_spans() if s.name == GUARDRAIL_SPAN_NAME
]
self.assertEqual(
len(guardrail_spans),
1,
"Dedupe must collapse the two emit calls into one span when the "
"metadata dict identity is shared between kwargs and request_data",
)
class TestGuardrailSpanAttributesOnViolation(unittest.TestCase):
"""Bug 2: the guardrail span must surface the violation status and
violation categories as queryable span attributes, not bury them inside
``guardrail_response`` (which is logged as a single serialised blob)."""
def _emit_and_get_guardrail_span(self, entry):
otel, _, exporter = _make_otel()
kwargs = _kwargs_with_guardrail(entries=[entry])
otel._create_guardrail_span(kwargs=kwargs, context=None)
guardrail_spans = [
s for s in exporter.get_finished_spans() if s.name == GUARDRAIL_SPAN_NAME
]
self.assertEqual(len(guardrail_spans), 1)
return guardrail_spans[0]
def test_status_attribute_present_for_intervened(self):
entry = _slg_entry("guardrail_intervened", _bedrock_block_response())
span = self._emit_and_get_guardrail_span(entry)
self.assertEqual(
_attr(span, "guardrail_status"),
"guardrail_intervened",
"guardrail_status must be exposed as a top-level span attribute",
)
def test_status_attribute_present_for_success(self):
entry = _slg_entry(
"success",
{"action": "NONE", "assessments": []},
)
span = self._emit_and_get_guardrail_span(entry)
self.assertEqual(_attr(span, "guardrail_status"), "success")
def test_status_attribute_present_for_failed_to_respond(self):
entry = _slg_entry(
"guardrail_failed_to_respond",
{"error": "endpoint unreachable"},
)
span = self._emit_and_get_guardrail_span(entry)
self.assertEqual(_attr(span, "guardrail_status"), "guardrail_failed_to_respond")
def test_violation_categories_surfaced_when_provider_populates_them(self):
"""The provider hook (e.g. Bedrock) extracts violation categories
from the raw response BEFORE redaction and stamps them onto the
StandardLoggingGuardrailInformation entry. OTEL must surface that
list as a queryable span attribute so dashboards can group by
violation type without parsing the redacted guardrail_response."""
entry = _slg_entry(
"guardrail_intervened",
_bedrock_block_response(),
violation_categories=["Fiduciary Advice", "VIOLENCE", "PROFANITY"],
)
span = self._emit_and_get_guardrail_span(entry)
categories = _attr(span, "guardrail_violation_categories")
self.assertIsNotNone(
categories,
"guardrail_violation_categories must be set when the entry "
"carries violation_categories",
)
# Serialised as JSON to keep set_attribute typing simple.
as_str = categories if isinstance(categories, str) else repr(list(categories))
self.assertIn("Fiduciary Advice", as_str)
self.assertIn("VIOLENCE", as_str)
self.assertIn("PROFANITY", as_str)
def test_no_violation_categories_when_field_absent(self):
"""When the provider didn't populate violation_categories (success
path, or provider didn't extract them), don't pollute the trace
with an empty attribute."""
entry = _slg_entry("success", {"action": "NONE", "assessments": []})
span = self._emit_and_get_guardrail_span(entry)
self.assertIsNone(_attr(span, "guardrail_violation_categories"))
def test_no_violation_categories_when_field_is_empty(self):
"""Empty list must not produce a span attribute either."""
entry = _slg_entry(
"guardrail_intervened",
_bedrock_block_response(),
violation_categories=[],
)
span = self._emit_and_get_guardrail_span(entry)
self.assertIsNone(_attr(span, "guardrail_violation_categories"))
def test_guardrail_action_surfaced_when_provider_populates_it(self):
"""The provider hook (e.g. Bedrock) writes its raw top-level
``action`` string onto StandardLoggingGuardrailInformation as
``guardrail_action``. OTEL must expose it as a queryable span
attribute so dashboards can pivot on the raw provider verdict
(Bedrock ``GUARDRAIL_INTERVENED`` / ``NONE``) without parsing
the redacted guardrail_response blob."""
entry = _slg_entry(
"guardrail_intervened",
_bedrock_block_response(),
guardrail_action="GUARDRAIL_INTERVENED",
)
span = self._emit_and_get_guardrail_span(entry)
self.assertEqual(
_attr(span, "guardrail_action"),
"GUARDRAIL_INTERVENED",
"guardrail_action must be exposed as a top-level span attribute",
)
def test_guardrail_action_surfaced_for_allowed_request(self):
"""Even on the success path, the provider's raw action (e.g.
Bedrock ``NONE``) should be queryable so dashboards can group
allowed-vs-blocked counts off the same attribute."""
entry = _slg_entry(
"success",
{"action": "NONE", "assessments": []},
guardrail_action="NONE",
)
span = self._emit_and_get_guardrail_span(entry)
self.assertEqual(_attr(span, "guardrail_action"), "NONE")
def test_no_guardrail_action_when_field_absent(self):
"""If the provider didn't populate the field (older payloads,
non-Bedrock providers without a top-level action), don't emit
an empty attribute."""
entry = _slg_entry("success", {"action": "NONE", "assessments": []})
span = self._emit_and_get_guardrail_span(entry)
self.assertIsNone(_attr(span, "guardrail_action"))
class TestMultipleGuardrailsOneBlocks(unittest.TestCase):
"""When several guardrails run sequentially and only the last one
intervenes, every guardrail span must appear with its own status —
losing the early "allowed" spans would mask which checks ran."""
def test_all_guardrail_spans_emitted_with_per_entry_status(self):
otel, _, exporter = _make_otel()
entries = [
_slg_entry(
"success",
{"action": "NONE", "assessments": []},
name="pii-mask",
start=1.0,
end=1.5,
),
_slg_entry(
"success",
{"action": "NONE", "assessments": []},
name="prompt-injection",
start=2.0,
end=2.2,
),
_slg_entry(
"guardrail_intervened",
_bedrock_block_response(),
name="bedrock-policy",
start=3.0,
end=3.4,
),
]
kwargs = _kwargs_with_guardrail(
entries=entries,
include_exception=True,
)
start = datetime.now(timezone.utc)
end = start + timedelta(milliseconds=50)
otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end)
spans = exporter.get_finished_spans()
guardrail_spans = sorted(
(s for s in spans if s.name == GUARDRAIL_SPAN_NAME),
key=lambda s: (s.attributes or {}).get("guardrail_name", ""),
)
self.assertEqual(
len(guardrail_spans),
3,
"Every guardrail invocation must emit a span — even the ones "
"that allowed the request through before the blocker fired",
)
statuses = {
_attr(s, "guardrail_name"): _attr(s, "guardrail_status")
for s in guardrail_spans
}
self.assertEqual(statuses["pii-mask"], "success")
self.assertEqual(statuses["prompt-injection"], "success")
self.assertEqual(statuses["bedrock-policy"], "guardrail_intervened")
class TestCustomGuardrailEndToEnd(unittest.TestCase):
"""End-to-end: a real ``CustomGuardrail`` subclass calls
``add_standard_logging_guardrail_information_to_request_data`` and then
raises. We then drive ``_handle_failure`` with the resulting kwargs
(matching the shape ``async_failure_handler`` would build) and verify
the guardrail span carries the recorded information."""
def test_real_custom_guardrail_violation_path(self):
# Deliberately not importing fastapi here — the real Bedrock guardrail
# raises HTTPException, but the OTEL span flow is exception-type
# agnostic. Using a plain Exception keeps this test runnable in
# SDK-only installs that don't ship fastapi.
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.guardrails import GuardrailEventHooks
class BlockingViolation(Exception):
pass
class BlockingGuardrail(CustomGuardrail):
async def async_pre_call_hook(
self,
user_api_key_dict,
cache,
data,
call_type,
):
start_ts = time.time()
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider="bedrock",
guardrail_json_response=_bedrock_block_response(),
request_data=data,
guardrail_status="guardrail_intervened",
start_time=start_ts,
end_time=start_ts + 0.01,
duration=0.01,
event_type=GuardrailEventHooks.pre_call,
tracing_detail={
"violation_categories": ["Fiduciary Advice", "VIOLENCE"]
},
)
raise BlockingViolation("violation")
request_data = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hi"}],
"metadata": {},
}
guardrail = BlockingGuardrail(
guardrail_name="blocking-test",
event_hook=GuardrailEventHooks.pre_call,
)
with self.assertRaises(BlockingViolation):
_run(
guardrail.async_pre_call_hook(
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
cache=None,
data=request_data,
call_type="completion",
)
)
slg_info = request_data["metadata"].get(
"standard_logging_guardrail_information"
)
self.assertTrue(
slg_info,
"Guardrail must have recorded its information to request_data "
"BEFORE raising — otherwise the OTEL hook sees nothing",
)
# Now simulate the OTEL failure handler picking up this metadata
otel, _, exporter = _make_otel()
kwargs = _kwargs_with_guardrail(
entries=slg_info,
include_exception=True,
)
start = datetime.now(timezone.utc)
end = start + timedelta(milliseconds=15)
otel._handle_failure(kwargs, response_obj=None, start_time=start, end_time=end)
spans = exporter.get_finished_spans()
guardrail_spans = [s for s in spans if s.name == GUARDRAIL_SPAN_NAME]
self.assertEqual(len(guardrail_spans), 1)
self.assertEqual(
_attr(guardrail_spans[0], "guardrail_status"),
"guardrail_intervened",
)
self.assertEqual(
_attr(guardrail_spans[0], "guardrail_name"),
"blocking-test",
)
# End-to-end: the violation_categories the guardrail passed through
# tracing_detail must arrive as a queryable span attribute.
categories = _attr(guardrail_spans[0], "guardrail_violation_categories")
self.assertIsNotNone(categories)
self.assertIn("Fiduciary Advice", str(categories))
self.assertIn("VIOLENCE", str(categories))
if __name__ == "__main__":
unittest.main()

View file

@ -2073,6 +2073,226 @@ def test_get_http_exception_includes_assessments_and_identifier():
assert exc.detail["assessments"][0]["matches"][0]["match"] == "[REDACTED]"
def test_extract_violation_category_names_mixed_policies():
"""Topic names, content-filter types, PII types, and managed-word types
flatten into a single category-name list — using only the operator-
defined `name`/`type` labels."""
g = _make_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": [
{"name": "Fiduciary Advice", "action": "BLOCKED"},
{"name": "Tax Advice", "action": "BLOCKED"},
]
},
"contentPolicy": {
"filters": [{"type": "VIOLENCE", "action": "BLOCKED"}]
},
"wordPolicy": {
"managedWordLists": [{"type": "PROFANITY", "action": "BLOCKED"}],
},
"sensitiveInformationPolicy": {
"piiEntities": [{"type": "EMAIL", "action": "BLOCKED"}]
},
}
],
}
names = g._extract_violation_category_names(response)
assert "Fiduciary Advice" in names
assert "Tax Advice" in names
assert "VIOLENCE" in names
assert "PROFANITY" in names
assert "EMAIL" in names
def test_extract_violation_category_names_does_not_leak_user_input():
"""SECURITY: customWords.match is the raw user-submitted word that
triggered the rule, and an unnamed regex match is the actual sensitive
value (e.g. a credit-card number). Neither must appear in
violation_categories — otherwise the content the guardrail blocked
leaks straight into telemetry backends."""
g = _make_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"wordPolicy": {
"customWords": [
{"match": "secret-codeword-abc-123", "action": "BLOCKED"}
],
},
"sensitiveInformationPolicy": {
"regexes": [{"match": "4111-1111-1111-1111", "action": "BLOCKED"}]
},
}
],
}
names = g._extract_violation_category_names(response)
assert "secret-codeword-abc-123" not in names
assert "4111-1111-1111-1111" not in names
assert names == []
def test_extract_violation_category_names_named_regex_uses_name():
"""A regex with a `name` field surfaces that operator-defined label
(safe to log), not the matched value."""
g = _make_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"sensitiveInformationPolicy": {
"regexes": [
{
"name": "credit-card-pattern",
"match": "4111-1111-1111-1111",
"action": "BLOCKED",
}
]
}
}
],
}
names = g._extract_violation_category_names(response)
assert names == ["credit-card-pattern"]
def test_extract_violation_category_names_skips_anonymized():
"""ANONYMIZED entries are not blocks — they must not contribute to the
violation_categories list."""
g = _make_guardrail()
response = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"sensitiveInformationPolicy": {
"piiEntities": [{"type": "NAME", "action": "ANONYMIZED"}]
}
}
],
}
assert g._extract_violation_category_names(response) == []
def test_extract_violation_category_names_no_assessments():
"""Empty / missing assessments → empty list, not an error."""
g = _make_guardrail()
assert g._extract_violation_category_names({"action": "NONE"}) == []
assert g._extract_violation_category_names({"assessments": None}) == []
@pytest.mark.asyncio
async def test_make_bedrock_api_request_forwards_guardrail_action():
"""Bedrock's top-level ``action`` string must be propagated through
``tracing_detail`` so downstream loggers (OTEL, ...) can surface the
raw provider verdict as a queryable attribute without re-parsing the
redacted guardrail_response blob."""
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
mock_credentials = MagicMock()
mock_credentials.access_key = "k"
mock_credentials.secret_key = "s"
mock_credentials.token = None
mock_bedrock_response = MagicMock()
mock_bedrock_response.status_code = 200
mock_bedrock_response.json.return_value = {
"action": "GUARDRAIL_INTERVENED",
"assessments": [
{
"topicPolicy": {
"topics": [{"name": "Fiduciary Advice", "action": "BLOCKED"}]
}
}
],
}
request_data = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
}
with (
patch.object(
guardrail.async_handler, "post", new_callable=AsyncMock
) as mock_post,
patch.object(
guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")
),
patch.object(guardrail, "_prepare_request", return_value=MagicMock()),
patch.object(
guardrail,
"add_standard_logging_guardrail_information_to_request_data",
) as mock_log,
patch.object(
guardrail,
"_get_http_exception_for_blocked_guardrail",
return_value=Exception("blocked"),
),
):
mock_post.return_value = mock_bedrock_response
with pytest.raises(Exception):
await guardrail.make_bedrock_api_request(
source="INPUT",
messages=request_data["messages"],
request_data=request_data,
)
tracing_detail = mock_log.call_args.kwargs["tracing_detail"]
assert tracing_detail is not None
assert tracing_detail["guardrail_action"] == "GUARDRAIL_INTERVENED"
@pytest.mark.asyncio
async def test_make_bedrock_api_request_omits_guardrail_action_when_missing():
"""If the Bedrock response omits ``action`` (older / partial payloads),
the field must be left off ``tracing_detail`` rather than written as
``None`` — downstream code expects strings or absence, not nulls."""
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
mock_credentials = MagicMock()
mock_credentials.access_key = "k"
mock_credentials.secret_key = "s"
mock_credentials.token = None
mock_bedrock_response = MagicMock()
mock_bedrock_response.status_code = 200
mock_bedrock_response.json.return_value = {"assessments": []}
with (
patch.object(
guardrail.async_handler, "post", new_callable=AsyncMock
) as mock_post,
patch.object(
guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")
),
patch.object(guardrail, "_prepare_request", return_value=MagicMock()),
patch.object(
guardrail,
"add_standard_logging_guardrail_information_to_request_data",
) as mock_log,
):
mock_post.return_value = mock_bedrock_response
await guardrail.make_bedrock_api_request(
source="INPUT",
messages=[{"role": "user", "content": "hi"}],
request_data={"model": "gpt-4o", "messages": []},
)
tracing_detail = mock_log.call_args.kwargs["tracing_detail"]
# No violation categories and no action ⇒ tracing_detail stays None
# (the hook collapses an empty dict before forwarding).
if tracing_detail is not None:
assert "guardrail_action" not in tracing_detail
def test_get_http_exception_no_blocked_assessments_omits_field():
"""L3: when no assessments are blocked, the `assessments` key is omitted entirely."""
g = _make_guardrail()