Repository navigation
feat(otel-ci): add vLLM model-server metrics solution - #353
Aakash-Dantre wants to merge 1 commit into
Conversation
| ## Namespace where Karpenter is deployed. | ||
| namespace: "kube-system" | ||
| ## Optional Prometheus scraping of vLLM model-server metrics (vllm:* series) | ||
| ## from KServe InferenceService pods, published through the OTEL CI pipeline. |
There was a problem hiding this comment.
Answered on the vLLM scrape job: #353 (comment) — short version, you're right, and I'd like to add a prometheus.io/scrape fallback so this isn't KServe-only.
3ba49bb to
f93c7cd
Compare
e50d1ae to
1e005ec
Compare
2574ee0 to
ba94333
Compare
| names: | ||
| - {{ .Values.otelContainerInsights.solutions.vllm.namespace }} | ||
| {{- end }} | ||
| relabel_configs: |
There was a problem hiding this comment.
don't we need
- source_labels: [__meta_kubernetes_pod_node_name]
regex: ${env:K8S_NODE_NAME}
action: keep
like csi-node job?
There was a problem hiding this comment.
Good catch. Fixed in 32c4649 on both jobs.
It was a real bug, not just inconsistency: without it every agent scraped every matching pod cluster-wide. One vLLM pod was emitting three copies of every vllm:* series under three k8s.node.name values — 3x cost, and sum() 3x high.
| prometheus/cw_k8s_ci_v0_knative_dataplane: | ||
| config: | ||
| scrape_configs: | ||
| - job_name: knative-dataplane |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in 32c4649 along with the vLLM job.
| - **Errors are not observable.** vLLM never sets span status, and only successfully | ||
| finished requests are traced at all — aborts, timeouts and errors emit no span. So | ||
| Application Signals `Error` and `Fault` stay at zero regardless of what the engine is | ||
| doing. Upstream fix: [vllm#32162](https://github.com/vllm-project/vllm/pull/32162). |
There was a problem hiding this comment.
this PR has been open for 8 months. I asked the author about the status. Can you get that CR to the finish line?
There was a problem hiding this comment.
I'll pick it up — blocked on conflicts and pre-commit, not review. It fixes span status and aborted requests (so Application Signals Error/Fault become real), but not gen_ai.response.model, which is vllm-project/vllm#51186.
| On vLLM 0.11 also set `OTEL_EXPORTER_OTLP_TRACES_INSECURE=true`; 0.26 needs nothing. | ||
| `--collect-detailed-traces` is not needed — it does nothing for traces on the V1 engine. | ||
|
|
||
| Spans are billed per trace and vLLM emits one per request with no sampling of its own, so on |
There was a problem hiding this comment.
instead of disabling the whole thing, should we enable it with 10% sampling rate
There was a problem hiding this comment.
Agreed, but the chart can't set it — OTEL_TRACES_SAMPLER is an env var on the engine container. I'll put traceidratio / 0.1 in the README next to --otlp-traces-endpoint.
Did "the whole thing" mean traces.enabled: false? That's off by default because without Transaction Search the endpoint 400s and the agent drops every span silently.
| {{- end }} | ||
|
|
||
| {{- if and .Values.otelContainerInsights.solutions.enabled .Values.otelContainerInsights.solutions.kserve.controlPlane.enabled }} | ||
| # Keep only the KServe operator health families; drop go_*/process_* runtime noise. |
There was a problem hiding this comment.
you can do the same to vllm if needed.
There was a problem hiding this comment.
Already there — filter/cw_k8s_ci_v0_vllm_keep at _otel-container-insights-config.tpl:470, wired in at :1220.
| default Istio/Knative path propagates one, so there is no end-to-end trace and no | ||
| visibility into gateway queueing. | ||
|
|
||
| ### Enabling tracing on the engine |
There was a problem hiding this comment.
it's okay if you want to submit another PR, but we can and should also get kserve traces https://knative.dev/docs/serving/observability/metrics/collecting-metrics/#enabling-metric-collection
There was a problem hiding this comment.
Yes, as a follow-up. The valuable half is queue-proxy propagating W3C context into the engine — vLLM honours an inbound traceparent but nothing sends one today, so every trace is a single orphan span. I'll open an issue and link it.
| error_mode: ignore | ||
| metrics: | ||
| metric: | ||
| - 'not IsMatch(name, "^(controller_runtime_|workqueue_|rest_client_|leader_election_).*")' |
There was a problem hiding this comment.
doesn't this drop a lot of other metrics?
https://knative.dev/docs/serving/observability/metrics/serving-metrics/#workload-metrics
https://github.com/kserve/kserve/blob/master/python/kserve/kserve/metrics.py#L20
There was a problem hiding this comment.
Request-level telemetry is instead sourced from the Knative queue-proxy via knative.dataPlane, but that doesn't exist in RawDeployment mode afaik, so those users lose serving-layer request metrics entirely.
There was a problem hiding this comment.
Two things; the second is a real gap.
Neither link is emitted by the kserve-controller-manager — metrics.py is the KServe Python model server (predictor pod), and the Knative page is queue-proxy. I scraped the controller-manager directly: 158 families in, 21 kept, the rest go_*/process_*/promhttp_*.
Your follow-up is the real issue though: RawDeployment has no queue-proxy, so those users get no request telemetry from this PR. For vLLM the http_* series cover it (already kept by vllm_keep); for other runtimes we'd need a predictor scrape. Here or follow-up?
There was a problem hiding this comment.
The KServe filter keeps metric names that KEDA deliberately drops. It keeps workqueue_* and rest_client_*. KEDA's filter/cw_k8s_ci_v0_keda_drop_non_keda drops exactly those because "These conflict with the apiserver pipeline". Either
We should probably decide in general why we drop these metrics or why we keep them. I'm not sure I fully understand the reason to drop them.... these are apiserver client metrics, they existing across a whole range of sources... the metric name is not the only denotation, its the labels
There was a problem hiding this comment.
Went with the KEDA behaviour for consistency: KServe now keeps only controller_runtime_* and leader_election_* (#389). I agree we should decide this once for all solutions. The labels do tell sources apart, so keeping them could be fine. Happy to follow up in a separate PR if you'd rather keep them everywhere.
| relabel_configs: | ||
| # keep only KServe InferenceService pods (match the label VALUE, like | ||
| # the ebs-csi job keys on pod_label_app) | ||
| - source_labels: [__meta_kubernetes_pod_label_serving_kserve_io_inferenceservice] |
There was a problem hiding this comment.
iiuc this job discovers targets by the serving.kserve.io/inferenceservice label and container name kserve-container, both of which only exist when the pod is reconciled from a KServe InferenceService. a plain vLLM Deployment (container vllm, no such label) gets zero targets and the vllm solution silently collects nothing.
if the intention is to vLLM-on-KServe specifically note in values.yaml that solutions.vllm requires a KServe InferenceService.
if the intention is vLLM generally, could we add a prometheus.io/scrape annotation fallback or any other fallback mechanism?
I prefer we do VLLM generally but not sure if there would be specific things would be required for rayserve
There was a problem hiding this comment.
Correct, and same as your Aug 26 comment — let me answer it.
A plain vLLM Deployment matches neither the label nor kserve-container, so it gets zero targets silently.
I'd rather do it your way. Proposal: scrape a pod if it's a KServe predictor or has prometheus.io/scrape: "true" + prometheus.io/port. Zero-config stays for KServe, plain Deployments get a standard opt-in.
RayServe also exposes ray_serve_* on the dashboard-agent port, so it needs its own solution rather than riding on this. OK to leave it out?
be5a78c to
96e901d
Compare
c4df29c to
aa15319
Compare
aa15319 to
30cd213
Compare
Thats not a good reason not to add tests. We cant accept this PR without a suite of integration tests. I'm also a bit concerned about the complexity of some of the prometheus scrape configs in here, how can we be sure they are matching only what we want them to. |
|
The PR description doesn't give me enough context to understand the architecture of kserve and knative so I can meaningfully understand exactly what we're trying to scrape, this seems pretty complex. Also idelally we would split kserve and knative into separate PRs to reduce the noise |
| static_configs: | ||
| - targets: | ||
| - kserve-controller-manager-service.{{ .Values.otelContainerInsights.solutions.kserve.controlPlane.namespace }}.svc:8443 | ||
| {{- end }} |
There was a problem hiding this comment.
Karpenter and KEDA find their pods by label, so a cluster without them scrapes nothing, but the KServe scrape points at a hard-coded Service address, so every cluster without KServe gets a scrape that fails every 30s and polluting the logs. Can't we use service discovery here too?
There was a problem hiding this comment.
Fixed (now in #389). The controller is found by its control-plane: kserve-controller-manager label, like Karpenter and KEDA. No KServe means no target and no errors.
| tls_config: | ||
| insecure_skip_verify: true | ||
| bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token |
There was a problem hiding this comment.
I think we probably didn't do it like this for karpenter and keda... But I think we need to find a solution to let customers adopt our TLS cert for secure communications as an optional feature... Not released yet but this is what I was thinking for OTLP cases: #317
Basicaly OTEL has env vars you can set and the OTLP exporter in whatever the application is will use your certs. We probably need to find the equivalent solution for Prometheus endpoints.
There was a problem hiding this comment.
Agree this is worth having, but I'd keep it out of these PRs. Proposal for a follow-up:
- an opt-in
tlsblock per solution (or one undersolutions), using the chart's existing CA and client cert by default, or a customer CA bundle; - a pod annotation to scrape a target over HTTPS;
- for servers that need a certificate, cert-manager issuing from the chart's CA.
vLLM supports TLS natively (--ssl-certfile/--ssl-keyfile). KServe's proxy uses a self-signed cert, so verification stays skipped there until a CA is configured. I can write this up as an issue if that works for you.
| - metricstarttime/cw_k8s_ci_v0 | ||
| - transform/cw_k8s_ci_v0_set_scope_kserve_controlplane | ||
| - transform/cw_k8s_ci_v0_set_cluster_name | ||
| - resourcedetection/cw_k8s_ci_v0 |
There was a problem hiding this comment.
I think this sets the availability zone to that of where the cluster scraper is not the kserve endpoint or knative one. Karpenter and KEDA each define their own detector with the AZ turned off I believe.
| - otlphttp/cw_k8s_ci_v0_cwotel | ||
| {{- end }} | ||
| {{- if and .Values.otelContainerInsights.solutions.enabled .Values.otelContainerInsights.solutions.kserve.controlPlane.enabled }} | ||
| metrics/cw_k8s_ci_v0_kserve_controlplane: |
There was a problem hiding this comment.
These two pipelines also skip the steps Karpenter/KEDA use to attach pod/namespace/node and workload details (groupbyattrs + *_promote + k8sattributes/cw_k8s_ci_v0_pod + set_workload). Is that on purpose?
There was a problem hiding this comment.
Not on purpose, fixed. Both now use the same steps as Karpenter/KEDA (groupbyattrs → promote → k8sattributes → set_workload), so series carry the controller's pod and Deployment. The Knative data plane also gets the revision's Deployment.
| scrape_interval: {{ .Values.otelContainerInsights.metricResolution }} | ||
| scrape_timeout: {{ include "otel-container-insights.scrapeTimeout" . }} | ||
| metrics_path: /metrics | ||
| kubernetes_sd_configs: |
There was a problem hiding this comment.
My agent claims: The Knative data-plane scrape watches every pod in the cluster from every node. Can you look into this?
There was a problem hiding this comment.
Right, fixed (now in #390). Discovery uses a field selector for the node and a label selector for serving.knative.dev/revision. The API server applies both, so each agent only watches the revision pods on its own node.
| # never renames it. | ||
| - source_labels: [__meta_kubernetes_pod_label_serving_kserve_io_inferenceservice, __meta_kubernetes_pod_container_name, __meta_kubernetes_pod_container_image] | ||
| separator: ";" | ||
| regex: '.+;kserve-container;.*|(?i)[^;]*;[^;]*;.*vllm.*' |
There was a problem hiding this comment.
Did we measure the cpu cost of all these regexes? We should evaluate this against the new resource consumption test harness - I think its in a non-blocking mode right now
There was a problem hiding this comment.
Measured. I compared solutions on vs. off on a 4-node cluster with the same load. The difference in agent CPU and memory is within noise: CPU even came out lower on 4 of 5 pods, and the agents use 10–40 millicores anyway (numbers in the #353 description). The regexes run once per discovered target when discovery refreshes, not per sample. The repo's resource harness compares against a fixed baseline calibrated on t3.medium nodes, so its pass/fail doesn't mean anything on this cluster; I can run it in its own setup if you'd like.
| error_mode: ignore | ||
| metrics: | ||
| metric: | ||
| - 'not IsMatch(name, "^(controller_runtime_|workqueue_|rest_client_|leader_election_).*")' |
There was a problem hiding this comment.
The KServe filter keeps metric names that KEDA deliberately drops. It keeps workqueue_* and rest_client_*. KEDA's filter/cw_k8s_ci_v0_keda_drop_non_keda drops exactly those because "These conflict with the apiserver pipeline". Either
We should probably decide in general why we drop these metrics or why we keep them. I'm not sure I fully understand the reason to drop them.... these are apiserver client metrics, they existing across a whole range of sources... the metric name is not the only denotation, its the labels
| A vLLM server built from an image without `vllm` in its name opts in with a pod label: | ||
|
|
||
| ```yaml | ||
| metadata: | ||
| labels: | ||
| cloudwatch.aws/vllm: "true" | ||
| ``` |
There was a problem hiding this comment.
Is this really necessary? My main concern is we're establishing a new API/pattern to mark containers for scraping via CWA which we currently don't have. That has broader implications, we'll need to adopt this pattern in other places too and we're married to whatever nomenclature we decide here. Is it strictly necessary?
There was a problem hiding this comment.
And honestly, this sounds like a another version of the pod/service monitor interface. I'm not sure we want to go down this route
There was a problem hiding this comment.
Agreed, removed. Opt-in now uses the standard prometheus.io/scrape, prometheus.io/port and prometheus.io/path annotations, so we add no new API. Since those annotations aren't vLLM-specific, we only keep vllm:* from pods found this way.
There was a problem hiding this comment.
Agreed, we're not adding one. With the standard annotations, the only rule we own is "find vLLM", and that needs no configuration in the common cases: KServe pods, and images named vllm.
| # vLLM model-server metrics, scraped per node like the ebs-csi-node job. | ||
| prometheus/cw_k8s_ci_v0_vllm: | ||
| config: | ||
| scrape_configs: |
There was a problem hiding this comment.
An agent suggests this, check it out:
KServe's runtimes already state where their metrics are, so the chart doesn't need to guess from names. The stock ClusterServingRuntimes carry spec.annotations saying so:
# kserve-huggingfaceserver
prometheus.kserve.io/port: '8080'
prometheus.kserve.io/path: "/metrics"
# kserve-tritonserver
prometheus.kserve.io/port: '8002'
I read these from kserve/kserve master config/runtimes/. A runtime's spec.annotations get applied to the predictor pod. I believe that, but haven't confirmed it on a live pod; it's one kubectl get pod -o yaml for the author to check. That gives three options:
KServe pods: scrape whatever port and path the runtime annotations give. Every runtime gets the right endpoint with no configuration, including Triton on 8002, which a guessed default gets wrong. There's no port defaulting and no multi-port ambiguity, and the "is this KServe?" check comes down to "has the annotation". Whether the metrics belong to vLLM is then up to the keep filter, which only keeps vllm:* and http_*, so it no longer matters which image a runtime uses.
Caveat: if a cluster sets enable-prometheus-scraping in KServe's metrics aggregator, KServe rewrites the pod to standard prometheus.io/* annotations pointing at the queue-proxy aggregation port. The relabel should prefer prometheus.kserve.io/* and fall back to that. I haven't checked how the aggregator interacts with this.
Everything else: the standard prometheus.io/scrape, prometheus.io/port and prometheus.io/path annotations. This is oberkem's suggestion and the usual Kubernetes convention. The user names exactly one port, so it replaces both the invented cloudwatch.aws/vllm label and the "every declared port" rule. The image-name check can stay as a convenience for vllm/vllm-openai with no annotations.
Prometheus Operator PodMonitor or ServiceMonitor via the target allocator. This is the most flexible option, but I'd advise against it here. The chart only wires the target allocator into the custom-agent path, not OTel Container Insights. It would also need the Prometheus Operator CRDs and a cluster-wide allocator, which is a much bigger change than this PR.
There was a problem hiding this comment.
Done. On KServe pods, prometheus.kserve.io/port and path now win, then the declared port, then 8080. We ignore prometheus.io/* on KServe pods because KServe sets them itself. They would also pick up the queue-proxy, which serves the model's metrics a second time. Testing caught exactly this, and #779 now checks it.
30cd213 to
f6e3b11
Compare
Agreed. Added end-to-end tests in aws/amazon-cloudwatch-agent-test#779, following the KEDA/Karpenter suites. A terraform setup builds an EKS cluster with KServe, Knative and real vLLM (on CPU), plus a few pods that each test one scrape rule. The Go tests then check in CloudWatch what was collected and how it is labelled. They also check what must not be collected: no duplicate series, nothing from a non-vLLM pod, no AZ on cluster-scraper series. All pass. Each chart PR also has a |
Split into three PRs: #353 (vLLM), #389 (KServe), #390 (Knative). Each description now has a diagram of which agent scrapes what, and how targets are found. It also lists which metrics are kept and the pipeline steps. |
miconeilaws
left a comment
There was a problem hiding this comment.
Some clarifications requested on slack.
f6e3b11 to
669e8c6
Compare
| - set(resource.attributes["inferenceservice"], attributes["inferenceservice"]) where attributes["inferenceservice"] != nil | ||
| - set(resource.attributes["aws.service.type"], attributes["aws_service_type"]) where attributes["aws_service_type"] != nil |
There was a problem hiding this comment.
What are these attributes? They don't seem part of any semconv.
| # Temporary label, moved to the aws.service.type resource attribute by | ||
| # transform/cw_k8s_ci_v0_vllm_promote. ai_inference unless the pod sets | ||
| # its own aws.service.type label (e.g. ai_training). | ||
| - target_label: aws_service_type |
There was a problem hiding this comment.
It was only a scrape-time label to carry the value to the promote step. It's gone: aws.service.type is now set directly in the pipeline.
| - source_labels: [__meta_kubernetes_pod_label_aws_service_type] | ||
| regex: "(.+)" | ||
| target_label: aws_service_type | ||
| action: replace |
There was a problem hiding this comment.
Why? Is this a supported and documented contract for Omni already?
There was a problem hiding this comment.
Removed. The chart no longer reads an aws.service.type (or any other) label from the pod; the value is always ai_inference, which the Omni experience needs to identify AI inference telemetry. vLLM only serves inference, even inside training loops, so there's nothing for customers to override.
Scrape vLLM's own Prometheus metrics (vllm:* and http_*) from the agent on each node. Servers are found without configuration: the kserve-container of a KServe InferenceService's predictor pods, or any container running a vLLM image. The chart reads no scrape labels or annotations of its own from pods; on KServe pods the serving runtime's prometheus.kserve.io/port and path are honoured. Series are attributed to the pod and workload. service.name is the InferenceService (from KServe's own pod label, as extracted by k8sattributes), else the workload, else the pod. Every series carries aws.service.type = ai_inference.
669e8c6 to
6c08e25
Compare
From slack. Changes in #353, in simple words
|
|
Opened #398 with the same change (rebased onto |
Summary
Adds
otelContainerInsights.solutions.vllm: the vLLM model server's own Prometheus metrics (vllm:*engine metrics and the API server'shttp_*), collected by the CloudWatch agent DaemonSet and sent to CloudWatch over OTLP. On by default; it finds vLLM servers without configuration and does nothing on clusters that have none.Part of the LLM model-serving solutions, alongside KServe control-plane metrics (#389) and Knative Serving metrics (#390). End-to-end tests: aws/amazon-cloudwatch-agent-test#779.
Background: how LLMs are served on Kubernetes
Three separate open-source projects are involved. This PR scrapes the first; #389 and #390 cover the other two.
/v1/chat/completions); exposes/metricson the same portkserve-controller-managerqueue-proxysidecar, scales on request concurrency, including to zeroqueue-proxyand, when scaled to zero, the activatorAn InferenceService is a KServe custom resource the customer applies to say "serve this model":
KServe and Knative are installed once per cluster by the platform team. The InferenceService does not create them; it is a request their (already running) controllers fulfil.
Creation path vs. request path
vllm serve <model>, imagevllm/vllm-openai), and requests go Service → vLLM.Either way the vLLM pod is the customer's workload in the customer's namespace. The chart never deploys it; it only scrapes it.
Where it runs, and which agent scrapes what
This PR scrapes only the vLLM process (marked #353): the
kserve-containerin KServe pods, and vLLM containers elsewhere. The DaemonSet agent on each node scrapes the vLLM pods on its own node. KServe's controller and Knative's components and queue-proxy are covered by #389 and #390.Questions this answers
Is this scraping random customer application containers? No. It scrapes only customer model-server containers that can be identified:
kserve-container, in the predictor pods it creates for an InferenceService (labelsserving.kserve.io/inferenceserviceandcomponent: predictor);vllm.No other containers are touched, and the chart reads no scrape labels or annotations of its own from customer pods. A vLLM server built from a custom-named image has no reliable signal, so it is not scraped automatically; a PodMonitor (feat(amazon-cloudwatch-observability): ServiceMonitor/PodMonitor scraping via the Target Allocator #376) is the general way to opt such workloads in.
Does vLLM have a control plane? No. Each vLLM process serves on its own. The control planes in this picture belong to KServe and Knative.
Why the node agent and not the cluster-scraper? vLLM pods run on many (GPU) nodes. Each node agent watches only its own node's pods, using an API-server field selector, so the load is spread out and grows with pods per node, not per cluster.
What is scraped
Which containers. A container is a target if either applies:
kserve-containerin a pod with the labelsserving.kserve.io/inferenceserviceandcomponent: predictor, any imageprometheus.kserve.io/port(set by the serving runtime), else the declared port, else 8080prometheus.kserve.io/path, else/metricsvllm(vllm/vllm-openai, mirrors, CPU builds)/metricskserve-container. Thecomponent: predictorlabel restricts the match to the model server, excluding transformers and explainers.kserve-containeris scraped, never the queue-proxy sidecar, which proxies the model server's/metrics.prometheus.kserve.io/*, and only on pods already matched. They pick the port and path; they don't select pods.metricResolution(default 30s).Which metrics. Kept:
vllm:*(scheduler state, KV cache, prompt and generation tokens, request outcomes, TTFT, inter-token, queue and end-to-end latency histograms) andhttp_*(request counts and latency by handler and status). Dropped:python_*,process_*,*_created, and summaries, which the CloudWatch OTLP endpoint does not accept.Pipeline
metrics/cw_k8s_ci_v0_vllmon the node agent:prometheus/cw_k8s_ci_v0_vllm: discovery and relabelling as above; target labelspodandnamespace.filter/cw_k8s_ci_v0_scrape_metadata,filter/cw_k8s_ci_v0_vllm_keep: drop scrape metadata and the families above.transform/cw_k8s_ci_v0_set_unit,metricstarttime/cw_k8s_ci_v0,transform/cw_k8s_ci_v0_set_cluster_name: shared Container Insights steps.transform/cw_k8s_ci_v0_vllm_promote: copiespodandnamespaceto resource attributes, so the next step can associate the pod, and setsaws.service.type.k8sattributes/cw_k8s_ci_v0_pod: pod, ReplicaSet and Deployment metadata.resourcedetection,cloud.resource_id, node metadata, scope, workload: shared steps.set_workloadresolvesk8s.workload.nameto the Deployment (or StatefulSet, DaemonSet, Job), not the ReplicaSet.transform/cw_k8s_ci_v0_vllm_service_name: setsservice.nameandservice.namespace.awsattributelimit,batch, thenotlphttp/cw_k8s_ci_v0_metrics_dest(SigV4) to the CloudWatch OTLP endpoint.Resulting series
github.com/vllm-project/vllm, withcloudwatch.pipeline = vllm.service.name: the InferenceService name (KServe's own pod label, read ask8s.pod.label.serving.kserve.io/inferenceservice), else the workload, else the pod. The InferenceService comes first because a Serverless revision's Deployment name changes on every redeploy (<isvc>-predictor-00004-deployment). No custom attribute key is added for it.aws.service.type = ai_inference, set by the chart on every series. This is the CloudWatch resource attribute that classifies the source, which the CloudWatch (Omni) experience uses to identify AI inference telemetry. vLLM only serves inference, including when it generates rollouts for a training loop, so the value is fixed and nothing is read from the pod.k8s.pod.name,k8s.namespace.name,k8s.workload.*,k8s.node.name, and pod labels such ask8s.pod.label.serving.kserve.io/inferenceservice) and cloud (cloud.region,cloud.availability_zone,cloud.resource_id,host.*of the node) attributes, plus vLLM's own labels (model_name,finished_reason,handler, ...).Configuration
Testing
tests/vllm_pipeline_shape.sh(make helm-vllm-shape, part ofmake all) renders the chart and checks, on the parsedotelConfig:prometheus.io/*,cloudwatch.aws/*oraws.service.typefrom podsaws.service.type, andservice.namefrom KServe's pod label with no custominferenceservicekeymake helm-lint helm-flag-matrix helm-yaml-injection helm-neuron-shapepass.test/otel/solutions/vllmin test(otel): add vLLM, KServe and Knative solution integration tests aws/amazon-cloudwatch-agent-test#779, on an EKS cluster with KServe Serverless, a CPU vLLM InferenceService, a plain vLLM Deployment, two annotated pods that must not be scraped (a vLLM mock from a custom-named image and a non-vLLM exporter) and load. The 9 tests cover:aws.service.typevllm/vllm-openaiStatefulSet and an annotated pod. Verified in CloudWatch: both servers are scraped with one series per pod, the annotated pod is not scraped, a pod labelaws.service.type: ai_trainingis ignored, and there are no agent errors.Resource cost
A/B on a 4-node cluster (3 vLLM servers), same nodes and load, fresh agent pods, 15–20 min averages. Solutions off → on:
No measurable difference: the deltas go both ways and are within run-to-run noise. Relabel rules run once per discovered target when discovery refreshes, not per sample.
PR Checklist
makechecks pass locally (helm-lint, flag matrix, YAML injection, shape tests)test/otel/solutions/vllm)tests/vllm_pipeline_shape.sh)otelContainerInsights.solutions.vllmvalue, on by default