Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions litellm/integrations/opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,9 +726,57 @@ async def async_post_call_failure_hook(
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)
Comment thread
yassin-berriai marked this conversation as resolved.

async def async_post_call_success_hook(
self,
data: dict,
Expand Down Expand Up @@ -1617,6 +1665,37 @@ def _create_guardrail_span(
"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))
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down
53 changes: 53 additions & 0 deletions litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
CallTypesLiteral,
Choices,
GuardrailStatus,
GuardrailTracingDetail,
Message,
ModelResponse,
ModelResponseStream,
Expand Down Expand Up @@ -509,6 +510,8 @@ async def make_bedrock_api_request(
# 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(
Expand All @@ -522,6 +525,7 @@ async def make_bedrock_api_request(
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:
Expand Down Expand Up @@ -640,6 +644,55 @@ def _parse_bedrock_guardrail_error_response(
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]:
Expand Down
16 changes: 16 additions & 0 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
Loading
Loading