Skip to content

Design: managed metrics collection - #363

Open
dennis-upbound wants to merge 28 commits into
modelplaneai:mainfrom
dennis-upbound:design/metrics
Open

Design: managed metrics collection#363
dennis-upbound wants to merge 28 commits into
modelplaneai:mainfrom
dennis-upbound:design/metrics

Conversation

@dennis-upbound

@dennis-upbound dennis-upbound commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Description of your changes

A design doc, and a draft docs page. No code. Addresses #269.

Collecting a deployment's metrics is hand-wired today. vLLM exposes /metrics on its serving port, the serving stack runs a Prometheus per workload cluster with open PodMonitor discovery, and an operator writes a PodMonitor by hand, keeps it in sync with the serving shape, and removes it on teardown. Each engine also names its metrics its own way, so an operator reading two deployments reads two vocabularies, and each cluster is an island they stitch together themselves.

The doc proposes four things:

Collect on every cluster. Modelplane collects from every source it owns — engines, endpoint pickers, the substrate — with no per-deployment toggle. The switch is at the fleet: with no destination configured anywhere, no cluster composes a collector, since a collector nothing reads is cost with no reader. A cluster-wide selector on modelplane.ai/serving already spans standalone, leader/worker, Grove cliques and prefill/decode, so the shape needs no special casing. Scraping the engine port by name rather than number also fixes a latent prefill/decode gap — the decode engine serves on 8001 because the pd-sidecar takes 8000 — and needs a change first, since no backend names that port today.

Normalize to modelplane_*. A MetricMapping kind carries the rename per engine, selected by a modelplane.ai/engine label that Modelplane stamps from a new engines[].type. The label can't come from the user: the XRD reserves the modelplane.ai/ prefix. Built-ins for vLLM, SGLang and Triton/TensorRT-LLM; a platform team applies one more for a forked engine, with no Modelplane release. An unmapped engine still gets scraped under its native names and Modelplane says so rather than guessing.

Aggregate to one view. Each cluster's collector pushes outbound by default, so a workload cluster needs only egress and nothing inbound. Where a cluster has no egress, the center pulls it through the API server proxy, over the kubeconfig connection Modelplane already holds. The roll-up is computed in memory, so Modelplane runs no store and the control plane stays stateless, as running in a Space requires.

Collect with OpenTelemetry, replacing the per-cluster Prometheus. The normalization target is already an OpenTelemetry standard, the rename happens in the pipeline instead of through per-cluster recording rules, and one pipeline carries metrics, the #77 traces, and logs. Each thing kube-prometheus-stack does today has a receiver that does it, and the Envoy scrape config we already have moves across verbatim.

Approving this means agreeing that normalization and aggregation are Modelplane's job rather than the platform team's, that collection is on for every source once a destination exists, and that the collector is OpenTelemetry in place of the Prometheus stack we install today.

The draft docs page

docs/content/guides/draft-collect-metrics.md is docs-driven development: the page a user would read, written to check the API before it's built. It's draft: true so Hugo excludes it, it's named draft so it's obvious in a listing, and it should be removed before this merges.

Writing it earned its place. It found that nothing in the design chose push or pull — the page said "Modelplane notices it can't reach out", which is what a reader wants and isn't implementable, since a composition function does no network probing. And it found that leaving the metrics destination's shape open blocks the docs, not just the build: configuring one is the first thing a user does, so the draft had to invent a MetricsDestination kind to say anything at all.

It also needed two vocabulary entries, MetricMapping and OTLP, which are in this PR.

Status

Nothing here is built. The MetricMapping kind and the collector that reads it were proven in #412 against vLLM 0.23.0 on a real GKE cluster, and that PR is closed unmerged waiting on this design; the branch dennis/metrics-poc stays. engines[].type postdates it and is unproven.

Where I want judgement

Four API changes carry this and two aren't settled: where a fleet's metrics destination lives (a field or its own kind, the same call MetricMapping faced), and the field that says whether a cluster pushes or is pulled from.

Beyond those: whether the node-scoped DaemonSet tier — kubeletstats and hostmetrics, replacing cAdvisor and node-exporter — belongs in the first cut, or whether engines, EPP and k8s_cluster are enough. The engine port name (http against a dedicated metrics). Disabling EPP metrics auth against composing the ClusterRole and token. And whether the fleet roll-up series are the right set.

Two upgrade notes come out of removing the Prometheus stack. A hand-written PodMonitor left in place goes inert rather than double-scraping, which is quieter and worse. And an operator relying on that Prometheus for anything of their own loses it, so a release note has to say where the series go instead.

I have:

  • Read and followed Modelplane's contribution process.
  • Run nix flake check (design doc and a draft docs page, no code paths)
  • Added or updated tests (no code)
  • Signed off every commit with git commit -s.

🤖 Generated with Claude Code

Comment thread design/metrics.md Outdated

## Interaction with #264

The [#264](https://github.com/modelplaneai/modelplane/issues/264) example

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Release notes we need to add a line telling existing users to delete their hand-written podmonitor.yaml, or they get a duplicate scrape job against the same pods after upgrade.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, without deleting it they'd get a duplicate scrape of the same pods after upgrade. Called it out as a release note in the #264 interaction section. Pushed.

Comment thread design/metrics.md Outdated

- **Port name.** `http` (the serving port that also serves `/metrics`) versus
`metrics`. Leaning `http`, since it's the one serving port.
- **Configurability.** `interval` and `path` are fixed (30s, `/metrics`) for now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if 30s is too Long If it comes to KV Cache utilization e.g.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For routing this interval doesn't matter: the EPP scrapes engine /metrics on its own fast internal loop (~100ms) to make routing decisions, so routing isn't gated on it. This PodMonitor feeds Prometheus for dashboards and alerting, where 30s is a normal default. If finer KV series are wanted, interval is a knob on the metrics object (open question). Made that distinction explicit in What gets scraped.

Comment thread design/metrics.md Outdated
example). Nothing owns that wiring. The operator builds it by hand and keeps it in
sync with the deployment's shape. They delete it on teardown.

Instead, Modelplane composes the collection, per source:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is Monitoring for Cache / PVC here out of scope?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope here, yes, this doc is serving metrics (engine + EPP). ModelCache PVC and hydration observability is a separate concern that belongs with the ModelCache work. Added a scope line to the summary.

Comment thread design/metrics.md Outdated

### What gets scraped

The `PodMonitor` ingests everything the engine exposes on `/metrics`. What the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are the expected series-per-pod ? is the Prometheus Stack configured with rolling / retention of the storage ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order of dozens of series per engine pod (request/latency histograms, KV-cache, throughput), plus the llm_d_epp_* set wherever an EPP runs. Retention and storage sizing are the serving stack's Prometheus config in compose-serving-stack, not this composition, worth a sane default there but out of scope for this doc. Noted it in What gets scraped.

Comment thread design/metrics.md Outdated
replicas: 1
template:
spec:
metrics:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider observability/monitoring as the object name if it's going to grow past scraping... later

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, and it lines up with #77 (ModelService.observability.traces). Renamed to spec.template.spec.observability.metrics.enabled so traces and logs can slot beside metrics later. (Metrics live on the deployment since we scrape its pods; #77's traces live on ModelService, different resource, same observability umbrella.) Pushed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the reframe: after Nic's review the doc drops the per-deployment opt-out field entirely. Collection is now always-on at every layer (engine, EPP, serving-stack) with no opt-in or opt-out, feeding a central Modelplane Prometheus. So there's no longer a field to name under observability. Leaving this for context; the observability grouping question may come back if we add a deployment-level knob later.

Comment thread design/metrics.md Outdated
matchLabels:
modelplane.ai/serving: <replica-name>
podMetricsEndpoints:
- port: http

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is http the right name here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an open question in the doc (http vs metrics). Leaning http since it's the one serving port that also serves /metrics, but happy to go metrics if that reads clearer.

@negz

negz commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

@dennis-upbound I think we need to step back and think about what we want to help folks monitor.

There's a few things you could monitor in Modelplane:

  1. Inference signal (data plane). vLLM /metrics, EPP llm_d_epp_*, Envoy. TTFT, tokens/sec, queue depth, KV-cache utilization, request/error rates per model. "Is my model serving well, and is it saturated?"
  2. Substrate health (the stack Modelplane installs on each workload cluster). Gateway up? cert-manager, LWS controller, NVIDIA DRA driver healthy? GPUs allocatable? "Is the machinery on this cluster working?"
  3. Control plane health (Modelplane itself). Crossplane reconcile rates/errors, function latency and panics, provider health, the fleet scheduler actually placing replicas, XR Ready/Synced. "Is the thing I operate working?"
  4. Fleet rollup. Across all clusters/deployments: total capacity, GPU utilization, how many deployments degraded, cost.

I think MD authors will care about 1. The rest feel more like platform team concerns.

This proposal helps with 1, but it feels incomplete. If I understand correctly, it configures the Prometheus instance we already deploy to each cluster to scrape ModelReplica (i.e. vLLM, EPP) metrics, but what then? How does the MD author see and consume those metrics? If the platform team needs to setup plumbing to actually expose the metrics somewhere the MD author can use them (e.g. a dashboard), why ask the MD author to opt-in (or out)?

My hunch is Modelplane should do something like:

  • Deploy a Prom per IC
  • Always setup PodMonitors for all MD bits (engines, EPPs, etc) - no opt-in or opt-out
  • Always setup PodMonitors for all serving stack components
  • Deploy a centralized Modelplane Prom instance
  • Have the per-IC instances feed into the centralized one, which also scrapes Crossplane metrics for Modelplane control plane health
  • Setup recording rules as appropriate

This'd give you one central Prom instance you can scrape to get your entire Modelplane deployment's metrics. Metrics you'd presumably feed onwards into your monitoring and alerting system of choice.

@dennis-upbound

Copy link
Copy Markdown
Collaborator Author

Completely agree, reframed the doc around this. It now opens with the four things to monitor (data plane / substrate / control plane / fleet roll-up), makes PodMonitor collection always-on at every layer (engine, EPP, serving-stack components) with no opt-in or opt-out, and adds a central Modelplane Prometheus that the per-IC instances feed (remote-write or federation) and that also scrapes Crossplane for control-plane health, with recording rules for the fleet roll-up.

The per-deployment opt-out field is dropped (now the first rejected alternative). Open questions left: remote-write vs federation, central retention sizing, and phasing (data-plane + substrate first, control-plane + roll-up after).

@negz

negz commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

reframed the doc around this

@dennis-upbound did you forget to push? 🤔 Doc looks unchanged to me.

@dennis-upbound

Copy link
Copy Markdown
Collaborator Author

Agree — pulled the scope to exactly this. The doc now composes collection always-on inside each IC (engines, EPPs, serving-stack), no MD-author opt-in/out, and exposes the cluster's Prometheus URL on the InferenceCluster status (status.metrics.prometheusURL) so the platform team can scrape or federate without reaching into internals. Cross-cluster aggregation and Crossplane monitoring stay with the platform team. I left the one-central-Prom-for-the-whole-deployment out of this proposal and flagged it as the main open question, so we can decide separately how far Modelplane takes it.

(Also: the doc genuinely hadn't updated when I said it had — I'd pushed to the wrong remote. Fixed now, the diff reflects the change.)

Comment thread design/metrics.md Outdated
Comment thread design/metrics.md Outdated
Comment thread design/metrics.md Outdated
@dennis-upbound

Copy link
Copy Markdown
Collaborator Author

Revved the doc. It now aggregates every cluster's metrics up to one Modelplane-level view at the control plane, rebranded under modelplane_* with a normalized label set (engine, cluster, deployment, model), rather than stopping at per-cluster collection — that's now the first rejected alternative. It also weighs the per-cluster mechanism, the incumbent Prometheus stack versus an OpenTelemetry collector, and leans toward the collector (lighter, does the modelplane_* rename in-pipeline, and something we already run elsewhere). The mechanism choice is the main open question.

@negz

negz commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

@dennis-upbound thanks! I like the direction. I think this needs some POC work before I can approve. Specifically I'd like to lock down:

  1. Do we use Prom or Otel collector?
  2. What metrics do we want to expose (i.e. what rewrite rules do we need)? What metrics are possible? Can we normalize metrics for different engines?

@dennis-upbound

Copy link
Copy Markdown
Collaborator Author

Locked both.

Collector: OpenTelemetry. The GenAI semantic conventions are the naming target, the rename runs in the collector's transform processor in-pipeline, and one pipeline carries metrics, traces (#77), and logs. A Prometheus stack would push the rename into per-cluster recording rules and stay metrics-only, so it's now a rejected alternative.

Metrics + normalization. Added the modelplane_* set and a per-engine mapping table (vLLM / SGLang / TRT-LLM), with TTFT, ITL, TPOT, and the prefill/decode split. On "can we normalize across engines": vLLM and SGLang map cleanly (near-identical names, both align to the OTel set); Triton/TRT-LLM exposes batch-manager stats, so those rows are derived — called out, not hidden.

On staying correct for an engine we don't recognize: an engine-type label picks the mapping, the same mechanism the GAIE picker already uses for routing, and an unmapped engine degrades to raw names rather than a wrong guess. See the new "Capture from an opaque engine" and "Normalize to modelplane_*" sections.

Still needs the POC to confirm the derived TRT-LLM rows and the collector's rename config, agreed.

dennis-upbound and others added 9 commits September 3, 2026 08:22
Say plainly what a mapping is, a selector plus the engine's source names, the
modelplane_* name each becomes, and the labels, and show a concrete vLLM
MetricMapping. Make the registry first-class Modelplane resources rather than a
ConfigMap or an EnvironmentConfig: compose-serving-stack reads every MetricMapping
as a required resource, the way compose-model-deployment reads InferenceCluster
and ModelCache, and renders them into the collector's config. A new engine is a
new MetricMapping, validated on apply and discoverable, with no fork and no
release. Schedulers use the same kind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
…tor-scoped rename

The picker and collector share the engine-type label, not a mapping registry, so
say the label serves both rather than claim one shared registry. Add a tokens_total
row so the table delivers the tokens-per-second the doc lists under what to monitor.
State that a MetricMapping's rename applies only to metrics from pods its selector
matches, which is what the selector is for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
…osticism

Push is the default connectivity: each collector remote-writes or OTLP-exports
outbound, so the workload cluster needs only egress and it works across regions
and firewalls, with pull kept for when the center already reaches the cluster.
Modelplane needs no persistent store: the control-plane collector rolls up in
memory, a short-retention in-memory Prometheus handles PromQL and quantile
roll-ups with no volume so it runs in a Space, and durable storage is the
operator's own backend. State that the picker routes any engine and only its
KV- and queue-aware scoring needs the standard metrics, degrading without them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Every fleet roll-up is a spatial aggregation the OTel collector does in memory:
it sums gauges and counters across clusters and merges histograms, and SLO
attainment is a ratio of buckets when a boundary sits at the target. Computing a
percentile value or an ad-hoc query is read-time work for whatever consumes the
export, so Modelplane runs no store and the control plane stays stateless, which
is what runs in a Space. Removes the short-retention Prometheus the earlier draft
kept for quantiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
After moving to a stateless collector, three store references were left dangling.
The summary said series roll up to a single store, which contradicts runs no
store, so it now says one view. The PodMonitor-per-replica alternative said
cluster-wide store where it meant collector. The Prometheus-stack alternative
implied our design keeps a central store, now scoped to an operator that wants
one. Add cost to the aggregate roll-up list so it matches what to monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The doc read as entirely unbuilt while the MetricMapping kind has since been
implemented, so a reader could not tell which parts describe a proposal and which
describe code.

