diff --git a/CHANGELOG.next.asciidoc b/CHANGELOG.next.asciidoc index ed6738ada14..f39cbee0558 100644 --- a/CHANGELOG.next.asciidoc +++ b/CHANGELOG.next.asciidoc @@ -79,6 +79,14 @@ https://github.com/elastic/beats/compare/v7.0.0-alpha2...master[Check the HEAD d *Metricbeat* +- Enhance Oracle Module: Change tablespace metricset collection period {issue}30948[30948] {pull}31259[#31259] +- Add orchestrator cluster ECS fields in kubernetes events {pull}31341[31341] +- Enhance Oracle Module: Refactor module to properly use host parsers instead of doing its own parsing of hosts {issue}31611[31611] {pull}31692[#31692] +- Enhance Oracle Module: Connection string for Oracle does not handle special characters properly {issue}24609[24609] {pull}31368[#31368] +- Enhance Oracle Module: New sysmetric metricset {issue}30946[30946] {pull}31462[#31462] +- Upgrade Mongodb library in Beats to v5 {pull}31185[31185] +- Azure Billing: upgrade Usage Details API to version 2019-10-01 {pull}31970[31970] +* Differentiate between actual idle CPU states and an uninterruptible disk sleep. https://github.com/elastic/elastic-agent-system-metrics/pull/32[system-metrics#32] *Packetbeat* diff --git a/NOTICE.txt b/NOTICE.txt index eea476ab985..226fdb989d2 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -862,12 +862,12 @@ Contents of probable licence file $GOMODCACHE/github.com/!azure/azure-event-hubs -------------------------------------------------------------------------------- -Dependency : github.com/Azure/azure-sdk-for-go -Version: v59.0.0+incompatible +Dependency : github.com/elastic/azure-sdk-for-go +Version: v59.0.0-elastic-1+incompatible Licence type (autodetected): MIT -------------------------------------------------------------------------------- -Contents of probable licence file $GOMODCACHE/github.com/!azure/azure-sdk-for-go@v59.0.0+incompatible/LICENSE.txt: +Contents of probable licence file $GOMODCACHE/github.com/elastic/azure-sdk-for-go@v59.0.0-elastic-1+incompatible/LICENSE.txt: The MIT License (MIT) diff --git a/go.mod b/go.mod index 3a9ddc1353f..c26b401f18f 100644 --- a/go.mod +++ b/go.mod @@ -284,6 +284,7 @@ require ( ) replace ( + github.com/Azure/azure-sdk-for-go => github.com/elastic/azure-sdk-for-go v59.0.0-elastic-1+incompatible github.com/Microsoft/go-winio => github.com/bi-zone/go-winio v0.4.15 github.com/Shopify/sarama => github.com/elastic/sarama v1.19.1-0.20210823122811-11c3ef800752 github.com/apoydence/eachers => github.com/poy/eachers v0.0.0-20181020210610-23942921fe77 //indirect, see https://github.com/elastic/beats/pull/29780 for details. diff --git a/go.sum b/go.sum index 53ca1912130..7cc7d4a24ce 100644 --- a/go.sum +++ b/go.sum @@ -69,10 +69,13 @@ github.com/Azure/azure-pipeline-go v0.1.8/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9a github.com/Azure/azure-pipeline-go v0.1.9/go.mod h1:XA1kFWRVhSK+KNFiOhfv83Fv8L9achrP7OxIzeTn1Yg= github.com/Azure/azure-pipeline-go v0.2.1 h1:OLBdZJ3yvOn2MezlWvbrBMTEUQC72zAftRZOMdj5HYo= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= +<<<<<<< HEAD github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v59.0.0+incompatible h1:I1ULJqny1qQhUBFy11yDXHhW3pLvbhwV0PTn7mjp9V0= github.com/Azure/azure-sdk-for-go v59.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +======= +>>>>>>> 1f232dc343 ([Azure Billing] Upgrade Usage Details API to version 2019-10-01 (#31970)) github.com/Azure/azure-storage-blob-go v0.6.0/go.mod h1:oGfmITT1V6x//CswqY2gtAHND+xIP64/qL7a5QJix0Y= github.com/Azure/azure-storage-blob-go v0.8.0 h1:53qhf0Oxa0nOjgbDeeYPUeyiNmafAFEY95rZLK0Tj6o= github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0= @@ -426,8 +429,19 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21 h1:YEetp8 github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +<<<<<<< HEAD github.com/eclipse/paho.mqtt.golang v1.2.1-0.20200121105743-0d940dd29fd2 h1:DW6WrARxK5J+o8uAKCiACi5wy9EK1UzrsCpGBPsKHAA= github.com/eclipse/paho.mqtt.golang v1.2.1-0.20200121105743-0d940dd29fd2/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= +======= +github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= +github.com/eclipse/paho.mqtt.golang v1.3.5 h1:sWtmgNxYM9P2sP+xEItMozsR3w0cqZFlqnNN1bdl41Y= +github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/elastic/azure-sdk-for-go v59.0.0-elastic-1+incompatible h1:jlUO91EFZuvAO+2Zg+WdV0iTWe/x1X8maTxdYIKCWu4= +github.com/elastic/azure-sdk-for-go v59.0.0-elastic-1+incompatible/go.mod h1:4zuQekLQi489ShcqTmS1Zj1ta0qrcNBlSuGa+ziu2vM= +github.com/elastic/bayeux v1.0.5 h1:UceFq01ipmT3S8DzFK+uVAkbCdiPR0Bqei8qIGmUeY0= +github.com/elastic/bayeux v1.0.5/go.mod h1:CSI4iP7qeo5MMlkznGvYKftp8M7qqP/3nzmVZoXHY68= +>>>>>>> 1f232dc343 ([Azure Billing] Upgrade Usage Details API to version 2019-10-01 (#31970)) github.com/elastic/dhcp v0.0.0-20200227161230-57ec251c7eb3 h1:lnDkqiRFKm0rxdljqrj3lotWinO9+jFmeDXIC4gvIQs= github.com/elastic/dhcp v0.0.0-20200227161230-57ec251c7eb3/go.mod h1:aPqzac6AYkipvp4hufTyMj5PDIphF3+At8zr7r51xjY= github.com/elastic/ecs v1.12.0 h1:u6WZ2AWtxv5vHvTQ4EuVZdWZ51mKHQ2UIltRePcta5U= diff --git a/metricbeat/docs/fields.asciidoc b/metricbeat/docs/fields.asciidoc index 11ac5669722..12186665821 100644 --- a/metricbeat/docs/fields.asciidoc +++ b/metricbeat/docs/fields.asciidoc @@ -5693,11 +5693,6 @@ azure module -[float] -=== azure - - - *`azure.timegrain`*:: + @@ -5772,6 +5767,16 @@ type: keyword The subscription ID +type: keyword + +-- + +*`azure.subscription_name`*:: ++ +-- +The subscription name + + type: keyword -- @@ -6060,7 +6065,7 @@ billing and usage details *`azure.billing.currency`*:: + -- -The currency +Billing Currency. type: keyword @@ -6070,7 +6075,27 @@ type: keyword *`azure.billing.pretax_cost`*:: + -- -Cost +The amount of cost before tax. + + +type: float + +-- + +*`azure.billing.unit_price`*:: ++ +-- +Unit Price is the price applicable to you. (your EA or other contract price). + + +type: float + +-- + +*`azure.billing.quantity`*:: ++ +-- +Measure the quantity purchased or consumed. The amount of the meter used during the billing period. type: float @@ -6090,7 +6115,7 @@ type: keyword *`azure.billing.product`*:: + -- -The product type +Product name for the consumed service or purchase. type: keyword @@ -6120,7 +6145,7 @@ type: date *`azure.billing.billing_period_id`*:: + -- -The billing period id +The billing period id. type: keyword @@ -6130,7 +6155,17 @@ type: keyword *`azure.billing.account_name`*:: + -- -The billing account name +Name of the Billing Account. + + +type: keyword + +-- + +*`azure.billing.account_id`*:: ++ +-- +Billing Account identifier. type: keyword diff --git a/x-pack/metricbeat/module/azure/_meta/fields.yml b/x-pack/metricbeat/module/azure/_meta/fields.yml index 8e96461286b..d03ecfe5159 100644 --- a/x-pack/metricbeat/module/azure/_meta/fields.yml +++ b/x-pack/metricbeat/module/azure/_meta/fields.yml @@ -6,7 +6,6 @@ fields: - name: azure type: group - description: > fields: - name: timegrain type: keyword @@ -43,6 +42,10 @@ type: keyword description: > The subscription ID + - name: subscription_name + type: keyword + description: > + The subscription name - name: application_id type: keyword description: > diff --git a/x-pack/metricbeat/module/azure/billing/_meta/fields.yml b/x-pack/metricbeat/module/azure/billing/_meta/fields.yml index 45609ffbd0f..23938c07f11 100644 --- a/x-pack/metricbeat/module/azure/billing/_meta/fields.yml +++ b/x-pack/metricbeat/module/azure/billing/_meta/fields.yml @@ -7,11 +7,19 @@ - name: currency type: keyword description: > - The currency + Billing Currency. - name: pretax_cost type: float description: > - Cost + The amount of cost before tax. + - name: unit_price + type: float + description: > + Unit Price is the price applicable to you. (your EA or other contract price). + - name: quantity + type: float + description: > + Measure the quantity purchased or consumed. The amount of the meter used during the billing period. - name: department_name type: keyword description: > @@ -19,7 +27,7 @@ - name: product type: keyword description: > - The product type + Product name for the consumed service or purchase. - name: usage_start type: date description: > @@ -31,11 +39,15 @@ - name: billing_period_id type: keyword description: > - The billing period id + The billing period id. - name: account_name type: keyword description: > - The billing account name + Name of the Billing Account. + - name: account_id + type: keyword + description: > + Billing Account identifier. - name: actual_cost type: float description: > diff --git a/x-pack/metricbeat/module/azure/billing/billing.go b/x-pack/metricbeat/module/azure/billing/billing.go index ea2a788b292..2f5bfd46d03 100644 --- a/x-pack/metricbeat/module/azure/billing/billing.go +++ b/x-pack/metricbeat/module/azure/billing/billing.go @@ -5,13 +5,17 @@ package billing import ( - "github.com/pkg/errors" - - "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" + "fmt" + "time" "github.com/elastic/beats/v7/libbeat/logp" "github.com/elastic/beats/v7/metricbeat/mb" "github.com/elastic/beats/v7/metricbeat/mb/parse" +<<<<<<< HEAD +======= + "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" + "github.com/elastic/elastic-agent-libs/logp" +>>>>>>> 1f232dc343 ([Azure Billing] Upgrade Usage Details API to version 2019-10-01 (#31970)) ) // init registers the MetricSet with the central registry as soon as the program @@ -24,7 +28,7 @@ func init() { // MetricSet holds any configuration or state information. It must implement // the mb.MetricSet interface. And this is best achieved by embedding -// mb.BaseMetricSet because it implements all of the required mb.MetricSet +// mb.BaseMetricSet because it implements all the required mb.MetricSet // interface methods except for Fetch. type MetricSet struct { mb.BaseMetricSet @@ -38,7 +42,7 @@ func New(base mb.BaseMetricSet) (mb.MetricSet, error) { var config azure.Config err := base.Module().UnpackConfig(&config) if err != nil { - return nil, errors.Wrap(err, "error unpack raw module config using UnpackConfig") + return nil, fmt.Errorf("error unpack raw module config using UnpackConfig: %w", err) } if err != nil { return nil, err @@ -46,11 +50,12 @@ func New(base mb.BaseMetricSet) (mb.MetricSet, error) { // instantiate monitor client billingClient, err := NewClient(config) if err != nil { - return nil, errors.Wrap(err, "error initializing the billing client: module azure - billing metricset") + return nil, fmt.Errorf("error initializing the billing client: module azure - billing metricset: %w", err) } return &MetricSet{ BaseMetricSet: base, client: billingClient, + log: logp.NewLogger("azure billing"), }, nil } @@ -58,11 +63,24 @@ func New(base mb.BaseMetricSet) (mb.MetricSet, error) { // 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 { - results, err := m.client.GetMetrics() + // The time interval is yesterday (00:00:00->23:59:59) in UTC. + startTime, endTime := previousDayFrom(time.Now()) + + m.log. + With("billing.start_time", startTime). + With("billing.end_time", endTime). + Infow("Fetching billing data") + + results, err := m.client.GetMetrics(startTime, endTime) if err != nil { - return errors.Wrap(err, "error retrieving usage information") + return fmt.Errorf("error retrieving usage information: %w", err) } - events := EventsMapping(m.client.Config.SubscriptionId, results) + + events, err := EventsMapping(m.client.Config.SubscriptionId, results, startTime, endTime) + if err != nil { + return fmt.Errorf("error mapping events: %w", err) + } + for _, event := range events { isOpen := report.Event(event) if !isOpen { @@ -72,3 +90,10 @@ 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 +} diff --git a/x-pack/metricbeat/module/azure/billing/billing_test.go b/x-pack/metricbeat/module/azure/billing/billing_test.go new file mode 100644 index 00000000000..ddf65f79af8 --- /dev/null +++ b/x-pack/metricbeat/module/azure/billing/billing_test.go @@ -0,0 +1,28 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package billing + +import ( + "testing" + "time" + + "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) { + 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) + + assert.Equal(t, expectedStartTime, actualStartTime) + assert.Equal(t, expectedEndTime, actualEndTime) + }) +} diff --git a/x-pack/metricbeat/module/azure/billing/client.go b/x-pack/metricbeat/module/azure/billing/client.go index 3f1c73bf2f0..bc138ba7d78 100644 --- a/x-pack/metricbeat/module/azure/billing/client.go +++ b/x-pack/metricbeat/module/azure/billing/client.go @@ -8,14 +8,14 @@ import ( "fmt" "time" - "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" - - "github.com/pkg/errors" - - prevConsumption "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-01-01/consumption" "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" +<<<<<<< HEAD "github.com/elastic/beats/v7/libbeat/logp" +======= + "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" + "github.com/elastic/elastic-agent-libs/logp" +>>>>>>> 1f232dc343 ([Azure Billing] Upgrade Usage Details API to version 2019-10-01 (#31970)) ) // Client represents the azure client which will make use of the azure sdk go metrics related clients @@ -26,12 +26,12 @@ type Client struct { } type Usage struct { - UsageDetails []prevConsumption.UsageDetail + UsageDetails []consumption.BasicUsageDetail ActualCosts []consumption.Forecast ForecastCosts []consumption.Forecast } -// NewClient instantiates the an Azure monitoring client +// NewClient builds a new client for the azure billing service func NewClient(config azure.Config) (*Client, error) { usageService, err := NewService(config) if err != nil { @@ -40,14 +40,13 @@ func NewClient(config azure.Config) (*Client, error) { client := &Client{ BillingService: usageService, Config: config, - Log: logp.NewLogger("azure monitor client"), + Log: logp.NewLogger("azure billing client"), } return client, nil } // GetMetrics returns the usage detail and forecast values. -func (client *Client) GetMetrics() (Usage, error) { - +func (client *Client) GetMetrics(startTime time.Time, endTime time.Time) (Usage, error) { var usage Usage scope := fmt.Sprintf("subscriptions/%s", client.Config.SubscriptionId) if client.Config.BillingScopeDepartment != "" { @@ -55,24 +54,47 @@ func (client *Client) GetMetrics() (Usage, error) { } else if client.Config.BillingScopeAccountId != "" { scope = fmt.Sprintf("/providers/Microsoft.Billing/billingAccounts/%s", client.Config.BillingScopeAccountId) } - startTime := time.Now().UTC().Truncate(24 * time.Hour).Add((-24) * time.Hour) - endTime := startTime.Add(time.Hour * 24).Add(time.Second * (-1)) - usageDetails, 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)), - "", nil, "properties/instanceLocation") + client.Log. + With("billing.scope", scope). + With("billing.start_time", startTime). + With("billing.end_time", endTime). + Infow("Getting usage details for scope") + + usageDetails, 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, + consumption.MetrictypeActualCostMetricType, + startTime.Format("2006-01-02"), // startDate + endTime.Format("2006-01-02"), // endDate + ) if err != nil { - return usage, errors.Wrap(err, "Retrieving usage details failed in client") + return usage, fmt.Errorf("retrieving usage details failed in client: %w", err) } + usage.UsageDetails = usageDetails.Values() - actualCosts, err := client.BillingService.GetForcast(fmt.Sprintf("properties/chargeType eq '%s'", "Actual")) + + // + // Forecast + // + + actualCosts, err := client.BillingService.GetForecast(fmt.Sprintf("properties/chargeType eq '%s'", "Actual")) if err != nil { - return usage, errors.Wrap(err, "Retrieving forecast - actual costs failed in client") + return usage, fmt.Errorf("retrieving forecast - actual costs failed in client: %w", err) } - usage.ActualCosts = *actualCosts.Value - forecastCosts, err := client.BillingService.GetForcast(fmt.Sprintf("properties/chargeType eq '%s'", "Forecast")) + usage.ActualCosts = actualCosts + + forecastCosts, err := client.BillingService.GetForecast(fmt.Sprintf("properties/chargeType eq '%s'", "Forecast")) if err != nil { - return usage, errors.Wrap(err, "Retrieving forecast failed in client") + return usage, fmt.Errorf("retrieving forecast - forecast costs failed in client: %w", err) } - usage.ForecastCosts = *forecastCosts.Value + usage.ForecastCosts = forecastCosts + return usage, nil } diff --git a/x-pack/metricbeat/module/azure/billing/client_test.go b/x-pack/metricbeat/module/azure/billing/client_test.go index c18562fd945..d153bfd909e 100644 --- a/x-pack/metricbeat/module/azure/billing/client_test.go +++ b/x-pack/metricbeat/module/azure/billing/client_test.go @@ -7,12 +7,13 @@ package billing import ( "errors" "testing" + "time" - prevConsumption "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-01-01/consumption" - "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" + "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" ) @@ -21,14 +22,16 @@ var ( ) func TestClient(t *testing.T) { + startTime, endTime := previousDayFrom(time.Now()) + t.Run("return error not valid query", func(t *testing.T) { client := NewMockClient() client.Config = config m := &MockService{} - m.On("GetForcast", mock.Anything).Return(consumption.ForecastsListResult{}, errors.New("invalid query")) - m.On("GetUsageDetails", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(prevConsumption.UsageDetailsListResultPage{}, nil) + m.On("GetForecast", mock.Anything).Return([]consumption.Forecast{}, 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() + results, err := client.GetMetrics(startTime, endTime) assert.Error(t, err) assert.Equal(t, len(results.ActualCosts), 0) m.AssertExpectations(t) @@ -38,10 +41,10 @@ func TestClient(t *testing.T) { client.Config = config m := &MockService{} forecasts := []consumption.Forecast{{}, {}} - m.On("GetForcast", mock.Anything).Return(consumption.ForecastsListResult{Value: &forecasts}, nil) - m.On("GetUsageDetails", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(prevConsumption.UsageDetailsListResultPage{}, nil) + m.On("GetForecast", 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() + results, err := client.GetMetrics(startTime, endTime) assert.NoError(t, err) assert.Equal(t, len(results.ActualCosts), 2) assert.Equal(t, len(results.ForecastCosts), 2) diff --git a/x-pack/metricbeat/module/azure/billing/data.go b/x-pack/metricbeat/module/azure/billing/data.go index 9aafb87d992..092fadbc3d9 100644 --- a/x-pack/metricbeat/module/azure/billing/data.go +++ b/x-pack/metricbeat/module/azure/billing/data.go @@ -5,21 +5,23 @@ package billing import ( - "fmt" "strings" "time" - "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" + "errors" + "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" "github.com/shopspring/decimal" "github.com/elastic/beats/v7/libbeat/common" "github.com/elastic/beats/v7/metricbeat/mb" ) -func EventsMapping(subscriptionId string, results Usage) []mb.Event { +// EventsMapping maps the usage details to a slice of metricbeat events. +func EventsMapping(subscriptionId string, results Usage, startTime time.Time, endTime time.Time) ([]mb.Event, error) { var events []mb.Event if len(results.UsageDetails) > 0 { +<<<<<<< HEAD for _, usageDetail := range results.UsageDetails { event := mb.Event{ ModuleFields: common.MapStr{ @@ -47,17 +49,111 @@ func EventsMapping(subscriptionId string, results Usage) []mb.Event { event.RootFields.Put("cloud.region", usageDetail.InstanceLocation) event.RootFields.Put("cloud.instance.name", usageDetail.InstanceName) event.RootFields.Put("cloud.instance.id", usageDetail.InstanceID) +======= + for _, ud := range results.UsageDetails { + event := mb.Event{Timestamp: time.Now().UTC()} + + // shared fields + event.RootFields = mapstr.M{ + "cloud.provider": "azure", + } + + if legacy, isLegacy := ud.AsLegacyUsageDetail(); isLegacy { + + // + // legacy data format + // + + event.ModuleFields = mapstr.M{ + "subscription_id": legacy.SubscriptionID, + "subscription_name": legacy.SubscriptionName, + "resource": mapstr.M{ + "name": legacy.ResourceName, + "type": legacy.ConsumedService, + "group": legacy.ResourceGroup, + }, + } + event.MetricSetFields = mapstr.M{ + // original fields + "billing_period_id": legacy.ID, + "product": legacy.Product, + "pretax_cost": legacy.Cost, + "currency": legacy.BillingCurrency, + "department_name": legacy.InvoiceSection, + "account_name": legacy.BillingAccountName, + "usage_start": startTime, + "usage_end": endTime, + + // additional fields + "usage_date": legacy.Date, // Date for the usage record. + "account_id": legacy.BillingAccountID, + "unit_price": legacy.UnitPrice, + "quantity": legacy.Quantity, + } + _, _ = event.RootFields.Put("cloud.region", legacy.ResourceLocation) + _, _ = event.RootFields.Put("cloud.instance.name", legacy.ResourceName) + _, _ = event.RootFields.Put("cloud.instance.id", legacy.ResourceID) + + } else if modern, isModern := ud.AsModernUsageDetail(); isModern { + + // + // modern data format + // + + event.ModuleFields = mapstr.M{ + "subscription_id": modern.SubscriptionGUID, + "subscription_name": modern.SubscriptionName, + "resource": mapstr.M{ + "name": getResourceNameFromPath(*modern.InstanceName), + "type": modern.ConsumedService, + "group": modern.ResourceGroup, + }, + } + event.MetricSetFields = mapstr.M{ + // original fields + "billing_period_id": modern.ID, + "product": modern.Product, + "pretax_cost": modern.CostInBillingCurrency, + "currency": modern.BillingCurrencyCode, + "department_name": modern.InvoiceSectionName, + "account_name": modern.BillingAccountName, + "usage_start": startTime, + "usage_end": endTime, + + // additional fields + "usage_date": modern.Date, // Date for the usage record. + "account_id": modern.BillingAccountID, + "unit_price": modern.UnitPrice, + "quantity": modern.Quantity, + } + _, _ = event.RootFields.Put("cloud.region", modern.ResourceLocation) + + } else { + + // + // Unsupported data format + // + return events, errors.New("unsupported usage details format: not legacy nor modern") + } + +>>>>>>> 1f232dc343 ([Azure Billing] Upgrade Usage Details API to version 2019-10-01 (#31970)) events = append(events, event) } } + // + // Forecasts + // groupedCosts := make(map[*string][]consumption.Forecast) + for _, forecast := range results.ForecastCosts { groupedCosts[forecast.UsageDate] = append(groupedCosts[forecast.UsageDate], forecast) } + for _, forecast := range results.ActualCosts { groupedCosts[forecast.UsageDate] = append(groupedCosts[forecast.UsageDate], forecast) } + for usageDate, items := range groupedCosts { var actualCost *decimal.Decimal var forecastCost *decimal.Decimal @@ -68,10 +164,12 @@ func EventsMapping(subscriptionId string, results Usage) []mb.Event { forecastCost = item.Charge } } + parsedDate, err := time.Parse("2006-01-02", *usageDate) if err != nil { parsedDate = time.Now().UTC() } + event := mb.Event{ RootFields: common.MapStr{ "cloud.provider": "azure", @@ -87,19 +185,22 @@ func EventsMapping(subscriptionId string, results Usage) []mb.Event { }, Timestamp: time.Now().UTC(), } - //event.ID = generateEventID(parsedDate) + events = append(events, event) } - return events + + return events, nil } -// getResourceGroupFromId maps resource group from resource ID -func getResourceGroupFromId(path string) string { - params := strings.Split(path, "/") - for i, param := range params { - if param == "resourceGroups" { - return fmt.Sprintf("%s", params[i+1]) - } - } - return "" +// getResourceNameFromPath returns the resource name by picking the last part from a `/` separated resource path. +// +// For example, given a path like the following: +// `/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}` +// +// It would return the value `{vmName}`. +func getResourceNameFromPath(path string) string { + parts := strings.Split(path, "/") + // According to the documentation, `string.Split()` always returns a non-empty slice when the separator is not empty, + // so it should be safe to use `len(parts) - 1` to get the last element. + return parts[len(parts)-1] } diff --git a/x-pack/metricbeat/module/azure/billing/data_test.go b/x-pack/metricbeat/module/azure/billing/data_test.go index 8d883d2862d..0f3f22b5bd3 100644 --- a/x-pack/metricbeat/module/azure/billing/data_test.go +++ b/x-pack/metricbeat/module/azure/billing/data_test.go @@ -8,19 +8,22 @@ import ( "testing" "time" - prevConsumption "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-01-01/consumption" - consumption "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" "github.com/Azure/go-autorest/autorest/date" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" + + "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" ) func TestEventMapping(t *testing.T) { + ID := "ID" + kind := "legacy" usageDate := "2020-08-08" name := "test" + billingAccountId := "123" startDate := date.Time{} - var charge decimal.Decimal = decimal.NewFromFloat(8.123456) + var charge = decimal.NewFromFloat(8.123456) var prop = consumption.ForecastProperties{ UsageDate: &usageDate, Grain: "", @@ -37,26 +40,21 @@ func TestEventMapping(t *testing.T) { ChargeType: "Actual", ConfidenceLevels: nil, } - var prop1 = prevConsumption.UsageDetailProperties{ - InstanceName: &name, - SubscriptionName: &name, - AccountName: &name, - DepartmentName: &name, - Product: &name, - InstanceID: &name, - UsageStart: &startDate, - UsageEnd: &startDate, + var pros = consumption.LegacyUsageDetailProperties{ + BillingAccountID: &billingAccountId, + BillingAccountName: &name, + BillingPeriodStartDate: &startDate, + BillingPeriodEndDate: &startDate, + Cost: &charge, + InvoiceSection: &name, + Product: &name, } - usage := Usage{ - UsageDetails: []prevConsumption.UsageDetail{ - { - UsageDetailProperties: &prop1, - ID: nil, - Name: nil, - Type: nil, - Tags: nil, - }, - }, + var legacy = consumption.LegacyUsageDetail{ + ID: &ID, + Kind: consumption.Kind(kind), + LegacyUsageDetailProperties: &pros, + } + var usage = Usage{UsageDetails: []consumption.BasicUsageDetail{legacy}, ActualCosts: []consumption.Forecast{ { ForecastProperties: &prop2, @@ -74,8 +72,14 @@ func TestEventMapping(t *testing.T) { Tags: nil, }}, } - events := EventsMapping("sub", usage) + + startTime := time.Now().UTC().Truncate(24 * time.Hour).Add((-48) * time.Hour) + endTime := startTime.Add(time.Hour * 24).Add(time.Second * (-1)) + + events, err := EventsMapping("sub", usage, startTime, endTime) + assert.NoError(t, err) assert.Equal(t, len(events), 2) + for _, event := range events { if ok, _ := event.MetricSetFields.HasKey("department_name"); ok { diff --git a/x-pack/metricbeat/module/azure/billing/mock_service.go b/x-pack/metricbeat/module/azure/billing/mock_service.go index 2ab1d557855..bb521a6cb41 100644 --- a/x-pack/metricbeat/module/azure/billing/mock_service.go +++ b/x-pack/metricbeat/module/azure/billing/mock_service.go @@ -7,12 +7,17 @@ package billing import ( "github.com/stretchr/testify/mock" - "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" + "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" +<<<<<<< HEAD "github.com/elastic/beats/v7/libbeat/logp" prevConsumption "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-01-01/consumption" "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" +======= + "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" + "github.com/elastic/elastic-agent-libs/logp" +>>>>>>> 1f232dc343 ([Azure Billing] Upgrade Usage Details API to version 2019-10-01 (#31970)) ) // MockService mock for the azure monitor services @@ -30,13 +35,13 @@ func NewMockClient() *Client { } // GetForcast is a mock function for the billing service -func (service *MockService) GetForcast(filter string) (consumption.ForecastsListResult, error) { +func (service *MockService) GetForecast(filter string) ([]consumption.Forecast, error) { args := service.Called(filter) - return args.Get(0).(consumption.ForecastsListResult), args.Error(1) + return args.Get(0).([]consumption.Forecast), args.Error(1) } // GetUsageDetails is a mock function for the billing service -func (service *MockService) GetUsageDetails(scope string, expand string, filter string, skiptoken string, top *int32, apply string) (prevConsumption.UsageDetailsListResultPage, error) { - args := service.Called(scope, expand, filter, skiptoken, top, apply) - return args.Get(0).(prevConsumption.UsageDetailsListResultPage), args.Error(1) +func (service *MockService) GetUsageDetails(scope string, expand string, filter string, skiptoken string, top *int32, metricType consumption.Metrictype, startDate string, endDate string) (consumption.UsageDetailsListResultPage, error) { + args := service.Called(scope, expand, filter, skiptoken, top, metricType, startDate, endDate) + return args.Get(0).(consumption.UsageDetailsListResultPage), args.Error(1) } diff --git a/x-pack/metricbeat/module/azure/billing/service.go b/x-pack/metricbeat/module/azure/billing/service.go index cb7d588a0f2..d13c5b1a533 100644 --- a/x-pack/metricbeat/module/azure/billing/service.go +++ b/x-pack/metricbeat/module/azure/billing/service.go @@ -7,31 +7,42 @@ package billing import ( "context" - "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" - "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-10-01/consumption" "github.com/Azure/go-autorest/autorest/azure/auth" +<<<<<<< HEAD prevConsumption "github.com/Azure/azure-sdk-for-go/services/consumption/mgmt/2019-01-01/consumption" "github.com/elastic/beats/v7/libbeat/logp" +======= + "github.com/elastic/beats/v7/x-pack/metricbeat/module/azure" + "github.com/elastic/elastic-agent-libs/logp" +>>>>>>> 1f232dc343 ([Azure Billing] Upgrade Usage Details API to version 2019-10-01 (#31970)) ) -// Service interface for the azure monitor service and mock for testing +// Service offers access to Azure Usage Details and Forecast data. type Service interface { - GetForcast(filter string) (consumption.ForecastsListResult, error) - GetUsageDetails(scope string, expand string, filter string, skiptoken string, top *int32, apply string) (prevConsumption.UsageDetailsListResultPage, error) + GetForecast(filter string) ([]consumption.Forecast, error) + GetUsageDetails( + scope string, + expand string, + filter string, + skipToken string, + top *int32, + metricType consumption.Metrictype, + startDate string, + endDate string) (consumption.UsageDetailsListResultPage, error) } -// BillingService service wrapper to the azure sdk for go +// UsageService is a thin wrapper to the Usage Details API and the Forecast API from the Azure SDK for Go. type UsageService struct { - usageDetailsClient *prevConsumption.UsageDetailsClient - forcastsClient *consumption.ForecastsClient + usageDetailsClient *consumption.UsageDetailsClient + forecastsClient *consumption.ForecastsClient context context.Context log *logp.Logger } -// NewService instantiates the Azure monitoring service +// NewService builds a new UsageService using the given config. func NewService(config azure.Config) (*UsageService, error) { clientConfig := auth.NewClientCredentialsConfig(config.ClientId, config.ClientSecret, config.TenantId) clientConfig.AADEndpoint = config.ActiveDirectoryEndpoint @@ -40,26 +51,53 @@ func NewService(config azure.Config) (*UsageService, error) { if err != nil { return nil, err } - forcastsClient := consumption.NewForecastsClientWithBaseURI(config.ResourceManagerEndpoint, config.SubscriptionId) - usageDetailsClient := prevConsumption.NewUsageDetailsClientWithBaseURI(config.ResourceManagerEndpoint, config.SubscriptionId) - forcastsClient.Authorizer = authorizer + forecastsClient := consumption.NewForecastsClientWithBaseURI(config.ResourceManagerEndpoint, config.SubscriptionId) + usageDetailsClient := consumption.NewUsageDetailsClientWithBaseURI(config.ResourceManagerEndpoint, config.SubscriptionId) + + forecastsClient.Authorizer = authorizer usageDetailsClient.Authorizer = authorizer - service := &UsageService{ + + service := UsageService{ usageDetailsClient: &usageDetailsClient, - forcastsClient: &forcastsClient, + forecastsClient: &forecastsClient, context: context.Background(), log: logp.NewLogger("azure billing service"), } - return service, nil + + return &service, nil } -// GetForcast -func (service *UsageService) GetForcast(filter string) (consumption.ForecastsListResult, error) { - return service.forcastsClient.List(service.context, filter) +// GetForecast fetches the forecast for the given filter. +func (service *UsageService) GetForecast(filter string) ([]consumption.Forecast, error) { + response, err := service.forecastsClient.List(service.context, filter) + if err != nil { + switch response.StatusCode { + case 404: + // Forecast API returns 404 when the subscription does not support forecasts. + // For example, at the time of writing, forecasts are only available to + // enterprises subscriptions: + // + // "[Forecasts API] Provides operations to get usage forecasts for Enterprise + // Subscriptions." [1] + // + // [1]: https://docs.microsoft.com/en-us/rest/api/consumption/ + service.log. + With("billing.filter", filter). + With("billing.subscription_id", service.forecastsClient.SubscriptionID). + Warnf( + "no forecasts available for subscription; possibly because the subscription is not an enterprise subscription. For details, see: https://docs.microsoft.com/en-us/rest/api/consumption/", + ) + return []consumption.Forecast{}, nil + default: + return nil, err + } + } + + return *response.Value, nil } -// GetUsageDetails -func (service *UsageService) GetUsageDetails(scope string, expand string, filter string, skiptoken string, top *int32, apply string) (prevConsumption.UsageDetailsListResultPage, error) { - return service.usageDetailsClient.List(service.context, scope, expand, filter, skiptoken, top, apply) +// GetUsageDetails fetches the usage details for the given filters. +func (service *UsageService) GetUsageDetails(scope string, expand string, filter string, skipToken string, top *int32, metrictype consumption.Metrictype, startDate string, endDate string) (consumption.UsageDetailsListResultPage, error) { + return service.usageDetailsClient.List(service.context, scope, expand, filter, skipToken, top, metrictype, startDate, endDate) } diff --git a/x-pack/metricbeat/module/azure/fields.go b/x-pack/metricbeat/module/azure/fields.go index b5165fc5a67..7adae8bb608 100644 --- a/x-pack/metricbeat/module/azure/fields.go +++ b/x-pack/metricbeat/module/azure/fields.go @@ -19,5 +19,5 @@ func init() { // AssetAzure returns asset data. // This is the base64 encoded zlib format compressed contents of module/azure. func AssetAzure() string { - return "eJzkmc9u67YSxvd5ikGWAU4ewIsL5LZdnEXRon/WxJgaK2wkkoccOsd9+oKiJMuSLMu27CaoFwZiSt/vG3JmJDJf4I12K8C/g6MHAFZc0AoeX+Lfjw8AGXnplGVl9Ar+9wAA6VooTRaKeIujgtDTCnJ8ANgoKjK/qi78AhpL2ovHD+9svNSZYOtfRgiHMl0pViXlDpVuRxrJN9q9G5d1fh8VTp8/XgleUhjETskR3YboyJvgJA2A3Rhm4Bod8Jak2ijqWu2HexDyztLBwPGIT9horMTbwWyAO7ZG0fF7WXRUnIXuz+4C7HYFhtrtXGPun59GsWb9F0nuDaUfxZSxziWiRGuVzuvrH58ezwsiZWwbRmV2kK/x21scSdizK6SVAk8FSe6kbEPzYd1KCJVdz+wKwtcfB0C0tlASF+N19MZwmSpJe2X0YVocSYkT6TA3FSYsHzStjrmB8XSJf3462/emMHhk8FLXPycz4IiD05QN7aK1Qmmv8lf2Jztt+8RZE+M8B91lHuFM9V/P6FhkyOOtcGRgRi+qRIf3NkzS2dJE0tlx3ni2wBnNr581sGjriwHUHkdTx/NhZDfJmz7kv540jr4F8uyFNEHzsw/lKHksM06gf0vKUAlPszeoCspuAU/KEx6CJ9cEH7T6FsZn/gILf3pyE2BPvur6N2H/nsRPxo2BX0lzLA7KFvbw0tVOvAk/a2fePTnBqlQ690ITvxv3JrLgqsJ9xm2+lLP/JxbULKhZ0LBmGfRVpd7Bna/q9xxrjiSpLYk7mKtR5/mzzsiYoDq/ywTucefZZMNY3MVhRZo2R98lVXo36NQ/NdoT9dnh1/N0QweA3KwGFLSl4pQlT257c0cJMmHIktsYV6KWlBYpttiSSuN2AreoClwXJNY7Jr9kNv26x0KDhYSFFgsVdr7tumiEdWqLfC/XNRVq6qWmpQ3CkpOkGXNKhXw379IG2MNTbV8bwr9k/mzbylQrFp0LT9Lo7G7Ov/6SsiX6g8Qe7DLWqiiUzm+xx6ilAXV814kLnxGjKuZuUGVwjrTcjc7Vxadlo6rtQjpi/C6k8f11uHiFfuhrtUcwZNFxSZrF8ueRe/HhYec+WJOFwe73SnAtOjzd3b9lx/5TbfkW3NCl/DqxkUxo0v2wrgdP7ifrOogdQJns8HQPrp/xpsySPPTk29MEWbWHG6RaW+aJcDzfUHLAYtHiqk46K1kYyDbYjXEk0fPi4Eb4ODol3MJnF3Ur7d7bdkxT2sAktmXvxOvwf1mHLu5+arp3ORGA8BIL8sSfI5LWbjs+EptmVJqcUNpzfGJ/7NBqt9C4nQjIUa48u93nCKhxOxFQ3Neoz7JAtdlBOBkyrtGTqDvzR46m8do8RQbBlEYrNm7+e2o+8y11KNx/Jz1e0p6Ni1uRDzyxtcWj81qPf4Yc6YfyTwAAAP//nCIVNA==" + return "eJzkmc1u4zYQx+95ikFO7QLrB8ihQLbdwx62XfTjLNDUWGZXIrXDoTfu0xckRVmWZPlLdhM0hwCRxPn/hvxzJE7ew1fcPoH4xxE+ALDiEp/g8dn//fgAkKOVpGpWRj/BTw8AEJ+FyuSu9EMISxQWn6AQDwArhWVun8KD70GLCnfB/Q9va/8oGVc3V7ojuqNYVViQULq9k0Z/xe13Q3nn+ghl+vlzjfAciZFJyZG4SZHQGkcSB4Jd3BPkUhywNUq1UthF7ae7l/K2xr0bhzM+gpFQ/HAwK+AO1qi0/z2vtI94knR/dmfQbldgGLuda1HYxbtRWbP8GyX3bsWL2RRY55GsEnWtdNE8//ju8bwkomPbNALswK/+t63FiGHP3iFtKLBYouSOZZOadcs2RKby6zW7AeHTL9OCPX/OIbkXsq1VdV0qKWZLshNvLMdcVaitMnrfiwd8eMSDp/pvAnmvUnbgBuDxEbt4dzb3qjTiwM1LqT9HGCBkRxrzIa6o60xpq4o126PlvX2jLZHFaQTdZR7RmSr6lgVxlgser78jN04ogCHocGzSRJ3PrYg6P6w37hY4o+L2XQOz1lufQMM4ah3L+5ndxDd9kf+7aQi/ObRsM2mc5oV11ajymDOOSP8eI0MIPK29EqrE/BbiMfIEg7NIKXmn1Tc3PvMXIPxlkSaELdpQ9W+i/UcMfjRv4XiNmv3mwHxmhudu7Kg3wbMk890iZawqpQubaeTvhr5muaOwcRdiU8xF9iFqQaMFjRYkrZMAbdipd6CzYf+eg0YoUW0wuwNcI3UeX01GeoPq4i4TuJM7D5MNi/IuhEFpGg5fJIZ4N6jUH1Psif3Z0W/m6YYEIDitBpS4wfIYkkXa3JwoikwA1UgrQ5XQEuMi+RJbYWVom4mNUKVYlpgtt4x2Tjd92clCkoUoC60sBNnTsZtNk9WkNoLvRd2oQqN6KbSsXVYjSdQsCowb+W7ssnawE497+9oU/iP4s7GVCSvmyTOL0uj8buSffotu8XwQtQenjKUqS6WLW5wxmtAgtP/W8QufIwtVnnpAlY4ItdyOztVFLboPDdHPTeTF+GoSsnjJpLH9xbh4mUJHpgpV06zAR4YlrgwhsHgZp3BasS81cr6vb60YvviIoGxoj4bw6SzoKyIb2Bq3gB+2xhF8fAZDYHgdvto1k5Acx/w4zvzNCc2Kx1fsAuLPKKzzk7TGNjbUjuRaWMw9nDTaugrzRW+KOR6skfxXdvhW9OvuryZX1kjK5ON55FgL4go19/t/cH2PeBd82PzeWdDkbtCYuEL4SwwYm+MrQ2Eq0uSF17h3gqF2dg+Y0m/jLJzXZzyNx+JwpAsQpVH3E79eeLIZ0Ngli3bZb83C9WbYdyOoA4YUMhT3md34a+c/Jak0PkelaYw5Z6EnDCr3J+SVQjrEwE6U81fnEBYGYZOsL9ZSWJ5dOAU+LB2dP3MHrHkhd8e2711T1Y4x21S9vun+f1z3Ke7ee99RTiSQWSlKtMhvI5MWt70/kptmoTRSprRl/933ulNraCHRTiREWCjLtH0bCSXaiYSa1+rbyKeBHaSTCxZLYTFriv9rziaxphfVIJnKaMWGTj/tFCeedYaB+yebw1vasiF/oH3FE9sgHpzX5v5b8Eg/lX8DAAD//4191+g=" }