Skip to content

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

Closed
Anai-Guo wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
Anai-Guo:fix/otel-guardrail-mode-list-hashable
Closed

fix(opentelemetry): normalize list-valued guardrail_mode for span dedupe (#28486)#28500
Anai-Guo wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
Anai-Guo:fix/otel-guardrail-mode-list-hashable

Conversation

@Anai-Guo

Copy link
Copy Markdown
Contributor

Summary

Closes the HTTP/500 path reported in #28486. When a guardrail is configured with a list-valued mode (e.g. mode: ["pre_call", "post_call"]) and the OpenTelemetry integration is active, every guardrailed request crashes with TypeError: unhashable type: 'list'.

Root cause

OpenTelemetry._emit_once builds the per-request dedupe key as a tuple:

dedupe_key = (self.__class__.__name__, id(self), *scope)
if spans_logged.get(dedupe_key) is True:
    ...

_create_guardrail_span passes guardrail_information.get("guardrail_mode") into *scope directly. When the configured mode is a list, the resulting tuple contains a list, becomes unhashable, and spans_logged.get(dedupe_key) raises.

_emit_once's docstring already says “scope parts can be any hashable identity” — so the fix belongs at the call site that knows guardrail_mode can be a user-supplied list.

Bisected to 1c4e4d4 (#27757), which introduced the _emit_once-based dedupe loop.

Fix

Normalize guardrail_mode to a tuple before passing it into _emit_once. The value emitted as the guardrail_mode span attribute is unchanged — only the dedupe-key form is normalized.

Test plan

  • Added TestOpenTelemetryGuardrails.test_create_guardrail_span_with_list_mode_is_hashable — exercises _create_guardrail_span with guardrail_mode: ["pre_call", "post_call"]. Without the fix it raises TypeError: unhashable type: 'list'; with the fix it produces exactly one span.
  • Existing TestOpenTelemetryGuardrails cases (string guardrail_mode) are unchanged.

AI-assisted, human reviewed.

…upe (BerriAI#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 BerriAI#28486. Bisected to 1c4e4d4 ("Fix 3 OpenTelemetry tracing
bugs in proxy integration (BerriAI#27757)").
@CLAassistant

CLAassistant commented May 21, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


Anai-Guo seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@codspeed-hq

codspeed-hq Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing Anai-Guo:fix/otel-guardrail-mode-list-hashable (b012ffe) with main (79b4578)

Open in CodSpeed

@codecov

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@oss-pr-review-agent-shin

Copy link
Copy Markdown
Contributor

🤖 litellm-agent: This PR is currently BLOCKED from merge.

Score: 2/5

Why blocked:

  • 1 PR-related CI failure (Greptile gate: score not yet reviewed below required 4/5 — request a Greptile review (@greptileai) and resolve its comments before maintainer review.) (pr_related_failures, -2 pts)
  • Greptile commented but no Confidence Score line was found (greptile_null, -1 pts)

Details: Score docked for: 1 PR-related CI failure (Greptile gate: score not yet reviewed below required 4/5 — request a Greptile review (@greptileai) and resolve its comments before maintainer review.); Greptile commented but no Confidence Score line was found.

Fix the issues above and push an update — the bot will re-review automatically.

Note: This bot is still in beta and might not always work as expected. Please share any feedback via Slack.

@Anai-Guo

Copy link
Copy Markdown
Contributor Author

@greptileai please review — flagged as missing Confidence Score by litellm-agent.

@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a TypeError: unhashable type: 'list' crash in OpenTelemetry._create_guardrail_span that occurred whenever a guardrail's mode was configured as a list (e.g. ["pre_call", "post_call"]). The fix normalizes the list to a tuple before it is used as part of the dedupe key in _emit_once.

  • Core fix: guardrail_mode is converted to a tuple before being passed to _emit_once; the span attribute itself is still emitted from the raw (un-normalized) guardrail_information value, preserving its original representation.
  • Bundled change: guardrail_response serialization switches from safe_set_attribute (which emits raw strings as-is) to guardrail_span.set_attribute(..., safe_dumps(...)), which JSON-encodes every value — including plain strings — via json.dumps. This changes the span attribute for existing string-valued guardrail_response entries from \"filtered_content\" to '\"filtered_content\"' (with surrounding quotes), a backwards-incompatible format change for any downstream consumer parsing that attribute.
  • Tests: A new regression test exercises the list-mode path and verifies exactly one span is emitted; two additional tests cover dict and None response serialization.

Confidence Score: 3/5

The hashability fix is correct and minimal, but the bundled guardrail_response serialization change alters the format of an existing span attribute for string values, which may break downstream trace consumers.

The core hashability fix is well-scoped and backed by a direct regression test. However, the same commit also changes how guardrail_response is written to spans: string values previously landed as bare strings and now land as JSON-encoded strings with surrounding quotes. Any monitoring pipeline, dashboard, or parsing code that currently reads guardrail_response and expects a raw string will silently receive a differently-formatted value after this merges. The updated test assertion confirms the change is intentional, but it is not called out in the PR description and will affect existing deployments without warning.

litellm/integrations/opentelemetry.py lines 1621-1625 — the guardrail_response serialization change that is bundled with the hashability fix.

Important Files Changed

Filename Overview
litellm/integrations/opentelemetry.py Fixes list-valued guardrail_mode hashability crash; also changes guardrail_response serialization from raw-string to json.dumps output, which is a backwards-incompatible behavioral change for string-valued responses.
tests/test_litellm/integrations/test_opentelemetry.py Adds regression test for list-mode hashability fix and two new tests for guardrail_response serialization; updates existing string-response assertion to safe_dumps, confirming the behavioral change.

Reviews (1): Last reviewed commit: "fix(opentelemetry): normalize list-value..." | Re-trigger Greptile

Comment on lines +1621 to +1625
guardrail_response = guardrail_information.get("guardrail_response")
if guardrail_response is not None:
guardrail_span.set_attribute(
"guardrail_response", safe_dumps(guardrail_response)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 String guardrail_response is now double-encoded as JSON

The old safe_set_attribute path returned a raw string value for str inputs (via _cast_as_primitive_value_type). The new path calls safe_dumps, which always passes values through json.dumps — so a string like "filtered_content" now becomes '"filtered_content"' (JSON-encoded with surrounding quotes) in the span. Any downstream consumer or dashboard that reads guardrail_response and expects a raw string will see extra surrounding quotes after this change. Dict/complex responses are correctly improved, but existing string-valued responses regress. The updated test reflects this by swapping the assertion to safe_dumps("filtered_content"), which confirms the behavioral change.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@Anai-Guo
Anai-Guo changed the base branch from main to litellm_internal_staging May 28, 2026 01:12
Anai-Guo added 2 commits May 27, 2026 18:12
The previous reassignment of guardrail_mode to a tuple tripped mypy
because the variable inferred type from .get() is
GuardrailEventHooks | list[GuardrailEventHooks] | GuardrailMode | None,
which does not include tuple. Use a separately-named Any-typed binding
for the hashable form passed to _emit_once, preserving the original
runtime fix and keeping mypy green.
@Anai-Guo

Copy link
Copy Markdown
Contributor Author

Superseded by #31262 (merged 2026-06-25), which makes the guardrail_mode scope hashable in _emit_once for the same list-valued case. Closing — thanks!

@Anai-Guo Anai-Guo closed this Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants