Skip to content

fix(sdk): authenticate OTLP exporter through its own session for OpenTelemetry 1.45 - #1468

Open
yoarajota wants to merge 1 commit into
AgentOps-AI:mainfrom
yoarajota:fix/otlp-exporter-otel-1.45-headers
Open

yoarajota wants to merge 1 commit into
AgentOps-AI:mainfrom
yoarajota:fix/otlp-exporter-otel-1.45-headers

Conversation

@yoarajota

Copy link
Copy Markdown

📥 Pull Request

📘 Description

With opentelemetry-exporter-otlp-proto-http 1.45.0 (released 2026-09-25) installed, agentops.init() no longer delivers any spans. AuthenticatedOTLPExporter._prepare_headers reads self._headers and export swaps self._session.headers. 1.45.0 removed both attributes from OTLPSpanExporter when it moved to the shared opentelemetry-exporter-otlp-common / opentelemetry-exporter-http-transport packages (open-telemetry/opentelemetry-python#5389). Every export raises AttributeError, and the catch-all at the end of export logs it and returns SpanExportResult.FAILURE:

AgentOps: Unexpected error during span export: 'AuthenticatedOTLPExporter' object has no attribute '_headers'

pyproject.toml (and agentops 0.4.21 on PyPI) allows opentelemetry-exporter-otlp-proto-http>1.29.0 on Python >=3.10 with no upper bound, so a fresh install resolves 1.45.1. uv.lock pins 1.31.1 for Python >=3.10, so an environment built from the lockfile does not hit it.

The change creates the requests.Session in AuthenticatedOTLPExporter.__init__, passes it to the parent through the public session= argument, and does the existing JWT header swap on that session. _prepare_headers and export no longer touch _headers or _session. pyproject.toml is untouched; #1458 edits the same OpenTelemetry pins.

🧪 Testing

Base: main at f8e907b. Clean venvs, Python 3.12, one with OTel 1.45.1 (what pip install resolves today), one with the uv.lock pins (sdk/api/exporter 1.31.1, instrumentation/semconv 0.52b1).

Attribute check: hasattr(OTLPSpanExporter(endpoint=..., headers=...), "_headers") and "_session" on 13 releases between 1.31.1 and 1.45.1 (1.31.1, 1.34.1, 1.36.0, 1.37.0, 1.38.0, 1.39.1, 1.40.0, 1.41.1, 1.42.0, 1.43.0, 1.44.0, 1.45.0, 1.45.1). True through 1.44.0, False for 1.45.0 and 1.45.1.

End to end through agentops.init() against a local fake backend (script below; DNS for any non-loopback host is blocked, so nothing leaves the machine):

OTel exporter exporters.py trace requests received Authorization
1.45.1 main 0 (plus the error above) none
1.45.1 this branch 1 Bearer jwt-abc
1.31.1 main 1 Bearer jwt-abc
1.31.1 this branch 1 Bearer jwt-abc

Unit tests, python -m pytest tests/unit/sdk/test_exporters.py -q -p no:cacheprovider -p no:depends -o log_cli=false:

  • main, OTel 1.45.1: 5 failed (test_export_empty_spans_list, test_export_jwt_expired_exception, test_export_success, test_headers_protected_from_override, test_full_export_cycle), 11 passed
  • main, OTel 1.31.1: 16 passed
  • this branch, both: 17 passed

The added test_export_sends_jwt_and_custom_headers runs the real parent export() with requests.Session.send patched, and asserts the request carries Authorization: Bearer <jwt> (a user-supplied Authorization is ignored) and X-Custom-Header. With the base exporters.py restored via git checkout f8e907b -- agentops/sdk/exporters.py, it fails on 1.45.1 (6 failed in total) and passes on 1.31.1.

Full tests/unit on this branch with openai-agents installed: 489 passed, 1 skipped on both OTel 1.45.1 and 1.31.1. ruff@0.12.9 check and ruff format --check on both changed files: clean.

Not run: Python 3.9 with OTel 1.29.0 (the pinned version there). pyproject.toml requires >1.29.0 on Python >=3.10 and I ran 3.12. I read the 1.29.0 source instead: OTLPSpanExporter.__init__ accepts session and does self._session = session or requests.Session().

End-to-end repro script (fakesrv.py + repro.py)

fakesrv.py:

import json, threading
from http.server import BaseHTTPRequestHandler, HTTPServer

class Srv:
    def __init__(self):
        self.hits = []
        outer = self
        class H(BaseHTTPRequestHandler):
            def log_message(self, *a): pass
            def do_POST(self):
                n = int(self.headers.get("Content-Length", 0)); body = self.rfile.read(n)
                outer.hits.append((self.path, {k.lower(): v for k, v in self.headers.items()}, len(body)))
                if self.path == "/v3/auth/token":
                    out = json.dumps({"token": "jwt-abc", "project_id": "p1", "project_prem_status": "pro"}).encode()
                    ct = "application/json"
                else:
                    out, ct = b"", "application/x-protobuf"
                self.send_response(200); self.send_header("Content-Type", ct)
                self.send_header("Content-Length", str(len(out))); self.end_headers(); self.wfile.write(out)
        self.httpd = HTTPServer(("127.0.0.1", 0), H)
        self.port = self.httpd.server_address[1]
        threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
    def trace_hits(self): return [h for h in self.hits if h[0] == "/v1/traces"]

repro.py (run as python -I repro.py from an empty directory, with fakesrv.py in /tmp/probe):

# public path: agentops.init() -> start_trace/end_trace -> flush, against a local fake AgentOps backend
import os, sys, time, socket
os.environ["AGENTOPS_LOGGING_TO_FILE"] = "False"
_gai = socket.getaddrinfo
def guard(host, *a, **k):
    if host not in ("127.0.0.1", "localhost"): raise OSError(f"blocked {host}")
    return _gai(host, *a, **k)
socket.getaddrinfo = guard
sys.path.insert(0, "/tmp/probe")
from fakesrv import Srv
srv = Srv()
import importlib.metadata as md
import agentops
agentops.init(api_key="00000000-0000-0000-0000-000000000000", endpoint=f"http://127.0.0.1:{srv.port}",
              exporter_endpoint=f"http://127.0.0.1:{srv.port}/v1/traces", auto_start_session=False,
              instrument_llm_calls=False)
time.sleep(1.5)
t = agentops.start_trace("probe"); agentops.end_trace(t)
from opentelemetry import trace
trace.get_tracer_provider().force_flush(5000)
time.sleep(0.5)
hits = srv.trace_hits()
print(f"otel-exporter={md.version('opentelemetry-exporter-otlp-proto-http')} auth_requests={len([h for h in srv.hits if h[0]=='/v3/auth/token'])} trace_requests={len(hits)}",
      *([f"authorization={hits[0][1].get('authorization')}"] if hits else []))

OpenTelemetry 1.45 removed OTLPSpanExporter._headers and ._session, so
AuthenticatedOTLPExporter._prepare_headers raised AttributeError and every
span export returned FAILURE. Create the session in the exporter and pass it
to the parent via the public session argument instead.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant