Prometheus metrics

August 24, 2026 · View on GitHub

ModelExpress exposes Prometheus metrics from two places: the Rust server, on by default, and the Python client, opt-in. This page covers how to scrape both, what the pipeline guarantees, and the two operational choices it leaves to the deployment.

This documents what ships today: the exposition path, mx_build_info, and per-RPC and storage-backend coverage on the server. The load and transfer timing tiers and the dashboard surface come later and are not here yet.


Quick start

Server

Metrics are on by default on port 9401, separate from the gRPC port.

curl -s http://<server>:9401/metrics | head

With the Helm chart, nothing to do — metrics.enabled defaults to true and the chart generates the scrape annotations:

metrics:
  enabled: true
  port: 9401
  podAnnotations: true   # emit prometheus.io/{scrape,port,path}
  service: false         # publish the port on the Service as well

To turn the listener off, --set metrics.enabled=false, or set MODEL_EXPRESS_SERVER_METRICS_PORT=0.

Client

Opt-in, and it needs three environment variables in the pod manifest — not in code:

env:
  - {name: MX_METRICS_ENABLED, value: "1"}
  - {name: PROMETHEUS_MULTIPROC_DIR, value: /tmp/mx-metrics}
  - {name: MX_METRICS_PORT, value: "9402"}
ports:
  - {name: mx-metrics, containerPort: 9402}

That is it. Every rank calls enable_metrics() from its engine loader; one of them binds the port and serves the merged union of all of them.

No volume is required. The ranks are processes inside one container, so an ordinary container path is already shared between them, and a container restart recreates it empty — which is what a stale .db file would otherwise survive. Mount a memory-backed emptyDir at the same path if you want tmpfs and a size cap; then clear it at the container entrypoint with python -m modelexpress.metrics --reset, because an emptyDir does persist across container restarts.

A worked TP=8 example is examples/p2p_transfer_k8s/client/vllm/vllm-single-node-metrics.yaml — a metrics-enabled sibling of the stock single-node manifest, so the diff between the two is exactly what metrics cost you.

Without the directory nothing breaks — the client falls back to one-rank-per-endpoint and says so in a warning — but only one of your ranks is then represented, which for TP=8 means losing seven eighths of the pod.


Why one endpoint per pod

A ModelExpress pod runs one worker process per GPU, and N is a customer variable: this repository's own configurations span TP=1 through TP=8. Anything that assumes one metrics-producing process per scrape target loses N-1 ranks.

So the client uses prometheus_client multiprocess mode. Each rank mmaps per-PID files into a pod-local directory; one process binds the port and its MultiProcessCollector serves the union of every rank, including ranks that have already exited. Losing the bind is the normal case, not a failure, and mmap writes are durable at increment time, so no exit hook is in the path — an OOM-killed rank's counters still show up.


The rules that are not style

Each of these has one failure mode, and it is silent in every case.

PROMETHEUS_MULTIPROC_DIR goes in the pod manifest, never in Python. prometheus_client.values.get_value_class() latches at module import. An in-process assignment lands after import vllm and produces zero .db files with no error. enable() detects the latched-wrong state and logs an error naming the cause, rather than serving an empty endpoint that returns 200.

Never wipe the directory from worker code. Ranks start staggered, so a late rank's wipe unlinks an early rank's already-mmapped files; that rank keeps writing to an unlinked inode and disappears from every later scrape. Wipe at the container entrypoint only, with python -m modelexpress.metrics --reset — and only if you mounted a volume, since an emptyDir survives container restarts within a pod while a plain container path does not.

mx_build_info is a Gauge set to 1, never an Info. Under multiprocess mode an Info writes no file, exposes nothing, and raises nothing. It would pass its own health check while being invisible and silently emptying every group_left join.

Gauges need an explicit multiprocess_mode, and livesum is not safe here. The default, all, appends a pid label and is unbounded. livesum and liveall depend on mark_process_dead, which only runs on a clean exit, so an in-flight value wedges forever when a rank is OOM-killed. In-flight quantities belong in _started_total / _finished_total counter pairs differenced in PromQL, which self-heals.

Push and scrape are mutually exclusive, enforced in code. Running both double-counts every series.


Two choices the deployment has to make

1. Scrape cost versus counter monotonicity

MultiProcessCollector re-reads every mmap file in Python, holding the GIL, inside the process driving the engine scheduler. Files for dead PIDs are never reclaimed, so a pod that recycles workers accumulates them and scrape cost grows.

Reaping them bounds the cost but makes merged counters decrease, which Prometheus reads as a reset and rate() then mis-accounts.

