Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs-mslearn/toolkit/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ _Released April 2026_
- **Fixed**
- Fixed Init-DataFactory deployment script failing when an Event Grid subscription is already provisioning by checking subscription status before attempting subscribe/unsubscribe and polling separately for completion ([#1996](https://github.com/microsoft/finops-toolkit/issues/1996)).
- Added row count check in `msexports_ExecuteETL` pipeline to fix error when export files have no rows ([#1535](https://github.com/microsoft/finops-toolkit/issues/1535)).
- Fixed Data Explorer dashboard cost totals and savings KPIs producing invalid sums in multi-billing-currency tenants by adding a Currency parameter that scopes all tile queries to a single currency, with a warning indicator when multiple currencies are present ([#2093](https://github.com/microsoft/finops-toolkit/issues/2093)).
- Fixed hub deployment failure in US Government cloud regions caused by missing region-to-time-zone mappings and an invalid default value for Data Factory schedule triggers ([#2087](https://github.com/microsoft/finops-toolkit/issues/2087)).

### [FinOps workbooks](workbooks/finops-workbooks-overview.md) v14
Expand Down
26 changes: 22 additions & 4 deletions src/templates/finops-hub/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -2779,6 +2779,18 @@
"description": "How many groups should be shown in charts? Remaining will be in an \"others\" group.",
"defaultValue": { "kind": "value", "value": 9 },
"showOnPages": { "kind": "all" }
},
{
"kind": "string",
"id": "7c3e4a2b-1d8f-4e9a-b5c7-a9d6f3e2b4c1",
"displayName": "Currency",
"description": "Filter costs to a single billing currency. Aggregating values across currencies without conversion produces incorrect totals and savings rates.",
"variableName": "selectedBillingCurrency",
"selectionType": "scalar",
"includeAllOption": false,
"defaultValue": { "kind": "query-result" },
"dataSource": { "kind": "query", "columns": { "value": "BillingCurrency", "label": "Label" }, "queryRef": { "kind": "query", "queryId": "f2a8c4d6-3b5e-4a7f-9c2d-8e5b1f4a7d9c" } },
"showOnPages": { "kind": "all" }
}
],
"dataSources": [
Expand Down Expand Up @@ -3269,9 +3281,9 @@
},
{
"dataSource": { "kind": "inline", "dataSourceId": "23540be2-ffc9-4b61-8c4c-05e493e682a6" },
"text": "Costs\n| extend EffectiveOverContracted = iff(ContractedCost < EffectiveCost, ContractedCost - EffectiveCost, real(0))\n| extend ContractedOverList = iff(ListCost < ContractedCost, ListCost - ContractedCost, real(0))\n| extend EffectiveOverList = iff(ListCost < EffectiveCost, ListCost - EffectiveCost, real(0))\n| extend Scenario = case(\n ListCost == 0 and CommitmentDiscountCategory == 'Usage' and ChargeCategory == 'Usage', 'Reservation usage missing list',\n ListCost == 0 and CommitmentDiscountCategory == 'Usage' and ChargeCategory == 'Purchase', 'Reservation purchase missing list',\n ListCost == 0 and CommitmentDiscountCategory == 'Spend' and ChargeCategory == 'Usage', 'Savings plan usage missing list',\n ListCost == 0 and CommitmentDiscountCategory == 'Spend' and ChargeCategory == 'Purchase', 'Savings plan purchase missing list',\n ListCost == 0 and ChargeCategory == 'Purchase', 'Other purchase missing list',\n isnotempty(CommitmentDiscountStatus) and ContractedOverList == 0 and EffectiveOverContracted < 0, 'Commitment cost over contracted',\n ListCost == 0 and BilledCost == 0 and EffectiveCost == 0 and ContractedCost > 0 and x_SourceChanges !contains 'MissingContractedCost', 'ContractedCost should be 0',\n ListCost == 0 and ContractedCost == 0 and BilledCost > 0 and EffectiveCost > 0 and x_PublisherCategory == 'Vendor' and ChargeCategory == 'Usage', 'Marketplace usage missing list/contracted',\n ContractedOverList < 0 and EffectiveOverContracted == 0 and x_SourceChanges !contains 'MissingListCost', 'ListCost too low',\n ContractedUnitPrice == x_EffectiveUnitPrice and EffectiveOverContracted < 0 and x_SourceChanges !contains 'MissingContractedCost', 'ContractedCost doesn\\'t match price',\n EffectiveOverContracted != 0 and abs(EffectiveOverContracted) < 0.00000001, 'Rounding error',\n ContractedOverList != 0 and abs(ContractedOverList) < 0.00000001, 'Rounding error',\n EffectiveOverList != 0 and abs(EffectiveOverList) < 0.00000001, 'Rounding error',\n ContractedCost < EffectiveCost or ListCost < ContractedCost or ListCost < EffectiveCost, '',\n EffectiveCost <= ContractedCost and ContractedCost <= ListCost, 'Good',\n '')\n| project-reorder ListCost, ContractedCost, BilledCost, EffectiveCost, EffectiveOverList, EffectiveOverContracted, ContractedOverList, x_SourceChanges, ListUnitPrice, ContractedUnitPrice, x_BilledUnitPrice, x_EffectiveUnitPrice, CommitmentDiscountStatus, PricingQuantity, PricingUnit, x_PricingBlockSize, x_PricingUnitDescription\n// DEBUG -- | where isempty(scenario) | limit 1000\n// Summarize -- \n| summarize Rows = count(), EffectiveCost = round(sum(EffectiveCost), 2), EffectiveOverContracted = abs(sum(EffectiveOverContracted)), ContractedOverList = abs(sum(ContractedOverList)), EffectiveOverList = abs(sum(EffectiveOverList)), Agreement = arraystring(make_set(x_BillingAccountAgreement)) by Scenario | order by Rows desc\n",
"text": "Costs\n| where isempty(selectedBillingCurrency) or BillingCurrency == selectedBillingCurrency\n| extend EffectiveOverContracted = iff(ContractedCost < EffectiveCost, ContractedCost - EffectiveCost, real(0))\n| extend ContractedOverList = iff(ListCost < ContractedCost, ListCost - ContractedCost, real(0))\n| extend EffectiveOverList = iff(ListCost < EffectiveCost, ListCost - EffectiveCost, real(0))\n| extend Scenario = case(\n ListCost == 0 and CommitmentDiscountCategory == 'Usage' and ChargeCategory == 'Usage', 'Reservation usage missing list',\n ListCost == 0 and CommitmentDiscountCategory == 'Usage' and ChargeCategory == 'Purchase', 'Reservation purchase missing list',\n ListCost == 0 and CommitmentDiscountCategory == 'Spend' and ChargeCategory == 'Usage', 'Savings plan usage missing list',\n ListCost == 0 and CommitmentDiscountCategory == 'Spend' and ChargeCategory == 'Purchase', 'Savings plan purchase missing list',\n ListCost == 0 and ChargeCategory == 'Purchase', 'Other purchase missing list',\n isnotempty(CommitmentDiscountStatus) and ContractedOverList == 0 and EffectiveOverContracted < 0, 'Commitment cost over contracted',\n ListCost == 0 and BilledCost == 0 and EffectiveCost == 0 and ContractedCost > 0 and x_SourceChanges !contains 'MissingContractedCost', 'ContractedCost should be 0',\n ListCost == 0 and ContractedCost == 0 and BilledCost > 0 and EffectiveCost > 0 and x_PublisherCategory == 'Vendor' and ChargeCategory == 'Usage', 'Marketplace usage missing list/contracted',\n ContractedOverList < 0 and EffectiveOverContracted == 0 and x_SourceChanges !contains 'MissingListCost', 'ListCost too low',\n ContractedUnitPrice == x_EffectiveUnitPrice and EffectiveOverContracted < 0 and x_SourceChanges !contains 'MissingContractedCost', 'ContractedCost doesn\\'t match price',\n EffectiveOverContracted != 0 and abs(EffectiveOverContracted) < 0.00000001, 'Rounding error',\n ContractedOverList != 0 and abs(ContractedOverList) < 0.00000001, 'Rounding error',\n EffectiveOverList != 0 and abs(EffectiveOverList) < 0.00000001, 'Rounding error',\n ContractedCost < EffectiveCost or ListCost < ContractedCost or ListCost < EffectiveCost, '',\n EffectiveCost <= ContractedCost and ContractedCost <= ListCost, 'Good',\n '')\n| project-reorder ListCost, ContractedCost, BilledCost, EffectiveCost, EffectiveOverList, EffectiveOverContracted, ContractedOverList, x_SourceChanges, ListUnitPrice, ContractedUnitPrice, x_BilledUnitPrice, x_EffectiveUnitPrice, CommitmentDiscountStatus, PricingQuantity, PricingUnit, x_PricingBlockSize, x_PricingUnitDescription\n// DEBUG -- | where isempty(scenario) | limit 1000\n// Summarize -- \n| summarize Rows = count(), EffectiveCost = round(sum(EffectiveCost), 2), EffectiveOverContracted = abs(sum(EffectiveOverContracted)), ContractedOverList = abs(sum(ContractedOverList)), EffectiveOverList = abs(sum(EffectiveOverList)), Agreement = arraystring(make_set(x_BillingAccountAgreement)) by Scenario | order by Rows desc\n",
"id": "56fd8707-bbb3-4f7e-8e37-8dd90ada3baa",
"usedVariables": []
"usedVariables": ["selectedBillingCurrency"]
},
{
"id": "43612ae4-c475-4f22-bb50-ce9d995abb8f",
Expand Down Expand Up @@ -3301,14 +3313,20 @@
{
"id": "eb9259cc-05b7-4441-a66d-a29026fe371b",
"dataSource": { "kind": "inline", "dataSourceId": "23540be2-ffc9-4b61-8c4c-05e493e682a6" },
"text": "Costs_v1_2\n//\n// Apply summarization settings\n| where ChargePeriodStart >= monthsago(numberOfMonths)\n| as filteredCosts\n| extend x_ChargeMonth = startofmonth(ChargePeriodStart)\n// TODO: Should we add granularity? -- | extend x_ReportingDate = iff(#\"Default Granularity\" == 'Monthly'), x_ChargeMonth, startofday(ChargePeriodStart))\n//\n// SKU details\n| extend x_SkuUsageType = tostring(coalesce(SkuPriceDetails.x_UsageType, x_SkuDetails.UsageType))\n| extend x_SkuLicenseUnusedQuantity = x_SkuLicenseQuantity - x_SkuCoreCount\n//\n// Commitment discounts\n| extend x_CommitmentDiscountKey = iff(isempty(x_SkuInstanceType), '', strcat(x_SkuInstanceType, x_SkuMeterId))\n| extend x_SkuTermLabel = case(isempty(x_SkuTerm) or x_SkuTerm <= 0, '', x_SkuTerm < 12, strcat(x_SkuTerm, ' month', iff(x_SkuTerm != 1, 's', '')), strcat(x_SkuTerm / 12, ' year', iff(x_SkuTerm != 12, 's', '')))\n//\n// CSP partners\n// x_PartnerBilledCredit = iff(x_PartnerCreditApplied, BilledCost * x_PartnerCreditRate, todouble(0))\n// x_PartnerEffectiveCredit = iff(x_PartnerCreditApplied, EffectiveCost * x_PartnerCreditRate, todouble(0))\n//\n// Toolkit\n| extend x_ToolkitTool = tostring(Tags['ftk-tool'])\n| extend x_ToolkitVersion = tostring(Tags['ftk-version'])\n| extend tmp_ResourceParent = database('Ingestion').parse_resourceid(Tags['cm-resource-parent'])\n| extend x_ResourceParentId = tostring(tmp_ResourceParent.ResourceId)\n| extend x_ResourceParentName = tostring(tmp_ResourceParent.ResourceName)\n| extend x_ResourceParentType = tostring(tmp_ResourceParent.ResourceType)\n//\n// TODO: Only add differentiators when the name is not unique\n| extend CommitmentDiscountNameUnique = iff(isempty(CommitmentDiscountId), '', strcat(CommitmentDiscountName, ' (', CommitmentDiscountType, ')'))\n| extend ResourceNameUnique = iff(isempty(ResourceId), '', strcat(ResourceName, ' (', ResourceType, ')'))\n| extend x_ResourceGroupNameUnique = iff(isempty(x_ResourceGroupName), '', strcat(x_ResourceGroupName, ' (', SubAccountName, ')'))\n| extend SubAccountNameUnique = iff(isempty(SubAccountId), '', strcat(SubAccountName, ' (', split(SubAccountId, '/')[3], ')'))\n//\n// Explain why cost is 0\n| extend x_FreeReason = case(\n BilledCost != 0.0 or EffectiveCost != 0.0, '',\n PricingCategory == 'Committed', strcat('Unknown ', CommitmentDiscountStatus, ' Commitment'),\n x_BilledUnitPrice == 0.0 and x_EffectiveUnitPrice == 0.0 and ContractedUnitPrice == 0.0 and ListUnitPrice == 0.0 and isempty(CommitmentDiscountType), case(\n x_SkuDescription contains 'Trial', 'Trial',\n x_SkuDescription contains 'Preview', 'Preview',\n 'Other'\n ),\n x_BilledUnitPrice > 0.0 or x_EffectiveUnitPrice > 0.0, case(\n PricingQuantity > 0.0, 'Low Usage',\n PricingQuantity == 0.0, 'No Usage',\n 'Unknown Negative Quantity'\n ),\n 'Unknown'\n)\n//\n| extend x_ResourceTop1K = ChargeCategory != 'Usage' or isempty(ResourceId) or ResourceId in (\n filteredCosts\n | where isnotempty(ResourceId) and ChargeCategory == 'Usage'\n | summarize sum(EffectiveCost) by ResourceId\n | order by sum_EffectiveCost desc\n | limit 1000\n | distinct ResourceId\n)\n//\n| project-away tmp_ResourceParent",
"usedVariables": ["numberOfMonths"]
"text": "Costs_v1_2\n//\n// Apply summarization settings\n| where ChargePeriodStart >= monthsago(numberOfMonths)\n| where isempty(selectedBillingCurrency) or BillingCurrency == selectedBillingCurrency\n| as filteredCosts\n| extend x_ChargeMonth = startofmonth(ChargePeriodStart)\n// TODO: Should we add granularity? -- | extend x_ReportingDate = iff(#\"Default Granularity\" == 'Monthly'), x_ChargeMonth, startofday(ChargePeriodStart))\n//\n// SKU details\n| extend x_SkuUsageType = tostring(coalesce(SkuPriceDetails.x_UsageType, x_SkuDetails.UsageType))\n| extend x_SkuLicenseUnusedQuantity = x_SkuLicenseQuantity - x_SkuCoreCount\n//\n// Commitment discounts\n| extend x_CommitmentDiscountKey = iff(isempty(x_SkuInstanceType), '', strcat(x_SkuInstanceType, x_SkuMeterId))\n| extend x_SkuTermLabel = case(isempty(x_SkuTerm) or x_SkuTerm <= 0, '', x_SkuTerm < 12, strcat(x_SkuTerm, ' month', iff(x_SkuTerm != 1, 's', '')), strcat(x_SkuTerm / 12, ' year', iff(x_SkuTerm != 12, 's', '')))\n//\n// CSP partners\n// x_PartnerBilledCredit = iff(x_PartnerCreditApplied, BilledCost * x_PartnerCreditRate, todouble(0))\n// x_PartnerEffectiveCredit = iff(x_PartnerCreditApplied, EffectiveCost * x_PartnerCreditRate, todouble(0))\n//\n// Toolkit\n| extend x_ToolkitTool = tostring(Tags['ftk-tool'])\n| extend x_ToolkitVersion = tostring(Tags['ftk-version'])\n| extend tmp_ResourceParent = database('Ingestion').parse_resourceid(Tags['cm-resource-parent'])\n| extend x_ResourceParentId = tostring(tmp_ResourceParent.ResourceId)\n| extend x_ResourceParentName = tostring(tmp_ResourceParent.ResourceName)\n| extend x_ResourceParentType = tostring(tmp_ResourceParent.ResourceType)\n//\n// TODO: Only add differentiators when the name is not unique\n| extend CommitmentDiscountNameUnique = iff(isempty(CommitmentDiscountId), '', strcat(CommitmentDiscountName, ' (', CommitmentDiscountType, ')'))\n| extend ResourceNameUnique = iff(isempty(ResourceId), '', strcat(ResourceName, ' (', ResourceType, ')'))\n| extend x_ResourceGroupNameUnique = iff(isempty(x_ResourceGroupName), '', strcat(x_ResourceGroupName, ' (', SubAccountName, ')'))\n| extend SubAccountNameUnique = iff(isempty(SubAccountId), '', strcat(SubAccountName, ' (', split(SubAccountId, '/')[3], ')'))\n//\n// Explain why cost is 0\n| extend x_FreeReason = case(\n BilledCost != 0.0 or EffectiveCost != 0.0, '',\n PricingCategory == 'Committed', strcat('Unknown ', CommitmentDiscountStatus, ' Commitment'),\n x_BilledUnitPrice == 0.0 and x_EffectiveUnitPrice == 0.0 and ContractedUnitPrice == 0.0 and ListUnitPrice == 0.0 and isempty(CommitmentDiscountType), case(\n x_SkuDescription contains 'Trial', 'Trial',\n x_SkuDescription contains 'Preview', 'Preview',\n 'Other'\n ),\n x_BilledUnitPrice > 0.0 or x_EffectiveUnitPrice > 0.0, case(\n PricingQuantity > 0.0, 'Low Usage',\n PricingQuantity == 0.0, 'No Usage',\n 'Unknown Negative Quantity'\n ),\n 'Unknown'\n)\n//\n| extend x_ResourceTop1K = ChargeCategory != 'Usage' or isempty(ResourceId) or ResourceId in (\n filteredCosts\n | where isnotempty(ResourceId) and ChargeCategory == 'Usage'\n | summarize sum(EffectiveCost) by ResourceId\n | order by sum_EffectiveCost desc\n | limit 1000\n | distinct ResourceId\n)\n//\n| project-away tmp_ResourceParent",
"usedVariables": ["numberOfMonths", "selectedBillingCurrency"]
},
{
"dataSource": { "kind": "inline", "dataSourceId": "23540be2-ffc9-4b61-8c4c-05e493e682a6" },
"text": "let months = toscalar(database('Ingestion').HubSettings | project toint(retention.final.months));\nlet monthname = dynamic(['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']);\nrange Value from toint(1) to iff(isempty(months), 24, months) step 1\n| order by Value desc\n| extend MonthsAgo = monthsago(Value)\n| extend Label = strcat(Value, ' mo (', monthname[monthofyear(MonthsAgo)], format_datetime(MonthsAgo, ' yyyy'), ')')\n| project-away MonthsAgo\n",
"id": "c9039243-968d-4e75-9899-8d4ab51a9896",
"usedVariables": []
},
{
"dataSource": { "kind": "inline", "dataSourceId": "23540be2-ffc9-4b61-8c4c-05e493e682a6" },
"text": "let months = toscalar(database('Ingestion').HubSettings | project toint(retention.final.months));\nlet currencies = Costs_v1_2\n | where ChargePeriodStart >= monthsago(iff(isempty(months), 24, months))\n | where isnotempty(BillingCurrency)\n | distinct BillingCurrency;\nlet currencyCount = toscalar(currencies | count);\ncurrencies\n| extend Label = BillingCurrency, _Order = 1\n| union (\n print BillingCurrency = '', Label = '⚠️ All', _Order = 0\n | where currencyCount > 1\n)\n| order by _Order asc, Label asc\n| project BillingCurrency, Label\n",
"id": "f2a8c4d6-3b5e-4a7f-9c2d-8e5b1f4a7d9c",
"usedVariables": []
}
]
}
Loading