Skip to content

Add AI billing + Cost Optimization ADX dashboard view to FinOps hub dashboard - #2282

Open
Arthur Silvany (arthursilvany) wants to merge 19 commits into
microsoft:devfrom
arthursilvany:arthursilvany/ai-billing-opt-dashboard-fetch
Open

Add AI billing + Cost Optimization ADX dashboard view to FinOps hub dashboard#2282
Arthur Silvany (arthursilvany) wants to merge 19 commits into
microsoft:devfrom
arthursilvany:arthursilvany/ai-billing-opt-dashboard-fetch

Conversation

@arthursilvany

Copy link
Copy Markdown

Summary\n- adds the custom - AI billing ADX page into the versioned FinOps Hub dashboard source\n- keeps the existing - Usage optimization (Advisor recommendations) view in place\n- wires AI visuals to shared cost base queries and applies the global billing currency filter\n- normalizes AI KQL service predicates with explicit grouping\n\n## Scope\n- src/templates/finops-hub/dashboard.json\n\n## Notes\n- the AI page is inserted after - Usage optimization under OPTIMIZE\n- includes 7 tiles and 4 AI-specific queries

Add a new Microsoft.Billing/Invoices hub app that downloads Microsoft
invoice PDFs from the Billing API into the ingestion container of the
hub data lake. The feature is opt-in via a new "Invoices" step in the
deployment wizard and supports multiple MCA/MPA billing accounts.

- New app with HTTP linked service, binary datasets, orchestrator and
  worker pipelines, and a configurable monthly trigger
- Billing accounts are read from settings.json, populated by the wizard
- New Add-FinOpsHubBillingReader cmdlet to grant the Data Factory
  managed identity the Billing Reader role after deployment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document the AGENTS.md symlink design, first-time repo setup via Init-Repo, and the Pester 6 requirement. Correct the build-ps command and the version reference, expand the test command reference (categories, -AllTests, -RunFailed, single-test runs), extend the architecture overview with the hub namespaces and fx modules, and add changelog conventions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a30c322e-2106-4435-a1d6-18b2380197d0
Capture the approved plan and the technical design for optional AWS and Google Cloud FOCUS ingestion in FinOps hubs. Records the spike results: exportConfig.resourceId is only used as a path segment, and focuscost_1.0.json cannot be reused because 52 of its 96 mappings are Azure-specific, so per-provider schemas selected through exportConfig.dataVersion are used instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a30c322e-2106-4435-a1d6-18b2380197d0
Add focuscost_1.2-aws.json so the existing msexports ETL can process FOCUS files exported from AWS. The schema maps the 53 FOCUS columns shared with the Azure 1.2 schema plus AvailabilityZone, x_Operation, and x_ServiceCode, all of which already exist in the Costs_raw table. The 51 Azure-specific x_ columns are omitted because AWS exports do not contain them.

Validated against a real AWS FOCUS export. x_Discounts and the three PricingCurrency columns are omitted because they have no equivalent in the Data Explorer schema; adding them would require changing the raw and final tables for all providers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a30c322e-2106-4435-a1d6-18b2380197d0
Record the S3 layout, the manifest delivery contract, and three design consequences: the AWS Manifest.json must never be copied into msexports, reading it is mandatory to avoid duplicating daily refresh folders in create-new mode, and it doubles as the completion signal. Also document how ingestionId and a stable destination folder make daily refreshes replace rather than accumulate data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a30c322e-2106-4435-a1d6-18b2380197d0
The Billing download API is a long-running operation that returns 202 with a
Location header, but the Web activity did not poll it. The pipeline used the
first response as if it were the final download URL, so invoices were saved
empty or the copy failed outright.

ADF does not allow an Until activity nested inside a ForEach, so per-invoice
download moved to a new invoices_DownloadInvoiceFile child pipeline. This also
gives each iteration its own variable scope, which the shared parent variables
could not provide when running in parallel.

