fix(kubernetes-cost): make cost.enabled=true work end to end#2840
Conversation
Deploying the kubernetes-agent chart with `--set cost.enabled=true` against a
real cluster (DigitalOcean, 3 nodes) never produced a single cost row. Three
independent defects, each fatal on its own:
1. The poller's `window` bound was rejected by the engine. Date.toISOString()
always emits milliseconds, and neither OpenCost nor Kubecost accepts a
fractional RFC3339 bound in the Allocation API, so every fetch — on every
candidate path — failed with:
HTTP 400 Invalid 'window' parameter: illegal window:
2026-07-25T16:00:00.000Z,2026-07-25T17:00:00.000Z
Truncate to second precision. Windows are floored to the window grid
before this point, so nothing is lost. The existing test asserted the
millisecond form, which is how this shipped — it now pins the format the
engines actually parse.
2. The cost agent image was unpullable. `cost.agent.image.tag` fell back to
.Chart.AppVersion ("1.0.0"), but nothing bumps the chart's appVersion at
release time and the pipeline publishes `<major>.<minor>` plus `release` —
so the documented one-command install landed in ImagePullBackOff. Default
to `release`, the moving tag that always exists. logs.api.image.tag had
the same latent bug (reachable via preset=gke-autopilot / eks-fargate).
3. Ingest was unroutable through the standard ingress. Every other ingest
family has its own nginx location; /kubernetes-cost had none, so with
BILLING_ENABLED=true the catch-all `location /` sent the POST to the
marketing Home service and it 404'd. Add the location, and post to the
already-routed /telemetry prefix (the server mounts the route under both)
so the agent also reaches instances whose proxy predates this change.
Also list cost collection in NOTES.txt — it was the one enabled collector the
install output never mentioned.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
End-to-end verification now completeThe test instance had Cluster auto-registration — the cost ingest alone created the cluster record via Cluster Costs page (Kubernetes → cluster → Costs):
Project Costs page (Kubernetes → Costs): same tiles plus Spend by Cluster — Numbers reconcile at every layer: $0.08 workload + $0.15 idle = $0.23 total; idle 66% = 0.15/0.23; and the total matches the two shipped windows (39 rows + 38 rows) summed from the raw Allocation API responses. Sentinel So the verified chain is: OpenCost pricing → bundled Prometheus → cost poller → HTTP POST → telemetry queue → ClickHouse → aggregate queries → both Costs pages. One operational note for the docs, not a defect in this diff: a full agent install (logs + eBPF traces + metrics from a 3-node cluster) produced ~55k spans and ~117k metric points in under two hours, which was enough to overwhelm a small single-instance OneUptime deployment once ingestion was on. Worth a sizing note in the agent guide. |
Found by deploying the
kubernetes-agentchart with--set cost.enabled=trueagainst a real 3-node DigitalOcean cluster and following the data. It produced zero cost rows. Three independent defects, each fatal on its own.1. The poller never got a single allocation
Date.toISOString()always emits milliseconds, and neither OpenCost nor Kubecost accepts a fractional RFC3339 bound in the Allocation API. Every fetch, on every candidate path, failed:Confirmed by hand against the bundled OpenCost 1.121.0:
windowbound/allocation/compute/allocation2026-07-25T17:00:00.000Z,...illegal windowillegal window2026-07-25T17:00:00Z,...Truncated to second precision. The poller floors windows to the window grid before this point, so the millisecond field is always zero — nothing is lost.
The existing test asserted the millisecond form, which is how this shipped; it now pins the format the engines actually parse.
2. The cost agent image was unpullable
cost.agent.image.tagfell back to.Chart.AppVersion("1.0.0"). Nothing bumps the chart's appVersion at release time, and the pipeline publishes<major>.<minor>plusrelease— so the documented one-command install lands inImagePullBackOff. Now defaults torelease, the moving tag that always exists (same convention as the mainoneuptimechart).logs.api.image.taghad the identical latent bug, reachable viapreset=gke-autopilot/eks-fargate.3. Ingest was unroutable through the standard ingress
Every other ingest family has its own nginx location (
/otlp,/telemetry,/pyroscope,/probe-ingest,/server-monitor, ...)./kubernetes-costhad none, so withBILLING_ENABLED=truethe catch-alllocation /proxies to the marketing Home service and the POST 404s there. On a live billing-enabled instance:Both are the same Express route (
TELEMETRY_PREFIXES = ["/telemetry", "/"]), which is what pins this on the proxy rather than the app.Added the missing location (validated with
nginx -t), and pointed the poller at the/telemetryprefix so a new agent also reaches instances whose reverse proxy predates this change.Also
Cost was the one enabled collector
NOTES.txtnever mentioned — added, distinguishing the bundled engine from an externalcost.engine.url.Verification
Deployed against a 3-node DigitalOcean cluster (
s-4vcpu-8gb, k8s 1.35.1) with the bundled engine.kubernetes-nodes-cadvisorvia the API-server node proxy, andopencost) up./model/allocation404s,/allocation/computewins.shipped cost window windowStart=18:00 windowEnd=19:00 rows=39.mapAllocationToRowover that window: 39 rows, all with valid second-precision windows, namespaces__idle__/kube-system/oneuptime/wordpress,__idle__sentinel mapped correctly, per-component costs and efficiency populated. Totals are internally consistent — $0.137/hr against 3 x $0.0714/hr of capacity scaled by the ~64% of the window Prometheus had history for.kubecost_network_*_egress_cost) are Kubecost-only and no dashboard panel references them.KubernetesCostAgent: 28/28 tests pass. eslint andtscclean.Not verified here: ClickHouse persistence and the Costs UI rendering. The test instance runs with
DISABLE_TELEMETRY_INGESTION=true, so every ingest endpoint returns 200 and drops the payload (a malformed body with a valid key returns 200 instead of 400). The 39 rows above were accepted and dropped. That is an instance setting, not a code path in this diff.