Note it in the status line and next to the section that introduces the kind, with
the two caveats that matter for reading the rest: nothing consumes a MetricMapping
yet, because the collector this hands them to does not exist, so applying one
reports Ready and changes no behaviour; and the reading side sits in its own
function rather than in compose-serving-stack as the doc describes, which is a
difference to close rather than keep.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Engine metric names are already namespaced, so a flat name-to-name map would
rename them unambiguously with no selector at all, and the field reads redundant.
It is not, and the reasons were nowhere in the doc, so the next reader is one step
from removing it.

Four of them. The degradation this section promises is label-based by
construction: reporting "no mapping for X" means reading a pod's claimed engine
and finding none, which name matching cannot tell from a successful rename of
nothing. The consistent label set is per pod rather than per series, so name
matching cannot label the series a mapping does not rename. A forked engine emits
the upstream names while needing its own mapping, and two mappings matching one
name cannot be told apart without the pod. And kube-scheduler's names carry no
vendor prefix at all, so the scheduler section needs the selector most.

Note the collector consequence while it is fresh: the rename is an OTTL transform
gated on a resource attribute, not the simpler metrics-transform processor, which
matches on metric name only.

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>
modelplaneai#406 merged on 31 August, so a workload cluster's substrate now depends on
InferenceCluster.spec.stack and this document described only one of the two.

The scraping mechanism it specifies doesn't work today, on any stack. It says the
backends name the engine port `http` in native.py, llmd.py and routing.py. None of
them do: all three compose {"containerPort": 8000} with no name, and grove.py makes
a fourth. A PodMonitor matching `port: http` matches nothing. The reasoning for
going by name still holds, since the decode engine serves on 8001 with the
pd-sidecar on 8000, so naming the port is now stated as a prerequisite of this
design rather than an existing property.

The serving label did survive, which is the more useful half. Grove puts
modelplane.ai/serving on the leader clique and propagates clique labels to pods, so
one cluster-wide selector still covers every shape. A fourth workload kind arrived
without touching the collection design, which is the test it needed to pass, and
the document now says so instead of listing three shapes.

Substrate health named cert-manager, the LeaderWorkerSet controller, the DRA driver
and the pod scheduler. LeaderWorkerSet is `Standard` only; a `Dynamo` cluster runs
Grove, the KAI Scheduler and a ModelExpress server. The substrate selector has to
follow the stack, and ModelExpress is a component we own and have not checked for a
metrics endpoint.

Cluster scheduler metrics read as though a platform team might bring a gang
scheduler. Modelplane installs one: compose-serving-stack composes the KAI Scheduler
with an unbounded modelplane-root queue and a modelplane child, and labels every
Grove pod into it, so KAI's series are first-party and the queue name is known.
Gang readiness has to come from KAI rather than Grove, because
PodCliqueSet.status.podGangStatuses is on the type and nothing writes it, leaving
availableReplicas unable to tell a gang that never formed from one still forming.

Finally the status. This claimed the MetricMapping kind had landed and was
implemented in modelplaneai#412. modelplaneai#412 is closed unmerged, waiting on this design, the same as
modelplaneai#411 waits on the model delivery one. What it proved stands and the branch stays.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The selection mechanism can't be built as written. Normalization picks a
MetricMapping from `modelplane.ai/engine: vllm` on the pod, set by the ML team.
The ModelDeployment XRD rejects it: a CEL rule forbids any label key under the
reserved modelplane.ai/ prefix, on the deployment and on the member pod template
both, so that apply fails validation. The prefix is Modelplane's, which is how
serving, workload and pool labels reach pods today.

So the engine type becomes `engines[].type`, an enum on the ModelDeployment, and
compose-model-replica stamps `modelplane.ai/engine` from it alongside the labels
it already applies. One field still feeds both consumers, the picker for routing
and the collector for normalization, and being typed it validates on apply rather
than selecting a mapping by a value nothing produces.

That also corrects a claim resting on it. This said the engine-type label already
exists if Modelplane runs the GAIE picker, and normalization reuses it. No such
label exists anywhere in the tree. The pattern is GAIE's and worth following; the
label is ours to add.

Separately the document never said what happens to the Prometheus already on every
cluster. compose_prometheus is unconditional on both stacks, and two things here
lean on it: it scrapes the substrate and the gateway's Envoy proxies, and its
operator defines the PodMonitor CRD this design composes against. The collector is
added beside it. What it removes is per-cluster recording rules and a second
Prometheus at the center, not the one already there, and the Prometheus alternative
no longer claims otherwise.

That exposes one open thing rather than hiding it: a plain OTel collector doesn't
read PodMonitors. Either it runs under the OpenTelemetry Operator, whose target
allocator consumes them, or it carries its own service-discovery scrape config and
PodMonitor is the wrong word throughout. The first is smaller and keeps the modelplaneai#264
upgrade path, and nobody has run it, so it is recorded as a decision rather than a
tested one.

