Skip to content

Commit c81c91a

Browse files
committed
test: source drift-check name resolution from the metrics themselves
The drift checks decided whether a declared name resolves by restating the exposition's naming conventions ("a counter matches X or X_total") in the test utils, a second copy of what Metric.get_queries already implements. The copies drifted: CounterMetric("vllm:prompt_tokens") queries the bare name only, while the check accepted the declaration because v0.26.0 exposes vllm:prompt_tokens_total. The check passed on a metric whose query selects nothing, which is the exact failure mode it exists to catch. Metric.candidate_names() now reports the series a metric's queries select, as groups that are OR'd with the names within a group AND'd, and both drift checks ask the metric rather than restating the rules. The test utils keep only the series to family/type mapping, which is genuinely fixture-format knowledge. Against the v0.26.0 golden this reds exactly vllm:prompt_tokens and vllm:generation_tokens, both fixed by kubernetes-sigs#568. They go on a KNOWN_UNRESOLVED list, kept separate from CONDITIONALLY_EXPOSED (metrics gated off on a stock server) because the reason differs, and guarded by test_known_unresolved_still_do_not_resolve so the entries cannot outlive the fix. Part of kubernetes-sigs#669.
1 parent f0470e0 commit c81c91a

11 files changed

Lines changed: 264 additions & 109 deletions

File tree

e2e/tests/test_sglang_tgi_metric_names.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
committed fixture is by construction what these checks would have parsed.
5252
"""
5353

54-
from typing import Dict
54+
from typing import Any, Dict
5555
from unittest.mock import MagicMock, patch
5656

5757
import pytest
@@ -74,6 +74,7 @@
7474
)
7575

7676
from inference_perf.config import APIConfig, APIType
77+
from inference_perf.client.modelserver.metrics.base import Metric
7778

7879
SERVER_IDS = sorted(SERVERS)
7980

@@ -101,8 +102,11 @@
101102
}
102103

103104

104-
def declared_for(spec: ServerSpec) -> Dict[str, str]:
105-
"""Metric base names -> type, as the server's client subclass declares them.
105+
def declared_for(spec: ServerSpec) -> Dict[str, Metric[Any]]:
106+
"""Declared metric name -> the metric object, as the server's client subclass declares them.
107+
108+
The metric is carried rather than its name and type because only the metric knows
109+
which series its queries select (``candidate_names``).
106110
107111
Only the declarations are read, never the tokenizer, so CustomTokenizer is
108112
patched out to keep construction offline (same approach as
@@ -118,7 +122,7 @@ def declared_for(spec: ServerSpec) -> Dict[str, str]:
118122
max_tcp_connections=1,
119123
additional_filters=[],
120124
)
121-
declared = declared_metrics(client.get_prometheus_metric_metadata(), spec.prefix)
125+
declared = declared_metrics(client.get_prometheus_metric_metadata())
122126
assert declared, f"{spec.name} client declared no metric names"
123127
return declared
124128

@@ -153,9 +157,7 @@ def test_declared_names_resolve_against_fixture(server: str) -> None:
153157
allowed = KNOWN_UNRESOLVED[server]
154158

155159
missing = sorted(
156-
name
157-
for name, metric_type in declared.items()
158-
if name not in allowed and not resolves(name, metric_type, fixture.families)
160+
name for name, metric in declared.items() if name not in allowed and not resolves(metric, fixture.families)
159161
)
160162
assert not missing, (
161163
f"{len(missing)}/{len(declared)} names declared by the {server} client do not resolve against "
@@ -177,7 +179,7 @@ def test_known_unresolved_are_still_unresolved(server: str) -> None:
177179
undeclared = sorted(name for name in allowed if name not in declared)
178180
assert not undeclared, f"{server} no longer declares {undeclared}; drop the KNOWN_UNRESOLVED entries"
179181

180-
now_resolving = sorted(name for name in allowed if resolves(name, declared[name], fixture.families))
182+
now_resolving = sorted(name for name in allowed if resolves(declared[name], fixture.families))
181183
assert not now_resolving, (
182184
f"{server} declarations {now_resolving} now resolve against {fixture_path(server).name}; "
183185
f"drop their KNOWN_UNRESOLVED entries so the strict check covers them again"
@@ -215,9 +217,7 @@ def test_declared_metric_names_exist(server: str) -> None:
215217
declared = declared_for(spec)
216218
allowed = KNOWN_UNRESOLVED[server]
217219

218-
missing = sorted(
219-
name for name, metric_type in declared.items() if name not in allowed and not is_exposed(name, metric_type, names)
220-
)
220+
missing = sorted(name for name, metric in declared.items() if name not in allowed and not is_exposed(metric, names))
221221
assert not missing, (
222222
f"{len(missing)}/{len(declared)} names declared by the {server} client are absent from a real "
223223
f"/metrics exposition (stale names produce silently empty report fields): {missing}"

e2e/tests/test_vllm_cpu_metric_names.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
"""
4646

