Skip to content

Commit 74321ee

Browse files
fix(otel): stop sending traces to api.openai.com and cleanup (#2400)
The Agents SDK registers a trace processor that POSTs everything to a hardcoded `https://api.openai.com/v1/traces/ingest` using `OPENAI_API_KEY`. It ignores `OPENAI_API_BASE`, so anyone behind other proxies is shipping their gateway's key to OpenAI and getting 401s back. `OpenAIAgentsInstrumentor` adds the OTel processor next to that one rather than replacing it, and `set_tracing_disabled(True)` would silence the OTel spans too, so we now drop the built-in processor before instrumenting. Set `KAGENT_OPENAI_AGENTS_NATIVE_TRACING=true` to keep it if you're using a real OpenAI key. Using `OpenAIAgentsInstrumentor(replace_existing_processors=True)` as suggested in review, with the pin bumped to `>=0.52.3,<0.53.0` since that's when the kwarg landed. Doing it through the kwarg also means a second `build()` can't wipe the OTel processor, since `instrument()` no-ops once instrumented. Cleaned up a couple of other smaller stuff: - `Resource(...)` -> `Resource.create(...)`: the bare constructor ignores `OTEL_RESOURCE_ATTRIBUTES` and drops `telemetry.sdk.*`, so nobody could set `deployment.environment.name` or `service.version` at all. - Same bug in the Go ADK, `resource.New` starts empty so it needs `WithFromEnv()` + `WithTelemetrySDK()`. - `HTTPXClientInstrumentor().instrument(excluded_urls=...)` did nothing, cleaned it up. - `_a2a.py` read `self.config.kagent_url` but `KAgentConfig` only has `url`, which blows up whenever `KAGENT_URL` isn't set. Rebased onto main, which dropped the Gemini bullet from this PR since main landed the same fix independently. --------- Signed-off-by: krisztianfekete <git@krisztianfekete.org>
1 parent b58666b commit 74321ee

8 files changed

Lines changed: 246 additions & 16 deletions

File tree

go/adk/pkg/telemetry/tracing.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,25 @@ func flushTimeout() time.Duration {
7171
return 3 * time.Second
7272
}
7373

74+
// newTelemetryResource builds the resource describing this service.
75+
func newTelemetryResource(ctx context.Context, serviceName string, serviceNamespace string) (*resource.Resource, error) {
76+
return resource.New(ctx,
77+
resource.WithFromEnv(),
78+
resource.WithTelemetrySDK(),
79+
resource.WithAttributes(
80+
semconv.ServiceNameKey.String(serviceName),
81+
semconv.ServiceNamespaceKey.String(serviceNamespace),
82+
))
83+
}
84+
7485
// Init initializes OpenTelemetry providers for Go ADK, sets global providers and
7586
// propagators, and returns a shutdown function.
7687
func Init(ctx context.Context, serviceName string, serviceNamespace string) (shutdown func(context.Context) error, enabled bool, err error) {
7788
if !isTelemetryEnabled() {
7889
return func(context.Context) error { return nil }, false, nil
7990
}
8091

81-
telemetryResource, err := resource.New(ctx, resource.WithAttributes(
82-
semconv.ServiceNameKey.String(serviceName),
83-
semconv.ServiceNamespaceKey.String(serviceNamespace),
84-
))
92+
telemetryResource, err := newTelemetryResource(ctx, serviceName, serviceNamespace)
8593
if err != nil {
8694
return nil, true, err
8795
}

go/adk/pkg/telemetry/tracing_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,32 @@ func TestFlushTimeout(t *testing.T) {
6565
})
6666
}
6767
}
68+
69+
// The resource must merge OTEL_RESOURCE_ATTRIBUTES and the telemetry.sdk.*
70+
// attributes. resource.New starts empty, so building it from WithAttributes
71+
// alone silently drops everything the environment supplies.
72+
func TestNewTelemetryResourceMergesEnvAttributes(t *testing.T) {
73+
t.Setenv("OTEL_SERVICE_NAME", "should-not-win")
74+
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment.name=prod,service.version=1.4.2")
75+
76+
res, err := newTelemetryResource(context.Background(), "svc", "ns")
77+
if err != nil {
78+
t.Fatalf("newTelemetryResource: %v", err)
79+
}
80+
81+
got := map[string]string{}
82+
for _, kv := range res.Attributes() {
83+
got[string(kv.Key)] = kv.Value.String()
84+
}
85+
for key, want := range map[string]string{
86+
"deployment.environment.name": "prod",
87+
"service.version": "1.4.2",
88+
"telemetry.sdk.language": "go",
89+
"service.name": "svc",
90+
"service.namespace": "ns",
91+
} {
92+
if got[key] != want {
93+
t.Errorf("attribute %s = %q, want %q (all: %v)", key, got[key], want, got)
94+
}
95+
}
96+
}