The vLLM example mapped three of the capture contract's four signals, missing
KV-cache occupancy. It maps four now, and inter-token latency lands on
time_per_output_token to match the OpenTelemetry GenAI name the document adopts.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>

@lsviben lsviben left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the direction this design is taking with using the power of OTEL to collect and send the data from Modelplane. Discussed this with Dennis in a 1on1 as well and just left here some notes on things the desing could clarify better.

Comment thread design/metrics.md Outdated
Comment on lines +27 to +28
**Collect with OpenTelemetry.** The collector is an OpenTelemetry collector, added beside
the per-cluster Prometheus rather than replacing it. The section below gives the reasons.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Prometheus mention a leftover from the previous solution. With OTEL collector we dont need it anymore.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover, and one I added rather than inherited. I'd argued Prometheus had to stay because its operator defines the PodMonitor CRD this design composes against, which gets the dependency backwards: PodMonitor is a consequence of having chosen Prometheus, not a requirement of collection. The prometheus receiver does Kubernetes SD itself.

The stack is gone now, with each thing it did named: k8s_cluster for kube-state-metrics, kubeletstats for cAdvisor, hostmetrics for node-exporter. Two costs are written down rather than left to be found — no local store to query ad-hoc, and the collecting-engine-metrics guide loses both halves of its workflow.

Comment thread design/metrics.md Outdated

## Collect on every cluster

On each cluster Modelplane collects from every source it owns, with no opt-in or opt-out.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems ok with setting it default as on, but why no opt-out? If the OTEL collector is not pushing the data anywhere, for what do we need it running?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and it changes the shape rather than just the wording. "Always on" was arguing against a per-deployment toggle, which I still think is right: a ModelDeployment author shouldn't opt out of telemetry the platform team consumes. But you're right that a collector with nowhere to send is cost with no reader.

So the switch moves up a level. No export destination configured, no collector composed. Once a destination exists, collection is on for every source Modelplane owns, with no per-deployment switch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 on if no export destination configured, no collector composed.

Comment thread design/metrics.md Outdated
Comment on lines +76 to +78
- **The stack already scrapes.** `compose-serving-stack` runs a metrics stack on every
workload cluster and already scrapes the gateway's Envoy proxies, so adding a target is
composition, not new infrastructure.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry i am not that familiar with Modelplane yet. Would that Prometheus composed here be replaced with the OTEL collector?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, replaced. And the migration is smaller than it looks: compose-serving-stack scrapes the Envoy proxies today through the Prometheus chart's additionalScrapeConfigs, which is a kubernetes_sd_configs block. That's the same format the collector's prometheus receiver takes, so the Envoy target moves across as it stands rather than being rewritten.

What it does add is a second collector tier. kubeletstats and hostmetrics are node-scoped, so they need a DaemonSet, where k8s_cluster and the engine and EPP scrapes run as one Deployment. The doc now says so, and asks whether the DaemonSet tier belongs in the first cut at all.

Comment thread design/metrics.md
Comment on lines +188 to +192
`compose-serving-stack` reads every `MetricMapping` as a required resource, the same way
`compose-model-deployment` reads `InferenceCluster` and `ModelCache`. It renders them into
the collector's config, the ConfigMap the OTel collector loads on each cluster. The
`rename` map becomes transform-processor rules, applied to metrics from the pods the
`selector` matches. A new engine is a new `MetricMapping`, not a package change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be good to see an example of the OTEL config generated based on a MetricMapping

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added — the vLLM mapping now sits beside the config it renders, so the OTTL is visible: one transform statement per rename, gated on the engine resource attribute that k8sattributes lifts off the pod label, plus the scrape config and its two relabel rules.

It also makes the degradation concrete. An unmapped engine matches the scrape config and no transform block, so it arrives under its own names without a rule having to say so.

Comment thread design/metrics.md Outdated
Comment on lines +338 to +350
The kube-prometheus-stack `compose-serving-stack` installs stays. It is unconditional
today, on both stacks, and two things here depend on it: it already scrapes the substrate
and the gateway's Envoy proxies, and its operator is what defines the `PodMonitor` CRD this
design composes against. So the collector is added beside it rather than in place of it,
and what the collector removes is per-cluster recording rules and a second Prometheus at
the center, not the one already on each cluster.

That leaves one thing to settle: a plain OTel collector doesn't read `PodMonitor`s. Either
it runs under the OpenTelemetry Operator, whose target allocator consumes `PodMonitor` and
`ServiceMonitor` directly and keeps the cluster-wide-selector shape below, or it carries
its own Kubernetes service-discovery scrape config and `PodMonitor` is the wrong word
throughout this document. The target allocator is the smaller change and preserves the
#264 upgrade path, but nobody has run it here, so it is a decision this design records

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this Prometheus/PodMonitors/ServiceMonitors also a leftover? The OTEL Collector can scrape the same as Prometheus using its receiver, and as we are sending the data out we dont need Prom as a data storage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same leftover, same answer as above — the stack and the PodMonitor framing are both out. Discovery is now a scrape config in the collector's own ConfigMap, so no CRD is involved and there's nothing to reconcile between an operator and a receiver.

