litellm/tests/test_litellm/proxy/test_prometheus_cleanup.py
devin-ai-integration[bot] 80839bb33c
feat(proxy): serve Prometheus /metrics from a separate process via --prometheus_metrics_port (#39889)
* feat(proxy): serve Prometheus /metrics from a separate process via --prometheus_metrics_port

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

* style(proxy): ruff format prometheus_metrics_server

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

* fix(proxy): fail fast when the separate metrics server cannot start and force the multiproc dir whenever it is enabled

- wait for the child's /health before starting uvicorn; raise a ClickException if it exits first (port in use)
- create PROMETHEUS_MULTIPROC_DIR whenever --prometheus_metrics_port is set, so DB-configured prometheus callbacks work
- honour lowercase prometheus_multiproc_dir; validate the port before spawning
- cover main() entry point, readiness, bind failure and wildcard-host probing in tests

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

* fix(proxy): pin metrics-server readiness to the child pid so another service on the port cannot pass the health check

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

* refactor(proxy): probe metrics-server readiness through the shared HTTPHandler instead of bare httpx.get

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

* refactor(proxy): serve only /metrics on the prometheus metrics port

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

* fix(proxy): validate metrics server CLI args with pydantic instead of typing.cast

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

* fix(proxy): satisfy metrics server lint gate

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

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-05 13:26:09 -07:00

173 lines
6.7 KiB
Python

"""
Tests for litellm.proxy.prometheus_cleanup.wipe_directory and
ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir.
"""
from __future__ import annotations
import os
from unittest.mock import patch
import pytest
from litellm.proxy.prometheus_cleanup import mark_worker_exit, wipe_directory
from litellm.proxy.proxy_cli import ProxyInitializationHelpers
class TestWipeDirectory:
def test_deletes_all_db_files(self, tmp_path):
(tmp_path / "counter_1234.db").touch()
(tmp_path / "histogram_5678.db").touch()
(tmp_path / "gauge_livesum_9999.db").touch()
wipe_directory(str(tmp_path))
assert not list(tmp_path.glob("*.db"))
class TestMarkWorkerExit:
def test_calls_mark_process_dead_when_env_set(self, tmp_path):
with patch.dict(os.environ, {"PROMETHEUS_MULTIPROC_DIR": str(tmp_path)}):
with patch("prometheus_client.multiprocess.mark_process_dead") as mock_mark:
mark_worker_exit(12345)
mock_mark.assert_called_once_with(12345)
def test_noop_when_env_not_set(self):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
with patch("prometheus_client.multiprocess.mark_process_dead") as mock_mark:
mark_worker_exit(12345)
mock_mark.assert_not_called()
def test_exception_is_caught_and_logged(self, tmp_path):
with patch.dict(os.environ, {"PROMETHEUS_MULTIPROC_DIR": str(tmp_path)}):
with patch(
"prometheus_client.multiprocess.mark_process_dead",
side_effect=FileNotFoundError("gone"),
) as mock_mark:
# Should not raise
mark_worker_exit(99)
mock_mark.assert_called_once_with(99)
class TestMaybeSetupPrometheusMultiprocDir:
def test_respects_existing_env_var(self, tmp_path):
"""When PROMETHEUS_MULTIPROC_DIR is already set, don't override it."""
custom_dir = str(tmp_path / "custom_prom")
litellm_settings = {"callbacks": ["prometheus"]}
with patch.dict(os.environ, {"PROMETHEUS_MULTIPROC_DIR": custom_dir}):
ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
num_workers=4,
litellm_settings=litellm_settings,
)
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == custom_dir
assert os.path.isdir(custom_dir)
@pytest.mark.parametrize(
"litellm_settings",
[
{"callbacks": "prometheus"},
{"success_callback": "prometheus"},
{"failure_callback": "prometheus"},
{"callbacks": "custom_callback"}, # string but not prometheus
],
)
def test_handles_string_callbacks(self, litellm_settings):
"""When callbacks are specified as a string instead of a list, should not crash."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
os.environ.pop("prometheus_multiproc_dir", None)
# Should not raise TypeError
ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
num_workers=4,
litellm_settings=litellm_settings,
)
# Cleanup
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
@pytest.mark.parametrize(
"num_workers, litellm_settings",
[
(1, {"callbacks": ["prometheus"]}),
(4, {"callbacks": ["langfuse"]}),
(4, None),
],
)
def test_noop_when_setup_not_needed(self, num_workers, litellm_settings):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
os.environ.pop("prometheus_multiproc_dir", None)
ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
num_workers=num_workers,
litellm_settings=litellm_settings,
)
assert os.environ.get("PROMETHEUS_MULTIPROC_DIR") is None
@pytest.mark.parametrize(
"litellm_settings",
[
{"callbacks": ["prometheus"]},
{"success_callback": ["prometheus"]},
],
)
def test_auto_creates_dir_when_prometheus_configured(self, litellm_settings):
"""When multiple workers + prometheus callback, auto-creates temp dir."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
os.environ.pop("prometheus_multiproc_dir", None)
ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
num_workers=4,
litellm_settings=litellm_settings,
)
result_dir = os.environ.get("PROMETHEUS_MULTIPROC_DIR")
assert result_dir is not None
assert os.path.isdir(result_dir)
# Cleanup
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
@pytest.mark.parametrize(
"litellm_settings",
[
{"callbacks": ["prometheus"]},
{"callbacks": ["langfuse"]},
None,
],
)
def test_separate_metrics_port_forces_dir_for_single_worker(self, litellm_settings):
"""The separate metrics process reads the samples, so one worker still needs the shared dir, even when
prometheus is not in config.yaml (callbacks can be turned on from the DB after startup)."""
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
os.environ.pop("prometheus_multiproc_dir", None)
result_dir = ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
num_workers=1,
litellm_settings=litellm_settings,
prometheus_metrics_port=4001,
)
assert result_dir is not None
assert os.environ.get("PROMETHEUS_MULTIPROC_DIR") == result_dir
assert os.path.isdir(result_dir)
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
def test_lowercase_env_var_is_reused_and_exported_uppercase(self, tmp_path):
"""prometheus_client honours both spellings; the metrics server only reads the uppercase one."""
with patch.dict(os.environ, {"prometheus_multiproc_dir": str(tmp_path)}, clear=False):
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
result_dir = ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
num_workers=4,
litellm_settings={"callbacks": "prometheus"},
)
assert result_dir == str(tmp_path)
assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(tmp_path)