4747
import os
48+
from pathlib import Path
49+
from typing import Any
4850

4951
import aiohttp
5052
import pytest
@@ -64,6 +66,7 @@
6466
from utils.testdata import extract_tarball
6567
from utils.vllm_server import DEFAULT_MODEL, VLLMServerRunner
6668

69+
from inference_perf.client.modelserver.metrics.base import Metric
6770
from inference_perf.client.modelserver.vllm_client import vLLMModelServerClient
6871
from inference_perf.config import APIConfig, APIType, CustomTokenizerConfig
6972
from inference_perf.metrics.request_collector.local import LocalRequestMetricCollector
@@ -94,11 +97,27 @@
9497
"vllm:prompt_tokens_recomputed",
9598
}
9699

100+
# Declarations whose queries select nothing, with a fix already in flight. Kept
101+
# apart from CONDITIONALLY_EXPOSED because the reason is different: those names
102+
# are gated off on a stock server, these are simply queried under a name the
103+
# server does not use. Found by this check once it started asking what the
104+
# queries select rather than whether the declared name is findable (#669).
105+
#
106+
# The list cannot rot: test_known_unresolved_still_do_not_resolve fails the
107+
# moment an entry starts resolving, which forces it to be deleted.
108+
# Empty: #568 landed and made counters span both name forms, so vllm:prompt_tokens and
109+
# vllm:generation_tokens resolve again and the strict check covers them directly. The
110+
# mechanism stays for the next declaration whose fix is still in flight.
111+
KNOWN_UNRESOLVED: dict[str, str] = {}
112+
113+
# Names excluded from the strict checks for either reason.
114+
SKIPPED = CONDITIONALLY_EXPOSED | set(KNOWN_UNRESOLVED)
115+
97116
GOLDEN_FILES = sorted(GOLDEN_DIR.glob("*.txt"))
98117

99118

