Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 52 additions & 12 deletions providers/aws/recommendations/sp_coverage.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package recommendations

import (
"context"
"errors"
"fmt"
"math"
"strconv"
Expand Down Expand Up @@ -70,6 +71,35 @@ type SPUtilizationSummary struct {
// TotalCommitmentUSDPerHour is the average total SP commitment in $/hr.
// Nil when CE returned no data.
TotalCommitmentUSDPerHour *float64
// OnDemandCostEquivalentUSDPerHour is the average on-demand price of the
// usage the SP commitment covered, in $/hr (CE Savings.OnDemandCostEquivalent
// divided by windowHours). Nil when CE returned no savings data.
OnDemandCostEquivalentUSDPerHour *float64
}

// ErrNoSPUtilization means CE has no usable utilization for the scope (no
// plan, a plan too new for CE to report, or 0% used), so the
// commitment-to-on-demand ratio cannot be derived. Callers must surface an
// explainable no-op rather than fall back to a 1:1 ratio.
var ErrNoSPUtilization = errors.New("sp utilization: no used commitment or on-demand equivalent reported")

// CommitmentToOnDemandRatio returns k = used commitment / on-demand-equivalent
// cost, computed from the window totals (not an average of daily ratios):
// one commitment dollar covers 1/k on-demand dollars of usage. k must satisfy
// 0 < k <= 1 (a plan cannot cost more than the usage it covers); k > 1 would
// shrink a commitment converted to on-demand terms (E/k) and over-buy, so it
// is an error. Missing or zero used/equivalent returns ErrNoSPUtilization.
func (s SPUtilizationSummary) CommitmentToOnDemandRatio() (float64, error) {
if s.UsedCommitmentUSDPerHour == nil || s.OnDemandCostEquivalentUSDPerHour == nil ||
*s.UsedCommitmentUSDPerHour == 0 || *s.OnDemandCostEquivalentUSDPerHour == 0 {
return 0, ErrNoSPUtilization
}
k := *s.UsedCommitmentUSDPerHour / *s.OnDemandCostEquivalentUSDPerHour
if k <= 0 || k > 1 {
return 0, fmt.Errorf("sp utilization: commitment/on-demand ratio %g is outside (0, 1]; used=%g on-demand-equivalent=%g",
k, *s.UsedCommitmentUSDPerHour, *s.OnDemandCostEquivalentUSDPerHour)
}
return k, nil
}

// spCoverageMetricSpendCovered is the value for the REQUIRED Metrics
Expand Down Expand Up @@ -475,26 +505,36 @@ func buildSPUtilizationSummary(result *costexplorer.GetSavingsPlansUtilizationOu
summary.UtilizationPct = &v
}
if windowHours > 0 {
if u.UsedCommitment != nil {
v, err := parseSPFloat(aws.ToString(u.UsedCommitment), "UsedCommitment")
if err != nil {
return SPUtilizationSummary{}, err
}
rate := v / windowHours
summary.UsedCommitmentUSDPerHour = &rate
var err error
if summary.UsedCommitmentUSDPerHour, err = spRatePerHour(u.UsedCommitment, "UsedCommitment", windowHours); err != nil {
return SPUtilizationSummary{}, err
}
if u.TotalCommitment != nil {
v, err := parseSPFloat(aws.ToString(u.TotalCommitment), "TotalCommitment")
if err != nil {
if summary.TotalCommitmentUSDPerHour, err = spRatePerHour(u.TotalCommitment, "TotalCommitment", windowHours); err != nil {
return SPUtilizationSummary{}, err
}
if result.Total.Savings != nil {
if summary.OnDemandCostEquivalentUSDPerHour, err = spRatePerHour(result.Total.Savings.OnDemandCostEquivalent, "OnDemandCostEquivalent", windowHours); err != nil {
return SPUtilizationSummary{}, err
}
rate := v / windowHours
summary.TotalCommitmentUSDPerHour = &rate
}
}
return summary, nil
}

// spRatePerHour converts a CE window-total dollar string into a $/hr pointer;
// nil in, nil out (CE returned no value).
func spRatePerHour(total *string, field string, windowHours float64) (*float64, error) {
if total == nil {
return nil, nil
}
v, err := parseSPFloat(aws.ToString(total), field)
if err != nil {
return nil, err
}
rate := v / windowHours
return &rate, nil
}

// parseSPFloat parses a Cost Explorer string value into a float64, returning
// an explicit error on unparseable values, NaN/Inf, or negative values
// (all of which are invalid for financial metrics). Unlike the package-level
Expand Down
160 changes: 160 additions & 0 deletions providers/aws/recommendations/sp_coverage_series.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package recommendations

import (
"context"
"fmt"
"sort"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
"github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
)

// spCoverageServiceEC2 is the SERVICE dimension value used to scope
// GetSavingsPlansCoverage to EC2 compute, matching the EC2-only on-demand
// residual series so covered and uncovered demand describe the same usage.
//
// MANUAL VERIFICATION REQUIRED (mock-unprovable): this string is verified
// offline only for the GetCostAndUsage and RI-coverage filters
// (ec2ComputeService). Whether GetSavingsPlansCoverage accepts the same
// vocabulary has not been confirmed against a live CE response; a wrong value
// makes CE match nothing (complete $0 rows, no error). Keep it a single named
// constant so a correction is one line.
const spCoverageServiceEC2 = "Amazon Elastic Compute Cloud - Compute"

// spCoverageSeriesFilter builds the GetSavingsPlansCoverage Filter for the
// covered-demand series: SERVICE = EC2 compute AND REGION = region. Both are
// supported coverage dimensions (see spCoverageRegionFilter). region is
// required: the covered series is paired with the region-scoped residual.
func spCoverageSeriesFilter(region string) *types.Expression {
return &types.Expression{
And: []types.Expression{
{Dimensions: &types.DimensionValues{
Key: types.DimensionService,
Values: []string{spCoverageServiceEC2},
}},
{Dimensions: &types.DimensionValues{
Key: types.DimensionRegion,
Values: []string{region},
}},
},
}
}

// GetSPCoveredSeries returns the daily EC2 usage covered by Savings Plans in
// region over the past lookbackDays days, ordered oldest-to-newest with
// strictly increasing unique UTC days.
//
// Units: CE's SpendCoveredBySavingsPlans is ON-DEMAND-EQUIVALENT dollars (the
// price the covered usage would have cost at on-demand rates), the same unit
// as the uncovered on-demand residual from GetOnDemandSeries. Each day's total
// is divided by 24 (DAILY granularity) to give USD/hr. It is NOT the SP
// commitment consumed; convert commitment to this unit with
// SPUtilizationSummary.CommitmentToOnDemandRatio.
//
// A row whose Coverage block is nil counts as zero covered (precedent:
// spCoverageAccumulator.add; CE omits the block for days without activity).
// An empty response yields an empty series and no error: an account without
// Savings Plans legitimately has no covered usage, and the caller joins this
// with the residual series. A non-nil Coverage block without
// SpendCoveredBySavingsPlans, an undatable row, a duplicate date, or an
// unparseable amount fails loud.
func (c *Client) GetSPCoveredSeries(ctx context.Context, region string, lookbackDays int) ([]DailyCost, error) {
if lookbackDays <= 0 {
return nil, fmt.Errorf("sp covered series: lookbackDays must be > 0, got %d", lookbackDays)
}
if region == "" {
return nil, fmt.Errorf("sp covered series: region must not be empty")
}
end := time.Now().UTC().Truncate(24 * time.Hour)
start := end.AddDate(0, 0, -lookbackDays)
input := &costexplorer.GetSavingsPlansCoverageInput{
TimePeriod: &types.DateInterval{
Start: aws.String(start.Format(ceDateLayout)),
End: aws.String(end.Format(ceDateLayout)),
},
Granularity: types.GranularityDaily,
Metrics: []string{spCoverageMetricSpendCovered},
Filter: spCoverageSeriesFilter(region),
}

byDate, err := c.fetchSPCoveredByDate(ctx, input)
if err != nil {
return nil, err
}
return sortedDailyCosts(byDate)
}

// fetchSPCoveredByDate pages through GetSavingsPlansCoverage and returns
// USD/hr keyed by "YYYY-MM-DD".
func (c *Client) fetchSPCoveredByDate(ctx context.Context, input *costexplorer.GetSavingsPlansCoverageInput) (map[string]float64, error) {
byDate := make(map[string]float64)
var token *string
for pageIdx := 0; ; pageIdx++ {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("sp covered series: pagination canceled: %w", err)
}
if pageIdx >= maxSPCoveragePages {
return nil, fmt.Errorf("sp covered series: pagination cap reached after %d pages", maxSPCoveragePages)
}
input.NextToken = token
result, err := c.fetchSPCoveragePage(ctx, input)
if err != nil {
return nil, err
}
if err := accumulateSPCoveredDays(byDate, result.SavingsPlansCoverages); err != nil {
return nil, fmt.Errorf("sp covered series: %w", err)
}
if result.NextToken == nil || *result.NextToken == "" {
return byDate, nil
}
token = result.NextToken
}
}

// sortedDailyCosts orders a date-keyed map oldest-to-newest (lexicographic
// order of "YYYY-MM-DD" is chronological; map keys are unique).
func sortedDailyCosts(byDate map[string]float64) ([]DailyCost, error) {
dates := make([]string, 0, len(byDate))
for d := range byDate {
dates = append(dates, d)
}
sort.Strings(dates)
series := make([]DailyCost, len(dates))
for i, d := range dates {
day, err := time.Parse(ceDateLayout, d)
if err != nil {
return nil, fmt.Errorf("sp covered series: cannot parse CE period start date %q: %w", d, err)
}
series[i] = DailyCost{Date: day, USDPerHour: byDate[d]}
}
return series, nil
}

// accumulateSPCoveredDays merges one page of daily coverage rows into byDate
// (USD/hr per "YYYY-MM-DD"). See GetSPCoveredSeries for the fail-loud rules.
func accumulateSPCoveredDays(byDate map[string]float64, rows []types.SavingsPlansCoverage) error {
for i, r := range rows {
if r.TimePeriod == nil || r.TimePeriod.Start == nil {
return fmt.Errorf("CE coverage row %d is missing its period start; an undatable row would hide a gap in the daily series", i)
}
date := aws.ToString(r.TimePeriod.Start)
if _, dup := byDate[date]; dup {
return fmt.Errorf("CE returned more than one coverage row for day %s", date)
}
var usd float64
if r.Coverage != nil {
if r.Coverage.SpendCoveredBySavingsPlans == nil {
return fmt.Errorf("CE coverage row for day %s has no %s value; CE echoes the requested metric on every row", date, spCoverageMetricSpendCovered)
}
v, err := parseSPFloat(aws.ToString(r.Coverage.SpendCoveredBySavingsPlans), spCoverageMetricSpendCovered)
if err != nil {
return fmt.Errorf("day %s: %w", date, err)
}
usd = v
}
byDate[date] = usd / 24.0
}
return nil
}
143 changes: 143 additions & 0 deletions providers/aws/recommendations/sp_coverage_series_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package recommendations

import (
"context"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
"github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func covDay(date, spend string) types.SavingsPlansCoverage {
c := types.SavingsPlansCoverage{TimePeriod: &types.DateInterval{Start: aws.String(date), End: aws.String(date)}}
if spend != "" {
c.Coverage = &types.SavingsPlansCoverageData{SpendCoveredBySavingsPlans: aws.String(spend)}
}
return c
}

func TestGetSPCoveredSeries(t *testing.T) {
page1 := &costexplorer.GetSavingsPlansCoverageOutput{
SavingsPlansCoverages: []types.SavingsPlansCoverage{
covDay("2026-01-02", "240"), // 240/24 = 10 OD$/hr
covDay("2026-01-01", "120"),
},
NextToken: aws.String("p2"),
}
page2 := &costexplorer.GetSavingsPlansCoverageOutput{
SavingsPlansCoverages: []types.SavingsPlansCoverage{
covDay("2026-01-03", ""), // nil Coverage block counts as zero
},
}
mock := &mockSPCE{coveragePages: []*costexplorer.GetSavingsPlansCoverageOutput{page1, page2}}
got, err := NewClientWithAPI(mock, "us-east-1").GetSPCoveredSeries(context.Background(), "us-east-1", 30)
require.NoError(t, err)
require.Len(t, got, 3)
assert.Equal(t, "2026-01-01", got[0].Date.Format(ceDateLayout), "sorted oldest-to-newest")
assert.InDelta(t, 5.0, got[0].USDPerHour, 1e-9)
assert.InDelta(t, 10.0, got[1].USDPerHour, 1e-9)
assert.Zero(t, got[2].USDPerHour)
require.Len(t, mock.coverageInputs, 2)
assert.Equal(t, types.GranularityDaily, mock.coverageInputs[0].Granularity)
assert.Equal(t, []string{spCoverageMetricSpendCovered}, mock.coverageInputs[0].Metrics)
}

// TestGetSPCoveredSeries_Filter pins the SERVICE and REGION clauses. The
// SERVICE string is unverified against live CE (see spCoverageServiceEC2);
// this test asserts the clause exists and carries the named constant, so
// dropping it (which would pull Fargate/Lambda into EC2 demand and over-buy)
// fails here.
func TestGetSPCoveredSeries_Filter(t *testing.T) {
mock := &mockSPCE{}
_, err := NewClientWithAPI(mock, "us-east-1").GetSPCoveredSeries(context.Background(), "eu-west-2", 30)
require.NoError(t, err)
require.Len(t, mock.coverageInputs, 1)
f := mock.coverageInputs[0].Filter
require.NotNil(t, f)
require.Len(t, f.And, 2)
got := map[types.Dimension][]string{}
for _, e := range f.And {
require.NotNil(t, e.Dimensions)
got[e.Dimensions.Key] = e.Dimensions.Values
}
assert.Equal(t, []string{spCoverageServiceEC2}, got[types.DimensionService])
assert.Equal(t, []string{"eu-west-2"}, got[types.DimensionRegion])
assert.NotContains(t, got, types.DimensionSavingsPlansType, "coverage rejects SAVINGS_PLANS_TYPE")
}

func TestGetSPCoveredSeries_EmptyIsNotAnError(t *testing.T) {
got, err := NewClientWithAPI(&mockSPCE{}, "us-east-1").GetSPCoveredSeries(context.Background(), "us-east-1", 30)
require.NoError(t, err)
assert.Empty(t, got, "an account without SPs has no covered usage")
}

func TestGetSPCoveredSeries_FailLoud(t *testing.T) {
undated := types.SavingsPlansCoverage{Coverage: &types.SavingsPlansCoverageData{SpendCoveredBySavingsPlans: aws.String("1")}}
noMetric := covDay("2026-01-01", "")
noMetric.Coverage = &types.SavingsPlansCoverageData{}
tests := []struct {
name string
rows []types.SavingsPlansCoverage
}{
{"undated row", []types.SavingsPlansCoverage{undated}},
{"missing metric in non-nil block", []types.SavingsPlansCoverage{noMetric}},
{"unparseable", []types.SavingsPlansCoverage{covDay("2026-01-01", "abc")}},
{"negative", []types.SavingsPlansCoverage{covDay("2026-01-01", "-5")}},
{"duplicate day", []types.SavingsPlansCoverage{covDay("2026-01-01", "1"), covDay("2026-01-01", "2")}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := &mockSPCE{coveragePages: []*costexplorer.GetSavingsPlansCoverageOutput{{SavingsPlansCoverages: tc.rows}}}
_, err := NewClientWithAPI(mock, "us-east-1").GetSPCoveredSeries(context.Background(), "us-east-1", 30)
require.Error(t, err)
})
}
}

func TestGetSPCoveredSeries_InvalidArgs(t *testing.T) {
c := NewClientWithAPI(&mockSPCE{}, "us-east-1")
_, err := c.GetSPCoveredSeries(context.Background(), "", 30)
require.Error(t, err)
_, err = c.GetSPCoveredSeries(context.Background(), "us-east-1", 0)
require.Error(t, err)
}

func TestSPUtilization_OnDemandEquivalentAndRatio(t *testing.T) {
// Window totals over 30 days (720h): used 3024 => 4.2/hr, on-demand
// equivalent 6000 => 8.3333/hr; k = 0.504.
out := &costexplorer.GetSavingsPlansUtilizationOutput{Total: &types.SavingsPlansUtilizationAggregates{
Utilization: &types.SavingsPlansUtilization{UsedCommitment: aws.String("3024"), TotalCommitment: aws.String("4320")},
Savings: &types.SavingsPlansSavings{OnDemandCostEquivalent: aws.String("6000")},
}}
s, err := NewClientWithAPI(&mockSPCE{utilOutput: out}, "us-east-1").
GetSPUtilization(context.Background(), types.SupportedSavingsPlansTypeComputeSp, "", 30)
require.NoError(t, err)
require.NotNil(t, s.OnDemandCostEquivalentUSDPerHour)
assert.InDelta(t, 6000.0/720, *s.OnDemandCostEquivalentUSDPerHour, 1e-9)
k, err := s.CommitmentToOnDemandRatio()
require.NoError(t, err)
assert.InDelta(t, 0.504, k, 1e-9)
}

func TestCommitmentToOnDemandRatio_Errors(t *testing.T) {
f := func(v float64) *float64 { return &v }
_, e0 := SPUtilizationSummary{}.CommitmentToOnDemandRatio()
assert.ErrorIs(t, e0, ErrNoSPUtilization)
for name, s := range map[string]SPUtilizationSummary{
"zero used": {UsedCommitmentUSDPerHour: f(0), OnDemandCostEquivalentUSDPerHour: f(5)},
"zero od": {UsedCommitmentUSDPerHour: f(5), OnDemandCostEquivalentUSDPerHour: f(0)},
"nil od": {UsedCommitmentUSDPerHour: f(5)},
} {
_, err := s.CommitmentToOnDemandRatio()
assert.ErrorIs(t, err, ErrNoSPUtilization, name)
}
_, err := SPUtilizationSummary{UsedCommitmentUSDPerHour: f(6), OnDemandCostEquivalentUSDPerHour: f(5)}.CommitmentToOnDemandRatio()
require.Error(t, err)
assert.NotErrorIs(t, err, ErrNoSPUtilization, "k > 1 is a data error, not a missing-data Hold")
k, err := SPUtilizationSummary{UsedCommitmentUSDPerHour: f(5), OnDemandCostEquivalentUSDPerHour: f(5)}.CommitmentToOnDemandRatio()
require.NoError(t, err)
assert.InDelta(t, 1.0, k, 1e-12, "k == 1 (no discount) is valid")
}
Loading