Skip to content

Commit 5e79389

Browse files
committed
fix(telemetry): preserve model-loading Prometheus metric name
1 parent 25bd316 commit 5e79389

4 files changed

Lines changed: 288 additions & 0 deletions

File tree

‎deploy/helm/sie-cluster/templates/_otel-collector-config.tpl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ processors:
249249
- context: metric
250250
statements:
251251
- 'set(unit, "") where name == "sie.gateway.pool.pinned_model.loaded"'
252+
- 'set(unit, "") where name == "sie.gateway.model_loads_in_progress"'
252253
transform/prometheus_application_identity:
253254
error_mode: propagate
254255
metric_statements:

‎telemetry/contract.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,9 @@ metric_processing:
660660
sie.gateway.pool.pinned_model.loaded:
661661
unit_override: ""
662662
prometheus_name: sie_gateway_pool_pinned_model_loaded
663+
sie.gateway.model_loads_in_progress:
664+
unit_override: ""
665+
prometheus_name: sie_gateway_model_loads_in_progress
663666
datapoint_attributes:
664667
policy: exact_metric_attribute_set
665668
unknown: drop

‎tools/ci/tests/test_collector_metric_privacy.py‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from __future__ import annotations
44

55
import json
6+
import re
7+
import subprocess
8+
import sys
9+
import time
610
import urllib.request
11+
import uuid
712
from pathlib import Path
813

914
import pytest
@@ -12,6 +17,7 @@
1217
from opentelemetry.proto.metrics.v1.metrics_pb2 import AGGREGATION_TEMPORALITY_DELTA
1318

1419
from tools.ci.tests.test_collector_trace_privacy import SENTINEL, run, wait_for
20+
from tools.ci.tests.test_helm_render import render_collector_config
1521

1622
pytestmark = pytest.mark.docker
1723

@@ -134,6 +140,210 @@ def batches(path: Path):
134140
return [json.loads(line) for line in path.read_text().splitlines()]
135141

136142