100-
def _declared(base_url: str, model_name: str) -> dict[str, str]:
101-
"""Metric base names -> metric type, as declared by the vLLM client."""
119+
def _declared(base_url: str, model_name: str) -> dict[str, Metric[Any]]:
120+
"""Declared metric name -> the metric object, as declared by the vLLM client."""
102121
client = vLLMModelServerClient(
103122
metrics_collector=LocalRequestMetricCollector(),
104123
api_config=APIConfig(type=APIType.Completion),
@@ -128,26 +147,41 @@ def test_goldens_are_committed() -> None:
128147

129148

130149
@pytest.mark.parametrize("golden_file", GOLDEN_FILES, ids=lambda p: p.stem)
131-
def test_declared_names_resolve_against_goldens(golden_file) -> None:
150+
def test_declared_names_resolve_against_goldens(golden_file: Path) -> None:
132151
golden = load_golden(golden_file)
133152
assert golden, f"golden {golden_file} is empty"
134153
declared = _declared("http://127.0.0.1:1", DEFAULT_MODEL)
135154
assert declared, "vLLM client declared no metric names"
136155

137-
missing = sorted(
138-
name
139-
for name, metric_type in declared.items()
140-
if name not in CONDITIONALLY_EXPOSED and not in_golden(name, metric_type, golden)
141-
)
156+
missing = sorted(name for name, metric in declared.items() if name not in SKIPPED and not in_golden(metric, golden))
142157
assert not missing, (
143158
f"{len(missing)}/{len(declared)} declared metric names do not resolve against {golden_file.name} "
144159
f"(stale names produce silently empty report fields): {missing}"
145160
)
146161

147162

163+
@pytest.mark.parametrize("golden_file", GOLDEN_FILES, ids=lambda p: p.stem)
164+
def test_known_unresolved_still_do_not_resolve(golden_file: Path) -> None:
165+
# Guards the allowlist above. Each KNOWN_UNRESOLVED name must still be declared
166+
# and must still fail to resolve; the moment #568 lands and vllm:prompt_tokens
167+
# starts selecting vllm:prompt_tokens_total, this goes red and the entry has to
168+
# be deleted. An allowlist that quietly stops applying is how a gate rots.
169+
golden = load_golden(golden_file)
170+
declared = _declared("http://127.0.0.1:1", DEFAULT_MODEL)
171+
172+
undeclared = sorted(name for name in KNOWN_UNRESOLVED if name not in declared)
173+
assert not undeclared, f"no longer declared, drop the KNOWN_UNRESOLVED entries: {undeclared}"
174+
175+
now_resolving = sorted(name for name in KNOWN_UNRESOLVED if in_golden(declared[name], golden))
176+
assert not now_resolving, (
177+
f"{now_resolving} now resolve against {golden_file.name}; drop their KNOWN_UNRESOLVED "
178+
f"entries so the strict check covers them again"
179+
)
180+
181+
148182
@pytest.mark.asyncio
149183
@pytest.mark.skipif(not VLLMServerRunner.is_available(), reason="no vLLM server or executable available")
150-
async def test_exposed_families_match_golden():
184+
async def test_exposed_families_match_golden() -> None:
151185
release_tag = os.environ.get(ENV_VLLM_VERSION)
152186
if not release_tag:
153187
pytest.skip(f"{ENV_VLLM_VERSION} not set; cannot tell which release the server runs")
@@ -176,17 +210,13 @@ async def test_exposed_families_match_golden():
176210

177211
@pytest.mark.asyncio
178212
@pytest.mark.skipif(not VLLMServerRunner.is_available(), reason="no vLLM server or executable available")
179-
async def test_declared_metric_names_exist():
213+
async def test_declared_metric_names_exist() -> None:
180214
async with VLLMServerRunner(port=get_free_port()) as server:
181215
names = exposed_names(await _warmed_up_exposition(server))
182216
declared = _declared(server.base_url, server.model)
183217

184218
assert declared, "vLLM client declared no metric names"
185-
missing = sorted(
186-
name
187-
for name, metric_type in declared.items()
188-
if name not in CONDITIONALLY_EXPOSED and not is_exposed(name, metric_type, names)
189-
)
219+
missing = sorted(name for name, metric in declared.items() if name not in SKIPPED and not is_exposed(metric, names))
190220
assert not missing, (
191221
f"{len(missing)}/{len(declared)} declared metric names absent from a real vLLM /metrics exposition "
192222
f"(stale names produce silently empty report fields): {missing}"

e2e/utils/metric_families.py

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,29 +33,38 @@
3333
``PROMETHEUS_DISABLE_CREATED_SERIES`` without any semantic change.
3434
"""
3535

36-
import re
3736
from pathlib import Path
38-
from typing import Dict, Set
37+
from typing import Any, Dict, Set
3938

4039
from inference_perf.client.modelserver.metrics import CounterMetric, GaugeMetric, HistogramMetric
40+
from inference_perf.client.modelserver.metrics.base import Metric
4141
from inference_perf.client.modelserver.openai_client import OpenAIMetrics
4242

4343
GOLDEN_DIR = Path(__file__).resolve().parents[1] / "testdata" / "vllm_metric_families"
4444

45-
_BASE_NAME = re.compile(r"vllm:[A-Za-z0-9_]+")
4645

46+
def declared_metrics(metadata: OpenAIMetrics) -> Dict[str, Metric[Any]]:
47+
"""Declared metric name -> the metric object, as declared by a client's metadata.
4748
48-
def declared_metrics(metadata: OpenAIMetrics) -> Dict[str, str]:
49-
"""Metric base names -> prometheus type, as declared by a client's metadata."""
50-
types = ((CounterMetric, "counter"), (GaugeMetric, "gauge"), (HistogramMetric, "histogram"))
51-
declared: Dict[str, str] = {}
49+
The metric itself is carried, not just its name and type, because it is the
50+
only thing that knows which series its queries select (``candidate_names``).
51+
The key stays the declared name so callers can key allowlists and failure
52+
messages off exactly what appears in the client source.
53+
"""
54+
declared: Dict[str, Metric[Any]] = {}
5255
for _field, metric in metadata:
53-
metric_type = next(t for cls, t in types if isinstance(metric, cls))
54-
for name in _BASE_NAME.findall(metric.metric_name):
55-
declared[name] = metric_type
56+
declared[metric.metric_name] = metric
5657
return declared
5758

5859

60+
def prometheus_type(metric: Metric[Any]) -> str:
61+
"""The exposition type a declared metric expects its family to carry."""
62+
for cls, metric_type in ((CounterMetric, "counter"), (GaugeMetric, "gauge"), (HistogramMetric, "histogram")):
63+
if isinstance(metric, cls):
64+
return metric_type
65+
raise TypeError(f"no prometheus type known for {type(metric).__name__}")
66+
67+
5968
def exposed_names(metrics_text: str) -> Set[str]:
6069
"""All family and sample names present in a /metrics exposition."""
6170
names = set()
@@ -79,25 +88,39 @@ def exposed_vllm_families(metrics_text: str) -> Dict[str, str]:
7988
return families
8089

8190

82-
def is_exposed(name: str, metric_type: str, names: Set[str]) -> bool:
83-
"""Whether a declared (name, type) is present in a live exposition's names.
91+
def provided_by_families(series: str, metric_type: str, families: Dict[str, str]) -> bool:
92+
"""Whether a family -> type map provides one series a query selects.
93+
94+
A family map records what ``# TYPE`` declares, so counter and gauge series
95+
are families in their own right, while ``_bucket``/``_count``/``_sum`` series
96+
are produced by a histogram or summary family with the suffix stripped. That
97+
second case also covers a counter declared straight onto a histogram's
98+
``_count`` series, which is valid PromQL and which SGLang's request count uses.
99+
"""
100+
if families.get(series) == metric_type:
101+
return True
102+
for suffix in ("_bucket", "_count", "_sum"):
103+
if series.endswith(suffix) and families.get(series[: -len(suffix)]) in ("histogram", "summary"):
104+
return True
105+
return False
106+
107+
108+
def is_exposed(metric: Metric[Any], names: Set[str]) -> bool:
109+
"""Whether every series this metric's queries select is in a live exposition.
84110
85-
Presence is type-aware, mirroring what a Prometheus scrape stores: gauges
86-
by bare name, counters by bare or ``_total``-suffixed name, histograms by
87-
their ``_bucket``/``_count``/``_sum`` series.
111+
An exposition lists real series names, so this is a plain subset test over the
112+
metric's own candidate groups; nothing here needs to know how a counter or a
113+
histogram is spelled.
88114
"""
89-
if metric_type == "histogram":
90-
return all(f"{name}{suffix}" in names for suffix in ("_bucket", "_count", "_sum"))
91-
if metric_type == "counter":
92-
return name in names or f"{name}_total" in names
93-
return name in names
94-
95-
96-
def in_golden(name: str, metric_type: str, golden: Dict[str, str]) -> bool:
97-
"""Whether a declared (name, type) resolves against a golden family map."""
98-
if metric_type == "counter":
99-
return golden.get(name) == "counter" or golden.get(f"{name}_total") == "counter"
100-
return golden.get(name) == metric_type
115+
return any(group <= names for group in metric.candidate_names())
116+
117+
118+
def in_golden(metric: Metric[Any], golden: Dict[str, str]) -> bool:
119+
"""Whether every series this metric's queries select resolves against a golden."""
120+
metric_type = prometheus_type(metric)
121+
return any(
122+
all(provided_by_families(series, metric_type, golden) for series in group) for group in metric.candidate_names()
123+
)
101124

102125

103126
def golden_path(release_tag: str) -> Path:

0 commit comments

Comments
 (0)