litellm/tests/e2e/perf/setup/telemetry.py
Kerry Lu b99d2cc06e
test(perf): ramp the load cases until the pod stops keeping up and floor sustained_rps (#47758)
* test(perf): add small_bodies_30rps and mixed_size_requests_4rps load cases

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): run the load cases at 50 rps small and 5 rps mixed, the pod's knee

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): ramp the load cases until the pod stops keeping up and report sustained_rps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): hand the ramp to the observation and widen the mixed ramp to 14 rps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): floor the small ramp at 20 rps and emit the per-step readings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): emit the ramp's hold bound and each step's unexpected statuses

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): read each ramp step's cpu, redis latency and connections from the samplers' windows

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): floor mixed_size_requests_ramp at 8 rps from the lane's 10 rps sustained step

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): compare step readings with math.isclose so the e2e basedpyright gate stays clean

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 22:31:08 -07:00

110 lines
4.3 KiB
Python

"""The gateway's own `/metrics`, read before and after a run (and every few seconds during a ramped run, see
`telemetry_sampler`) for the numbers nothing outside the gateway can see. Today that is `litellm_redis_latency`, the histogram customers' Grafana panels show when Redis
slows the gateway down. The gateway's latency and request counts are read per request from its response
headers instead (`setup/wire.py`), which gives percentiles where a histogram delta only gives a mean.
RSS and CPU are not here: a multi-worker gateway serves `/metrics` through the prometheus multiprocess
collector, which drops `process_resident_memory_bytes` and `process_cpu_seconds_total`, so those stay
on psutil (`proxy_usage.py`)."""
from __future__ import annotations
import math
import threading
from collections.abc import Generator, Iterable
from contextlib import contextmanager
from dataclasses import dataclass
from itertools import chain
from typing import Final
from e2e_http import NoBody
from prometheus_client.parser import text_string_to_metric_families
from prometheus_client.samples import Sample
from proxy_client import ProxyClient
from setup.series import Recorder, Series
_SAMPLE_SECONDS: Final = 10.0
@dataclass(frozen=True, slots=True)
class HistogramTotals:
"""`_sum` and `_count` of one histogram across every label set, enough for a mean over a window."""
total: float
count: float
def minus(self, earlier: HistogramTotals) -> HistogramTotals:
return HistogramTotals(total=self.total - earlier.total, count=self.count - earlier.count)
@property
def mean(self) -> float | None:
return self.total / self.count if self.count > 0 else None
@dataclass(frozen=True, slots=True)
class GatewayTelemetry:
redis_latency: HistogramTotals
"""`litellm_redis_latency`, seconds per Redis call the gateway made."""
def minus(self, earlier: GatewayTelemetry) -> GatewayTelemetry:
return GatewayTelemetry(redis_latency=self.redis_latency.minus(earlier.redis_latency))
def _value(samples: Iterable[Sample], name: str) -> float:
return sum(sample.value for sample in samples if sample.name == name and math.isfinite(sample.value))
def _histogram(samples: tuple[Sample, ...], family: str) -> HistogramTotals:
return HistogramTotals(total=_value(samples, f"{family}_sum"), count=_value(samples, f"{family}_count"))
def parse_gateway_telemetry(text: str) -> GatewayTelemetry:
samples: Final = tuple(chain.from_iterable(family.samples for family in text_string_to_metric_families(text)))
return GatewayTelemetry(redis_latency=_histogram(samples, "litellm_redis_latency"))
def read_gateway_telemetry(proxy: ProxyClient) -> GatewayTelemetry | None:
"""One `/metrics` scrape, or None when the gateway does not serve it (no `prometheus` callback), in which
case every limit on a gateway-reported metric fails as missing rather than reading a silent zero."""
scrape: Final = proxy.probe("/metrics", params=NoBody())
return parse_gateway_telemetry(scrape.body) if scrape.status_code == 200 else None
class TelemetrySampler:
"""`/metrics` every `_SAMPLE_SECONDS` on a background thread while traffic runs, so a ramped run can read
the Redis latency of each step's window as the difference between the scrapes around it."""
def __init__(self, proxy: ProxyClient) -> None:
self._proxy: Final = proxy
self._stop: Final = threading.Event()
self._thread: Final = threading.Thread(target=self._run, name="gateway-telemetry", daemon=True)
self._scrapes: Final[Recorder[GatewayTelemetry]] = Recorder()
@property
def series(self) -> Series[GatewayTelemetry]:
return self._scrapes.series
def _run(self) -> None:
while not self._stop.is_set():
scrape: GatewayTelemetry | None = read_gateway_telemetry(self._proxy)
if scrape is not None:
self._scrapes.record(scrape)
_ = self._stop.wait(_SAMPLE_SECONDS)
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._thread.join(timeout=30)
@contextmanager
def telemetry_sampler(proxy: ProxyClient) -> Generator[TelemetrySampler]:
sampler: Final = TelemetrySampler(proxy)
sampler.start()
try:
yield sampler
finally:
sampler.stop()