The alternatives entry keeps "a Prometheus stack" as a considered option, since the local store it gives an operator is a real thing to give up, but it's recorded as rejected rather than half-adopted.

Comment thread design/metrics.md
carries over, punctuation caveat included. An unmapped scheduler still gets scraped
under its own names, and Modelplane surfaces that rather than guessing.

## Aggregate to one view

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be good to have a section as well on cardinality and what labels we dont really need in the metrics. We can remove some in the OTEL collector processing before sending it out.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, added. Dropping pod, pod_uid and container_id — each is new on every restart, so each turns a rolling update into a fresh set of series nothing writes to again. Keeping engine, cluster, model, deployment and namespace, the dimensions the roll-up and dashboards group by.

One thing worth flagging since the obvious processor is the wrong one: attributes/delete_key removes the label but leaves the series that collided on it as separate undefined points. Merging needs metricstransform with an aggregation action. Getting it wrong looks like it worked.

Histogram buckets stay as the GenAI conventions define them — le is what makes the fleet histogram and the SLO ratio possible.

Comment thread design/metrics.md Outdated
Comment on lines +304 to +309
The cluster sends by pushing outbound. Each collector remote-writes or OTLP-exports to a
control-plane endpoint, so the workload cluster needs only egress, which is what makes it
work across regions and through firewalls. Nothing inbound to the cluster is required, and
nothing is exposed outside it. The center can pull instead where it already reaches the
cluster, publishing the cluster's endpoint on the `InferenceCluster` status, but push is the
default for the regional and firewalled case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to state which exporters we would support and how to configure them as a ModelPlane user. Auth is also something not touched yet in this doc, and the OTEL collector will need a way to auth to wherever it is sending the data.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The biggest gap in the doc, and it needed a section rather than a sentence. Both are in now.

Exporters: OTLP (otlp over gRPC, or otlphttp) as the contract, prometheusremotewrite for an operator who wants a Prometheus-compatible store. Vendor exporters go behind the gateway, so one configuration serves the fleet instead of one per cluster. A user doesn't write collector YAML: the destination is fleet-level config propagated to each ServingStack. Where exactly that field or kind lives I've left open rather than guessed.

Transport is the part I'd most like your eyes on. Modelplane has exactly one connection it can count on and it runs the wrong way: the control plane reaches a cluster's API server through the kubeconfig, and nothing guarantees a path back from a firewalled GPU cluster. So push to a gateway collector is the default, with its credential riding the same Secret propagation that already carries a HuggingFace token to every matched cluster. Where a cluster has no egress, the center scrapes the collector through the API server proxy over the connection it already has — no inbound exposure, no second credential, at the cost of putting every series through the API server. Direct pull per cluster is named only to rule it out.

Addresses @lsviben's review. Six changes, and the first five follow from the
same correction.

The Prometheus stack goes. I'd argued last week that it had to stay because its
operator defines the PodMonitor CRD this design composes against, which has the
dependency backwards: PodMonitor is a consequence of choosing Prometheus, not a
requirement of collection. The collector's prometheus receiver does Kubernetes
service discovery itself. The Envoy target we scrape today is already an
additionalScrapeConfigs block of kubernetes_sd_configs, the format that receiver
takes, so it moves across as it stands.

Each thing the stack did now has a named replacement: k8s_cluster for
kube-state-metrics, kubeletstats for cAdvisor, hostmetrics for node-exporter.
That splits the collector into a node-scoped DaemonSet and a cluster-scoped
Deployment, which the document didn't describe, and it raises whether the
DaemonSet tier belongs in the first cut at all. Two costs are written down rather
than left to be discovered: no local store to query ad-hoc, and the published
collecting-engine-metrics guide loses both halves of its workflow. A stale
hand-written PodMonitor goes inert rather than double-scraping, which is quieter
and worse, so it needs a release note.

Collection is no longer unconditionally always-on. Lovro's question was why run a
collector that pushes nowhere, and the answer moves the switch up a level rather
than reinstating a per-deployment toggle: no destination configured, no collector
composed. Once one exists, every source Modelplane owns is collected and an MD
author still gets no say over telemetry the platform team consumes.

Transport was one hand-waving sentence and is now a section, because Modelplane
has exactly one connection it can count on and it runs the wrong way. The control
plane reaches a cluster's API server through the kubeconfig; nothing guarantees a
path back from a firewalled GPU cluster. Push to a gateway collector is the
default, and its credential rides the same Secret propagation that already carries
a HuggingFace token to every matched cluster. Where a cluster has no egress, the
center scrapes it through the API server proxy over the connection it already
has, needing no inbound exposure and no second credential, at the cost of putting
every series through the API server. Direct pull is recorded and dismissed.

Exporters, destination and auth get a section: OTLP as the contract,
prometheusremotewrite for a Prometheus-compatible store, vendor exporters behind
the gateway. Where the fleet-level destination lives is called out as open rather
than guessed at.

Cardinality gets one too. Dropping pod, pod_uid and container_id, which are new on
every restart; keeping the dimensions the roll-up groups by. With the trap named,
because the obvious processor is wrong: attributes/delete_key removes a label but
leaves the collided series undefined, where metricstransform with an aggregation
action merges them.

And the config a MetricMapping renders is shown rather than described, since that
is what makes the selector and the degradation concrete.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The last commit reintroduced the tic this document was already cleaned of once:
announcing how many things are coming before saying any of them. "Two existing
pieces make it cheap." "Two things, both worth stating rather than discovering."
"Two upgrade notes come with it." "One trap worth naming." Each asks a reader to
carry a count through a passage that reads without it, and every one of them
introduced bold-led or self-contained paragraphs that needed no introduction.

Removed, along with three that predate the change: the four-things opener under
What to monitor, "All four are in the central view", and "Five signals matter".
Where a count carries weight it stays inside a sentence that says something, as
"Two API additions carry it" does.

Also dropped three "which is what X" clauses that stopped to explain the
significance of a sentence just finished, tidied two bold leads that had split
into fragments, and fixed an awkward possessive and a short-wrapped line.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Explaining the design in plain words broke two sentences, both added in the last
two commits.

The registry is open and the selector was closed. A platform team can apply a
MetricMapping for a forked or unreleased engine, and `engines[].type` was an enum
of the kinds Modelplane ships mappings for, so nothing could ever carry the value
that mapping selects on. The justification I wrote for closing it, that a mapping
can't be selected by a value nothing produces, argues for closing the one thing
that has to stay open. It's a free-form string validated as a label value now, and
a value with no mapping degrades to passthrough, which is already the behaviour
the section describes.

The collector gate forbade the fallback. "A cluster with no export destination
configured composes no collector" and "where a cluster has no egress the center
pulls from it" cannot both hold, because a pulled cluster has no exporter by
definition. The gate is the fleet destination rather than a per-cluster exporter:
with nowhere configured for the fleet, no cluster composes a collector, and a
cluster the center pulls from still delivers.

Two things that followed from looking at it that way. Pull mode changes the
exporter and not only the transport, since a pulled cluster exposes a scrape
endpoint where a pushing one exposes nothing, and the doc said the exporter
contract was OTLP without qualification. And a fresh install inverts: today
Modelplane installs a working per-cluster store with no aggregation, where after
this it aggregates and stores nothing, so an install with no destination collects
nothing at all. Right for a fleet, wrong for a first afternoon, which is an
argument about the getting started path rather than about this design, but it
belongs written down.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The same test the model delivery design got: write the page a user would read and
see whether the API holds up. This one replaces the collecting-engine-metrics
guide, since that guide is the manual path this removes. It stays out of
docs/content so Hugo doesn't build a page for something unbuilt.

Two things it found.

Nobody chooses push or pull. The document said the mode is per cluster and
reported on status, and never said who decides or how. The draft wrote "Modelplane
notices it can't reach out", which is what a reader wants and is not implementable:
a composition function does no network probing, so nothing at compose time knows
whether a cluster can reach the destination. The platform team declares it, on the
InferenceCluster, defaulting to push, with the resolved mode on status and as a
printer column.

The open destination blocks the docs, not just the build. Configuring one is the
first thing a user does, because nothing is collected until it exists, so it is the
first thing the page describes, and the draft had to invent a MetricsDestination
kind to say anything at all. Recorded as needing a decision before implementation
rather than during, with scope and owner attached: the platform team owns it and
one serves the fleet, which points cluster-scoped.

The page also wants two things the document leaves implicit and the built-in
mapping names, `vllm`, `sglang` and something for Triton/TensorRT-LLM, are one of
them. Its SGLang queue-depth metric name is a guess and marked as illustrative
rather than carried into the design.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Reading it start to finish rather than around the last edit, on the theory that
four rounds of local changes leave stale sentences behind. It found seven, and
five were mine.

The worst was a contradiction twelve lines wide. One paragraph still called
`engines[].type` "an enum of the kinds Modelplane ships a mapping for" while the
bullet below it said free-form string and not an enum, because the previous commit
fixed the bullet and not the prose that led into it. The same edit had also run two
paragraphs together with no blank line between them.

The vLLM example mapped `vllm:inter_token_latency_seconds` to
`modelplane_time_per_output_token`, collapsing two metrics this document argues at
length should stay separate, and it invented `modelplane_kv_cache_utilization`
where the vocabulary table and the disaggregation section both say
`modelplane_kv_cache_usage`. Every `modelplane_*` name in the document and the docs
draft now appears in that table. The table's own vLLM source name for that row
disagreed with the one the proof of concept measured, so it takes the measured one.

Three claims had gone stale under their own section. "Collect on every cluster,
always on" outlived the destination gate that replaced always-on. "One query covers
the whole deployment" promises something Modelplane doesn't hold, since it runs no
store and exports onward, so the view is whatever consumes the stream. And "Two API
additions carry it" was written before the destination and the push-or-pull field
existed; there are four, two settled and two needing a decision.

Two more bare counts the earlier sweep missed because they sit mid-sentence rather
than at the start of a line: "I propose four things" and "for four reasons worth
writing down". And three lines that had drifted past the wrap.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The page was beside the design where nothing checked it. In docs/content it gets
the same treatment as any other page: docs-vale lints it, and Hugo would build it
if it were allowed to. `draft: true` keeps it out of the site, which Hugo honours by
default since buildDrafts isn't set, and a local build confirms it produces no
output. The filename says draft too, so it's obvious in a directory listing and
easy to drop before merge.

Passing docs-vale needed two vocabulary entries and several rewrites. MetricMapping
and OTLP go into accept.txt: the first is a kind this design adds, alongside
ModelCache and the rest, and the second is a standard acronym. OpenTelemetry goes in
with it. The rest were mine to fix rather than to accept, so queue-depth, Pod-scoped,
roll-up and Ad-hoc are reworded, PodMonitor in a heading is backticked as it already
is in prose, and the opening sentence loses a tricolon.

The API line is plain text where every other page links to its reference. Hugo's
refLinksErrorLevel defaults to ERROR, and the reference pages are generated from
CRDs, so a `ref` to metricmappings would fail the build the moment anyone flips
draft to false. Better a dead-looking line than a trap.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Comment thread design/metrics.md Outdated
Comment on lines +392 to +397
**Pull through the API server proxy.** The fallback, for a cluster with no egress. The
center scrapes it over the connection it already has. The Kubernetes API server proxies to
in-cluster Services, so the collector is reachable at
`/api/v1/namespaces/<ns>/services/<collector>:<port>/proxy/metrics` with the credential
Modelplane already holds. No inbound exposure, no firewall change, no second credential;
the only addition is a `ClusterRole` granting `services/proxy`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to support 2 modes. Maybe going with just pull from the clusters to the control plane makes more sense.

Makes it also easier considering that we want to roll up all telemetry in one place fleet-wide, as it seems that InferenceGateways are becoming their own clusters as well.

Then we have just one place where to set up exporters and auth for them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch that above 😄 . I did some digging and agree with your sentance below that this would be too intensive.

Anyway, i still think we should at least start with 1 mode only to keep things simpler, and that should be the push one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it's out. One transport, push, and no field selecting it.

You talked me into it with the retraction rather than the original suggestion: once pull-only is off the table, the second mode is a fallback whose own analysis in this section admitted it was degraded, and I'd still have been paying for it in full. It isn't only a second transport, either. A pulled cluster exposes a scrape endpoint where a pushing one exports and exposes nothing, so it's a second collector configuration, a second exporter shape, a ClusterRole granting services/proxy, and a field with a status mirror and a printer column.

What makes dropping it easy is that deferring is free. The field would be optional and default to push, so adding it the day a cluster that genuinely can't egress turns up is additive and breaks nobody. So there's nothing to migrate later and no reason to build it now.

I've kept the analysis in the section rather than deleting it: what would be built, who declares the mode, and why Modelplane can't detect it for itself, since a composition function does no network probing. Pull direct stays ruled out on its own terms. The diagram now has both clusters pushing, and the API-change count drops from four to three.

Comment thread design/metrics.md Outdated

It blocks more than the implementation. Configuring a destination is the first thing a user
does, since nothing is collected until one exists, so it is the first thing the docs
describe, and a draft of that page had to invent a `MetricsDestination` kind to say

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: suggest going with TelemetryDestination/ObservabilityDestination instead, to keep it open for more signals in the future

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking it, and it's cheaper than a nit. TelemetryDestination.

Two things push it past preference. The name was never really proposed: it's a placeholder a draft of the docs page had to invent to have something to write, on a question the doc explicitly leaves open, so there's nothing to reverse. And an OTLP endpoint carries metrics, logs and traces on the same wire, so the destination genuinely is signal-agnostic already. MetricsDestination would describe it narrower than it is, which makes the broader name more accurate rather than more speculative.

Also the asymmetry: the rename is free while nothing is built, and costs a deprecation window plus a migration for every user once something is.

I went with telemetry over observability since that's OTel's own word for the data, where observability is the property. And the answer holds whichever shape it lands in, so the doc now says TelemetryDestination as a kind or spec.telemetry.destination as a field, with the kind-or-field question still open.

Two review points from Lovro, both of which shrink the design.

The transport section offered push as the default and a pull through the API
server proxy as a fallback for a cluster with no egress, selected by a field on
the InferenceCluster. Its own analysis undercut it: every series would cross an
API server not built to carry them, which caps what a cluster in that mode
could send. And a second mode isn't only a second transport, because a pulled
cluster exposes a scrape endpoint where a pushing one exports and exposes
nothing, so it costs a second collector configuration, a second exporter shape,
a ClusterRole granting services/proxy, and a field with a status mirror and a
printer column.

So there is one transport and no field selecting it. What makes that easy is
that deferring costs nothing: the field would be optional and default to push,
so adding it when a cluster that can't egress actually turns up is additive and
breaks nobody. The analysis stays in the section, as what would be built and
who would declare it, including why Modelplane can't detect the mode itself.
Pull direct stays ruled out on its own terms. The architecture diagram has both
clusters pushing, and the API-change count drops from four to three.

The destination's name is settled ahead of its shape: telemetry, not metrics. An
OTLP endpoint carries metrics, logs and traces on the same wire, so the
destination is already signal-agnostic and MetricsDestination would describe it
narrower than it is. TelemetryDestination as a kind, or spec.telemetry.destination
as a field, with the kind-or-field question still open. The rename is free while
nothing is built and costs a deprecation window and a migration for every user
once something is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

4 participants