143+
def model_loading_payload():
144+
wire = ExportMetricsServiceRequest()
145+
resource = wire.resource_metrics.add()
146+
for key, value in {
147+
"service.name": "sie-gateway",
148+
"service.instance.id": "model-loading-test",
149+
"service.version": "test-version",
150+
}.items():
151+
add(resource.resource.attributes, key, value)
152+
metric = resource.scope_metrics.add().metrics.add(name="sie.gateway.model_loads_in_progress", unit="1")
153+
for profile, bundle, value in [("cpu", "default", 0), ("l4", "default", 1), ("cpu", "candle", 0)]:
154+
point = metric.gauge.data_points.add(time_unix_nano=time.time_ns(), as_double=value)
155+
for key, label in {"pool": "default", "machine_profile": profile, "bundle": bundle}.items():
156+
add(point.attributes, key, label)
157+
return wire.SerializeToString()
158+
159+
160+
def require_cached_collector_image(image):
161+
try:
162+
run("docker", "image", "inspect", image)
163+
except subprocess.CalledProcessError:
164+
pytest.fail(
165+
f"Docker must be available with {image} already cached; this test never pulls images.", pytrace=False
166+
)
167+
168+
169+
def cleanup_model_loading_collector(container, original_error):
170+
try:
171+
removed = subprocess.run( # noqa: S603 - exact test-owned container
172+
["docker", "rm", "--force", container], # noqa: S607 - local Docker CLI
173+
check=False,
174+
capture_output=True,
175+
text=True,
176+
timeout=20,
177+
)
178+
remaining = subprocess.run( # noqa: S603 - exact test-owned container
179+
["docker", "ps", "--all", "--filter", f"name=^/{container}$", "--format", "{{.Names}}"], # noqa: S607 - local Docker CLI
180+
check=True,
181+
capture_output=True,
182+
text=True,
183+
timeout=10,
184+
)
185+
if remaining.stdout.strip():
186+
raise RuntimeError(f"test-owned collector was not removed: {container}")
187+
if original_error is None:
188+
removed.check_returncode()
189+
except Exception as cleanup_error:
190+
if original_error is not None:
191+
raise BaseExceptionGroup("collector test and cleanup failed", [original_error, cleanup_error]) from None
192+
raise
193+
194+
195+
def test_model_loading_image_prerequisite_never_pulls(monkeypatch):
196+
calls = []
197+
198+
def missing(*args):
199+
calls.append(args)
200+
raise subprocess.CalledProcessError(1, args)
201+
202+
monkeypatch.setitem(require_cached_collector_image.__globals__, "run", missing)
203+
image = "otel/opentelemetry-collector-contrib:0.119.0"
204+
with pytest.raises(pytest.fail.Exception, match="already cached; this test never pulls"):
205+
require_cached_collector_image(image)
206+
assert calls == [("docker", "image", "inspect", image)]
207+
208+
209+
@pytest.mark.parametrize("removed", [False, True])
210+
@pytest.mark.parametrize("original_error", [None, RuntimeError("startup or test failure")])
211+
def test_model_loading_cleanup_preserves_failures(monkeypatch, removed, original_error):
212+
container = "sie-model-loading-test"
213+
calls = []
214+
215+
def fake_run(args, **kwargs):
216+
calls.append((args, kwargs))
217+
return subprocess.CompletedProcess(args, 0 if removed or args[1] == "ps" else 1, "", "")
218+
219+
monkeypatch.setattr(subprocess, "run", fake_run)
220+
expected = type(original_error) if original_error else None if removed else subprocess.CalledProcessError
221+
if expected:
222+
with pytest.raises(expected) as caught:
223+
try:
224+
if original_error:
225+
raise original_error
226+
finally:
227+
cleanup_model_loading_collector(container, sys.exception())
228+
if original_error:
229+
assert caught.value is original_error
230+
else:
231+
cleanup_model_loading_collector(container, None)
232+
assert calls[0][0] == ["docker", "rm", "--force", container]
233+
assert calls[1][0] == ["docker", "ps", "--all", "--filter", f"name=^/{container}$", "--format", "{{.Names}}"]
234+
assert [kwargs["timeout"] for _, kwargs in calls] == [20, 10]
235+
236+
237+
@pytest.mark.parametrize("verification_fails", [False, True])
238+
def test_model_loading_cleanup_failure_reports_both_errors(monkeypatch, verification_fails):
239+
original_error = RuntimeError("original failure")
240+
241+
def fake_run(args, **_kwargs):
242+
if args[1] == "ps" and verification_fails:
243+
raise subprocess.CalledProcessError(1, args)
244+
return subprocess.CompletedProcess(args, 0, "sie-model-loading-test" if args[1] == "ps" else "", "")
245+
246+
monkeypatch.setattr(subprocess, "run", fake_run)
247+
with pytest.raises(ExceptionGroup) as caught:
248+
cleanup_model_loading_collector("sie-model-loading-test", original_error)
249+
assert caught.value.exceptions[0] is original_error
250+
assert isinstance(caught.value.exceptions[1], subprocess.CalledProcessError if verification_fails else RuntimeError)
251+
252+
253+
def test_model_loading_gauge_preserves_prometheus_name_and_remote_unit(tmp_path):
254+
config, image = render_collector_config(tmp_path)
255+
assert image == "otel/opentelemetry-collector-contrib:0.119.0"
256+
require_cached_collector_image(image)
257+
exporter = config["exporters"]["prometheus"]
258+
assert exporter["add_metric_suffixes"] is True
259+
exporter["endpoint"] = "0.0.0.0:9464"
260+
config["exporters"] = {
261+
"prometheus": exporter,
262+
"file/remote": {"path": "/out/remote.json", "flush_interval": "100ms"},
263+
}
264+
pipelines = config["service"]["pipelines"]
265+
config["service"]["pipelines"] = {
266+
key: pipelines[key] for key in ["metrics/prometheus/gateway", "metrics/betterstack/gateway"]
267+
}
268+
pipelines["metrics/betterstack/gateway"]["exporters"] = ["file/remote"]
269+
config["receivers"]["otlp/gateway"]["protocols"]["http"] = {"endpoint": "0.0.0.0:4338"}
270+
config["processors"]["batch"]["timeout"] = "100ms"
271+
(tmp_path / "collector.yaml").write_text(yaml.safe_dump(config))
272+
container = f"sie-model-loading-{uuid.uuid4().hex}"
273+
try:
274+
run(
275+
"docker",
276+
"run",
277+
"--detach",
278+
"--pull=never",
279+
"--name",
280+
container,
281+
"--cpus=1",
282+
"--memory=256m",
283+
"--pids-limit=64",
284+
"--cap-drop=ALL",
285+
"--security-opt=no-new-privileges",
286+
"--read-only",
287+
"--user=0",
288+
"-p",
289+
"127.0.0.1::4338",
290+
"-p",
291+
"127.0.0.1::9464",
292+
"-p",
293+
"127.0.0.1::13133",
294+
"-v",
295+
f"{tmp_path}:/out",
296+
image,
297+
"--config=/out/collector.yaml",
298+
)
299+
ports = {port: run("docker", "port", container, f"{port}/tcp").split(":")[-1] for port in [4338, 9464, 13133]}
300+
301+
def get(port, path=""):
302+
with urllib.request.urlopen(f"http://127.0.0.1:{ports[port]}{path}", timeout=2) as response:
303+
return response.read(1_048_577).decode()
304+
305+
wait_for(lambda: get(13133))
306+
request = urllib.request.Request(
307+
f"http://127.0.0.1:{ports[4338]}/v1/metrics",
308+
data=model_loading_payload(),
309+
headers={"Content-Type": "application/x-protobuf"},
310+
)
311+
with urllib.request.urlopen(request, timeout=5) as response: # noqa: S310 - fixed loopback HTTP
312+
assert response.status == 200
313+
remote = wait_for(lambda: batches(tmp_path / "remote.json"))
314+
text = wait_for(
315+
lambda: value if "sie_gateway_model_loads_in_progress" in (value := get(9464, "/metrics")) else None
316+
)
317+
assert len(text) <= 1_048_576
318+
assert "sie_gateway_model_loads_in_progress_ratio" not in text
319+
samples = re.findall(
320+
r"^sie_gateway_model_loads_in_progress\{([^}]+)\} ([01])(?:\.0)?(?: \d+)?$", text, re.MULTILINE
321+
)
322+
assert len(samples) == 3
323+
observed = {}
324+
for labels, value in samples:
325+
attributes = dict(re.findall(r'(\w+)="([^"\\]*)"', labels))
326+
assert attributes["pool"] == "default"
327+
assert attributes["producer_service"] == "sie-gateway"
328+
assert attributes["producer_instance"] == "model-loading-test"
329+
assert re.fullmatch(r"[0-9a-f-]{36}", attributes["collector_generation"])
330+
observed[attributes["machine_profile"], attributes["bundle"]] = int(value)
331+
assert observed == {("cpu", "default"): 0, ("l4", "default"): 1, ("cpu", "candle"): 0}
332+
metrics = [
333+
metric
334+
for batch in remote
335+
for resource in batch["resourceMetrics"]
336+
for scope in resource["scopeMetrics"]
337+
for metric in scope["metrics"]
338+
]
339+
assert len(metrics) == 1
340+
assert metrics[0]["name"] == "sie.gateway.model_loads_in_progress"
341+
assert metrics[0]["unit"] == "1"
342+
assert sorted(point["asDouble"] for point in metrics[0]["gauge"]["dataPoints"]) == [0, 0, 1]
343+
finally:
344+
cleanup_model_loading_collector(container, sys.exception())
345+
346+
137347
@pytest.mark.parametrize("receiver", ["gateway", "application"])
138348
def test_remote_metric_maps_have_one_scalar_per_retained_key(tmp_path, receiver):
139349
config, image = rendered_config()

‎tools/ci/tests/test_helm_render.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,45 @@ def render_template(tmp_path: Path, values: dict, template: str) -> subprocess.C
6262
)
6363

6464

65+
def render_collector_config(tmp_path: Path, *, remote_enabled: bool = True) -> tuple[dict, str]:
66+
chart = tmp_path / "collector-chart"
67+
(chart / "templates").mkdir(parents=True)
68+
(chart / "Chart.yaml").write_text("apiVersion: v2\nname: collector-test\nversion: 0.0.0\n")
69+
shutil.copyfile(ROOT / helm.CHART_DIR / "templates/_otel-collector-config.tpl", chart / "templates/_collector.tpl")
70+
(chart / "templates/config.yaml").write_text(
71+
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\ndata:\n collector.yaml: |\n"
72+
' {{- include "sie-cluster.otel.collectorConfig" .Values | nindent 4 }}\n'
73+
)
74+
defaults = yaml.safe_load((ROOT / helm.CHART_DIR / "values.yaml").read_text())
75+
collector = defaults["observability"]["otel"]["collector"]
76+
collector["betterStack"].update({"enabled": remote_enabled, "endpoint": "https://example.invalid"})
77+
values = {
78+
"metricsEnabled": True,
79+
"logsEnabled": False,
80+
"tracesEnabled": False,
81+
"prometheusEnabled": True,
82+
"collector": collector,
83+
"traceEndpoint": "",
84+
"logEndpoint": "",
85+
"betterStack": collector["betterStack"],
86+
"deploymentEnvironment": "dev",
87+
"cloudRegion": "test-region",
88+
"localTraceExporters": [],
89+
"logExporters": [],
90+
}
91+
(chart / "values.yaml").write_text(yaml.safe_dump(values))
92+
rendered = subprocess.run( # noqa: S603 - fixed local Helm test chart
93+
["mise", "exec", "--", "helm", "template", "test", str(chart)], # noqa: S607 - mise-managed Helm
94+
cwd=ROOT,
95+
capture_output=True,
96+
text=True,
97+
check=True,
98+
timeout=30,
99+
).stdout
100+
image = collector["image"]
101+
return yaml.safe_load(yaml.safe_load(rendered)["data"]["collector.yaml"]), f"{image['repository']}:{image['tag']}"
102+
103+
65104
def worker_statefulsets(tmp_path: Path, values: dict) -> list[dict]:
66105
result = render_workers(tmp_path, values)
67106
assert result.returncode == 0, result.stderr
@@ -174,6 +213,10 @@ def test_worker_scaledobject_appends_the_sixth_trigger(tmp_path: Path) -> None:
174213
"sie_gateway_rejected_rate_l4_default",
175214
"sie_gateway_model_loads_in_progress_l4_default",
176215
]
216+
loading = triggers[-1]["metadata"]
217+
assert "max(sie_gateway_model_loads_in_progress{" in loading["query"]
218+
assert "model_loads_in_progress_ratio" not in loading["query"]
219+
assert loading["ignoreNullValues"] == "false"
177220

178221
hook = render_template(tmp_path, values, "templates/hooks/keda-ready-test.yaml")
179222
assert hook.returncode == 0, hook.stderr
@@ -190,6 +233,37 @@ def test_worker_scaledobject_appends_the_sixth_trigger(tmp_path: Path) -> None:
190233
assert job["spec"]["activeDeadlineSeconds"] == timeout + 60
191234

192235

236+
@pytest.mark.parametrize("remote_enabled", [False, True])
237+
def test_model_loading_unit_override_is_prometheus_only(tmp_path: Path, remote_enabled: bool) -> None:
238+
name = "sie.gateway.model_loads_in_progress"
239+
contract = yaml.safe_load((ROOT / "telemetry/contract.yaml").read_text())
240+
(metric,) = [metric for metric in contract["metrics"] if metric["name"] == name]
241+
assert metric["unit"] == "1"
242+
assert metric["prometheus_name"] == "sie_gateway_model_loads_in_progress"
243+
assert contract["metric_processing"]["prometheus_compatibility"][name] == {
244+
"unit_override": "",
245+
"prometheus_name": metric["prometheus_name"],
246+
}
247+
config, _ = render_collector_config(tmp_path, remote_enabled=remote_enabled)
248+
processors = config["processors"]
249+
compatibility = processors["transform/prometheus_compatibility"]["metric_statements"]
250+
assert f'set(unit, "") where name == "{name}"' in compatibility[0]["statements"]
251+
canonical = processors["transform/contract_metrics"]["metric_statements"]
252+
(descriptor,) = [group for group in canonical if group["context"] == "metric"]
253+
assert any(
254+
statement.startswith('set(unit, "1")') and "sie[.]gateway[.]model_loads_in_progress" in statement
255+
for statement in descriptor["statements"]
256+
)
257+
pipelines = config["service"]["pipelines"]
258+
for receiver in ("gateway", "application"):
259+
local = pipelines[f"metrics/prometheus/{receiver}"]["processors"]
260+
assert local.index("transform/contract_metrics") < local.index("transform/prometheus_compatibility")
261+
if remote_enabled:
262+
assert (
263+
"transform/prometheus_compatibility" not in pipelines[f"metrics/betterstack/{receiver}"]["processors"]
264+
)
265+
266+
193267
def test_scaledobject_apply_hook_also_runs_on_rollback(tmp_path: Path) -> None:
194268
hooks = hook_events(tmp_path, AUTOSCALING_VALUES, KEDA_APPLY_TEMPLATE)
195269

0 commit comments

Comments
 (0)