litellm/tests/e2e/logging/s3_reader.py
Yassin Kortam 2090047bd3 test(e2e): cover s3 object delivery and datadog failure logging
Adds live coverage for three P0 logging-registry cells against a real proxy
and the real sinks: logging.s3.success.writes_object,
logging.s3.failure.writes_object and logging.datadog.failure.exports_metric.

The failure cases drive a genuine upstream rejection; each test registers a
deployment whose provider api_key is invalid, so OpenAI itself returns the
401 and litellm's failure path is what has to deliver. Delivery is then read
back out of the sink, never inferred from the proxy's own response: the s3
tests fetch the object with the AWS SDK and assert the stored payload's
status, model, cost and prompt, while the datadog test reuses the existing
logs-search reader and asserts the event's failure status alongside the
provider's error class, code and name. Every object a test writes is deleted
on teardown.

Both failure tests correlate on the x-litellm-call-id of the attempt they
accepted rather than on the prompt. A rejection at the gateway is logged as a
failure too, carrying the same prompt, so a virtual key that briefly 401s
before the auth cache catches up would otherwise contribute a second record
and turn the exactly-one assertion red on correct behavior.

A logging integration is a process-wide callback rather than a per-request
option, so callback_config.py lets a test declare the destination it needs:
it reads the registered callbacks back from /get/config/callbacks, registers
the missing one through /config/update, and unregisters exactly what it
registered afterwards. A proxy that already ships the integration is left
untouched. Every write is a read-modify-write of the live callback list, so
enabling or disabling a destination cannot clobber a registration made
concurrently by another test on the same proxy.

A read-modify-write is still not atomic and cannot be made so here, because
the config API offers only a whole-list write and a server-side
read-remove-write, with no per-entry update to compare-and-set against. Each
write therefore re-reads the list and fails, naming the entries, if anything
registered beforehand that belongs to someone else has gone, which turns a
silent change to a shared proxy's logging configuration into a diagnosable
failure. Entries that appear only after a write are a later registration
rather than damage and are left alone.
2026-07-27 17:37:57 -07:00

197 lines
7.2 KiB
Python

