3232import re
3333import threading
3434import time
35+ import urllib .error
36+ import urllib .request
3537from dataclasses import dataclass , field
3638from pathlib import Path
3739from typing import IO , Any , Callable , Optional
@@ -167,6 +169,9 @@ class WorkerLaunchSpec:
167169 # ``None`` when launched with ``save_log=False`` (output inherits
168170 # pytest stdout); log_scanner skips ``None`` paths with a warning.
169171 log_path : Optional [str ] = None
172+ # Host for HTTP scraping (Prometheus / health). Multi-host setups
173+ # set this from the YAML ``hostname:`` field at cluster-setup time.
174+ host : str = "localhost"
170175
171176
172177# ---------------------------------------------------------------------------
@@ -305,6 +310,79 @@ def _compile_patterns(raw_patterns: list[Any]) -> list[tuple[str, re.Pattern[str
305310 return compiled
306311
307312
313+ # ---------------------------------------------------------------------------
314+ # Metrics-thread helpers
315+ # ---------------------------------------------------------------------------
316+
317+
318+ # Tolerates optional ``{labels}`` and trailing timestamp; captures value in group 1.
319+ _KV_CACHE_UTIL_RE = re .compile (
320+ r"^trtllm_kv_cache_utilization(?:\{[^}]*\})?\s+([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)"
321+ )
322+
323+
324+ def _parse_kv_cache_utilization (metrics_text : str ) -> Optional [float ]:
325+ """Extract ``trtllm_kv_cache_utilization`` from a Prometheus exposition.
326+
327+ Skips ``# HELP`` / ``# TYPE`` lines. Returns the first matching
328+ sample's value as a float, or ``None`` if no sample is present.
329+
330+ Args:
331+ metrics_text: Body of an HTTP response from
332+ ``/prometheus/metrics``.
333+
334+ Returns:
335+ Float utilization in the range the worker exposes (typically
336+ ``[0.0, 1.0]``), or ``None`` if the metric is absent.
337+ """
338+ for line in metrics_text .splitlines ():
339+ if not line or line .startswith ("#" ):
340+ continue
341+ m = _KV_CACHE_UTIL_RE .match (line )
342+ if m is not None :
343+ try :
344+ return float (m .group (1 ))
345+ except ValueError :
346+ return None
347+ return None
348+
349+
350+ def _fetch_kv_cache_utilization (
351+ host : str , port : int , timeout_s : float
352+ ) -> tuple [Optional [float ], Optional [str ]]:
353+ """HTTP GET ``/prometheus/metrics`` and parse the KV-cache utilization.
354+
355+ All failures (connection refused, timeout, HTTP error, parse miss)
356+ are folded into ``(None, error_string)`` rather than raising. The
357+ metrics thread must not fail-fast on transient scrape misses — the
358+ worker may be mid-restart, the metrics endpoint may not be wired
359+ up on every backend, etc.
360+
361+ Args:
362+ host: Worker hostname or IP.
363+ port: Worker HTTP port.
364+ timeout_s: Per-request timeout in seconds.
365+
366+ Returns:
367+ Tuple ``(utilization, error)``. Exactly one is ``None``: on
368+ success ``utilization`` is the gauge value and ``error`` is
369+ ``None``; on failure ``utilization`` is ``None`` and ``error``
370+ is a short string explaining why (for the timeseries record).
371+ """
372+ url = f"http://{ host } :{ port } /prometheus/metrics"
373+ try :
374+ with urllib .request .urlopen (url , timeout = timeout_s ) as response :
375+ body = response .read ().decode ("utf-8" , errors = "replace" )
376+ except urllib .error .URLError as exc :
377+ return None , f"url_error: { exc .reason } "
378+ except (TimeoutError , OSError ) as exc :
379+ return None , f"io_error: { exc } "
380+ util = _parse_kv_cache_utilization (body )
381+ if util is None :
382+ return None , "metric_absent"
383+ return util , None
384+
385+
308386# ---------------------------------------------------------------------------
309387# Harness
310388# ---------------------------------------------------------------------------
@@ -337,6 +415,8 @@ def __init__(
337415 yaml_path : Path ,
338416 * ,
339417 log_scanner_poll_interval_s : float = 0.5 ,
418+ metrics_scrape_interval_s : float = 30.0 ,
419+ metrics_scrape_timeout_s : float = 5.0 ,
340420 ) -> None :
341421 """Construct a marathon harness.
342422
@@ -349,6 +429,14 @@ def __init__(
349429 measurable load source on its own; tests pass a
350430 smaller value (e.g. 0.02 s) to keep real-clock
351431 latency bounded.
432+ metrics_scrape_interval_s: Cadence (seconds) between
433+ consecutive Prometheus scrapes of every worker.
434+ Default 30 s matches the spec's KV-cache utilization
435+ sampling rate; tests pass a smaller value.
436+ metrics_scrape_timeout_s: Per-request HTTP timeout for
437+ the metrics scrape. Short by design — a slow scrape
438+ is recorded as a miss rather than blocking the
439+ metrics thread past its next scheduled scrape.
352440
353441 Raises:
354442 ValueError: If the YAML is malformed or its
@@ -364,12 +452,9 @@ def __init__(
364452 self ._failure_reason : Optional [str ] = None
365453 self ._failure_lock = threading .Lock ()
366454
367- # Per-thread tunables. The default cadence is reactive enough
368- # for human-scale debugging (~0.5 s lag from log line to
369- # fail-fast) without becoming a measurable load source on its
370- # own; tests pass a smaller value via the constructor to keep
371- # real-clock latency bounded.
372455 self ._log_scanner_poll_interval_s : float = log_scanner_poll_interval_s
456+ self ._metrics_scrape_interval_s : float = metrics_scrape_interval_s
457+ self ._metrics_scrape_timeout_s : float = metrics_scrape_timeout_s
373458
374459 # Cluster + worker tracking (populated by setup()).
375460 self ._cluster : Any = None # tuple returned by setup_disagg_cluster
@@ -658,13 +743,59 @@ def _log_scanner_thread_body(self) -> None:
658743 logger .debug ("[log_scanner] exiting; closed %d source(s)" , len (sources ))
659744
660745 def _metrics_thread_body (self ) -> None :
661- """Scrape ``/prometheus/metrics`` for KV-cache utilization at ~30 s cadence.
662-
663- Stub: no-op. Real implementation parses
664- ``trtllm_kv_cache_utilization`` and appends timestamped
665- samples to ``self._kv_utilization_samples``.
746+ """Scrape Prometheus KV-cache utilization from every worker.
747+
748+ Polls each ``WorkerLaunchSpec`` at
749+ ``_metrics_scrape_interval_s`` cadence, parses
750+ ``trtllm_kv_cache_utilization`` out of
751+ ``/prometheus/metrics``, and appends a timestamped sample to
752+ ``self._kv_utilization_samples``. Each sample records
753+ ``timestamp``, ``role``, ``index``, ``host``, ``port``,
754+ ``utilization`` (``float`` on success, ``None`` on scrape
755+ miss), and ``error`` (``None`` on success, short string on
756+ miss). Scrape misses are recorded — not fail-fast — because
757+ the worker may be mid-restart from a SIGKILL injection, mid-
758+ SIGSTOP pause, or the Prometheus endpoint may not be wired
759+ for the active backend.
760+
761+ Exits when ``stop_event`` or ``failed_event`` is set. Honors
762+ either signal between scrapes via ``stop_event.wait`` rather
763+ than ``time.sleep`` to keep teardown latency bounded.
666764 """
667- logger .debug ("[metrics_thread] stub — exiting immediately" )
765+ if not self ._worker_specs :
766+ logger .warning ("[metrics_thread] no worker specs; exiting" )
767+ return
768+
769+ logger .info (
770+ "[metrics_thread] scraping %d worker(s) every %.1fs" ,
771+ len (self ._worker_specs ),
772+ self ._metrics_scrape_interval_s ,
773+ )
774+
775+ while not self .stop_event .is_set () and not self .failed_event .is_set ():
776+ scrape_start = time .monotonic ()
777+ for spec in self ._worker_specs :
778+ if self .stop_event .is_set () or self .failed_event .is_set ():
779+ break
780+ util , err = _fetch_kv_cache_utilization (
781+ spec .host , spec .port , self ._metrics_scrape_timeout_s
782+ )
783+ self ._kv_utilization_samples .append (
784+ {
785+ "timestamp" : time .time (),
786+ "role" : spec .role ,
787+ "index" : spec .index ,
788+ "host" : spec .host ,
789+ "port" : spec .port ,
790+ "utilization" : util ,
791+ "error" : err ,
792+ }
793+ )
794+ remaining = self ._metrics_scrape_interval_s - (time .monotonic () - scrape_start )
795+ if remaining > 0.0 :
796+ self .stop_event .wait (timeout = remaining )
797+
798+ logger .debug ("[metrics_thread] exiting; %d sample(s)" , len (self ._kv_utilization_samples ))
668799
669800 # ------------------------------------------------------------------
670801 # Internal helpers
0 commit comments