Skip to content

Commit 8f1e690

Browse files
RolandKrummenacherRoland Krummenacherclaudegithub-actions[bot]
authored
Fix KQL join pitfalls and adopt lookup for dimension enrichment (#2225)
Co-authored-by: Roland Krummenacher <roland.krummenacher@alescent.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 6ad9126 commit 8f1e690

25 files changed

Lines changed: 238 additions & 74 deletions

File tree

‎docs-mslearn/best-practices/compute.md‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: FinOps best practices for compute
33
description: This article provides FinOps best practices for compute services, including cost optimization, efficiency improvements, and insights into Azure resources.
44
author: flanakin
55
ms.author: micflan
6-
ms.date: 04/01/2026
6+
ms.date: 08/12/2026
77
ms.topic: concept-article
88
ms.service: finops
99
ms.subservice: finops-learning-resources
@@ -180,7 +180,7 @@ Costs
180180
// Join with prices to filter out ineligible SKUs
181181
| extend tmp_MeterKey = strcat(substring(ChargePeriodStart, 0, 7), x_SkuMeterId)
182182
| project tmp_MeterKey, EffectiveCost, PricingCategory, CommitmentDiscountCategory, ResourceName, x_ResourceGroupName, SubAccountName, BillingCurrency
183-
| join kind=leftouter (
183+
| lookup kind=leftouter (
184184
Prices
185185
| where x_SkuMeterCategory startswith 'Virtual Machines'
186186
| summarize sp = countif(x_SkuPriceType == 'SavingsPlan'), ri = countif(x_SkuPriceType == 'ReservedInstance')
@@ -216,7 +216,7 @@ Costs
216216
// Join with prices to filter out ineligible SKUs
217217
| extend tmp_MeterKey = strcat(substring(ChargePeriodStart, 0, 7), x_SkuMeterId)
218218
| project tmp_MeterKey, EffectiveCost, PricingCategory, CommitmentDiscountCategory, ResourceName, x_ResourceGroupName, SubAccountName, BillingCurrency
219-
| join kind=leftouter (
219+
| lookup kind=leftouter (
220220
Prices
221221
| where x_SkuMeterCategory startswith 'Virtual Machines'
222222
| summarize sp = countif(x_SkuPriceType == 'SavingsPlan'), ri = countif(x_SkuPriceType == 'ReservedInstance')
@@ -366,7 +366,7 @@ resourcecontainers
366366
| where type =~ 'Microsoft.Resources/subscriptions'
367367
| where tostring(properties.subscriptionPolicies.quotaId) !has 'MSDNDevTest_2014-09-01'
368368
| project SubscriptionName = name, subscriptionId
369-
| join (
369+
| join kind=inner (
370370
resources
371371
| where type =~ 'microsoft.compute/virtualmachines'
372372
or type =~ 'microsoft.compute/virtualMachineScaleSets'
@@ -422,28 +422,28 @@ resourcecontainers
422422
| where type =~ 'Microsoft.Resources/subscriptions'
423423
| where tostring(properties.subscriptionPolicies.quotaId) !has 'MSDNDevTest_2014-09-01'
424424
| project SubscriptionName = name, subscriptionId
425-
| join (
425+
| join kind=inner (
426426
resources
427427
| where type =~ 'Microsoft.SqlVirtualMachine/SqlVirtualMachines'
428428
and tostring(properties.['sqlServerLicenseType']) != 'AHUB'
429429
| project
430430
ResourceId = id,
431431
ResourceName = name,
432+
VMResourceId = tolower(tostring(properties.virtualMachineResourceId)),
432433
LicenseType = tostring(properties.['sqlServerLicenseType']),
433434
SQLVersion = tostring(properties.['sqlImageOffer']),
434435
SQLSKU = tostring(properties.['sqlImageSku']),
435436
Region = location,
436437
ResourceGroupName = resourceGroup,
437438
subscriptionId
438439
) on subscriptionId
439-
| join (
440+
| join kind=inner (
440441
resources
441442
| where type =~ 'Microsoft.Compute/virtualMachines'
442443
| project
443-
ResourceName = tolower(name),
444-
VMSize = tostring(properties.hardwareProfile.vmSize),
445-
subscriptionId
446-
) on ResourceName
444+
VMResourceId = tolower(id),
445+
VMSize = tostring(properties.hardwareProfile.vmSize)
446+
) on VMResourceId
447447
| where SQLSKU != 'Developer' and SQLSKU != 'Express'
448448
| project
449449
ResourceId,

‎docs-mslearn/best-practices/networking.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: FinOps best practices for Networking
33
description: This article outlines proven FinOps practices for networking services, focusing on cost optimization, efficiency improvements, and resource insights.
44
author: flanakin
55
ms.author: micflan
6-
ms.date: 04/01/2026
6+
ms.date: 08/12/2026
77
ms.topic: concept-article
88
ms.service: finops
99
ms.subservice: finops-learning-resources
@@ -134,7 +134,7 @@ resources
134134
backendPools = properties.backendAddressPools,
135135
resourceGroup = strcat('/subscriptions/',subscriptionId,'/resourceGroups/',resourceGroup)
136136
| project id, name, SKUName, SKUTier, SKUCapacity, resourceGroup, subscriptionId
137-
| join (
137+
| join kind=inner (
138138
resources
139139
| where type =~ 'Microsoft.Network/applicationGateways'
140140
| mvexpand backendPools = properties.backendAddressPools
@@ -578,8 +578,8 @@ resources
578578
| mv-expand IPconfig
579579
| extend PublicIpId= tostring(IPconfig.properties.publicIPAddress.id)
580580
| project PublicIpId
581-
| join (
582-
resource
581+
| join kind=inner (
582+
resources
583583
| where type =~ 'Microsoft.Network/publicIPAddresses'
584584
| extend
585585
PublicIpId = id,

‎docs-mslearn/toolkit/changelog.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ The following section lists features and enhancements that are currently in deve
3434
- Replaced whole-term `contains` matches with `has` across hub KQL and the query catalog (resource ID paths, licensing phrases, SKU description terms) and added a per-row operator-equivalence regression harness with unit test coverage ([#2220](https://github.com/microsoft/finops-toolkit/pull/2220)).
3535
- **Fixed**
3636
- 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)).
37+
- 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)).
38+
- 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)).
39+
40+
### [FinOps workbooks](workbooks/finops-workbooks-overview.md)
41+
42+
- **Fixed**
43+
- Fixed the savings plan workbook summary and details queries silently dropping all but one recommendation per subscription due to an implicit `innerunique` join ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
44+
- Fixed the AHB workbook "VM Latest Change Last 7 days" tile, which joined the `resourcechanges` record id against the resource id and always returned no rows, and the SQL VM queries, which never matched VMs with uppercase names ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
45+
- Changed the savings plan workbook subscription name joins to `leftouter` so a recommendation is still shown when its subscription is missing from `resourcecontainers` ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
46+
47+
### [FinOps alerts](alerts/finops-alerts-overview.md)
48+
49+
- **Fixed**
50+
- Made the idle application gateway and idle public IP query join kinds explicit so they no longer rely on the `innerunique` default ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
3751
3852
### [Power BI reports](power-bi/reports.md)
3953

‎docs-mslearn/toolkit/workbooks/customize-workbooks.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: Use and customize FinOps workbooks
33
description: Learn how to install and customize FinOps workbooks to achieve FinOps goals, including cost recommendations, idle resource identification, and more.
44
author: flanakin
55
ms.author: micflan
6-
ms.date: 04/01/2026
6+
ms.date: 08/12/2026
77
ms.topic: how-to
88
ms.service: finops
99
ms.subservice: finops-toolkit
@@ -122,7 +122,7 @@ In the last section, the query joins the `ResourceContainerstable` with the `res
122122

123123
```kusto
124124
ResourceContainers | "Insert first code section go here"
125-
| join (
125+
| join kind=inner (
126126
resources "Insert second code section here"
127127
) on subscriptionId
128128
| order by type asc
@@ -137,7 +137,7 @@ Here's the full code example that you use to insert into the workbook.
137137

138138
```kusto
139139
ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has "MSDNDevTest_2014-09-01" | extend SubscriptionName=name
140-
| join (
140+
| join kind=inner (
141141
resources
142142
| where resourceGroup in ({ResourceGroup})
143143
| where type == 'microsoft.azurestackhci/clusters'

‎src/optimization-engine/runbooks/recommendations/Recommend-SqlDbOptimizationsToBlobStorage.ps1‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ $baseQuery = @"
162162
| where ResourceId in (CandidateDatabaseIds) and MetricNames_s == 'dtu_consumption_percent' and AggregationType_s == 'Maximum'
163163
| summarize P99DTUPercentage = percentile(todouble(MetricValue_s), dtuPercentPercentile) by ResourceId
164164
| where P99DTUPercentage < DTUPercentageThreshold
165-
| join (
165+
| join kind=inner (
166166
$sqlDbsTableName
167167
| where TimeGenerated > ago(1d)
168168
| project ResourceId = InstanceId_s, DBName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, SkuName_s, ServiceObjectiveName_s, Tags_s, Cloud_s
@@ -311,7 +311,7 @@ $baseQuery = @"
311311
| where ResourceId in (CandidateDatabaseIds) and MetricNames_s == 'dtu_consumption_percent' and AggregationType_s == 'Average' and AggregationOfType_s == 'Maximum'
312312
| summarize AvgDTUPercentage = avg(todouble(MetricValue_s)) by ResourceId
313313
| where AvgDTUPercentage > DTUPercentageThreshold
314-
| join (
314+
| join kind=inner (
315315
$sqlDbsTableName
316316
| where TimeGenerated > ago(1d)
317317
| project ResourceId = InstanceId_s, DBName_s, ResourceGroupName_s, SubscriptionGuid_g, TenantGuid_g, SkuName_s, ServiceObjectiveName_s, Tags_s, Cloud_s
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
<#
5+
Lint rule: every KQL join must state an explicit kind (PR #2225).
6+
7+
A bare `| join (...)` defaults to kind=innerunique, which deduplicates the left side
8+
on the join key and silently drops rows. This has caused real data loss (savings plan
9+
recommendations collapsing to one row per subscription, SQL VMs with duplicate names
10+
disappearing). In Azure Resource Graph the same default applies and `lookup` is not
11+
available, so an explicit kind is the only way to state intent.
12+
13+
The rule scans every surface that carries KQL: hub database scripts, the query catalog,
14+
ARG recommendation queries, the ADX dashboard, the finops-alerts logic app, workbooks,
15+
optimization engine runbooks and views, and the published docs examples.
16+
17+
Known pre-existing bare joins are baselined per file below. The baseline is a ratchet:
18+
- Fixing a bare join REQUIRES lowering the count here (the test fails on stale entries).
19+
- Adding a new bare join is never allowed; write `join kind=...` explicitly.
20+
21+
ARG-only surfaces (workbooks, recommendation queries, the alerts logic app) are additionally
22+
checked for operators Azure Resource Graph rejects: the lookup operator and the semi/anti
23+
join flavors. Verified live against ARG (2026-08): supported kinds are inner, innerunique,
24+
leftouter, rightouter, fullouter; lookup, leftsemi, leftanti, rightsemi, rightanti, and
25+
`in`/`!in` with a subquery are all rejected with InvalidQuery. Exclusion joins in ARG must
26+
therefore use the leftouter + `where isempty(<right key>)` emulation (with a key-unique
27+
right side) — the one place that pattern is acceptable.
28+
#>
29+
30+
Describe 'KqlJoinKinds' {
31+
32+
BeforeDiscovery {
33+
$repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path
34+
35+
$scanTargets = @(
36+
@{ Path = 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts'; Filter = '*.kql'; Recurse = $false }
37+
@{ Path = 'src/queries/catalog'; Filter = '*.kql'; Recurse = $false }
38+
@{ Path = 'src/powershell/Tests/assets'; Filter = '*.kql'; Recurse = $false }
39+
@{ Path = 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries'; Filter = '*.json'; Recurse = $false }
40+
@{ Path = 'src/templates/finops-hub'; Filter = 'dashboard.json'; Recurse = $false }
41+
@{ Path = 'src/templates/finops-alerts/modules'; Filter = 'logicApp.bicep'; Recurse = $false }
42+
@{ Path = 'src/workbooks'; Filter = '*.workbook'; Recurse = $true }
43+
@{ Path = 'src/workbooks'; Filter = 'workbook.json'; Recurse = $true }
44+
@{ Path = 'src/optimization-engine/runbooks'; Filter = '*.ps1'; Recurse = $true }
45+
@{ Path = 'src/optimization-engine/views'; Filter = '*.json'; Recurse = $true }
46+
@{ Path = 'docs-mslearn'; Filter = '*.md'; Recurse = $true }
47+
)
48+
49+
$scanFiles = @($scanTargets | ForEach-Object {
50+
$full = Join-Path $repoRoot $_.Path
51+
Get-ChildItem -Path $full -Filter $_.Filter -Recurse:$_.Recurse -File -ErrorAction SilentlyContinue
52+
} | Sort-Object FullName -Unique | ForEach-Object {
53+
@{ Name = $_.Name; FullName = $_.FullName; RelPath = $_.FullName.Substring($repoRoot.Length + 1).Replace('\', '/') }
54+
})
55+
56+
# Surfaces whose KQL runs on Azure Resource Graph. Workbook files may also contain the
57+
# occasional Log Analytics query (queryType 0); if one legitimately needs lookup or a
58+
# semi/anti join, add a per-file allowlist analogous to the bare-join baseline.
59+
$argFiles = @($scanFiles | Where-Object {
60+
$_.RelPath -like 'src/workbooks/*' -or
61+
$_.RelPath -like 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/*' -or
62+
$_.RelPath -eq 'src/templates/finops-alerts/modules/logicApp.bicep'
63+
})
64+
65+
# Published docs mix engines within a single file: docs-mslearn/best-practices/compute.md
66+
# carries both ARG inventory queries and hub (ADX) cost queries, and the latter legitimately
67+
# use lookup. So docs are classified per code block rather than per file - see the
68+
# 'ARG examples' test below.
69+
$docsFiles = @($scanFiles | Where-Object { $_.RelPath -like 'docs-mslearn/*' })
70+
}
71+
72+
BeforeAll {
73+
$repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path
74+
$scanFileCount = @(
75+
(Join-Path $repoRoot 'src/workbooks'),
76+
(Join-Path $repoRoot 'src/optimization-engine'),
77+
(Join-Path $repoRoot 'src/queries/catalog'),
78+
(Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts')
79+
) | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Include '*.kql', '*.workbook', 'workbook.json', '*.ps1', '*.json' -File -ErrorAction SilentlyContinue } | Measure-Object | Select-Object -ExpandProperty Count
80+
81+
# Matches `| join` not followed by `kind=` before the right-table parenthesis.
82+
# Catches `| join (`, `| join(`, and `| join hint.x=y (`; ignores `| join kind=...` and `lookup`.
83+
$bareJoinPattern = [regex]'\|\s*join\b(?![^(\r\n]*\bkind\s*=)'
84+
85+
# Operators Azure Resource Graph rejects with InvalidQuery (verified live, 2026-08).
86+
$argRejectedPattern = [regex]'\|\s*lookup\b|join\s+kind\s*=\s*(leftanti|leftsemi|rightanti|rightsemi|anti|semi|leftantisemi|rightantisemi)\b'
87+
88+
# ARG tables that can open a query. A KQL query names its source table first, so the first
89+
# non-comment line of a docs code block identifies the engine it targets.
90+
$argTablePattern = [regex]'^\s*(resources|resourcecontainers|advisorresources|resourcechanges|resourcecontainerchanges|healthresources|securityresources|policyresources|guestconfigurationresources|patchassessmentresources|patchinstallationresources|maintenanceresources|servicehealthresources|desktopvirtualizationresources|kubernetesconfigurationresources|extendedlocationresources|networkresources|chaosresources|iotsecurityresources|insightsresources)\b'
91+
92+
# Pre-existing bare joins, counted per repo-relative path. Ratchet only: lower on fix, never raise.
93+
# All remaining entries are benign today (left side unique on the join key) but rely on the
94+
# innerunique default implicitly. Convert to an explicit kind when touching these queries.
95+
$baseline = @{
96+
'src/workbooks/optimization/AHB/AHB.workbook' = 24
97+
'src/workbooks/optimization/Compute/AHB.workbook' = 20
98+
'src/workbooks/optimization/Networking/Networking.workbook' = 3
99+
'src/workbooks/governance/workbook.json' = 1
100+
}
101+
}
102+
103+
It 'Should scan at least one file per surface' {
104+
$scanFileCount | Should -BeGreaterThan 100
105+
}
106+
107+
It 'Should not add bare joins (no explicit kind): <RelPath>' -ForEach $scanFiles {
108+
$content = Get-Content -Path $FullName -Raw
109+
$bareJoins = @($bareJoinPattern.Matches($content))
110+
$allowed = if ($baseline.ContainsKey($RelPath)) { $baseline[$RelPath] } else { 0 }
111+
112+
$bareJoins.Count | Should -BeLessOrEqual $allowed -Because ('a bare "| join" defaults to kind=innerunique, which deduplicates the left side on the join key and silently drops rows (see PR #2225). State the kind explicitly: kind=inner for lookups/filters, kind=leftouter for enrichment, kind=leftanti for exclusion. In ADX/Log Analytics, prefer the lookup operator for small dimension tables.')
113+
114+
if ($bareJoins.Count -le $allowed)
115+
{
116+
# Ratchet: if bare joins were removed, the baseline must be lowered so they cannot return.
117+
$bareJoins.Count | Should -Be $allowed -Because ("the bare-join count in this file dropped below the baseline ($allowed); lower the baseline entry for '$RelPath' in KqlJoinKinds.Tests.ps1 to $($bareJoins.Count) (or remove it if 0) so the fix is locked in.")
118+
}
119+
}
120+
121+
It 'Should not use operators ARG rejects (lookup, semi/anti joins): <RelPath>' -ForEach $argFiles {
122+
$content = Get-Content -Path $FullName -Raw
123+
$rejected = @($argRejectedPattern.Matches($content))
124+
125+
@($rejected | ForEach-Object { $_.Value }) -join '; ' | Should -BeNullOrEmpty -Because ('Azure Resource Graph rejects the lookup operator and all semi/anti join flavors with InvalidQuery (verified live; supported kinds are inner, innerunique, leftouter, rightouter, fullouter). For exclusions in ARG, use join kind=leftouter + where isempty(<right key>) with a key-unique right side. If this file contains a Log Analytics query that legitimately needs the operator, add a per-file allowlist to this test.')
126+
}
127+
128+
It 'Should not use operators ARG rejects in docs ARG examples: <RelPath>' -ForEach $docsFiles {
129+
$content = Get-Content -Path $FullName -Raw
130+
131+
# Fenced code blocks, so a hub (ADX) example in the same file cannot mask or trip this rule.
132+
$offenders = @(
133+
foreach ($block in [regex]::Matches($content, '(?ms)^```[a-zA-Z]*\r?\n(.*?)^```'))
134+
{
135+
$code = $block.Groups[1].Value
136+
$firstLine = @($code -split '\r?\n' | Where-Object { $_.Trim() -and $_.Trim() -notmatch '^//' })[0]
137+
if ($null -eq $firstLine -or -not $argTablePattern.IsMatch($firstLine)) { continue }
138+
139+
$argRejectedPattern.Matches($code) | ForEach-Object { $_.Value.Trim() }
140+
}
141+
)
142+
143+
$offenders -join '; ' | Should -BeNullOrEmpty -Because ('this code block opens with an Azure Resource Graph table, and ARG rejects the lookup operator and all semi/anti join flavors with InvalidQuery (verified live). Published examples are copied verbatim by readers, so they must run as written: use join kind=leftouter + where isempty(<right key>) for exclusions. Hub (ADX) examples in the same file are unaffected - they open with Costs, Prices, or another hub table.')
144+
}
145+
}

0 commit comments

Comments
 (0)