Skip to content

Commit

Permalink
Merge branch 'main' into dependabot/nuget/src/CsvHelper-31.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
mivano authored Feb 16, 2024
2 parents 93121f8 + ed932aa commit 5fde962
Show file tree
Hide file tree
Showing 12 changed files with 109 additions and 27 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/issue-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
SEARCH_QUERY: 'repo:mivano/azure-cost-cli is:issue created:${{ env.last_month }} -reason:"not planned"'

- name: Create issue
uses: peter-evans/create-issue-from-file@v4
uses: peter-evans/create-issue-from-file@v5
with:
title: Monthly issue metrics report
content-filepath: ./issue_metrics.md
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ OPTIONS:
--useUSD Force the use of USD for the currency. Defaults to false to use the currency returned by the API
--skipHeader Skip header creation for specific output formats. Useful when appending the output from multiple runs into one file. Defaults to false
--filter Filter the output by the specified properties. Defaults to no filtering and can be multiple values.
--includeTags Include Tags from the selected dimension. Valid only for DailyCost report and output to Json, JsonC or Csv. Ignored in the rest of reports and output formats.
-m, --metric ActualCost The metric to use for the costs. Defaults to ActualCost. (ActualCost, AmortizedCost)

COMMANDS:
Expand Down Expand Up @@ -207,6 +208,22 @@ The above screenshots show the default console output, but the other formatters
The available dimensions are: `ResourceGroup`,`ResourceGroupName`,`ResourceLocation`,`ConsumedService`,`ResourceType`,`ResourceId`,`MeterId`,`BillingMonth`,`MeterCategory`,`MeterSubcategory`,`Meter`,`AccountName`,`DepartmentName`,`SubscriptionId`,`SubscriptionName`,`ServiceName`,`ServiceTier`,`EnrollmentAccountName`,`BillingAccountId`,`ResourceGuid`,`BillingPeriod`,`InvoiceNumber`,`ChargeType`,`PublisherType`,`ReservationId`,`ReservationName`,`Frequency`,`PartNumber`,`CostAllocationRuleName`,`MarkupRuleName`,`PricingModel`,`BenefitId`,`BenefitName`
### Include Tags
This option allows to include the dimensions' Tags in the same row. Tags allow cost analysis customization. Adding the Tags from the dimension allows complementary analysis in tools like Power BI. This option is enabled for DailyCost report and for Json, JsonC, and Csv expor formats. Using other formats, ignores the option.
The following query shows the daily costs for subscription x group by resource group name including the tags for the resource group ready to export to Csv:
```bash
azure-cost dailyCosts -s XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX --dimension ResourceGroupName --includeTags -o Csv
```
That would extend into a column called Tags the resource group tags in Json format:
```bash
[""\""cost-center\"":\""my_cost_center\"""",""\""owner\"":\""[email protected]\""""]
```
Note that the Json column should be parsed in the analytical tool.
### Detect Anomalies
Based on the daily cost data, this command will try to detect anomalies and trends. It will scan for the following anomalies:
Expand Down
7 changes: 6 additions & 1 deletion src/Commands/CostSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ public class CostSettings : LogCommandSettings, ICostSettings
[Description("The metric to use for the costs. Defaults to ActualCost. (ActualCost, AmortizedCost)")]
[DefaultValue(MetricType.ActualCost)]
public MetricType Metric { get; set; } = MetricType.ActualCost;


[CommandOption("--includeTags")]
[Description("Include Tags from the selected dimension. The option is used for DailyCost report and output to Json, JsonC or Csv. Valid only for DailyCost report and output to Json, JsonC or Csv. Ignored in other reports and output formats.")]
[DefaultValue(false)]
public bool IncludeTags { get; set; }


public Scope GetScope
{
Expand Down
12 changes: 11 additions & 1 deletion src/Commands/DailyCost/DailyCost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using AzureCostCli.OutputFormatters;
using Spectre.Console;
using Spectre.Console.Cli;
using System;

namespace AzureCostCli.Commands.DailyCost;

Expand Down Expand Up @@ -86,6 +87,14 @@ public override async Task<int> ExecuteAsync(CommandContext context, DailyCostSe

IEnumerable<CostDailyItem> dailyCost = new List<CostDailyItem>();

// if output format is not csv, json, or jsonc, then don't include tags
if (settings.Output != OutputFormat.Json &&
settings.Output != OutputFormat.Jsonc &&
settings.Output != OutputFormat.Csv)
{
settings.IncludeTags = false;
}

await AnsiConsoleExt.Status()
.StartAsync("Fetching daily cost data...", async ctx =>
{
Expand All @@ -96,7 +105,8 @@ await AnsiConsoleExt.Status()
settings.Metric,
settings.Dimension,
settings.Timeframe,
settings.From, settings.To);
settings.From, settings.To,
settings.IncludeTags);
});

