Skip to content

[Hubs] Add new custom recommendations#2049

Open
flanakin wants to merge 4 commits intofeatures/hubs-recsfrom
flanakin/new-recs
Open

[Hubs] Add new custom recommendations#2049
flanakin wants to merge 4 commits intofeatures/hubs-recsfrom
flanakin/new-recs

Conversation

@flanakin
Copy link
Collaborator

@flanakin flanakin commented Mar 10, 2026

🛠️ Description

Added 15 new custom recommendation queries and updated 3 existing ones to expand FinOps hub optimization coverage across compute, databases, networking, storage, and web services.

New recommendations: Basic LBs, Basic Public IPs, Classic App Gateways, Empty App Service Plans, Empty NSGs, Idle VNet Gateways, Legacy MySQL/PostgreSQL/Storage, Orphaned NAT Gateways, Premium Snapshots, Unassociated DDoS Plans, Unattached NICs, Unmanaged Disks, Unprovisioned ExpressRoute Circuits

Updated recommendations: NonSpotAKSClusters, SQLVMsWithoutAHB, VMsWithoutAHB

Docs updates: Expanded best practices docs (compute, databases, networking, storage, web) with recommendations documentation. Updated configure-recommendations.md with grouped service categories.

📋 Checklist

🔬 How did you test this change?

  • 🤏 Lint tests
  • 🤞 PS -WhatIf / az validate
  • 👍 Manually deployed + verified
  • 💪 Unit tests
  • 🙌 Integration tests

🙋‍♀️ Do any of the following that apply?

  • 🚨 This is a breaking change.
  • 🤏 The change is less than 20 lines of code.

📑 Did you update docs/changelog.md?

  • ✅ Updated changelog (required for dev PRs)
  • ➡️ Will add log in a future PR (feature branch PRs only)
  • ❎ Log not needed (small/internal change)

📖 Did you update documentation?

  • ✅ Public docs in docs (required for dev)
  • ✅ Public docs in docs-mslearn (required for dev)
  • ✅ Internal dev docs in docs-wiki (required for dev)
  • ✅ Internal dev docs in src (required for dev)
  • ➡️ Will add docs in a future PR (feature branch PRs only)
  • ❎ Docs not needed (small/internal change)

Copilot AI review requested due to automatic review settings March 10, 2026 02:54
@microsoft-github-policy-service microsoft-github-policy-service bot added the Needs: Review 👀 PR that is ready to be reviewed label Mar 10, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR expands FinOps hubs recommendations by adding new Azure Resource Graph (ARG) recommendation queries and exposing new deployment switches to control noisy recommendation sets, alongside broad documentation refreshes and some build/test script improvements across the repo.

Changes:

  • Added multiple new custom ARG recommendation query JSON files and normalized several existing recommendation query metadata fields.
  • Updated FinOps hubs Bicep parameters to make recommendations opt-in by default and add separate toggles for AHB and non-Spot AKS recommendations.
  • Updated PowerShell build/test/publish scripts and refreshed a large set of docs (notably ms.date, formatting, and best-practices content).

Reviewed changes

