Skip to content

FinOps

AlertHawk.FinOps is an ASP.NET Core service that analyzes Azure subscriptions for cost and utilization signals, persists results in SQL Server, optionally calls an external AI API for optimization recommendations, and exposes a REST API for dashboards and automation.

The solution is defined in AlertHawk.FinOps.slnx at the repository root of the FinOps folder. It includes:

ProjectRole
AlertHawk.FinOpsWeb API, Azure data collection, analysis orchestration, optional weekly scheduler
AlertHawk.FinOps.TestsUnit and integration tests

All controller routes are prefixed with /finops (see GlobalRoutePrefixConvention in the service). Example: /finops/api/Version. Most endpoints require JWT Bearer or Azure AD; exceptions are called out below.


Overview

  • Azure: Uses a service principal (Azure__*) to read subscriptions listed in Azure__SubscriptionIds (comma-separated). Typical roles include Reader on the subscription and Cost Management Reader where cost APIs are used.
  • SQL Server: Stores analysis runs, resource snapshots, cost details, historical cost rows, AI recommendations, and subscription metadata (description and optional monthly budget).
  • Analysis: On-demand (sync or async job) or weekly (UTC) via WeeklyAnalysis options for every configured subscription id. Async jobs are queued one at a time per FinOps API process to reduce Azure Cost Management throttling.
  • AI: Optional; URL, key, and header name come from AI configuration. When a subscription has a monthly budget configured, budget utilization is included in the AI prompt so recommendations can prioritize overspend risk.
  • Observability: Sentry is wired in Program.cs; Swagger UI is enabled only when ASPNETCORE_ENVIRONMENT is Development.

Subscription budgets

Each Azure subscription can have an optional monthly budget (USD) stored in the Subscriptions table. Budget is metadata managed through the API (or the AlertHawk UI); it is not read from Azure Cost Management budgets.

FieldTypeDescription
DescriptionstringFree-text label shown in the UI
Budgetdecimal?Monthly budget in USD; null means no budget
InfraSupportCostdecimalMonthly infra support overlay for historical charts (USD); default 400

Database migration

If upgrading an existing database, apply the SQL scripts before using budgets or per-subscription infra support:

sql
-- AlertHawk.FinOps/Migrations/AddSubscriptionBudget.sql
-- AlertHawk.FinOps/Migrations/AddSubscriptionInfraSupportCost.sql

The script is idempotent (IF COL_LENGTH ... IS NULL). New deployments that use EF EnsureCreated or full migrations already include the column when the schema is created from the current model.

API usage

Create or update metadata (including budget) with POST /finops/api/Subscriptions:

json
{
  "subscriptionId": "00000000-0000-0000-0000-000000000000",
  "description": "Production workloads",
  "budget": 10000.00,
  "infraSupportCost": 400.00
}
  • Omit budget or send null to clear the budget.
  • infraSupportCost defaults to 400 when omitted on create; used by the UI historical chart “Infra support costs” overlay.
  • GET /finops/api/AnalysisRuns/latest-per-subscription returns budget and infraSupportCost on each row (joined from Subscriptions) together with the latest run’s MTD cost, so clients can compute utilization without a second call.

AI recommendations

During analysis, the orchestrator loads the budget from SQL and attaches it to AzureResourceData.MonthlyBudget. When set, the AI prompt includes:

  • Monthly budget amount
  • MTD cost vs budget (utilization %)
  • Status: over budget (≥100%), near budget (≥80%), or remaining headroom

Recommendations are asked to factor budget risk into prioritization. If no budget is configured, prompt content is unchanged.


FinOps UI (AlertHawk UI)

The FinOps Metrics page in AlertHawk UI consumes the FinOps API. Highlights:

FeatureDescription
AnalysisPrimary action — runs async analysis for the selected subscription (POST /finops/api/Analysis/start-async)
Cost type labelHeader and detail panel show Costs: Actual (invoice) or Costs: Amortized from GET /finops/api/Analysis/settings (hover for description)
Description & budgetEdit subscription description, monthly budget, and infra support cost (same POST /finops/api/Subscriptions payload)
Budget columnGrid and table show budget; rows highlight near budget (≥80% MTD) and over budget (≥100%)
Cost details / AI / Historical / ForecastExplore cards open modals for the latest analysis run
Historical chartsFilters by App ID, resource group, and service type (cascading); optional budget reference line
Cost forecastProjects spend from daily historical totals (7 / 14 / 30 days); excludes incomplete today (uses through yesterday); daily budget reference line when budget is set

Budget thresholds in the UI match backend AI logic: 80% = near, 100% = over.


Analysis pipeline and Azure rate limits