- Add invoices_DownloadInvoiceFile with an Until loop that polls the operation
  for up to 30 minutes, falling back to Azure-AsyncOperation when Location is
  absent, and a Fail activity that reports InvoiceDownloadUrlNotAvailable
  instead of silently writing an empty file.
- Mark every activity that handles the SAS URL as secureInput/secureOutput.
- Guard the optional billingProfileId and purchaseOrderNumber properties with
  contains(). coalesce() cannot rescue a missing property because dereferencing
  an absent property fails to evaluate first.
- Move the invoice download changelog entry from the released v15 section to
  Unreleased, since the feature has not shipped yet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both design documents were written in Portuguese. Repository files must be in
English, so this rewrites them in full while preserving the technical content.

Also records findings from validating the AWS path end to end against a
deployed v14 hub using a real customer FOCUS export:

- Add section 11 documenting the run. Load Schema Mappings resolved
  focuscost_1.2-aws.json and Convert to Parquet succeeded against the real
  19,827-row AWS parquet, so the schema needs no ETL changes. Tags and
  SkuPriceDetails survive the TabularTranslator as JSON and ADX materializes
  them as dynamic, which closes the last open type risk.
- Document that the ADX drop-by tag is case-sensitive and carries the full blob
  path including the file name. Because the ETL lowercases the scope,
  exportConfig.resourceId must always be emitted in lowercase or a refresh
  duplicates the month silently. Ingesting the same file through both routes
  produced 39,654 rows and double the cost with no error raised.
- Correct section 2.2, which showed FocusCost/... as the destination path.
  focuscost maps to the Costs hub dataset, so the path is Costs/....
- Fix three broken references to files/design-multicloud-focus.md in the plan
  and flag its spike results as superseded, since they still claimed FOCUS 1.0
  and provenance stamping via additionalColumns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Billing download API is a long-running operation that returns 202 with a
Location header, but the Web activity did not poll it. The pipeline used the
first response as if it were the final download URL, so invoices were saved
empty or the copy failed outright.

ADF does not allow an Until activity nested inside a ForEach, so per-invoice
download moved to a new invoices_DownloadInvoiceFile child pipeline. This also
gives each iteration its own variable scope, which the shared parent variables
could not provide when running in parallel.

- Add invoices_DownloadInvoiceFile with an Until loop that polls the operation
  for up to 30 minutes, falling back to Azure-AsyncOperation when Location is
  absent, and a Fail activity that reports InvoiceDownloadUrlNotAvailable
  instead of silently writing an empty file.
- Mark every activity that handles the SAS URL as secureInput/secureOutput.
- Guard the optional billingProfileId and purchaseOrderNumber properties with
  contains(). coalesce() cannot rescue a missing property because dereferencing
  an absent property fails to evaluate first.
- Move the invoice download changelog entry from the released v15 section to
  Unreleased, since the feature has not shipped yet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 79763cf1-bdec-4a16-9c6a-6854ebdd3176
… assignment

Data Factory rejects a container activity inside another container activity at
run time, so the ForEach nested in the If Condition branch of
invoices_DownloadInvoices failed with "Container activity cannot include another
container activity". The pipeline deployed successfully, so the failure only
surfaced on the monthly run. Replace the If Condition with two top-level ForEach
activities that each iterate over an empty array when they do not apply.

Billing account scopes are not part of Azure RBAC, so New-AzRoleAssignment
cannot grant Billing Reader at /providers/Microsoft.Billing/billingAccounts/<id>.
Use the billing role assignment API instead, matching the pattern already used
by Add-FinOpsServicePrincipal, and look up the reader role definition by name so
the command works across agreement types. Update the README to point at the
portal instead of the equivalent az role assignment create command.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2331b11e-b4d8-460b-981d-b69dfd5eb133
Add an optional AWS connector that copies FOCUS 1.2 cost and usage
exports from an Amazon S3 bucket into the hub data lake on a daily
schedule, so AWS costs flow through the existing msexports ETL and land
in ADX alongside Microsoft Cloud costs.