ModelExpress does not reap. Counters stay monotonic; bound pod lifetime instead. Measured on an idle H200 node, the curve runs ~0.1 ms at one file set to ~2.4 ms at 256 — inside Prometheus's 10 s timeout, but taken without an engine competing for the GIL, so treat it as a floor. If you recycle workers aggressively, watch scrape_duration_seconds for the pod in Prometheus itself rather than trusting that figure.

2. Sharing the directory with the engine's exporter

vLLM sets and manages PROMETHEUS_MULTIPROC_DIR itself, and the value class is process-global, so a genuinely separate directory is not achievable from inside the engine process.

The decision is: share the directory, take a distinct port, never wipe it from code. ModelExpress families are namespaced mx_* and cannot collide with the engine's. A scrape of the ModelExpress port will also carry the engine's series; that is a superset, not a conflict. If you would rather scrape one port, point both at the engine's.


Families

mx_build_info is the join target for everything process-constant. Both halves of the deployment export it, distinguished by component.

Server

FamilyTypeLabels
mx_build_infoGauge (= 1)component="server", version, backend, scheme
mx_grpc_requests_totalCountermethod, outcome
mx_grpc_request_secondsHistogrammethod, outcome
mx_grpc_requests_in_flightGaugemethod
mx_backend_ops_totalCounterstore, op, result
mx_backend_op_secondsHistogramstore, op, result
mx_backend_ops_in_flightGaugestore, op
mx_registry_status_transitions_totalCounterfrom, to
mx_download_claims_totalCounterresult
mx_download_lease_refresh_totalCounterresult
mx_download_secondsHistogramoutcome
mx_cache_evictions_totalCounterreason
mx_registry_entriesGaugestatus
mx_state_entriesGaugemap
mx_task_last_success_timestamp_secondsGaugetask

method is a closed set of the 21 routed RPCs plus other; an unrecognised path cannot mint a series.

outcome includes cancelled, recorded from a drop guard when the caller goes away before the handler finishes -- a client disconnect, an RST_STREAM, or a deadline. Without it the gap would not be uniform: the requests most likely to be cancelled are the slow ones, so the latency histogram would be conditioned on completion and its tail would look healthy precisely because the slowest samples were missing. A cancellation increments the counter only and writes no latency sample, since a partial duration would enter the distribution as a fast one.

The two _in_flight gauges are the direct reading of the same situation: a store that wedges while its peers serve normally shows up immediately as operations accumulating, instead of having to be inferred from an absence of samples. They are plain gauges rather than the _started_total/_finished_total counter pairs the client side uses, because that pattern exists to survive a SIGKILLed rank under multiprocess mode -- the server is one process with an in-process registry, so there is nothing to wedge.

The download lifecycle

mx_download_claims_total{result="takeover"} is the one to watch: a takeover means a previous downloader died and the bytes are being pulled again, which for a large model is hundreds of gigabytes of repeated transfer. mx_download_lease_refresh_total{result="lost"} is its leading indicator.

Downloads currently in flight:

mx_registry_entries{status="downloading"}

Use the gauge, not a difference over the transition counters. The gauge is recomputed from the registry itself, so it is correct across a restart and across replicas; the counters are process-local while DOWNLOADING persists in the store, so after a restart a download that began in the previous process contributes a departure with no matching arrival and a raw difference can go negative.

The transition counters answer flow rather than level -- how often downloads start, and how often they end in error:

sum(rate(mx_registry_status_transitions_total{to="downloading"}[5m]))
sum(rate(mx_registry_status_transitions_total{to="error"}[5m]))

A mx_registry_entries{status="downloading"} that stays non-zero with no to="downloading" rate underneath it is a model wedged in DOWNLOADING.

Refreshed gauges, not scrape-time collection

mx_registry_entries and mx_state_entries are written by a background task on MX_REGISTRY_STATS_INTERVAL_SECS (default 60), not computed during a scrape. Counting registry entries walks the keyspace -- a SCAN plus a pipelined per-key fetch on Redis, an unpaginated list of every ModelCacheEntry on Kubernetes -- so collecting at scrape time would put that on the metadata store every fifteen seconds.

The task is independent of cache eviction on purpose: that service ticks hourly and is skipped entirely when eviction is disabled, which would leave these gauges permanently absent rather than merely stale.

When a refresh fails the gauges hold their previous values and the heartbeat is not stamped, so staleness is the signal:

time() - mx_task_last_success_timestamp_seconds{task="registry_stats_refresh"} > 300

mx_registry_entries counts entries, not models: one logical model holds several at once, one per revision plus separate ones for metadata-only downloads.

The two streaming RPCs are deliberately absent. EnsureModelDownloaded and StreamModelFiles return their response head as soon as the stream is set up and report failure as a stream item or trailer, so recording at the head would show outcome="ok" and a sub-millisecond duration for a download that ran for forty minutes and failed. Health/Watch is excluded for the same reason. Timing these means instrumenting the response body, which is a later change; until then they are absent rather than wrong. store names the subsystem (p2p, registry, refit), not the storage engine -- only one engine is live per pod, so the engine is carried by mx_build_info{backend=...} and joined from there.

