Skip to content

Commit 3e353f5

Browse files
[Bugfix][Router] Fix stale Prometheus endpoint health metrics (#888) (#936)
* fix(router): clear stale Prometheus endpoint health metrics (fixes #888) Endpoint health gauges retained stale server= labels indefinitely when a backend was removed from service discovery, causing Prometheus to keep exporting the last known value for dead endpoints. Changes: - Add EndpointInfo.healthy field (default True) to carry health state from service discovery into the metrics handler - Introduce _LABEL_GAUGES list and _clear_label_gauges() helper in metrics_router.py; call it at the start of every /metrics scrape so only currently-active endpoints appear in the output - Update healthy_pods_total to use ep.healthy instead of a hardcoded 1 - Add test_stale_metrics.py covering: EndpointInfo.healthy default/ override, stale label removal, full endpoint removal, unlabeled gauge preservation, _LABEL_GAUGES completeness, and post-clear repopulation - Remove redundant inline comments and section headers throughout Signed-off-by: prashansapkota <prashan.sapkota3456@gmail.com> * fix(router): address review feedback on PR #936 - Add num_requests_waiting to _LABEL_GAUGES and import it so stale waiting-request series are cleared along with all other server labels - Restore the metrics() endpoint docstring - Expand test imports and expected set in test_label_gauges_list_contains_all_expected_gauges to cover all server-labeled gauges including num_requests_waiting Signed-off-by: prashansapkota <prashan.sapkota3456@gmail.com> * fix: remove unused CollectorRegistry import in test_stale_metrics.py Signed-off-by: prashansapkota <prashan.sapkota3456@gmail.com> --------- Signed-off-by: prashansapkota <prashan.sapkota3456@gmail.com> Co-authored-by: Rui Zhang <51696593+ruizhang0101@users.noreply.github.com>
1 parent 72c2b7f commit 3e353f5

3 files changed

Lines changed: 160 additions & 32 deletions

File tree

src/tests/test_stale_metrics.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import pytest
2+
from prometheus_client import REGISTRY, generate_latest
3+
4+
from vllm_router.routers.metrics_router import _LABEL_GAUGES, _clear_label_gauges
5+
from vllm_router.service_discovery import EndpointInfo
6+
from vllm_router.services.metrics_service import (
7+
avg_decoding_length,
8+
avg_itl,
9+
avg_latency,
10+
current_qps,
11+
gpu_prefix_cache_hit_rate,
12+
gpu_prefix_cache_hits_total,
13+
gpu_prefix_cache_queries_total,
14+
healthy_pods_total,
15+
num_decoding_requests,
16+
num_prefill_requests,
17+
num_requests_running,
18+
num_requests_swapped,
19+
num_requests_waiting,
20+
)
21+
22+
23+
def test_endpoint_info_healthy_defaults_to_true():
24+
ep = EndpointInfo(
25+
url="http://ep1:8000",
26+
model_names=["llama"],
27+
Id="id1",
28+
added_timestamp=0,
29+
model_label="default",
30+
sleep=False,
31+
)
32+
assert ep.healthy is True
33+
34+
35+
def test_endpoint_info_healthy_can_be_set_false():
36+
ep = EndpointInfo(
37+
url="http://ep1:8000",
38+
model_names=["llama"],
39+
Id="id1",
40+
added_timestamp=0,
41+
model_label="default",
42+
sleep=False,
43+
healthy=False,
44+
)
45+
assert ep.healthy is False
46+
47+
48+
@pytest.fixture(autouse=True)
49+
def _reset_gauges():
50+
"""Clear every label-based gauge before and after each test."""
51+
_clear_label_gauges()
52+
yield
53+
_clear_label_gauges()
54+
55+
56+
def test_cleared_gauge_removes_stale_labels():
57+
"""Stale server labels must disappear after _clear_label_gauges()."""
58+
# Simulate two active endpoints
59+
healthy_pods_total.labels(server="http://ep1:8000").set(1)
60+
healthy_pods_total.labels(server="http://ep2:8000").set(1)
61+
62+
output = generate_latest(REGISTRY).decode()
63+
assert "ep1" in output
64+
assert "ep2" in output
65+
66+
# Clear all labels (simulates start of /metrics handler)
67+
_clear_label_gauges()
68+
69+
# Re-populate only ep1 (ep2 was removed from service discovery)
70+
healthy_pods_total.labels(server="http://ep1:8000").set(1)
71+
72+
output = generate_latest(REGISTRY).decode()
73+
assert "ep1" in output
74+
assert "ep2" not in output
75+
76+
77+
def test_clear_removes_all_labels_when_all_endpoints_gone():
78+
"""When every endpoint is removed, no server labels remain."""
79+
current_qps.labels(server="http://ep1:8000").set(42)
80+
num_requests_running.labels(server="http://ep1:8000").set(3)
81+
82+
_clear_label_gauges()
83+
84+
output = generate_latest(REGISTRY).decode()
85+
assert "ep1" not in output
86+
87+
88+
def test_clear_does_not_affect_unlabeled_gauges():
89+
"""System gauges (CPU, memory, disk) have no labels and are unaffected."""
90+
from vllm_router.routers.metrics_router import router_cpu_usage_percent
91+
92+
router_cpu_usage_percent.set(42.0)
93+
healthy_pods_total.labels(server="http://ep1:8000").set(1)
94+
95+
_clear_label_gauges()
96+
97+
output = generate_latest(REGISTRY).decode()
98+
assert "router_cpu_usage_percent" in output
99+
assert "42.0" in output
100+
101+
102+
def test_label_gauges_list_contains_all_expected_gauges():
103+
"""Ensure every gauge we export with a server label is in _LABEL_GAUGES."""
104+
expected = {
105+
current_qps,
106+
avg_decoding_length,
107+
num_prefill_requests,
108+
num_decoding_requests,
109+
num_requests_running,
110+
num_requests_waiting,
111+
avg_latency,
112+
avg_itl,
113+
num_requests_swapped,
114+
gpu_prefix_cache_hit_rate,
115+
gpu_prefix_cache_hits_total,
116+
gpu_prefix_cache_queries_total,
117+
healthy_pods_total,
118+
}
119+
assert expected.issubset(set(_LABEL_GAUGES))
120+
121+
122+
def test_repopulate_after_clear_shows_correct_values():
123+
"""Values set after clear must reflect in Prometheus output."""
124+
_clear_label_gauges()
125+
126+
healthy_pods_total.labels(server="http://new-ep:8000").set(1)
127+
current_qps.labels(server="http://new-ep:8000").set(99.5)
128+
129+
output = generate_latest(REGISTRY).decode()
130+
assert "new-ep" in output
131+
assert "99.5" in output

src/vllm_router/routers/metrics_router.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from fastapi import APIRouter, Response
1919
from prometheus_client import CONTENT_TYPE_LATEST, Gauge, generate_latest
2020

21+
from vllm_router.log import init_logger
2122
from vllm_router.service_discovery import get_service_discovery
2223
from vllm_router.services.metrics_service import (
2324
avg_decoding_length,
@@ -32,13 +33,15 @@
3233
num_prefill_requests,
3334
num_requests_running,
3435
num_requests_swapped,
36+
num_requests_waiting,
3537
)
3638
from vllm_router.stats.engine_stats import get_engine_stats_scraper
3739
from vllm_router.stats.request_stats import get_request_stats_monitor
3840

41+
logger = init_logger(__name__)
42+
3943
metrics_router = APIRouter()
4044

41-
# Define Gauges for system resource usage
4245
router_cpu_usage_percent = Gauge(
4346
"router_cpu_usage_percent",
4447
"CPU usage percent",
@@ -53,10 +56,30 @@
5356
)
5457

5558

56-
# --- Prometheus Metrics Endpoint ---
59+
_LABEL_GAUGES = [
60+
current_qps,
61+
avg_decoding_length,
62+
num_prefill_requests,
63+
num_decoding_requests,
64+
num_requests_running,
65+
num_requests_waiting,
66+
avg_latency,
67+
avg_itl,
68+
num_requests_swapped,
69+
gpu_prefix_cache_hit_rate,
70+
gpu_prefix_cache_hits_total,
71+
gpu_prefix_cache_queries_total,
72+
healthy_pods_total,
73+
]
74+
75+
76+
def _clear_label_gauges() -> None:
77+
for gauge in _LABEL_GAUGES:
78+
gauge.clear()
79+
80+
5781
@metrics_router.get("/metrics")
5882
async def metrics():
59-
# Retrieve request stats from the monitor.
6083
"""
6184
Endpoint to expose Prometheus metrics for the vLLM router.
6285
@@ -72,20 +95,17 @@ async def metrics():
7295
Response: A HTTP response containing the latest Prometheus metrics in
7396
the appropriate content type.
7497
"""
98+
_clear_label_gauges()
7599

76-
# Collect CPU utilization (short interval)
77100
cpu_percent = psutil.cpu_percent(interval=0.1)
78101
router_cpu_usage_percent.set(cpu_percent)
79102

80-
# Collect memory utilization
81103
memory_percent = psutil.virtual_memory().percent
82104
router_memory_usage_percent.set(memory_percent)
83105

84-
# Collect disk utilization on root filesystem
85106
disk_percent = psutil.disk_usage("/").percent
86107
router_disk_usage_percent.set(disk_percent)
87108

88-
# Existing vLLM router request statistics
89109
stats = get_request_stats_monitor().get_request_stats(time.time())
90110
for server, stat in stats.items():
91111
current_qps.labels(server=server).set(stat.qps)
@@ -99,7 +119,6 @@ async def metrics():
99119
avg_itl.labels(server=server).set(stat.avg_itl)
100120
num_requests_swapped.labels(server=server).set(stat.num_swapped_requests)
101121

102-
# Engine statistics (GPU prefix cache metrics)
103122
engine_stats = get_engine_stats_scraper().get_engine_stats()
104123
for server, engine_stat in engine_stats.items():
105124
gpu_prefix_cache_hit_rate.labels(server=server).set(
@@ -112,12 +131,8 @@ async def metrics():
112131
engine_stat.gpu_prefix_cache_queries_total
113132
)
114133

115-
# Service discovery health status
116134
endpoints = get_service_discovery().get_endpoint_info()
117135
for ep in endpoints:
118-
healthy_pods_total.labels(server=ep.url).set(
119-
1 if getattr(ep, "healthy", True) else 0
120-
)
136+
healthy_pods_total.labels(server=ep.url).set(1 if ep.healthy else 0)
121137

122-
# Return all metrics in Prometheus format
123138
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

src/vllm_router/service_discovery.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -96,34 +96,16 @@ def to_dict(self) -> Dict:
9696

9797
@dataclass
9898
class EndpointInfo:
99-
# Endpoint's url
10099
url: str
101-
102-
# Model names
103100
model_names: List[str]
104-
105-
# Endpoint Id
106101
Id: str
107-
108-
# Added timestamp
109102
added_timestamp: float
110-
111-
# Model label
112103
model_label: str
113-
114-
# Endpoint's sleep status
115104
sleep: bool
116-
117-
# Pod name
105+
healthy: bool = True
118106
pod_name: Optional[str] = None
119-
120-
# Service name
121107
service_name: Optional[str] = None
122-
123-
# Namespace
124108
namespace: Optional[str] = None
125-
126-
# Model information including relationships
127109
model_info: Dict[str, ModelInfo] = None
128110

129111
def __str__(self):

0 commit comments

Comments
 (0)