Collection is driven by the AWS Data Exports manifest (dataFiles array)
rather than by listing the data folder, because in "create new" mode the
folder accumulates one subfolder per daily refresh and listing would
duplicate the month's costs.

- Add Microsoft.FinOpsHubs/AmazonWebServices app and metadata modules
- Wire the module into hub.bicep and main.bicep behind
  enableAwsFocusIngestion (no-op when disabled)
- Add a Multicloud step to createUiDefinition.json
- Add AWS parameters to Deploy-FinOpsHub, gated on hub version 15.0
- Add unit tests, module README, and template/changelog docs
- Correct section 5 of the multicloud FOCUS design to match the
  manifest-driven flow validated during the spike

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2331b11e-b4d8-460b-981d-b69dfd5eb133
Both settings compile and deploy cleanly but fail once Data Factory
actually uses them. They were found by deploying the connector to a live
hub and running the collection end to end.

- Add the zone designator to the daily trigger start time. With
  timeZone UTC, Data Factory requires 'yyyy-MM-ddTHH:mm:ssZ'. Without it
  the trigger deploys but fails to start with
  InvalidWorkflowTriggerRecurrence, which aborts the hub deployment in
  the start-triggers script and leaves every trigger stopped.
- Drop quoteAllText from the manifest sink. Data Factory rejects false
  with DelimitedTextInvalidSettings. The empty quoteChar on the dataset
  already disables quoting, so the manifest is written as raw JSON.

Add a lint rule covering both traps across the template tree.

Validated end to end against a deployed hub: a real AWS FOCUS 1.2 export
staged with a generated manifest ingested 19,827 rows and USD 82,246.63
into Data Explorer with ProviderName AWS, matching the source file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 2331b11e-b4d8-460b-981d-b69dfd5eb133
- Updated Multicloud-FOCUS-plan.md to outline the scope and rationale for optional FOCUS data ingestion from AWS and Google during FinOps hub installation.
- Added new parameters for AWS and Google FOCUS ingestion in the main template and portal UI.
- Enhanced security by using Key Vault for storing AWS/Google credentials.
- Extended Data Factory with new Bicep modules for downloading invoices, including a new pipeline for downloading individual invoice files.
- Improved error handling and validation in the invoice download process.
- Updated README.md for Invoices module to reflect new pipeline and error handling scenarios.
…ashboard

Integrates the AI billing page into the current FinOps hub ADX dashboard and keeps the existing Usage optimization recommendations view. Also aligns AI visuals with shared cost base queries and global billing currency filtering.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d854c6ea-51ff-49f7-8b9f-1d75f702cfac

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 substantially expands FinOps hub capabilities by adding new Azure Data Explorer (ADX) dashboard views (Usage optimization + AI billing) and introducing two optional hub apps: Microsoft invoice download and AWS FOCUS ingestion, wired end-to-end through the hub template, portal deployment UX, docs, and PowerShell tooling.

Changes:

  • Added ADX dashboard pages/queries for Usage optimization (recommendations) and AI billing.
  • Added optional Invoices app (monthly Microsoft invoice download) and optional AWS FOCUS ingestion app (daily S3 collection + manifest-driven ingestion).
  • Updated deployment UX/docs/PowerShell/tests to surface and validate the new options.

Reviewed changes

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