// Write the output
Expand Down
3 changes: 2 additions & 1 deletion src/Commands/DetectAnomaly/DetectAnomaly.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ public override async Task<int> ExecuteAsync(CommandContext context, DetectAnoma
settings.Metric,
settings.Dimension,
settings.Timeframe,
settings.From, settings.To);
settings.From, settings.To,
false);

var costAnalyzer = new CostAnalyzer(settings);

Expand Down
43 changes: 33 additions & 10 deletions src/CostApi/AzureCostApiRetriever.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ public async Task<IEnumerable<CostItem>> RetrieveCosts(bool includeDebugOutput,
TimeframeType timeFrame, DateOnly from, DateOnly to)
{
var filters = GenerateFilters(filter);
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000");
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2023-03-01&$top=5000");

var payload = new
{
Expand Down Expand Up @@ -222,7 +222,7 @@ public async Task<IEnumerable<CostItem>> RetrieveCosts(bool includeDebugOutput,
public async Task<IEnumerable<CostNamedItem>> RetrieveCostByServiceName(bool includeDebugOutput,
Scope scope, string[] filter, MetricType metric, TimeframeType timeFrame, DateOnly from, DateOnly to)
{
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000");
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2023-03-01&$top=5000");

var payload = new
{
Expand Down Expand Up @@ -294,7 +294,7 @@ public async Task<IEnumerable<CostNamedItem>> RetrieveCostByLocation(bool includ
string[] filter,MetricType metric,
TimeframeType timeFrame, DateOnly from, DateOnly to)
{
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000");
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2023-03-01&$top=5000");

var payload = new
{
Expand Down Expand Up @@ -366,7 +366,7 @@ public async Task<IEnumerable<CostNamedItem>> RetrieveCostByResourceGroup(bool i
Scope scope, string[] filter,MetricType metric,
TimeframeType timeFrame, DateOnly from, DateOnly to)
{
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000");
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2023-03-01&$top=5000");

var payload = new
{
Expand Down Expand Up @@ -443,7 +443,7 @@ public async Task<IEnumerable<CostNamedItem>> RetrieveCostBySubscription(bool in
Scope scope, string[] filter, MetricType metric,
TimeframeType timeFrame, DateOnly from, DateOnly to)
{
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000");
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2023-03-01&$top=5000");

var payload = new
{
Expand Down Expand Up @@ -518,10 +518,9 @@ public async Task<IEnumerable<CostNamedItem>> RetrieveCostBySubscription(bool in

public async Task<IEnumerable<CostDailyItem>> RetrieveDailyCost(bool includeDebugOutput,
Scope scope, string[] filter, MetricType metric, string dimension,
TimeframeType timeFrame, DateOnly from, DateOnly to)
TimeframeType timeFrame, DateOnly from, DateOnly to, bool includeTags)
{
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000");

var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2023-03-01&$top=5000");

var payload = new
{
Expand All @@ -537,6 +536,7 @@ public async Task<IEnumerable<CostDailyItem>> RetrieveDailyCost(bool includeDebu
dataSet = new
{
granularity = "Daily",
include = includeTags ? new[] { "Tags" } : null,
aggregation = new
{
totalCost = new
Expand Down Expand Up @@ -587,9 +587,32 @@ public async Task<IEnumerable<CostDailyItem>> RetrieveDailyCost(bool includeDebu
var value = double.Parse(row[0].ToString(), CultureInfo.InvariantCulture);
var valueUsd = double.Parse(row[1].ToString(), CultureInfo.InvariantCulture);

// if includeTags is true, row[5] is the tag, and row[6] is the currency, otherwise row[5] is the currency
var currency = row[5].ToString();
Dictionary<string, string>? tags =null;

// if includeTags is true, switch the value between currency and tags
// that's the order how the API REST exposes the resultset
if (includeTags)
{
var tagsArray = row[5].EnumerateArray().ToArray();

tags = new Dictionary<string, string>();

foreach (var tagString in tagsArray)
{
var parts = tagString.GetString().Split(':');
if (parts.Length == 2) // Ensure the string is in the format "key:value"
{
var key = parts[0].Trim('"'); // Remove quotes from the key
var tagValue = parts[1].Trim('"'); // Remove quotes from the value
tags[key] = tagValue;
}
}
currency = row[6].ToString();
}

var costItem = new CostDailyItem(date, resourceGroupName, value, valueUsd, currency);
var costItem = new CostDailyItem(date, resourceGroupName, value, valueUsd, currency, tags);
items.Add(costItem);
}

Expand Down Expand Up @@ -696,7 +719,7 @@ public async Task<IEnumerable<CostResourceItem>> RetrieveCostForResources(bool i
DateOnly from,
DateOnly to)
{
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2021-10-01&$top=5000");
var uri = DeterminePath(scope, "/providers/Microsoft.CostManagement/query?api-version=2023-03-01&$top=5000");

object grouping;
if (excludeMeterDetails == false)
Expand Down
3 changes: 2 additions & 1 deletion src/CostApi/CostDailyItem.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
namespace AzureCostCli.CostApi;

public record CostDailyItem(DateOnly Date, string Name, double Cost, double CostUsd, string Currency);
public record CostDailyItem(DateOnly Date, string Name, double Cost, double CostUsd, string Currency, Dictionary<string, string>? Tags);
public record CostDailyItemWithoutTags(DateOnly Date, string Name, double Cost, double CostUsd, string Currency);
2 changes: 1 addition & 1 deletion src/CostApi/ICostRetriever.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Task<IEnumerable<CostResourceItem>> RetrieveCostForResources(bool settingsDebug,
Task<IEnumerable<UsageDetails>> RetrieveUsageDetails(bool includeDebugOutput,
Scope scope, string filter, DateOnly from, DateOnly to);

Task<IEnumerable<CostDailyItem>> RetrieveDailyCost(bool settingsDebug, Scope scope, string[] filter,MetricType metric, string dimension, TimeframeType settingsTimeframe, DateOnly settingsFrom, DateOnly settingsTo);
Task<IEnumerable<CostDailyItem>> RetrieveDailyCost(bool settingsDebug, Scope scope, string[] filter,MetricType metric, string dimension, TimeframeType settingsTimeframe, DateOnly settingsFrom, DateOnly settingsTo, bool includeTags);
}


Expand Down
14 changes: 13 additions & 1 deletion src/OutputFormatters/CsvOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public override Task WriteBudgets(BudgetsSettings settings, IEnumerable<BudgetIt

public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable<CostDailyItem> dailyCosts)
{
return ExportToCsv(settings.SkipHeader, dailyCosts);
return ExportToCsv(settings.SkipHeader, dailyCosts);
}

public override Task WriteAnomalyDetectionResults(DetectAnomalySettings settings, List<AnomalyDetectionResult> anomalies)
Expand Down Expand Up @@ -126,6 +126,7 @@ private static Task ExportToCsv(bool skipHeader, IEnumerable<object> resources)
using (var csv = new CsvWriter(writer, config))
{
csv.Context.TypeConverterCache.AddConverter<double>(new CustomDoubleConverter());
csv.Context.TypeConverterCache.AddConverter<Dictionary<string, string>>(new TagsConverter());
csv.WriteRecords(resources);

Console.Write(writer.ToString());
Expand All @@ -137,6 +138,17 @@ private static Task ExportToCsv(bool skipHeader, IEnumerable<object> resources)



}

public class TagsConverter : DefaultTypeConverter
{
public override string ConvertToString(object value, IWriterRow row, MemberMapData memberMapData)
{
if (value == null)
return string.Empty;
var tags = (Dictionary<string, string>)value;
return string.Join(";", tags.Select(a => $"{a.Key}:{a.Value}"));
}
}

public class CustomDoubleConverter : DoubleConverter
Expand Down
29 changes: 21 additions & 8 deletions src/OutputFormatters/JsonOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,29 @@ public override Task WriteBudgets(BudgetsSettings settings, IEnumerable<BudgetIt
public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable<CostDailyItem> dailyCosts)
{
// Create a new variable to hold the dailyCost items per day
var output = dailyCosts
// Code to avoid creating the column Tags when is not needed
if (settings.IncludeTags == false)
{
var output = dailyCosts
.GroupBy(a => a.Date)
.Select(a => new
{
Date = a.Key,
Items = a.Select(b => new { b.Name, b.Cost, b.Currency, b.CostUsd})
});
WriteJson(settings, output);
}
else
{
var output = dailyCosts
.GroupBy(a => a.Date)
.Select(a => new
{
Date = a.Key,
Items = a.Select(b => new { b.Name, b.Cost, b.Currency, b.CostUsd })
});

WriteJson(settings, output);

{
Date = a.Key,
Items = a.Select(b => new { b.Name, b.Cost, b.Currency, b.CostUsd, b.Tags})
});
WriteJson(settings, output);
}
return Task.CompletedTask;
}

Expand Down
2 changes: 1 addition & 1 deletion src/OutputFormatters/MarkdownOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable<Cost
var othersCost = day.Except(topCosts)
.Sum(item => settings.UseUSD ? item.CostUsd : item.Cost);

topCosts.Add(new CostDailyItem(day.Key, "Other", othersCost, othersCost, day.First().Currency));
topCosts.Add(new CostDailyItem(day.Key, "Other", othersCost, othersCost, day.First().Currency, null));

var dailyCost = 0D; // Keep track of the total cost for this day
var breakdown = new List<string>();
Expand Down
2 changes: 1 addition & 1 deletion src/OutputFormatters/TextOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable<Cost
var othersCost = day.Except(topCosts)
.Sum(item => settings.UseUSD ? item.CostUsd : item.Cost);

topCosts.Add(new CostDailyItem(day.Key, "Other", othersCost, othersCost, day.First().Currency));
topCosts.Add(new CostDailyItem(day.Key, "Other", othersCost, othersCost, day.First().Currency, null));

Console.Write($"{day.Key.ToString(CultureInfo.CurrentCulture)} ");

Expand Down

0 comments on commit 5fde962

Please sign in to comment.