mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
* test: add logging e2e coverage (s3_v2, gcs_bucket, team langfuse callback, datadog failure) Five new live e2e scenarios raising Logging & Guardrails registry coverage: s3_v2 success and failure objects read back from the real S3 bucket, gcs_bucket success record read back through the GCS JSON API (with nextPageToken pagination and per-request bearer minting), team-scoped Langfuse callback delivery with non-team isolation, and DataDog failure event delivery queried by indexed model_group. datadog_reader gains query-based variants of the marker search; the langfuse cell is a new registry row. Bucket readers settle past a full flush interval so a late duplicate cannot hide from the exactly-one assertions * test: cover clock-skew day prefix in gcs read-back and retry team callback propagation * test: key the s3 failure read-back on the provider error, not payload absence * chore: rerun ci * chore: rerun ci after config sync * chore: rerun ci with pr lane env * chore: rerun ci * chore: rerun ci * chore: rerun ci * chore: rerun ci * chore: rerun ci * test: add guardrail e2e coverage (presidio masking, bedrock post and during call, moderation on messages) (#38553) * test: add guardrail e2e coverage (presidio masking, bedrock post/during, moderation on messages) * test: require the phone placeholder positively in the presidio masking predicate * test: count only the 400 verdict body as a bedrock post_call block * test(e2e): exempt the guardrail config echo from the post_call leak assertion * test(e2e): pin the fail-closed contract for an unknown guardrail name (skipped, product gap) * test(e2e): tolerate the readiness 503 from a transient db blip in the callback-config probes
179 lines
7.2 KiB
Python
179 lines
7.2 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 time
|
|
from dataclasses import dataclass
|
|
|
|
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, RateLimitedError, Success, post
|
|
|
|
#: How many rate-limited responses in a row one search tolerates before the
|
|
#: hard fail; each retry sleeps a full search interval, so this rides out a
|
|
#: burst from a concurrent consumer of the org-wide search budget.
|
|
_RATE_LIMIT_RETRIES = 5
|
|
|
|
|
|
class _DdAuthHeaders(Headers):
|
|
api_key: str = Field(serialization_alias="DD-API-KEY")
|
|
app_key: str = Field(serialization_alias="DD-APPLICATION-KEY")
|
|
|
|
|
|
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
|
|
app_key: str
|
|
|
|
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."""
|
|
for _ in range(_RATE_LIMIT_RETRIES):
|
|
result = post(
|
|
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)),
|
|
response_type=_SearchResponse,
|
|
timeout=30.0,
|
|
)
|
|
match result:
|
|
case Success(data=page):
|
|
return [event.attributes for event in page.data]
|
|
case RateLimitedError(retry_after_seconds=retry_after):
|
|
time.sleep(retry_after if retry_after else DD_SEARCH_INTERVAL)
|
|
case failure:
|
|
pytest.fail(f"DataDog Logs Search API at api.{self.site} failed: {failure}")
|
|
pytest.fail(
|
|
f"DataDog Logs Search API at api.{self.site} still rate-limited after "
|
|
f"{_RATE_LIMIT_RETRIES} retries {DD_SEARCH_INTERVAL}s apart - the org-wide "
|
|
"logs_public_search_api budget (2 requests per 10s) is exhausted by another consumer"
|
|
)
|
|
|
|
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. At the deadline the last result is returned as-is."""
|
|
deadline = time.monotonic() + POLL_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
events = self.events_for_query(query)
|
|
if events:
|
|
return self._settled_events_for_query(query, events)
|
|
time.sleep(DD_SEARCH_INTERVAL)
|
|
return self.events_for_query(query)
|
|
|
|
def _settled_events_for_query(self, query: str, events: list[DdLogEvent]) -> 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.
|
|
"""
|
|
settle_deadline = time.monotonic() + DD_SETTLE_SECONDS
|
|
last_nonempty = events
|
|
while time.monotonic() < settle_deadline:
|
|
time.sleep(DD_SEARCH_INTERVAL)
|
|
latest = self.events_for_query(query)
|
|
if not latest:
|
|
continue
|
|
if len(latest) > 1:
|
|
return latest
|
|
last_nonempty = latest
|
|
return last_nonempty
|
|
|
|
|
|
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)
|