Cost and historical data come from the Azure Cost Management Query API, which is heavily rate-limited. By default queries use ActualCost (invoice-style, as billed). Set Azure__CostQueryType=AmortizedCost to spread reservation and savings plan charges over the commitment term (FinOps / showback view). After changing this setting, re-run analysis so MTD and historical rows match the new mode; do not mix cost types in the same comparison window.

The service mitigates HTTP 429 responses by:

  • Serializing Cost Management POSTs process-wide (one query at a time)
  • Longer inter-page delays for historical $skiptoken pagination
  • Honoring Retry-After and x-ms-ratelimit-*-retry-after headers (backoff up to ~5 minutes)
  • Pausing ~15–25 seconds between MTD cost query and historical fetch in the same run
  • Running at most one background analysis job at a time (start-async queue)

Operational notes

  • Overlapping analyses (multiple start-async calls, sync start, and weekly jobs) are queued; later jobs stay pending until the gate frees.
  • A restart that “fixes” 429 errors usually stops an in-process retry storm so Azure’s window can expire — not a sign of broken TCP connections.
  • Watch logs for ⚠️ Azure HTTP TooManyRequests; waiting … during large historical pulls.

Environment variables

Configuration is read from appsettings.json and environment variables. Nested keys use __ (double underscore), e.g. ConnectionStrings__SqlConnectionString.

General

VariableDescription
ASPNETCORE_ENVIRONMENTDevelopment enables Swagger / Swagger UI; use Production in deployed environments
DOTNET_SYSTEM_GLOBALIZATION_INVARIANTSet to false in Docker images that need full culture data (see service Dockerfile)

Database

VariableDescription
ConnectionStrings__SqlConnectionStringRequired. SQL Server connection string for FinOpsDbContext

On startup the app applies EF Core migrations when pending migrations exist; otherwise it may EnsureCreated when no migrations are present (see Program.cs).

Azure (service principal)

VariableDescription
Azure__TenantIdAzure AD tenant id
Azure__ClientIdApplication (client) id
Azure__ClientSecretClient secret
Azure__SubscriptionIdsComma-separated Azure subscription GUIDs to analyze
Azure__CostQueryTypeActualCost (default, invoice / cash view) or AmortizedCost (reservation & savings plan spread). Exposed to the UI via GET /finops/api/Analysis/settings.

Weekly analysis (background)

VariableDescription
WeeklyAnalysis__Enabledtrue to run the hosted scheduler; false to disable
WeeklyAnalysis__DayOfWeekUtcDay name in English, e.g. Sunday
WeeklyAnalysis__HourUtc0–23 (UTC)
WeeklyAnalysis__MinuteUtc0–59 (UTC)

AI recommendations (optional)

VariableDescription
AI__ApiUrlHTTP endpoint for the recommendation agent
AI__ApiKeyAPI key value
AI__ApiKeyHeaderNameHeader name sent with the key (e.g. vendor-specific header)

The AI client uses a 600s HTTP timeout and retries transient failures (timeouts, 429, 5xx) up to 3 attempts per recommendation request.

Authentication

The API uses a combined default policy: an authenticated user via JWT Bearer (JwtBearer scheme) or Azure AD (AzureAd scheme from Microsoft.Identity.Web).

VariableDescription
Jwt__KeySymmetric key for JWT validation (replace insecure defaults in production)
Jwt__IssuersComma-separated valid issuers
Jwt__AudiencesComma-separated valid audiences
AzureAd__Instance, AzureAd__TenantId, AzureAd__ClientId, AzureAd__ClientSecret, …Standard Microsoft.Identity.Web / Azure AD app registration settings (see Authentication patterns in other services)

If Jwt__* or AzureAd__* values are missing, the application falls back to development placeholders in code; production deployments must set real secrets.

Sentry

VariableDescription
Sentry__Dsn, Sentry__Environment, Sentry__SendDefaultPii, …Sentry SDK options (see appsettings.json in the project for the full set used there)

API controllers

Base path: /finops/api. Unless noted, endpoints use [Authorize] and accept JWT Bearer or Azure AD tokens.

Version — /finops/api/Version

MethodRouteAuthDescription
GET/NoneReturns the entry assembly version string

Analysis — /finops/api/Analysis/*

MethodRouteDescription
POST/startBody: JSON string (Azure subscription id). Runs analysis synchronously; returns summary including AnalysisRunId
POST/start-asyncBody: JSON string (subscription id). Returns 202 Accepted with JobId; poll GET jobs/{jobId}. Jobs run sequentially (one active analysis per API instance)
GET/jobs/{jobId}Job status: pending, running, completed, or failed
GET/settingsActive FinOps settings (e.g. Azure Cost Management query type label and description for UI)
POST/cleanupDeletes old analysis runs, keeping the latest run per subscription (and related rows)

Analysis runs — /finops/api/AnalysisRuns/*

MethodRouteDescription
GET/List analysis runs
GET/{id}Run by id
GET/latestLatest run
GET/latest-per-subscriptionLatest run per subscription, with description and budget from Subscriptions
GET/subscription/{subscriptionId}Runs for a subscription
DELETE/{id}Delete a run

Subscription summaries (from runs) — /finops/api/Subscription/*

MethodRouteDescription
GET/Distinct subscriptions derived from stored analysis runs (name from latest run)

Subscriptions (CRUD metadata) — /finops/api/Subscriptions/*

Stores per-subscription description and optional monthly budget (USD). Not the same as Azure subscription display names from analysis runs.

MethodRouteDescription
GET/All Subscription rows
GET/{id}By primary key
GET/by-subscription-id/{subscriptionId}By Azure subscription id
POST/Create or update by subscriptionId (body: subscriptionId, description, budget)
PUT/{id}Update description and/or budget
DELETE/{id}Delete

Create / upsert body (CreateSubscriptionDto):

FieldTypeRequiredDescription
subscriptionIdstringYesAzure subscription GUID
descriptionstringNoDisplay label
budgetdecimalNoMonthly budget USD; null to unset
infraSupportCostdecimalNoInfra support overlay USD/mo; defaults to 400 on create

Resources — /finops/api/Resources/*

MethodRouteDescription
GET/analysis/{analysisRunId}Resources for a run
GET/analysis/{analysisRunId}/type/{resourceType}Filter by resource type
GET/analysis/{analysisRunId}/resourcegroup/{resourceGroup}Filter by resource group
GET/analysis/{analysisRunId}/flagsResources with flags
GET/analysis/{analysisRunId}/summary/typesSummary by type
GET/analysis/{analysisRunId}/summary/resourcegroupsSummary by resource group
GET/analysis/{analysisRunId}/searchQuery: searchTerm — search name, group, or type

Cost details — /finops/api/CostDetails/*

MethodRouteDescription
GET/analysis/{analysisRunId}Cost lines for a run
GET/analysis/{analysisRunId}/type/{costType}Filter by cost type
GET/analysis/{analysisRunId}/top/{count}Top contributors
GET/analysis/{analysisRunId}/summary/resourcegroupsAggregated by resource group
GET/analysis/{analysisRunId}/summary/servicesAggregated by service

Historical costs — /finops/api/HistoricalCosts/*

MethodRouteDescription
GET/analysis/{analysisRunId}Historical cost rows for a run
GET/subscription/{subscriptionId}By subscription
GET/analysis/{analysisRunId}/daily-totalsDaily totals
GET/analysis/{analysisRunId}/by-resourcegroupBy resource group
GET/analysis/{analysisRunId}/by-serviceBy service
GET/analysis/{analysisRunId}/trendTrend payload for charts

Recommendations — /finops/api/Recommendations/*

MethodRouteDescription
GET/analysis/{analysisRunId}Recommendations for a run
GET/analysis/{analysisRunId}/latestLatest recommendations for that run context
GET/{id}/formattedSingle recommendation, formatted (e.g. markdown-friendly)
GET/List recommendations

Dashboard — /finops/api/Dashboard/*

MethodRouteDescription
GET/summaryAggregated dashboard summary
GET/cost-trendsCost trend data
GET/resource-distributionResource distribution
GET/optimization-opportunitiesOptimization-oriented summary

Helm chart reference

The main Helm chart deploys FinOps as finops-api: set finops-api.replicas and finops-api.env in values.yaml, and the container image under image.finops-api.

Configure SQL, JWT, Azure AD, Sentry, and Swagger credentials the same way as other AlertHawk APIs. Add Azure data collection (Azure__*), optional AI (AI__ApiUrl, AI__ApiKey, AI__ApiKeyHeaderName), and optional weekly runs (WeeklyAnalysis__*). Full variable list: Environment variables — finops-api.


Local development

From the repository folder that contains AlertHawk.FinOps.slnx:

bash
dotnet restore AlertHawk.FinOps/AlertHawk.FinOps.slnx
dotnet run --project AlertHawk.FinOps/AlertHawk.FinOps/AlertHawk.FinOps.csproj

With ASPNETCORE_ENVIRONMENT=Development, open Swagger at the URL shown in Properties/launchSettings.json (e.g. https://localhost:5001/swagger).

Run tests:

bash
dotnet test AlertHawk.FinOps/AlertHawk.FinOps.slnx

Further reading

AlertHawk - Self-hosted monitoring solution.