Skip to content

feat: add Kubecost REST API support for cost data #1055

Description

@nathanmartins

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:

  1. 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.
  2. opencost/opencost:9003 — self-hosted OpenCost
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions