Skip to content

fix(opencost): make agent OpenCost connection detection reliable#489

Open
RamanKharchee wants to merge 2 commits into
mainfrom
fix/opencost-connection-detection
Open

fix(opencost): make agent OpenCost connection detection reliable#489
RamanKharchee wants to merge 2 commits into
mainfrom
fix/opencost-connection-detection

Conversation

@RamanKharchee

Copy link
Copy Markdown
Contributor

Summary

The agent reports opencostConnection=true only when it has a non-empty OpenCost URL and /healthz returns 2xx. Three defects kept that false even when OpenCost was healthy and running:

  1. URL resolved once at boot. opencostURL was discovered a single time at startup and captured by the telemetry closure, while every other datasource in that closure is re-probed per tick. If OpenCost wasn't deployed/ready at boot, the URL stayed empty forever and opencostConnection never recovered without a pod restart. Now resolved per tick — cached ~CacheTTL by the Discoverer, so no extra API-server load — so the agent self-heals when OpenCost is deployed after the agent.

  2. Discovery picked the Service's first port. findOne hardcoded Ports[0]. OpenCost's Service can expose both the cost-model API (9003, serves /healthz) and a UI port (9090); if the UI port sorts first, /healthz was probed on it and a healthy OpenCost reported down. Added FindFirstPreferPort and resolve OpenCost preferring port 9003, falling back to the first port otherwise. The generic FindFirst (Prometheus/Loki/AlertManager) is unchanged — it delegates with no preference.

  3. No endpoint override. The chart wired only OPENCOST_ENABLED, leaving the agent fully dependent on the two label selectors (app=opencost, app.kubernetes.io/name=opencost). Wired optional opencost.endpointOPENCOST_ENDPOINT for non-standard installs (the binary already honoured the env).

Type of change

  • Bug fix (non-breaking)

Chart version

  • No manual version bump needed — version/appVersion in Chart.yaml are placeholders set by .github/workflows/release.yaml (see the comment above them).

Test plan

  • go build ./..., go vet ./... pass
  • go test ./pkg/svcdiscover/... ./pkg/telemetry/... pass, including new tests: TestFindFirstPreferPort_PicksAPIPortOverUI, TestFindFirstPreferPort_FallsBackToFirstPort, TestSelectPort
  • gofmt clean
  • helm lint / helm template — not run locally; chart subcharts need helm dependency build first. The added block mirrors the existing {{- if .Values.globalConfig.prometheus_url }} conditional verbatim.
  • Not yet installed on a live/kind cluster

Review Notes → Risks & Counterarguments

  • Per-tick discovery load: FindFirstPreferPort reuses the Discoverer's 1h cache (positive and negative), so per-tick calls are cache hits between refreshes — at most one Services().List per selector group per CacheTTL. Self-heal latency after OpenCost appears is therefore ≤ CacheTTL.
  • OPENCOST_ENABLED=false still wins: when OpenCost is disabled at the agent the closure skips resolution entirely, preserving the server-side-takeover behaviour (opencostConnection stays absent/false by design).
  • Port preference scope: only OpenCost passes a preferred port (9003); generic discovery is byte-for-byte unchanged, so Prometheus/Loki/AlertManager resolution is unaffected.

Related

Counterpart to nudgebee-enterprise#32611 / nudgebee-enterprise#32612: those false-negative opencostConnection reports flow into the server-side OpenCost spend-sync account selection and can trigger a double-write. This PR fixes the agent-side reporting; that PR hardens the server-side query.

🤖 Generated with Claude Code

The agent reports opencostConnection=true only when it has a non-empty OpenCost
URL and /healthz returns 2xx. Three defects kept that false even when OpenCost
was healthy:

1. URL resolved once at boot. opencostURL was discovered a single time at
   startup and captured by the telemetry closure, while every other datasource
   in that closure is re-probed per tick. If OpenCost was not deployed/ready at
   boot, the URL stayed empty forever and opencostConnection never recovered
   without a pod restart. Resolve it per tick instead (cached ~CacheTTL by the
   Discoverer, so no extra API load) so the agent self-heals.

2. Discovery picked the Service's first port. findOne hardcoded Ports[0].
   OpenCost's Service can expose both the cost-model API (9003, serves /healthz)
   and a UI port (9090); if the UI port sorts first, /healthz was probed on it
   and a healthy OpenCost reported down. Add FindFirstPreferPort and resolve
   OpenCost preferring port 9003, falling back to the first port otherwise.

3. No endpoint override. The chart wired only OPENCOST_ENABLED, leaving the
   agent fully dependent on the two label selectors. Wire optional
   opencost.endpoint -> OPENCOST_ENDPOINT for non-standard installs (the binary
   already honoured the env).

Adds unit tests for port preference (selectPort, FindFirstPreferPort) covering
the multi-port and fallback cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@RamanKharchee
RamanKharchee requested a review from a team as a code owner June 19, 2026 08:18
@github-actions

Copy link
Copy Markdown
Contributor

📦 Image Tags Updated

I've automatically updated the image tags in `charts/nudgebee-agent/values.yaml` to the latest versions from GHCR for the `main` branch.

The image tags are now synchronized with the latest builds and ready for release.

@cla-assistant

cla-assistant Bot commented Jun 19, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the nudgebee-agent to resolve the OpenCost URL dynamically per telemetry tick rather than only at startup, allowing the agent to self-heal if OpenCost is deployed after the agent boots. It also introduces a port preference mechanism (FindFirstPreferPort) to prioritize the cost-model API port (9003) over the UI port (9090) during autodiscovery. Feedback points out a potential issue where transient Kubernetes API errors during service discovery could result in caching an empty URL for the duration of the cache TTL (1 hour), and suggests refactoring the discovery logic to avoid caching on API errors.

Comment on lines +151 to 154
func (d *Discoverer) findOne(ctx context.Context, selector string, preferredPorts []int32) string {
list, err := d.cs.CoreV1().Services("").List(ctx, metav1.ListOptions{LabelSelector: selector})
if err != nil || len(list.Items) == 0 {
return ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Transient API Error Caching Bug

In the current implementation, if d.cs.CoreV1().Services("").List fails due to a transient API error (such as a temporary network glitch, API server rate limiting, or context timeout/cancellation), findOne returns "" (an empty string).

Because findOne does not distinguish between a transient API error and a service actually not being found (both return ""), FindFirstPreferPort will cache this negative result ("") for the full CacheTTL (1 hour).

This means that if a single transient error occurs during a telemetry tick, the agent will report opencostConnection=false (and potentially other auto-discovered connections as down) for a whole hour, even if OpenCost is perfectly healthy and running. This can lead to false-negative alerts or server-side issues like double-writes.

Suggested Fix

Refactor findOne to return (string, error) so that FindFirstPreferPort can distinguish between a successful "not found" result and an API error. Only cache the result in d.cache if no API errors were encountered during the discovery loop:

func (d *Discoverer) FindFirstPreferPort(ctx context.Context, selectors []string, preferredPorts ...int32) string {
    // ...
    url := ""
    var apiErr error
    for _, sel := range selectors {
        u, err := d.findOne(ctx, sel, preferredPorts)
        if err != nil {
            apiErr = err
        }
        if u != "" {
            url = u
            break
        }
    }

    d.mu.Lock()
    if apiErr == nil || url != "" {
        d.cache[cacheKey] = cacheEntry{url: url, expires: now.Add(CacheTTL)}
    }
    d.mu.Unlock()
    return url
}

func (d *Discoverer) findOne(ctx context.Context, selector string, preferredPorts []int32) (string, error) {
    list, err := d.cs.CoreV1().Services("").List(ctx, metav1.ListOptions{LabelSelector: selector})
    if err != nil {
        return "", err
    }
    if len(list.Items) == 0 {
        return "", nil
    }
    // ...
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant