diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index d1f201360..4c8878ac0 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -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: 10/28/2026 +ms.date: 10/29/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -80,6 +80,9 @@ _Released October 2026_ - Applies to the `PricingUnits`, `Regions`, `ResourceTypes`, and `Services` lookups in the FOCUS 1.0 and 1.2 ingestion transforms. - Updated the hubs data model and upgrade documentation for upgrading across multiple FOCUS versions. - **Fixed** + - Fixed `ListCost` and `ContractedCost` staying at 0 for rows without a meter or offer ID, like third-party Marketplace purchases ([#2214](https://github.com/microsoft/finops-toolkit/issues/2214), [#2286](https://github.com/microsoft/finops-toolkit/issues/2286)). + - The repair now falls back to `EffectiveCost` for these rows, so Effective Savings Rate (ESR) reporting is no longer skewed. + - The `MissingListCost` data quality flag is cleared once a row is repaired. - Fixed FOCUS 1.0 cost data being labeled as FOCUS 1.2 in `x_SourceVersion`. - The cost transform backfilled `PricingCurrency` from `x_PricingCurrency` before detecting the FOCUS version, so every 1.0 row looked like 1.2. - FOCUS 1.0 rows also skipped 1.0-specific fixes, like the savings plan purchase `ChargeFrequency` correction. diff --git a/src/powershell/Tests/Unit/HubsMissingCostGate.Tests.ps1 b/src/powershell/Tests/Unit/HubsMissingCostGate.Tests.ps1 new file mode 100644 index 000000000..9e5c3154b --- /dev/null +++ b/src/powershell/Tests/Unit/HubsMissingCostGate.Tests.ps1 @@ -0,0 +1,115 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Regression coverage for the missing-cost gate split (#2214 / #2235 / #2286): + the ListCost/ContractedCost repair fallback must not require a meter ID and offer ID -- only the + price-sheet lookup does. Rows without either (most commonly third-party Marketplace/ISV purchases, + which have no Microsoft retail list price by design) must still reach the repair fallback so ListCost + isn't left at 0 despite a real EffectiveCost, which was corrupting x_TotalSavings and Effective Savings + Rate reporting. + + Per-row behavioral coverage lives in the executable harness + Tests/assets/MissingCostGateSplit.kql (PASS = 0 returned rows on any Kusto database). + + v1.0 also gets the MissingListCost flag-clearing fix (defect 4), but NOT the x_SourceValues audit trail + (defect 3) -- v1.0's Costs_final_v1_0 schema is intentionally frozen so people can revert to legacy + behavior; adding a column there is out of scope permanently, not just for this change. +#> + +Describe 'HubsMissingCostGate' { + + BeforeDiscovery { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $scriptsPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts' + $guardFiles = @('IngestionSetup_v1_0.kql', 'IngestionSetup_v1_2.kql') | ForEach-Object { + @{ Name = $_; FullName = (Join-Path $scriptsPath $_) } + } + } + + BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $harnessPath = Join-Path $repoRoot 'src/powershell/Tests/assets/MissingCostGateSplit.kql' + } + + Context 'Gate split' { + + It 'Should gate the cost-repair fallback on tmp_MissingCost, not tmp_MissingPrices: ' -ForEach $guardFiles { + $content = Get-Content -Path $FullName -Raw + $content | Should -Match 'extend\s+tmp_MissingCost\s*=' ` + -Because 'the cost-repair fallback must be reachable without a meter/offer ID (#2214); tmp_MissingCost is the broader gate that no longer requires them' + } + + It 'Should narrow tmp_MissingPrices from tmp_MissingCost plus the meter/offer ID requirement: ' -ForEach $guardFiles { + $content = Get-Content -Path $FullName -Raw + $content | Should -Match 'extend\s+tmp_MissingPrices\s*=\s*tmp_MissingCost\s+and\s+isnotempty\(x_SkuMeterId\)\s+and\s+\(isnotempty\(x_SkuOfferId\) or CommitmentDiscountCategory == ''Usage''\)' ` + -Because 'only the price-sheet join needs a meter/offer ID; the cost-repair fallback below does not' + } + + It 'Should filter into the repair block on tmp_MissingCost, not tmp_MissingPrices: ' -ForEach $guardFiles { + $content = Get-Content -Path $FullName -Raw + $content | Should -Match '(?m)\|\s*where\s+tmp_MissingCost\s*$' -Because 'rows without a meter/offer ID must still enter the repair block, just skip the price-sheet join' + } + + It 'Should restrict the price-sheet join to rows that pass tmp_MissingPrices: ' -ForEach $guardFiles { + $content = Get-Content -Path $FullName -Raw + $content | Should -Match 'costsWithMissingPrices\s*\|\s*where\s+tmp_MissingPrices\s*\|\s*summarize\s+by\s+tmp_ReservationPriceLookupKey' ` + -Because 'a row without a meter/offer ID has no valid lookup key and must not join to the price sheet' + } + + It 'Should merge unrepaired rows back by tmp_MissingCost, not tmp_MissingPrices: ' -ForEach $guardFiles { + $content = Get-Content -Path $FullName -Raw + $content | Should -Match 'union\s*\(allCosts\s*\|\s*where\s+not\(tmp_MissingCost\)\)' ` + -Because 'rows that entered the repair block are gated by tmp_MissingCost now, so the merge-back of untouched rows must exclude the same set' + } + } + + Context 'MissingListCost flag clears after repair' { + + It 'Should clear MissingListCost from x_SourceChanges once ListCost is repaired: ' -ForEach $guardFiles { + $content = Get-Content -Path $FullName -Raw + $content | Should -Match "iff\(\(isnotempty\(ListCost\) and ListCost != 0\),\s*\r?\n\s*trim_end\(',', replace_string\(replace_string\(x_SourceChanges, 'MissingListCost,', ''\), 'MissingListCost', ''\)\),\s*\r?\n\s*x_SourceChanges\)" ` + -Because 'MissingListCost is computed before the repair runs, so a repaired row must not still report itself as broken (#2214 defect 4)' + } + } + + Context 'Equivalence harness' { + + It 'Should have the gate-split harness asset' { + Test-Path $harnessPath | Should -BeTrue + } + + It 'Should assert both the repaired outcome and the changed-vs-old-gate outcome per row' { + $harness = Get-Content -Path $harnessPath -Raw + $harness | Should -Match '\| where new_entersRepair != expectedRepaired or \(old_entersRepair != new_entersRepair\) != expectedChanged' ` + -Because 'the harness must return only rows that violate either the final gate outcome or the expected delta from the old gate' + } + + It 'Should cover rows missing only one of meter ID or offer ID' { + $harness = Get-Content -Path $harnessPath -Raw + $harness | Should -Match 'only meter ID set' -Because 'the old gate required both IDs; a row with just one must still change behavior' + $harness | Should -Match 'only offer ID set' -Because 'the old gate required both IDs; a row with just one must still change behavior' + } + + It 'Should cover the unused spend commitment exclusion' { + $harness = Get-Content -Path $harnessPath -Raw + $harness | Should -Match 'unused spend commitment' -Because 'unused spend commitments must stay excluded from the repair fallback under both gates' + } + + It 'Should cover the non-Microsoft provider exclusion' { + $harness = Get-Content -Path $harnessPath -Raw + $harness | Should -Match 'non-Microsoft provider' -Because 'the gate is Microsoft-only under both old and new behavior' + } + } + + Context 'v1.0 schema is frozen' { + + It 'Should not add x_SourceValues to the v1.0 schema' { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $v10Path = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql' + $content = Get-Content -Path $v10Path -Raw + $content | Should -Not -Match 'x_SourceValues' ` + -Because 'Costs_final_v1_0 is kept for people who want to revert to legacy behavior; its schema is intentionally frozen and must never gain new columns' + } + } +} diff --git a/src/powershell/Tests/assets/MissingCostGateSplit.kql b/src/powershell/Tests/assets/MissingCostGateSplit.kql new file mode 100644 index 000000000..200c79ced --- /dev/null +++ b/src/powershell/Tests/assets/MissingCostGateSplit.kql @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//====================================================================================================================== +// Missing-cost gate split regression harness +// +// Verifies, per row, the behavior change shipped for #2214/#2235/#2286: the ListCost/ContractedCost repair fallback +// used to be gated behind the same condition as the price-sheet lookup, which requires a meter ID and offer ID. +// Rows without either -- most commonly third-party Marketplace/ISV purchases, which have no Microsoft retail list +// price by design -- were excluded from the whole repair block and never got a repaired ListCost/ContractedCost, +// even though a real EffectiveCost was present. That leaves ListCost at 0 despite real spend, which collapses +// x_TotalSavings and corrupts downstream Effective Savings Rate reporting. +// +// old: tmp_MissingPrices gates BOTH the price-sheet join AND the cost-repair fallback +// (isempty(ListUnitPrice) or isempty(ContractedUnitPrice) or ListUnitPrice == 0 or ContractedUnitPrice == 0) +// and x_EffectiveUnitPrice != 0 and not(unused spend commitment) +// and isnotempty(x_SkuMeterId) and isnotempty(x_SkuOfferId) +// +// new: tmp_MissingCost (no meter/offer ID requirement) gates the cost-repair fallback; +// tmp_MissingPrices narrows that further to rows the price-sheet join can resolve +// tmp_MissingCost = (isempty(ListUnitPrice) or isempty(ContractedUnitPrice) or ListUnitPrice == 0 or ContractedUnitPrice == 0) +// and x_EffectiveUnitPrice != 0 and not(unused spend commitment) +// tmp_MissingPrices = tmp_MissingCost and isnotempty(x_SkuMeterId) +// and (isnotempty(x_SkuOfferId) or CommitmentDiscountCategory == 'Usage') +// +// A row that is tmp_MissingCost but NOT tmp_MissingPrices now enters the repair block (skips the join, all joined +// columns come back null) and falls through the existing case() branches' null-safe fallback to EffectiveCost-based +// values -- the same outcome #2214 asked for, reusing logic that already exists for the join's no-match case. +// +// How to run: paste into any Kusto database (ADX or Fabric eventhouse; no table access required). +// - PASS = the query returns 0 rows. +// +// expectedRepaired = whether the row should enter the repair fallback (new_entersRepair) after the fix. +// expectedChanged = whether this row's outcome should actually differ from the old gate (old_entersRepair). Rows +// with a meter/offer ID behave identically under both gates -- only rows missing one or both IDs should change. +//====================================================================================================================== + +let cases = datatable( + label:string, + provider:string, + listPrice:real, + contractedPrice:real, + effectivePrice:real, + commitmentCategory:string, + commitmentStatus:string, + meterId:string, + offerId:string, + expectedRepaired:bool, + expectedChanged:bool +) [ + 'ISV row, no meter/offer ID -- the #2214 case', + 'Microsoft', real(0), real(0), real(1.5), '', '', '', '', true, true, + 'Microsoft usage, has meter/offer ID -- price-sheet lookup applies as before', + 'Microsoft', real(0), real(0), real(1.5), '', '', 'meter1', 'offer1', true, false, + 'Microsoft usage, only meter ID set (no offer ID) -- still enters repair, still skips the join', + 'Microsoft', real(0), real(0), real(1.5), '', '', 'meter1', '', true, true, + 'Microsoft usage, only offer ID set (no meter ID) -- still enters repair, still skips the join', + 'Microsoft', real(0), real(0), real(1.5), '', '', '', 'offer1', true, true, + 'prices already populated -- gate should not fire either way', + 'Microsoft', real(1.5), real(1.5), real(1.5), '', '', '', '', false, false, + 'no effective price -- nothing to repair from', + 'Microsoft', real(0), real(0), real(0), '', '', '', '', false, false, + 'unused spend commitment -- excluded on purpose, no consumption to base a fallback on', + 'Microsoft', real(0), real(0), real(1.5), 'Spend', 'Unused', '', '', false, false, + 'non-Microsoft provider -- gate is Microsoft-only', + 'AWS', real(0), real(0), real(1.5), '', '', '', '', false, false, + 'only contracted price missing, meter/offer ID absent -- still enters repair under the new gate', + 'Microsoft', real(1.5), real(0), real(1.5), '', '', '', '', true, true, +] +| extend old_entersRepair = coalesce(provider == 'Microsoft' + and (listPrice == 0 or contractedPrice == 0) + and effectivePrice != 0 + and not(commitmentCategory == 'Spend' and commitmentStatus == 'Unused') + and isnotempty(meterId) and isnotempty(offerId), false) +| extend new_entersRepair = coalesce(provider == 'Microsoft' + and (listPrice == 0 or contractedPrice == 0) + and effectivePrice != 0 + and not(commitmentCategory == 'Spend' and commitmentStatus == 'Unused'), false) +| where new_entersRepair != expectedRepaired or (old_entersRepair != new_entersRepair) != expectedChanged +| order by label asc diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql index a4628cedd..94fdcc341 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql @@ -409,21 +409,30 @@ Costs_transform_v1_0() ConsumedQuantity ) // - // Populate missing prices -- mapping to on-demand prices requires meter ID; offer ID is EA-only, so rows without one are recovered only when they are reservation usage (CommitmentDiscountCategory == 'Usage') - | extend tmp_MissingPrices = ProviderName == 'Microsoft' + // Populate missing costs -- the price-sheet lookup below needs a meter ID and offer ID to map to an on-demand + // price, but the cost fallback further down doesn't. Rows without either ID (e.g. third-party Marketplace/ISV + // purchases, which have no Microsoft retail list price by design) used to be excluded from this whole block and + // never reached the cost fallback, leaving ListCost/ContractedCost at 0 despite a real EffectiveCost -- which + // breaks downstream savings/ESR math (#2214, #2286). tmp_MissingCost is the broader set that flows through to + // the fallback case() blocks below; tmp_MissingPrices narrows that to rows the price-sheet join can actually + // resolve. Rows in tmp_MissingCost but not tmp_MissingPrices skip the join (all Prices_final_v1_0 columns come + // back null) and fall through the case() blocks' existing null-safe branches straight to the EffectiveCost-based + // fallback -- the same outcome #2214's fix asks for, using logic that already exists for the join's no-match case. + | extend tmp_MissingCost = ProviderName == 'Microsoft' and (ListUnitPrice == 0 or ContractedUnitPrice == 0) and x_EffectiveUnitPrice != 0 and not(CommitmentDiscountCategory == 'Spend' and CommitmentDiscountStatus == 'Unused') + | extend tmp_MissingPrices = tmp_MissingCost and isnotempty(x_SkuMeterId) and (isnotempty(x_SkuOfferId) or CommitmentDiscountCategory == 'Usage') | as allCosts - | where tmp_MissingPrices + | where tmp_MissingCost | extend tmp_ReservationPriceLookupKey = tolower(strcat(x_BillingProfileId, substring(ChargePeriodStart, 0, 7), x_SkuMeterId, x_SkuOfferId)) | as costsWithMissingPrices | join kind=leftouter ( Prices_final_v1_0 | extend tmp_ReservationPriceLookupKey = tolower(strcat(x_BillingProfileId, substring(x_EffectivePeriodStart, 0, 7), x_SkuMeterId, x_SkuOfferId)) - | where x_SkuPriceType == 'Consumption' and tmp_ReservationPriceLookupKey in ((costsWithMissingPrices | summarize by tmp_ReservationPriceLookupKey)) + | where x_SkuPriceType == 'Consumption' and tmp_ReservationPriceLookupKey in ((costsWithMissingPrices | where tmp_MissingPrices | summarize by tmp_ReservationPriceLookupKey)) // When duplicate price rows collapse under one lookup key, use the highest on-demand price as the savings baseline; min() can pick an anomalously low row and underreport savings | summarize ListUnitPrice = max(ListUnitPrice), ContractedUnitPrice = max(ContractedUnitPrice) by tmp_ReservationPriceLookupKey, x_PricingBlockSize, PricingUnit ) on tmp_ReservationPriceLookupKey @@ -489,12 +498,21 @@ Costs_transform_v1_0() ListCost ) // Merge the rest of the unmodified cost records and remove excess columns - | union (allCosts | where not(tmp_MissingPrices)) - | project-away x_PricingBlockSize1, PricingUnit1, ListUnitPrice1, ContractedUnitPrice1, tmp_MissingPrices, tmp_ReservationPriceLookupKey, tmp_ReservationPriceLookupKey1 + | union (allCosts | where not(tmp_MissingCost)) + | project-away x_PricingBlockSize1, PricingUnit1, ListUnitPrice1, ContractedUnitPrice1, tmp_MissingCost, tmp_MissingPrices, tmp_ReservationPriceLookupKey, tmp_ReservationPriceLookupKey1 // // BUG: Fix ContractedCost that has bad values | extend ContractedCost = iff(ProviderName == 'Microsoft' and isnotempty(PricingQuantity) and isnotempty(x_PricingBlockSize) and isnotempty(ContractedUnitPrice) and (isempty(ContractedCost) or abs(ContractedCost - ContractedUnitPrice * PricingQuantity) >= 0.0001), ContractedUnitPrice * PricingQuantity, ContractedCost) // + // MissingListCost was flagged before the repair above ran, so a repaired row still carries the flag and can't be + // told apart from a row the repair couldn't fix (#2214, #2286). Clear it once ListCost is known-good post-repair. + // MissingListCost keeps its trailing comma unless it's the last flag in the string (trim_end strips only the + // final comma of the whole value), so both forms need stripping; trim_end re-cleans a comma left dangling at + // the end if MissingListCost, was that last flag. + | extend x_SourceChanges = iff((isnotempty(ListCost) and ListCost != 0), + trim_end(',', replace_string(replace_string(x_SourceChanges, 'MissingListCost,', ''), 'MissingListCost', '')), + x_SourceChanges) + // // Handle FOCUS 1.0-preview UsageQuantity/Unit | extend ConsumedQuantity = iff(ChargeCategory == 'Usage', coalesce(ConsumedQuantity, UsageQuantity, UsageAmount), todecimal('')) | extend ConsumedUnit = iff(ChargeCategory == 'Usage' and isnotempty(ConsumedQuantity), coalesce(ConsumedUnit, UsageUnit, 'Units'), '') diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql index 391ccca3f..33bd8fb98 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql @@ -413,21 +413,30 @@ Costs_transform_v1_2() ConsumedQuantity ) // - // Populate missing prices -- mapping to on-demand prices requires meter ID; offer ID is EA-only, so rows without one are recovered only when they are reservation usage (CommitmentDiscountCategory == 'Usage') - | extend tmp_MissingPrices = ProviderName == 'Microsoft' + // Populate missing costs -- the price-sheet lookup below needs a meter ID and offer ID to map to an on-demand + // price, but the cost fallback further down doesn't. Rows without either ID (e.g. third-party Marketplace/ISV + // purchases, which have no Microsoft retail list price by design) used to be excluded from this whole block and + // never reached the cost fallback, leaving ListCost/ContractedCost at 0 despite a real EffectiveCost -- which + // breaks downstream savings/ESR math (#2214, #2286). tmp_MissingCost is the broader set that flows through to + // the fallback case() blocks below; tmp_MissingPrices narrows that to rows the price-sheet join can actually + // resolve. Rows in tmp_MissingCost but not tmp_MissingPrices skip the join (all Prices_final_v1_2 columns come + // back null) and fall through the case() blocks' existing null-safe branches straight to the EffectiveCost-based + // fallback -- the same outcome #2214's fix asks for, using logic that already exists for the join's no-match case. + | extend tmp_MissingCost = ProviderName == 'Microsoft' and (isempty(ListUnitPrice) or isempty(ContractedUnitPrice) or ListUnitPrice == 0 or ContractedUnitPrice == 0) and x_EffectiveUnitPrice != 0 and not(CommitmentDiscountCategory == 'Spend' and CommitmentDiscountStatus == 'Unused') + | extend tmp_MissingPrices = tmp_MissingCost and isnotempty(x_SkuMeterId) and (isnotempty(x_SkuOfferId) or CommitmentDiscountCategory == 'Usage') | as allCosts - | where tmp_MissingPrices + | where tmp_MissingCost | extend tmp_ReservationPriceLookupKey = tolower(strcat(x_BillingProfileId, substring(ChargePeriodStart, 0, 7), x_SkuMeterId, x_SkuOfferId)) | as costsWithMissingPrices | join kind=leftouter ( Prices_final_v1_2 | extend tmp_ReservationPriceLookupKey = tolower(strcat(x_BillingProfileId, substring(x_EffectivePeriodStart, 0, 7), x_SkuMeterId, x_SkuOfferId)) - | where x_SkuPriceType == 'Consumption' and tmp_ReservationPriceLookupKey in ((costsWithMissingPrices | summarize by tmp_ReservationPriceLookupKey)) + | where x_SkuPriceType == 'Consumption' and tmp_ReservationPriceLookupKey in ((costsWithMissingPrices | where tmp_MissingPrices | summarize by tmp_ReservationPriceLookupKey)) // When duplicate price rows collapse under one lookup key, use the highest on-demand price as the savings baseline; min() can pick an anomalously low row and underreport savings | summarize ListUnitPrice = max(ListUnitPrice), ContractedUnitPrice = max(ContractedUnitPrice) by tmp_ReservationPriceLookupKey, x_PricingBlockSize, PricingUnit ) on tmp_ReservationPriceLookupKey @@ -492,8 +501,16 @@ Costs_transform_v1_2() ListCost ) // Merge the rest of the unmodified cost records and remove excess columns - | union (allCosts | where not(tmp_MissingPrices)) - | project-away x_PricingBlockSize1, PricingUnit1, ListUnitPrice1, ContractedUnitPrice1, tmp_MissingPrices, tmp_ReservationPriceLookupKey, tmp_ReservationPriceLookupKey1 + | union (allCosts | where not(tmp_MissingCost)) + | project-away x_PricingBlockSize1, PricingUnit1, ListUnitPrice1, ContractedUnitPrice1, tmp_MissingCost, tmp_MissingPrices, tmp_ReservationPriceLookupKey, tmp_ReservationPriceLookupKey1 + // MissingListCost was flagged before the repair above ran, so a repaired row still carries the flag and can't be + // told apart from a row the repair couldn't fix (#2214, #2286). Clear it once ListCost is known-good post-repair. + // MissingListCost keeps its trailing comma unless it's the last flag in the string (trim_end strips only the + // final comma of the whole value), so both forms need stripping; trim_end re-cleans a comma left dangling at + // the end if MissingListCost, was that last flag. + | extend x_SourceChanges = iff((isnotempty(ListCost) and ListCost != 0), + trim_end(',', replace_string(replace_string(x_SourceChanges, 'MissingListCost,', ''), 'MissingListCost', '')), + x_SourceChanges) // | extend SkuPriceDetails = parse_json(SkuPriceDetails) | extend Tags = parse_json(Tags)