fix(opentelemetry): normalize list-valued guardrail_mode for span dedupe (#28486)

`_emit_once` builds the per-request dedupe key as
`(self.__class__.__name__, id(self), *scope)` and looks it up in a
dict. When the guardrail is configured with a list-valued mode
(e.g. `mode: ["pre_call", "post_call"]`), `_create_guardrail_span`
passes that list straight into `scope`, so the resulting tuple is
unhashable. `dict.get` then raises `TypeError: unhashable type:
'list'` and the proxy returns HTTP/500 for every guardrailed
request when OTel is enabled.

Normalize `guardrail_mode` to a tuple at the call site so the
hashable-scope contract documented on `_emit_once` is honored,
without changing what is emitted as the span attribute.

Regression test exercises the failing code path; it raised before
this change and passes after.

Refs #28486. Bisected to 1c4e4d4 ("Fix 3 OpenTelemetry tracing
bugs in proxy integration (#27757)").
This commit is contained in:
Anai-Guo 2026-05-21 12:10:51 -07:00
parent b60d4677cd
commit b012ffe74b
2 changed files with 37 additions and 1 deletions

View file

@ -1565,12 +1565,19 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
# ``_handle_failure``) and re-reads the (mutating) entry list
# each time. Dedupe at entry granularity so a single real
# guardrail invocation produces exactly one span per handler.
#
# ``guardrail_mode`` is user-configured and may be a list
# (e.g. ``mode: ["pre_call", "post_call"]``); ``_emit_once``
# requires hashable scope parts, so normalize lists to tuples.
guardrail_mode = guardrail_information.get("guardrail_mode")
if isinstance(guardrail_mode, list):
guardrail_mode = tuple(guardrail_mode)
if not self._emit_once(
kwargs,
"guardrail",
guardrail_information.get("guardrail_name"),
start_time_float,
guardrail_information.get("guardrail_mode"),
guardrail_mode,
):
continue

View file

@ -146,6 +146,35 @@ class TestOpenTelemetryGuardrails(unittest.TestCase):
]
self.assertNotIn("guardrail_response", attribute_keys)
def test_create_guardrail_span_with_list_mode_is_hashable(self):
"""Regression: when ``guardrail_mode`` is configured as a list
(e.g. ``mode: ["pre_call", "post_call"]``), ``_emit_once`` builds
a dedupe-key tuple containing the mode; using the list directly
raised ``TypeError: unhashable type: 'list'`` and produced an
HTTP/500 for every guardrailed request. The mode must be
normalized to a hashable form before lookup.
"""
otel = OpenTelemetry()
otel.tracer = MagicMock()
mock_span = MagicMock()
otel.tracer.start_span.return_value = mock_span
guardrail_info = {
"guardrail_name": "custom-guardrail",
"guardrail_mode": ["pre_call", "post_call"],
"start_time": 1609459200.0,
"end_time": 1609459201.0,
}
kwargs = {
"standard_logging_object": {"guardrail_information": [guardrail_info]}
}
# Must not raise ``TypeError: unhashable type: 'list'``.
otel._create_guardrail_span(kwargs=kwargs, context=None)
otel.tracer.start_span.assert_called_once()
mock_span.set_attribute.assert_any_call("guardrail_name", "custom-guardrail")
class TestOpenTelemetryTeamAttributesOnChildSpans(unittest.TestCase):
"""team_id / team_alias must land on every child span of a