bosun.org@v0.0.0-20210513094433-e25bc3e69a1f/cmd/scollector/collectors/azureeabilling.go (about)

     1  package collectors
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"strconv"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/mhenderson-so/azure-ea-billing"
    11  
    12  	"bosun.org/cmd/scollector/conf"
    13  	"bosun.org/metadata"
    14  	"bosun.org/opentsdb"
    15  )
    16  
    17  var azBillConf = azureEABillingConfig{}
    18  
    19  const (
    20  	hoursInDay = 24
    21  	usageDesc  = "Usage of Azure service. Category is concatenated meter details. Resource is concatenated resource group and resource name."
    22  	costDesc   = "Cost of Azure service. Category is concatenated meter details. Resource is concatenated resource group and resource name."
    23  	priceDesc  = "Azure price sheet data for Enterprise Agreement services"
    24  )
    25  
    26  func init() {
    27  	registerInit(startAzureEABilling)
    28  }
    29  
    30  func startAzureEABilling(c *conf.Conf) {
    31  	for _, config := range c.AzureEA {
    32  		if config.EANumber > 0 && config.APIKey != "" {
    33  			azBillConf = azureEABillingConfig{
    34  				AZEABillingConfig: azureeabilling.Config{
    35  					EA:     config.EANumber,
    36  					APIKey: config.APIKey,
    37  				},
    38  				CollectorConfig: config,
    39  			}
    40  
    41  			collectors = append(collectors, &IntervalCollector{
    42  				F:        c_azureeabilling,
    43  				Interval: 1 * time.Hour,
    44  			})
    45  		}
    46  	}
    47  }
    48  
    49  func c_azureeabilling() (opentsdb.MultiDataPoint, error) {
    50  	var md opentsdb.MultiDataPoint
    51  
    52  	//Get the list of available bills from the portal
    53  	reports, err := azBillConf.AZEABillingConfig.GetUsageReports()
    54  	if err != nil {
    55  		return nil, err
    56  	}
    57  
    58  	//Process the report list
    59  	if err = processAzureEAReports(reports, &md); err != nil {
    60  		return nil, err
    61  	}
    62  
    63  	return md, nil
    64  
    65  }
    66  
    67  // processAzureEAReports will go through the monthly reports provided and pull out the ones that we're going to process
    68  func processAzureEAReports(reports *azureeabilling.UsageReports, md *opentsdb.MultiDataPoint) error {
    69  	baseTime := time.Now()
    70  	thisMonth := baseTime.Format("2006-01")
    71  	lastMonth := time.Date(baseTime.Year(), baseTime.Month()-1, 1, 0, 0, 0, 0, time.UTC).Format("2006-01")
    72  	for _, r := range reports.AvailableMonths {
    73  		//There's potentially a lot of reports. We only want to process this months + last months report
    74  		if !(thisMonth == r.Month || lastMonth == r.Month) {
    75  			continue
    76  		}
    77  
    78  		csv := azBillConf.AZEABillingConfig.GetMonthReportsCSV(r, azureeabilling.DownloadForStructs)
    79  		structs, err := csv.ConvertToStructs()
    80  
    81  		if err != nil {
    82  			return err
    83  		}
    84  		for _, p := range structs.PriceSheetReport {
    85  			err := processAzureEAPriceSheetRow(p, md)
    86  			if err != nil {
    87  				return err
    88  			}
    89  		}
    90  		for _, d := range structs.DetailReport {
    91  			err := processAzureEADetailRow(d, md)
    92  			if err != nil {
    93  				return err
    94  			}
    95  		}
    96  	}
    97  
    98  	return nil
    99  }
   100  
   101  // processAzureEAPriceSheetRow will take the price sheet info and log it, so we can track price changes over time
   102  func processAzureEAPriceSheetRow(p *azureeabilling.PriceSheetRow, md *opentsdb.MultiDataPoint) error {
   103  	fullProdName := fmt.Sprintf("%s-%s", p.Service, p.UnitOfMeasure)
   104  	priceString := convertAzurePriceToString(p.UnitPrice)
   105  	tags := opentsdb.TagSet{
   106  		"partnumber": p.PartNumber,
   107  		"service":    fullProdName,
   108  	}
   109  	Add(md, "azure.ea.pricesheet", priceString, tags, metadata.Gauge, metadata.Count, priceDesc)
   110  	return nil
   111  }
   112  
   113  // processAzureEADetailRow will take the actual usage data for the provided month
   114  func processAzureEADetailRow(p *azureeabilling.DetailRow, md *opentsdb.MultiDataPoint) error {
   115  	//Don't process todays records as they are subject to change
   116  	nowYear, nowMonth, nowDay := time.Now().Date()
   117  	recordMonth := int(nowMonth)
   118  	if nowYear == p.Year && recordMonth == p.Month && nowDay == p.Day {
   119  		return nil
   120  	}
   121  
   122  	resourcePaths := strings.Split(strings.ToLower(p.InstanceID), "/")
   123  	var resourceString string
   124  
   125  	if len(resourcePaths) < 8 {
   126  		resourceString = strings.ToLower(p.InstanceID)
   127  	} else {
   128  		resourceIDs := resourcePaths[8:]
   129  		resourceString = strings.Join(resourceIDs, "-")
   130  	}
   131  
   132  	if p.ResourceGroup != "" {
   133  		resourceString = fmt.Sprintf("%s-%s", strings.ToLower(p.ResourceGroup), resourceString)
   134  	}
   135  
   136  	tags := opentsdb.TagSet{
   137  		"category":    p.MeterCategory,
   138  		"subcategory": fmt.Sprintf("%s-%s", strings.ToLower(p.MeterSubCategory), strings.ToLower(p.MeterName)),
   139  	}
   140  
   141  	resourceString, err := opentsdb.Clean(resourceString)
   142  	if err != nil && resourceString != "" {
   143  		tags["resource"] = resourceString
   144  	}
   145  
   146  	//Only log resource group details if they are enabled in the config
   147  	if azBillConf.CollectorConfig.LogResourceDetails {
   148  		resourcLocation, _ := opentsdb.Clean(p.ResourceLocation)
   149  		resouceGroup, _ := opentsdb.Clean(p.ResourceGroup)
   150  		if resouceGroup != "" {
   151  			tags["resoucegroup"] = strings.ToLower(resouceGroup)
   152  		}
   153  		if resourcLocation != "" {
   154  			tags["resourcelocation"] = strings.ToLower(resourcLocation)
   155  		}
   156  	}
   157  
   158  	//Only log extra Azure tags if enabled in the config
   159  	if azBillConf.CollectorConfig.LogExtraTags {
   160  		if p.Tags != "" {
   161  			customTags := make(map[string]string)
   162  			json.Unmarshal([]byte(p.Tags), &customTags)
   163  			for t, v := range customTags {
   164  				if t[:6] == "hidden" {
   165  					continue
   166  				}
   167  				value, _ := opentsdb.Clean(v)
   168  				if value == "" {
   169  					continue
   170  				}
   171  				tags[strings.ToLower(t)] = strings.ToLower(value)
   172  			}
   173  		}
   174  	}
   175  
   176  	//Only log billing details if they are enabled in the config
   177  	if azBillConf.CollectorConfig.LogBillingDetails {
   178  		if p.CostCenter != "" {
   179  			tags["costcenter"] = strings.ToLower(p.CostCenter)
   180  		}
   181  		cleanAccountName, _ := opentsdb.Clean(p.AccountName)
   182  		tags["accountname"] = strings.ToLower(cleanAccountName)
   183  		tags["subscription"] = strings.ToLower(p.SubscriptionName)
   184  	}
   185  
   186  	recordDate := time.Date(p.Year, time.Month(p.Month), p.Day, 0, 0, 0, 0, time.UTC)
   187  
   188  	//Because we need to log this hourly and we only have daily data, divide the daily cost into hourly costs
   189  	qtyPerHour := p.ConsumedQuantity / hoursInDay
   190  
   191  	//ExtendedCost is stored only in a string, because it's a variable number of decimal places. Which means we can't reliably store it in an int, and storing in a float reduces precision.
   192  	//This way we're choosing ourselves to drop the precision, which adds up to around 10-20c under initial testing.
   193  	costPerDay, err := strconv.ParseFloat(p.ExtendedCostRaw, 64)
   194  	if err != nil {
   195  		return err
   196  	}
   197  	costPerHour := costPerDay / hoursInDay
   198  
   199  	//Get 24 records for 24 hours in a day
   200  	for i := 0; i < hoursInDay; i++ {
   201  		recordTime := recordDate.Add(time.Duration(i) * time.Hour)
   202  		AddTS(md, "azure.ea.usage", recordTime.Unix(), qtyPerHour, tags, metadata.Gauge, metadata.Count, usageDesc)
   203  		AddTS(md, "azure.ea.cost", recordTime.Unix(), costPerHour, tags, metadata.Gauge, metadata.Count, costDesc)
   204  	}
   205  
   206  	return nil
   207  }
   208  
   209  //The cost is stored in cents, and we want to translate the cent cost into dollars and cents, but in a string
   210  //which will not lose precision and is close enough for government work.
   211  func convertAzurePriceToString(costInCents int) string {
   212  	priceString := strconv.Itoa(costInCents)
   213  	priceLen := len(priceString)
   214  	if priceLen == 1 {
   215  		priceString = fmt.Sprintf("0.0%s", priceString)
   216  	}
   217  	if priceLen == 2 {
   218  		priceString = fmt.Sprintf("0.%s", priceString)
   219  	}
   220  	if priceLen >= 3 {
   221  		priceString = fmt.Sprintf("%s.%s", priceString[0:priceLen-2], priceString[priceLen-2:])
   222  	}
   223  
   224  	return priceString
   225  }
   226  
   227  type azureEABillingConfig struct {
   228  	CollectorConfig   conf.AzureEA
   229  	AZEABillingConfig azureeabilling.Config
   230  }