fix(langfuse_otel): keep non-ASCII input/output unescaped

safe_dumps gains an ensure_ascii flag (default True, unchanged behavior).
The Langfuse OTEL integration passes ensure_ascii=False so observation
input/output keep non-ASCII text as-is instead of \uXXXX escapes.
This commit is contained in:
jahngalt 2026-09-28 12:55:10 +03:00
parent 90e4962c81
commit 13eb5bf702
4 changed files with 63 additions and 6 deletions

View file

@ -159,7 +159,7 @@ class LangfuseOtelLogger(OpenTelemetry):
safe_set_attribute(
span,
LangfuseSpanAttributes.OBSERVATION_OUTPUT.value,
safe_dumps(transformed_tool_calls),
safe_dumps(transformed_tool_calls, ensure_ascii=False),
)
else:
output_data: Final = {}
@ -171,7 +171,7 @@ class LangfuseOtelLogger(OpenTelemetry):
safe_set_attribute(
span,
LangfuseSpanAttributes.OBSERVATION_OUTPUT.value,
safe_dumps(output_data),
safe_dumps(output_data, ensure_ascii=False),
)
output: Final = response_obj.get("output", [])
@ -215,7 +215,7 @@ class LangfuseOtelLogger(OpenTelemetry):
safe_set_attribute(
span,
LangfuseSpanAttributes.OBSERVATION_OUTPUT.value,
safe_dumps(output_items_data),
safe_dumps(output_items_data, ensure_ascii=False),
)
@staticmethod
@ -250,7 +250,7 @@ class LangfuseOtelLogger(OpenTelemetry):
safe_set_attribute(
span,
LangfuseSpanAttributes.OBSERVATION_INPUT.value,
safe_dumps(messages),
safe_dumps(messages, ensure_ascii=False),
)
LangfuseOtelLogger._set_observation_output(span=span, response_obj=response_obj)

View file

@ -88,6 +88,15 @@ def safe_dumps(
data: object,
max_depth: int = DEFAULT_MAX_RECURSE_DEPTH,
value_transform: Callable[[str | None, str], str] | None = None,
ensure_ascii: bool = True,
) -> str:
"""Serialize data to JSON text through safe_json_structure."""
return json.dumps(safe_json_structure(data, max_depth, value_transform), default=str)
"""Serialize data to JSON text through safe_json_structure.
ensure_ascii=False keeps non-ASCII characters as-is instead of \\uXXXX
escapes; the parsed value is identical either way.
"""
return json.dumps(
safe_json_structure(data, max_depth, value_transform),
default=str,
ensure_ascii=ensure_ascii,
)

View file

@ -362,6 +362,42 @@ class TestLangfuseOtelIntegration:
actual == expect_output
), "Mismatch in observation input/output OTEL attributes."
def test_set_langfuse_specific_attributes_keeps_non_ascii_unescaped(self):
"""Non-ASCII input/output is written as-is, not as \\uXXXX escapes."""
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
from litellm.types.utils import Choices, ModelResponse
response_obj = ModelResponse(
id="chatcmpl-test",
model="gpt-4o",
choices=[
Choices(
finish_reason="stop",
message={"role": "assistant", "content": "В Токио солнечно. 晴れ"},
)
],
)
kwargs = {"messages": [{"role": "user", "content": "Какая погода в Токио?"}]}
with patch(
"litellm.integrations.arize._utils.safe_set_attribute"
) as mock_safe_set_attribute:
LangfuseOtelLogger._set_langfuse_specific_attributes(
MagicMock(), kwargs, response_obj
)
raw = {
call.args[1]: call.args[2]
for call in mock_safe_set_attribute.call_args_list
}
input_raw = raw[LangfuseSpanAttributes.OBSERVATION_INPUT.value]
output_raw = raw[LangfuseSpanAttributes.OBSERVATION_OUTPUT.value]
assert "Какая погода в Токио?" in input_raw
assert "В Токио солнечно. 晴れ" in output_raw
assert "\\u" not in input_raw and "\\u" not in output_raw
assert json.loads(input_raw) == kwargs["messages"]
def test_set_langfuse_specific_attributes_with_tool_calls(self):
"""Test that _set_langfuse_specific_attributes correctly sets observation.output with tool calls in Langfuse format."""
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes

View file

@ -236,3 +236,15 @@ def test_safe_json_structure_keeps_tuples_and_drops_non_string_keys():
assert structure == {"models": ("A", "B"), "tags": ["X", "Y"], "nested": {"deep": ("C",)}}
assert type(structure["models"]) is tuple
assert json.loads(safe_dumps(data)) == {"models": ["a", "b"], "tags": ["x", "y"], "nested": {"deep": ["c"]}}
def test_ensure_ascii_default_escapes_non_ascii():
# Default stays json.dumps-compatible: non-ASCII is escaped
assert safe_dumps({"text": "Привет"}) == '{"text": "\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"}'
def test_ensure_ascii_false_keeps_non_ascii():
data = {"text": "Привет, 世界", "nested": ["ü", {"k": "é"}]}
result = safe_dumps(data, ensure_ascii=False)
assert result == '{"text": "Привет, 世界", "nested": ["ü", {"k": "é"}]}'
assert json.loads(result) == json.loads(safe_dumps(data))