Skip to content

Collect metrics from every inference cluster - #470

Merged
dennis-upbound merged 44 commits into
modelplaneai:mainfrom
dennis-upbound:dennis/metrics-impl
Oct 2, 2026
Merged

dennis-upbound merged 44 commits into
modelplaneai:mainfrom
dennis-upbound:dennis/metrics-impl

Conversation

@dennis-upbound

@dennis-upbound dennis-upbound commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Description of your changes

#363 described how a fleet's metrics get off its clusters and onto one surface. Nothing implemented it, so a platform engineer had no way to read an engine, a gateway or a GPU across clusters, and the names they would read differed per engine anyway.

This composes an OpenTelemetry collector onto every InferenceCluster and gives it two kinds to read. A MetricMapping says what a component emits and what Modelplane calls it: from, to, fromUnit where the source is measured in something other than the target name claims, and labels and part for the series that need several of a component's metrics to become one of Modelplane's. A TelemetryDestination says where it goes: name, type, endpoint, secretRef and auth are fields, and everything else an exporter takes passes through unread, because that schema is OpenTelemetry's and moves on its own schedule. Several can exist and their sinks are concatenated, so a second backend is a new object rather than an edit to one somebody else owns, and the credential named by a secretRef is copied from the control plane to each cluster that mounts it.

The function compiles a mapping to OTTL rather than an operator writing it, which keeps the collector's configuration language out of a Modelplane API and leaves room to render something else later. Five built-in mappings carry 32 metrics off vLLM, SGLang, the gateway, the picker and DCGM. Only modelplane_* leaves a cluster. The collector does not count towards a ServingStack's readiness: nothing serving depends on it, and a destination pointing somewhere unreachable should not stop the scheduler placing replicas.

Each replica publishes its own series, told apart by a replica label, and a query over a deployment combines them - sum by (deployment) for anything counted, avg for a ratio. The collector does not combine them, because a scrape of one replica is one batch and adding two cumulative readings taken at different moments is not the traffic that happened. The label is the replica's index rather than its pod, so it is bounded by the replica count and survives a restart and a rolling update.

That is 23 of the 50 metrics #363 describes, which is everything reachable by renaming one metric to one name. #476 lists the rest; most of it needs per-object state from resource-state-metrics rather than anything here.

The endpoint picker, the credential path and multiple destinations are verified on that cluster too: modelplane_route_decision_seconds arrives labelled role=picker once the gateway routes a request, a sink's secretRef Secret written only on the control plane turns up in modelplane-system on the GPU cluster with the collector mounting it, and two TelemetryDestinations render one config carrying both sinks.

It also fixes cross-cluster routing on GKE, because the gateway metrics depend on it. A cluster gateway's name was published as a headless Service and an EndpointSlice, and kube-dns builds its records from the Endpoints API and was never taught the slice one. GKE runs kube-dns, so the name answered NXDOMAIN, Envoy resolved no address for the backend, and every cross-cluster request was refused with no healthy upstream. kind runs CoreDNS, which reads the slice, which is why CI has never seen it. #486 has the detail.

Verification

Run on a GKE cluster with two L4s, vLLM serving Qwen2.5-0.5B, two replicas, loaded to saturation, and later alongside SGLang on the second GPU. Everything here is modelplane_* arriving through the composed collector.

Modelplane telemetry in Grafana

Each layer caught what the one before it couldn't. The collector's own validate caught a value rewrite rendered into a context that cannot reach values. The local two-cluster e2e, which --verify now gates on, caught an engine scrape job matching a label value that never existed, an unnamed engine port, a gateway job asking Envoy for a path it 404s on, a substrate job ignoring prometheus.io/port beside the annotation it honoured, and every series labelled with a generated name rather than the cluster's. Real GPU hardware caught every modelplane_gpu_* series arriving empty, because nothing annotates DCGM for scraping and GKE runs its own. A second replica caught two replicas colliding into one series: the engines held 300 and 4640 prefix-cache lookups and the series read 300. Reading a Grafana dashboard caught Prometheus receiving every series stripped of its identity, since that identity is resource attributes and the remote-write exporter drops those unless asked. Running the collector against a rendered config caught scale_metric refusing an integer factor, which would have stopped it starting on any fleet converting mebibytes; feeding it a histogram in milliseconds confirmed the sum, the bounds and the bucket counts all come out converted, which the previous datapoint rewrite never reached.