Copilot reviewed 199 out of 201 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/templates/finops-hub/modules/hub.bicep Adds recommendation enablement parameters and passes new flags to recommendations module
src/templates/finops-hub/modules/fx/scripts/Init-DataFactory.ps1 Changes trigger subscription handling logic and polling
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnprovisionedExpressRouteCircuits.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnmanagedDisks.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedNICs.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnattachedDisks.json Normalizes query metadata/provider strings
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-UnassociatedDDoSPlans.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-StoppedVMs.json Normalizes query metadata/provider strings
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-PremiumSnapshots.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-OrphanedNATGateways.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-LegacyStorageAccounts.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-LegacyPostgreSQLServers.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-LegacyMySQLServers.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-IdleVNetGateways.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptySQLElasticPools.json Normalizes query metadata/provider strings
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyNSGs.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-EmptyAppServicePlans.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-ClassicAppGateways.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicPublicIPs.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BasicLoadBalancers.json New recommendation query
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-BackendlessLoadBalancers.json Normalizes query metadata/provider strings
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-AdvisorCost.json Normalizes query metadata/provider strings
src/templates/finops-hub/main.bicep Exposes new recommendation parameters at top-level template
src/scripts/Update-Version.ps1 Uses Get-Version -AsTag to compute tag
src/scripts/Test-PowerShell.ps1 Adds Workbooks/Actions test switches and selection logic
src/scripts/Publish-Toolkit.ps1 Updates help text and makes Find-Repo return first match
src/scripts/Package-Toolkit.ps1 Adjusts packaging messages and ZIP naming to use tag
src/scripts/New-Directory.ps1 Improves comment-based help
src/scripts/Invoke-Task.ps1 Expands comment-based help for versioning switches
src/scripts/Get-Version.ps1 Adds -AsTag option and help text
src/scripts/Build-Workbook.ps1 Reorders/help text cleanup for workbook parameter docs
src/scripts/Build-Toolkit.ps1 Expands help text for version increment switches
src/scripts/Build-PowerBI.ps1 Improves comment-based help parameter descriptions
src/scripts/Build-Bicep.ps1 Adds CmdletBinding and updates help text
src/powershell/Tests/Unit/New-FinOpsCostExport.Tests.ps1 Adjusts assertions to trim folder path slashes
src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 Adjusts mocks/signatures and adds Initialize-FinOpsHubDeployment mocking
src/powershell/Tests/Integration/Hubs.Tests.ps1 Uses helper function instead of global var for required RPs
src/powershell/Tests/Initialize-Tests.ps1 Replaces global required RPs with a function
src/powershell/Public/Start-FinOpsCostExport.ps1 Adds SupportsShouldProcess and refines verbose messaging/flow
src/powershell/Public/Remove-FinOpsHub.ps1 Switches user output from Write-Host to Write-Information
src/powershell/Public/Remove-FinOpsCostExport.ps1 File encoding/BOM update
src/powershell/Public/New-FinOpsCostExport.ps1 Adds SupportsShouldProcess guarding to RP registration and export creation
src/powershell/Public/Initialize-FinOpsHubDeployment.ps1 Fixes ShouldProcess handling and removes WhatIfPreference pass-through hack
src/powershell/Public/Get-FinOpsCostExport.ps1 File encoding/BOM update
src/powershell/Private/Split-AzureResourceId.ps1 Changes handling for null/empty IDs (now returns nothing)
src/powershell/Private/Save-FinOpsHubTemplate.ps1 Whitespace cleanup
src/powershell/Private/Invoke-Rest.ps1 Whitespace cleanup for token acquisition
src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl Adds CapacityReservationId/Status columns
src/power-bi/queries/ftk_NormalizeSchema.pq Adds capacity reservation normalization columns
docs/guide.md Removes ebook tile from landing page
docs/README.md Adds contributor entry
docs-mslearn/toolkit/workbooks/optimization.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/workbooks/governance.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/workbooks/finops-workbooks-overview.md Updates ms.date and formatting
docs-mslearn/toolkit/roadmap.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/toolkit/get-finopstoolkitversion.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/toolkit/finops-toolkit-commands.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/powershell-commands.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/hubs/remove-finopshubscope.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/hubs/remove-finopshub.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/hubs/register-finopshubproviders.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/hubs/initialize-finopshubdeployment.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/hubs/get-finopshub.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/hubs/finops-hubs-commands.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/hubs/deploy-finopshub.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/data/open-data-commands.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/data/get-finopsservice.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/data/get-finopsresourcetype.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/data/get-finopsregion.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/data/get-finopspricingunit.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/cost/start-finopscostexport.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/cost/remove-finopscostexport.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/cost/new-finopscostexport.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/cost/get-finopscostexport.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/cost/cost-management-commands.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/powershell/cost/add-finopsserviceprincipal.md Updates ms.date and formatting/prettier ignore blocks
docs-mslearn/toolkit/power-bi/workload-optimization.md Updates ms.date, formatting, and required dataset versions table
docs-mslearn/toolkit/power-bi/template-app.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/power-bi/help-me-choose.md Updates ms.date and formatting
docs-mslearn/toolkit/power-bi/data-ingestion.md Updates ms.date and adds download links block formatting
docs-mslearn/toolkit/power-bi/connector.md Updates ms.date, formatting, and fixes spacing typo
docs-mslearn/toolkit/optimization-engine/troubleshooting.md Updates ms.date and formatting
docs-mslearn/toolkit/optimization-engine/suppress-recommendations.md Updates ms.date and formatting
docs-mslearn/toolkit/optimization-engine/reports.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/optimization-engine/overview.md Updates ms.date and formatting (incl. IMPORTANT block)
docs-mslearn/toolkit/optimization-engine/faq.md Updates ms.date and formatting
docs-mslearn/toolkit/optimization-engine/customize.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/optimization-engine/configure-workspaces.md Updates ms.date and formatting
docs-mslearn/toolkit/hubs/upgrade.md Updates ms.date, formatting, and download/deploy blocks
docs-mslearn/toolkit/hubs/template.md Fixes URL formatting to angle-bracket style
docs-mslearn/toolkit/hubs/savings-calculations.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/hubs/configure-remote-hubs.md Updates content (China cloud wording + list formatting)
docs-mslearn/toolkit/hubs/configure-dashboards.md Updates ms.date and markdownlint directives
docs-mslearn/toolkit/hubs/configure-ai.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/hubs/compatibility.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/help/troubleshooting.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/help/terms.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/help/support.md Updates ms.date and formatting
docs-mslearn/toolkit/help/help-options.md Updates ms.date and formatting
docs-mslearn/toolkit/help/deploy.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/help/data-dictionary.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/finops-toolkit-overview.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/bicep-registry/scheduled-actions.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/bicep-registry/modules.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/toolkit/alerts/finops-alerts-overview.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/overview.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/index.yml Removes ebook highlighted card
docs-mslearn/implementing-finops-guide.md Updates ms.date and removes ebook link
docs-mslearn/framework/understand/understand-cloud-usage-cost.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/understand/reporting.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/understand/ingestion.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/understand/anomalies.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/understand/allocation.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/quantify/unit-economics.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/quantify/quantify-business-value.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/quantify/planning.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/quantify/forecasting.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/quantify/budgeting.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/quantify/benchmarking.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/optimize/workloads.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/optimize/rates.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/optimize/optimize-cloud-usage-cost.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/tools-services.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/operations.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/onboarding.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/manage-finops.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/invoicing-chargeback.md Updates ms.date and fixes spacing in a bullet
docs-mslearn/framework/manage/intersecting-disciplines.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/governance.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/education.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/manage/assessment.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/finops-framework.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/framework/capabilities.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/focus/what-is-focus.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/focus/validate.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/focus/metadata.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/focus/mapping.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/focus/convert.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/focus/conformance-summary.md Updates ms.date and adds prettier ignore blocks
docs-mslearn/best-practices/web.md Adds recommendation documentation for empty App Service plans
docs-mslearn/best-practices/storage.md Adds premium snapshot + legacy storage guidance and rewrites section
docs-mslearn/best-practices/library.md Updates ms.date and formatting
docs-mslearn/best-practices/compute.md Adds recommendation documentation for unmanaged disks
docs-mslearn/TOC.yml Removes ebook entry and adds remote hubs + data lake connectivity entries
docs-mslearn/.markdownlint.json Adds markdownlint configuration overrides
README.md Adds contributor entry
.vscode/settings.json PowerShell editor settings update (encoding/whitespace)
.github/workflows/update-mslearn-dates.yml New workflow to auto-update ms.date on PRs
.github/policies/issues-02-duplicate.yml Policy now closes duplicates and adjusts steps
.github/policies/issues-01-new.yml Policy description updated and adds welcome reply
.github/policies/issues-00-conventions.yml Renames label from Skill to Tool for workbooks
.github/copilot-instructions.md Updates Copilot repo instructions text/formatting
.editorconfig Sets UTF-8 BOM charset for PowerShell files
.build/BuildHelper/Start-PesterTest.ps1 Ensures Pester run exits with non-zero on failures
.all-contributorsrc Adds new contributor metadata
Comments suppressed due to low confidence (1)

src/powershell/Private/Split-AzureResourceId.ps1:57

  • Split-AzureResourceId no longer returns an AzureResourceIdInfo object when $Id is null/empty (it returns nothing). Unit tests in Split-AzureResourceId.Tests.ps1 expect an object with null/empty properties for these cases, and callers may also rely on a consistent return type. Consider returning an empty AzureResourceIdInfo instance when $Id is null/empty, instead of exiting without output.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 19 comments.

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.compute/snapshots' | where sku.name =~ 'Premium_LRS' | extend DiskSizeGB=tostring(properties.diskSizeGB), TimeCreated=tostring(properties.timeCreated), Location=location, SKUName=tostring(sku.name) | project id, name, resourceGroup, subscriptionId, Location, DiskSizeGB, TimeCreated, SKUName, type | project x_RecommendationId=strcat(tolower(id),'-premiumSnapshot'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Snapshot is using Premium SSD storage instead of Standard', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\\\"DiskSizeGB\\\": ', DiskSizeGB, ', \\\"SKUName\\\": \\\"', SKUName, '\\\", \\\"TimeCreated\\\": \\\"', TimeCreated, '\\\", \\\"Location\\\": \\\"', Location, '\\\", \\\"x_RecommendationProvider\\\": \\\"FinOps hubs\\\", \\\"x_RecommendationSolution\\\": \\\"Change snapshot storage type from Premium SSD to Standard HDD\\\", \\\"x_RecommendationTypeId\\\": \\\"e7f8a9b0-1c2d-3e4f-5a6b-7c8d9e0f1a2b\\\", \\\"x_ResourceType\\\": \\\"', type, '\\\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted all query files from strcat() to bag_pack() for x_RecommendationDetails.

Comment on lines +46 to +47
FinOps hubs include the following recommendations. Most are enabled by default. Optional recommendations may generate noise for organizations where they don't apply and can be enabled during deployment via the specified template parameter.

Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

This PR introduces customer-facing recommendations and docs changes, but there’s no corresponding entry in docs-mslearn/toolkit/changelog.md. Repo guidance requires external-facing changes to be captured in the changelog for the next release (see docs-wiki/Branching-strategy.md and docs-wiki/Release-process.md). Please add a changelog entry for the FinOps hubs recommendations expansion.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Noted

Changelog is covered by an existing entry. No additional update needed.

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.network/publicipaddresses' | where sku.name =~ 'Basic' | extend Location=location, AllocationMethod=tostring(properties.publicIPAllocationMethod), IpAddress=tostring(properties.ipAddress) | project id, name, resourceGroup, subscriptionId, Location, AllocationMethod, IpAddress, type | project x_RecommendationId=strcat(tolower(id),'-basicPublicIP'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Public IP is using the retired Basic SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"AllocationMethod\": \"', AllocationMethod, '\", \"IpAddress\": \"', IpAddress, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Upgrade to Standard SKU public IP\", \"x_RecommendationTypeId\": \"f1a2b3c4-d5e6-7f8a-9b0c-1d2e3f4a5b6c\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) (as used in existing recommendation queries) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted to bag_pack().

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.network/applicationgateways' | where tostring(properties.sku.tier) in~ ('Standard', 'WAF') | extend Location=location, SKUName=tostring(properties.sku.name), SKUTier=tostring(properties.sku.tier), SKUCapacity=tostring(properties.sku.capacity) | project id, name, resourceGroup, subscriptionId, Location, SKUName, SKUTier, SKUCapacity, type | project x_RecommendationId=strcat(tolower(id),'-classicAppGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Application Gateway is using the retiring v1 SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"SKUName\": \"', SKUName, '\", \"SKUTier\": \"', SKUTier, '\", \"SKUCapacity\": \"', SKUCapacity, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Migrate to Application Gateway v2 (Standard_v2 or WAF_v2)\", \"x_RecommendationTypeId\": \"b3c4d5e6-f7a8-9b0c-1d2e-3f4a5b6c7d8e\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) (as used in existing recommendation queries) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted to bag_pack().

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.network/networksecuritygroups' | where isnull(properties.networkInterfaces) and isnull(properties.subnets) | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-emptyNSG'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Network security group is not associated with any network interface or subnet', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Delete the orphaned network security group or associate it with a subnet or NIC\", \"x_RecommendationTypeId\": \"b9c0d1e2-f3a4-5b6c-7d8e-9f0a1b2c3d4e\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) (as used in existing recommendation queries) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted to bag_pack().

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.network/natgateways' | where isnull(properties.subnets) or array_length(properties.subnets) == 0 | extend Location=location, SKUName=tostring(sku.name) | project id, name, resourceGroup, subscriptionId, Location, SKUName, type | project x_RecommendationId=strcat(tolower(id),'-orphanedNATGateway'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='NAT gateway is not associated with any subnet', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\\\"SKUName\\\": \\\"', SKUName, '\\\", \\\"Location\\\": \\\"', Location, '\\\", \\\"x_RecommendationProvider\\\": \\\"FinOps hubs\\\", \\\"x_RecommendationSolution\\\": \\\"Delete the orphaned NAT gateway or associate it with a subnet\\\", \\\"x_RecommendationTypeId\\\": \\\"b4c5d6e7-8f9a-0b1c-2d3e-4f5a6b7c8d9e\\\", \\\"x_ResourceType\\\": \\\"', type, '\\\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted to bag_pack().

