litellm/tests/e2e/logging/datadog_reader.py
yuneng-jiang 36bd7f1138
fix(mcp): honor an explicit null on toolset update, cover MCP lifecycle e2e (#40022)
* fix(mcp): honor an explicit null on toolset update, cover MCP lifecycle e2e

PUT /v1/mcp/toolset dumped its payload with exclude_none, so a field sent as
null looked exactly like one the caller left out and the stored value
survived. An admin could not clear a toolset's description: the save reported
success and the old text came straight back. It now dumps with exclude_unset,
so absent keeps and null clears, which is what PUT /v1/mcp/server already did.
A null tools list clears the selection to empty, and a null toolset_name is
ignored because a toolset always has a name.

Adds create, read, partial-update, clear and delete e2e coverage for MCP
servers and toolsets, with every read-back polled on every replica so an edit
that lands on one replica and not another fails the test, plus an enforcement
test proving a key granted a toolset lists exactly that toolset's tools
against the real Datadog upstream.

* fix(e2e): refuse a read-back that no replica serves

A read-back over an empty replica mapping satisfied every predicate and
returned as if it had converged, so it would have asserted nothing and
passed. No wiring can produce that today, since the replica list always
falls back to at least one URL, but a helper whose whole job is proving a
write reached every replica should not have a shape that passes vacuously.

* fix(mcp): keep a null tools list a no-op on toolset update

Treating a null tools list as a clear meant an existing client that sends
tools=null during a partial update, meaning "leave the selection alone",
silently lost every tool the toolset grants. That is a permission surface,
so the quiet version of it is the worst version.

A toolset always has a tool list, the same way it always has a name, so a
null on either is now a no-op. Emptying the selection is an explicit [],
which cannot be confused with a field the caller left out, and which is
what the dashboard already sends.

* fix(e2e): keep MCP admin routes on the data plane

/v1/mcp/* is a lazily mounted feature, so a gateway registers it on the first
matching request, which happens after the startup route trim that drops
management endpoints. Routing it to the control plane therefore sent every MCP
call to the one backend process: the new lifecycle read-backs proved a single
process rather than every replica, and mcp_client's await_registered barrier
waited on a registry that does not serve the tools/list call it guards, so the
existing MCP suites polled a gateway that had not synced yet until poll_timeout

Verified against a two-gateway split stack (backend on 4001, gateways on 4010
and 4011, one postgres): both gateways answer /v1/mcp/server and /v1/mcp/toolset,
and each served 6 server reads and 7 toolset reads over the run

* fix(e2e): grant the toolset by the tool's own name, not the wire name

tools/list serves a tool as <prefix><tool_name>, but a toolset grants by the
tool's own name: resolve_toolset_permissions reads toolset.tools[].tool_name
straight through, and the prefix is added on the way out. The test built the
toolset from the names tools/list reported, so the grant matched nothing, the
scoped key listed no tools, and await_tools ran out its whole poll_timeout
before failing

Measure the prefix off search_datadog_logs, whose own name is known, rather than
guessing it from the alias, since the proxy can be configured to prefix with a
short server id instead. The expectation compared against tools/list stays in
wire names; only what the toolset stores crosses back

* test(mcp): build immutable lifecycle updates and replica results

* test: validate opaque stream IDs and hide log-reader credentials

* test: isolate auto-router scenarios and clean partial setup

* test: honor Datadog search rate-limit reset headers

* test: share the Datadog read-back deadline across retries

* test: preserve captured MCP toolset update fields
2026-09-08 22:50:13 -07:00

215 lines
8.8 KiB
Python

"""Read-back for the DataDog logging tests against the real DataDog Logs
Search API.
Delivery is judged on what DataDog itself ingested: the proxy ships logs with
DD_API_KEY exactly as in production (no base-URL override, no local sink), and
the tests search the ingested events back with POST /api/v2/logs/events/search,
authenticated with the same DD_API_KEY plus a DD_APP_KEY application key. On
the cluster the secret manager injects both keys; locally tests/e2e/.env
provides them. Missing keys or a failed search call are hard failures, never an
empty result. External reads go through ``e2e_http``.
"""
from __future__ import annotations
import math
import random
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Final
import pytest
from pydantic import BaseModel, ConfigDict, Field
from e2e_config import (
DD_API_KEY,
DD_APP_KEY,
DD_SEARCH_FROM,
DD_SEARCH_INTERVAL,
DD_SETTLE_SECONDS,
DD_SITE,
POLL_TIMEOUT,
)
from e2e_http import URL, Headers, StreamingResponse, send
type SearchCall = Callable[[str, float], StreamingResponse]
def _seconds(value: str | None) -> float | None:
if value is None:
return None
try:
seconds: Final = float(value)
except ValueError:
return None
return seconds if math.isfinite(seconds) and seconds >= 0 else None
def _rate_limit_delay(headers: Mapping[str, str]) -> float:
delays: Final = tuple(
delay
for name in ("x-ratelimit-reset", "retry-after")
if (delay := _seconds(headers.get(name))) is not None
)
return max(1.0, max(delays, default=DD_SEARCH_INTERVAL))
class _DdAuthHeaders(Headers):
api_key: str = Field(serialization_alias="DD-API-KEY", repr=False)
app_key: str = Field(serialization_alias="DD-APPLICATION-KEY", repr=False)
class _SearchFilter(BaseModel):
query: str
#: Wide enough to cover a full suite run plus DataDog's ingestion lag;
#: markers are unique per test, so a wide window cannot match foreign events.
#: Override via E2E_DD_SEARCH_FROM when CI lookback needs more than the default.
from_: str = Field(default_factory=lambda: DD_SEARCH_FROM, serialization_alias="from")
to: str = "now"
class _SearchPage(BaseModel):
limit: int = 100
class _SearchRequest(BaseModel):
filter: _SearchFilter
page: _SearchPage = _SearchPage()
sort: str = "timestamp"
class DdLogEvent(BaseModel):
"""One ingested log event as the search API returns it: the indexed
envelope (service/status/tags) plus ``attributes`` - DataDog's parse of the
JSON message the integration shipped, i.e. the StandardLoggingPayload
fields."""
model_config = ConfigDict(extra="ignore")
service: str | None = None
status: str | None = None
tags: list[str] = []
attributes: dict[str, object] = {}
class _SearchEvent(BaseModel):
model_config = ConfigDict(extra="ignore")
attributes: DdLogEvent
class _SearchResponse(BaseModel):
model_config = ConfigDict(extra="ignore")
data: list[_SearchEvent] = []
@dataclass(frozen=True, slots=True)
class DdLogsReader:
site: str
api_key: str = field(repr=False)
app_key: str = field(repr=False)
search: SearchCall | None = field(default=None, repr=False)
now: Callable[[], float] = field(default=time.monotonic, repr=False)
sleep: Callable[[float], None] = field(default=time.sleep, repr=False)
jitter: Callable[[], float] = field(default=random.random, repr=False)
def events_for_marker(self, marker: str) -> list[DdLogEvent]:
"""Every ingested event whose attributes carry the marker. DataDog
consumes the shipped JSON message into ``attributes`` and leaves the
indexed ``message`` empty, so a plain full-text query matches nothing;
``*:`` extends the scan to every attribute (the marker sits in the
prompt, e.g. ``messages.content``, wherever the route's payload puts
it)."""
return self.events_for_query(f"*:*{marker}*")
def events_for_query(self, query: str) -> list[DdLogEvent]:
"""Every ingested event the search query matches (failure payloads
carry no prompt to mark, so failure scenarios query indexed attributes
like ``@model_group:...`` instead of a body marker). More than one hit
for one call IS the duplicate-delivery bug, so this never collapses to
a single event. A 429 backs off and retries - the search budget is
org-wide, so another consumer can empty it under us - while any other
failure stays a hard fail."""
return self._events_for_query(query, self.now() + POLL_TIMEOUT)
def _events_for_query(self, query: str, deadline: float) -> list[DdLogEvent]:
search: Final = self.search or self._search_page
while (remaining := deadline - self.now()) > 0:
if (result := search(query, min(30.0, remaining))).ok:
return [event.attributes for event in _SearchResponse.model_validate_json(result.body).data]
if result.status_code != 429:
pytest.fail(f"DataDog Logs Search API at api.{self.site} failed with HTTP {result.status_code}")
if (delay := min(_rate_limit_delay(result.headers) + self.jitter(), deadline - self.now())) > 0:
self.sleep(delay)
pytest.fail(
f"DataDog Logs Search API at api.{self.site} remained rate-limited for {POLL_TIMEOUT}s; "
"the org-wide logs_public_search_api budget is exhausted"
)
def _search_page(self, query: str, timeout: float) -> StreamingResponse:
return send(
URL(f"https://api.{self.site}/api/v2/logs/events/search"),
headers=_DdAuthHeaders(api_key=self.api_key, app_key=self.app_key),
json=_SearchRequest(filter=_SearchFilter(query=query)),
timeout=timeout,
)
def poll_events_for_marker(self, marker: str) -> list[DdLogEvent]:
"""``poll_events_for_query`` over the every-attribute marker scan."""
return self.poll_events_for_query(f"*:*{marker}*")
def poll_events_for_query(self, query: str) -> list[DdLogEvent]:
"""Poll until at least one matching event is searchable (the callback
flushes in periodic batches and DataDog ingestion adds seconds of lag),
then keep re-reading for DD_SETTLE_SECONDS so a late duplicate cannot
hide from the exactly-one assertion - real-DataDog jitter can surface
one call's two events tens of seconds apart. Searches pace at
DD_SEARCH_INTERVAL, not POLL_INTERVAL, to respect the search API's
request budget. Discovery, quota retries, and duplicate detection share
one POLL_TIMEOUT deadline; an incomplete settle window fails closed."""
deadline: Final = self.now() + POLL_TIMEOUT
while (remaining := deadline - self.now()) > 0:
events = self._events_for_query(query, deadline)
if events:
return self._settled_events_for_query(query, events, deadline)
if (remaining := deadline - self.now()) > 0:
self.sleep(min(DD_SEARCH_INTERVAL, remaining))
return []
def _settled_events_for_query(self, query: str, events: list[DdLogEvent], deadline: float) -> list[DdLogEvent]:
"""Re-read at every search interval until the settle window closes; a
duplicate ends the watch early because more waiting cannot clear it.
Keep the last non-empty result: a transient empty search (index lag)
must not erase events already confirmed earlier in the settle window.
A successful final search must reach the full settle window before the
shared read-back deadline; otherwise duplicate detection is incomplete.
"""
settle_deadline: Final = self.now() + DD_SETTLE_SECONDS
last_nonempty = events
if len(events) > 1:
return events
while (remaining := deadline - self.now()) > 0:
self.sleep(min(DD_SEARCH_INTERVAL, remaining))
if self.now() >= deadline:
break
latest = self._events_for_query(query, deadline)
if len(latest) > 1:
return latest
if latest:
last_nonempty = latest
if self.now() >= settle_deadline:
return last_nonempty
pytest.fail(f"DataDog log delivery could not complete its duplicate-detection window within {POLL_TIMEOUT}s")
def build_dd_logs_reader() -> DdLogsReader:
if not DD_API_KEY or not DD_APP_KEY:
pytest.fail(
"DD_API_KEY and DD_APP_KEY must be set: the DataDog tests deliver to and "
"read back from the real DataDog API (on the cluster the secret manager "
"injects them; locally set them in tests/e2e/.env)"
)
return DdLogsReader(site=DD_SITE, api_key=DD_API_KEY, app_key=DD_APP_KEY)