Running a second engine found more. SGLang v0.4.9.post2 on the same cluster publishes three of the eight metrics its built-in named and no others: sglang:queue_time_seconds and both retraction counters don't exist at that version, or at v0.4.6 or v0.5.0, so those mappings could never match. Its two token histograms are real but need --collect-tokens-histogram beside --enable-metrics, which the guide didn't say. And a MetricMapping stored before a field became required took the whole ServingStack to ReconcileError: a CRD validates on write, not on what it already holds, so the stale object parses straight into a ValidationError and fails the pipeline step. A telemetry object now gets dropped with a warning rather than stopping the fleet placing replicas.

labels and part have no built-in using them, but both are exercised on a cluster. A mapping folding SGLang's prompt and generation counters onto one name gives modelplane_tokens_total{direction="input"} 398 and {direction="output"} 607; part: Count over its latency histogram gives modelplane_engine_requests_total 12, matching the requests sent; and a values remap turns engine_type="unified" into engine_kind="Unified" and drops the original. They are what the counters in #363 need - tokens by direction, responses by reason, requests by status.

One thing I have not done: the gateway's time-to-first-token and time-per-output-token exist only for streaming requests, and I have not driven one.

I have:

  • Read and followed Modelplane's contribution process.
  • Run nix flake check (or ./nix.sh flake check) and made sure it passes.
  • Added or updated tests covering any composition function changes.
  • Signed off every commit with git commit -s.

@github-actions

Copy link
Copy Markdown

Docs preview: https://modelplane-docs-pr-470.vercel.app (ready once the site's Content workflow finishes)

@dennis-upbound
dennis-upbound force-pushed the dennis/metrics-impl branch 2 times, most recently from 5cfcadf to 0e711f4 Compare September 28, 2026 16:08
@dennis-upbound dennis-upbound changed the title Add the MetricMapping and TelemetryDestination kinds Collect metrics from every inference cluster Sep 28, 2026
@dennis-upbound dennis-upbound mentioned this pull request Sep 28, 2026
2 tasks done
Comment thread crossplane-project.yaml
Comment thread crossplane-project.yaml
Comment thread apis/metricmappings/definition.yaml Outdated
Comment thread apis/telemetrydestinations/definition.yaml Outdated
Comment thread docs/content/guides/telemetry.md Outdated
Comment on lines +9 to +18
<!-- vale write-good.Passive = NO -->
{{< hint warning >}}
**Draft.** This page documents [the metrics design][design], which isn't built yet. It's
here to check the experience reads well before it's implemented, and it's excluded from the
site by `draft: true`. It replaces [Collecting engine metrics]({{< ref
"guides/collecting-engine-metrics.md" >}}) when the per-cluster Prometheus stack is
removed, and takes that page's URL with it.

[design]: https://github.com/modelplaneai/modelplane/pull/363
{{< /hint >}}

@negz negz Sep 30, 2026 •

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.

Given this introduces new Modelplane APIs, I think it deserves documentation in the 'main' docs - I'm thinking a new 'Monitor the fleet' section (or something like that) under the Platform heading in the sidebar. Ideally we'd add those docs in this PR.

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.

Moved it to Platform as "Monitor the Fleet" and deleted the Prometheus-stack guide it replaces, in this PR. The two pages that linked there link here now.

Comment thread docs/content/platform/telemetry.md Outdated
Comment on lines +72 to +75
spec:
exporters:
otlphttp:
endpoint: https://otel.example.internal

@negz negz Sep 30, 2026 •

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.

Can you enable more than one exporter at once? Do we expect other types?

Would an API like this work?

apiVersion: modelplane.ai/v1alpha1
kind: TelemetryDestination
metadata:
  name: platform
spec:
  sinks:
  - name: otel
    protocol: OTLP                # OTLP | PrometheusRemoteWrite
    otlp:
      endpoint: https://otel.acme.example
      transport: HTTP             # HTTP | GRPC
    auth:
      scheme: Bearer              # None | Bearer | Basic
      bearer:
        secretRef:
          name: otel-token
          key: token
  - name: prometheus
    protocol: PrometheusRemoteWrite
    prometheusRemoteWrite:
      endpoint: https://prom.acme.example/api/v1/write
    auth:
      scheme: Basic
      basic:
        secretRef:
          name: prom-credentials
status:
  conditions:
  - type: Ready
    status: "False"
    reason: SecretNotFound
    message: "Sink otel: Secret modelplane-system/otel-token has no key token"

I would imagine potentially allowing multiple TDs like this but rolling them up to form the actual composed config.

The goals of this sketch:

  • Use lists of named subobjects, e.g. to allow multiple sinks of the same type
  • Associate sinks with their creds
  • Use a required discriminator field to clearly discriminate the union

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.

Took the named list and the per-sink credentials. That second one was a real bug — secretRef was one Secret for the whole object, so two sinks had to share it and tell their keys apart by prefix. Each sink mounts under its own directory now.

Left the body pass-through. The exporter schema is upstream and versioned separately from us, so a closed protocol enum means a Modelplane release per exporter and dropping the tls/retry/sending_queue settings destinations actually need — tls alone is 16 keys, and insecure_skip_verify is what an internal endpoint wants. The OTel Operator hit this twice and passed through both times, most recently typing the pipeline wiring in v1beta1 and leaving exporters/extensions as preserve-unknown-fields.

No discriminator either: Prometheus Operator uses optional siblings validated in the controller, and KEP-1027 never shipped, so there's no APIUnions gate and it'd be hand-written CEL. Say the word if you want one anyway.

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.

Went further on this — took the middle. A sink now types what's Modelplane's and passes through what's OpenTelemetry's:

sinks:
- name: primary
  type: otlphttp
  endpoint: https://otel.example.internal
  secretRef:
    name: telemetry-credentials
  auth:
    bearerTokenKey: token
  config:            # anything else the exporter takes
    compression: gzip

endpoint is required and typed — every exporter has one and it was the setting most worth catching at the apiserver. auth composes the bearertokenauth extension and wires the reference, since the collector carries no credential on an exporter, only a reference to one; that was previously three hand-written stanzas. Both render after config, so a passed-through key can't redirect a sink or unpick its credential.

One scheme, not an enum of them, per the conventions note about anticipating a union with a single optional field — and bearer reads a file from the sink's own directory, which sidesteps the env-var collision two Secrets would otherwise have. Anything else still works the old way: define the extension under spec.extensions and name it from config.

Still not typing the exporter body. TLS is 16 keys, retry and queue another dozen, and prometheusremotewrite is mid-deprecation of its top-level HTTP settings as of v0.158.0 — typing that means versioning our API through theirs.

@negz negz left a comment

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.

I know a lot of this feedback is more design shaped. Sorry it's coming in so late.

Comment thread docs/content/guides/telemetry.md Outdated
Comment thread apis/metricmappings/definition.yaml Outdated
Comment on lines +44 to +52
description: >-
OTTL statements, rendered into the collector's transform
processor beside Modelplane's own. Modelplane does not
interpret them: what you write here is the collector's own
configuration language, documented by OpenTelemetry, and it
is the same thing Modelplane writes for vLLM.

Statements select through their own where clauses, so
nothing declares which engine a deployment runs.

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.

Not super actionable feedback, but I don't love that we leak OTTL here. It'd be nice to abstract it so we have the freedom to switch out to another metric collector if we wanted to in future.

OTOH the only way I can do that is something like this, which feels like it'd have the downfalls of Crossplane P&T:

apiVersion: modelplane.ai/v1alpha1
kind: MetricMapping
metadata:
  name: my-engine
spec:
  metrics:
  # Rename.
  - from: my_engine_queued_requests
    to: modelplane_requests_waiting

  # Rename and convert units. The target's unit is known, so the mapping
  # says what the source is measured in, not a factor.
  # Real cases: SGLang KV transfer in ms, DCGM energy in mJ, FB_USED in MiB,
  # thermal violation in ns.
  - from: my_engine_kv_transfer_ms
    fromUnit: Milliseconds
    to: modelplane_request_kv_transfer_seconds

  # Fold two metrics into one, told apart by a fixed label.
  # Real case: vLLM prompt and generation tokens become
  # modelplane_tokens_total{direction}.
  - from: my_engine_input_tokens_total
    to: modelplane_tokens_total
    labels:
    - name: direction
      value: input
  - from: my_engine_output_tokens_total
    to: modelplane_tokens_total
    labels:
    - name: direction
      value: output

  # Rename a label and map its values onto Modelplane's vocabulary.
  # Real case: each engine's finish vocabulary becomes one reason label.
  - from: my_engine_requests_finished_total
    to: modelplane_responses_total
    labels:
    - name: reason
      from: finish_reason
      values:
        eos: stop
        max_tokens: length
        cancelled: abort

  # Take a histogram's count as a counter.
  # Real case: the gateway's duration histogram becomes
  # modelplane_requests_total.
  - from: my_engine_request_duration_seconds
    part: Count
    to: modelplane_requests_total

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.