Show a summary per file
File Description
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/Copy-FileToAzureBlob.ps1 Extends settings generation/upload to include invoices.billingAccounts driven by an env var.
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/app.bicep Adds invoiceBillingAccounts parameter and passes it into the settings upload deployment script.
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AmazonWebServices/README.md New documentation for AWS FOCUS collection behavior, requirements, and troubleshooting.
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AmazonWebServices/metadata.bicep Defines the metadata contract (datasets/linked services/pipelines) for the AWS app.
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AmazonWebServices/app.bicep New AWS S3 connector: linked service, datasets, pipelines, and schedule trigger.
src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/schemas/focuscost_1.2-aws.json Adds an AWS-specific FOCUS 1.2 schema mapping file for the existing ETL.
src/templates/finops-hub/modules/Microsoft.CostManagement/Exports/app.bicep Publishes the new AWS schema into the hub’s schema storage.
src/templates/finops-hub/modules/Microsoft.Billing/Invoices/README.md New documentation for invoice download, required permissions, and validation steps.
src/templates/finops-hub/modules/Microsoft.Billing/Invoices/metadata.bicep Defines the metadata contract for the Invoices app.
src/templates/finops-hub/modules/Microsoft.Billing/Invoices/app.bicep New invoices downloader: Billing API polling, copy to data lake, and monthly trigger.
src/templates/finops-hub/modules/hub.bicep Adds parameters and conditionally deploys the Invoices and AWS apps; updates telemetry and trigger startup dependencies.
src/templates/finops-hub/main.bicep Adds top-level parameters, parses invoice billing accounts, and wires values into modules/hub.bicep.
src/templates/finops-hub/dashboard.json Adds new ADX dashboard tiles/pages and KQL queries for optimization and AI billing views.
src/templates/finops-hub/createUiDefinition.json Adds new wizard steps for Invoices and Multicloud (AWS) and wires outputs to template params.
src/templates/finops-hub/.build.config Excludes new module READMEs from template packaging.
src/scripts/Deploy-Hub.ps1 Adds switches/params to enable invoice download and pass billing account IDs.
src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 Adds unit coverage around AWS FOCUS parameter passing and validation behavior.
src/powershell/Tests/Unit/Add-FinOpsHubResourceGraphReader.Tests.ps1 Adds unit tests for the new Resource Graph Reader role assignment cmdlet.
src/powershell/Tests/Lint/DataFactorySettings.Tests.ps1 Adds lint rules for ADF trigger startTime/timeZone and quoteAllText runtime pitfalls.
src/powershell/Public/Deploy-FinOpsHub.ps1 Adds public cmdlet parameters/wiring for AWS FOCUS ingestion deployment.
src/powershell/Public/Add-FinOpsHubResourceGraphReader.ps1 New helper cmdlet to grant Reader at subscription/mgmt-group scopes for ARG queries.
src/powershell/Public/Add-FinOpsHubBillingReader.ps1 New helper cmdlet to grant Billing Reader at billing account scope via Billing API.
src/powershell/en-US/FinOpsToolkit.strings.psd1 Adds localized strings for the new role-assignment cmdlets.
docs-wiki/design/Multicloud-FOCUS-plan.md Design plan describing the optional multicloud ingestion direction and rationale.
docs-wiki/design/Multicloud-FOCUS-design.md Detailed technical design and validation notes for the multicloud connector approach.
docs-mslearn/toolkit/hubs/template.md Documents new template parameters (invoices + AWS FOCUS).
docs-mslearn/toolkit/hubs/configure-recommendations.md Adds guidance for using Add-FinOpsHubResourceGraphReader.
docs-mslearn/toolkit/changelog.md Updates changelog with new hubs features and cmdlets.
AGENTS.md Enhances repo agent guidance (setup, testing, structure, and changelog conventions).
Suppressed comments (3)

src/templates/finops-hub/dashboard.json:3923

  • This AI billing trend query uses case-sensitive equality (==) for ServiceCategory / ServiceName. Using case-insensitive equality (=~) is safer and consistent with the dashboard's general KQL filtering approach.
      "text": "CostsByDay\n| where (ServiceCategory == 'AI and Machine Learning' or ServiceName == 'Azure AI Search')\n| summarize EffectiveCost = round(sum(EffectiveCost), 2) by bin(ChargePeriodStart, 1d)\n| order by ChargePeriodStart asc",

