litellm/tests/e2e/perf/setup/ui_journeys.py
Kerry Lu 95b6eea5bb
test(perf): tag a UI journey sample with its API response, not a Next.js prefetch under /ui/ (#47650)
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 14:59:34 -07:00

430 lines
17 KiB
Python

from __future__ import annotations
import math
import re
import statistics
import time
from collections.abc import Callable, Generator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from itertools import chain
from types import MappingProxyType
from typing import Final
from urllib.parse import urlsplit
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import Locator, Page, Response, expect, sync_playwright
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from prometheus_client.parser import text_string_to_metric_families
from pydantic import BaseModel, ConfigDict
MONEY: Final = re.compile(r"^\$[\d,]+\.\d{2}$")
LOADING_LOGS: Final = "Loading request logs…"
LOADING_MODELS: Final = "Loading models…"
LOADING_KEYS: Final = "Loading keys..."
class Sample(BaseModel):
model_config = ConfigDict(frozen=True)
seconds: float
rows: int = 0
error: str = ""
status: int = 0
request: str = ""
class Timings(BaseModel):
model_config = ConfigDict(frozen=True)
first_seconds: float | None
repeated_samples: int
repeated_p50_seconds: float | None
repeated_p95_seconds: float | None
repeated_p99_seconds: float | None
repeated_max_seconds: float | None
@dataclass(frozen=True, slots=True)
class Journey:
"""One dashboard interaction. ``start`` brings the page to the state the user acts from (untimed),
``act`` is the click or navigation being measured and ``settled`` blocks until the data the user
was waiting for is on screen, returning the number of rows or tiles that rendered."""
name: str
surface: str
start: Callable[[Page, float], None]
act: Callable[[Page], None]
settled: Callable[[Page, float], int]
@dataclass(frozen=True, slots=True)
class Dashboard:
base_url: str
username: str
password: str
anchor: datetime
def summarize(samples: tuple[Sample, ...]) -> Timings:
assert samples, "No UI journeys were measured"
repeated: Final = tuple(sorted(sample.seconds for sample in samples[1:] if not sample.error))
return Timings(
first_seconds=samples[0].seconds if not samples[0].error else None,
repeated_samples=len(repeated),
repeated_p50_seconds=statistics.median(repeated) if repeated else None,
repeated_p95_seconds=repeated[math.ceil(len(repeated) * 0.95) - 1] if len(repeated) >= 20 else None,
repeated_p99_seconds=repeated[math.ceil(len(repeated) * 0.99) - 1] if len(repeated) >= 100 else None,
repeated_max_seconds=max(repeated) if repeated else None,
)
def error_code(sample: Sample) -> str:
if not sample.error:
return ""
if sample.status >= 400:
return f"http_{sample.status}"
if sample.error.startswith("wall_clock"):
return "wall_clock"
return "timeout" if sample.error == "timeout" else "ui"
def ui_junit_properties(samples: tuple[Sample, ...]) -> tuple[tuple[str, float], ...]:
codes: Final = tuple(error_code(sample) for sample in samples if sample.error)
timings: Final = summarize(samples)
successful: Final = tuple(sorted(sample.seconds for sample in samples if not sample.error))
latency: Final = (
("seconds_p50", statistics.median(successful) if successful else None),
("seconds_p90", successful[math.ceil(len(successful) * 0.90) - 1] if successful else None),
("seconds_max", max(successful) if successful else None),
("first_seconds", timings.first_seconds),
("repeated_seconds_p50", timings.repeated_p50_seconds),
("repeated_seconds_p95", timings.repeated_p95_seconds),
("repeated_seconds_p99", timings.repeated_p99_seconds),
("repeated_seconds_max", timings.repeated_max_seconds),
)
return (
("perf_ui_journeys", len(samples)),
("perf_ui_failures", len(codes)),
("perf_ui_samples", len(successful)),
("perf_ui_repeated_samples", timings.repeated_samples),
*((f"perf_ui_error_{code}", codes.count(code)) for code in sorted(frozenset(codes))),
*((f"perf_ui_{name}", value) for name, value in latency if value is not None),
)
@dataclass(frozen=True, slots=True)
class Ceiling:
p50_seconds: float
p90_seconds: float
LOGS_CEILING: Final = Ceiling(p50_seconds=10, p90_seconds=20)
CHART_CEILING: Final = Ceiling(p50_seconds=20, p90_seconds=40)
TABLE_CEILING: Final = Ceiling(p50_seconds=5, p90_seconds=10)
UI_JOURNEY_LIMITS: Final = MappingProxyType(
{
"logs_open_24h": LOGS_CEILING,
"logs_last_7_days": LOGS_CEILING,
"logs_next_page": LOGS_CEILING,
"logs_filter_key_hash": LOGS_CEILING,
"logs_filter_key_alias": LOGS_CEILING,
"logs_filter_team": LOGS_CEILING,
"logs_filter_model": LOGS_CEILING,
"usage_open": CHART_CEILING,
"usage_last_30_days": CHART_CEILING,
"usage_team_view": CHART_CEILING,
"usage_customer_view": CHART_CEILING,
"usage_model_activity_30_days": CHART_CEILING,
"cache_open": CHART_CEILING,
"models_open": TABLE_CEILING,
"keys_open": TABLE_CEILING,
}
)
def assert_within_ceiling(journey_name: str, samples: tuple[Sample, ...]) -> None:
failed: Final = tuple(sample.error for sample in samples if sample.error)
assert not failed, f"{len(failed)} of {len(samples)} {journey_name} samples failed: {failed[0]}"
metrics: Final = dict(ui_junit_properties(samples))
ceiling: Final = UI_JOURNEY_LIMITS[journey_name]
assert metrics["perf_ui_seconds_p50"] <= ceiling.p50_seconds, (
f"{journey_name} p50 {metrics['perf_ui_seconds_p50']:.1f}s over {ceiling.p50_seconds}s"
)
assert metrics["perf_ui_seconds_p90"] <= ceiling.p90_seconds, (
f"{journey_name} p90 {metrics['perf_ui_seconds_p90']:.1f}s over {ceiling.p90_seconds}s"
)
@contextmanager
def dashboard_page(dashboard: Dashboard, timeout: float) -> Generator[Page]:
"""A logged-in admin tab whose clock reads the seed anchor, so relative windows such as
Last 24 Hours land on seeded rows. Live Tail is off so its 15 second refetch cannot land
inside a sample. Playwright keeps a UTC timezone to match the seed."""
with sync_playwright() as playwright:
browser: Final = playwright.chromium.launch()
context: Final = browser.new_context(
base_url=dashboard.base_url, timezone_id="UTC", viewport={"width": 1600, "height": 1000}
)
context.set_default_timeout(timeout * 1000)
context.add_init_script('sessionStorage.setItem("isLiveTail", "false")')
page: Final = context.new_page()
page.clock.set_fixed_time(dashboard.anchor)
_login(page, dashboard)
try:
yield page
finally:
context.close()
browser.close()
def _login(page: Page, dashboard: Dashboard) -> None:
_ = page.goto("/ui/login")
page.get_by_placeholder("Enter your username").fill(dashboard.username)
page.get_by_placeholder("Enter your password").fill(dashboard.password)
page.get_by_role("button", name="Login", exact=True).click()
page.wait_for_url(lambda url: "/ui" in url and "/login" not in url)
expect(page.get_by_label("LiteLLM home")).to_be_visible()
def _is_data_fetch(response: Response) -> bool:
"""API calls only. The Admin UI's own assets and Next.js prefetches live under /ui/ and /_next/."""
path: Final = urlsplit(response.url).path
return response.request.resource_type in ("fetch", "xhr") and not path.startswith(("/ui/", "/_next/"))
def _worst(responses: list[Response]) -> Response | None:
return next((response for response in responses if response.status >= 400), responses[-1] if responses else None)
def _with_response(sample: Sample, responses: list[Response]) -> Sample:
worst: Final = _worst(responses)
if worst is None:
return sample
return sample.model_copy(
update={"status": worst.status, "request": f"{worst.request.method} {urlsplit(worst.url).path}"[:120]}
)
def measure(page: Page, journey: Journey, timeout: float) -> Sample:
"""Wall time from the user's action to the data being on screen. Every sample starts from the
same pre-action state so repeats measure the same journey, not a warmed client cache."""
try:
journey.start(page, timeout)
except (PlaywrightError, AssertionError) as error:
return Sample(seconds=0.0, error=f"start {type(error).__name__}: {error}"[:300])
responses: Final[list[Response]] = [] # mutable-ok: filled by the playwright response listener
def record(response: Response) -> None:
if _is_data_fetch(response):
responses.append(response)
page.on("response", record)
started: Final = time.monotonic()
try:
with page.expect_response(_is_data_fetch, timeout=timeout * 1000):
journey.act(page)
rows: Final = journey.settled(page, timeout)
except PlaywrightTimeoutError:
return _with_response(Sample(seconds=time.monotonic() - started, error="timeout"), responses)
except (PlaywrightError, AssertionError) as error:
return _with_response(
Sample(seconds=time.monotonic() - started, error=f"{type(error).__name__}: {error}"[:300]), responses
)
finally:
page.remove_listener("response", record)
return _with_response(Sample(seconds=time.monotonic() - started, rows=rows), responses)
def _visible(page: Page, test_id: str) -> Locator:
return page.get_by_test_id(test_id).filter(visible=True).first
def _logs_rows(page: Page) -> Locator:
return page.locator("table").filter(visible=True).first.locator("tbody tr")
def _logs_settled(page: Page, timeout: float) -> int:
millis: Final = timeout * 1000
rows: Final = _logs_rows(page).filter(has_not_text="No results")
expect(rows.first).to_be_visible(timeout=millis)
expect(_visible(page, "datatable-refresh")).to_be_enabled(timeout=millis)
expect(page.get_by_text(LOADING_LOGS)).to_have_count(0, timeout=millis)
return rows.count()
def _no_skeletons(page: Page, timeout: float) -> None:
expect(page.locator('[data-slot="skeleton"]')).to_have_count(0, timeout=timeout * 1000)
def _usage_settled(page: Page, timeout: float) -> int:
millis: Final = timeout * 1000
expect(page.get_by_text("Total Spend", exact=True).first).to_be_visible(timeout=millis)
_no_skeletons(page, timeout)
money: Final = page.get_by_text(MONEY)
expect(money.first).to_be_visible(timeout=millis)
return money.count()
def _cache_settled(page: Page, timeout: float) -> int:
millis: Final = timeout * 1000
expect(page.get_by_text("Cache Hits", exact=True)).to_be_visible(timeout=millis)
bars: Final = page.locator(".recharts-bar-rectangle")
expect(bars.first).to_be_visible(timeout=millis)
return bars.count()
def _table_settled(loading: str) -> Callable[[Page, float], int]:
def settled(page: Page, timeout: float) -> int:
millis: Final = timeout * 1000
rows: Final = page.locator("[data-row-id]")
expect(rows.first).to_be_visible(timeout=millis)
expect(page.get_by_text(loading)).to_have_count(0, timeout=millis)
return rows.count()
return settled
def _open_logs(page: Page, timeout: float) -> None:
_ = page.goto("/ui/logs")
_ = _logs_settled(page, timeout)
def _open_usage(page: Page, timeout: float) -> None:
_ = page.goto("/ui/usage")
_ = _usage_settled(page, timeout)
def _filters_drawer(page: Page) -> Locator:
_visible(page, "datatable-filters-trigger").click()
drawer: Final = page.get_by_role("dialog", name="Filters")
expect(drawer).to_be_visible()
return drawer
def _apply_filters(page: Page) -> None:
page.get_by_role("dialog", name="Filters").get_by_role("button", name="Apply Filters").click()
def _pick_first_option(page: Page, combobox: str) -> None:
_filters_drawer(page).get_by_role("combobox", name=combobox).click()
page.get_by_role("option").first.click()
def _open_time_range(page: Page, timeout: float) -> None:
_open_logs(page, timeout)
page.get_by_role("button", name="Last 24 Hours").click()
def _open_usage_range(page: Page, timeout: float) -> None:
_open_usage(page, timeout)
page.locator('[data-slot="advanced-date-picker-trigger"]').click()
def _model_activity_settled(page: Page, timeout: float) -> int:
millis: Final = timeout * 1000
expect(page.locator('[role="tab"][data-active]', has_text="Model Activity")).to_be_visible(timeout=millis)
_no_skeletons(page, timeout)
money: Final = page.get_by_text(MONEY).filter(visible=True)
expect(money.first).to_be_visible(timeout=millis)
return money.count()
def _open_model_activity_range(page: Page, timeout: float) -> None:
_open_usage(page, timeout)
page.get_by_role("tab", name="Model Activity").click()
_ = _model_activity_settled(page, timeout)
page.locator('[data-slot="advanced-date-picker-trigger"]').click()
def _pick_usage_preset(preset: str) -> Callable[[Page], None]:
def act(page: Page) -> None:
page.locator('[data-slot="advanced-date-picker-preset"]', has_text=preset).click()
return act
def _select_usage_view(page: Page, view: str) -> None:
page.locator('[data-slot="select-trigger"]').filter(has_text="Usage").first.click()
page.get_by_role("option", name=re.compile(rf"^{re.escape(view)}")).click()
def _nothing(page: Page, timeout: float) -> None:
return None
def _goto(path: str) -> Callable[[Page], None]:
def act(page: Page) -> None:
_ = page.goto(path)
return act
def _logs_filter(name: str, prepare: Callable[[Page], None]) -> Journey:
def start(page: Page, timeout: float) -> None:
_open_logs(page, timeout)
prepare(page)
return Journey(name, "logs", start, _apply_filters, _logs_settled)
def journeys(api_key: str) -> tuple[Journey, ...]:
return (
Journey("logs_open_24h", "logs", _nothing, _goto("/ui/logs"), _logs_settled),
Journey(
"logs_last_7_days",
"logs",
_open_time_range,
lambda page: page.get_by_role("button", name="Last 7 Days", exact=True).click(),
_logs_settled,
),
Journey(
"logs_next_page", "logs", _open_logs, lambda page: _visible(page, "pagination-next").click(), _logs_settled
),
_logs_filter(
"logs_filter_key_hash",
lambda page: _filters_drawer(page).get_by_placeholder("Enter key hash…").fill(api_key),
),
_logs_filter("logs_filter_key_alias", lambda page: _pick_first_option(page, "Search a key alias")),
_logs_filter("logs_filter_team", lambda page: _pick_first_option(page, "Search or select a team")),
_logs_filter("logs_filter_model", lambda page: _pick_first_option(page, "Search a model")),
Journey("usage_open", "usage", _nothing, _goto("/ui/usage"), _usage_settled),
Journey("usage_last_30_days", "usage", _open_usage_range, _pick_usage_preset("Last 30 days"), _usage_settled),
Journey(
"usage_team_view", "usage", _open_usage, lambda page: _select_usage_view(page, "Team Usage"), _usage_settled
),
Journey(
"usage_customer_view",
"usage",
_open_usage,
lambda page: _select_usage_view(page, "Customer Usage"),
_usage_settled,
),
Journey(
"usage_model_activity_30_days",
"usage",
_open_model_activity_range,
_pick_usage_preset("Last 30 days"),
_model_activity_settled,
),
Journey("cache_open", "caching", _nothing, _goto("/ui/caching"), _cache_settled),
Journey("models_open", "models", _nothing, _goto("/ui/models-and-endpoints"), _table_settled(LOADING_MODELS)),
Journey("keys_open", "keys", _nothing, _goto("/ui/api-keys"), _table_settled(LOADING_KEYS)),
)
def database_failures(text: str) -> float:
families: Final = tuple(text_string_to_metric_families(text))
required: Final = frozenset(("litellm_postgres_failed_requests", "litellm_batch_write_to_db_failed_requests"))
missing: Final = required - frozenset(family.name for family in families)
all_samples: Final = tuple(chain.from_iterable(family.samples for family in families))
has_activity: Final = any(
sample.name == "litellm_postgres_total_requests_total" and math.isfinite(sample.value) and sample.value > 0
for sample in all_samples
)
assert not missing or has_activity, f"Missing PostgreSQL failure counters {missing}"
names: Final = frozenset(f"{name}_total" for name in required)
values: Final = tuple(sample.value for sample in all_samples if sample.name in names)
assert all(math.isfinite(value) and value >= 0 for value in values), "Invalid PostgreSQL failure counter"
return sum(values)