Symptom
Savings Plans recommendations never appear in the Opportunities list. The AWS rec-collection sweep returns only EC2, RDS, ElastiCache, OpenSearch, and Redshift rows; SP rows are silently absent.
Observed today (2026-05-28) in dev account 909626172446:
2026/05/28 09:29:15 [INFO] Collected 46 recommendations with $1076.22/month potential savings
46 recs collected, zero of them SP, despite the account having recurring SP-eligible workloads.
Root cause
providers/aws/recommendations/client.go GetAllRecommendations spawns 5 goroutines, one per RI service:
g.Go(func() error { ec2Recs, ec2Err = c.GetRecommendationsForService(gctx, common.ServiceEC2); return nil })
g.Go(func() error { rdsRecs, rdsErr = c.GetRecommendationsForService(gctx, common.ServiceRDS); return nil })
g.Go(func() error { cacheRecs, cacheErr = c.GetRecommendationsForService(gctx, common.ServiceElastiCache); return nil })
g.Go(func() error { osRecs, osErr = c.GetRecommendationsForService(gctx, common.ServiceOpenSearch); return nil })
g.Go(func() error { redshiftRecs, redshiftErr = c.GetRecommendationsForService(gctx, common.ServiceRedshift); return nil })
There is no goroutine for common.ServiceSavingsPlans (the canonical umbrella SP slug defined in pkg/common/types.go:68).
The dispatch helper in Client.GetRecommendations (lines 67-70) correctly routes SP slugs to getSavingsPlansRecommendations:
if common.IsSavingsPlan(params.Service) {
return c.getSavingsPlansRecommendations(ctx, params)
}
But that branch is never reached because nothing in the fan-out passes an SP slug. The mergeServiceResults call at line 301 also only enumerates the 5 RI services, so even if SP recs were fetched there's no place for them in the merged output.
Fix
Add a 6th goroutine for common.ServiceSavingsPlans:
var spRecs []common.Recommendation
var spErr error
g.Go(func() error {
spRecs, spErr = c.GetRecommendationsForService(gctx, common.ServiceSavingsPlans)
return nil
})
Extend mergeServiceResults to include the SP slot:
return mergeServiceResults(
serviceResult{name: "EC2", recs: ec2Recs, err: ec2Err},
serviceResult{name: "RDS", recs: rdsRecs, err: rdsErr},
serviceResult{name: "ElastiCache", recs: cacheRecs, err: cacheErr},
serviceResult{name: "OpenSearch", recs: osRecs, err: osErr},
serviceResult{name: "Redshift", recs: redshiftRecs, err: redshiftErr},
serviceResult{name: "SavingsPlans", recs: spRecs, err: spErr},
), nil
The downstream code already handles SP via getSavingsPlansRecommendations (parser_sp.go:30), which iterates planTypesForParams(params) and fans out across the 4 plan types (Compute / EC2Instance / SageMaker / Database) for the umbrella slug. The CE call budget is 2 terms × 3 payment options × 4 plan types = 24 calls per sweep -- within the existing rate-limiter and concurrency caps.
Cost
Each per-service sweep makes 2 terms × 3 payment options = 6 CE calls (plus retries). The umbrella SP slug expands further to 4 plan types internally, so the SP sweep adds ~24 CE calls per refresh. This is bounded by pkg/concurrency and the rate limiter; should not change tail latency materially.
Acceptance criteria
Cross-references
Symptom
Savings Plans recommendations never appear in the Opportunities list. The AWS rec-collection sweep returns only EC2, RDS, ElastiCache, OpenSearch, and Redshift rows; SP rows are silently absent.
Observed today (2026-05-28) in dev account
909626172446:46 recs collected, zero of them SP, despite the account having recurring SP-eligible workloads.
Root cause
providers/aws/recommendations/client.goGetAllRecommendationsspawns 5 goroutines, one per RI service:There is no goroutine for
common.ServiceSavingsPlans(the canonical umbrella SP slug defined inpkg/common/types.go:68).The dispatch helper in
Client.GetRecommendations(lines 67-70) correctly routes SP slugs togetSavingsPlansRecommendations:But that branch is never reached because nothing in the fan-out passes an SP slug. The
mergeServiceResultscall at line 301 also only enumerates the 5 RI services, so even if SP recs were fetched there's no place for them in the merged output.Fix
Add a 6th goroutine for
common.ServiceSavingsPlans:Extend
mergeServiceResultsto include the SP slot:The downstream code already handles SP via
getSavingsPlansRecommendations(parser_sp.go:30), which iteratesplanTypesForParams(params)and fans out across the 4 plan types (Compute / EC2Instance / SageMaker / Database) for the umbrella slug. The CE call budget is 2 terms × 3 payment options × 4 plan types = 24 calls per sweep -- within the existing rate-limiter and concurrency caps.Cost
Each per-service sweep makes
2 terms × 3 payment options = 6CE calls (plus retries). The umbrella SP slug expands further to 4 plan types internally, so the SP sweep adds ~24 CE calls per refresh. This is bounded bypkg/concurrencyand the rate limiter; should not change tail latency materially.Acceptance criteria
GetAllRecommendationsspawns 6 goroutines including SPmergeServiceResultsreceives aserviceResult{name: "SavingsPlans", ...}entryclient_test.goasserting SP recs are present in the merged output when the mock returns SP rowsCross-references
pkg/common/types.go:68--ServiceSavingsPlanscanonical slugproviders/aws/recommendations/parser_sp.go-- existing per-plan-type fan-out (planTypesForParams)providers/aws/recommendations/client.go:67-70-- existing SP dispatch inGetRecommendations(never reached today)commitmentopts). Complementary but does NOT fix this; feat(commitmentopts): probe Savings Plans offerings live (closes #108) #569 affects the purchase-modal dropdowns, not the rec-collection sweep.GetSavingsPlansPurchaseRecommendation. Necessary precondition for SP rec completeness once the SP fan-out is wired up, but irrelevant today because the call is never made.