Skip to content
Draft
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
4 changes: 3 additions & 1 deletion docs-mslearn/toolkit/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: FinOps toolkit changelog
description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more.
author: MSBrett
ms.author: brettwil
ms.date: 08/12/2026
ms.date: 08/17/2026
ms.topic: reference
ms.service: finops
ms.subservice: finops-toolkit
Expand Down Expand Up @@ -36,6 +36,8 @@ The following section lists features and enhancements that are currently in deve
- Fixed the `ContractedCost` recompute guard to compare with a null-safe tolerance instead of exact float equality, eliminating millions of no-op rewrites that polluted the `x_SourceValues` audit trail while preserving the null-cost backfill and no longer overwriting an existing cost when the unit price is missing ([#2216](https://github.com/microsoft/finops-toolkit/issues/2216)).
- Fixed the SQL VMs without Azure Hybrid Benefit recommendation query to join on the SQL VM `virtualMachineResourceId` instead of a case-sensitive VM name match that skipped VMs with uppercase names and dropped duplicate names, and made all Azure Resource Graph join kinds explicit so no query relies on the `innerunique` default ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
- Switched dimension enrichment in the v1_0/v1_2 ingestion transforms (`PricingUnits`, `Regions`, `ResourceTypes`, `Services`) from `join` to the broadcast-optimized `lookup` operator and deduplicated the `Services` mapping per resource type to prevent cost row fan-out ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
- Fixed the v1_0/v1_2 price transforms creating more rows than were ingested: the savings plan price `lookup` deduplicated its Consumption dimension side with `distinct` over columns that vary by region/currency, so a meter with multiple regional prices fanned out every matching savings plan row; switched to `summarize take_any(...) by tmp_SavingsPlanKey` for a guaranteed one-row-per-key dimension side ([#1736](https://github.com/microsoft/finops-toolkit/issues/1736)).
- Fixed commitment discount eligibility (`x_CommitmentDiscountSpendEligibility`, `x_CommitmentDiscountUsageEligibility`) being computed incorrectly when a pricesheet export lands as multiple parquet files: because `Prices_transform_v1_0`/`v1_2` are Data Explorer update policy functions, each file triggers a separate invocation that only sees that file's rows of `Prices_raw`, so a meter's Reservation/SavingsPlan row and its Consumption row could each be visible in different invocations and eligibility would be computed against a partial view. Eligibility is now sourced from the [Commitment discount eligibility](open-data.md#commitment-discount-eligibility) open-data table, ingested into a new `CommitmentDiscountEligibility` ADX table, which isn't affected by per-invocation partitioning ([#1625](https://github.com/microsoft/finops-toolkit/issues/1625)). Both sides of the eligibility lookup are normalized to lowercase before joining, since the pricesheet's meter ID casing isn't guaranteed to match the open-data table's.

### [FinOps workbooks](workbooks/finops-workbooks-overview.md)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1092,12 +1092,43 @@ resource pipeline_InitializeHub 'Microsoft.DataFactory/factories/pipelines@2018-
}
}
}
{ // Update CommitmentDiscountEligibility in ADX
name: 'Update CommitmentDiscountEligibility in ADX'
type: 'AzureDataExplorerCommand'
dependsOn: [
{
activity: 'Update Services in ADX'
dependencyConditions: [
'Succeeded'
]
}
]
policy: {
timeout: '0.12:00:00'
retry: 2 // CommitmentDiscountEligibility.csv is ~8x larger than the next biggest reference CSV (ResourceTypes); retry transient network failures instead of failing the whole pipeline
retryIntervalInSeconds: 30
secureOutput: false
secureInput: false
}
userProperties: []
typeProperties: {
command: '.set-or-replace CommitmentDiscountEligibility <| externaldata(MeterId: string, x_CommitmentDiscountSpendEligibility: string, x_CommitmentDiscountUsageEligibility: string)[@"${ftkReleaseUri}/CommitmentDiscountEligibility.csv"] with (format="csv", ignoreFirstRecord=true)'
commandTimeout: '00:30:00'
}
linkedServiceName: {
referenceName: linkedService_dataExplorer.name
type: 'LinkedServiceReference'
parameters: {
database: INGESTION_DB // Do not use dynamic reference since that won't work with Fabric
}
}
}
{ // Ingestion Complete
name: 'Ingestion Complete'
type: 'SetVariable'
dependsOn: [
{
activity: 'Update Services in ADX'
activity: 'Update CommitmentDiscountEligibility in ADX'
dependencyConditions: [
'Succeeded'
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ HubScopes()
x_ServiceModel: string
)

// CommitmentDiscountEligibility
.create-merge table CommitmentDiscountEligibility(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — local hubs will silently report every meter as "Not Eligible".

dev gained IngestionSetup_OpenDataExternal.kql in #2187 ("Run FinOps hubs on your own hardware"), which merged after this branch forked — so it isn't visible in this diff. It's the local/emulator stand-in that populates the open-data tables, built into the finops-hub-local-opendata.kql release artifact via .build.config and consumed by Initialize-FinOpsHubLocal.ps1:181.

This PR adds a fifth open-data table, creates it here, and populates it only through the new Update CommitmentDiscountEligibility in ADX pipeline activity. Local hubs never run Data Factory. So the table exists, empty, the lookup in Prices_transform_v1_0/v1_2 misses every meter, and both eligibility columns come out 'Not Eligible' for 100% of rows — with no error anywhere.

The fix is one line added to IngestionSetup_OpenDataExternal.kql, mirroring the four already there:

.set-or-replace CommitmentDiscountEligibility <| externaldata(MeterId: string, x_CommitmentDiscountSpendEligibility: string, x_CommitmentDiscountUsageEligibility: string)[@'$$openDataPath$$/CommitmentDiscountEligibility.csv'] with (format='csv', ignoreFirstRecord=true)

This will surface when you merge dev in to clear the changelog conflict, but it's worth catching deliberately rather than discovering the empty table later.


Minor, same area — Fabric upgrade path. ingestion_InitScripts and ingestion_VersionedScripts are both if (useAzure) (app.bicep:357/376), so Fabric users run these scripts by hand. Prices_transform_v1_2() now references CommitmentDiscountEligibility, so a Fabric user who reruns only the versioned script against an existing eventhouse hits a function-validation failure on a table that doesn't exist yet. Worth an explicit "run the infra script first" note in the upgrade guidance.

MeterId: string,
x_CommitmentDiscountSpendEligibility: string,
x_CommitmentDiscountUsageEligibility: string
)

//----------------------------------------------------------------------------------------------------------------------

// parse_resourceid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,26 +78,45 @@ Prices_transform_v1_0()
| extend x_IngestionTime = ingestion_time()
);
//
// Meters for reservations and savings plans to identify commitment eligibility
let riMeters = prices | where x_SkuPriceType == 'ReservedInstance' | distinct x_SkuMeterId;
let spMeters = prices | where x_SkuPriceType == 'SavingsPlan' | distinct x_SkuMeterId;
// Commitment discount eligibility per meter, used below. Sourced from the
// CommitmentDiscountEligibility open-data table (a snapshot of the Azure Retail Prices API)
// rather than derived from `prices` itself: Prices_raw can land as multiple parquet-snappy
// files per export, each triggering a SEPARATE update policy invocation (see #1625), so a
// meter's Reservation/SavingsPlan row and its Consumption row can be visible in different
// invocations. Deriving eligibility from `prices` only sees whichever rows happen to be in the
// current invocation; CommitmentDiscountEligibility is ingested as a whole table and doesn't
// have that gap. Dedupe with take_any() even though MeterId is expected to be unique in the
// source data -- defends against a future duplicate row silently fanning out the lookup below.
// MeterId is lowercased by the generator (Update-CommitmentDiscountEligibility.ps1); the
// pricesheet's MeterId/MeterID casing isn't documented/guaranteed, and `lookup on` only
// supports case-sensitive equality, so both sides are normalized to lowercase for the join.
let commitmentEligibility = CommitmentDiscountEligibility
| extend tmp_MeterId = tolower(MeterId)
| summarize take_any(x_CommitmentDiscountSpendEligibility), take_any(x_CommitmentDiscountUsageEligibility) by tmp_MeterId;
//
// Copy list/base/contracted prices from on-demand SKUs
prices
| where x_SkuPriceType == 'SavingsPlan'
// If we use join, specify the shuffle key
// TODO: Compare join vs. lookup perf -- | join kind=leftouter hint.strategy=shuffle (prices | where x_SkuPriceType == 'Consumption' | where x_SkuMeterId in (spMeters) | distinct tmp_SavingsPlanKey, ListUnitPrice, ContractedUnitPrice, x_BaseUnitPrice) on tmp_SavingsPlanKey
| lookup kind=leftouter (prices | where x_SkuPriceType == 'Consumption' | where x_SkuMeterId in (spMeters) | distinct tmp_SavingsPlanKey, ListUnitPrice, ContractedUnitPrice, x_BaseUnitPrice) on tmp_SavingsPlanKey
// TODO: Compare join vs. lookup perf -- | join kind=leftouter hint.strategy=shuffle (prices | where x_SkuPriceType == 'Consumption' | summarize take_any(ListUnitPrice), take_any(ContractedUnitPrice), take_any(x_BaseUnitPrice) by tmp_SavingsPlanKey) on tmp_SavingsPlanKey
// The dimension side must be unique per tmp_SavingsPlanKey (meter+product+SKU+tier+offer, no region/currency):
// `distinct` over the price columns does not guarantee that when the same key has rows with
// different prices (e.g. multi-region exports), so it can fan out matching SavingsPlan rows.
// `summarize take_any(...) by tmp_SavingsPlanKey` guarantees exactly one row per key.
| lookup kind=leftouter (prices | where x_SkuPriceType == 'Consumption' | summarize take_any(ListUnitPrice), take_any(ContractedUnitPrice), take_any(x_BaseUnitPrice) by tmp_SavingsPlanKey) on tmp_SavingsPlanKey

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the v1_2 comment: the take_any here fixes the row count but can silently pick another billing profile's or currency's price, and the | where x_SkuMeterId in (spMeters) prefilter is gone from the dimension side. Whatever you settle on for v1_2 should apply here too — I agree with keeping the two versions in sync rather than dropping the deprecated path.

| extend ListUnitPrice = coalesce(ListUnitPrice, ListUnitPrice1)
| extend ContractedUnitPrice = coalesce(ContractedUnitPrice, ContractedUnitPrice1)
| extend x_BaseUnitPrice = coalesce(x_BaseUnitPrice, x_BaseUnitPrice1)
| project-away ListUnitPrice1, ContractedUnitPrice1, x_BaseUnitPrice1, tmp_SavingsPlanKey
| union ((prices | where x_SkuPriceType != 'SavingsPlan'))
//
// Calculate commitment discount elgibility
// Calculate commitment discount eligibility from the open-data snapshot; unmatched meters default to not eligible
// TODO: Would a join be faster?
| extend x_CommitmentDiscountSpendEligibility = iff(x_SkuMeterId in (riMeters) and x_SkuPriceType != 'ReservedInstance', 'Eligible', 'Not Eligible')
| extend x_CommitmentDiscountUsageEligibility = iff(x_SkuMeterId in (spMeters), 'Eligible', 'Not Eligible')
| extend tmp_MeterId = tolower(x_SkuMeterId)
| lookup kind=leftouter (commitmentEligibility) on tmp_MeterId
| extend x_CommitmentDiscountSpendEligibility = iff(isnotempty(x_CommitmentDiscountSpendEligibility) and x_SkuPriceType != 'ReservedInstance', x_CommitmentDiscountSpendEligibility, 'Not Eligible')
| extend x_CommitmentDiscountUsageEligibility = coalesce(x_CommitmentDiscountUsageEligibility, 'Not Eligible')
| project-away tmp_MeterId
//
// Add PricingUnit and x_PricingBlockSize
// TODO: Compare join vs. lookup perf -- | join kind=leftouter (PricingUnits) on x_PricingUnitDescription | project-away x_PricingUnitDescription1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,32 @@ Prices_transform_v1_2()
| extend x_IngestionTime = ingestion_time()
);
//
// Meters for reservations and savings plans to identify commitment eligibility
let riMeters = prices | where x_SkuPriceType == 'ReservedInstance' | distinct x_SkuMeterId;
let spMeters = prices | where x_SkuPriceType == 'SavingsPlan' | distinct x_SkuMeterId;
// Commitment discount eligibility per meter, used below. Sourced from the
// CommitmentDiscountEligibility open-data table (a snapshot of the Azure Retail Prices API)
// rather than derived from `prices` itself: Prices_raw can land as multiple parquet-snappy
// files per export, each triggering a SEPARATE update policy invocation (see #1625), so a
// meter's Reservation/SavingsPlan row and its Consumption row can be visible in different
// invocations. Deriving eligibility from `prices` only sees whichever rows happen to be in the
// current invocation; CommitmentDiscountEligibility is ingested as a whole table and doesn't
// have that gap. Dedupe with take_any() even though MeterId is expected to be unique in the
// source data -- defends against a future duplicate row silently fanning out the lookup below.
// MeterId is lowercased by the generator (Update-CommitmentDiscountEligibility.ps1); the
// pricesheet's MeterId/MeterID casing isn't documented/guaranteed, and `lookup on` only
// supports case-sensitive equality, so both sides are normalized to lowercase for the join.
let commitmentEligibility = CommitmentDiscountEligibility
| extend tmp_MeterId = tolower(MeterId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a problem — flagging pre-emptively so this doesn't get bounced on a false positive.

This tolower() is correct and does not violate the repo's KQL rule. The guideline targets tolower() in comparison position, where the operator is already case-insensitive. lookup ... on only does case-sensitive equality and has no =~ equivalent, so normalizing both sides of the join key is the right call — the alternative would be a join on $left.k =~ $right.k, which is worse on every axis.

HubsKqlOperators.Tests.ps1:91-92 only matches tolower() adjacent to a comparison operator, so the lint pass reported in the description is a true pass, not a gap in the rule.

| summarize take_any(x_CommitmentDiscountSpendEligibility), take_any(x_CommitmentDiscountUsageEligibility) by tmp_MeterId;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this regresses Azure Government and Azure China hubs to 100% "Not Eligible".

Update-CommitmentDiscountEligibility.ps1:63 fetches https://prices.azure.com/api/retail/prices with meterRegion 'primary' — the public-cloud retail catalog. Gov and China meter IDs don't appear there, so for a hub ingesting a Gov or China pricesheet this lookup matches nothing and both eligibility columns become 'Not Eligible' for every row.

Today's riMeters/spMeters logic works in those clouds precisely because it reads the customer's own pricesheet rather than a public catalog. It's wrong under the multi-file split you diagnosed, but it isn't uniformly wrong — this would be.

The description lists "unmatched meter defaults to Not Eligible" as preserved behavior, which is mechanically true, but that was a rare tail case before and becomes the only outcome for entire clouds. Options as I see them:

  • Keep the self-derived calc as a fallback when the lookup misses — coalesce against the old iff(... in (riMeters) ...) rather than defaulting straight to 'Not Eligible'. Preserves today's behavior wherever the open data can't reach, and the open data wins wherever it has an answer.
  • Or gate on cloud and document the limitation explicitly.

The first looks strictly better to me — it also covers the new-meter freshness gap you flagged in the description, since a meter the weekly refresh hasn't picked up yet would fall back to the pricesheet-derived answer instead of silently reading "Not Eligible".

//
// Copy list/base/contracted prices from on-demand SKUs
prices
| where x_SkuPriceType == 'SavingsPlan'
// If we use join, specify the shuffle key
// TODO: Compare join vs. lookup perf -- | join kind=leftouter hint.strategy=shuffle (prices | where x_SkuPriceType == 'Consumption' | where x_SkuMeterId in (spMeters) | distinct tmp_SavingsPlanKey, ListUnitPrice, ContractedUnitPrice, x_BaseUnitPrice) on tmp_SavingsPlanKey
| lookup kind=leftouter (prices | where x_SkuPriceType == 'Consumption' | where x_SkuMeterId in (spMeters) | distinct tmp_SavingsPlanKey, ListUnitPrice, ContractedUnitPrice, x_BaseUnitPrice) on tmp_SavingsPlanKey
// TODO: Compare join vs. lookup perf -- | join kind=leftouter hint.strategy=shuffle (prices | where x_SkuPriceType == 'Consumption' | summarize take_any(ListUnitPrice), take_any(ContractedUnitPrice), take_any(x_BaseUnitPrice) by tmp_SavingsPlanKey) on tmp_SavingsPlanKey
// The dimension side must be unique per tmp_SavingsPlanKey (meter+product+SKU+tier+offer, no region/currency):
// `distinct` over the price columns does not guarantee that when the same key has rows with
// different prices (e.g. multi-region exports), so it can fan out matching SavingsPlan rows.
// `summarize take_any(...) by tmp_SavingsPlanKey` guarantees exactly one row per key.
| lookup kind=leftouter (prices | where x_SkuPriceType == 'Consumption' | summarize take_any(ListUnitPrice), take_any(ContractedUnitPrice), take_any(x_BaseUnitPrice) by tmp_SavingsPlanKey) on tmp_SavingsPlanKey

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substantive — this fixes the row count but can silently assign the wrong price.

The fan-out is real and take_any does guarantee one row per key. But the description already identifies the actual root cause: tmp_SavingsPlanKey = strcat(x_SkuMeterId, x_SkuProductId, x_SkuId, x_SkuTier, x_SkuOfferId) carries no billing profile, currency, or region. When that key legitimately has several Consumption rows with different prices, take_any doesn't resolve the ambiguity — it picks arbitrarily and hides it.

That matters because a hub can ingest pricesheets for multiple billing accounts and profiles into one Prices_raw, and Prices_final keeps x_BillingAccountId, x_BillingProfileId, and PricingCurrency per row. Two enrollments with different negotiated rates for the same meter+product+SKU+tier+offer collide on this key, and a savings plan row can end up carrying the other enrollment's ContractedUnitPrice. That propagates into x_ContractedUnitPriceDiscount, x_EffectiveUnitPriceDiscount, and both ...DiscountPercent columns, so the discount math on savings plan rows goes wrong in a way nothing downstream can detect.

Before: loud and detectable — row counts exceed Prices_raw, which is how #1736 got reported in the first place. After: correct row counts, quietly wrong numbers. That's arguably the worse failure mode.

The fix that matches the diagnosis is widening the key — adding x_BillingProfileId and PricingCurrency, plus x_SkuRegion if regional collisions are real for a given meter — and then keeping take_any on top as the cheap defence it's meant to be. Worth noting that the repo guideline preferring take_any over distinct assumes the key is genuinely unique; this PR establishes that it isn't.

If widening the key is out of scope here, I'd rather see that stated as a deliberate decision with a follow-up issue than left as an arbitrary pick.


Minor, same line — the semi-join prefilter is gone. The old dimension side had | where x_SkuMeterId in (spMeters), which had to go along with spMeters. The summarize now aggregates every Consumption row in the invocation instead of only those for savings-plan meters. Results are identical, but it's more work on the ingestion hot path. Recoverable with an inline subquery if it shows up in practice:

| where x_SkuMeterId in ((prices | where x_SkuPriceType == 'SavingsPlan' | distinct x_SkuMeterId))

| extend ListUnitPrice = coalesce(ListUnitPrice, ListUnitPrice1)
| extend ContractedUnitPrice = coalesce(ContractedUnitPrice, ContractedUnitPrice1)
| extend x_BaseUnitPrice = coalesce(x_BaseUnitPrice, x_BaseUnitPrice1)
Expand All @@ -92,11 +108,12 @@ Prices_transform_v1_2()
''
)
//
// Calculate commitment discount eligibility
// TODO: Would a join be faster?
// TODO: Check this to ensure it's correct
| extend x_CommitmentDiscountSpendEligibility = iff(x_SkuMeterId in (riMeters) and x_SkuPriceType != 'ReservedInstance', 'Eligible', 'Not Eligible')
| extend x_CommitmentDiscountUsageEligibility = iff(x_SkuMeterId in (spMeters), 'Eligible', 'Not Eligible')
// Calculate commitment discount eligibility from the open-data snapshot; unmatched meters default to not eligible
| extend tmp_MeterId = tolower(x_SkuMeterId)
| lookup kind=leftouter (commitmentEligibility) on tmp_MeterId
| extend x_CommitmentDiscountSpendEligibility = iff(isnotempty(x_CommitmentDiscountSpendEligibility) and x_SkuPriceType != 'ReservedInstance', x_CommitmentDiscountSpendEligibility, 'Not Eligible')
| extend x_CommitmentDiscountUsageEligibility = coalesce(x_CommitmentDiscountUsageEligibility, 'Not Eligible')
| project-away tmp_MeterId
//
// TODO: Implement x_CommitmentDiscountNormalizedRatio
| extend x_CommitmentDiscountNormalizedRatio = real(null)
Expand Down