From 301c78efb2771bbfde385cf8a37d43c5abdcd968 Mon Sep 17 00:00:00 2001 From: pragnyanramtha Date: Sun, 17 May 2026 00:15:38 +0000 Subject: [PATCH] fix: preserve unsafe trace integers --- src/agents/tracing/processors.py | 58 +++++++++++++++++++- tests/test_trace_processor.py | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 6711bc92de..62f20c57da 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -34,6 +34,7 @@ class BackendSpanExporter(TracingExporter): _OPENAI_TRACING_INGEST_ENDPOINT = "https://api.openai.com/v1/traces/ingest" _OPENAI_TRACING_MAX_FIELD_BYTES = 100_000 _OPENAI_TRACING_STRING_TRUNCATION_SUFFIX = "... [truncated]" + _MAX_JAVASCRIPT_SAFE_INTEGER = 9_007_199_254_740_991 _OPENAI_TRACING_ALLOWED_USAGE_KEYS = frozenset( { "input_tokens", @@ -251,7 +252,7 @@ def _sanitize_for_openai_tracing_api(self, payload_item: dict[str, Any]) -> dict for field_name in ("input", "output"): if field_name not in span_data: continue - sanitized_field = self._truncate_span_field_value(span_data[field_name]) + sanitized_field = self._sanitize_span_field_value(span_data[field_name]) if sanitized_field is span_data[field_name]: continue if not did_mutate: @@ -344,6 +345,61 @@ def _truncate_span_field_value(self, value: Any) -> Any: return self._truncate_json_value_for_limit(sanitized_value, max_bytes) + def _sanitize_span_field_value(self, value: Any) -> Any: + normalized_value = self._normalize_trace_numbers_for_json_viewers(value) + truncated_value = self._truncate_span_field_value(normalized_value) + if truncated_value is value: + return value + return truncated_value + + def _normalize_trace_numbers_for_json_viewers(self, value: Any) -> Any: + """Stringify unsafe integers so browser-based trace viewers do not round them.""" + if isinstance(value, str): + try: + parsed_value = json.loads(value) + except (TypeError, ValueError): + return value + normalized_value = self._normalize_trace_numbers_for_json_viewers(parsed_value) + if normalized_value is parsed_value: + return value + return json.dumps(normalized_value, ensure_ascii=False, separators=(",", ":")) + + if isinstance(value, bool): + return value + + if isinstance(value, int): + if abs(value) > self._MAX_JAVASCRIPT_SAFE_INTEGER: + return str(value) + return value + + if isinstance(value, dict): + normalized_dict: dict[Any, Any] | None = None + for key, nested_value in value.items(): + normalized_nested = self._normalize_trace_numbers_for_json_viewers(nested_value) + if normalized_nested is nested_value: + if normalized_dict is not None: + normalized_dict[key] = nested_value + continue + if normalized_dict is None: + normalized_dict = dict(value) + normalized_dict[key] = normalized_nested + return value if normalized_dict is None else normalized_dict + + if isinstance(value, list): + normalized_list: list[Any] | None = None + for index, nested_value in enumerate(value): + normalized_nested = self._normalize_trace_numbers_for_json_viewers(nested_value) + if normalized_nested is nested_value: + if normalized_list is not None: + normalized_list.append(nested_value) + continue + if normalized_list is None: + normalized_list = value[:index] + normalized_list.append(normalized_nested) + return value if normalized_list is None else normalized_list + + return value + def _truncate_json_value_for_limit(self, value: Any, max_bytes: int) -> Any: if self._value_json_size_bytes(value) <= max_bytes: return value diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index c0d8898599..ccb8a411fc 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -1,3 +1,4 @@ +import json import logging import os import subprocess @@ -1139,6 +1140,97 @@ def test_sanitize_for_openai_tracing_api_keeps_small_input_without_mutation(): exporter.close() +def test_sanitize_for_openai_tracing_api_stringifies_unsafe_integers_in_json_input(): + exporter = BackendSpanExporter(api_key="test_key") + unsafe_integer = BackendSpanExporter._MAX_JAVASCRIPT_SAFE_INTEGER + 1 + payload = { + "object": "trace.span", + "span_data": { + "type": "function", + "input": json.dumps( + { + "safe": BackendSpanExporter._MAX_JAVASCRIPT_SAFE_INTEGER, + "unsafe": unsafe_integer, + "nested": [unsafe_integer * -1], + "flag": True, + } + ), + }, + } + + sanitized = exporter._sanitize_for_openai_tracing_api(payload) + + assert sanitized is not payload + assert json.loads(sanitized["span_data"]["input"]) == { + "safe": BackendSpanExporter._MAX_JAVASCRIPT_SAFE_INTEGER, + "unsafe": str(unsafe_integer), + "nested": [str(unsafe_integer * -1)], + "flag": True, + } + assert json.loads(payload["span_data"]["input"])["unsafe"] == unsafe_integer + exporter.close() + + +def test_sanitize_for_openai_tracing_api_stringifies_unsafe_integers_in_structured_output(): + exporter = BackendSpanExporter(api_key="test_key") + unsafe_integer = BackendSpanExporter._MAX_JAVASCRIPT_SAFE_INTEGER + 1 + payload = { + "object": "trace.span", + "span_data": { + "type": "function", + "output": { + "safe": BackendSpanExporter._MAX_JAVASCRIPT_SAFE_INTEGER, + "unsafe": unsafe_integer, + "nested": [{"value": unsafe_integer}], + }, + }, + } + + sanitized = exporter._sanitize_for_openai_tracing_api(payload) + + assert sanitized["span_data"]["output"] == { + "safe": BackendSpanExporter._MAX_JAVASCRIPT_SAFE_INTEGER, + "unsafe": str(unsafe_integer), + "nested": [{"value": str(unsafe_integer)}], + } + assert payload["span_data"]["output"]["unsafe"] == unsafe_integer + exporter.close() + + +@patch("httpx.Client") +def test_backend_span_exporter_keeps_unsafe_integers_for_custom_endpoint(mock_client): + class DummyItem: + tracing_api_key = None + + def __init__(self): + self.unsafe_integer = BackendSpanExporter._MAX_JAVASCRIPT_SAFE_INTEGER + 1 + self.exported_payload = { + "object": "trace.span", + "span_data": { + "type": "function", + "input": json.dumps({"unsafe": self.unsafe_integer}), + }, + } + + def export(self): + return self.exported_payload + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.return_value.post.return_value = mock_response + + exporter = BackendSpanExporter( + api_key="test_key", + endpoint="https://example.com/v1/traces/ingest", + ) + item = DummyItem() + exporter.export([cast(Any, item)]) + + sent_payload = mock_client.return_value.post.call_args.kwargs["json"]["data"][0] + assert sent_payload["span_data"]["input"] == item.exported_payload["span_data"]["input"] + exporter.close() + + def test_sanitize_for_openai_tracing_api_truncates_oversized_output(): exporter = BackendSpanExporter(api_key="test_key") payload: dict[str, Any] = {