outcome is not the gRPC status code

Several handlers report failure in band: they return Ok carrying success: false, or an empty list. ListSources is the clearest case -- a backend outage and "no peers have published yet" are the same Ok(ListSourcesResponse { instances: [] }) on the wire.

A metric derived from the status code would therefore read 100% success straight through a total backend outage. Instead each handler publishes its own verdict, which the metrics layer prefers over anything it could infer:

# Backend outages, which a status-code-derived metric would report as success.
sum by (method) (rate(mx_grpc_requests_total{outcome="backend_error"}[5m]))

backend_error is wider than the metadata store: every Unavailable maps to it, including the auth layer's when the Kubernetes TokenReview API is down. That case appears on every method at once and can read like a total store outage, so confirm against mx_backend_ops_total{result="error"} -- a real store outage lights that family up too.

Handlers that fail honestly with Err(Status) need no such tag -- the status carries itself -- so only the handlers that would otherwise misreport are touched.

Client

FamilyTypeLabels
mx_build_infoGauge (= 1)component="client", version, scheme
mx_p2p_source_selections_totalCounterpolicy, scheme
mx_p2p_source_attempts_totalCounterpolicy, scheme, result
mx_p2p_metadata_lookup_failures_totalCounterpolicy, scheme
mx_p2p_list_sources_totalCounterpolicy, scheme, result
mx_p2p_candidatesHistogrampolicy, scheme, stage
mx_p2p_source_selection_secondsHistogrampolicy, scheme
mx_p2p_transfer_secondsHistogrampolicy, scheme, outcome
mx_nixl_data_plane_errors_totalCounterscheme, kind
mx_nixl_receive_totalCounterscheme, result

NIXL data-plane health

mx_nixl_data_plane_errors_total{kind} counts the failures that demote an agent from READY, classified as timeout or status_error. The distinction matters because they fail differently: a status_error is NIXL reporting a failed transfer, while a timeout is NIXL reporting nothing at all -- a wedged queue pair neither completes nor transitions to ERR, so the timeout is the only evidence anything went wrong.

The kind is assigned where the failure is constructed, not derived later. The underlying field is a formatted message, so classifying at the consumer would mean parsing prose into a label and the domain would grow with the wording.

mx_nixl_receive_total{result} records what a receive actually moved:

resultmeaning
completeevery locally registered tensor was filled
partiala source/local name mismatch; the transfer completed and reported success, but the local-only tensors still hold their dummy values
emptyno tensors matched; returned success having moved nothing
rejectedstrict mode refused the transfer and raised, rather than completing it

partial and empty both return success to the caller and are logged only as warnings, so before this they were indistinguishable from a healthy transfer. rejected raises instead, and is counted so the family partitions every receive rather than only the ones that returned. A non-zero partial rate across a fleet means manifest drift between source and target, which shows up later as wrong model output rather than as a failure.

Two changes to the client families are breaking for existing dashboards:

  • source_worker_id is off by default on mx_p2p_source_selections_total. It is uuid4().hex[:8], minted fresh per process, so its label domain grows with process count over time rather than with cluster size — unbounded series growth on any long-lived Prometheus watching a fleet with pod churn. Set MX_METRICS_SOURCE_ID_LABEL=1 to restore it for a benchmark run; see Selection skew.
  • mx_p2p_list_sources_total is new, with result in {ok, empty, error}. Two things were previously indistinguishable: a metadata-backend outage and a healthy cluster that has published no peers. Both presented as an absence. Relatedly, mx_p2p_candidates{stage="listed"} can now observe zero — the selection funnel returned before recording anything when no instances came back, so the one bucket that separates "no peers published" from "peers listed but all filtered out" was unreachable.

Selection skew and the source_worker_id label

Is one source peer being picked disproportionately? is the central question when comparing selection policies, and the obvious way to answer it does not survive a production fleet — the id is a per-process uuid, so its label domain grows with process count over time.

On a fleet, leave it off. Note that a dashboard built as sum by (source_worker_id) (...) does not error once the label is gone: PromQL groups every sample under source_worker_id="", so N lines become one and the panel reads as perfectly balanced. Group by policy instead. Pinned matchers return no data, and were already unreliable — the id was reminted on every process start.

On a benchmark run, set MX_METRICS_SOURCE_ID_LABEL=1, point it at a Prometheus you are willing to throw away, and keep the run short enough that process churn does not outgrow it. Then:

sum by (source_worker_id) (mx_p2p_source_selections_total)
  / ignoring(source_worker_id) group_left sum(mx_p2p_source_selections_total)