SKUTier = tostring(sku.tier),
Location = location,
resourceGroup = tostring(strcat('/subscriptions/', subscriptionId, '/resourceGroups/', resourceGroup)),
subnet = tostring(properties.subnet),
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

properties.subnet doesn’t match the NAT gateway shape used elsewhere in this PR (properties.subnets). As written, subnet = tostring(properties.subnet) will always be empty/null; either project properties.subnets (for example, count/ids) or remove this column from the sample.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Fixed properties.subnetproperties.subnets in the docs sample query.

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.network/loadbalancers' | where sku.name =~ 'Basic' | extend Location=location | project id, name, resourceGroup, subscriptionId, Location, type | project x_RecommendationId=strcat(tolower(id),'-basicLoadBalancer'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Load balancer is using the retired Basic SKU', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Upgrade to Standard SKU load balancer\", \"x_RecommendationTypeId\": \"a2b3c4d5-e6f7-8a9b-0c1d-2e3f4a5b6c7d\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) (as used in existing recommendation queries) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted to bag_pack().

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.storage/storageaccounts' | where kind in~ ('Storage', 'BlobStorage') | extend Location=location, AccessTier=tostring(properties.accessTier), Kind=tostring(kind) | project id, name, resourceGroup, subscriptionId, Location, Kind, AccessTier, type | project x_RecommendationId=strcat(tolower(id),'-legacyStorageAccount'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='Storage account is using a legacy kind that is being retired', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\"Kind\": \"', Kind, '\", \"AccessTier\": \"', AccessTier, '\", \"Location\": \"', Location, '\", \"x_RecommendationProvider\": \"FinOps hubs\", \"x_RecommendationSolution\": \"Upgrade to General Purpose v2 (StorageV2) storage account\", \"x_RecommendationTypeId\": \"c4d5e6f7-a8b9-0c1d-2e3f-4a5b6c7d8e9f\", \"x_ResourceType\": \"', type, '\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted to bag_pack().

