litellm/tests/unit/telemetry/test_aggregate.py
devin-ai-integration[bot] 596c51a5fa
feat(telemetry): add AggregatingSink, fixed-bucket histograms, Report and HttpExporter (#45487)
* 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>
2026-10-13 18:33:01 -07:00

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",
]