WIP: Collect and normalize engine metrics with a MetricMapping - #412
WIP: Collect and normalize engine metrics with a MetricMapping#412dennis-upbound wants to merge 8 commits into
Conversation
The metrics design normalizes each engine's Prometheus metrics onto a modelplane_* surface, selected per engine by a first-class resource rather than a hand-edited ConfigMap. Add that resource: a cluster-scoped MetricMapping XRD carrying a pod selector, a rename map, and label rewrites, plus a mark-ready composition function, following the InferenceClass config-kind pattern. The collector that reads these and the built-in per-engine mappings follow in later commits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Adding the kind left the tree unbuildable. flake.nix lists compose-metric-mapping among its functions and the uv2nix resolver builds its package set from uv.lock, which had no such workspace member, so nix failed at evaluation with "attribute 'compose-metric-mapping' missing" before any build ran. That also blocked the schema regeneration, which is the other half: function/fn.py imports models.ai.modelplane.metricmapping, and nothing had generated it, so the function could not import and its tests and type check could not run. Refresh uv.lock to add the member, then regenerate, which produces the metricmapping models and moves the fs://apis digest. test-compose-metric-mapping and ty-compose-metric-mapping pass, and uv-lock stops failing. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The engine's containerPort is unnamed on every backend, so anything scraping it has to match a number. Under prefill/decode that number is wrong: the pd-sidecar takes ENGINE_PORT and the engine moves to its own --port, so a target matching 8000 hits the sidecar and reports the proxy's metrics as the engine's. Name it http, on the Standalone, llm-d and decode paths alike, so a scrape follows the engine wherever it listens. The sidecar's port stays unnamed: a pod's named ports must be unique and the engine is the one worth following. Nothing referenced these ports by name before, and the Service targets ENGINE_PORT by number, so naming them changes no existing wiring. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A MetricMapping selects the pods it normalizes by an engine-type label, and nothing stamped one. The label existed only in the MetricMapping XRD's own description, so the selector could never match and normalization had no way to tell a vLLM series from an SGLang one without detecting the engine, which is what reading a label is meant to avoid. Add engines[].type on ModelDeployment and ModelReplica, copied down beside phase, and stamp it as modelplane.ai/engine on the pods that serve. Free-form rather than an enum: a platform team applies a MetricMapping for a forked or new engine without a Modelplane release, and an engine that declares no type is still scraped under its native names rather than renamed by a guess. Only serving pods carry it. A gang's workers serve nothing and have no metrics to attribute, so they are left alone, the same rule the serving label already follows. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Nothing consumed a MetricMapping. Applying one reported Ready and changed no behaviour, because the collector the design hands mappings to did not exist. Compose an OpenTelemetry collector per workload cluster, reading every MetricMapping as a required resource and rendering each into OTTL: one statement per rename and per added label, every one gated on the engine the mapping selects. The gate is what keeps this label-driven. Two mappings can rename the same source name, since a fork of an engine emits its upstream names, and a series from an engine with no mapping has to pass through untouched rather than be renamed by someone else's rule. A mapping that selects nothing we can key on is skipped for the same reason. Statements are sorted so the rendered config does not reshuffle between reconciles and churn the release. The scrape keeps pods carrying the serving label, and among their ports only the one named http. Matching the port by name is what makes it correct under prefill/decode, where the pd-sidecar holds 8000 and the engine has moved on. The collector sits beside kube-prometheus-stack rather than replacing it, and re-exposes the renamed series for the existing Prometheus to scrape. That keeps this additive: the native series stay reachable, so a mapping that is wrong or missing costs nothing. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The collector was covered by unit tests on the values it renders, which cannot see what the chart does with them or whether a renamed series ever reaches a scrape. Wiring an assertion found two defects that unit tests could not: The collector's Service exposed nothing. `ports.prom-export` set containerPort without servicePort, and the chart builds the Service from servicePort, so the normalized metrics were reachable on the pod and nowhere else. The Service name was derived from the release name, so nothing could address the collector reliably. It is now pinned with fullnameOverride, the way the Prometheus release in this same function already does it. On the harness: the mock engine serves /metrics under vLLM's own metric names, the ModelDeployment declares `type: vllm` so its pods carry the engine label, and a vLLM MetricMapping is applied. `--verify` then polls the collector's exporter and fails if no modelplane_* series appear. It reads the collector directly rather than through Prometheus, so a failure means normalization broke rather than that Prometheus had not discovered the target yet. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The collector was composed but never listed in mark_readiness, so Crossplane reported it as an unready resource on every reconcile. That blocked the ServingStack, which blocked BackendReady, which meant an InferenceCluster could never reach Ready -- on any cluster, whether or not a MetricMapping existed. The local e2e did not catch it. Its metrics assertion reads the collector's exporter directly, so a collector that works but is never reported as working passes. This only showed up against a real GKE cluster, where the InferenceCluster sat at "Unready resources: serving-stack" with a collector Release that was itself Ready=True. The test asserts both directions, since the composed-and-unready state is the defect: not ready before the Release is observed, ready once it is. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The example told an operator to write a PodMonitor by hand, port-forward the workload cluster's Prometheus, and query `vllm:num_requests_running`. All three are now wrong: collection is composed on every workload cluster, the engine port is named, and the mapped names are `modelplane_*`. Leaving the PodMonitor documented is worse than stale. Applying it alongside the composed collector scrapes every engine twice, so the file is deleted and the page says to delete an existing one. Two behaviours found while validating this on GKE are written down, because both are surprising: The exporter replaces `:` with `_`, so an unmapped vLLM metric is published as `vllm_gpu_cache_usage_perc`, not `vllm:gpu_cache_usage_perc`. Passthrough keeps the name but not the punctuation, which matters to anyone holding a dashboard query. The per-shape `targetPort` advice is gone. Scraping by port name is what makes prefill/decode work without special casing, and the old advice to scrape decode on 8001 was a workaround for matching by number. The example's ModelDeployment now sets `type: vllm`, so it matches the mapping the page describes. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Two things in the doc are no longer accurate now that the collector exists and has been run against a real engine. Passthrough is not byte-identical. The doc said an unmapped engine keeps its "native names"; the collector's Prometheus exporter sanitizes `:` to `_`, so `vllm:gpu_cache_usage_perc` is published as `vllm_gpu_cache_usage_perc`. That changes the upgrade story from one rename to two, so it belongs in the degradation rule rather than in a reader's surprise later. The implementation-status note said nothing consumed a MetricMapping and that the reading side sat in the wrong function. Both are fixed in modelplaneai#412, so the note now says what is built and what was measured, and narrows the remaining gap to the endpoint picker, which still exposes no metrics port. Signed-off-by: Dennis Ramdass <dennis@upbound.io>
|
Closing this one too, on the same reasoning: it implements #363, which is still under review, so the same "are we aligned on direction yet" question applies. The branch Two findings from it are already in #363 rather than sitting only here: the exporter rewrites |
Description of your changes
Towards #269, implementing the collection and normalization halves of the design in #363.
MetricMappinglanded as a data resource that nothing consumed. Applying one reportedReadyand changed no behaviour, because the collector the design hands mappings to did not exist. Building it turned up three more things the design assumed were already true, so the commits are ordered by what unblocked the next.The tree did not build.
flake.nixlistscompose-metric-mappingand the uv2nix resolver builds its package set fromuv.lock, which had no such workspace member, so nix failed at evaluation before any build ran. That also blocked schema regeneration, which the function depends on.The engine's serving port was unnamed on every backend, so anything scraping it had to match a number. Under prefill/decode that number is wrong — the pd-sidecar takes 8000 and the engine moves to its own port, so a target matching 8000 reports the proxy's metrics as the engine's. It is now named
httpon the Standalone, llm-d and decode paths alike.Nothing stamped an engine-type label.
modelplane.ai/engineexisted only in theMetricMappingXRD's own description, so the selector could never match.engines[].typeis new onModelDeploymentandModelReplica, copied down besidephase, and stamped on serving pods. It is free-form rather than an enum so a platform team can map a forked engine without a Modelplane release, and an engine declaring no type is scraped under its native names rather than renamed by a guess.Then the collector.
compose-serving-stackreads everyMetricMappingas a required resource and renders each into OTTL: one statement per rename and per added label, each gated on the engine the mapping selects. The gate keeps this label-driven rather than name-driven, so two mappings can rename the same source name and an unmapped engine's series pass through untouched.What is verified
Run end to end on a real GKE cluster with vLLM 0.23.0 serving Qwen2.5-0.5B on an NVIDIA L4, and on the local two-cluster
e2eharness.On GKE the engine published 359 metric lines and the collector re-exposed the mapped ones renamed, every series carrying the engine label:
The rename is in place, not a duplication: all four mapped source names are absent from the exported set afterwards. The remaining 308 unmapped lines passed through.
nix run .#e2e -- --verifyalso passes, asserting the same thing against a mock engine that serves vLLM-named metrics.The affected checks pass:
test-/ty-compose-serving-stack, the same pair forcompose-model-replicaandcompose-metric-mapping,uv-lock, and the repo-widepythoncheck.uv-lockand bothcompose-metric-mappingchecks were failing before this.Four defects the unit tests could not catch
Worth reading, because all four passed a green test suite. Each test asserted what a builder returned; none asserted the function did anything.
_otel_release()was defined and never called.compose()had no collector step and never requested the mappings —requestwas not even imported. The feature did not exist, and every builder test passed.The collector's Service exposed nothing.
ports.prom-exportsetcontainerPortwithoutservicePort, and the chart builds the Service fromservicePort.The Service name was derived from the release name, so nothing could address the collector reliably. It is pinned with
fullnameOverride, the way the Prometheus release in the same function already does it.The collector was composed but never listed in
mark_readiness, so Crossplane reported it unready on every reconcile. That blocked the ServingStack, which blockedBackendReady, which meant anInferenceClustercould never reachReady— on any cluster, with or without aMetricMapping. The local e2e passed straight through it, because that assertion reads the collector's exporter directly and a collector that works but is never reported as working looks identical to success. Only a real cluster, where something downstream depends on that readiness, surfaced it.Tests were added for the last one in both directions, and I confirmed it fails with the fix reverted rather than passing vacuously.
A behaviour the design got slightly wrong
The design says an unmapped engine "still gets scraped under its native names". The collector's exporter replaces
:with_, so vLLM'svllm:gpu_cache_usage_percis published asvllm_gpu_cache_usage_perc. Passthrough keeps the name but not the punctuation. That makes the upgrade note two changes rather than one, and it is now written down in the docs page instead of implied.Docs
collecting-engine-metrics.mdtold an operator to hand-write aPodMonitor, port-forward the workload Prometheus and queryvllm:num_requests_running. All three are now wrong. ThePodMonitormanifest is deleted and the page says to delete an existing one, since applying it alongside the composed collector scrapes every engine twice.Still open
The EPP half of #363 is untouched. Its metrics port does not exist either — the container exposes
grpcon 9002 andgrpc-healthon 9003, its args carry no metrics flag, and I have not confirmed--metrics-endpoint-auth=falseexists onllm-d-router-endpoint-picker:v0.9.0.compose-serving-stackhardcodesmodelplane.ai/engine,modelplane_ai_servingandhttpby value, because a composition function cannot import another's package.compose-model-replicaowns all three, so this is a fourth cross-function naming contract in this codebase and I have added to a problem rather than fixed one.engines[].typeis a property namedtypeinside an OpenAPI schema. Legal, and ugly to read in the XRD. I took the trade becauseengines: [{name: qwen, type: vllm}]is what people read, but it is a one-word change toengineTypeif that is the wrong call.I have:
nix flake check— the cross-arch image builds fail locally on arm64 for reasons unrelated to this change.git commit -s.🤖 Generated with Claude Code