Problem statement
Radar's cost view requires Prometheus metrics emitted by OpenCost or Kubecost. Kubecost Enterprise 3.x in federated mode does not emit Prometheus metrics at all — cost data flows from the finopsagent to S3, then to the aggregator, which exposes it only via REST API. Clusters running this setup see:
Cost metrics not found — Prometheus is available, but no OpenCost or Kubecost metrics were detected
There is no workaround: the metrics do not exist in Prometheus, so pointing Radar at a working Prometheus instance still produces the same error.
Proposed solution
Radar auto-discovers Kubecost and OpenCost services in the connected cluster and queries /allocation and /assets directly via REST. No configuration is needed — on startup, Radar probes well-known services in priority order, connects in-cluster when possible, and falls back to programmatic port forwarding (the same mechanism used for Prometheus discovery) when running on a developer's laptop. Port forwarding is torn down and re-established transparently during context switches. The existing Prometheus path is unchanged and used as a fallback when no cost service is found.
Discovery order:
kubecost/kubecost-aggregator:9008 — Kubecost Enterprise 3.x. Port 9008 serves the allocation API without authentication; port 9004 (the primary API port) enforces SAML/JWT.
opencost/opencost:9003 — self-hosted OpenCost
kubecost/kubecost-cost-analyzer:9090 — older Kubecost 1.x
All four cost surfaces populate when a service is found: namespace summary, workload breakdown, cost trend chart, and node costs.
Files changed
New files
pkg/opencost/http_transport.go
Implements the Transport interface against a fixed base URL. The interface (pkg/opencost/transport.go) mirrors pkg/prom.Transport so the same shape works across both subsystems.
type HTTPTransport struct {
baseURL string
Headers map[string]string
httpClient *http.Client
}
func (t *HTTPTransport) Do(ctx context.Context, method, path string, params url.Values) ([]byte, error) {
rawURL := t.baseURL + path
if len(params) > 0 { rawURL += "?" + params.Encode() }
req, _ := http.NewRequestWithContext(ctx, method, rawURL, nil)
for k, v := range t.Headers { req.Header.Set(k, v) }
// returns body, or error on HTTP >= 400
}
pkg/opencost/workloads_rest.go
Aggregates by controller filtered to a single namespace. NamespaceFilter (the client-side post-filter in ComputeCostSummary) is deliberately not set — Kubecost doesn't populate Properties["namespace"] when aggregate=controller, so it would drop every row. The server-side filter param is sufficient.
func ComputeWorkloadsFromREST(ctx context.Context, client *RESTClient, namespace string) *WorkloadCostResponse {
summary := ComputeCostSummary(ctx, client, SummaryOptions{
Aggregate: "controller",
Filter: `namespace:"` + namespace + `"`,
Window: "1h",
})
// splitControllerName() handles both "kind:name" (OpenCost) and plain "name" (Kubecost port 9008)
}
pkg/opencost/trend_rest.go
Calls /allocation with a step parameter to get time-bucketed data, then pivots to per-namespace series. Top 8 by latest value are returned individually; the rest are summed into an "other" bucket.
func restTrendParams(rangeStr string) (window, step, label string) {
switch rangeStr {
case "6h": return "6h", "15m", "6h"
case "7d": return "7d", "6h", "7d"
default: return "24h", "1h", "24h"
}
}
pkg/opencost/nodes_rest.go
Calls /assets with window=24h (window=1h often returns null; the node aggregate param is not supported on port 9008). Merges duplicate entries across time buckets by averaging. Kubecost normalizes label keys (dots/slashes → underscores), so both forms are checked:
instanceType := a.Labels["node.kubernetes.io/instance-type"]
if instanceType == "" { instanceType = a.Labels["node_kubernetes_io_instance_type"] }
if instanceType == "" { instanceType = a.Labels["beta_kubernetes_io_instance_type"] }
internal/opencost/global.go
Thread-safe global REST client. Nil until auto-discovery succeeds; handlers fall through to Prometheus when nil.
internal/opencost/portforward.go
Dedicated cost port-forward singleton. The shared internal/portforward singleton is already used by Prometheus/traffic and can't be reused. Uses pkg/portforward primitives (FindPodForService, FindFreePort, RunPortForward) directly, with a 10s ready timeout and context-aware teardown.
internal/opencost/discovery.go
Probes known services in order, tries in-cluster DNS first, falls back to port-forward:
var knownCostServices = []costCandidate{
{"kubecost", "kubecost-aggregator", 9008},
{"opencost", "opencost", 9003},
{"kubecost", "kubecost-cost-analyzer", 9090},
}
func AutoDiscover(ctx context.Context, client kubernetes.Interface, config *rest.Config, contextName string) {
for _, cand := range knownCostServices {
// 1. try http://<service>.<namespace>.svc.cluster.local:<port>
if probeAllocation(ctx, clusterAddr) { initRESTClientLocked(clusterAddr, nil); return }
// 2. programmatic port-forward
localPort, err := startCostPortForward(ctx, client, config, cand.Namespace, cand.Service, cand.Port, contextName)
localAddr := fmt.Sprintf("http://localhost:%d", localPort)
if probeAllocation(ctx, localAddr) { initRESTClientLocked(localAddr, nil); return }
stopCostPortForward()
}
}
// probeAllocation: GET /allocation?window=1d&aggregate=namespace, 3s timeout.
// /healthz is not used — port 9008 doesn't expose it.
// ResetAndStop: stops port-forward + clears global client. Called on context switch.
Modified files
pkg/opencost/rest_client.go
Added Asset, AssetsResponse, GetAssets() (calls /assets, no aggregate param — port 9008 rejects aggregate=node), and GetData() (filters nil buckets — Kubecost returns data:[null] when a window has no processed data).
pkg/opencost/compute.go
Added window fallback: when window=1h returns null data (Kubecost's aggregator may not have the last hour processed), retries with window=1d. The existing windowHours() normalization already divides all costs by window duration before returning, so hourly rates are correct regardless of which window was used.
windows := []string{opts.Window}
if opts.Window == "1h" {
windows = append(windows, "1d")
}
for _, w := range windows {
resp, _ = client.GetAllocation(ctx, AllocationOptions{Window: w, ...})
if hasData(resp.GetData()) { break }
}
internal/opencost/handlers.go
Each handler (handleSummary, handleWorkloads, handleTrend, handleNodes) checks getRESTClient() first and uses the REST path when non-nil, otherwise falls through to the existing Prometheus path.
internal/k8s/context_manager.go
Added CostResetFunc, CostReinitFunc types and RegisterCostFuncs(), following the same pattern as RegisterPrometheusFuncs.
internal/k8s/subsystems.go
costReinitFn runs in parallel with Prometheus/traffic/helm in InitAllSubsystems. costResetFn runs first in ResetAllSubsystems so the port-forward closes before the cluster client tears down.
internal/app/bootstrap.go
Registers cost callbacks — auto-discovery runs in a background goroutine on each cluster connect, reset tears down the port-forward on context switch:
k8s.RegisterCostFuncs(kubecostpkg.ResetAndStop, func() error {
go kubecostpkg.AutoDiscover(context.Background(), k8s.GetClient(), k8s.GetConfig(), k8s.GetContextName())
return nil
})
pkg/prom/discovery.go
Added kubecost-prometheus-server to well-known Prometheus locations and boosted score for kubecost-named services — covers older Kubecost 1.x setups that bundle a Prometheus server.
web/src/components/cost/CostView.tsx
Updated error messages to name both tools in all three error states (no_prometheus, no_metrics, fallback).
Alternatives considered
- Scraping Kubecost's
/metrics endpoint and routing through an existing Prometheus instance — not viable in federated mode; the aggregator serves ClickHouse-backed data with no Prometheus export.
- A
--costs-url manual override flag — prototyped and removed in favor of full auto-discovery; adds surface area without benefit since the well-known service list covers all real deployments.
Additional context
Observed on Kubecost Enterprise 3.x with serviceMonitor.enabled: false and localStore.enabled: false on EKS. Port 9008 on the aggregator serves the same allocation data as port 9004 but without the SAML/JWT authentication enforced on the primary API port. The same REST path works for self-hosted OpenCost (port 9003).
Problem statement
Radar's cost view requires Prometheus metrics emitted by OpenCost or Kubecost. Kubecost Enterprise 3.x in federated mode does not emit Prometheus metrics at all — cost data flows from the
finopsagentto S3, then to theaggregator, which exposes it only via REST API. Clusters running this setup see:There is no workaround: the metrics do not exist in Prometheus, so pointing Radar at a working Prometheus instance still produces the same error.
Proposed solution
Radar auto-discovers Kubecost and OpenCost services in the connected cluster and queries
/allocationand/assetsdirectly via REST. No configuration is needed — on startup, Radar probes well-known services in priority order, connects in-cluster when possible, and falls back to programmatic port forwarding (the same mechanism used for Prometheus discovery) when running on a developer's laptop. Port forwarding is torn down and re-established transparently during context switches. The existing Prometheus path is unchanged and used as a fallback when no cost service is found.Discovery order:
kubecost/kubecost-aggregator:9008— Kubecost Enterprise 3.x. Port 9008 serves the allocation API without authentication; port 9004 (the primary API port) enforces SAML/JWT.opencost/opencost:9003— self-hosted OpenCostkubecost/kubecost-cost-analyzer:9090— older Kubecost 1.xAll four cost surfaces populate when a service is found: namespace summary, workload breakdown, cost trend chart, and node costs.
Files changed
New files
pkg/opencost/http_transport.goImplements the
Transportinterface against a fixed base URL. The interface (pkg/opencost/transport.go) mirrorspkg/prom.Transportso the same shape works across both subsystems.pkg/opencost/workloads_rest.goAggregates by controller filtered to a single namespace.
NamespaceFilter(the client-side post-filter inComputeCostSummary) is deliberately not set — Kubecost doesn't populateProperties["namespace"]whenaggregate=controller, so it would drop every row. The server-sidefilterparam is sufficient.pkg/opencost/trend_rest.goCalls
/allocationwith astepparameter to get time-bucketed data, then pivots to per-namespace series. Top 8 by latest value are returned individually; the rest are summed into an"other"bucket.pkg/opencost/nodes_rest.goCalls
/assetswithwindow=24h(window=1hoften returns null; thenodeaggregate param is not supported on port 9008). Merges duplicate entries across time buckets by averaging. Kubecost normalizes label keys (dots/slashes → underscores), so both forms are checked:internal/opencost/global.goThread-safe global REST client. Nil until auto-discovery succeeds; handlers fall through to Prometheus when nil.
internal/opencost/portforward.goDedicated cost port-forward singleton. The shared
internal/portforwardsingleton is already used by Prometheus/traffic and can't be reused. Usespkg/portforwardprimitives (FindPodForService,FindFreePort,RunPortForward) directly, with a 10s ready timeout and context-aware teardown.internal/opencost/discovery.goProbes known services in order, tries in-cluster DNS first, falls back to port-forward:
Modified files
pkg/opencost/rest_client.goAdded
Asset,AssetsResponse,GetAssets()(calls/assets, no aggregate param — port 9008 rejectsaggregate=node), andGetData()(filters nil buckets — Kubecost returnsdata:[null]when a window has no processed data).pkg/opencost/compute.goAdded window fallback: when
window=1hreturns null data (Kubecost's aggregator may not have the last hour processed), retries withwindow=1d. The existingwindowHours()normalization already divides all costs by window duration before returning, so hourly rates are correct regardless of which window was used.internal/opencost/handlers.goEach handler (
handleSummary,handleWorkloads,handleTrend,handleNodes) checksgetRESTClient()first and uses the REST path when non-nil, otherwise falls through to the existing Prometheus path.internal/k8s/context_manager.goAdded
CostResetFunc,CostReinitFunctypes andRegisterCostFuncs(), following the same pattern asRegisterPrometheusFuncs.internal/k8s/subsystems.gocostReinitFnruns in parallel with Prometheus/traffic/helm inInitAllSubsystems.costResetFnruns first inResetAllSubsystemsso the port-forward closes before the cluster client tears down.internal/app/bootstrap.goRegisters cost callbacks — auto-discovery runs in a background goroutine on each cluster connect, reset tears down the port-forward on context switch:
pkg/prom/discovery.goAdded
kubecost-prometheus-serverto well-known Prometheus locations and boosted score for kubecost-named services — covers older Kubecost 1.x setups that bundle a Prometheus server.web/src/components/cost/CostView.tsxUpdated error messages to name both tools in all three error states (
no_prometheus,no_metrics, fallback).Alternatives considered
/metricsendpoint and routing through an existing Prometheus instance — not viable in federated mode; the aggregator serves ClickHouse-backed data with no Prometheus export.--costs-urlmanual override flag — prototyped and removed in favor of full auto-discovery; adds surface area without benefit since the well-known service list covers all real deployments.Additional context
Observed on Kubecost Enterprise 3.x with
serviceMonitor.enabled: falseandlocalStore.enabled: falseon EKS. Port 9008 on the aggregator serves the same allocation data as port 9004 but without the SAML/JWT authentication enforced on the primary API port. The same REST path works for self-hosted OpenCost (port 9003).