fix(frontend/recs): Monthly cost column always $0; Effective % shows 100% / negatives — scheduler hardcodes MonthlyCost=0 · Issue #252 · LeanerCloud/cloud-commitments-cli · GitHub
Skip to content

fix(frontend/recs): Monthly cost column always $0; Effective % shows 100% / negatives — scheduler hardcodes MonthlyCost=0 #252

Closed

Description

@cristim

Bug: Monthly cost column + Effective savings % column produce nonsense values

Source: PR #242 shipped UI for #220 + #221, but the underlying data is broken.

Symptoms (from current production data)

AZURE Test Azure Joshua  Standard_D2as_v4   3y  $107 sav  $65 upfront  $0 monthly  96.7% eff
AWS   CUDly host         db.t4g.micro       1y    $4 sav  $95 upfront  $0 monthly  -35.6% eff   ← negative
AWS   CUDly host         db.t4g.micro       1y    $3 sav   $0 upfront  $0 monthly  100.0% eff   ← infinite ROI
AWS   CUDly host         t4g.nano           3y    $2 sav   $0 upfront  $0 monthly  100.0% eff   ← infinite ROI
AWS   CUDly host         t4g.nano           1y    $1 sav  $22 upfront  $0 monthly  -20.0% eff   ← negative

Every row shows $0 monthly. Effective % is negative for ~1y partial-upfront recs and 100% for any rec with $0 upfront. Both are artefacts of the same root cause.

Root cause

internal/scheduler/scheduler.go:881 hardcodes MonthlyCost: 0 when it persists Cost Explorer recommendations:

records = append(records, config.RecommendationRecord{
    ...
    UpfrontCost:  rec.CommitmentCost,
    MonthlyCost:  0, // Cost Explorer doesn't always provide monthly breakdown
    Savings:      rec.EstimatedSavings,
    ...
})

Two separate problems compound here:

  1. MonthlyCost: 0 is hardcoded. The comment is stale — pkg/common.Recommendation doesn't carry a MonthlyCost field at all (it has OnDemandCost, CommitmentCost, EstimatedSavings), so even if the provider has the data, it's discarded at the provider→common boundary. The scheduler then writes the literal 0.

  2. UpfrontCost: rec.CommitmentCost is mislabeled. CommitmentCost is the total commitment over the term (recurring + upfront). For a no-upfront SP it's the full hourly-rate × hours-in-term and the actual upfront is $0. So the column the frontend reads as "Upfront" is really "Total committed spend" for some payment options.

Why the frontend math then explodes

frontend/src/recommendations.ts::effectiveSavingsPct:

const amortized      = r.upfront_cost / monthsInTerm;
const effectiveSav   = r.savings - amortized;
const onDemand       = (r.monthly_cost ?? 0) + r.savings + amortized;
return (effectiveSav / onDemand) * 100;

With monthly_cost = 0 baked in:

  • onDemand = savings + amortized — denominator no longer reflects real on-demand monthly cost.
  • When upfront_cost is also 0 (e.g. no-upfront recs where the backend mistakenly set CommitmentCost=0): onDemand = savings, eff = savings, ratio = 100% regardless of magnitude → the "$3 savings, infinite ROI" rows.
  • When upfront_cost is a 1y total cost stuffed into the upfront column: amortized > savings → negative percentages.
  • "$0 monthly" is shown literally because the field is literally 0, not null/undefined, so the frontend falls through to formatCurrency(0).

Suggested fix shape

Two-layer fix; both pieces are needed.

Backend (pkg/common.Recommendation + scheduler):

  • Add separate UpfrontCost and RecurringMonthlyCost fields to pkg/common.Recommendation (or a derived helper on the struct based on PaymentOption + CommitmentCost + Term). The provider parsers already have the raw breakdown (AWS CE returns EstimatedMonthlyOnDemandCost, EstimatedSavingsAmount, plus the SP hourly commitment); we're throwing it away.
  • In scheduler.go::buildRecommendationRecords, pass the proper split through instead of hardcoding MonthlyCost: 0 and stuffing total into UpfrontCost.

Frontend (recommendations.ts):

  • When monthly_cost == null (genuinely unknown vs zero), render — not $0.
  • Sanity-check effectiveSavingsPct for the 100% corner: if onDemand <= |effectiveSav|, surface as anomaly (or —) — silently rendering 100% hides the underlying data gap.
  • Add a unit test for a no-upfront variant where the backend correctly reports monthly_cost > 0 and assert the percentage matches (savings - amortized) / on_demand_monthly.

Repro / verification

  • Open /recommendations on the deployed app. Every row in the table I sampled has $0 in the Monthly column.
  • Filter to AWS RDS db.t4g.micro: same SKU appears with both negative and 100% Effective % depending on which broken branch of the formula it lands in.

Why this matters / priority

The Effective Savings % column was intended to be the user's primary "is this rec worth it" signal. With the current data it's actively misleading — recommending a 1y no-upfront RDS as 100% effective while the same SKU's 1y partial-upfront variant shows -35% will push users toward objectively worse picks.

cc: closes-rec comments I posted on #220 / #221 a few minutes ago should be treated as retracted — the columns ship pixels, but the numbers are wrong.

Activity

  1. cristim commented on May 4, 2026

    @cristim
    MemberAuthor

    #252 still broken after PR #254 — verified against deployed app

    PR #254 fixed the Go struct types (pkg/common.Recommendation.RecurringMonthlyCost *float64, RecommendationRecord.MonthlyCost *float64) and the AWS RI / SP parsers. But the deployed app still returns monthly_cost: 0 for every recommendation (17/17 AWS, 16/16 Azure). Two compounding gaps:

    Gap 1 — Cached payloads still hold the literal 0

    The recommendations table stores each record as a JSONB payload. Pre-deploy rows were inserted with the old struct (MonthlyCost float64 = 0), so the JSONB literally contains "monthly_cost": 0. After the deploy, the new code unmarshals that into *float64 → &0 (pointer to zero), not nil. The API then re-emits 0 — indistinguishable from a real zero from the user's perspective.

    Fix needed: cache invalidation. Either (a) drop and re-collect on next scheduler tick, (b) add a one-shot migration that nulls out payload->monthly_cost (forcing the next collection to write the new shape), or (c) make RecurringMonthlyCost non-nullable on read with a sentinel (less clean).

    Sample from the deployed /api/recommendations GET (n=33, all rows):

    "aws|zero": 17
    "azure|zero": 16
    "aws|null":  0
    "azure|null": 0
    "...|>0":    0
    

    A db.t4g.micro 1y rec is still showing upfront: 0, monthly: 0, savings: 3.21 — the original "infinite ROI" symptom from the bug report.

    Gap 2 — Azure and GCP parsers never updated

    PR #254's diff only touched providers/aws/recommendations/parser_ri.go and parser_sp.go. providers/azure/... and providers/gcp/... were never updated to populate RecurringMonthlyCost. So even after a fresh collection, all 16 Azure recs (and any GCP recs) will still produce monthly_cost: nil → JSON null → frontend — for a column that could show real data (Azure Reservations API exposes BillingCurrencyTotal / RecurringMonthlyCost-equivalent in Reservations.GetCatalog and the Pricing API; GCP CUD recommendations expose cost_per_period similarly).

    Fix needed: extend Azure and GCP parsers to fill RecurringMonthlyCost from the provider responses. For payment options where the cloud genuinely doesn't expose a recurring breakdown, leave nil (which is now correctly distinguished in the frontend).

    Reopening scope

    I'm leaving #252 open. PR #254 was a partial fix (Go types + AWS parser). The remaining work is:

    1. Cache invalidation strategy (one-shot migration OR scheduler force-refresh on first read after a struct-shape change).
    2. Azure parser fills RecurringMonthlyCost.
    3. GCP parser fills RecurringMonthlyCost.
    4. Smoke test against the deployed /api/recommendations endpoint that at least one row per provider has monthly_cost != 0 after collection (currently 0/33).

    Spawning a follow-up agent to drive (1)–(4).

  2. added a commit that references this issue on May 4, 2026
    c8c3ba9
  3. cristim commented on May 4, 2026

    @cristim
    MemberAuthor

    Closing — fixed end-to-end across PRs #256 (cache invalidation + Azure parsers), #259 (Azure parallelization), #260 (async refresh architecture), and #265 (async self-invoke payload shape). The auto-link from #256's title didn't fire because the closing keyword was in a parenthetical at the end. Verified the deployed Lambda env now has SCHEDULER_LAMBDA_ARN set and the IAM policy in place; collection runs end-to-end after #265 merged.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions