Skip to content
Merged
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
37 changes: 28 additions & 9 deletions src/agentevals/otel/sdk_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
outbound requests.

The exporters are built with an explicit endpoint, explicit headers, an explicit session and
gzip, and never read ``OTEL_EXPORTER_OTLP_*``: the OTLP exporters fall back to those variables for
anything left unset, which would send the application's backend credentials to agentevals.
gzip. The OTLP exporters still fill in headers and TLS options from ``OTEL_EXPORTER_OTLP_*``, and
some versions merge them with explicit values, which would send the application's backend
credentials to agentevals. The session therefore enforces isolation on every request it sends.
"""

from __future__ import annotations
Expand Down Expand Up @@ -44,6 +45,19 @@
PREFLIGHT_TIMEOUT_SECONDS = 2.0
EXPORT_TIMEOUT_SECONDS = 10.0
_SDK_HEADER = {"x-agentevals-sdk": "1"}
_ALLOWED_HEADERS = frozenset(
{
"accept",
"accept-encoding",
"connection",
"content-encoding",
"content-length",
"content-type",
"transfer-encoding",
"user-agent",
*_SDK_HEADER,
}
)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -136,18 +150,23 @@ def _http_session() -> Any:
"""A requests session that ignores ``.netrc`` and proxy variables, so nothing configured for
other hosts is applied to the agentevals endpoint.

It also drops the TLS options of every request. The exporters build them in ``__init__`` from
``OTEL_EXPORTER_OTLP_*CERTIFICATE`` and ``*CLIENT_KEY`` when they are not passed, and an empty
value cannot be passed, so the application's client certificate and CA bundle would otherwise
be used for agentevals. Requests verify against the default trust store with no client
certificate."""
The exporters configure TLS and headers from ``OTEL_EXPORTER_OTLP_*CERTIFICATE``,
``*CLIENT_KEY`` and ``*HEADERS`` when they are not passed, and an empty value cannot be passed.
Depending on the version those values land on the request or on the session itself, so isolation is
enforced in ``send``, after requests has merged session settings and before anything goes on
the wire: requests verify against the default trust store with no client certificate and no
proxy, and carry only transport headers and the SDK marker."""
import requests

class _IsolatedSession(requests.Session):
def request(self, method: str, url: str, **kwargs: Any) -> Any:
def send(self, request: Any, **kwargs: Any) -> Any:
kwargs["verify"] = True
kwargs["cert"] = None
return super().request(method, url, **kwargs)
kwargs["proxies"] = {}
for name in list(request.headers):
if name.lower() not in _ALLOWED_HEADERS:
del request.headers[name]
return super().send(request, **kwargs)

session = _IsolatedSession()
session.trust_env = False
Expand Down
123 changes: 92 additions & 31 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,51 +127,112 @@ def test_otel_exporter_endpoint_is_never_read(self, monkeypatch):
assert sdk_export.resolve_endpoint(None, None) == "http://localhost:4318"


_APPLICATION_SECRETS = {"authorization", "cookie", "x-api-key"}
_APPLICATION_HEADER_ENVS = {
"generic": {"OTEL_EXPORTER_OTLP_HEADERS": "authorization=Bearer app-token,cookie=sid=1"},
"per_signal": {
"OTEL_EXPORTER_OTLP_HEADERS": "authorization=Bearer app-token",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS": "x-api-key=app-key",
"OTEL_EXPORTER_OTLP_LOGS_HEADERS": "x-api-key=app-key",
},
}


def _export_span(endpoint: str) -> None:
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(sdk_export._span_exporter(endpoint)))
provider.get_tracer("t").start_span("s").end()


def _export_log(endpoint: str) -> None:
from opentelemetry._logs import LogRecord
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor

provider = LoggerProvider()
provider.add_log_record_processor(SimpleLogRecordProcessor(sdk_export._log_exporter(endpoint)))
provider.get_logger("t").emit(LogRecord(body="hi"))


_SENDERS = {
"span_export": (_export_span, "/v1/traces"),
"log_export": (_export_log, "/v1/logs"),
"preflight": (sdk_export.preflight, "/v1/traces"),
}


class TestExporterIsolation:
"""The OTLP exporters fill unset options from OTEL_EXPORTER_OTLP_*; none of it may apply."""
"""The OTLP exporters fill unset options from OTEL_EXPORTER_OTLP_*, and some versions merge them
with explicit ones; none of it may reach the agentevals endpoint. Requests are read at the
transport adapter, after requests has merged session and environment settings."""

def test_span_exporter_ignores_application_otlp_settings(self, monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_HEADERS", "authorization=Bearer app-token")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "x-api-key=app-key")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://observability.example.com")
exporter = sdk_export._span_exporter("http://localhost:4318")
assert exporter._endpoint == "http://localhost:4318/v1/traces"
assert exporter._headers == {"x-agentevals-sdk": "1"}
assert "authorization" not in {k.lower() for k in exporter._session.headers}
assert exporter._session.trust_env is False

@pytest.mark.parametrize("factory", ["_span_exporter", "_log_exporter"])
def test_exports_never_use_application_tls_settings(self, factory, monkeypatch):
@pytest.fixture
def wire(self, monkeypatch):
import requests.adapters
from requests.models import Response

monkeypatch.setenv("OTEL_EXPORTER_OTLP_CERTIFICATE", "/etc/app/ca.pem")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_CLIENT_KEY", "/etc/app/client.key")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", "/etc/app/client.crt")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE", "/etc/app/traces.crt")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE", "/etc/app/logs.crt")
sent = []

def send(adapter, request, **kwargs):
sent.append(kwargs)
sent.append((request, kwargs))
response = Response()
response.status_code = 200
response.request = request
response._content = b""
return response

monkeypatch.setattr(requests.adapters.HTTPAdapter, "send", send)
getattr(sdk_export, factory)("https://agentevals.example:4318")._export(b"payload")
assert len(sent) == 1
assert sent[0]["cert"] is None
assert sent[0]["verify"] is True

def test_log_exporter_ignores_application_otlp_settings(self, monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_HEADERS", "authorization=Bearer app-token")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_LOGS_HEADERS", "x-api-key=app-key")
exporter = sdk_export._log_exporter("http://localhost:4318")
assert exporter._endpoint == "http://localhost:4318/v1/logs"
assert exporter._headers == {"x-agentevals-sdk": "1"}
assert exporter._session.trust_env is False
return sent

@pytest.fixture
def application_tls_and_proxy(self, monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_CERTIFICATE", "/etc/app/ca.pem")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_CLIENT_KEY", "/etc/app/client.key")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", "/etc/app/client.crt")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE", "/etc/app/traces.crt")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE", "/etc/app/logs.crt")
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128")
monkeypatch.setenv("HTTP_PROXY", "http://proxy.example:3128")

@pytest.mark.parametrize("sender", _SENDERS)
@pytest.mark.parametrize("env", _APPLICATION_HEADER_ENVS)
def test_application_headers_are_not_sent(self, sender, env, wire, monkeypatch):
for name, value in _APPLICATION_HEADER_ENVS[env].items():
monkeypatch.setenv(name, value)
send, path = _SENDERS[sender]
send("https://agentevals.example:4318")
assert len(wire) == 1
request, _ = wire[0]
assert request.url == f"https://agentevals.example:4318{path}"
assert request.headers["x-agentevals-sdk"] == "1"
assert _APPLICATION_SECRETS.isdisjoint(name.lower() for name in request.headers)

@pytest.mark.parametrize("sender", _SENDERS)
def test_application_tls_and_proxy_settings_are_not_used(self, sender, wire, application_tls_and_proxy):
_SENDERS[sender][0]("https://agentevals.example:4318")
assert len(wire) == 1
_, kwargs = wire[0]
assert kwargs["cert"] is None
assert kwargs["verify"] is True
assert kwargs["proxies"] == {}

@pytest.mark.parametrize("sender", ["span_export", "log_export"])
def test_exports_keep_their_transport_headers(self, sender, wire):
_SENDERS[sender][0]("http://localhost:4318")
request, _ = wire[0]
assert request.headers["Content-Type"] == "application/x-protobuf"
assert request.headers["Content-Encoding"] == "gzip"

def test_settings_placed_on_the_session_are_overridden(self, wire):
session = sdk_export._http_session()
session.cert = ("/etc/app/client.crt", "/etc/app/client.key")
session.verify = "/etc/app/ca.pem"
session.proxies = {"https": "http://proxy.example:3128"}
session.headers["Authorization"] = "Bearer app-token"
session.post("https://agentevals.example:4318/v1/traces", data=b"", headers={"X-Api-Key": "app-key"})
request, kwargs = wire[0]
assert (kwargs["cert"], kwargs["verify"], kwargs["proxies"]) == (None, True, {})
assert _APPLICATION_SECRETS.isdisjoint(name.lower() for name in request.headers)


# ---------------------------------------------------------------------------
Expand Down
Loading