Did it. spec.metrics is from/to/fromUnit and the function compiles to OTTL. Left out labels and part from your sketch — both real, neither has a caller yet.

fromUnit caught a live bug while I was converting: DCGM reports FB_USED in MiB and we were renaming it to modelplane_gpu_memory_used_bytes with no conversion, so the series read a millionth of the memory in use. A field that asks the question catches that; a hand-written statement doesn't.

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.

FWIW I was more thinking out loud on this, not actually suggesting we adopt this API. I'm okay proceeding with it, but it's not clear to me that it's a better approach than what you had originally. They both feel imperfect.

Comment thread apis/metricmappings/definition.yaml Outdated
Comment thread apis/telemetrydestinations/definition.yaml Outdated
Comment thread functions/compose-serving-stack/function/stacks/metrics.py
Comment on lines +36 to +39
# Pinned rather than floating: a collector that silently changed what it
# renames on a chart bump would move the metric surface under an operator's
# dashboards.
IMAGE = "otel/opentelemetry-collector-contrib:0.139.0"

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.

This is a pretty old version right?

(Also might be worth asking an agent to review commentary in this PR for low value / filler content. This comment reads a bit filler-y.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agree, we should start with a newer version plus probably add a renovate to update as OTEL stuff release quite often

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.

Bumped to 0.161.0. 0.162.0 is tagged but has no multi-arch manifest on Docker Hub yet. Did a pass over the commentary in this PR and cut three, that one included. No renovate rule yet — worth a follow-up.

Comment thread functions/compose-serving-stack/function/collector.py
@dennis-upbound

dennis-upbound commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Replaced by the one in the description, which is from a two-replica run under load.

Worth keeping the finding though: taking the first of these is what caught the identity bug. Every legend rendered as /, because Prometheus was receiving each series stripped of its cluster, deployment, engine and role. Those are resource attributes, since that is what the replica merge groups on, and the remote-write exporter drops resource attributes unless asked. The debug exporter prints them, so every check before that one looked right.

dennis-upbound and others added 7 commits October 1, 2026 10:19
Modelplane collects from everything it installs and normalizes it onto
one modelplane_* surface, and two things about that are the operator's:
where the telemetry goes, and what to do about a component Modelplane
ships no rules for. These are the two kinds the telemetry design gives
them.

Both hold OpenTelemetry collector configuration that Modelplane passes
through unread. A MetricMapping carries OTTL statements rendered into
every inference cluster's transform processor, and passthrough for
keeping a component's own metric names flowing during a migration. A
TelemetryDestination carries the collector's exporters and extensions,
a secretRef so credentials stay out of the object, and collector:
External for a platform that already runs a fleet collector and hands
over its endpoint.

Passing configuration through unread is the point rather than an
omission. An operator writes the collector's own language, documented
upstream, so any exporter or authenticator it gains works without a
Modelplane release, and a field-by-field schema would either restate all
of it or quietly cap what a destination can be.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Neither kind composes anything: compose-serving-stack is what renders
them into a collector. So both functions could mark their XR Ready and
look no further, which for a data resource is nearly honest. It is also
how a fleet ends up producing no telemetry with every object green.

A MetricMapping reaches every inference cluster, so the function
resolves them and reports the count, and is not Ready when there are
none. It also catches a mapping carrying neither statements nor
passthrough, which changes nothing and otherwise looks exactly like one
that works.

A TelemetryDestination gets the one check Modelplane can make without
modelling what an exporter is. An exporter's auth block names an
authenticator by extension name, and the collector refuses to start when
no extension defines it, so a typo there stops telemetry with the
failure two layers from its cause. The same for a credential Secret that
does not exist, and for a destination carrying no exporters at all.

Neither reads further in. Validating the statements or the exporters
would be Modelplane modelling the collector's configuration, which is
what these kinds exist to avoid.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
x-kubernetes-preserve-unknown-fields generates as dict[str, Any], which
is what a block passed through unread should be, and the collector enum
generates as a Literal with its default.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
What an operator reads: the metric surface, where to point a
TelemetryDestination, what to write for an engine Modelplane ships no
statements for, and migrating off the per-cluster Prometheus, with the
rename table and the compatibility rules that keep an existing dashboard
working while its panels are rewritten.

draft: true until the kinds it describes are composing and collecting,
so it stays out of the published site. The Vale vocabulary comes with
it; every word it adds is one only this guide uses.

Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The MetricMapping and TelemetryDestination kinds accept OTTL statements and
exporter config, but nothing renders a collector from them, so a fleet that
declares both still has no telemetry pipeline.

Compose one collector per InferenceCluster from those kinds: a ConfigMap
carrying the rendered YAML, a Deployment running it, and the RBAC the
Prometheus receiver needs to discover pods. The receiver scrapes engines, the
gateway and the substrate; the transform processor applies the built-in rename
statements plus whatever the MetricMappings add, and the exporters come
straight from the TelemetryDestination.

The Deployment carries a checksum of the rendered config so a mapping or
destination change restarts the collector. The checksum is a sha256 of the
config rather than Python's hash(), which is seeded per process and would
redeploy on every reconcile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Two fields went in ahead of any demand for them.

TelemetryDestination's collector enum chose between composing a fleet
collector and deferring to someone else's. Nothing composes a fleet collector
yet, and nothing reads the field: each inference cluster's collector exports
straight to the destination's exporters, so a platform that already runs one
points those exporters at its own endpoint and the enum never comes up. When a
fleet collector does land, whether to compose it is a composition-level
choice, not a field every user reads.

MetricMapping's passthrough kept an engine's own metric names flowing during a
migration off the Prometheus stack. Migrations aren't what this project should
be optimising for yet, and the flag was a bool where an enum would belong if
it came back. Dropping it means only modelplane_* leaves a cluster, which
takes a branch out of the collector config too.

Three smaller corrections alongside: the collector image was pinned 23
releases back, at 0.139.0; both XRDs were missing the category that files them
under Platform in the API reference; and the guide offered Grafana dashboards
that don't exist anywhere in this repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The collector refuses to start on the config this composes. The statement that
scales DCGM's energy counter reads value_double, which is a datapoint path,
and every statement was rendered into a single metric-context block:

  segment "value_double" from path "metric.value_double" is not a valid path
  nor a valid OTTL keyword for the metric context

Render two blocks instead, the datapoint one first. It has to be a separate
block rather than an earlier line in the same one: the transform processor
finishes a block over every datapoint before it starts the next, so a rename
sharing the block would run after the first datapoint was scaled and leave
every later datapoint unmatched, and unscaled.

Verified by running `validate` in the collector image itself, against the
rendered config, with and without exporter authentication.

Also build the renames Modelplane provides as MetricMappings rather than as a
bare list of statements, so they reach the collector through the same path an
operator's mapping does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
dennis-upbound and others added 5 commits October 1, 2026 17:57
The collector counted towards the ServingStack the same way every serving
component does, so a collector that could not start - a sink naming an
exporter that does not exist, an endpoint that has gone away - took the
ServingStack and then the InferenceCluster out of Ready, and the scheduler
stopped placing replicas there. One bad TelemetryDestination is fleet-wide,
so that is every cluster at once: a telemetry mistake taking down serving.

The collector observes the fleet and nothing serving depends on it, so it
is Ready on arrival. A collector that cannot start still says so on its own
objects and on the TelemetryDestination, which is where that belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A TelemetryDestination is cluster-scoped on the control plane and its
sinks name one Secret. The collector runs on every workload cluster, and
its Deployment mounts that Secret by name from modelplane-system there -
a namespace nothing copied it into. Every destination with a credential
composed a pod that could never start, which is every destination that
reaches a real backend. The destination reported Ready throughout,
because it had resolved the Secret it could see.

The serving stack now resolves it and composes a copy onto each cluster,
the way compose-model-cache already propagates a ModelCache's token. Same
name and namespace out there, so the mount needs nothing rewritten, and
the base64 data is copied verbatim rather than decoded and re-encoded.

A Secret that resolves to nothing still composes the collector. The
destination reports it, and the pod waits the way any pod waits for a
Secret, which recovers the moment the operator creates one.

The destination's own check asked for the Secret with no namespace, so it
would have accepted one of that name in any namespace while the one it
meant was absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
The fleet exported through one destination - whichever sorted first - and
warned about the rest. Adding a second backend therefore meant editing an
object another team owned, and creating your own got a warning and
silence.

Their sinks and extensions are concatenated instead, so a destination is
an additive object rather than a singleton. A sink's name is what the
collector calls its exporter, so two destinations cannot share one: the
destination sorting first keeps the name and the other is dropped with a
warning, because taking the whole fleet's telemetry down over a name
collision is the worse failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A cluster's gateway address is published as both an EndpointSlice and the
legacy Endpoints kube-dns still reads. The endpointslice mirroring
controller copies a hand-written Endpoints into an EndpointSlice of its
own, and the slice composed beside it already is that slice, so two
writers kept correcting each other's object for the same Service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Several descriptions described behaviour the code does not have.

A later rename was said to win over a built-in. Each rename matches the
name the component emitted, so once a built-in has renamed a metric the
operator's rename matches nothing and the built-in stands. Taking a part
of a histogram was said to leave the histogram exported under its own
name, but only modelplane_* leaves a cluster, so it is dropped unless a
second entry renames it too. The fleet-wide quantile example selected on
a model label the collector does not add, and the MetricMapping example
omitted acrossReplicas, which is required - as written the API would have
rejected it.

values is documented as meaningful only alongside from, and now a CEL
rule says so rather than it being silently ignored beside a fixed value.
A label's from naming the label itself is documented, since that is how a
mapping rewrites values in place.

The remote-write exporter is named prometheus_remote_write as of 0.161.0,
with prometheusremotewrite a deprecated alias, so the examples use the
canonical spelling. The oidc extension authenticates callers of a
receiver rather than an exporter, so it is no longer offered as a way to
authenticate a sink. endpoint and auth are documented as overriding
config, which is what the renderer does, and the one default Modelplane
applies to a sink is documented rather than left to be discovered.

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

Copy link
Copy Markdown
Collaborator Author

@negz all 29 addressed and replied to in the threads. Seven commits on top; check, render and the label-gated e2e are green.

Nine of them were real bugs rather than doc fixes, and the ones worth knowing about:

  • fromUnit never reached a histogram. value_double doesn't touch its sum or bucket bounds, so a histogram was renamed to seconds with buckets still in milliseconds. It's scale_metric in the metric context now — sum 4200 and bounds 100/500/2000 come out 4.2 and 0.1/0.5/2.0, checked against the collector.
  • A secretRef could never work. Nothing copied the Secret to the clusters that mount it, so every destination with a credential composed a pod that could not start.
  • prometheus_remote_write is the canonical name, as you thought, and it missed the default — so the canonical spelling exported every series stripped of its identity.
  • The decode engine and the picker were both unscrapeable. The picker's metrics endpoint also does TokenReview by default against a ServiceAccount that can't hold the ClusterRole for it.

The collector no longer counts towards ServingStack readiness, and destinations concatenate rather than first-sorted-wins.

Two things for you:

  • acrossReplicas — you're right that nothing reads it. My reasoning for keeping it is in that thread; happy to drop the field if you'd rather not ship one nothing reads.
  • Move the collector off resource_to_telemetry_conversion #489 for resource_to_telemetry_conversion, which is deprecated (it warns at runtime). Left alone here because the current setting is verified against a real backend and getting resource_constant_labels subtly wrong loses identity labels silently.

dennis-upbound and others added 2 commits October 1, 2026 20:53
Checked the built-ins against a running SGLang v0.4.9.post2 on a GPU
cluster. Three of the eight named metrics it does not emit, at that
version or at v0.4.6 or v0.5.0, so those mappings could never match and
the fleet reported nothing for them with nothing to say why.

sglang:queue_time_seconds does not exist. The nearest thing,
sglang:avg_request_queue_latency, is a gauge of the mean over the last
batch rather than a per-request histogram, so folding it into
modelplane_request_queue_seconds beside vLLM's would make a quantile over
the fleet meaningless. SGLang publishes no retraction counters at all, so
sglang:num_retracted_requests_total and
sglang:num_retracted_input_tokens_total named nothing - preemption is a
vLLM concept here. modelplane_tokens_recomputed_total had no other source,
so it goes with them.

The two token histograms are real but need --collect-tokens-histogram
alongside --enable-metrics: without it the engine publishes only the
_total counters, and modelplane_request_input_tokens and
modelplane_request_output_tokens stay empty for SGLang. The guide said to
pass --enable-metrics and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
A CRD validates on write, not on what it already stored, so an object
written under an older schema still comes back whole on read. The
function parsed every MetricMapping and TelemetryDestination straight
into its Pydantic model, so one stored before a field was required raised
a ValidationError - and that fails the pipeline step, which fails the
whole ServingStack. The fleet stops placing replicas because a telemetry
object is out of date.

Hit on a live cluster: a MetricMapping written before acrossReplicas
became required took the serving stack to ReconcileError, and nothing
about the error named telemetry as the cause.

An object that will not parse is now dropped with a warning naming it,
and the rest of the fleet's telemetry carries on - the same reasoning
that keeps the collector out of the stack's readiness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Left some comments that can be done as follow-ups. Most importantly as we do not have the collector or RSM at the control plane level, we do not collect some of the metrics planned in the design. We could check if we can get them from the workload clusters directly.

Furthermore, another thing caused by above is as @negz pointed out, we strayed from having the fleet telemetry converge to the control plane cluster Maybe we can document how to set it and give a TelemetryDestination example sending metrics there. It could be nice as an example for users trying it out. Ofc we can add that in a follow up documentation PR.

I think the guide in general could do a rework and making it more concise. Right now its hard to read

)


def mappings() -> list[v1alpha1.MetricMapping]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

we are not mapping all the planned metrics from the design ATM right?

Can we add a follow-up ticket with whats missing so we can add them later and not forget. I guess for some that we planned RSM on the control plane side we need to rethink how to get them as now no collector lives there.

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.

Right, 24 of the 50. #476 lists the other 26 grouped by what actually blocks each one, so nothing's lost.

Your control-plane point wasn't captured there though, and it's the important one — #470 composes a collector onto each inference cluster and none onto the control plane, so there's nothing there to scrape resource-state-metrics. Added that to the issue as a thing to decide before that group can be built.

],
},
{
"job_name": "modelplane-substrate",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what metrics are we trying to get with the substrate job atm?

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.

Nothing, today. It keeps any pod annotated prometheus.io/scrape, minus the gateway, engine and DCGM pods the jobs above already have — but filter/modelplane drops everything that isn't modelplane_*, and no built-in mapping names a substrate metric. So it scrapes the stack's own components and discards all of it.

It's there so an operator can write a MetricMapping for something in the stack — llm-d, ModelExpress, cert-manager — and have it collected without us adding a scrape job per component. That's the only thing it buys today.

Given you and Nic have both leaned toward not shipping surface until there's demand for it, dropping the job until something uses it is a reasonable call. Say the word and I'll pull it — it's a scrape interval on every GPU cluster for no series.

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.

Checked it on a live cluster rather than just reading the code, and it's worth correcting what I said: modelplane-substrate isn't the only empty one. Series per job right now, on a fleet serving traffic:

modelplane-engines          232
modelplane-gateway-genai     36
modelplane-gpu               16
modelplane-gateway            0
modelplane-substrate          0

modelplane-gateway scrapes the Envoy admin port, which serves only envoy_* names — I confirmed zero gen_ai_* on 19001 and 55 of them on the ext-proc's 1064. All three built-in gateway mappings name gen_ai_*, so the genai job is what actually produces modelplane_frontend_*. The admin-port job collects Envoy's whole stat surface every interval and the filter drops all of it.

So it's two of five jobs, on every cluster, for no series. Both are there for the same reason: they're what lets an operator's own MetricMapping reach the gateway or a stack component without us adding a scrape job per component.

Worth a decision either way. Keeping them means an operator can map envoy_* or a ModelExpress metric and have it just work; dropping them means anyone who tries gets silence until a release adds the job back. I don't think it should hold up this PR — happy to take it as a follow-up.

Signed-off-by: Rae Sharp <resharp20@gmail.com>
tr0njavolta and others added 3 commits October 2, 2026 11:21
Leans down guide, clarifying some sections, adds examples
Four claims in the guide aren't true of the code, two of them long-standing
and two lost in the edit.

There is no collector on the control plane. Only compose-serving-stack
composes one, onto each inference cluster, and it exports to the
destination's sinks directly - so there is no "single egress point" tier,
and the sinks are whatever the collector has an exporter for rather than
OTLP alone.

Nothing emits a `_max` series. A saturation gauge is one series per
replica and the choice between an average and a maximum belongs to the
query, which is the whole point of `acrossReplicas`. The example beside
the paragraph already said so.

SGLang needs `--collect-tokens-histogram` as well as `--enable-metrics`,
or the two token histograms stay empty, and it publishes neither a
per-request queue time nor preemption counters. Both were checked against
a running engine. The credential Secret goes in modelplane-system on the
control plane and Modelplane copies it to every cluster running a
collector, which is the question a reader has at that point.

Also restores the collecting-engine-metrics alias, so the old URL keeps
resolving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
Sweeps what was left of the second review pass over the telemetry guide.

`acrossReplicas` arrived with no introduction, in a sentence that assumed
the reader already knew the field. It now says what it is where it first
appears. The `instance` paragraphs spent six lines justifying a label's
existence; one sentence says what it is and what to group by instead. How
the collector reads a credential off disk is our business rather than the
reader's, so that goes, keeping only the part an operator acts on: rotating
a token needs no restart. The Engines section told you vLLM and SGLang need
no mapping a page before anything said what a mapping was. And "top-line
numbers" wasn't a phrase anyone outside this repo uses - they're the
frontend numbers, which is what the metrics are called.

The TelemetryDestination schema justified `type` not being an enum by the
Modelplane release a new exporter would otherwise need. That isn't true: a
new exporter needs a new collector image, which needs a release anyway. The
honest reason is narrower - the collector already rejects a name it doesn't
have, so a list here would only be a second thing to go stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
@sumbry
sumbry requested a review from lsviben October 2, 2026 15:59

@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.

Thanks @dennis-upbound for the great work.

The guide looks much better now. One thing I noticed while reading this is the acrossReplica does not really do anything and is just an informative field for users running queries. But that would also require them to check the MetricMapping where it is set. So I am not sure if its really useful, we can have such info in the docs. We can remove it in the next iteration not to block the release (ofc if you agree).

As discussed in the other comment, ill add a ticket to build a custom OTEL image for Modelplane that uses just the components we need and support. That means we will wont support all exporter type or any extension. We will though allow users to use their images if they want something not in the initial set. Another follow-up.

Anyway, excited to get this rolling so LGTM!

@dennis-upbound
dennis-upbound requested a review from lsviben October 2, 2026 17:55
Nothing read it. It was required on every entry of every mapping, and no
code anywhere consumed the value - it existed to tell whoever wrote a
dashboard query whether to sum or average a metric.

It fails at that. Finding out what a metric says means fetching the
MetricMapping and reading the field, which is more work than deciding from
the metric itself, and for everything Modelplane ships the name already
answers it: a _total sums, a _ratio averages. So the field taxed every
author of a mapping to serve a reader who is better off without it.

Removing it now is cheap. Taking a required field out of a published API
later is not, and adding it back if something ever generates dashboards or
recording rules from mappings would be purely additive.

The reasoning it carried stays in the guide, because it is about the
collector rather than the field: Modelplane doesn't combine a deployment's
replicas, since a scrape of one replica is one batch and summing readings
taken at different moments is not the traffic that happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Dennis Ramdass <dennis@upbound.io>
@dennis-upbound
dennis-upbound requested review from bassam and removed request for lsviben October 2, 2026 18:23
@dennis-upbound
dennis-upbound merged commit 4616c91 into modelplaneai:main Oct 2, 2026
7 checks passed
@dennis-upbound dennis-upbound mentioned this pull request Oct 2, 2026
3 of 4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants