mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
* feat(telemetry): add AggregatingSink, fixed-bucket histograms, Report and HttpExporter * fix(telemetry): add records to rows in place, guard concurrent flushes, cap restored rows and count bad histogram values Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(telemetry): give the shared row helper its lint reasons Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(telemetry): retry the same statuses as the proxy's should_retry (408, 409, 429, 5xx) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(telemetry): cover populated block_types and header_keys in report JSON Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: nate <nate@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
265 lines
9.2 KiB
Python
265 lines
9.2 KiB
Python
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
|
|
import pytest
|
|
|
|
from litellm.telemetry.aggregate import AggregatingSink
|
|
from litellm.telemetry.histogram import ATTEMPT_BOUNDS, BLOCK_COUNT_BOUNDS, LATENCY_BOUNDS_MS, Histogram
|
|
from litellm.telemetry.records import (
|
|
AttemptRecord,
|
|
BlockCounts,
|
|
BlockType,
|
|
InstanceInfo,
|
|
RequestRecord,
|
|
StatusClass,
|
|
TelemetryGroup,
|
|
TokenCounts,
|
|
UIAction,
|
|
UIEvent,
|
|
)
|
|
from litellm.telemetry.report import Report, RequestKey
|
|
from litellm.telemetry.sink import Exporter, ExportOutcome
|
|
|
|
|
|
@dataclass
|
|
class _FakeExporter:
|
|
outcome: ExportOutcome = ExportOutcome.SENT
|
|
reports: tuple[Report, ...] = ()
|
|
|
|
async def export(self, report: Report) -> ExportOutcome:
|
|
self.reports = (*self.reports, report)
|
|
return self.outcome
|
|
|
|
|
|
@dataclass
|
|
class _Clock:
|
|
now: float = 1000.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
_INSTANCE: Final = InstanceInfo(instance_id="i", litellm_version="1.0.0", groups=frozenset(TelemetryGroup))
|
|
|
|
|
|
def _request(
|
|
*, provider: str = "openai", latency_ms: float = 80.0, tokens: TokenCounts = TokenCounts(), attempts: int = 1
|
|
) -> RequestRecord:
|
|
return RequestRecord(
|
|
endpoint="/chat/completions",
|
|
stream=False,
|
|
litellm_status=StatusClass.SUCCESS,
|
|
latency_to_first_byte_ms=latency_ms,
|
|
provider=provider,
|
|
provider_status=StatusClass.SUCCESS,
|
|
provider_attempts=attempts,
|
|
tokens=tokens,
|
|
blocks=BlockCounts(total=2, by_type=((BlockType.TEXT, 2),)),
|
|
header_keys=frozenset({"anthropic-beta"}),
|
|
)
|
|
|
|
|
|
def _histogram(bounds: tuple[float, ...], *values: float) -> Histogram:
|
|
histogram: Final = Histogram.empty(bounds)
|
|
for value in values:
|
|
histogram.add(value)
|
|
return histogram
|
|
|
|
|
|
def _sink(exporter: Exporter, clock: _Clock, max_rows: int = 100) -> AggregatingSink:
|
|
sink: Final = AggregatingSink(exporter, clock=clock, max_rows=max_rows)
|
|
sink.set_instance(_INSTANCE)
|
|
return sink
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_requests_with_the_same_dimensions_fold_into_one_row() -> None:
|
|
exporter: Final = _FakeExporter()
|
|
clock: Final = _Clock()
|
|
sink: Final = _sink(exporter, clock)
|
|
sink.record_request(_request(latency_ms=80.0, tokens=TokenCounts(input=10, output=3, cache_read=4), attempts=1))
|
|
sink.record_request(_request(latency_ms=600.0, tokens=TokenCounts(input=5, output=2, cache_read=7), attempts=2))
|
|
clock.now = 1060.0
|
|
await sink.flush()
|
|
|
|
(report,) = exporter.reports
|
|
assert (report.window_start, report.window_end) == (1000.0, 1060.0)
|
|
((key, metrics),) = report.requests
|
|
assert key == RequestKey.of(_request())
|
|
assert (metrics.request_count, metrics.input_tokens, metrics.output_tokens, metrics.cache_read_tokens) == (
|
|
2,
|
|
15,
|
|
5,
|
|
11,
|
|
)
|
|
assert metrics.latency_to_first_byte == _histogram(LATENCY_BOUNDS_MS, 80.0, 600.0)
|
|
assert metrics.provider_attempts == _histogram(ATTEMPT_BOUNDS, 1, 2)
|
|
assert metrics.block_count == _histogram(BLOCK_COUNT_BOUNDS, 2, 2)
|
|
assert metrics.block_types == {BlockType.TEXT: 4}
|
|
assert metrics.header_keys == {"anthropic-beta": 2}
|
|
assert metrics.latency_to_headers == Histogram.empty(LATENCY_BOUNDS_MS)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_each_window_starts_empty_after_a_successful_export() -> None:
|
|
exporter: Final = _FakeExporter()
|
|
clock: Final = _Clock()
|
|
sink: Final = _sink(exporter, clock)
|
|
sink.record_request(_request(provider="openai"))
|
|
sink.record_request(_request(provider="anthropic"))
|
|
await sink.flush()
|
|
clock.now = 1100.0
|
|
await sink.flush()
|
|
|
|
first, second = exporter.reports
|
|
assert {key.provider for key, _ in first.requests} == {"openai", "anthropic"}
|
|
assert second.requests == ()
|
|
assert second.window_start == first.window_end
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_retryable_export_failure_carries_the_window_into_the_next_report() -> None:
|
|
exporter: Final = _FakeExporter(outcome=ExportOutcome.RETRY)
|
|
clock: Final = _Clock()
|
|
sink: Final = _sink(exporter, clock)
|
|
sink.record_request(_request())
|
|
sink.record_attempt(AttemptRecord(provider="openai", provider_status=StatusClass.SUCCESS, stream=False))
|
|
sink.record_ui_event(UIEvent(page="models", action=UIAction.VIEW))
|
|
await sink.flush()
|
|
sink.record_request(_request())
|
|
sink.record_ui_event(UIEvent(page="models", action=UIAction.VIEW))
|
|
exporter.outcome = ExportOutcome.SENT
|
|
clock.now = 2000.0
|
|
await sink.flush()
|
|
|
|
retried: Final = exporter.reports[-1]
|
|
assert retried.window_start == 1000.0
|
|
assert retried.requests[0][1].request_count == 2
|
|
assert retried.attempts[0][1].attempt_count == 1
|
|
assert retried.ui_events == ((UIEvent(page="models", action=UIAction.VIEW), 2),)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_rejected_report_is_not_resent() -> None:
|
|
exporter: Final = _FakeExporter(outcome=ExportOutcome.REJECTED)
|
|
sink: Final = _sink(exporter, _Clock())
|
|
sink.record_request(_request())
|
|
await sink.flush()
|
|
await sink.flush()
|
|
assert exporter.reports[-1].requests == ()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_rows_past_the_cap_are_dropped_and_counted_but_existing_rows_keep_folding() -> None:
|
|
exporter: Final = _FakeExporter()
|
|
sink: Final = _sink(exporter, _Clock(), max_rows=1)
|
|
sink.record_request(_request(provider="openai"))
|
|
sink.record_request(_request(provider="anthropic"))
|
|
sink.record_attempt(AttemptRecord(provider="openai", provider_status=StatusClass.SUCCESS, stream=False))
|
|
sink.record_request(_request(provider="openai"))
|
|
await sink.flush()
|
|
|
|
(report,) = exporter.reports
|
|
assert [(key.provider, metrics.request_count) for key, metrics in report.requests] == [("openai", 2)]
|
|
assert report.attempts == ()
|
|
assert report.dropped_records == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nothing_is_exported_before_the_instance_is_known() -> None:
|
|
exporter: Final = _FakeExporter()
|
|
sink: Final = AggregatingSink(exporter, clock=_Clock())
|
|
sink.record_request(_request())
|
|
await sink.flush()
|
|
assert exporter.reports == ()
|
|
sink.set_instance(_INSTANCE)
|
|
await sink.flush()
|
|
assert exporter.reports[0].requests[0][1].request_count == 1
|
|
|
|
|
|
@dataclass
|
|
class _RecordsDuringExport:
|
|
records: tuple[RequestRecord, ...]
|
|
sink: AggregatingSink | None = None
|
|
outcome: ExportOutcome = ExportOutcome.RETRY
|
|
reports: tuple[Report, ...] = ()
|
|
|
|
async def export(self, report: Report) -> ExportOutcome:
|
|
self.reports = (*self.reports, report)
|
|
sink: Final = self.sink
|
|
if sink is not None:
|
|
for record in self.records:
|
|
sink.record_request(record)
|
|
return self.outcome
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_retried_window_restores_only_up_to_the_row_cap_and_counts_the_rest_as_dropped() -> None:
|
|
exporter: Final = _RecordsDuringExport(records=(_request(provider="bedrock"), _request(provider="vertex_ai")))
|
|
sink: Final = _sink(exporter, _Clock(), max_rows=2)
|
|
exporter.sink = sink
|
|
sink.record_request(_request(provider="openai"))
|
|
sink.record_request(_request(provider="openai"))
|
|
sink.record_request(_request(provider="anthropic"))
|
|
await sink.flush()
|
|
exporter.outcome, exporter.records = ExportOutcome.SENT, ()
|
|
await sink.flush()
|
|
|
|
retried: Final = exporter.reports[-1]
|
|
assert [(key.provider, metrics.request_count) for key, metrics in retried.requests] == [
|
|
("bedrock", 1),
|
|
("vertex_ai", 1),
|
|
]
|
|
assert retried.dropped_records == 3
|
|
|
|
|
|
@dataclass
|
|
class _BlockingExporter:
|
|
release: asyncio.Event
|
|
reports: tuple[Report, ...] = ()
|
|
|
|
async def export(self, report: Report) -> ExportOutcome:
|
|
self.reports = (*self.reports, report)
|
|
await self.release.wait()
|
|
return ExportOutcome.SENT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_flush_while_another_flush_is_exporting_does_nothing() -> None:
|
|
exporter: Final = _BlockingExporter(release=asyncio.Event())
|
|
sink: Final = AggregatingSink(exporter, clock=_Clock())
|
|
sink.set_instance(_INSTANCE)
|
|
sink.record_request(_request())
|
|
first: Final = asyncio.create_task(sink.flush())
|
|
await asyncio.sleep(0)
|
|
sink.record_request(_request())
|
|
await sink.flush()
|
|
exporter.release.set()
|
|
await first
|
|
|
|
assert len(exporter.reports) == 1
|
|
await sink.flush()
|
|
assert exporter.reports[-1].requests[0][1].request_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_each_retry_past_the_quiet_limit_logs_an_error_until_an_export_succeeds(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
exporter: Final = _FakeExporter(outcome=ExportOutcome.RETRY)
|
|
sink: Final = AggregatingSink(exporter, clock=_Clock(), logger=logging.getLogger("telemetry-test"))
|
|
sink.set_instance(_INSTANCE)
|
|
with caplog.at_level(logging.ERROR, logger="telemetry-test"):
|
|
for _ in range(4):
|
|
await sink.flush()
|
|
exporter.outcome = ExportOutcome.SENT
|
|
await sink.flush()
|
|
exporter.outcome = ExportOutcome.RETRY
|
|
await sink.flush()
|
|
|
|
assert [record.getMessage() for record in caplog.records] == [
|
|
"telemetry: report export failed 3 times in a row, keeping the window for the next flush",
|
|
"telemetry: report export failed 4 times in a row, keeping the window for the next flush",
|
|
]
|