The call site always passes the peer id and the collector drops it unless the variable is set, so the cardinality decision lives in one place. The label set is latched when the family is built, so flipping the variable mid-process does nothing — prometheus_client fixes a family's labels at construction.

For skew on a long-lived fleet the bounded form is a client-side gauge, which is follow-on work. The same data is in the structured logs regardless: rdma_strategy emits source_worker_id= on every source attempt.

Useful queries

# Is the metadata backend down, or has nobody published weights?
sum by (result) (rate(mx_p2p_list_sources_total[5m]))

# Compare two benchmark runs. `scheme` is already a label on every client
# family, so this needs no join.
sum by (scheme) (rate(mx_p2p_transfer_seconds_sum[5m]))
  / sum by (scheme) (rate(mx_p2p_transfer_seconds_count[5m]))

# Attach a constant that is only on build_info (version), not on the family itself.
mx_p2p_list_sources_total
  * on (instance) group_left(version) mx_build_info{component="client"}

# Version skew across the fleet.
count by (version, component) (mx_build_info)

# Pods that intended to export metrics but are not being scraped.
up{job="modelexpress"} == 0

scheme is on mx_build_info and on every client mx_p2p_* family, so a group_left(scheme) join against mx_build_info copies a value the left side already has. Use scheme directly. On the server it is on mx_build_info only. Consolidating the client families onto the join is a follow-on taxonomy change.


Environment variables

Server

VariableDefaultMeaning
MODEL_EXPRESS_SERVER_METRICS_PORT9401/metrics port. 0 disables the listener.
MX_METRICS_SCHEME""Benchmark run label on mx_build_info.

MODEL_EXPRESS_SERVER_METRICS_PORT is read only through the clap --metrics-port override. The layered config loader builds its environment source as Environment::with_prefix("MODEL_EXPRESS").separator("_"), so this name resolves to the key path server.metrics.port, matches no field, and is dropped by serde without a warning. In a config file the field is server.metrics_port.

Client

VariableDefaultMeaning
MX_METRICS_ENABLED0Master switch.
PROMETHEUS_MULTIPROC_DIRunsetShared per-pod directory. Required for multi-rank pods.
MX_METRICS_PORTunset/metrics port. One rank binds; it serves them all.
MX_METRICS_PUSHGATEWAYunsetBatch-pod escape hatch. Mutually exclusive with MX_METRICS_PORT.
MX_METRICS_SCHEME""Benchmark run label. Carried on mx_build_info and on every mx_p2p_* family — see the family table above.
MX_METRICS_BIND_RETRY_SECS15How often a rank that lost the bind re-attempts it, so endpoint ownership migrates when the winner exits.
MX_METRICS_SOURCE_ID_LABEL0Restore the per-peer source_worker_id label. Benchmark runs only — the id is a per-process uuid, so its label domain grows with process count over time. See Selection skew.

The client needs prometheus-client, which is the metrics extra:

pip install "modelexpress[metrics]"

In practice the engine images already provide it — vLLM, SGLang and TensorRT-LLM all depend on it for their own metrics — so the extra matters only for an image built without one of those. If it is missing the collector catches the ImportError and disables itself, so the symptom is up == 0 with nothing else to go on.


Diagnosing an endpoint that returns nothing

SymptomLikely cause
up == 0 on server podsScrape is aimed at the gRPC port. tonic is HTTP/2 only; use metrics.port.
up == 0 on worker podsMX_METRICS_ENABLED is unset, or prometheus-client is missing from the image. The engine images ship it; an image built without one needs modelexpress[metrics].
200 with no mx_* seriesPROMETHEUS_MULTIPROC_DIR was set after prometheus_client was imported. Check the logs for the "no .db files were written" error.
Only one rank's numbersPROMETHEUS_MULTIPROC_DIR is not set, so there is nothing to merge.
A rank vanishes mid-runSomething wiped the directory after ranks started. Only the entrypoint may do that.
Scrapes time outDead-PID file sets have accumulated. See "Scrape cost" above.
mx_build_info present, nothing elseWorking as intended: the exporter came up and this run recorded no events.

Verification

# Client pipeline, per issue.
cd modelexpress_client/python
pytest tests/test_metrics.py tests/test_metrics_deployment.py -v

# Selection funnel and the ListSources outcome counter.
pytest tests/test_source_selection.py -v

# Server registry, build info, and the config wiring.
cargo test --package modelexpress-server metrics

# The listener itself, scraped over real HTTP/1.1. Gated behind the feature, so
# the command above does NOT run it.
cargo test --package modelexpress-server --features integration-tests --test in_process_server

tests/test_metrics.py runs the merged-endpoint check at both TP=2 and TP=8 by forking real ranks, SIGKILLing them, and asserting the merged total. That is the exit criterion for this phase: one port exposing merged series from every rank, surviving a hard kill.