"""Read-back for the S3 logging tests against the real bucket the proxy writes to.
Delivery is judged on what actually landed in S3: the proxy uploads with its own
AWS credentials exactly as in production (no local minio, no endpoint override),
and the tests fetch the object back with the official AWS SDK. Which bucket the
proxy is configured to write to is not discoverable over any proxy route, so the
test process is told through E2E_S3_LOG_BUCKET (plus E2E_S3_LOG_REGION,
E2E_S3_LOG_PATH when the deployment sets an s3_path prefix, and
E2E_S3_LOG_PROFILE to read back through a named AWS profile). A missing bucket is
a hard failure, never an empty result.
An object's key ends in the id litellm assigned the call - the completion id for
a successful call, the litellm call id (the x-litellm-call-id response header)
for a failed one - so a test locates its own object exactly, without scanning
anyone else's, and deletes just that key on teardown.
boto3 is the AWS SDK's own client and is deliberately not routed through
``e2e_http``: hand-signing SigV4 to reach S3 would be a worse test than using the
vendor client. Its s3 client is untyped, so it is confined to this module behind
the ``_S3Client`` protocol, its responses are narrowed here, and every payload is
validated into a pydantic model before a test sees it.
"""
from __future__ import annotations
import os
import time
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Protocol, cast
import boto3
import pytest
from pydantic import BaseModel, ConfigDict, ValidationError
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT
S3_LOG_BUCKET = os.environ.get("E2E_S3_LOG_BUCKET", "").strip()
S3_LOG_REGION = os.environ.get("E2E_S3_LOG_REGION", "us-east-1").strip()
#: The deployment's litellm_settings.s3_callback_params.s3_path, if it sets one.
S3_LOG_PATH = os.environ.get("E2E_S3_LOG_PATH", "").strip()
#: Named AWS profile for the read-back; empty uses boto3's default credential chain.
S3_LOG_PROFILE = os.environ.get("E2E_S3_LOG_PROFILE", "").strip()
class _S3Body(Protocol):
def read(self) -> bytes: ...
class _S3Client(Protocol):
"""The three S3 calls this module makes."""
def list_objects_v2(self, **kwargs: str) -> object: ...
def get_object(self, *, Bucket: str, Key: str) -> object: ...
def delete_object(self, *, Bucket: str, Key: str) -> object: ...
class S3LogError(BaseModel):
"""The error_information block of a failed call's payload."""
model_config = ConfigDict(extra="ignore")
error_class: str = ""
error_code: str = ""
llm_provider: str = ""
class S3LogMessage(BaseModel):
model_config = ConfigDict(extra="ignore")
role: str
content: str
class S3LogRecord(BaseModel):
"""The StandardLoggingPayload as the proxy wrote it into the bucket. Only the
fields the delivery tests pin are modelled."""
model_config = ConfigDict(extra="ignore")
id: str
status: str
call_type: str
model_group: str
response_cost: float
total_tokens: int
messages: tuple[S3LogMessage, ...] = ()
error_information: S3LogError = S3LogError()
error_str: str | None = None
@dataclass(frozen=True, slots=True)
class S3LogObject:
key: str
record: S3LogRecord
@dataclass(frozen=True, slots=True)
class _ListPage:
keys: tuple[str, ...]
next_token: str | None
def _s3_client() -> _S3Client:
session = boto3.Session(profile_name=S3_LOG_PROFILE) if S3_LOG_PROFILE else boto3.Session()
client = session.client("s3", region_name=S3_LOG_REGION) # pyright: ignore[reportUnknownMemberType] # boto3 ships no types for the per-service clients; _S3Client pins the calls made here
return cast(_S3Client, client)
def _response(raw: object, call: str) -> dict[str, object]:
if not isinstance(raw, dict):
pytest.fail(f"S3 {call} returned {type(raw).__name__}, not a response mapping")
return cast(dict[str, object], raw)
def _list_page(raw: object) -> _ListPage:
listing = _response(raw, "list_objects_v2")
contents = listing.get("Contents", [])
if not isinstance(contents, list):
pytest.fail(f"S3 list_objects_v2 Contents was {type(contents).__name__}, not a list")
keys = tuple(
key
for entry in cast(list[object], contents)
if isinstance(entry, dict) and isinstance(key := cast(dict[str, object], entry).get("Key"), str)
)
token = listing.get("NextContinuationToken")
truncated = listing.get("IsTruncated") is True
return _ListPage(keys=keys, next_token=token if truncated and isinstance(token, str) else None)
def _body(raw: object) -> bytes:
stream = _response(raw, "get_object").get("Body")
if not hasattr(stream, "read"):
pytest.fail(f"S3 get_object returned a Body of {type(stream).__name__}, which is not readable")
return cast(_S3Body, stream).read()
@dataclass(frozen=True, slots=True)
class S3LogsReader:
bucket: str
path: str
def _keys(self, client: _S3Client) -> Iterator[str]:
page = _list_page(client.list_objects_v2(Bucket=self.bucket, Prefix=self.path))
while True:
yield from page.keys
if page.next_token is None:
return
page = _list_page(
client.list_objects_v2(Bucket=self.bucket, Prefix=self.path, ContinuationToken=page.next_token)
)
def _record(self, client: _S3Client, key: str) -> S3LogRecord:
body = _body(client.get_object(Bucket=self.bucket, Key=key))
try:
return S3LogRecord.model_validate_json(body)
except ValidationError as exc:
pytest.fail(f"the object at {key} is not a StandardLoggingPayload: {exc}")
def objects_for_call(self, call_id: str) -> tuple[S3LogObject, ...]:
"""Every object the proxy wrote for the call litellm identified as
``call_id``. More than one is a duplicate-delivery bug, so this never
collapses to a single object."""
client = _s3_client()
suffix = f"_{call_id}.json"
return tuple(
S3LogObject(key=key, record=self._record(client, key))
for key in self._keys(client)
if key.endswith(suffix)
)
def poll_objects_for_call(self, call_id: str) -> tuple[S3LogObject, ...]:
"""Poll until the call's object is readable - the integration batches
uploads behind a flush interval - and return whatever is there at the
deadline, so the caller's assertion, not a timeout, reports the gap."""
deadline = time.monotonic() + POLL_TIMEOUT
while time.monotonic() < deadline:
found = self.objects_for_call(call_id)
if found:
return found
time.sleep(POLL_INTERVAL)
return self.objects_for_call(call_id)
def delete(self, key: str) -> None:
_ = _s3_client().delete_object(Bucket=self.bucket, Key=key)
def build_s3_logs_reader() -> S3LogsReader:
if not S3_LOG_BUCKET:
pytest.fail(
"E2E_S3_LOG_BUCKET must name the bucket the proxy's s3_callback_params write to: "
"the s3 tests read delivery back out of the real bucket, and no proxy route reports "
"which one is configured; missing it is a hard failure, not a skip"
)
return S3LogsReader(bucket=S3_LOG_BUCKET, path=S3_LOG_PATH)