Collect metrics from every inference cluster - #470
Conversation
|
Docs preview: https://modelplane-docs-pr-470.vercel.app (ready once the site's Content workflow finishes) |
5cfcadf to
0e711f4
Compare
ad85067 to
7805fd1
Compare
| <!-- 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 >}} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| spec: | ||
| exporters: | ||
| otlphttp: | ||
| endpoint: https://otel.example.internal |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: gzipendpoint 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
left a comment
There was a problem hiding this comment.
I know a lot of this feedback is more design shaped. Sorry it's coming in so late.
| 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. |
There was a problem hiding this comment.
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_totalThere was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| # 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" |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
Agree, we should start with a newer version plus probably add a renovate to update as OTEL stuff release quite often
There was a problem hiding this comment.
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.
ac8673f to
2c8b399
Compare
494313c to
03d91f4
Compare
|
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 |
84d5bd9 to
82c789d
Compare
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>
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>
8d95b0e to
8d00dc8
Compare
|
@negz all 29 addressed and replied to in the threads. Seven commits on top; Nine of them were real bugs rather than doc fixes, and the ones worth knowing about:
The collector no longer counts towards Two things for you:
|
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
left a comment
There was a problem hiding this comment.
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]: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
what metrics are we trying to get with the substrate job atm?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
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>
lsviben
left a comment
There was a problem hiding this comment.
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!
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>
16cbc78 to
191b0bf
Compare
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
InferenceClusterand gives it two kinds to read. AMetricMappingsays what a component emits and what Modelplane calls it:from,to,fromUnitwhere the source is measured in something other than the target name claims, andlabelsandpartfor the series that need several of a component's metrics to become one of Modelplane's. ATelemetryDestinationsays where it goes:name,type,endpoint,secretRefandauthare 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 asecretRefis 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 aServingStack'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
replicalabel, and a query over a deployment combines them -sum by (deployment)for anything counted,avgfor 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_secondsarrives labelledrole=pickeronce the gateway routes a request, a sink'ssecretRefSecret written only on the control plane turns up inmodelplane-systemon the GPU cluster with the collector mounting it, and twoTelemetryDestinations 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.Each layer caught what the one before it couldn't. The collector's own
validatecaught a value rewrite rendered into a context that cannot reach values. The local two-cluster e2e, which--verifynow 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 ignoringprometheus.io/portbeside the annotation it honoured, and every series labelled with a generated name rather than the cluster's. Real GPU hardware caught everymodelplane_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 caughtscale_metricrefusing 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_secondsand 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-histogrambeside--enable-metrics, which the guide didn't say. And aMetricMappingstored before a field became required took the whole ServingStack toReconcileError: a CRD validates on write, not on what it already holds, so the stale object parses straight into aValidationErrorand fails the pipeline step. A telemetry object now gets dropped with a warning rather than stopping the fleet placing replicas.labelsandparthave no built-in using them, but both are exercised on a cluster. A mapping folding SGLang's prompt and generation counters onto one name givesmodelplane_tokens_total{direction="input"}398 and{direction="output"}607;part: Countover its latency histogram givesmodelplane_engine_requests_total12, matching the requests sent; and avaluesremap turnsengine_type="unified"intoengine_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:
nix flake check(or./nix.sh flake check) and made sure it passes.git commit -s.