{
"dataset": "Recommendations",
"provider": "Microsoft",
"query": "resources | where type =~ 'microsoft.network/expressroutecircuits' | where tostring(properties.serviceProviderProvisioningState) != 'Provisioned' | extend CircuitState=tostring(properties.circuitProvisioningState), ProviderState=tostring(properties.serviceProviderProvisioningState), ServiceProvider=tostring(properties.serviceProviderProperties.serviceProviderName), BandwidthMbps=tostring(properties.serviceProviderProperties.bandwidthInMbps), SKUName=tostring(sku.name), SKUTier=tostring(sku.tier), SKUFamily=tostring(sku.family), Location=location | project id, name, resourceGroup, subscriptionId, Location, CircuitState, ProviderState, ServiceProvider, BandwidthMbps, SKUName, SKUTier, SKUFamily, type | project x_RecommendationId=strcat(tolower(id),'-unprovisionedExpressRoute'), x_ResourceGroupName=tolower(resourceGroup), SubAccountId=subscriptionId, x_RecommendationCategory='Cost', x_RecommendationDescription='ExpressRoute circuit is not in provisioned state', ResourceId = tolower(id), ResourceName=tolower(name), x_RecommendationDetails= strcat('{\\\"CircuitState\\\": \\\"', CircuitState, '\\\", \\\"ProviderState\\\": \\\"', ProviderState, '\\\", \\\"ServiceProvider\\\": \\\"', ServiceProvider, '\\\", \\\"BandwidthMbps\\\": \\\"', BandwidthMbps, '\\\", \\\"SKUName\\\": \\\"', SKUName, '\\\", \\\"SKUTier\\\": \\\"', SKUTier, '\\\", \\\"SKUFamily\\\": \\\"', SKUFamily, '\\\", \\\"Location\\\": \\\"', Location, '\\\", \\\"x_RecommendationProvider\\\": \\\"FinOps hubs\\\", \\\"x_RecommendationSolution\\\": \\\"Complete provisioning or delete the ExpressRoute circuit\\\", \\\"x_RecommendationTypeId\\\": \\\"d6e7f8a9-0b1c-2d3e-4f5a-6b7c8d9e0f1a\\\", \\\"x_ResourceType\\\": \\\"', type, '\\\"}'), x_RecommendationDate = now() | join kind=leftouter ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project SubAccountName=name, SubAccountId=subscriptionId ) on SubAccountId | project-away SubAccountId1",
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

x_RecommendationDetails is being built as a JSON-looking string with escaped quotes via strcat(...). This will be ingested as a string (not a dynamic object) and the value isn’t valid JSON due to the embedded \" sequences, which breaks downstream queries that expect x_RecommendationDetails to be a dynamic bag. Prefer emitting a dynamic value using bag_pack(...) and avoid manual JSON string construction.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [AI][Claude] ✅ Implemented

Converted to bag_pack().

@flanakin flanakin changed the title Add new custom recommendations [Hubs] Add new custom recommendations Mar 10, 2026
@flanakin flanakin added the Tool: FinOps hubs Data pipeline solution label Mar 10, 2026
@flanakin
Copy link
Collaborator Author

test summary

- Convert 15 recommendation query files from strcat() to bag_pack() for x_RecommendationDetails
- Fix networking docs NAT gateway query: add array_length check and fix subnet to subnets

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: copilot-pull-request-reviewer <copilot-pull-request-reviewer@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@flanakin flanakin force-pushed the flanakin/new-recs branch from 5d23e40 to 89be70e Compare March 10, 2026 11:15
@flanakin
Copy link
Collaborator Author

🤖 [AI][Claude] PR Update Summary

Addressed: 19 thread(s)

  • ✅ Implemented: 16
  • 📝 Noted: 3 (changelog covered by existing entry, app.bicep inclusion planned for future PR)

Key changes:

  • Converted all 15 new recommendation query files from strcat() to bag_pack() for x_RecommendationDetails
  • Fixed networking docs NAT gateway sample query (added array_length check, fixed properties.subnet to properties.subnets)

@microsoft-github-policy-service microsoft-github-policy-service bot added Needs: Attention 👋 Issue or PR needs to be reviewed by the author or it will be closed due to no activity and removed Needs: Review 👀 PR that is ready to be reviewed labels Mar 10, 2026
- Replace IdleVNetGateways query to lowercase gateway ID and simplify mv-expand
- Replace LegacyStorageAccounts query to exclude Premium blob storage accounts
- Update EmptyAppServicePlans query to cast numberOfSites to int and exclude Free tier
- Remove obsolete LegacyMySQLServers and LegacyPostgreSQLServers recommendations and docs

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: helderpinto <helderpinto@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@flanakin
Copy link
Collaborator Author

🤖 [AI][Claude] PR Update Summary — Addressed 7 threads: 5 implemented and 3 questions (group property deferred). Key changes: replaced IdleVNetGateways and LegacyStorageAccounts queries per reviewer feedback. Removed obsolete MySQL/PostgreSQL recs. Updated EmptyAppServicePlans query.

@microsoft-github-policy-service microsoft-github-policy-service bot removed the Needs: Attention 👋 Issue or PR needs to be reviewed by the author or it will be closed due to no activity label Mar 12, 2026
@microsoft-github-policy-service microsoft-github-policy-service bot added Needs: Triage 🔍 Untriaged issue needs to be reviewed Needs: Review 👀 PR that is ready to be reviewed labels Mar 12, 2026
)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Review 👀 PR that is ready to be reviewed Needs: Triage 🔍 Untriaged issue needs to be reviewed Tool: FinOps hubs Data pipeline solution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants