diff --git a/README.md b/README.md index 00bcc63..0ca0e53 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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\"":\""my_email@email.com\""""] +``` +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: diff --git a/src/Commands/CostSettings.cs b/src/Commands/CostSettings.cs index 1d87116..9910663 100644 --- a/src/Commands/CostSettings.cs +++ b/src/Commands/CostSettings.cs @@ -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 { diff --git a/src/Commands/DailyCost/DailyCost.cs b/src/Commands/DailyCost/DailyCost.cs index a0707ff..db126b0 100644 --- a/src/Commands/DailyCost/DailyCost.cs +++ b/src/Commands/DailyCost/DailyCost.cs @@ -4,6 +4,7 @@ using AzureCostCli.OutputFormatters; using Spectre.Console; using Spectre.Console.Cli; +using System; namespace AzureCostCli.Commands.DailyCost; @@ -86,6 +87,12 @@ public override async Task ExecuteAsync(CommandContext context, DailyCostSe IEnumerable dailyCost = new List(); + // if output format is not csv, json, or jsonc, then don't include tags + if (settings.Output.ToString().ToLower() != "json" && settings.Output.ToString().ToLower() != "jsonc" && settings.Output.ToString().ToLower() != "csv") + { + settings.IncludeTags = false; + } + await AnsiConsoleExt.Status() .StartAsync("Fetching daily cost data...", async ctx => { @@ -96,12 +103,13 @@ await AnsiConsoleExt.Status() settings.Metric, settings.Dimension, settings.Timeframe, - settings.From, settings.To); + settings.From, settings.To, + settings.IncludeTags); }); // Write the output await _outputFormatters[settings.Output] - .WriteDailyCost(settings, dailyCost); + .WriteDailyCost(settings, dailyCost); return 0; // Omitted } diff --git a/src/Commands/DetectAnomaly/DetectAnomaly.cs b/src/Commands/DetectAnomaly/DetectAnomaly.cs index 4c3040a..49ffee1 100644 --- a/src/Commands/DetectAnomaly/DetectAnomaly.cs +++ b/src/Commands/DetectAnomaly/DetectAnomaly.cs @@ -96,7 +96,8 @@ public override async Task ExecuteAsync(CommandContext context, DetectAnoma settings.Metric, settings.Dimension, settings.Timeframe, - settings.From, settings.To); + settings.From, settings.To, + false); var costAnalyzer = new CostAnalyzer(settings); diff --git a/src/CostApi/AzureCostApiRetriever.cs b/src/CostApi/AzureCostApiRetriever.cs index f9bf160..739718b 100644 --- a/src/CostApi/AzureCostApiRetriever.cs +++ b/src/CostApi/AzureCostApiRetriever.cs @@ -156,7 +156,7 @@ public async Task> 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 { @@ -210,7 +210,7 @@ public async Task> RetrieveCosts(bool includeDebugOutput, var currency = row[3].ToString(); - var costItem = new CostItem(date, value, valueUsd, currency); + var costItem = new CostItem(date, value, valueUsd, currency, ""); items.Add(costItem); } @@ -222,7 +222,7 @@ public async Task> RetrieveCosts(bool includeDebugOutput, public async Task> 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 { @@ -294,7 +294,7 @@ public async Task> 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 { @@ -366,7 +366,7 @@ public async Task> 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 { @@ -443,7 +443,7 @@ public async Task> 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 { @@ -518,10 +518,9 @@ public async Task> RetrieveCostBySubscription(bool in public async Task> 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 { @@ -537,6 +536,7 @@ public async Task> RetrieveDailyCost(bool includeDebu dataSet = new { granularity = "Daily", + include = includeTags ? new[] { "Tags" } : null, aggregation = new { totalCost = new @@ -587,9 +587,20 @@ public async Task> 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(); + var tags = ""; + + // if includeTags is true, switch the value between currency and tags + // that's the order how the API REST exposes the resultset + if (includeTags) + { + System.Text.Json.JsonElement element = row[5]; + tags = element.GetRawText(); + 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); } @@ -674,7 +685,7 @@ public async Task> RetrieveForecastedCosts(bool includeDeb var currency = row[3].ToString(); - var costItem = new CostItem(date, value, value, currency); + var costItem = new CostItem(date, value, value, currency, ""); items.Add(costItem); } } @@ -696,7 +707,7 @@ public async Task> 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) diff --git a/src/CostApi/CostDailyItem.cs b/src/CostApi/CostDailyItem.cs index 8006926..b0afeb6 100644 --- a/src/CostApi/CostDailyItem.cs +++ b/src/CostApi/CostDailyItem.cs @@ -1,3 +1,4 @@ namespace AzureCostCli.CostApi; -public record CostDailyItem(DateOnly Date, string Name, double Cost, double CostUsd, string Currency); \ No newline at end of file +public record CostDailyItem(DateOnly Date, string Name, double Cost, double CostUsd, string Currency, string Tags); +public record CostDailyItemWithoutTags(DateOnly Date, string Name, double Cost, double CostUsd, string Currency); diff --git a/src/CostApi/CostItem.cs b/src/CostApi/CostItem.cs index 2ae42af..c9cda55 100644 --- a/src/CostApi/CostItem.cs +++ b/src/CostApi/CostItem.cs @@ -1,3 +1,3 @@ namespace AzureCostCli.CostApi; -public record CostItem(DateOnly Date, double Cost, double CostUsd, string Currency); \ No newline at end of file +public record CostItem(DateOnly Date, double Cost, double CostUsd, string Currency, string Tags); \ No newline at end of file diff --git a/src/CostApi/ICostRetriever.cs b/src/CostApi/ICostRetriever.cs index dbf20e3..9d5bc30 100644 --- a/src/CostApi/ICostRetriever.cs +++ b/src/CostApi/ICostRetriever.cs @@ -35,7 +35,7 @@ Task> RetrieveCostForResources(bool settingsDebug, Task> RetrieveUsageDetails(bool includeDebugOutput, Scope scope, string filter, DateOnly from, DateOnly to); - Task> RetrieveDailyCost(bool settingsDebug, Scope scope, string[] filter,MetricType metric, string dimension, TimeframeType settingsTimeframe, DateOnly settingsFrom, DateOnly settingsTo); + Task> RetrieveDailyCost(bool settingsDebug, Scope scope, string[] filter,MetricType metric, string dimension, TimeframeType settingsTimeframe, DateOnly settingsFrom, DateOnly settingsTo, bool includeTags); } diff --git a/src/OutputFormatters/CsvOutputFormatter.cs b/src/OutputFormatters/CsvOutputFormatter.cs index 7a1f1c6..ae8e3c5 100644 --- a/src/OutputFormatters/CsvOutputFormatter.cs +++ b/src/OutputFormatters/CsvOutputFormatter.cs @@ -34,7 +34,28 @@ public override Task WriteBudgets(BudgetsSettings settings, IEnumerable dailyCosts) { - return ExportToCsv(settings.SkipHeader, dailyCosts); + // code to create the column Tags only when needed + // small trick with the records dailyCostItemWithoutTags, and dailyCostItem + if (settings.IncludeTags == false) + { + var dailyCostsWithoutTags = new List(); + foreach (var item in dailyCosts) + { + var newItem = new CostDailyItemWithoutTags( + Name: item.Name, + Date: item.Date, + Cost: item.Cost, + Currency: item.Currency, + CostUsd: item.CostUsd + ); + dailyCostsWithoutTags.Add(newItem); + } + return ExportToCsv(settings.SkipHeader, dailyCostsWithoutTags); + } + else + { + return ExportToCsv(settings.SkipHeader, dailyCosts); + } } public override Task WriteAnomalyDetectionResults(DetectAnomalySettings settings, List anomalies) diff --git a/src/OutputFormatters/JsonOutputFormatter.cs b/src/OutputFormatters/JsonOutputFormatter.cs index 1b1400e..611c948 100644 --- a/src/OutputFormatters/JsonOutputFormatter.cs +++ b/src/OutputFormatters/JsonOutputFormatter.cs @@ -65,16 +65,29 @@ public override Task WriteBudgets(BudgetsSettings settings, IEnumerable 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; } diff --git a/src/OutputFormatters/MarkdownOutputFormatter.cs b/src/OutputFormatters/MarkdownOutputFormatter.cs index be679b7..95c19bd 100644 --- a/src/OutputFormatters/MarkdownOutputFormatter.cs +++ b/src/OutputFormatters/MarkdownOutputFormatter.cs @@ -301,7 +301,7 @@ public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable 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, "")); var dailyCost = 0D; // Keep track of the total cost for this day var breakdown = new List(); diff --git a/src/OutputFormatters/TextOutputFormatter.cs b/src/OutputFormatters/TextOutputFormatter.cs index 98fe273..476a8b5 100644 --- a/src/OutputFormatters/TextOutputFormatter.cs +++ b/src/OutputFormatters/TextOutputFormatter.cs @@ -178,7 +178,7 @@ public override Task WriteDailyCost(DailyCostSettings settings, IEnumerable 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, "")); Console.Write($"{day.Key.ToString(CultureInfo.CurrentCulture)} ");