Skip to content
Closed
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
58 changes: 57 additions & 1 deletion src/agents/tracing/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tests/test_trace_processor.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
import os
import subprocess
Expand Down Expand Up @@ -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] = {
Expand Down