python/packages/kagent-core/src/kagent/core/tracing/_utils.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,10 @@ def configure(
236236
tracing_enabled = os.getenv("OTEL_TRACING_ENABLED", "false").lower() == "true"
237237
logging_enabled = os.getenv("OTEL_LOGGING_ENABLED", "false").lower() == "true"
238238

239-
resource = Resource({"service.name": name, "service.namespace": namespace})
239+
# Resource.create merges in OTEL_RESOURCE_ATTRIBUTES and the telemetry.sdk.*
240+
# attributes; the bare constructor drops both, so deployment.environment.name,
241+
# service.version and friends never reach the backend.
242+
resource = Resource.create({"service.name": name, "service.namespace": namespace})
240243

241244
# Configure tracing if enabled
242245
if tracing_enabled:
@@ -273,9 +276,11 @@ def configure(
273276

274277
# Exclude agent-card endpoint from traces — this is used as a health
275278
# check endpoint (high-frequency polling requests) and has little
276-
# diagnostic value.
279+
# diagnostic value. Inbound only: HTTPXClientInstrumentor accepts no
280+
# excluded_urls kwarg (newer releases read OTEL_PYTHON_HTTPX_EXCLUDED_URLS
281+
# instead), so passing one here was silently dropped.
277282
_excluded_urls = ".*/\\.well-known/agent-card\\.json"
278-
HTTPXClientInstrumentor().instrument(excluded_urls=_excluded_urls)
283+
HTTPXClientInstrumentor().instrument()
279284
if fastapi_app:
280285
FastAPIInstrumentor().instrument_app(fastapi_app, excluded_urls=_excluded_urls)
281286
# Pre-response flushing is opt-in (the controller sets this on Agent

python/packages/kagent-core/tests/test_tracing_configure.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,42 @@ def fake_instrument_google(logger_provider=None):
8383
assert instrument_calls["google_logger_provider"] is None
8484

8585

86+
def test_configure_resource_merges_otel_env_attributes(monkeypatch):
87+
# OTEL_RESOURCE_ATTRIBUTES is the only way to set deployment.environment.name
88+
# or service.version. The bare Resource() constructor ignores it entirely.
89+
monkeypatch.setenv("OTEL_LOGGING_ENABLED", "false")
90+
monkeypatch.setenv("OTEL_TRACING_ENABLED", "true")
91+
monkeypatch.setenv("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment.name=prod,service.version=1.4.2")
92+
93+
captured = {}
94+
95+
class FakeTracerProvider:
96+
def __init__(self, resource):
97+
captured["resource"] = resource
98+
99+
def add_span_processor(self, processor):
100+
pass
101+
102+
monkeypatch.setattr(_utils, "TracerProvider", FakeTracerProvider)
103+
monkeypatch.setattr(_utils, "_create_span_exporter", lambda **kwargs: object())
104+
monkeypatch.setattr(_utils, "BatchSpanProcessor", lambda exporter: object())
105+
monkeypatch.setattr(_utils.trace, "set_tracer_provider", lambda provider: None)
106+
monkeypatch.setattr(_utils, "HTTPXClientInstrumentor", lambda: SimpleNamespace(instrument=lambda **kw: None))
107+
monkeypatch.setattr(_utils, "OpenAIInstrumentor", lambda **kwargs: SimpleNamespace(instrument=lambda **kw: None))
108+
monkeypatch.setattr(_utils, "_instrument_anthropic", lambda *a, **kw: None)
109+
monkeypatch.setattr(_utils, "_instrument_google_generativeai", lambda *a, **kw: None)
110+
111+
_utils.configure(name="test-agent", namespace="test-ns")
112+
113+
attributes = captured["resource"].attributes
114+
# Identity stays under our control; env-supplied attributes come along.
115+
assert attributes["service.name"] == "test-agent"
116+
assert attributes["service.namespace"] == "test-ns"
117+
assert attributes["deployment.environment.name"] == "prod"
118+
assert attributes["service.version"] == "1.4.2"
119+
assert attributes["telemetry.sdk.language"] == "python"
120+
121+
86122
def test_configure_all_disabled_skips_instrumentation(monkeypatch):
87123
monkeypatch.setenv("OTEL_LOGGING_ENABLED", "false")
88124
monkeypatch.setenv("OTEL_TRACING_ENABLED", "false")
@@ -279,7 +315,7 @@ def test_configure_gates_post_response_flush_on_env(monkeypatch, env_value, expe
279315
)
280316
monkeypatch.setattr(_utils, "OpenAIInstrumentor", lambda **kwargs: SimpleNamespace(instrument=lambda **kw: None))
281317
monkeypatch.setattr(_utils, "_instrument_anthropic", lambda *a, **kw: None)
282-
monkeypatch.setattr(_utils, "_instrument_google_generativeai", lambda: None)
318+
monkeypatch.setattr(_utils, "_instrument_google_generativeai", lambda *a, **kw: None)
283319

284320
app = FastAPI()
285321
_utils.configure(name="test", namespace="test", fastapi_app=app)

python/packages/kagent-openai/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ dependencies = [
1515
"uvicorn>=0.20.0",
1616
"pydantic>=2.0.0",
1717
"typing-extensions>=4.16.0",
18-
"opentelemetry-instrumentation-openai-agents>=0.50.0,<0.53.0"
18+
"opentelemetry-instrumentation-openai-agents>=0.52.3,<0.53.0"
1919
]
2020

2121
[project.optional-dependencies]

python/packages/kagent-openai/src/kagent/openai/_a2a.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,33 @@ def _configure_openai_client() -> None:
8080
logger.info(f"Configured OpenAI client with base URL: {openai_api_base}")
8181

8282

83+
def _configure_openai_agents_tracing() -> None:
84+
"""Export OpenAI Agents SDK traces through OpenTelemetry only.
85+
86+
The SDK's built-in processor POSTs to a hardcoded https://api.openai.com/v1/traces/ingest
87+
using OPENAI_API_KEY, ignoring OPENAI_API_BASE, so agents behind an OpenAI-compatible
88+
gateway (Azure AI Foundry, LiteLLM, ...) send that gateway's key to OpenAI and get 401s.
89+
The instrumentor adds its processor alongside that one, and set_tracing_disabled(True)
90+
would silence the OpenTelemetry spans too, so drop the built-in processor instead.
91+
92+
KAGENT_OPENAI_AGENTS_NATIVE_TRACING=true keeps it, for real OpenAI platform keys.
93+
"""
94+
keep_native = os.getenv("KAGENT_OPENAI_AGENTS_NATIVE_TRACING", "false").strip().lower() == "true"
95+
if keep_native:
96+
logger.info("Keeping the OpenAI Agents SDK native trace exporter alongside OpenTelemetry")
97+
else:
98+
logger.info("Disabling the OpenAI Agents SDK native trace exporter; traces are exported via OpenTelemetry")
99+
100+
OpenAIAgentsInstrumentor(replace_existing_processors=not keep_native).instrument()
101+
102+
if os.getenv("OPENAI_AGENTS_DISABLE_TRACING", "false").strip().lower() in ("true", "1"):
103+
logger.warning(
104+
"OPENAI_AGENTS_DISABLE_TRACING is set, which switches off the Agents SDK tracing that the "
105+
"OpenTelemetry instrumentation feeds on, so no agent spans will be exported. Unset it and rely "
106+
"on KAGENT_OPENAI_AGENTS_NATIVE_TRACING=false (the default) to keep traces away from OpenAI."
107+
)
108+
109+
83110
class KAgentApp:
84111
"""FastAPI application builder for OpenAI Agents SDK with KAgent integration."""
85112

@@ -121,7 +148,7 @@ def build(self) -> FastAPI:
121148

122149
# Create HTTP client with KAgent backend
123150
http_client = httpx.AsyncClient(
124-
base_url=kagent_url_override or self.config.kagent_url,
151+
base_url=kagent_url_override or self.config.url,
125152
)
126153

127154
# Create session factory
@@ -166,19 +193,20 @@ def build(self) -> FastAPI:
166193
# OpenAIInstrumentor, whose SDK monkeypatch breaks Agents SDK streaming.
167194
logger.info("Configuring tracing for KAgent OpenAI app")
168195
configure_tracing(self.config.name, self.config.namespace, app, instrument_openai_client=False)
196+
logger.info("Tracing configured for KAgent OpenAI app")
197+
except Exception as e:
198+
logger.error(f"Failed to configure tracing: {e}")
169199

170-
# Configure tracing for OpenAI Agents SDK
200+
try:
171201
tracing_enabled = os.getenv("OTEL_TRACING_ENABLED", "false").lower() == "true"
172202
if tracing_enabled:
173203
logger.info("Enabling OpenAI Agents SDK tracing")
174-
OpenAIAgentsInstrumentor().instrument()
204+
_configure_openai_agents_tracing()
175205
else:
176206
logger.info("Disabling OpenAI Agents SDK tracing")
177207
set_tracing_disabled(True)
178-
179-
logger.info("Tracing configured for KAgent OpenAI app")
180208
except Exception as e:
181-
logger.error(f"Failed to configure tracing: {e}")
209+
logger.error(f"Failed to configure OpenAI Agents SDK tracing: {e}")
182210

183211
# Add health check endpoints
184212
app.add_route("/health", methods=["GET"], route=health_check)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Tracing wiring for the OpenAI Agents SDK runtime."""
2+
3+
import agents.tracing.setup as agents_tracing_setup
4+
import pytest
5+
from agents.tracing import get_trace_provider
6+
from agents.tracing.processors import BackendSpanExporter
7+
from agents.tracing.traces import NoOpTrace
8+
from opentelemetry.instrumentation.openai_agents import OpenAIAgentsInstrumentor
9+
from opentelemetry.instrumentation.openai_agents._hooks import OpenTelemetryTracingProcessor
10+
11+
from kagent.openai._a2a import _configure_openai_agents_tracing
12+
13+
14+
def _reset():
15+
instrumentor = OpenAIAgentsInstrumentor()
16+
if instrumentor.is_instrumented_by_opentelemetry:
17+
instrumentor.uninstrument()
18+
# Cleared so the next get_trace_provider() rebuilds the SDK default provider,
19+
# which is the state a fresh agent process starts in.
20+
agents_tracing_setup.GLOBAL_TRACE_PROVIDER = None
21+
22+
23+
@pytest.fixture(autouse=True)
24+
def reset_agents_tracing(monkeypatch):
25+
monkeypatch.delenv("KAGENT_OPENAI_AGENTS_NATIVE_TRACING", raising=False)
26+
monkeypatch.delenv("OPENAI_AGENTS_DISABLE_TRACING", raising=False)
27+
_reset()
28+
yield
29+
_reset()
30+
31+
32+
def _processors():
33+
return list(get_trace_provider()._multi_processor._processors)
34+
35+
36+
def _exports_to_openai(processors):
37+
return any(isinstance(getattr(p, "_exporter", None), BackendSpanExporter) for p in processors)
38+
39+
40+
def test_default_provider_exports_to_openai():
41+
"""Guards the premise: the SDK ships the api.openai.com exporter by default."""
42+
assert _exports_to_openai(_processors())
43+
44+
45+
def test_native_exporter_dropped_by_default():
46+
_configure_openai_agents_tracing()
47+
48+
processors = _processors()
49+
assert not _exports_to_openai(processors)
50+
assert [type(p) for p in processors] == [OpenTelemetryTracingProcessor]
51+
52+
53+
def test_repeat_configuration_keeps_opentelemetry_processor():
54+
"""BaseInstrumentor.instrument() no-ops once instrumented.
55+
56+
Anything that cleared processors outside _instrument would wipe the
57+
OpenTelemetry processor on a second call with nothing to reinstall it.
58+
"""
59+
_configure_openai_agents_tracing()
60+
_configure_openai_agents_tracing()
61+
62+
processors = _processors()
63+
assert not _exports_to_openai(processors)
64+
assert [type(p) for p in processors] == [OpenTelemetryTracingProcessor]
65+
66+
67+
def test_spans_still_reach_opentelemetry():
68+
"""Dropping the native exporter must not disable SDK tracing altogether."""
69+
_configure_openai_agents_tracing()
70+
71+
assert not isinstance(get_trace_provider().create_trace("test"), NoOpTrace)
72+
73+
74+
def test_native_exporter_kept_when_opted_in(monkeypatch):
75+
monkeypatch.setenv("KAGENT_OPENAI_AGENTS_NATIVE_TRACING", "true")
76+
77+
_configure_openai_agents_tracing()
78+
79+
processors = _processors()
80+
assert _exports_to_openai(processors)
81+
assert any(isinstance(p, OpenTelemetryTracingProcessor) for p in processors)
82+
83+
84+
def test_build_drops_native_exporter_even_if_configure_tracing_fails(monkeypatch):
85+
"""A broken OTLP setup must not leave the SDK shipping traces to OpenAI."""
86+
from agents import Agent
87+
from kagent.core import KAgentConfig
88+
89+
from kagent.openai import _a2a
90+
91+
monkeypatch.setenv("OTEL_TRACING_ENABLED", "true")
92+
93+
def boom(*args, **kwargs):
94+
raise RuntimeError("no collector")
95+
96+
monkeypatch.setattr(_a2a, "configure_tracing", boom)
97+
98+
agent_card = {
99+
"name": "test",
100+
"description": "test agent",
101+
"version": "0.0.1",
102+
"supportedInterfaces": [{"url": "http://localhost:8080", "protocolBinding": "JSONRPC"}],
103+
"capabilities": {"streaming": True},
104+
"defaultInputModes": ["text/plain"],
105+
"defaultOutputModes": ["text/plain"],
106+
"skills": [],
107+
}
108+
app = _a2a.KAgentApp(
109+
agent=Agent(name="test"),
110+
agent_card=agent_card,
111+
config=KAgentConfig(url="http://localhost", name="test", namespace="test"),
112+
)
113+
app.build()
114+
115+
assert not _exports_to_openai(_processors())
116+
117+
118+
def test_warns_when_sdk_tracing_disabled_by_env(monkeypatch, caplog):
119+
monkeypatch.setenv("OPENAI_AGENTS_DISABLE_TRACING", "1")
120+
121+
with caplog.at_level("WARNING"):
122+
_configure_openai_agents_tracing()
123+
124+
assert "OPENAI_AGENTS_DISABLE_TRACING" in caplog.text

python/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)