src/templates/finops-hub/dashboard.json:3934

  • This AI-by-service query uses case-sensitive equality (==) for ServiceCategory / ServiceName. Consider switching to =~ to avoid missing rows due to casing differences.
      "text": "let aiCosts = CostsByMonth | where (ServiceCategory == 'AI and Machine Learning' or ServiceName == 'Azure AI Search');\nlet totalAI = toscalar(aiCosts | summarize sum(EffectiveCost));\naiCosts\n| summarize\n    EffectiveCost = round(sum(EffectiveCost), 2),\n    ResourceCount = dcount(ResourceName)\n    by ServiceName\n| extend SharePercent = iff(totalAI > 0, round(EffectiveCost / totalAI * 100, 1), 0.0)\n| order by EffectiveCost desc",

src/templates/finops-hub/dashboard.json:3945

  • This Top resources query uses case-sensitive equality (==) for ServiceCategory / ServiceName. Using =~ avoids case-related misses and aligns with the dashboard's other KQL filters.
      "text": "CostsByDay\n| where (ServiceCategory == 'AI and Machine Learning' or ServiceName == 'Azure AI Search')\n| summarize\n    TotalCost = round(sum(EffectiveCost), 2),\n    DailyAvg = round(avg(EffectiveCost), 2)\n    by ResourceName, ServiceName\n| top 5 by TotalCost desc",

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +402 to +406
//------------------------------------------------------------------------------
// Invoices
//------------------------------------------------------------------------------

module invoices 'Microsoft.Billing/Invoices/app.bicep' = if (enableInvoiceDownload) {
author: flanakin
ms.author: micflan
ms.date: 06/03/2026
ms.date: 08/24/2026
| **awsBucketName** | String | Optional. Name of the Amazon S3 bucket that contains the FOCUS export. Requires enableAwsFocusIngestion. | |
| **awsBucketPath** | String | Optional. Path to the FOCUS export root folder in the bucket. This is the folder that contains the `data` and `metadata` subfolders created by AWS Data Exports. Requires enableAwsFocusIngestion. | |
| **awsAccountId** | String | Optional. AWS account ID (management or payer account) used to identify the source scope in the data lake. Requires enableAwsFocusIngestion. | |
| **awsRegion** | String | Optional. AWS region of the S3 bucket. Requires enableAwsFocusIngestion. | "us-east-1" |
"kind": "inline",
"dataSourceId": "23540be2-ffc9-4b61-8c4c-05e493e682a6"
},
"text": "let _isAI = (svcCat: string, svcName: string) { svcCat == 'AI and Machine Learning' or svcName == 'Azure AI Search' };\nlet aiCosts = CostsByDay | where _isAI(ServiceCategory, ServiceName);\nlet totalAI = toscalar(aiCosts | summarize round(sum(EffectiveCost), 2));\nlet totalCloud = toscalar(CostsByDay | summarize sum(EffectiveCost));\nlet aiPercent = iff(totalCloud > 0, round(totalAI / totalCloud * 100, 1), 0.0);\nlet resourceCount = toscalar(aiCosts | summarize dcount(ResourceName));\nlet currentMonthStart = startofmonth(now());\nlet priorMonthStart = startofmonth(datetime_add('month', -1, now()));\nlet currentMonthAI = toscalar(\n CostsPlus\n | where (ServiceCategory == 'AI and Machine Learning' or ServiceName == 'Azure AI Search')\n | where ChargePeriodStart >= currentMonthStart\n | summarize sum(EffectiveCost)\n);\nlet priorMonthAI = toscalar(\n CostsPlus\n | where (ServiceCategory == 'AI and Machine Learning' or ServiceName == 'Azure AI Search')\n | where ChargePeriodStart >= priorMonthStart and ChargePeriodStart < currentMonthStart\n | summarize sum(EffectiveCost)\n);\nlet momChange = iff(priorMonthAI > 0, round((currentMonthAI - priorMonthAI) / priorMonthAI * 100, 1), 0.0);\nprint\n ['Total AI Cost'] = totalAI,\n ['AI % of Cloud'] = aiPercent,\n ['MoM Change %'] = momChange,\n ['AI Resources'] = resourceCount",
…ling-opt-dashboard-fetch

# Conflicts:
#	docs-mslearn/toolkit/changelog.md
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants