Skip to content

Commit d75be9b

Browse files
Bslabe123jjk-g
authored andcommitted
Add E2e testing for Prometheus Querying and Report Contents (kubernetes-sigs#413)
Addresses: kubernetes-sigs#386 Adds a dedicated E2E test for making sure Prometheus metrics are correctly queried by using the llm-d-inference sim and a dedicated ephemeral Prometheus instance. Other small bug fixes. Note: * Since `vLLM >= 0.7.0` `request_success_total` is the recommended metric for counting successful requests. Updated the query to cover both cases.
1 parent a8b07ec commit d75be9b

9 files changed

Lines changed: 555 additions & 9 deletions

File tree

e2e/conftest.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,72 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
15+
from pathlib import Path
16+
import subprocess
17+
import tempfile
18+
import time
19+
import pytest
20+
import requests
21+
22+
23+
@pytest.fixture(scope="module")
24+
def prometheus_server():
25+
"""Starts a lightweight ephemeral Prometheus instance."""
26+
with tempfile.TemporaryDirectory() as tmpdir:
27+
tmp_path = Path(tmpdir)
28+
config_path = tmp_path / "prometheus.yml"
29+
30+
# Write minimal config pointing to the simulator
31+
config_path.write_text(
32+
"""
33+
global:
34+
scrape_interval: 5s
35+
scrape_configs:
36+
- job_name: 'llm-d-inference-sim'
37+
static_configs:
38+
- targets: ['127.0.0.1:18000', '127.0.0.1:18001']
39+
""",
40+
encoding="utf-8",
41+
)
42+
43+
data_dir = tmp_path / "data"
44+
data_dir.mkdir()
45+
46+
# Start prometheus
47+
proc = subprocess.Popen(
48+
[
49+
"prometheus",
50+
f"--config.file={config_path}",
51+
f"--storage.tsdb.path={data_dir}",
52+
"--web.listen-address=127.0.0.1:9090",
53+
],
54+
stdout=subprocess.PIPE,
55+
stderr=subprocess.STDOUT,
56+
)
57+
58+
# Wait for ready
59+
ready = False
60+
for _ in range(30):
61+
try:
62+
resp = requests.get("http://127.0.0.1:9090/-/ready", timeout=1)
63+
if resp.status_code == 200:
64+
ready = True
65+
break
66+
except Exception:
67+
pass
68+
time.sleep(1)
69+
70+
if not ready:
71+
proc.terminate()
72+
stdout, _ = proc.communicate()
73+
raise Exception(f"Prometheus failed to become ready. Output:\n{stdout.decode()}")
74+
75+
yield "http://127.0.0.1:9090"
76+
77+
# Teardown
78+
proc.terminate()
79+
try:
80+
proc.wait(timeout=5)
81+
except subprocess.TimeoutExpired:
82+
proc.kill()

e2e/tests/test_metrics_fallback.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Copyright 2026 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import http.server
16+
import json
17+
import pathlib
18+
import sys
19+
import threading
20+
import pytest
21+
22+
from utils.benchmark import run_benchmark_minimal
23+
from test_prometheus import is_prometheus_available
24+
25+
PROJECT_ROOT = pathlib.Path(__file__).parent.parent.parent.resolve()
26+
MAIN_PY_PATH = PROJECT_ROOT / "inference_perf" / "main.py"
27+
28+
29+
class MockHandler(http.server.BaseHTTPRequestHandler):
30+
metric_name = "vllm:request_success"
31+
success_count = 0
32+
33+
@classmethod
34+
def reset(cls, metric_name: str) -> None:
35+
cls.metric_name = metric_name
36+
cls.success_count = 0
37+
38+
def do_GET(self):
39+
if self.path == "/health":
40+
self.send_response(200)
41+
self.end_headers()
42+
elif self.path == "/metrics":
43+
body = (
44+
f"# HELP {self.metric_name} Count of successfully processed requests.\n"
45+
f"# TYPE {self.metric_name} counter\n"
46+
f'{self.metric_name}{{model_name="facebook/opt-125m"}} {float(MockHandler.success_count)}\n'
47+
)
48+
self.send_response(200)
49+
self.send_header("Content-Type", "text/plain")
50+
self.end_headers()
51+
self.wfile.write(body.encode("utf-8"))
52+
else:
53+
self.send_response(404)
54+
self.end_headers()
55+
56+
def do_POST(self):
57+
if self.path == "/v1/completions":
58+
MockHandler.success_count += 1
59+
self.send_response(200)
60+
self.send_header("Content-Type", "application/json")
61+
self.end_headers()
62+
response = {
63+
"id": "cmpl-mock",
64+
"object": "text_completion",
65+
"created": 12345,
66+
"model": "facebook/opt-125m",
67+
"choices": [{"text": " mock response", "finish_reason": "length"}],
68+
"usage": {"prompt_tokens": 1, "total_tokens": 5, "completion_tokens": 4},
69+
}
70+
self.wfile.write(json.dumps(response).encode("utf-8"))
71+
else:
72+
self.send_response(404)
73+
self.end_headers()
74+
75+
def log_message(self, format, *args):
76+
return
77+
78+
79+
def start_mock_server(port: int, metric_name: str) -> http.server.HTTPServer:
80+
MockHandler.reset(metric_name)
81+
server = http.server.HTTPServer(("127.0.0.1", port), MockHandler)
82+
thread = threading.Thread(target=server.serve_forever)
83+
thread.daemon = True
84+
thread.start()
85+
return server
86+
87+
88+
def _benchmark_config(prometheus_url: str, port: int) -> dict:
89+
return {
90+
"data": {
91+
"type": "shared_prefix",
92+
"shared_prefix": {
93+
"num_groups": 1,
94+
"num_prompts_per_group": 5,
95+
"system_prompt_len": 10,
96+
"question_len": 10,
97+
"output_len": 10,
98+
},
99+
},
100+
"load": {
101+
"type": "constant",
102+
"stages": [{"rate": 1, "duration": 15}],
103+
"num_workers": 1,
104+
},
105+
"api": {
106+
"type": "completion",
107+
"streaming": True,
108+
},
109+
"server": {
110+
"type": "vllm",
111+
"model_name": "facebook/opt-125m",
112+
"base_url": f"http://127.0.0.1:{port}",
113+
"ignore_eos": True,
114+
},
115+
"tokenizer": {
116+
"pretrained_model_name_or_path": "facebook/opt-125m",
117+
},
118+
"metrics": {
119+
"type": "prometheus",
120+
"prometheus": {
121+
"url": prometheus_url,
122+
"scrape_interval": 5,
123+
},
124+
},
125+
"report": {
126+
"prometheus": {
127+
"summary": True,
128+
},
129+
},
130+
}
131+
132+
133+
@pytest.mark.asyncio
134+
@pytest.mark.skipif(not is_prometheus_available(), reason="local environment missing prometheus")
135+
async def test_legacy_metric_name(prometheus_server):
136+
"""Verifies that inference-perf can collect metrics using the legacy name 'vllm:request_success'."""
137+
server = start_mock_server(18000, "vllm:request_success")
138+
139+
try:
140+
result = await run_benchmark_minimal(
141+
_benchmark_config(prometheus_server, 18000),
142+
executable=[sys.executable, str(MAIN_PY_PATH)],
143+
extra_env={"PYTHONPATH": str(PROJECT_ROOT)},
144+
)
145+
146+
assert result.success, f"Benchmark failed: {result.stdout}"
147+
assert "summary_prometheus_metrics.json" in result.reports
148+
report = result.reports["summary_prometheus_metrics.json"]
149+
assert "successes" in report
150+
success_count = report["successes"]["request_success_count"]
151+
assert success_count > 0, f"Expected non-zero success count from mock, got {success_count}"
152+
153+
finally:
154+
server.shutdown()
155+
server.server_close()
156+
157+
158+
@pytest.mark.asyncio
159+
@pytest.mark.skipif(not is_prometheus_available(), reason="local environment missing prometheus")
160+
async def test_new_metric_name(prometheus_server):
161+
"""Verifies that inference-perf can collect metrics using the new name 'vllm:request_success_total'."""
162+
server = start_mock_server(18001, "vllm:request_success_total")
163+
164+
try:
165+
result = await run_benchmark_minimal(
166+
_benchmark_config(prometheus_server, 18001),
167+
executable=[sys.executable, str(MAIN_PY_PATH)],
168+
extra_env={"PYTHONPATH": str(PROJECT_ROOT)},
169+
)
170+
171+
assert result.success, f"Benchmark failed: {result.stdout}"
172+
assert "summary_prometheus_metrics.json" in result.reports
173+
report = result.reports["summary_prometheus_metrics.json"]
174+
assert "successes" in report
175+
success_count = report["successes"]["request_success_count"]
176+
assert success_count > 0, f"Expected non-zero success count from mock, got {success_count}"
177+
178+
finally:
179+
server.shutdown()
180+
server.server_close()

0 commit comments

Comments
 (0)