Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Azure Billing] Switch to Cost Management API for forecast data #32589

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.next.asciidoc
Expand Up @@ -134,7 +134,9 @@ https://github.com/elastic/beats/compare/v8.2.0\...main[Check the HEAD diff]


*Metricbeat*

- Allow filtering on AWS tags by more than 1 value per key. {pull}32775[32775]
- Azure Billing: switch to Cost Management API for forecast data {pull}32589[32589]

*Packetbeat*

Expand Down
64 changes: 53 additions & 11 deletions x-pack/metricbeat/module/azure/billing/billing.go
Expand Up @@ -55,24 +55,45 @@ func New(base mb.BaseMetricSet) (mb.MetricSet, error) {
}, nil
}

// TimeIntervalOptions represents the options used to retrieve the billing data.
zmoog marked this conversation as resolved.
Show resolved Hide resolved
type TimeIntervalOptions struct {
// Usage details start time (UTC).
usageStart time.Time
// Usage details end time (UTC).
usageEnd time.Time
// Forecast start time (UTC).
forecastStart time.Time
// Forecast end time (UTC).
forecastEnd time.Time
}

// Fetch methods implements the data gathering and data conversion to the right metricset
// It publishes the event which is then forwarded to the output. In case
// of an error set the Error field of mb.Event or simply call report.Error().
func (m *MetricSet) Fetch(report mb.ReporterV2) error {
// The time interval is yesterday (00:00:00->23:59:59) in UTC.
startTime, endTime := previousDayFrom(time.Now())
// reference time used to calculate usage and forecast time intervals.
referenceTime := time.Now()

usageStart, usageEnd := usageIntervalFrom(referenceTime)
forecastStart, forecastEnd := forecastIntervalFrom(referenceTime)

timeIntervalOptions := TimeIntervalOptions{
usageStart: usageStart,
usageEnd: usageEnd,
forecastStart: forecastStart,
forecastEnd: forecastEnd,
}

m.log.
With("billing.start_time", startTime).
With("billing.end_time", endTime).
With("billing.reference_time", referenceTime).
Infow("Fetching billing data")

results, err := m.client.GetMetrics(startTime, endTime)
results, err := m.client.GetMetrics(timeIntervalOptions)
if err != nil {
return fmt.Errorf("error retrieving usage information: %w", err)
}

events, err := EventsMapping(m.client.Config.SubscriptionId, results, startTime, endTime)
events, err := EventsMapping(m.client.Config.SubscriptionId, results, timeIntervalOptions, m.log)
if err != nil {
return fmt.Errorf("error mapping events: %w", err)
}
Expand All @@ -87,9 +108,30 @@ func (m *MetricSet) Fetch(report mb.ReporterV2) error {
return nil
}

// previousDayFrom returns the start/end times (00:00:00->23:59:59 UTC) of the day before, given the `reference` time.
func previousDayFrom(reference time.Time) (time.Time, time.Time) {
startTime := reference.UTC().Truncate(24 * time.Hour).Add((-24) * time.Hour)
endTime := startTime.Add(time.Hour * 24).Add(time.Second * (-1))
return startTime, endTime
// usageIntervalFrom returns the start/end times (UTC) of the usage period given the `reference` time.
//
// Currently, the usage period is the start/end time (00:00:00->23:59:59 UTC) of the day before the reference time.
//
// For example, if the reference time is 2007-01-09 09:41:00Z, the usage period is:
// 2007-01-08 00:00:00Z -> 2007-01-08 23:59:59Z
//
func usageIntervalFrom(reference time.Time) (time.Time, time.Time) {
beginningOfDay := reference.UTC().Truncate(24 * time.Hour).Add((-24) * time.Hour)
endOfDay := beginningOfDay.Add(time.Hour * 24).Add(time.Second * (-1))
return beginningOfDay, endOfDay
}

// forecastIntervalFrom returns the start/end times (UTC) of the forecast period, given the `reference` time.
//
// Currently, the forecast period is the start/end times (00:00:00->23:59:59 UTC) of the current month relative to the
// reference time.
//
// For example, if the reference time is 2007-01-09 09:41:00Z, the forecast period is:
// 2007-01-01T00:00:00Z -> 2007-01-31:59:59Z
//
func forecastIntervalFrom(reference time.Time) (time.Time, time.Time) {
referenceUTC := reference.UTC()
beginningOfMonth := time.Date(referenceUTC.Year(), referenceUTC.Month(), 1, 0, 0, 0, 0, time.UTC)
endOfMonth := beginningOfMonth.AddDate(0, 1, 0).Add(-1 * time.Second)
return beginningOfMonth, endOfMonth
}
23 changes: 20 additions & 3 deletions x-pack/metricbeat/module/azure/billing/billing_test.go
Expand Up @@ -11,16 +11,33 @@ import (
"github.com/stretchr/testify/assert"
)

func TestPreviousDayFrom(t *testing.T) {
t.Run("returns the previous day as time interval to collect metrics", func(t *testing.T) {
func TestUsagePeriodFrom(t *testing.T) {
zmoog marked this conversation as resolved.
Show resolved Hide resolved
t.Run("returns the start and end times for the usage period", func(t *testing.T) {
referenceTime, err := time.Parse("2006-01-02 15:04:05", "2007-01-09 09:41:00")
assert.NoError(t, err)
expectedStartTime, err := time.Parse("2006-01-02 15:04:05", "2007-01-08 00:00:00")
assert.NoError(t, err)
expectedEndTime, err := time.Parse("2006-01-02 15:04:05", "2007-01-08 23:59:59")
assert.NoError(t, err)

actualStartTime, actualEndTime := previousDayFrom(referenceTime)
actualStartTime, actualEndTime := usageIntervalFrom(referenceTime)

assert.Equal(t, expectedStartTime, actualStartTime)
assert.Equal(t, expectedEndTime, actualEndTime)
})
}

func TestForecastPeriodFrom(t *testing.T) {
t.Run("returns the start and end times for the forecast period", func(t *testing.T) {
referenceTime, err := time.Parse("2006-01-02 15:04:05", "2007-01-09 09:41:00")
assert.NoError(t, err)

expectedStartTime, err := time.Parse("2006-01-02 15:04:05", "2007-01-01 00:00:00")
assert.NoError(t, err)
expectedEndTime, err := time.Parse("2006-01-02 15:04:05", "2007-01-31 23:59:59")
assert.NoError(t, err)

actualStartTime, actualEndTime := forecastIntervalFrom(referenceTime)

assert.Equal(t, expectedStartTime, actualStartTime)
assert.Equal(t, expectedEndTime, actualEndTime)
Expand Down
70 changes: 45 additions & 25 deletions x-pack/metricbeat/module/azure/billing/client.go
Expand Up @@ -5,10 +5,12 @@
package billing

import (
"context"
"fmt"
"time"

"github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption"
"github.com/Azure/azure-sdk-for-go/services/costmanagement/mgmt/2019-11-01/costmanagement"

"github.com/elastic/beats/v7/x-pack/metricbeat/module/azure"
"github.com/elastic/elastic-agent-libs/logp"
Expand All @@ -21,10 +23,10 @@ type Client struct {
Log *logp.Logger
}

// Usage contains the usage details and forecast values.
type Usage struct {
UsageDetails []consumption.BasicUsageDetail
ActualCosts []consumption.Forecast
ForecastCosts []consumption.Forecast
UsageDetails []consumption.BasicUsageDetail
Forecasts costmanagement.QueryResult
}

// NewClient builds a new client for the azure billing service
Expand All @@ -42,55 +44,73 @@ func NewClient(config azure.Config) (*Client, error) {
}

// GetMetrics returns the usage detail and forecast values.
func (client *Client) GetMetrics(startTime time.Time, endTime time.Time) (Usage, error) {
func (client *Client) GetMetrics(timeOpts TimeIntervalOptions) (Usage, error) {
var usage Usage

zmoog marked this conversation as resolved.
Show resolved Hide resolved
//
// Establish the requested scope
//

scope := fmt.Sprintf("subscriptions/%s", client.Config.SubscriptionId)
if client.Config.BillingScopeDepartment != "" {
scope = fmt.Sprintf("/providers/Microsoft.Billing/departments/%s", client.Config.BillingScopeDepartment)
} else if client.Config.BillingScopeAccountId != "" {
scope = fmt.Sprintf("/providers/Microsoft.Billing/billingAccounts/%s", client.Config.BillingScopeAccountId)
}

//
// Fetch the usage details
//

client.Log.
With("billing.scope", scope).
With("billing.start_time", startTime).
With("billing.end_time", endTime).
With("billing.usage.start_time", timeOpts.usageStart).
With("billing.usage.end_time", timeOpts.usageEnd).
Infow("Getting usage details for scope")

usageDetails, err := client.BillingService.GetUsageDetails(
filter := fmt.Sprintf(
"properties/usageStart eq '%s' and properties/usageEnd eq '%s'",
timeOpts.usageStart.Format(time.RFC3339Nano),
timeOpts.usageEnd.Format(time.RFC3339Nano),
)

paginator, err := client.BillingService.GetUsageDetails(
scope,
"properties/meterDetails",
fmt.Sprintf(
"properties/usageStart eq '%s' and properties/usageEnd eq '%s'",
startTime.Format(time.RFC3339Nano),
endTime.Format(time.RFC3339Nano),
),
"", // skipToken
nil,
filter,
"", // skipToken, used for paging, not required on the first call.
nil, // result page size, defaults to ?
consumption.MetrictypeActualCostMetricType,
startTime.Format("2006-01-02"), // startDate
endTime.Format("2006-01-02"), // endDate
timeOpts.usageStart.Format("2006-01-02"), // startDate
timeOpts.usageEnd.Format("2006-01-02"), // endDate
)
if err != nil {
return usage, fmt.Errorf("retrieving usage details failed in client: %w", err)
}

usage.UsageDetails = usageDetails.Values()
for paginator.NotDone() {
Copy link
Contributor

Choose a reason for hiding this comment

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

you didn't change the behaviour here, but does it make sense to change and fetch forecasts even if we don't have usage details?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The module collects Usage Details data and forecast data at different time intervals:

  • usage details: previous day (24 hours)
  • forecast: current month (30 days)

Suppose we have zero usage in the last 24h and some resource usage in the previous days. In this case, usage details would be empty, but we could have some forecasts based on the days before.

WDYT? I have no previous knowledge of this topic, I reconstructed it from the codebase and Microsoft docs, so any question like this one is more than welcome!

Copy link
Contributor

Choose a reason for hiding this comment

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

Suppose we have zero usage in the last 24h and some resource usage in the previous days. In this case, usage details would be empty, but we could have some forecasts based on the days before.

WDYT? I have no previous knowledge of this topic, I reconstructed it from the codebase and Microsoft docs, so any question like this one is more than welcome!

I indeed didn't think about the fact that the call to the usage details could fail because there are zero usage in the last 24h :)

Mine was a more generic doubt about the fact that they are two different API calls, that can fail each for different reasons, even transient (ie: by the time we call the first we have a network outage that will be solved by the time of the next call, or just one upstream service is down while the other is up). Does it make sense to keep fetching the second metrics if fetching the first fails?

I would look the answer to this in the dashboards or the way the two metrics are related to each others.
Taking the example you made, and assuming having zero usage data is the reason for a possible failure: I see it can make sense to see in a dashboard that I have zero usage and the forecast is horizontal, rather than missing at all any information. At the same time is the usage is zero and the forecast increase this can be an hint that something is not working properly.

Probably @ravikesarwani might have an opinion here as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does it make sense to keep fetching the second metrics if fetching the first fails?

Yep, I think it makes sense.

In the dashboard, usage details and forecasts have independent visualizations: we don't have one visualization that uses data from both. However, the sum of the usage details matches the amount of actual data in the forecast for the same day.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm in favor of changing the logic and continuing with the forecast even if the call for usage details fails for some reason.

@ravikesarwani, let us know what's on your mind.

usage.UsageDetails = append(usage.UsageDetails, paginator.Values()...)
if err := paginator.NextWithContext(context.Background()); err != nil {
return usage, fmt.Errorf("retrieving usage details failed in client: %w", err)
}
}

//
// Forecast
// Fetch the Forecast
//

actualCosts, err := client.BillingService.GetForecast(fmt.Sprintf("properties/chargeType eq '%s'", "Actual"))
Copy link
Contributor

@aspacca aspacca Aug 12, 2022

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@aspacca, we probably should add what actual/forecast data are, WDYT?

if err != nil {
return usage, fmt.Errorf("retrieving forecast - actual costs failed in client: %w", err)
}
usage.ActualCosts = actualCosts
client.Log.
With("billing.scope", scope).
With("billing.forecast.start_time", timeOpts.forecastStart).
With("billing.forecast.end_time", timeOpts.forecastEnd).
Infow("Getting forecast for scope")

forecastCosts, err := client.BillingService.GetForecast(fmt.Sprintf("properties/chargeType eq '%s'", "Forecast"))
queryResult, err := client.BillingService.GetForecast(scope, timeOpts.forecastStart, timeOpts.forecastEnd)
if err != nil {
return usage, fmt.Errorf("retrieving forecast - forecast costs failed in client: %w", err)
}
usage.ForecastCosts = forecastCosts

usage.Forecasts = queryResult

return usage, nil
}
29 changes: 20 additions & 9 deletions x-pack/metricbeat/module/azure/billing/client_test.go
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/mock"

"github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption"
"github.com/Azure/azure-sdk-for-go/services/costmanagement/mgmt/2019-11-01/costmanagement"

"github.com/elastic/beats/v7/x-pack/metricbeat/module/azure"
)
Expand All @@ -22,32 +23,42 @@ var (
)

func TestClient(t *testing.T) {
startTime, endTime := previousDayFrom(time.Now())
usageStart, usageEnd := usageIntervalFrom(time.Now())
forecastStart, forecastEnd := forecastIntervalFrom(time.Now())
opts := TimeIntervalOptions{
usageStart: usageStart,
usageEnd: usageEnd,
forecastStart: forecastStart,
forecastEnd: forecastEnd,
}

t.Run("return error not valid query", func(t *testing.T) {
client := NewMockClient()
client.Config = config
m := &MockService{}
m.On("GetForecast", mock.Anything).Return([]consumption.Forecast{}, errors.New("invalid query"))
m.On("GetForecast", mock.Anything, mock.Anything, mock.Anything).Return(costmanagement.QueryResult{}, errors.New("invalid query"))
m.On("GetUsageDetails", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(consumption.UsageDetailsListResultPage{}, nil)
client.BillingService = m
results, err := client.GetMetrics(startTime, endTime)
_, err := client.GetMetrics(opts)
assert.Error(t, err)
assert.Equal(t, len(results.ActualCosts), 0)
//assert.NotNil(t, usage.Forecasts)
//assert.True(t, usage.Forecasts.Rows == nil)
//assert.Equal(t, len(*usage.Forecasts.Rows), 0)
m.AssertExpectations(t)
})
t.Run("return results", func(t *testing.T) {
client := NewMockClient()
client.Config = config
m := &MockService{}
forecasts := []consumption.Forecast{{}, {}}
m.On("GetForecast", mock.Anything).Return(forecasts, nil)
forecasts := costmanagement.QueryResult{}
m.On("GetForecast", mock.Anything, mock.Anything, mock.Anything).Return(forecasts, nil)
m.On("GetUsageDetails", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(consumption.UsageDetailsListResultPage{}, nil)
client.BillingService = m
results, err := client.GetMetrics(startTime, endTime)
_, err := client.GetMetrics(opts)
assert.NoError(t, err)
assert.Equal(t, len(results.ActualCosts), 2)
assert.Equal(t, len(results.ForecastCosts), 2)
//assert.NotNil(t, usage.Forecasts.Rows)
//assert.Equal(t, len(*usage.Forecasts.Rows), 2)
// assert.Equal(t, len(results.ForecastCosts), 2)
m.AssertExpectations(t)
})
}