Skip to content

Commit 7f59aa5

Browse files
seratchOiPunk
andauthored
fix(tracing): #2444 drop unsupported usage fields for OpenAI trace ingest (#2448)
Co-authored-by: liweiguang <codingpunk@gmail.com>
1 parent d2e9c93 commit 7f59aa5

File tree

2 files changed

+195
-2
lines changed

2 files changed

+195
-2
lines changed

src/agents/tracing/processors.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,23 @@ def export(self, items: list[Trace | Span[Any]]) -> None:
2828

2929

3030
class BackendSpanExporter(TracingExporter):
31+
_OPENAI_TRACING_INGEST_ENDPOINT = "https://api.openai.com/v1/traces/ingest"
32+
_OPENAI_TRACING_ALLOWED_USAGE_KEYS = frozenset(
33+
{
34+
"input_tokens",
35+
"output_tokens",
36+
"total_tokens",
37+
"input_tokens_details",
38+
"output_tokens_details",
39+
}
40+
)
41+
3142
def __init__(
3243
self,
3344
api_key: str | None = None,
3445
organization: str | None = None,
3546
project: str | None = None,
36-
endpoint: str = "https://api.openai.com/v1/traces/ingest",
47+
endpoint: str = _OPENAI_TRACING_INGEST_ENDPOINT,
3748
max_retries: int = 3,
3849
base_delay: float = 1.0,
3950
max_delay: float = 30.0,
@@ -103,7 +114,14 @@ def export(self, items: list[Trace | Span[Any]]) -> None:
103114
logger.warning("OPENAI_API_KEY is not set, skipping trace export")
104115
continue
105116

106-
data = [item.export() for item in grouped if item.export()]
117+
sanitize_for_openai = self._should_sanitize_for_openai_tracing_api()
118+
data: list[dict[str, Any]] = []
119+
for item in grouped:
120+
exported = item.export()
121+
if exported:
122+
if sanitize_for_openai:
123+
exported = self._sanitize_for_openai_tracing_api(exported)
124+
data.append(exported)
107125
payload = {"data": data}
108126

109127
headers = {
@@ -160,6 +178,36 @@ def export(self, items: list[Trace | Span[Any]]) -> None:
160178
time.sleep(sleep_time)
161179
delay = min(delay * 2, self.max_delay)
162180

181+
def _should_sanitize_for_openai_tracing_api(self) -> bool:
182+
return self.endpoint.rstrip("/") == self._OPENAI_TRACING_INGEST_ENDPOINT.rstrip("/")
183+
184+
def _sanitize_for_openai_tracing_api(self, payload_item: dict[str, Any]) -> dict[str, Any]:
185+
"""Drop fields known to be rejected by OpenAI tracing ingestion."""
186+
span_data = payload_item.get("span_data")
187+
if not isinstance(span_data, dict):
188+
return payload_item
189+
190+
if span_data.get("type") != "generation":
191+
return payload_item
192+
193+
usage = span_data.get("usage")
194+
if not isinstance(usage, dict):
195+
return payload_item
196+
197+
filtered_usage = {
198+
key: value
199+
for key, value in usage.items()
200+
if key in self._OPENAI_TRACING_ALLOWED_USAGE_KEYS
201+
}
202+
if filtered_usage == usage:
203+
return payload_item
204+
205+
sanitized_span_data = dict(span_data)
206+
sanitized_span_data["usage"] = filtered_usage
207+
sanitized_payload_item = dict(payload_item)
208+
sanitized_payload_item["span_data"] = sanitized_span_data
209+
return sanitized_payload_item
210+
163211
def close(self):
164212
"""Close the underlying HTTP client."""
165213
self._client.close()

tests/test_trace_processor.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import time
3+
from typing import Any, cast
34
from unittest.mock import MagicMock, patch
45

56
import httpx
@@ -276,3 +277,147 @@ def test_backend_span_exporter_close(mock_client):
276277

277278
# Ensure underlying http client is closed
278279
mock_client.return_value.close.assert_called_once()
280+
281+
282+
@patch("httpx.Client")
283+
def test_backend_span_exporter_sanitizes_generation_usage_for_openai_tracing(mock_client):
284+
"""Unsupported usage keys should be stripped before POSTing to OpenAI tracing."""
285+
286+
class DummyItem:
287+
tracing_api_key = None
288+
289+
def __init__(self):
290+
self.exported_payload: dict[str, Any] = {
291+
"object": "trace.span",
292+
"span_data": {
293+
"type": "generation",
294+
"usage": {
295+
"requests": 1,
296+
"input_tokens": 10,
297+
"output_tokens": 5,
298+
"total_tokens": 15,
299+
"input_tokens_details": {"cached_tokens": 1},
300+
"output_tokens_details": {"reasoning_tokens": 2},
301+
},
302+
},
303+
}
304+
305+
def export(self):
306+
return self.exported_payload
307+
308+
mock_response = MagicMock()
309+
mock_response.status_code = 200
310+
mock_client.return_value.post.return_value = mock_response
311+
312+
exporter = BackendSpanExporter(api_key="test_key")
313+
item = DummyItem()
314+
exporter.export([cast(Any, item)])
315+
316+
sent_payload = mock_client.return_value.post.call_args.kwargs["json"]["data"][0]
317+
sent_usage = sent_payload["span_data"]["usage"]
318+
assert "requests" not in sent_usage
319+
assert sent_usage["input_tokens"] == 10
320+
assert sent_usage["output_tokens"] == 5
321+
assert sent_usage["total_tokens"] == 15
322+
assert sent_usage["input_tokens_details"] == {"cached_tokens": 1}
323+
assert sent_usage["output_tokens_details"] == {"reasoning_tokens": 2}
324+
325+
# Ensure the original exported object has not been mutated.
326+
assert "requests" in item.exported_payload["span_data"]["usage"]
327+
exporter.close()
328+
329+
330+
@patch("httpx.Client")
331+
def test_backend_span_exporter_keeps_generation_usage_for_custom_endpoint(mock_client):
332+
class DummyItem:
333+
tracing_api_key = None
334+
335+
def __init__(self):
336+
self.exported_payload = {
337+
"object": "trace.span",
338+
"span_data": {
339+
"type": "generation",
340+
"usage": {
341+
"requests": 1,
342+
"input_tokens": 10,
343+
"output_tokens": 5,
344+
},
345+
},
346+
}
347+
348+
def export(self):
349+
return self.exported_payload
350+
351+
mock_response = MagicMock()
352+
mock_response.status_code = 200
353+
mock_client.return_value.post.return_value = mock_response
354+
355+
exporter = BackendSpanExporter(
356+
api_key="test_key",
357+
endpoint="https://example.com/v1/traces/ingest",
358+
)
359+
exporter.export([cast(Any, DummyItem())])
360+
361+
sent_payload = mock_client.return_value.post.call_args.kwargs["json"]["data"][0]
362+
assert sent_payload["span_data"]["usage"]["requests"] == 1
363+
assert sent_payload["span_data"]["usage"]["input_tokens"] == 10
364+
assert sent_payload["span_data"]["usage"]["output_tokens"] == 5
365+
exporter.close()
366+
367+
368+
@patch("httpx.Client")
369+
def test_backend_span_exporter_does_not_modify_non_generation_usage(mock_client):
370+
class DummyItem:
371+
tracing_api_key = None
372+
373+
def export(self):
374+
return {
375+
"object": "trace.span",
376+
"span_data": {
377+
"type": "function",
378+
"usage": {"requests": 1},
379+
},
380+
}
381+
382+
mock_response = MagicMock()
383+
mock_response.status_code = 200
384+
mock_client.return_value.post.return_value = mock_response
385+
386+
exporter = BackendSpanExporter(api_key="test_key")
387+
exporter.export([cast(Any, DummyItem())])
388+
389+
sent_payload = mock_client.return_value.post.call_args.kwargs["json"]["data"][0]
390+
assert sent_payload["span_data"]["usage"] == {"requests": 1}
391+
exporter.close()
392+
393+
394+
def test_sanitize_for_openai_tracing_api_keeps_allowed_generation_usage():
395+
exporter = BackendSpanExporter(api_key="test_key")
396+
payload = {
397+
"object": "trace.span",
398+
"span_data": {
399+
"type": "generation",
400+
"usage": {
401+
"input_tokens": 1,
402+
"output_tokens": 2,
403+
"total_tokens": 3,
404+
"input_tokens_details": {"cached_tokens": 0},
405+
"output_tokens_details": {"reasoning_tokens": 0},
406+
},
407+
},
408+
}
409+
assert exporter._sanitize_for_openai_tracing_api(payload) is payload
410+
exporter.close()
411+
412+
413+
def test_sanitize_for_openai_tracing_api_skips_non_dict_generation_usage():
414+
exporter = BackendSpanExporter(api_key="test_key")
415+
payload = {
416+
"object": "trace.span",
417+
"span_data": {
418+
"type": "generation",
419+
"usage": None,
420+
},
421+
}
422+
assert exporter._sanitize_for_openai_tracing_api(payload) is payload
423+
exporter.close()

0 commit comments

Comments
 (0)