diff --git a/Bicep/getavailability.bicep b/Bicep/getavailability.bicep
index b8375ea..aaf2eaf 100644
--- a/Bicep/getavailability.bicep
+++ b/Bicep/getavailability.bicep
@@ -57,12 +57,20 @@ param dnsZonesSubscriptionId string = ''
@description('Resource group name containing existing Private DNS Zones. Required only when usePrivateEndpoints is true.')
param dnsZonesResourceGroupName string = ''
-@description('Comma-separated list of Azure subscription names or IDs to monitor (written to GETAVAIL_SUBSCRIPTIONS app setting).')
+@description('Comma-separated list of Azure subscription display names to monitor (written to GETAVAIL_SUBSCRIPTIONS app setting).')
param getavailSubscriptions string
@description('Comma-separated resource kinds to monitor. Default: vm,sql,storage,webapp')
param getavailKinds string = 'vm,sql,storage,webapp'
+@description('Use the regional Metrics Batch API for the scheduled availability run.')
+param getavailBatch bool = true
+
+@description('Maximum resources per Metrics Batch API request.')
+@minValue(1)
+@maxValue(50)
+param getavailBatchSize int = 10
+
@description('Log Analytics workspace customer ID used as source for Activity Log and Resource Health queries (SOURCE_WORKSPACE_ID app setting). Leave empty to skip.')
param sourceWorkspaceId string = ''
@@ -87,19 +95,19 @@ var functionAppPublicNetworkAccess = usePrivateEndpoints ? 'Disabled' : 'Enabled
// ── Existing Private DNS Zones ───────────────────────────────────────────────
-resource blobDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (usePrivateEndpoints) {
+resource blobDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = if (usePrivateEndpoints) {
name: 'privatelink.blob.${environment().suffixes.storage}'
scope: resourceGroup(dnsZonesSubscriptionId, dnsZonesResourceGroupName)
}
-resource webAppDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (usePrivateEndpoints) {
+resource webAppDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = if (usePrivateEndpoints) {
name: 'privatelink.azurewebsites.net'
scope: resourceGroup(dnsZonesSubscriptionId, dnsZonesResourceGroupName)
}
// ── Log Analytics Workspace ──────────────────────────────────────────────────
-resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
+resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2026-03-01' = {
name: logAnalyticsWorkspaceName
location: location
properties: {
@@ -113,7 +121,7 @@ resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09
// ── Custom Table: GetAvailResources_CL (per-resource detail) ─────────────────
-resource resourcesTable 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = {
+resource resourcesTable 'Microsoft.OperationalInsights/workspaces/tables@2026-03-01' = {
name: 'GetAvailResources_CL'
parent: logAnalyticsWorkspace
properties: {
@@ -147,7 +155,7 @@ resource resourcesTable 'Microsoft.OperationalInsights/workspaces/tables@2022-10
// ── Custom Table: GetAvailSummary_CL (aggregated summaries) ──────────────────
-resource summaryTable 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = {
+resource summaryTable 'Microsoft.OperationalInsights/workspaces/tables@2026-03-01' = {
name: 'GetAvailSummary_CL'
parent: logAnalyticsWorkspace
properties: {
@@ -176,7 +184,7 @@ resource summaryTable 'Microsoft.OperationalInsights/workspaces/tables@2022-10-0
// ── Data Collection Endpoint ─────────────────────────────────────────────────
-resource dataCollectionEndpoint 'Microsoft.Insights/dataCollectionEndpoints@2023-03-11' = {
+resource dataCollectionEndpoint 'Microsoft.Insights/dataCollectionEndpoints@2024-03-11' = {
name: dataCollectionEndpointName
location: location
properties: {
@@ -189,7 +197,7 @@ resource dataCollectionEndpoint 'Microsoft.Insights/dataCollectionEndpoints@2023
// ── Data Collection Rule (two streams, one per table) ────────────────────────
-resource dataCollectionRule 'Microsoft.Insights/dataCollectionRules@2023-03-11' = {
+resource dataCollectionRule 'Microsoft.Insights/dataCollectionRules@2025-05-11' = {
name: dataCollectionRuleName
location: location
kind: 'Direct'
@@ -272,7 +280,7 @@ resource dataCollectionRule 'Microsoft.Insights/dataCollectionRules@2023-03-11'
// ── Storage Account ──────────────────────────────────────────────────────────
-resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = {
+resource storageAccount 'Microsoft.Storage/storageAccounts@2026-06-01' = {
name: storageAccountName
location: location
sku: {
@@ -313,7 +321,7 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = {
// ── Private Endpoint: Storage Account (blob) ─────────────────────────────────
-resource storageAccountBlobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = if (usePrivateEndpoints) {
+resource storageAccountBlobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2026-03-01' = if (usePrivateEndpoints) {
name: 'pe-blob-${storageAccountName}'
location: location
properties: any({
@@ -372,7 +380,7 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
// ── Flex Consumption Plan ────────────────────────────────────────────────────
-resource flexServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = {
+resource flexServicePlan 'Microsoft.Web/serverfarms@2026-07-15' = {
name: 'asp-${functionAppName}'
location: location
kind: 'functionapp'
@@ -388,7 +396,7 @@ resource flexServicePlan 'Microsoft.Web/serverfarms@2024-11-01' = {
// ── Function App ─────────────────────────────────────────────────────────────
-resource functionApp 'Microsoft.Web/sites@2024-11-01' = {
+resource functionApp 'Microsoft.Web/sites@2026-07-15' = {
name: functionAppName
location: location
kind: 'functionapp,linux'
@@ -424,7 +432,7 @@ resource functionApp 'Microsoft.Web/sites@2024-11-01' = {
}
runtime: {
name: 'powerShell'
- version: '7.4'
+ version: '7.6'
}
}
}
@@ -440,6 +448,8 @@ resource functionApp 'Microsoft.Web/sites@2024-11-01' = {
// Get-Availability configuration — auto-wired from Bicep resources
GETAVAIL_SUBSCRIPTIONS: getavailSubscriptions
GETAVAIL_KINDS: getavailKinds
+ GETAVAIL_BATCH: getavailBatch ? 'true' : 'false'
+ GETAVAIL_BATCH_SIZE: string(getavailBatchSize)
DCE_ENDPOINT: dataCollectionEndpoint.properties.logsIngestion.endpoint
DCR_IMMUTABLE_ID: dataCollectionRule.properties.immutableId
SOURCE_WORKSPACE_ID: sourceWorkspaceId
@@ -456,7 +466,7 @@ resource functionApp 'Microsoft.Web/sites@2024-11-01' = {
// ── Private Endpoint: Function App (sites) ───────────────────────────────────
-resource functionAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-10-01' = if (usePrivateEndpoints) {
+resource functionAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2026-03-01' = if (usePrivateEndpoints) {
name: 'pe-sites-${functionAppName}'
location: location
properties: any({
diff --git a/Bicep/parameters.dev.bicepparam b/Bicep/parameters.dev.bicepparam
index 175f1a7..ea927e6 100644
--- a/Bicep/parameters.dev.bicepparam
+++ b/Bicep/parameters.dev.bicepparam
@@ -37,5 +37,9 @@ param dnsZonesResourceGroupName = 'rg-alz-dns-hub-itn-001'
// Comma-separated subscription names monitored by the Function App.
param getavailSubscriptions = 'Flaz-Connectivity,Flaz-Management,Flaz-Identity,Flaz-Workloads'
+// Use regional batch metric requests, with up to 10 resources per request.
+param getavailBatch = true
+param getavailBatchSize = 10
+
// Existing Log Analytics workspace used as the source for Activity Log and Resource Health KQL queries.
param sourceWorkspaceId = 'f25755bb-9b46-4aac-bfae-6a10c4c18440'
diff --git a/Functions/GetAvail/RunGetAvailability/run.ps1 b/Functions/GetAvail/RunGetAvailability/run.ps1
index 867f466..6799f8e 100644
--- a/Functions/GetAvail/RunGetAvailability/run.ps1
+++ b/Functions/GetAvail/RunGetAvailability/run.ps1
@@ -7,7 +7,7 @@
Can also be started manually from the Azure Portal or via the Functions runtime API.
Reads configuration from App Settings (environment variables):
- GETAVAIL_SUBSCRIPTIONS - Comma-separated list of subscription names or IDs (required)
+ GETAVAIL_SUBSCRIPTIONS - Comma-separated list of subscription display names (required)
GETAVAIL_KINDS - Comma-separated resource kinds (default: vm,sql,storage,webapp)
DCE_ENDPOINT - Data Collection Endpoint URL (optional, enables ingestion)
DCR_IMMUTABLE_ID - Data Collection Rule immutable ID (optional, paired with DCE_ENDPOINT)
diff --git a/Functions/GetAvail/get-availability.ps1 b/Functions/GetAvail/get-availability.ps1
index b2f865f..8026ab6 100644
--- a/Functions/GetAvail/get-availability.ps1
+++ b/Functions/GetAvail/get-availability.ps1
@@ -1,4 +1,4 @@
-#Requires -Version 7.0
+#Requires -Version 7.6
#Requires -Modules Az.Accounts, Az.ResourceGraph
<#
@@ -18,9 +18,8 @@
Contiguous suspect minutes form "suspect gaps" for narration.
Suspect minutes are first checked against Activity Log events:
- - Resource creation/deletion (all kinds): minutes when the resource
- did not exist are excused (before creation, between delete/recreate
- cycles, after final deletion).
+ - Resource creation/deletion: VM timeCreated establishes initial existence;
+ successful delete/write pairs establish later non-existence intervals.
- Virtual Machines: start/deallocate/power off/restart
- Azure SQL Databases: pause/resume
- Web Apps: stop/start/restart
@@ -328,7 +327,8 @@ ${nameFilter}| where not(type =~ 'microsoft.web/sites' and kind contains 'functi
'Other'
)
| project id, name, displayName, type, subscriptionId, resourceGroup, location, resourceKind,
- sqlServerName, databaseName
+ sqlServerName, databaseName,
+ creationTime=iff(type =~ 'microsoft.compute/virtualmachines', tostring(properties.timeCreated), '')
"@
$resources = [System.Collections.Generic.List[object]]::new()
@@ -342,6 +342,21 @@ ${nameFilter}| where not(type =~ 'microsoft.web/sites' and kind contains 'functi
$kind = [string]$row.resourceKind
$subId = [string]$row.subscriptionId
$name = if ($row.displayName) { [string]$row.displayName } else { [string]$row.name }
+ $creationTime = $null
+ if ($kind -eq 'VirtualMachine' -and $row.creationTime) {
+ if ($row.creationTime -is [datetime]) {
+ $creationTime = ([DateTimeOffset][datetime]$row.creationTime).ToUniversalTime()
+ } else {
+ $parsedCreationTime = [DateTimeOffset]::MinValue
+ if ([DateTimeOffset]::TryParse(
+ [string]$row.creationTime,
+ [System.Globalization.CultureInfo]::InvariantCulture,
+ [System.Globalization.DateTimeStyles]::AssumeUniversal,
+ [ref]$parsedCreationTime)) {
+ $creationTime = $parsedCreationTime.ToUniversalTime()
+ }
+ }
+ }
$resources.Add([PSCustomObject]@{
Name = $name
Kind = $kind
@@ -350,6 +365,7 @@ ${nameFilter}| where not(type =~ 'microsoft.web/sites' and kind contains 'functi
SubscriptionName = $SubIdToName.ContainsKey($subId) ? $SubIdToName[$subId] : $subId
ResourceGroupName = [string]$row.resourceGroup
Location = [string]$row.location
+ CreationTime = $creationTime
})
}
$skipToken = [string]::IsNullOrWhiteSpace($response.SkipToken) ? $null : $response.SkipToken
@@ -410,6 +426,7 @@ function Get-LogAnalyticsData {
param(
[string]$WorkspaceId,
[string[]]$SubscriptionIds,
+ [string[]]$ResourceIds,
[DateTimeOffset]$PeriodStart,
[DateTimeOffset]$PeriodEnd,
[string]$ArmToken
@@ -418,11 +435,18 @@ function Get-LogAnalyticsData {
$startIso = $PeriodStart.ToString('O')
$endIso = $PeriodEnd.ToString('O')
$subList = ($SubscriptionIds | ForEach-Object { "'$_'" }) -join ', '
+ $normalizedResourceIds = @($ResourceIds |
+ ForEach-Object { $_.ToLowerInvariant() } |
+ Sort-Object -Unique)
+ $resourceList = ($normalizedResourceIds | ForEach-Object {
+ "'$($_.Replace("'", "''"))'"
+ }) -join ', '
# Single KQL query that fetches both Activity Log operations and Resource
# Health transitions, tagged with a Source column to distinguish them.
$kql = @"
let subs = dynamic([$subList]);
+let resourceIds = dynamic([$resourceList]);
let actOps = dynamic([
'MICROSOFT.COMPUTE/VIRTUALMACHINES/START/ACTION',
'MICROSOFT.COMPUTE/VIRTUALMACHINES/DEALLOCATE/ACTION',
@@ -448,6 +472,7 @@ let actData = AzureActivity
| where OperationNameValue in~ (actOps)
| where ActivityStatusValue == 'Success'
| where TimeGenerated >= datetime($startIso) and TimeGenerated <= datetime($endIso)
+ | where tolower(_ResourceId) in (resourceIds)
| project TimeGenerated, ResourceId=tolower(_ResourceId),
OperationName=OperationNameValue, CorrelationId,
Source='Activity';
@@ -455,6 +480,7 @@ let healthData = AzureActivity
| where SubscriptionId in (subs)
| where CategoryValue == 'ResourceHealth'
| where ResourceProviderValue in ('MICROSOFT.COMPUTE', 'MICROSOFT.SQL', 'MICROSOFT.STORAGE', 'MICROSOFT.WEB')
+ | where tolower(_ResourceId) in (resourceIds)
| project TimeGenerated, ResourceId=tolower(_ResourceId),
Source='Health', OperationName=OperationNameValue,
Properties=todynamic(Properties);
@@ -469,6 +495,8 @@ actData | union healthData
$httpClient.DefaultRequestHeaders.Add('Authorization', "Bearer $ArmToken")
$httpClient.Timeout = [TimeSpan]::FromMinutes(5)
+ $content = $null
+ $response = $null
try {
$content = [System.Net.Http.StringContent]::new(
$body, [System.Text.Encoding]::UTF8, 'application/json')
@@ -477,6 +505,8 @@ actData | union healthData
$jsonStr = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
finally {
+ if ($response) { $response.Dispose() }
+ if ($content) { $content.Dispose() }
$httpClient.Dispose()
}
@@ -1221,6 +1251,7 @@ function Get-BatchAvailabilityMetrics {
[System.Net.Http.HttpMethod]::Post, $uri)
$httpReq.Content = [System.Net.Http.StringContent]::new(
$bodyJson, [System.Text.Encoding]::UTF8, 'application/json')
+ $httpResp = $null
try {
$httpResp = $client.SendAsync($httpReq,
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
@@ -1230,11 +1261,11 @@ function Get-BatchAvailabilityMetrics {
$respStream = $httpResp.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
try { $doc = [System.Text.Json.JsonDocument]::ParseAsync($respStream).GetAwaiter().GetResult() }
finally { $respStream.Dispose() }
- $httpResp.Dispose(); $httpReq.Dispose()
break
}
- $httpResp.Dispose(); $httpReq.Dispose()
if (($sc -eq 429 -or $sc -ge 500) -and $attempt -lt 5) {
+ $httpResp.Dispose()
+ $httpResp = $null
Start-Sleep -Seconds ([Math]::Min(30, [Math]::Pow(2, $attempt)))
continue
}
@@ -1243,7 +1274,6 @@ function Get-BatchAvailabilityMetrics {
break
}
catch {
- try { $httpReq.Dispose() } catch {}
$retryable = $_.ToString() -match 'transport|connection.*closed|reset by peer|timed?\s*out'
if ($retryable -and $attempt -lt 5) {
Start-Sleep -Seconds ([Math]::Min(30, [Math]::Pow(2, $attempt)))
@@ -1253,6 +1283,10 @@ function Get-BatchAvailabilityMetrics {
Write-Warning "Batch metric query failed for [$names]: $_"
break
}
+ finally {
+ if ($httpResp) { $httpResp.Dispose() }
+ $httpReq.Dispose()
+ }
}
$bodyJson = $null
@@ -1394,6 +1428,7 @@ function Get-AvailabilityMetrics {
for ($attempt = 1; $attempt -le 5; $attempt++) {
$httpReq = [System.Net.Http.HttpRequestMessage]::new(
[System.Net.Http.HttpMethod]::Get, $uri)
+ $httpResp = $null
try {
$httpResp = $client.SendAsync($httpReq,
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
@@ -1403,11 +1438,11 @@ function Get-AvailabilityMetrics {
$respStream = $httpResp.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
try { $doc = [System.Text.Json.JsonDocument]::ParseAsync($respStream).GetAwaiter().GetResult() }
finally { $respStream.Dispose() }
- $httpResp.Dispose(); $httpReq.Dispose()
break
}
- $httpResp.Dispose(); $httpReq.Dispose()
if (($sc -eq 429 -or $sc -ge 500) -and $attempt -lt 5) {
+ $httpResp.Dispose()
+ $httpResp = $null
Start-Sleep -Seconds ([Math]::Min(30, [Math]::Pow(2, $attempt)))
continue
}
@@ -1415,7 +1450,6 @@ function Get-AvailabilityMetrics {
break
}
catch {
- try { $httpReq.Dispose() } catch {}
$retryable = $_.ToString() -match 'transport|connection.*closed|reset by peer|timed?\s*out'
if ($retryable -and $attempt -lt 5) {
Start-Sleep -Seconds ([Math]::Min(30, [Math]::Pow(2, $attempt)))
@@ -1424,6 +1458,10 @@ function Get-AvailabilityMetrics {
Write-Warning "Metric query failed for '$($resource.Name)': $_"
break
}
+ finally {
+ if ($httpResp) { $httpResp.Dispose() }
+ $httpReq.Dispose()
+ }
}
# Parse the JSON response via compiled C# MetricProcessor — classifies
@@ -1553,6 +1591,7 @@ function Invoke-SuspectGapInvestigation {
for ($a = 0; $a -lt 6; $a++) {
$httpReq = [System.Net.Http.HttpRequestMessage]::new(
[System.Net.Http.HttpMethod]::Get, $uri)
+ $httpResp = $null
try {
$httpResp = $httpClient.SendAsync($httpReq,
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
@@ -1562,7 +1601,6 @@ function Invoke-SuspectGapInvestigation {
return $httpResp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
if (($sc -eq 429 -or $sc -ge 500) -and $a -lt 5) {
- $httpResp.Dispose()
Start-Sleep -Seconds ([math]::Min(30, [math]::Pow(2, $a)))
continue
}
@@ -1577,6 +1615,7 @@ function Invoke-SuspectGapInvestigation {
}
throw
} finally {
+ if ($httpResp) { $httpResp.Dispose() }
$httpReq.Dispose()
}
}
@@ -1630,8 +1669,8 @@ function Invoke-SuspectGapInvestigation {
# ── Activity Log ──────────────────────────────────────────────
# Checks Activity Log for events that explain metric gaps:
- # a) Resource creation/deletion — excuses non-existence intervals
- # (before first write, between delete→write cycles, after final delete).
+ # a) Resource creation/deletion — VM timeCreated establishes initial
+ # existence; delete→write cycles establish later non-existence.
# b) Kind-specific lifecycle operations (VM start/deallocate/poweroff/restart,
# SQL pause/resume, WebApp stop/start/restart). Kinds with no known
# lifecycle ops (e.g. Storage) produce no matches here.
@@ -1676,6 +1715,15 @@ function Invoke-SuspectGapInvestigation {
$deleteToken = (($using:KindConfig)[$c.Kind].Namespace + '/delete').ToLowerInvariant()
$existenceEvents = [System.Collections.Generic.List[object]]::new()
+ # Unlike the generic ARM write operation, a VM's timeCreated value
+ # is authoritative creation evidence for its current incarnation.
+ if ($c.Kind -eq 'VirtualMachine' -and $null -ne $c.CreationTime) {
+ $existenceEvents.Add([PSCustomObject]@{
+ Timestamp = [DateTimeOffset]$c.CreationTime
+ Type = 'Created'
+ })
+ }
+
if ($resLaData -and $resLaData.ActivityEvents.Count -gt 0) {
# ── Log Analytics path: use pre-fetched events ────
foreach ($laEvt in $resLaData.ActivityEvents) {
@@ -1872,22 +1920,37 @@ function Invoke-SuspectGapInvestigation {
}
# Build non-existence intervals from resource creation/deletion
- # events. Walk write+delete events chronologically as a state machine:
- # Write → resource comes into existence (non-existence ends)
+ # events. Walk events chronologically as a state machine:
+ # Created → authoritative VM creation timestamp
+ # Write → recreation only after a confirmed Delete
# Delete → resource destroyed (non-existence begins)
# Non-existence intervals cover:
- # - periodStart → first Write (resource created mid-period)
+ # - periodStart → VM timeCreated (when no prior incarnation is seen)
# - Delete → next Write (destroy/recreate cycle)
# - last Delete → periodEnd (resource deleted, not recreated)
if ($existenceEvents.Count -gt 0) {
$sortedExEvts = $existenceEvents | Sort-Object Timestamp
$nonExistIntervals = [System.Collections.Generic.List[object]]::new()
- $exState = 'unknown' # unknown | exists | not-exists
+ $createdEvent = $sortedExEvts | Where-Object { $_.Type -eq 'Created' } | Select-Object -First 1
+ $deleteBeforeCreation = $createdEvent -and ($sortedExEvts | Where-Object {
+ $_.Type -eq 'Delete' -and $_.Timestamp -lt $createdEvent.Timestamp
+ } | Select-Object -First 1)
+ $exState = if ($createdEvent -and $createdEvent.Timestamp -le $pStart) {
+ 'exists'
+ } elseif ($createdEvent -and -not $deleteBeforeCreation) {
+ 'not-exists'
+ } else {
+ 'unknown'
+ }
$nonExistStart = $null # timestamp where non-existence began
+ $absenceEstablishedByDelete = $false
+ if ($exState -eq 'not-exists') { $nonExistStart = $pStart }
foreach ($exEvt in $sortedExEvts) {
- if ($exEvt.Type -eq 'Write') {
- if ($exState -ne 'exists') {
+ if ($exEvt.Type -eq 'Created' -or $exEvt.Type -eq 'Write') {
+ $isAuthoritativeCreation = $exEvt.Type -eq 'Created'
+ if ($exState -eq 'not-exists' -and
+ ($isAuthoritativeCreation -or $absenceEstablishedByDelete)) {
# Resource came into existence — close non-existence interval
$nFrom = if ($nonExistStart) { TruncMin $nonExistStart } else { $pStart }
$nTo = (TruncMin $exEvt.Timestamp).AddMinutes(1 + $graceMin)
@@ -1901,12 +1964,14 @@ function Invoke-SuspectGapInvestigation {
}
$exState = 'exists'
$nonExistStart = $null
+ $absenceEstablishedByDelete = $false
}
- # else: already exists, this is an update — ignore
+ # A write while state is exists or unknown is only an update.
}
elseif ($exEvt.Type -eq 'Delete') {
$exState = 'not-exists'
$nonExistStart = $exEvt.Timestamp
+ $absenceEstablishedByDelete = $true
}
}
@@ -1960,12 +2025,15 @@ function Invoke-SuspectGapInvestigation {
}
}
- # Fetch REST API health transitions — sole source without
- # -SourceWorkspaceId; covers last ~30 days with curated authoritative
- # data in hybrid mode.
- $url = "https://management.azure.com$($c.ResourceId)" +
- "/providers/Microsoft.ResourceHealth/availabilityStatuses" +
- "?api-version=2025-05-01"
+ # Fetch REST health transitions when they can overlap the period.
+ # In hybrid mode, Log Analytics is sufficient when the period
+ # ends before the REST API's ~30-day retention window.
+ $useRestHealth = $null -eq $laData -or $pEnd -gt $restCutoff
+ $url = if ($useRestHealth) {
+ "https://management.azure.com$($c.ResourceId)" +
+ "/providers/Microsoft.ResourceHealth/availabilityStatuses" +
+ "?api-version=2025-05-01"
+ } else { $null }
while ($url) {
$json = ArmGet $url $client
@@ -2191,52 +2259,107 @@ function Write-ResultsTable([object[]]$Sorted) {
Write-Host ''
}
-## Prints per-subscription summaries grouped by Kind + Location, plus a cross-
-## subscription overall summary when multiple subscriptions are present.
-function Write-SubscriptionSummaries([object[]]$Sorted) {
+## Calculates summary records once for console output and optional ingestion.
+function Get-AvailabilitySummaries([object[]]$Sorted) {
$eligible = @($Sorted | Where-Object { $_.AvailabilityPct -ne 'N/A' })
+ $summaries = [System.Collections.Generic.List[object]]::new()
+ $subscriptionGroups = @($eligible | Group-Object SubscriptionName | Sort-Object Name)
- foreach ($subGroup in ($eligible | Group-Object SubscriptionName | Sort-Object Name)) {
- Write-Host "--- $($subGroup.Name) Summary ---"
+ foreach ($subGroup in $subscriptionGroups) {
foreach ($g in ($subGroup.Group | Group-Object { "$($_.Kind)|$($_.Location)" } | Sort-Object Name)) {
$items = @($g.Group)
- $n = $items.Count
$a = ($items | Measure-Object AvailableMinutes -Sum).Sum
$e = ($items | Measure-Object EligibleMinutes -Sum).Sum
$pct = if ($e -gt 0) { [math]::Round($a / $e * 100, 5) } else { 0 }
- $kind = Get-ShortKind $items[0].Kind
- $loc = $items[0].Location
- Write-Host " $kind, $loc [$n res]: $pct% ($([math]::Round($a, 2)) / $([math]::Round($e, 2)) eligible min)"
+ $summaries.Add([PSCustomObject]@{
+ SummaryLevel = 'KindLocation'
+ SubscriptionName = $subGroup.Name
+ Kind = $items[0].Kind
+ Location = $items[0].Location
+ ResourceCount = $items.Count
+ AvailableMinutes = $a
+ EligibleMinutes = $e
+ AvailabilityPct = $pct
+ })
}
- $tn = $subGroup.Count
$ta = ($subGroup.Group | Measure-Object AvailableMinutes -Sum).Sum
$te = ($subGroup.Group | Measure-Object EligibleMinutes -Sum).Sum
$tpct = if ($te -gt 0) { [math]::Round($ta / $te * 100, 5) } else { 0 }
- Write-Host " TOTAL [$tn res]: $tpct% ($([math]::Round($ta, 2)) / $([math]::Round($te, 2)) eligible min)"
- Write-Host ''
+ $summaries.Add([PSCustomObject]@{
+ SummaryLevel = 'SubscriptionTotal'
+ SubscriptionName = $subGroup.Name
+ Kind = ''
+ Location = ''
+ ResourceCount = $subGroup.Count
+ AvailableMinutes = $ta
+ EligibleMinutes = $te
+ AvailabilityPct = $tpct
+ })
}
- # Cross-subscription summary
- $subs = @($eligible | ForEach-Object { $_.SubscriptionName } | Select-Object -Unique)
- if ($subs.Count -gt 1 -and $eligible.Count -gt 0) {
- Write-Host ([string]::new([char]0x2550, 62))
- Write-Host ' OVERALL (all subscriptions)'
- Write-Host ([string]::new([char]0x2550, 62))
+ if ($subscriptionGroups.Count -gt 1) {
foreach ($g in ($eligible | Group-Object { "$($_.Kind)|$($_.Location)" } | Sort-Object Name)) {
$items = @($g.Group)
- $n = $items.Count
$a = ($items | Measure-Object AvailableMinutes -Sum).Sum
$e = ($items | Measure-Object EligibleMinutes -Sum).Sum
$pct = if ($e -gt 0) { [math]::Round($a / $e * 100, 5) } else { 0 }
- $kind = Get-ShortKind $items[0].Kind
- $loc = $items[0].Location
- Write-Host " $kind, $loc [$n res]: $pct% ($([math]::Round($a, 2)) / $([math]::Round($e, 2)) eligible min)"
+ $summaries.Add([PSCustomObject]@{
+ SummaryLevel = 'OverallKindLocation'
+ SubscriptionName = ''
+ Kind = $items[0].Kind
+ Location = $items[0].Location
+ ResourceCount = $items.Count
+ AvailableMinutes = $a
+ EligibleMinutes = $e
+ AvailabilityPct = $pct
+ })
}
- $on = $eligible.Count
$oa = ($eligible | Measure-Object AvailableMinutes -Sum).Sum
$oe = ($eligible | Measure-Object EligibleMinutes -Sum).Sum
$opct = if ($oe -gt 0) { [math]::Round($oa / $oe * 100, 5) } else { 0 }
- Write-Host " OVERALL [$on res]: $opct% ($([math]::Round($oa, 2)) / $([math]::Round($oe, 2)) eligible min)"
+ $summaries.Add([PSCustomObject]@{
+ SummaryLevel = 'Overall'
+ SubscriptionName = ''
+ Kind = ''
+ Location = ''
+ ResourceCount = $eligible.Count
+ AvailableMinutes = $oa
+ EligibleMinutes = $oe
+ AvailabilityPct = $opct
+ })
+ }
+
+ $summaries.ToArray()
+}
+
+## Prints precomputed per-subscription and cross-subscription summaries.
+function Write-SubscriptionSummaries([object[]]$Summaries) {
+ $subscriptionTotals = @($Summaries |
+ Where-Object SummaryLevel -eq 'SubscriptionTotal' |
+ Sort-Object SubscriptionName)
+
+ foreach ($total in $subscriptionTotals) {
+ Write-Host "--- $($total.SubscriptionName) Summary ---"
+ foreach ($summary in ($Summaries |
+ Where-Object { $_.SummaryLevel -eq 'KindLocation' -and $_.SubscriptionName -eq $total.SubscriptionName } |
+ Sort-Object Kind, Location)) {
+ Write-Host " $(Get-ShortKind $summary.Kind), $($summary.Location) [$($summary.ResourceCount) res]: $($summary.AvailabilityPct)% ($([math]::Round($summary.AvailableMinutes, 2)) / $([math]::Round($summary.EligibleMinutes, 2)) eligible min)"
+ }
+ Write-Host " TOTAL [$($total.ResourceCount) res]: $($total.AvailabilityPct)% ($([math]::Round($total.AvailableMinutes, 2)) / $([math]::Round($total.EligibleMinutes, 2)) eligible min)"
+ Write-Host ''
+ }
+
+ $overall = $Summaries | Where-Object SummaryLevel -eq 'Overall' | Select-Object -First 1
+ if ($overall) {
+ Write-Host ([string]::new([char]0x2550, 62))
+ Write-Host ' OVERALL (all subscriptions)'
+ Write-Host ([string]::new([char]0x2550, 62))
+ foreach ($summary in ($Summaries |
+ Where-Object SummaryLevel -eq 'OverallKindLocation' |
+ Sort-Object Kind, Location)) {
+ Write-Host " $(Get-ShortKind $summary.Kind), $($summary.Location) [$($summary.ResourceCount) res]: $($summary.AvailabilityPct)% ($([math]::Round($summary.AvailableMinutes, 2)) / $([math]::Round($summary.EligibleMinutes, 2)) eligible min)"
+ }
+ Write-Host " OVERALL [$($overall.ResourceCount) res]: $($overall.AvailabilityPct)% ($([math]::Round($overall.AvailableMinutes, 2)) / $([math]::Round($overall.EligibleMinutes, 2)) eligible min)"
Write-Host ''
}
}
@@ -2283,11 +2406,17 @@ if ($PSBoundParameters.ContainsKey('BatchSize') -and -not $Batch) { $Batch = [sw
Write-Host -NoNewline 'Authenticating... '
# Resolve subscription display names to objects, build ID→Name map
$allAzSubs = @(Get-AzSubscription)
+$azSubsByName = @{}
+foreach ($subscription in $allAzSubs) {
+ if (-not $azSubsByName.ContainsKey($subscription.Name)) {
+ $azSubsByName[$subscription.Name] = [System.Collections.Generic.List[object]]::new()
+ }
+ $azSubsByName[$subscription.Name].Add($subscription)
+}
$resolvedSubs = @(foreach ($name in $Subscriptions) {
- $found = @($allAzSubs | Where-Object Name -eq $name)
- if ($found.Count -eq 0) { throw "Subscription '$name' not found." }
- if ($found.Count -gt 1) { throw "Multiple subscriptions named '$name'." }
- $found[0]
+ if (-not $azSubsByName.ContainsKey($name)) { throw "Subscription '$name' not found." }
+ if ($azSubsByName[$name].Count -gt 1) { throw "Multiple subscriptions named '$name'." }
+ $azSubsByName[$name][0]
})
$subIds = @($resolvedSubs.Id)
$subIdToName = @{}; foreach ($s in $resolvedSubs) { $subIdToName[$s.Id] = $s.Name }
@@ -2397,6 +2526,7 @@ foreach ($res in $resources) {
Kind = $res.Kind
ResourceId = $res.ResourceId
SubscriptionId = $res.SubscriptionId
+ CreationTime = $res.CreationTime
AllGapTicks = @($allTicks)
ZeroTicksArray = @($mr.ZeroAvailTicks)
DegradedTicks = @($mr.DegradedTicks)
@@ -2415,7 +2545,8 @@ if ($SourceWorkspaceId -and $suspectCandidates.Count -gt 0) {
Write-Host -NoNewline 'Fetching Activity Log + Resource Health history from Log Analytics... '
$laTokenStr = Get-PlainToken 'https://api.loganalytics.io'
$logAnalyticsData = Get-LogAnalyticsData -WorkspaceId $SourceWorkspaceId `
- -SubscriptionIds $subIds -PeriodStart $utcStart -PeriodEnd $utcEnd `
+ -SubscriptionIds $subIds -ResourceIds @($suspectCandidates.ResourceId) `
+ -PeriodStart $utcStart -PeriodEnd $utcEnd `
-ArmToken $laTokenStr
$laTokenStr = $null
}
@@ -2596,9 +2727,10 @@ foreach ($res in $resources) {
# Step 8: Output — sort results and print table + per-subscription summaries
$sorted = @($eligByRes.Values |
Sort-Object SubscriptionName, Kind, Name)
+$summaries = @(Get-AvailabilitySummaries $sorted)
Write-ResultsTable $sorted
-Write-SubscriptionSummaries $sorted
+Write-SubscriptionSummaries $summaries
# Step 9: Optional Log Analytics ingestion
if ($sendToLogAnalytics) {
@@ -2639,10 +2771,7 @@ if ($sendToLogAnalytics) {
Send-ToLogAnalytics -Endpoint $DceEndpoint -RuleId $DcrImmutableId `
-StreamName 'Custom-GetAvailResources_CL' -Token $monitorToken -Payload $resourcePayload
- # Build summary payload
- $eligible = @($sorted | Where-Object { $_.AvailabilityPct -ne 'N/A' })
- $summaryPayload = [System.Collections.Generic.List[hashtable]]::new()
-
+ # Project precomputed summaries into the ingestion schema.
$commonSummary = @{
RunId = $runId
Month = $normalizedMonth
@@ -2651,59 +2780,23 @@ if ($sendToLogAnalytics) {
IsMonthToDate = $isMonthToDate
}
- foreach ($subGroup in ($eligible | Group-Object SubscriptionName | Sort-Object Name)) {
- foreach ($g in ($subGroup.Group | Group-Object { "$($_.Kind)|$($_.Location)" } | Sort-Object Name)) {
- $items = @($g.Group)
- $a = ($items | Measure-Object AvailableMinutes -Sum).Sum
- $e = ($items | Measure-Object EligibleMinutes -Sum).Sum
- $pct = if ($e -gt 0) { [math]::Round($a / $e * 100, 5) } else { 0 }
- $summaryPayload.Add(($commonSummary + @{
- SummaryLevel = 'KindLocation'
- SubscriptionName = $subGroup.Name
- Kind = $items[0].Kind
- Location = $items[0].Location
- ResourceCount = $items.Count
- EligibleMinutes = [math]::Round($e, 2)
- AvailableMinutes = [math]::Round($a, 2)
- AvailabilityPct = $pct
- }))
+ $summaryPayload = @(foreach ($summary in ($summaries |
+ Where-Object SummaryLevel -ne 'OverallKindLocation')) {
+ $commonSummary + @{
+ SummaryLevel = $summary.SummaryLevel
+ SubscriptionName = $summary.SubscriptionName
+ Kind = $summary.Kind
+ Location = $summary.Location
+ ResourceCount = $summary.ResourceCount
+ EligibleMinutes = [math]::Round($summary.EligibleMinutes, 2)
+ AvailableMinutes = [math]::Round($summary.AvailableMinutes, 2)
+ AvailabilityPct = $summary.AvailabilityPct
}
- $ta = ($subGroup.Group | Measure-Object AvailableMinutes -Sum).Sum
- $te = ($subGroup.Group | Measure-Object EligibleMinutes -Sum).Sum
- $tpct = if ($te -gt 0) { [math]::Round($ta / $te * 100, 5) } else { 0 }
- $summaryPayload.Add(($commonSummary + @{
- SummaryLevel = 'SubscriptionTotal'
- SubscriptionName = $subGroup.Name
- Kind = ''
- Location = ''
- ResourceCount = $subGroup.Count
- EligibleMinutes = [math]::Round($te, 2)
- AvailableMinutes = [math]::Round($ta, 2)
- AvailabilityPct = $tpct
- }))
- }
-
- # Cross-subscription overall (only if >1 subscription)
- $subs = @($eligible | ForEach-Object { $_.SubscriptionName } | Select-Object -Unique)
- if ($subs.Count -gt 1 -and $eligible.Count -gt 0) {
- $oa = ($eligible | Measure-Object AvailableMinutes -Sum).Sum
- $oe = ($eligible | Measure-Object EligibleMinutes -Sum).Sum
- $opct = if ($oe -gt 0) { [math]::Round($oa / $oe * 100, 5) } else { 0 }
- $summaryPayload.Add(($commonSummary + @{
- SummaryLevel = 'Overall'
- SubscriptionName = ''
- Kind = ''
- Location = ''
- ResourceCount = $eligible.Count
- EligibleMinutes = [math]::Round($oe, 2)
- AvailableMinutes = [math]::Round($oa, 2)
- AvailabilityPct = $opct
- }))
- }
+ })
if ($summaryPayload.Count -gt 0) {
Send-ToLogAnalytics -Endpoint $DceEndpoint -RuleId $DcrImmutableId `
- -StreamName 'Custom-GetAvailSummary_CL' -Token $monitorToken -Payload $summaryPayload.ToArray()
+ -StreamName 'Custom-GetAvailSummary_CL' -Token $monitorToken -Payload $summaryPayload
}
$monitorToken = $null
diff --git a/Old/Get-Availability.sln b/Old/Get-Availability.sln
deleted file mode 100644
index c76ff3a..0000000
--- a/Old/Get-Availability.sln
+++ /dev/null
@@ -1,24 +0,0 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.5.2.0
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GetAvailability", "GetAvailability\GetAvailability.csproj", "{D8E3ED6B-CACB-5DD7-2217-51DEFB71535E}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {D8E3ED6B-CACB-5DD7-2217-51DEFB71535E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D8E3ED6B-CACB-5DD7-2217-51DEFB71535E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D8E3ED6B-CACB-5DD7-2217-51DEFB71535E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D8E3ED6B-CACB-5DD7-2217-51DEFB71535E}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {E926E2CD-7269-4A5C-B040-BA155000B4DB}
- EndGlobalSection
-EndGlobal
diff --git a/Old/GetAvailability/GetAvailability.csproj b/Old/GetAvailability/GetAvailability.csproj
deleted file mode 100644
index 83cbdaf..0000000
--- a/Old/GetAvailability/GetAvailability.csproj
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- Exe
- net10.0
- enable
- enable
- true
- true
- true
- true
- false
- 0.0.0
-
-
-
-
-
-
-
-
-
-
diff --git a/Old/GetAvailability/Models/EligibilityResult.cs b/Old/GetAvailability/Models/EligibilityResult.cs
deleted file mode 100644
index 27c916e..0000000
--- a/Old/GetAvailability/Models/EligibilityResult.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-namespace GetAvailability.Models;
-
-/// Per-resource availability result. Eligible minutes start at the observation-window
-/// total and are reduced by suspect minutes that are later classified as lifecycle activity,
-/// metric issues, customer-initiated transitions, or zero-transaction storage minutes.
-public sealed class EligibilityResult
-{
- public required string Name { get; init; }
- public required string Kind { get; init; }
- public required string ResourceId { get; init; }
- public required string ResourceGroupName { get; init; }
- public required string Location { get; init; }
- public required string SubscriptionName { get; init; }
- public int EligibleMinutes { get; set; }
-
- // Set after metric computation in the assembly step
- public double AvailableMinutes { get; set; }
-
- /// Total suspect minutes from the metric scan (null + 0% + positive degraded datapoints).
- public int SuspectMinutes { get; set; }
-
- /// Suspect minutes confirmed as platform issues by Resource Health fault intervals.
- public int ConfirmedDowntimeMinutes { get; set; }
-
- /// Suspect minutes excused from eligibility: lifecycle activity, customer-initiated,
- /// Health Unknown explanations, and metric-issue nulls.
- public int ExcusedMinutes { get; set; }
-
- /// Suspect minutes that remain unexplained after Activity Log, Resource Health,
- /// and fallback classification have been applied.
- public int UnexplainedSuspectMinutes { get; set; }
-
- /// Availability percentage (5 decimal places), or "N/A" if fully excluded.
- public string AvailabilityPct => EligibleMinutes > 0
- ? Math.Round(AvailableMinutes / EligibleMinutes * 100, 5).ToString("F5")
- : "N/A";
-}
diff --git a/Old/GetAvailability/Models/MetricScalars.cs b/Old/GetAvailability/Models/MetricScalars.cs
deleted file mode 100644
index 88e2b9a..0000000
--- a/Old/GetAvailability/Models/MetricScalars.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-namespace GetAvailability.Models;
-
-/// Compact result from per-resource metric computation.
-/// Sum of metric values above 0% (each 0.0–1.0). Becomes AvailableMinutes after customer-excused degraded contributions are removed.
-/// Count of null and 0%-valued suspect minutes.
-/// Storage only: minutes with zero transactions — subtracted from eligible.
-/// True when the metric API returned no usable datapoints at all for the window. These resources are excluded from availability calculations.
-/// UTC ticks of null-valued suspect minutes.
-/// UTC ticks of 0%-valued suspect minutes.
-/// Minutes where a metric datapoint was present and strictly between 0% and 100%.
-/// Normalized availability values for positive degraded datapoints, used to remove customer-excused degraded contributions from eligibility and available minutes.
-public readonly record struct MetricScalars(
- double AvailableSum,
- int GapMinutes,
- int ZeroTxMin,
- bool ExcludeFromAvailability = false,
- long[]? GapTicks = null,
- long[]? ZeroAvailTicks = null,
- int DegradedMinutes = 0,
- MetricValueSample[]? DegradedSamples = null)
-{
- public int SuspectMinutes => GapMinutes + DegradedMinutes;
-}
-
-/// Minute-level normalized availability value for a degraded datapoint.
-public readonly record struct MetricValueSample(long Tick, double Value);
diff --git a/Old/GetAvailability/Models/TrackedResource.cs b/Old/GetAvailability/Models/TrackedResource.cs
deleted file mode 100644
index 34956fe..0000000
--- a/Old/GetAvailability/Models/TrackedResource.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace GetAvailability.Models;
-
-/// An Azure resource discovered via Resource Graph inventory query.
-public sealed record TrackedResource
-{
- public required string Name { get; init; }
- public required string Kind { get; init; } // VirtualMachine | AzureSqlDatabase | StorageAccount | WebApp
- public required string ResourceId { get; init; }
- public required string SubscriptionId { get; init; }
- public required string SubscriptionName { get; init; }
- public required string ResourceGroupName { get; init; }
- public required string Location { get; init; }
-}
diff --git a/Old/GetAvailability/Output/SummaryWriter.cs b/Old/GetAvailability/Output/SummaryWriter.cs
deleted file mode 100644
index 2d80a7b..0000000
--- a/Old/GetAvailability/Output/SummaryWriter.cs
+++ /dev/null
@@ -1,114 +0,0 @@
-using System.Globalization;
-using GetAvailability.Models;
-
-namespace GetAvailability.Output;
-
-/// Writes the per-resource table and per-subscription/overall availability summaries.
-public static class SummaryWriter
-{
- /// Prints a fixed-width table with one row per resource showing availability metrics.
- public static void WriteResults(EligibilityResult[] sorted)
- {
- // Table header — compact column widths to fit standard terminals.
- // Columns: Subscription(24) Name(30) Kind(7) Location(12) Suspect(7) Faults(6)
- // Excused(7) Unresolved(10) AvailMin(10) EligMin(8) Avail%(10)
- const string fmt = "{0,-24} {1,-30} {2,-7} {3,-12} {4,7} {5,6} {6,7} {7,10} {8,10} {9,8} {10,10}";
- Console.WriteLine();
- Console.WriteLine(string.Format(fmt,
- "Subscription", "Name", "Kind", "Location",
- "Suspect", "Faults", "Excused", "Unresolved",
- "AvailMin", "EligMin", "Avail%"));
- Console.WriteLine(new string('─', 141));
-
- foreach (var r in sorted)
- {
- Console.WriteLine(string.Format(fmt,
- Truncate(r.SubscriptionName, 24),
- Truncate(r.Name, 30),
- ShortKind(r.Kind),
- r.Location,
- r.SuspectMinutes > 0 ? $"{r.SuspectMinutes}" : "",
- r.ConfirmedDowntimeMinutes > 0 ? $"{r.ConfirmedDowntimeMinutes}" : "",
- r.ExcusedMinutes > 0 ? $"{r.ExcusedMinutes}" : "",
- r.UnexplainedSuspectMinutes > 0 ? $"{r.UnexplainedSuspectMinutes}" : "",
- Math.Round(r.AvailableMinutes, 2),
- r.EligibleMinutes,
- r.AvailabilityPct));
- }
- Console.WriteLine();
- }
-
- ///
- /// Prints per-subscription summaries (grouped by Kind + Location with resource count and
- /// aggregate availability %) followed by a cross-subscription overall summary when
- /// multiple subscriptions are present.
- ///
- public static void WriteSubscriptionSummaries(EligibilityResult[] sorted)
- {
- var eligible = sorted.Where(r => r.AvailabilityPct != "N/A").ToArray();
-
- foreach (var subGroup in eligible.GroupBy(r => r.SubscriptionName).OrderBy(g => g.Key))
- {
- Console.WriteLine($"--- {subGroup.Key} Summary ---");
- foreach (var g in subGroup.GroupBy(r => (r.Kind, r.Location)).OrderBy(g => g.Key))
- {
- int n = g.Count();
- double a = g.Sum(r => r.AvailableMinutes);
- double e = g.Sum(r => r.EligibleMinutes);
- double pct = e > 0 ? Math.Round(a / e * 100, 5) : 0;
- Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
- " {0}, {1} [{2} res]: {3}% ({4} / {5} eligible min)",
- ShortKind(g.Key.Kind), g.Key.Location, n, pct, Math.Round(a, 2), Math.Round(e, 2)));
- }
- int tn = subGroup.Count();
- double ta = subGroup.Sum(r => r.AvailableMinutes);
- double te = subGroup.Sum(r => r.EligibleMinutes);
- double tpct = te > 0 ? Math.Round(ta / te * 100, 5) : 0;
- Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
- " TOTAL [{0} res]: {1}% ({2} / {3} eligible min)",
- tn, tpct, Math.Round(ta, 2), Math.Round(te, 2)));
- Console.WriteLine();
- }
-
- // Cross-subscription summary
- var subs = eligible.Select(r => r.SubscriptionName).Distinct().ToArray();
- if (subs.Length > 1 && eligible.Length > 0)
- {
- Console.WriteLine("══════════════════════════════════════════════════════════════");
- Console.WriteLine(" OVERALL (all subscriptions)");
- Console.WriteLine("══════════════════════════════════════════════════════════════");
- foreach (var g in eligible.GroupBy(r => (r.Kind, r.Location)).OrderBy(g => g.Key))
- {
- int n = g.Count();
- double a = g.Sum(r => r.AvailableMinutes);
- double e = g.Sum(r => r.EligibleMinutes);
- double pct = e > 0 ? Math.Round(a / e * 100, 5) : 0;
- Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
- " {0}, {1} [{2} res]: {3}% ({4} / {5} eligible min)",
- ShortKind(g.Key.Kind), g.Key.Location, n, pct, Math.Round(a, 2), Math.Round(e, 2)));
- }
- int on = eligible.Length;
- double oa = eligible.Sum(r => r.AvailableMinutes);
- double oe = eligible.Sum(r => r.EligibleMinutes);
- double opct = oe > 0 ? Math.Round(oa / oe * 100, 5) : 0;
- Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
- " OVERALL [{0} res]: {1}% ({2} / {3} eligible min)",
- on, opct, Math.Round(oa, 2), Math.Round(oe, 2)));
- Console.WriteLine();
- }
- }
-
- /// Truncates a string to max length with "..." suffix, using Span to avoid allocation.
- private static string Truncate(string s, int max) =>
- s.Length <= max ? s : string.Concat(s.AsSpan(0, max - 3), "...");
-
- /// Abbreviates resource kind for compact table display.
- private static string ShortKind(string kind) => kind switch
- {
- "VirtualMachine" => "VM",
- "AzureSqlDatabase" => "SQL",
- "StorageAccount" => "Storage",
- "WebApp" => "Web",
- _ => kind
- };
-}
diff --git a/Old/GetAvailability/Program.cs b/Old/GetAvailability/Program.cs
deleted file mode 100644
index b8b816c..0000000
--- a/Old/GetAvailability/Program.cs
+++ /dev/null
@@ -1,549 +0,0 @@
-// Get-Availability — month-scoped Azure resource availability reporter (C# / Native AOT)
-//
-// Pipeline: resolve subscriptions → inventory (Resource Graph)
-// → fetch metrics (Azure Monitor, parallel) → investigate suspect gaps
-// (Activity Log first, then Resource Health where retained)
-// → assemble results → print table + summaries
-//
-// A suspect minute is any metric datapoint that is null or below 100%.
-// Contiguous suspect minutes form "suspect gaps" for narration purposes.
-// Suspect minutes are first checked against Activity Log events:
-// a) Resource creation/deletion — excuses non-existence intervals
-// (before first write, between delete→write cycles, after final delete).
-// b) Kind-specific lifecycle operations:
-// – Virtual Machines: start/deallocate/power off/restart
-// – Azure SQL Databases: pause/resume
-// – Web Apps: stop/start/restart (paired stop→start spanning intervals)
-// Kinds with no known lifecycle ops (e.g. Storage) produce no matches here.
-// Resource Health is then applied for the overlap with its current retention window:
-// – platform fault confirmed (Unavailable/Degraded) → counts as downtime
-// – Unknown / customer-initiated → valid explanation for null and 0% suspect minutes
-// Remaining null minutes become metric issues (excluded from eligibility), while
-// remaining 0% minutes are trusted as downtime. Remaining positive degraded datapoints
-// stay as degraded availability.
-//
-// When --workspace is specified, Activity Log lifecycle events are fetched from a
-// Log Analytics workspace via a single bulk KQL query instead of per-resource REST
-// API calls. Resource Health uses a hybrid approach: Log Analytics transitions cover
-// the period beyond the REST API's ~30-day retention, while REST API transitions
-// (curated, with retroactively corrected causes) are authoritative for the last
-// ~30 days. The two sources are merged to produce complete coverage.
-//
-// The observation window is a UTC calendar month selected via --month YYYYMM.
-// Current month runs month-to-date; past months run full-month.
-
-using System.Collections.Concurrent;
-using System.Diagnostics;
-using System.Globalization;
-using Azure.Identity;
-using Azure.Monitor.Query;
-using Azure.ResourceManager;
-using GetAvailability.Models;
-using GetAvailability.Output;
-using GetAvailability.Services;
-
-// ── CLI argument parsing ─────────────────────────────────────────────────────
-// Supports: --subscriptions (required), --month (required), --kinds,
-// --resource, --parallelism, --activity-grace-minutes,
-// --batch, --batch-size, --workspace, --help
-
-string[] subscriptionNames = [];
-string[] kinds = ["vm", "sql", "storage", "webapp"];
-string? resourceName = null;
-string? monthParameter = null;
-int parallelism = Math.Clamp(Environment.ProcessorCount, 4, 16);
-int activityGraceMinutes = 10;
-bool useBatch = false;
-int batchSize = 10;
-string? workspace = null;
-
-for (int i = 0; i < args.Length; i++)
-{
- switch (args[i])
- {
- case "--subscriptions" or "-s":
- subscriptionNames = ReadRequiredValues(args, ref i, "--subscriptions");
- break;
- case "--kinds" or "-k":
- kinds = ReadRequiredValues(args, ref i, "--kinds");
- break;
- case "--resource" or "-r":
- resourceName = ReadRequiredValue(args, ref i, "--resource");
- break;
- case "--month" or "-m":
- monthParameter = ReadRequiredValue(args, ref i, "--month");
- break;
- case "--parallelism" or "-p":
- parallelism = ParseIntOption(ReadRequiredValue(args, ref i, "--parallelism"), "--parallelism", minValue: 1);
- break;
- case "--activity-grace-minutes" or "-g":
- activityGraceMinutes = ParseIntOption(ReadRequiredValue(args, ref i, "--activity-grace-minutes"), "--activity-grace-minutes", minValue: 0);
- break;
- case "--batch" or "-b":
- useBatch = true;
- break;
- case "--batch-size":
- batchSize = ParseIntOption(ReadRequiredValue(args, ref i, "--batch-size"), "--batch-size", minValue: 1);
- if (batchSize > 50) throw new ArgumentException("--batch-size must be <= 50.");
- useBatch = true;
- break;
- case "--workspace" or "-w":
- workspace = ReadRequiredValue(args, ref i, "--workspace");
- if (!Guid.TryParse(workspace, out _))
- throw new ArgumentException("--workspace must be a valid GUID.");
- break;
- case "--version" or "-v":
- Console.WriteLine($"GetAvailability {typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"}");
- return 0;
- case "--help" or "-h":
- Console.WriteLine("Usage: GetAvailability --subscriptions [name2 ...] [options]");
- Console.WriteLine(" --subscriptions, -s Required. One or more Azure subscription display names.");
- Console.WriteLine(" --month, -m Required. Observation month in UTC, format YYYYMM.");
- Console.WriteLine(" --kinds, -k Resource kinds: vm, sql, storage, webapp (default: all).");
- Console.WriteLine(" --resource, -r Filter to a single resource name.");
- Console.WriteLine(" --parallelism, -p Max concurrent metric calls (default: auto).");
- Console.WriteLine(" --activity-grace-minutes, -g Post-operation grace window for supported Activity Log lifecycle events (default: 10).");
- Console.WriteLine(" --batch, -b Use the regional Metrics Batch API instead of per-resource calls.");
- Console.WriteLine(" --batch-size Max resources per batch call (default: 10, max: 50). Implies --batch.");
- Console.WriteLine(" --workspace, -w Log Analytics workspace ID (GUID). Fetches Activity Log via bulk KQL;");
- Console.WriteLine(" Resource Health uses hybrid approach (KQL for older + REST for last ~30 days).");
- Console.WriteLine(" --version, -v Print version and exit.");
- return 0;
- }
-}
-
-if (subscriptionNames.Length == 0)
-{
- Console.Error.WriteLine("Error: --subscriptions is required. Use --help for usage.");
- return 1;
-}
-
-if (string.IsNullOrWhiteSpace(monthParameter))
-{
- Console.Error.WriteLine("Error: --month is required. Use --help for usage.");
- return 1;
-}
-
-try
-{
- await RunAsync(subscriptionNames, kinds, resourceName, monthParameter, parallelism, activityGraceMinutes, useBatch, batchSize, workspace);
- return 0;
-}
-catch (Exception ex) when (ex is AuthenticationFailedException or CredentialUnavailableException)
-{
- Console.Error.WriteLine(ex.Message);
- return 1;
-}
-catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
-{
- Console.Error.WriteLine($"Error: {ex.Message}");
- return 1;
-}
-
-// ── Main orchestration ───────────────────────────────────────────────────────
-
-static async Task RunAsync(
- string[] subscriptionNames,
- string[] kinds,
- string? resourceName,
- string monthParameter,
- int parallelism,
- int activityGraceMinutes,
- bool useBatch,
- int batchSize,
- string? workspace)
-{
- var sw = Stopwatch.StartNew();
-
- var (utcStart, utcEnd, normalizedMonth, isMonthToDate) = ResolveObservationWindow(monthParameter);
- int totalMinutes = (int)(utcEnd - utcStart).TotalMinutes;
- bool useLogAnalytics = workspace is not null;
- var healthCoverageStart = ResourceHealthService.GetHealthCoverageStart(utcStart, useLogAnalytics);
- int healthCoveredMinutes = healthCoverageStart < utcEnd
- ? (int)(utcEnd - healthCoverageStart).TotalMinutes
- : 0;
-
- string periodLabel = isMonthToDate ? $"month {normalizedMonth} (month-to-date)" : $"month {normalizedMonth}";
- Console.WriteLine($"Period: {periodLabel} ({utcStart:u} -> {utcEnd:u}, {totalMinutes} min)");
-
- if (useLogAnalytics)
- {
- Console.WriteLine($"Log Analytics workspace: {workspace} (Activity Log via KQL, Resource Health via KQL + REST API hybrid)");
- }
- else if (healthCoverageStart > utcStart && healthCoveredMinutes > 0)
- {
- Console.WriteLine(
- $"WARNING: Resource Health history covers only part of this period ({healthCoverageStart:u} -> {utcEnd:u}, {healthCoveredMinutes} of {totalMinutes} min). Earlier minutes will use Activity Log and metric fallback rules.");
- }
- else if (healthCoveredMinutes == 0)
- {
- Console.WriteLine(
- "WARNING: Resource Health history does not cover this period. All suspect minutes will use Activity Log and metric fallback rules.");
- }
-
- // Authenticate using DefaultAzureCredential (az login, managed identity, etc.)
- Console.Write("Authenticating... ");
- var credential = new DefaultAzureCredential();
- var armClient = new ArmClient(credential);
- var metricsClient = new MetricsQueryClient(credential);
-
- // Step 1: Resolve subscription display names → subscription IDs
- var resolved = await SubscriptionResolver.ResolveAsync(armClient, subscriptionNames);
- Console.WriteLine("OK");
- var subIds = resolved.Select(r => r.Id).ToArray();
- var subIdToName = resolved.ToDictionary(r => r.Id, r => r.Name);
- Console.WriteLine($"Processing {resolved.Count} subscription(s): {string.Join(", ", resolved.Select(r => r.Name))}");
- Console.WriteLine($"Kinds: {string.Join(", ", kinds)}");
-
- // Step 2: Query Resource Graph for all VMs, SQL DBs, Storage Accounts, and Web Apps
- Console.Write("Querying resource inventory... ");
- var resources = await ResourceInventoryService.QueryAsync(armClient, subIds, subIdToName, kinds, resourceName);
- Console.WriteLine($"Found {resources.Count} resource(s) across {resolved.Count} subscription(s).");
-
- if (resources.Count == 0) { Console.WriteLine("No resources found."); return; }
-
- // Step 3: Build initial eligibility (all minutes eligible — suspect-minute investigation adjusts it later)
- var eligByRes = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var res in resources)
- {
- eligByRes[res.ResourceId.ToLowerInvariant()] = new EligibilityResult
- {
- Name = res.Name,
- Kind = res.Kind,
- ResourceId = res.ResourceId,
- ResourceGroupName = res.ResourceGroupName,
- Location = res.Location,
- SubscriptionName = res.SubscriptionName,
- EligibleMinutes = totalMinutes,
- };
- }
-
- // Step 4: Fetch Azure Monitor metrics (batch or per-resource)
- ConcurrentDictionary metricResults;
- if (useBatch)
- {
- Console.WriteLine($"Using Batch API (batch size: {batchSize}).");
- metricResults = await BatchMetricsService.QueryAsync(credential, resources, utcStart, utcEnd, parallelism, batchSize);
- }
- else
- {
- metricResults = await MetricsService.QueryAsync(metricsClient, resources, utcStart, utcEnd, parallelism);
- }
-
- // Step 5: For resources with suspect metric minutes, investigate null/0% suspect minutes and
- // positive degraded datapoints. Activity Log is checked first for supported lifecycle actions,
- // then Resource Health is applied where still retained.
- // Build the candidate list: each entry carries the resource, its combined null+zero tick array,
- // a HashSet of zero-valued ticks (for O(1) null-vs-zero discrimination during classification),
- // and any degraded samples (0% < value < 100%).
- var suspectCandidates = new List<(TrackedResource Res, long[] AllGapTicks, HashSet? ZeroTicks, MetricValueSample[]? DegradedSamples)>();
- foreach (var res in resources)
- {
- var key = res.ResourceId.ToLowerInvariant();
- if (metricResults.TryGetValue(key, out var mr) && mr.SuspectMinutes > 0)
- {
- var allTicks = new List();
- if (mr.GapTicks is not null) allTicks.AddRange(mr.GapTicks);
- if (mr.ZeroAvailTicks is not null) allTicks.AddRange(mr.ZeroAvailTicks);
- if (allTicks.Count > 0 || (mr.DegradedSamples?.Length ?? 0) > 0)
- {
- var zeroSet = mr.ZeroAvailTicks is not null ? new HashSet(mr.ZeroAvailTicks) : null;
- suspectCandidates.Add((res, allTicks.ToArray(), zeroSet, mr.DegradedSamples));
- }
- }
- }
-
- ConcurrentDictionary? suspectResults = null;
- if (suspectCandidates.Count > 0)
- {
- // When -Workspace is specified, bulk-fetch Activity Log + Resource Health data
- // from Log Analytics before starting per-resource investigation.
- Dictionary? laData = null;
- if (workspace is not null)
- {
- Console.Write("Fetching Activity Log + Resource Health history from Log Analytics... ");
- laData = await LogAnalyticsService.FetchAsync(
- credential, workspace, subIds, utcStart, utcEnd);
- }
-
- suspectResults = await ResourceHealthService.InvestigateSuspectGapsAsync(
- credential,
- suspectCandidates,
- utcStart,
- utcEnd,
- parallelism,
- activityGraceMinutes,
- laData);
- }
-
- // Step 6: Assemble final results — apply suspect-gap investigation outcomes and zero-tx storage exclusions.
- // For each resource, print per-resource classification narration (suspect count, Activity Log matches,
- // Health History outcomes, eligibility adjustments) and compute the final availability figures:
- // SuspectMinutes, ConfirmedDowntimeMinutes, ExcusedMinutes, UnexplainedSuspectMinutes,
- // AvailableMinutes, EligibleMinutes, AvailabilityPct.
- foreach (var res in resources)
- {
- var key = res.ResourceId.ToLowerInvariant();
- var elig = eligByRes[key];
-
- if (metricResults.TryGetValue(key, out var mr))
- {
- if (mr.ExcludeFromAvailability)
- {
- elig.EligibleMinutes = 0;
- elig.AvailableMinutes = 0;
- elig.SuspectMinutes = 0;
- elig.ConfirmedDowntimeMinutes = 0;
- elig.ExcusedMinutes = 0;
- elig.UnexplainedSuspectMinutes = 0;
- Console.WriteLine($" [{res.Name}] excluded from availability (no numeric availability datapoints in period)");
- continue;
- }
-
- // Apply suspect-gap investigation results: walk through each classification bucket
- // and subtract excused/explained minutes from eligibility, accumulate counters.
- int activityLogExcludedGapMinutes = 0;
- int healthExplainedGapMinutes = 0;
- int metricIssueNullMinutes = 0;
- int excludedDegradedMinutes = 0;
- if (suspectResults is not null && suspectResults.TryGetValue(key, out var gc))
- {
- int totalSuspectMinutes = mr.SuspectMinutes;
- int suspectGapCount = CountSuspectGaps(mr);
-
- if (totalSuspectMinutes > 0)
- {
- Console.WriteLine(
- $" [{res.Name}] metric scan found {totalSuspectMinutes} suspect min across {suspectGapCount} suspect gaps (null or <100% availability values)");
-
- int activityExplainedSuspectMinutes = gc.ActivityLogExcludedGapMinutes + gc.ActivityLogDegradedMinutes;
- int remainingAfterActivity = totalSuspectMinutes - activityExplainedSuspectMinutes;
- if (remainingAfterActivity > 0)
- {
- Console.WriteLine(
- $" [{res.Name}] checked against Activity Log: {activityExplainedSuspectMinutes} suspect min explained by admin lifecycle events, {remainingAfterActivity} remain for Health History / fallback rules");
- }
- else
- {
- Console.WriteLine(
- $" [{res.Name}] checked against Activity Log: {activityExplainedSuspectMinutes} suspect min explained by admin lifecycle events");
- }
-
- if (remainingAfterActivity > 0 && gc.HealthHistoryApplied)
- {
- int healthExplainedSuspectMinutes = gc.HealthExplainedGapMinutes + (gc.CustomerExcusedDegradedMinutes - gc.ActivityLogDegradedMinutes);
- Console.WriteLine(
- $" [{res.Name}] checked remaining suspect min against Health History: {gc.PlatformFaultGapMinutes} gap min confirmed as platform issues, {healthExplainedSuspectMinutes} suspect min explained as Unknown / customer-initiated");
- }
- else if (remainingAfterActivity > 0)
- {
- Console.WriteLine(
- $" [{res.Name}] Health History skipped for remaining suspect min (outside current retention window); applying fallback rules directly");
- }
- }
-
- if (gc.ActivityLogExcludedGapMinutes > 0)
- {
- elig.EligibleMinutes = Math.Max(0, elig.EligibleMinutes - gc.ActivityLogExcludedGapMinutes);
- activityLogExcludedGapMinutes = gc.ActivityLogExcludedGapMinutes;
- }
- if (gc.HealthExplainedGapMinutes > 0)
- {
- elig.EligibleMinutes = Math.Max(0, elig.EligibleMinutes - gc.HealthExplainedGapMinutes);
- healthExplainedGapMinutes = gc.HealthExplainedGapMinutes;
- }
- if (gc.MetricIssueNullMinutes > 0)
- {
- elig.EligibleMinutes = Math.Max(0, elig.EligibleMinutes - gc.MetricIssueNullMinutes);
- metricIssueNullMinutes = gc.MetricIssueNullMinutes;
- Console.WriteLine($" [{res.Name}] {gc.MetricIssueNullMinutes} unresolved null suspect min treated as metric issues and excluded from eligibility");
- }
- if (gc.CustomerExcusedDegradedMinutes > 0)
- {
- elig.EligibleMinutes = Math.Max(0, elig.EligibleMinutes - gc.CustomerExcusedDegradedMinutes);
- excludedDegradedMinutes = gc.CustomerExcusedDegradedMinutes;
- int healthExcusedDegradedMinutes = gc.CustomerExcusedDegradedMinutes - gc.ActivityLogDegradedMinutes;
- var degradedReasons = new List();
- if (gc.ActivityLogDegradedMinutes > 0)
- degradedReasons.Add($"{gc.ActivityLogDegradedMinutes} matched in Activity Log");
- if (healthExcusedDegradedMinutes > 0)
- degradedReasons.Add($"{healthExcusedDegradedMinutes} matched customer-initiated Health History");
- Console.WriteLine($" [{res.Name}] {gc.CustomerExcusedDegradedMinutes} degraded suspect min excluded from eligibility ({string.Join(", ", degradedReasons)})");
- }
- if (gc.PlatformFaultGapMinutes > 0)
- {
- Console.WriteLine($" [{res.Name}] {gc.PlatformFaultGapMinutes} gap min confirmed as downtime (Health History platform issue)");
- }
- if (gc.HealthConfirmedDegradedMinutes > 0)
- {
- Console.WriteLine($" [{res.Name}] {gc.HealthConfirmedDegradedMinutes} degraded suspect min confirmed as downtime (Health History platform issue)");
- }
- if (gc.UnresolvedZeroDowntimeMinutes > 0)
- {
- Console.WriteLine($" [{res.Name}] {gc.UnresolvedZeroDowntimeMinutes} unresolved 0% suspect min trusted as downtime");
- }
- }
-
- // Compute downtime and unresolved counters from investigation results.
- // ConfirmedDowntime = platform faults (gap + degraded); Unresolved = zero-downtime + remaining degraded.
- int confirmedHealthDowntimeMinutes = 0;
- int unexplainedPositiveDegradedMinutes = mr.DegradedMinutes;
- int unexplainedSuspectMinutes = mr.SuspectMinutes;
-
- if (suspectResults is not null && suspectResults.TryGetValue(key, out var cls))
- {
- confirmedHealthDowntimeMinutes = cls.PlatformFaultGapMinutes + cls.HealthConfirmedDegradedMinutes;
- unexplainedPositiveDegradedMinutes = Math.Max(0, mr.DegradedMinutes - cls.CustomerExcusedDegradedMinutes - cls.HealthConfirmedDegradedMinutes);
- unexplainedSuspectMinutes = cls.UnresolvedZeroDowntimeMinutes + unexplainedPositiveDegradedMinutes;
- }
-
- // For Storage Accounts, zero-transaction minutes have no availability signal and are
- // excluded from eligibility. They count as suspect (no data) and excused (nothing to measure).
- int zeroTxExcludedMinutes = 0;
- if (mr.ZeroTxMin > 0 && res.Kind == "StorageAccount")
- {
- elig.EligibleMinutes = Math.Max(0, elig.EligibleMinutes - mr.ZeroTxMin);
- zeroTxExcludedMinutes = mr.ZeroTxMin;
- }
-
- elig.SuspectMinutes = mr.SuspectMinutes + zeroTxExcludedMinutes;
- elig.ConfirmedDowntimeMinutes = confirmedHealthDowntimeMinutes;
- elig.ExcusedMinutes = activityLogExcludedGapMinutes + healthExplainedGapMinutes + metricIssueNullMinutes + excludedDegradedMinutes + zeroTxExcludedMinutes;
- elig.UnexplainedSuspectMinutes = unexplainedSuspectMinutes;
-
- if (activityLogExcludedGapMinutes > 0 || healthExplainedGapMinutes > 0 || metricIssueNullMinutes > 0 || excludedDegradedMinutes > 0 || zeroTxExcludedMinutes > 0)
- {
- var eligibilityAdjustments = new List();
- if (activityLogExcludedGapMinutes > 0)
- eligibilityAdjustments.Add($"{activityLogExcludedGapMinutes} gap min excluded by Activity Log");
- if (healthExplainedGapMinutes > 0)
- eligibilityAdjustments.Add($"{healthExplainedGapMinutes} gap min excluded by Health History");
- if (metricIssueNullMinutes > 0)
- eligibilityAdjustments.Add($"{metricIssueNullMinutes} null suspect min treated as metric issues");
- if (excludedDegradedMinutes > 0)
- eligibilityAdjustments.Add($"{excludedDegradedMinutes} customer-excused degraded min");
- if (zeroTxExcludedMinutes > 0)
- eligibilityAdjustments.Add($"{zeroTxExcludedMinutes} zero-tx min");
-
- Console.WriteLine(
- $" [{res.Name}] eligible min = {totalMinutes} - {string.Join(" - ", eligibilityAdjustments)} = {elig.EligibleMinutes}");
- }
-
- double customerExcusedAvail = suspectResults is not null && suspectResults.TryGetValue(key, out var ac)
- ? ac.CustomerExcusedDegradedAvailableSum
- : 0;
- elig.AvailableMinutes = Math.Round(Math.Max(0, mr.AvailableSum - customerExcusedAvail), 2);
- }
- }
-
- var sorted = eligByRes.Values
- .OrderBy(r => r.SubscriptionName)
- .ThenBy(r => r.Kind)
- .ThenBy(r => r.Name)
- .ToArray();
-
- SummaryWriter.WriteResults(sorted);
- SummaryWriter.WriteSubscriptionSummaries(sorted);
-
- sw.Stop();
- Console.WriteLine($"Completed in {sw.Elapsed:hh\\:mm\\:ss\\.ff}");
-}
-
-static int CountSuspectGaps(MetricScalars metrics)
-{
- var ticks = new List();
- if (metrics.GapTicks is not null)
- ticks.AddRange(metrics.GapTicks);
- if (metrics.ZeroAvailTicks is not null)
- ticks.AddRange(metrics.ZeroAvailTicks);
- if (metrics.DegradedSamples is not null)
- ticks.AddRange(metrics.DegradedSamples.Select(sample => sample.Tick));
-
- if (ticks.Count == 0)
- return 0;
-
- ticks.Sort();
- int gapCount = 1;
- long previous = ticks[0];
- long oneMinute = TimeSpan.FromMinutes(1).Ticks;
-
- for (int i = 1; i < ticks.Count; i++)
- {
- long current = ticks[i];
- if (current != previous && current - previous > oneMinute)
- gapCount++;
- previous = current;
- }
-
- return gapCount;
-}
-
-static (DateTimeOffset Start, DateTimeOffset End, string NormalizedMonth, bool IsMonthToDate) ResolveObservationWindow(string monthParameter)
-{
- if (!DateTime.TryParseExact(
- monthParameter,
- "yyyyMM",
- CultureInfo.InvariantCulture,
- DateTimeStyles.None,
- out var monthDate))
- {
- throw new ArgumentException("--month must use format YYYYMM.");
- }
-
- var now = DateTimeOffset.UtcNow;
- var currentMinute = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, TimeSpan.Zero);
- var start = new DateTimeOffset(monthDate.Year, monthDate.Month, 1, 0, 0, 0, TimeSpan.Zero);
- if (start >= currentMinute)
- throw new ArgumentException("--month must not be in the future.");
-
- if (start < currentMinute.AddDays(-90))
- throw new ArgumentException("--month cannot start more than 90 days before now.");
-
- var nextMonth = start.AddMonths(1);
- var end = nextMonth < currentMinute ? nextMonth : currentMinute;
- if (end <= start)
- throw new ArgumentException("--month produced an empty observation period.");
-
- string normalizedMonth = start.ToString("yyyyMM", CultureInfo.InvariantCulture);
- bool isMonthToDate = end < nextMonth;
- return (start, end, normalizedMonth, isMonthToDate);
-}
-
-static string ReadRequiredValue(string[] args, ref int index, string optionName)
-{
- if (index + 1 >= args.Length || args[index + 1].StartsWith("-", StringComparison.Ordinal))
- throw new ArgumentException($"{optionName} requires a value.");
-
- return args[++index];
-}
-
-static string[] ReadRequiredValues(string[] args, ref int index, string optionName)
-{
- var values = new List();
- while (index + 1 < args.Length && !args[index + 1].StartsWith("-", StringComparison.Ordinal))
- {
- // Support comma-separated values: "A, B, C" or "A,B,C" or mixed
- var raw = args[++index];
- foreach (var part in raw.Split(','))
- {
- var trimmed = part.Trim();
- if (trimmed.Length > 0)
- values.Add(trimmed);
- }
- }
-
- return values.Count > 0
- ? values.ToArray()
- : throw new ArgumentException($"{optionName} requires at least one value.");
-}
-
-static int ParseIntOption(string rawValue, string optionName, int minValue)
-{
- if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
- throw new ArgumentException($"{optionName} must be an integer.");
-
- if (value < minValue)
- throw new ArgumentException($"{optionName} must be >= {minValue}.");
-
- return value;
-}
diff --git a/Old/GetAvailability/Services/ActivityLogService.cs b/Old/GetAvailability/Services/ActivityLogService.cs
deleted file mode 100644
index 48f717d..0000000
--- a/Old/GetAvailability/Services/ActivityLogService.cs
+++ /dev/null
@@ -1,600 +0,0 @@
-using System.Text.Json;
-using GetAvailability.Models;
-
-namespace GetAvailability.Services;
-
-///
-/// Queries Azure Activity Log for lifecycle operations and resource creation/deletion
-/// events that can explain suspect availability minutes before Health History and
-/// fallback rules are applied.
-///
-/// Lifecycle rules are kind-specific (VM, SQL, WebApp). Kinds with no known lifecycle
-/// operations (e.g. Storage) still get creation/deletion detection.
-///
-/// For Web Apps, paired stop→start intervals cover the full stopped window because
-/// Resource Health does not track stopped web app state (unlike VMs where deallocation
-/// creates "Unavailable – Customer Initiated").
-///
-public static class ActivityLogService
-{
- // All VM lifecycle operations apply grace (trailing metrics during state transitions).
- private static readonly ActivityOperationRule[] VmActivityRules =
- [
- new(true,
- [
- "microsoft.compute/virtualmachines/start/action",
- "start virtual machine",
- ]),
- new(true,
- [
- "microsoft.compute/virtualmachines/deallocate/action",
- "deallocate virtual machine",
- ]),
- new(true,
- [
- "microsoft.compute/virtualmachines/poweroff/action",
- "power off virtual machine",
- ]),
- new(true,
- [
- "microsoft.compute/virtualmachines/restart/action",
- "restart virtual machine",
- ]),
- ];
-
- private static readonly ActivityOperationRule[] SqlDatabaseActivityRules =
- [
- new(true,
- [
- "microsoft.sql/servers/databases/pause",
- "pause sql database",
- "pause database",
- ]),
- new(true,
- [
- "microsoft.sql/servers/databases/resume",
- "resume sql database",
- "resume database",
- ]),
- ];
-
- // Web App lifecycle: stop/start/restart all apply grace for trailing zero metrics
- // during process shutdown/startup.
- private static readonly ActivityOperationRule[] WebAppActivityRules =
- [
- new(true,
- [
- "microsoft.web/sites/stop/action",
- "stop web app",
- "stopwebsite",
- ]),
- new(true,
- [
- "microsoft.web/sites/start/action",
- "start web app",
- "startwebsite",
- ]),
- new(true,
- [
- "microsoft.web/sites/restart/action",
- "restart web app",
- "restartwebsite",
- ]),
- ];
-
- /// Maps resource kind to its ARM namespace for creation/deletion token matching.
- private static readonly Dictionary KindNamespace = new(StringComparer.OrdinalIgnoreCase)
- {
- ["VirtualMachine"] = "microsoft.compute/virtualmachines",
- ["AzureSqlDatabase"] = "microsoft.sql/servers/databases",
- ["StorageAccount"] = "microsoft.storage/storageaccounts",
- ["WebApp"] = "microsoft.web/sites",
- };
-
- ///
- /// Builds merged lifecycle + existence intervals from pre-fetched Log Analytics
- /// activity events. Used when --workspace is specified, replacing REST API calls
- /// with bulk KQL data.
- ///
- public static List<(DateTimeOffset From, DateTimeOffset To)> BuildLifecycleIntervalsFromEvents(
- IReadOnlyList laEvents,
- string resourceKind,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd,
- int activityGraceMinutes)
- {
- TryGetActivityLogRules(resourceKind, out var activityRules);
-
- string? createToken = null, deleteToken = null;
- if (KindNamespace.TryGetValue(resourceKind, out var ns))
- {
- createToken = ns + "/write";
- deleteToken = ns + "/delete";
- }
-
- var lifecycleEvents = new List();
- var existenceEvents = new List<(DateTimeOffset Timestamp, string Type)>();
-
- foreach (var laEvt in laEvents)
- {
- var normalized = laEvt.OperationName.ToLowerInvariant();
-
- // Check for resource creation/deletion events
- if (createToken is not null && normalized.Contains(createToken, StringComparison.OrdinalIgnoreCase))
- existenceEvents.Add((laEvt.Timestamp.ToUniversalTime(), "Write"));
- else if (deleteToken is not null && normalized.Contains(deleteToken, StringComparison.OrdinalIgnoreCase))
- existenceEvents.Add((laEvt.Timestamp.ToUniversalTime(), "Delete"));
-
- // Check for lifecycle operations
- if (activityRules is not null &&
- TryMatchActivityOperation(laEvt.OperationName, activityRules, activityGraceMinutes, out int graceMinutes))
- {
- lifecycleEvents.Add(new ActivityLogEvent(
- laEvt.Timestamp.ToUniversalTime(),
- laEvt.OperationName,
- laEvt.CorrelationId,
- graceMinutes));
- }
- }
-
- var intervals = lifecycleEvents.Count > 0
- ? BuildIntervalsFromEvents(lifecycleEvents, periodStart, periodEnd)
- : new List<(DateTimeOffset, DateTimeOffset)>();
-
- // For web apps, build paired stop→start spanning intervals
- if (resourceKind.Equals("WebApp", StringComparison.OrdinalIgnoreCase) && lifecycleEvents.Count > 0)
- {
- var pairedIntervals = BuildPairedStopStartIntervals(lifecycleEvents, periodStart, periodEnd, activityGraceMinutes);
- if (pairedIntervals.Count > 0)
- intervals = MergeIntervals([.. intervals, .. pairedIntervals]);
- }
-
- // Build non-existence intervals from creation/deletion events
- var nonExistIntervals = BuildNonExistenceIntervals(existenceEvents, periodStart, periodEnd, activityGraceMinutes);
- if (nonExistIntervals.Count > 0)
- intervals = MergeIntervals([.. intervals, .. nonExistIntervals]);
-
- return intervals;
- }
-
- ///
- /// Builds merged lifecycle + existence intervals for a resource from Activity Log
- /// events fetched via the REST API.
- ///
- public static async Task> BuildLifecycleIntervalsAsync(
- HttpClient http,
- TrackedResource resource,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd,
- int activityGraceMinutes,
- CancellationToken ct)
- {
- TryGetActivityLogRules(resource.Kind, out var activityRules);
-
- var (lifecycleEvents, existenceEvents) = await FetchActivityEventsAsync(
- http,
- resource,
- periodStart,
- periodEnd,
- activityGraceMinutes,
- activityRules,
- ct);
-
- var intervals = lifecycleEvents.Count > 0
- ? BuildIntervalsFromEvents(lifecycleEvents, periodStart, periodEnd)
- : new List<(DateTimeOffset, DateTimeOffset)>();
-
- // For web apps, build paired stop→start spanning intervals
- if (resource.Kind.Equals("WebApp", StringComparison.OrdinalIgnoreCase) && lifecycleEvents.Count > 0)
- {
- var pairedIntervals = BuildPairedStopStartIntervals(lifecycleEvents, periodStart, periodEnd, activityGraceMinutes);
- if (pairedIntervals.Count > 0)
- intervals = MergeIntervals([.. intervals, .. pairedIntervals]);
- }
-
- // Build non-existence intervals from creation/deletion events
- var nonExistIntervals = BuildNonExistenceIntervals(existenceEvents, periodStart, periodEnd, activityGraceMinutes);
- if (nonExistIntervals.Count > 0)
- intervals = MergeIntervals([.. intervals, .. nonExistIntervals]);
-
- return intervals;
- }
-
- ///
- /// Builds merged lifecycle intervals from a list of parsed activity events.
- /// Groups events by operation+correlationId, computes per-group intervals with
- /// grace windows, clamps to the observation period, and merges overlaps.
- ///
- private static List<(DateTimeOffset From, DateTimeOffset To)> BuildIntervalsFromEvents(
- List events,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd)
- {
- var rawIntervals = new List<(DateTimeOffset From, DateTimeOffset To)>();
-
- foreach (var group in events.GroupBy(BuildActivityGroupKey, StringComparer.OrdinalIgnoreCase))
- {
- var min = group.Min(e => e.Timestamp);
- var max = group.Max(e => e.Timestamp);
- int graceMinutes = group.Max(e => e.GraceMinutes);
-
- var from = TruncateToMinute(min);
- var to = TruncateToMinute(max).AddMinutes(1);
- to = ExtendActivityInterval(graceMinutes, to);
-
- if (from < periodStart) from = periodStart;
- if (to > periodEnd) to = periodEnd;
-
- if (to > from)
- rawIntervals.Add((from, to));
- }
-
- return MergeIntervals(rawIntervals);
- }
-
- ///
- /// Fetches Activity Log events via the REST API, collecting both lifecycle events
- /// (matched by kind-specific rules) and creation/deletion events (matched by
- /// namespace write/delete tokens). Returns both lists.
- ///
- private static async Task<(List LifecycleEvents, List<(DateTimeOffset Timestamp, string Type)> ExistenceEvents)> FetchActivityEventsAsync(
- HttpClient http,
- TrackedResource resource,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd,
- int activityGraceMinutes,
- ActivityOperationRule[]? activityRules,
- CancellationToken ct)
- {
- var lifecycleEvents = new List();
- var existenceEvents = new List<(DateTimeOffset Timestamp, string Type)>();
-
- string? createToken = null, deleteToken = null;
- if (KindNamespace.TryGetValue(resource.Kind, out var ns))
- {
- createToken = ns + "/write";
- deleteToken = ns + "/delete";
- }
-
- string filter = $"eventTimestamp ge '{periodStart:O}' and eventTimestamp le '{periodEnd:O}' and resourceUri eq '{resource.ResourceId}'";
- string select = "eventTimestamp,operationName,correlationId,status";
-
- string? url =
- $"https://management.azure.com/subscriptions/{resource.SubscriptionId}/providers/microsoft.insights/eventtypes/management/values" +
- $"?api-version=2015-04-01&$filter={Uri.EscapeDataString(filter)}&$select={Uri.EscapeDataString(select)}";
-
- while (url is not null)
- {
- string json = await GetWithRetryAsync(http, url, ct);
- using var doc = JsonDocument.Parse(json);
-
- if (doc.RootElement.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array)
- {
- foreach (var item in value.EnumerateArray())
- {
- string? occurredStr = item.TryGetProperty("eventTimestamp", out var occurredEl)
- ? occurredEl.GetString()
- : null;
-
- var occurred = ParseAzureTimestamp(occurredStr);
- if (occurred is null)
- continue;
-
- string operationValue = GetNestedPropString(item, "operationName", "value");
- string operationLabel = GetNestedPropString(item, "operationName", "localizedValue");
- string operationKey = string.IsNullOrWhiteSpace(operationValue) ? operationLabel : operationValue;
- if (string.IsNullOrWhiteSpace(operationKey)) continue;
-
- var normalized = operationKey.ToLowerInvariant();
- var ts = occurred.Value.ToUniversalTime();
-
- // Check for resource creation/deletion events
- if (createToken is not null && normalized.Contains(createToken, StringComparison.OrdinalIgnoreCase))
- existenceEvents.Add((ts, "Write"));
- else if (deleteToken is not null && normalized.Contains(deleteToken, StringComparison.OrdinalIgnoreCase))
- existenceEvents.Add((ts, "Delete"));
-
- // Check for lifecycle operations
- if (activityRules is not null &&
- TryMatchActivityOperation(operationKey, activityRules, activityGraceMinutes, out int graceMinutes))
- {
- string correlationId = GetPropString(item, "correlationId");
- lifecycleEvents.Add(new ActivityLogEvent(ts, operationKey, correlationId, graceMinutes));
- }
- }
- }
-
- url = doc.RootElement.TryGetProperty("nextLink", out var next) &&
- next.ValueKind == JsonValueKind.String
- ? next.GetString()
- : null;
- }
-
- return (lifecycleEvents, existenceEvents);
- }
-
- private static async Task GetWithRetryAsync(HttpClient http, string url, CancellationToken ct)
- {
- for (int attempt = 0; ; attempt++)
- {
- using var response = await http.GetAsync(url, ct);
-
- if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests ||
- (int)response.StatusCode >= 500)
- {
- if (attempt >= 5) response.EnsureSuccessStatusCode();
- var delay = response.Headers.RetryAfter?.Delta
- ?? TimeSpan.FromSeconds(1 << attempt);
- await Task.Delay(delay, ct);
- continue;
- }
-
- response.EnsureSuccessStatusCode();
- return await response.Content.ReadAsStringAsync(ct);
- }
- }
-
- private static bool TryGetActivityLogRules(string resourceKind, out ActivityOperationRule[]? rules)
- {
- if (resourceKind.Equals("VirtualMachine", StringComparison.OrdinalIgnoreCase))
- {
- rules = VmActivityRules;
- return true;
- }
-
- if (resourceKind.Equals("AzureSqlDatabase", StringComparison.OrdinalIgnoreCase))
- {
- rules = SqlDatabaseActivityRules;
- return true;
- }
-
- if (resourceKind.Equals("WebApp", StringComparison.OrdinalIgnoreCase))
- {
- rules = WebAppActivityRules;
- return true;
- }
-
- rules = null;
- return false;
- }
-
- private static bool TryMatchActivityOperation(
- string operation,
- IReadOnlyList rules,
- int activityGraceMinutes,
- out int graceMinutes)
- {
- graceMinutes = 0;
- if (string.IsNullOrWhiteSpace(operation))
- return false;
-
- var normalized = operation.ToLowerInvariant();
-
- foreach (var rule in rules)
- {
- if (rule.MatchTokens.Any(token => normalized.Contains(token, StringComparison.OrdinalIgnoreCase)))
- {
- graceMinutes = rule.ApplyGrace ? activityGraceMinutes : 0;
- return true;
- }
- }
-
- return false;
- }
-
- private static string BuildActivityGroupKey(ActivityLogEvent evt)
- {
- string correlationPart = string.IsNullOrWhiteSpace(evt.CorrelationId)
- ? TruncateToMinute(evt.Timestamp).ToString("O")
- : evt.CorrelationId;
- return $"{evt.OperationKey}|{correlationPart}";
- }
-
- private static List<(DateTimeOffset From, DateTimeOffset To)> MergeIntervals(
- List<(DateTimeOffset From, DateTimeOffset To)> intervals)
- {
- if (intervals.Count == 0)
- return intervals;
-
- var ordered = intervals.OrderBy(i => i.From).ToList();
- var merged = new List<(DateTimeOffset From, DateTimeOffset To)> { ordered[0] };
-
- for (int i = 1; i < ordered.Count; i++)
- {
- var current = ordered[i];
- var last = merged[^1];
-
- if (current.From <= last.To)
- {
- merged[^1] = (last.From, current.To > last.To ? current.To : last.To);
- }
- else
- {
- merged.Add(current);
- }
- }
-
- return merged;
- }
-
- ///
- /// For web apps, builds spanning intervals from stop→start/restart pairs.
- /// Unlike VMs (where Resource Health reports "Unavailable – Customer Initiated"
- /// for the entire deallocated period), stopped web apps show only "Available"
- /// in Resource Health. We infer the full stopped window from Activity Log events.
- /// An unpaired trailing stop extends to periodEnd.
- ///
- private static List<(DateTimeOffset From, DateTimeOffset To)> BuildPairedStopStartIntervals(
- List events,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd,
- int activityGraceMinutes)
- {
- var stopTokens = WebAppActivityRules[0].MatchTokens; // stop
- var startTokens = WebAppActivityRules[1].MatchTokens // start
- .Concat(WebAppActivityRules[2].MatchTokens) // restart
- .ToArray();
-
- var stopTimes = new List();
- var startTimes = new List();
-
- foreach (var evt in events)
- {
- var norm = evt.OperationKey.ToLowerInvariant();
- if (stopTokens.Any(t => norm.Contains(t, StringComparison.OrdinalIgnoreCase)))
- stopTimes.Add(evt.Timestamp);
- else if (startTokens.Any(t => norm.Contains(t, StringComparison.OrdinalIgnoreCase)))
- startTimes.Add(evt.Timestamp);
- }
-
- if (stopTimes.Count == 0)
- return [];
-
- stopTimes.Sort();
- startTimes.Sort();
-
- var intervals = new List<(DateTimeOffset From, DateTimeOffset To)>();
- foreach (var stop in stopTimes)
- {
- var nextStart = startTimes.FirstOrDefault(s => s > stop);
- var from = TruncateToMinute(stop);
- var to = nextStart != default
- ? TruncateToMinute(nextStart).AddMinutes(1 + activityGraceMinutes)
- : periodEnd;
- if (from < periodStart) from = periodStart;
- if (to > periodEnd) to = periodEnd;
- if (to > from)
- intervals.Add((from, to));
- }
-
- return intervals;
- }
-
- ///
- /// Builds non-existence intervals from resource creation/deletion events using a
- /// state machine. Non-existence intervals cover:
- /// - periodStart → first Write (resource created mid-period)
- /// - Delete → next Write (destroy/recreate cycle)
- /// - last Delete → periodEnd (resource deleted, not recreated)
- ///
- private static List<(DateTimeOffset From, DateTimeOffset To)> BuildNonExistenceIntervals(
- List<(DateTimeOffset Timestamp, string Type)> existenceEvents,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd,
- int activityGraceMinutes)
- {
- if (existenceEvents.Count == 0)
- return [];
-
- var sorted = existenceEvents.OrderBy(e => e.Timestamp).ToList();
- var intervals = new List<(DateTimeOffset From, DateTimeOffset To)>();
- string state = "unknown"; // unknown | exists | not-exists
- DateTimeOffset? nonExistStart = null;
-
- foreach (var (timestamp, type) in sorted)
- {
- if (type == "Write")
- {
- if (state != "exists")
- {
- // Resource came into existence — close non-existence interval
- var from = nonExistStart.HasValue ? TruncateToMinute(nonExistStart.Value) : periodStart;
- var to = TruncateToMinute(timestamp).AddMinutes(1 + activityGraceMinutes);
- if (from < periodStart) from = periodStart;
- if (to > periodEnd) to = periodEnd;
- if (to > from)
- intervals.Add((from, to));
- state = "exists";
- nonExistStart = null;
- }
- }
- else if (type == "Delete")
- {
- state = "not-exists";
- nonExistStart = timestamp;
- }
- }
-
- // If resource was deleted and not recreated, non-existence extends to period end
- if (state == "not-exists" && nonExistStart.HasValue)
- {
- var from = TruncateToMinute(nonExistStart.Value);
- if (from < periodStart) from = periodStart;
- if (periodEnd > from)
- intervals.Add((from, periodEnd));
- }
-
- return intervals;
- }
-
- private static DateTimeOffset ExtendActivityInterval(int graceMinutes, DateTimeOffset currentEnd)
- => graceMinutes > 0 ? currentEnd.AddMinutes(graceMinutes) : currentEnd;
-
- ///
- /// Parses Azure timestamp strings which may use several formats depending on
- /// region and API version. Falls back to DateTimeOffset.TryParse for formats
- /// not in the explicit list.
- ///
- private static DateTimeOffset? ParseAzureTimestamp(string? timestamp)
- {
- if (string.IsNullOrWhiteSpace(timestamp))
- return null;
-
- ReadOnlySpan formats =
- [
- "MM/dd/yyyy HH:mm:ss",
- "M/d/yyyy H:mm:ss",
- "dd/MM/yyyy HH:mm:ss",
- "d/M/yyyy H:mm:ss",
- "yyyy-MM-ddTHH:mm:ssZ",
- "yyyy-MM-ddTHH:mm:ss.fffffffZ",
- ];
-
- foreach (var format in formats)
- {
- if (DateTimeOffset.TryParseExact(
- timestamp,
- format,
- System.Globalization.CultureInfo.InvariantCulture,
- System.Globalization.DateTimeStyles.AssumeUniversal,
- out var parsed))
- {
- return parsed;
- }
- }
-
- return DateTimeOffset.TryParse(
- timestamp,
- System.Globalization.CultureInfo.InvariantCulture,
- System.Globalization.DateTimeStyles.AssumeUniversal,
- out var fallback)
- ? fallback
- : null;
- }
-
- /// Truncates to minute boundary for interval grouping.
- private static DateTimeOffset TruncateToMinute(DateTimeOffset value)
- => new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, TimeSpan.Zero);
-
- /// Reads a nested string property (e.g. operationName.value) from a JsonElement.
- private static string GetNestedPropString(JsonElement parent, string propertyName, string nestedName)
- {
- if (!parent.TryGetProperty(propertyName, out var obj) || obj.ValueKind != JsonValueKind.Object)
- return "";
- return GetPropString(obj, nestedName);
- }
-
- /// Reads a string property from a JsonElement, returning empty if absent or null.
- private static string GetPropString(JsonElement props, string name)
- => props.TryGetProperty(name, out var el) ? el.GetString() ?? "" : "";
-}
-
-internal readonly record struct ActivityLogEvent(
- DateTimeOffset Timestamp,
- string OperationKey,
- string CorrelationId,
- int GraceMinutes);
-
-internal readonly record struct ActivityOperationRule(
- bool ApplyGrace,
- string[] MatchTokens);
\ No newline at end of file
diff --git a/Old/GetAvailability/Services/BatchMetricsService.cs b/Old/GetAvailability/Services/BatchMetricsService.cs
deleted file mode 100644
index fa4e26c..0000000
--- a/Old/GetAvailability/Services/BatchMetricsService.cs
+++ /dev/null
@@ -1,482 +0,0 @@
-using Azure.Core;
-using GetAvailability.Models;
-using System.Collections.Concurrent;
-using System.Net.Http.Headers;
-using System.Text.Json;
-
-namespace GetAvailability.Services;
-
-///
-/// Fetches Azure Monitor metrics using the regional Batch API (metrics:getBatch) instead of
-/// per-resource calls. Groups resources by (subscription, region, kind) and sends batched
-/// POST requests to reduce total HTTP calls and throttling risk.
-///
-/// Batch API constraints:
-/// - All resources in a batch must share subscription, region, and resource type.
-/// - Max 50 resource IDs per batch call (default 10 for memory safety).
-/// - Endpoint: https://{region}.metrics.monitor.azure.com
-/// - Auth scope: https://metrics.monitor.azure.com/.default
-///
-public static class BatchMetricsService
-{
- private static readonly Dictionary KindConfigs = new(StringComparer.OrdinalIgnoreCase)
- {
- ["VirtualMachine"] = new("Microsoft.Compute/virtualMachines", "VmAvailabilityMetric", "Minimum"),
- ["AzureSqlDatabase"] = new("Microsoft.Sql/servers/databases", "Availability", "Minimum"),
- ["StorageAccount"] = new("Microsoft.Storage/storageAccounts", "Availability,Transactions", "Minimum,Total"),
- ["WebApp"] = new("Microsoft.Web/sites", "MemoryWorkingSet", "Average"),
- };
-
- ///
- /// Queries metrics for all resources using the batch API. Returns a dictionary keyed by
- /// lowercase resource ID, same shape as MetricsService.QueryAsync for drop-in replacement.
- ///
- public static async Task> QueryAsync(
- TokenCredential credential,
- IReadOnlyList resources,
- DateTimeOffset startDate,
- DateTimeOffset endDate,
- int parallelism,
- int batchSize = 10)
- {
- int total = resources.Count;
-
- // Acquire metrics-scoped token
- var tokenResponse = await credential.GetTokenAsync(
- new TokenRequestContext(["https://metrics.monitor.azure.com/.default"]), default);
-
- using var http = new HttpClient();
- http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.Token);
- http.Timeout = TimeSpan.FromMinutes(5);
-
- // Group resources by (subscriptionId, location, kind) — the batch API requires all
- // resources in a single call to share subscription, region, and resource type.
- var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase);
- foreach (var res in resources)
- {
- string key = $"{res.SubscriptionId}|{res.Location.ToLowerInvariant()}|{res.Kind}";
- if (!groups.TryGetValue(key, out var list))
- {
- list = [];
- groups[key] = list;
- }
- list.Add(res);
- }
-
- // Split each group into chunks of batchSize (max 50 per API call)
- var workItems = new List();
- foreach (var (key, resList) in groups)
- {
- var parts = key.Split('|');
- string subId = parts[0], location = parts[1], kind = parts[2];
- if (!KindConfigs.TryGetValue(kind, out var config))
- {
- Console.Error.WriteLine($" WARNING: No batch config for kind '{kind}', skipping.");
- continue;
- }
-
- for (int i = 0; i < resList.Count; i += batchSize)
- {
- var chunk = resList.GetRange(i, Math.Min(batchSize, resList.Count - i));
- workItems.Add(new BatchWorkItem(subId, location, kind, config, chunk));
- }
- }
-
- // Print grouping summary
- int uniqueRegions = groups.Keys
- .Select(k => k.Split('|')[1])
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .Count();
- Console.WriteLine($"Grouped {total} resource(s) into {workItems.Count} batch(es) across {uniqueRegions} region(s) (max {batchSize} per batch).");
-
- foreach (var (key, resList) in groups.OrderBy(g => g.Key))
- {
- var parts = key.Split('|');
- string subName = resList[0].SubscriptionName;
- string kind = ShortKind(parts[2]);
- string location = parts[1];
- int chunks = (int)Math.Ceiling((double)resList.Count / batchSize);
- Console.WriteLine($" {location} / {kind} / {Truncate(subName, 30)} : {resList.Count} resource(s) -> {chunks} batch(es)");
- }
- Console.WriteLine();
-
- string startIso = startDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
- string endIso = endDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
-
- var results = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
- int done = 0;
- int totalBatches = workItems.Count;
-
- await Parallel.ForEachAsync(workItems,
- new ParallelOptions { MaxDegreeOfParallelism = parallelism },
- async (workItem, ct) =>
- {
- await ProcessBatchWorkItemAsync(http, workItem, startIso, endIso, results, ct);
-
- int current = Interlocked.Increment(ref done);
- if (current % 5 == 0 || current == totalBatches)
- Console.Write($"\r Fetching batch metrics: {current}/{totalBatches} batches done");
- });
-
- Console.WriteLine($"\r Batch queries completed. Received results for {results.Count} / {total} resource(s). ");
- return results;
- }
-
- private static async Task ProcessBatchWorkItemAsync(
- HttpClient http,
- BatchWorkItem workItem,
- string startIso,
- string endIso,
- ConcurrentDictionary results,
- CancellationToken ct)
- {
- bool isVm = workItem.Kind == "VirtualMachine";
- bool isStorage = workItem.Kind == "StorageAccount";
- bool isWebApp = workItem.Kind == "WebApp";
-
- var resourceIds = workItem.Resources.Select(r => r.ResourceId).ToArray();
-
- string uri = $"https://{workItem.Location}.metrics.monitor.azure.com" +
- $"/subscriptions/{workItem.SubscriptionId}/metrics:getBatch" +
- $"?starttime={Uri.EscapeDataString(startIso)}" +
- $"&endtime={Uri.EscapeDataString(endIso)}" +
- $"&interval=PT1M" +
- $"&metricnamespace={Uri.EscapeDataString(workItem.Config.Namespace)}" +
- $"&metricnames={Uri.EscapeDataString(workItem.Config.MetricNames)}" +
- $"&aggregation={Uri.EscapeDataString(workItem.Config.Aggregation)}" +
- $"&api-version=2023-10-01";
-
- string bodyJson = BuildResourceIdsJson(resourceIds);
-
- // Build resource lookup
- var resById = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var r in workItem.Resources)
- resById[r.ResourceId.ToLowerInvariant()] = r;
-
- JsonDocument? doc = null;
-
- for (int attempt = 1; attempt <= 5; attempt++)
- {
- try
- {
- using var request = new HttpRequestMessage(HttpMethod.Post, uri)
- {
- Content = new StringContent(bodyJson, System.Text.Encoding.UTF8, "application/json")
- };
-
- using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
- int statusCode = (int)response.StatusCode;
-
- if (statusCode >= 200 && statusCode < 300)
- {
- await using var stream = await response.Content.ReadAsStreamAsync(ct);
- doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
- break;
- }
-
- if ((statusCode == 429 || statusCode >= 500) && attempt < 5)
- {
- await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 1 << attempt)), ct);
- continue;
- }
-
- string names = string.Join(", ", workItem.Resources.Select(r => r.Name));
- Console.Error.WriteLine($" WARNING: Batch metric query failed for [{names}]: HTTP {statusCode}");
- EmitExcludedResults(workItem.Resources, results);
- return;
- }
- catch (Exception ex) when (attempt < 5 &&
- (ex.Message.Contains("transport", StringComparison.OrdinalIgnoreCase) ||
- ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) ||
- ex.Message.Contains("timed out", StringComparison.OrdinalIgnoreCase)))
- {
- await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 1 << attempt)), ct);
- }
- catch (Exception ex)
- {
- string names = string.Join(", ", workItem.Resources.Select(r => r.Name));
- Console.Error.WriteLine($" WARNING: Batch metric query failed for [{names}]: {ex.Message}");
- EmitExcludedResults(workItem.Resources, results);
- return;
- }
- }
-
- if (doc is null)
- {
- EmitExcludedResults(workItem.Resources, results);
- return;
- }
-
- // Parse the batch response JSON: { "values": [ { "resourceid": "...", "value": [...] }, ... ] }
- // Each entry contains per-metric timeseries data that is dispatched to the appropriate
- // storage or VM/SQL processor based on resource kind.
- try
- {
- if (!doc.RootElement.TryGetProperty("values", out var valuesArray))
- {
- EmitExcludedResults(workItem.Resources, results);
- return;
- }
-
- foreach (var resourceEntry in valuesArray.EnumerateArray())
- {
- string resId = resourceEntry.GetProperty("resourceid").GetString()!;
- string resIdLower = resId.ToLowerInvariant();
-
- if (!resourceEntry.TryGetProperty("value", out var metricValueArr))
- {
- results[resIdLower] = default;
- continue;
- }
-
- if (isStorage)
- results[resIdLower] = ProcessStorageBatch(metricValueArr);
- else if (isWebApp)
- results[resIdLower] = ProcessWebAppBatch(metricValueArr);
- else
- results[resIdLower] = ProcessVmOrSqlBatch(metricValueArr, isVm);
- }
- }
- finally
- {
- doc.Dispose();
- }
- }
-
- ///
- /// Processes VM or SQL batch response: walks each timeseries data point, classifies it as
- /// available (value = 1.0), degraded (0 < value < 1.0), zero (value = 0), or null (missing).
- /// VM metrics are natively 0.0–1.0; SQL metrics are 0–100 and normalised to 0.0–1.0.
- ///
- private static MetricScalars ProcessVmOrSqlBatch(JsonElement metricValueArr, bool isVm)
- {
- double availSum = 0;
- int numericPoints = 0;
- var nullTicks = new List();
- var zeroTicks = new List();
- var degraded = new List();
-
- foreach (var metricEl in metricValueArr.EnumerateArray())
- {
- string mName = metricEl.GetProperty("name").GetProperty("value").GetString()!;
- bool isPrimary = mName.Equals("VmAvailabilityMetric", StringComparison.OrdinalIgnoreCase)
- || mName.Equals("Availability", StringComparison.OrdinalIgnoreCase);
- if (!isPrimary) continue;
-
- foreach (var tsEl in metricEl.GetProperty("timeseries").EnumerateArray())
- foreach (var dp in tsEl.GetProperty("data").EnumerateArray())
- {
- long ticks = DateTime.Parse(dp.GetProperty("timeStamp").GetString()!).ToUniversalTime().Ticks;
- double? minVal = TryGetDouble(dp, "minimum");
-
- if (minVal.HasValue)
- {
- numericPoints++;
- double v = isVm ? minVal.Value : minVal.Value / 100.0;
- if (v == 0.0)
- {
- zeroTicks.Add(ticks);
- }
- else
- {
- availSum += v;
- if (v < 1.0)
- degraded.Add(new MetricValueSample(ticks, v));
- }
- }
- else
- {
- nullTicks.Add(ticks);
- }
- }
- }
-
- int gapMinutes = nullTicks.Count + zeroTicks.Count;
- int degradedMinutes = degraded.Count;
- bool exclude = numericPoints == 0 && nullTicks.Count == 0 && zeroTicks.Count == 0 && degradedMinutes == 0;
-
- return new MetricScalars(
- availSum, gapMinutes, 0, exclude,
- nullTicks.Count > 0 ? nullTicks.ToArray() : null,
- zeroTicks.Count > 0 ? zeroTicks.ToArray() : null,
- degradedMinutes,
- degraded.Count > 0 ? degraded.ToArray() : null);
- }
-
- ///
- /// Processes Storage Account batch response: correlates Availability with Transactions.
- /// Minutes with zero transactions have no availability signal and are excluded from eligibility.
- /// Availability is normalised from 0–100 to 0.0–1.0.
- ///
- private static MetricScalars ProcessStorageBatch(JsonElement metricValueArr)
- {
- double availSum = 0;
- int zeroTxMin = 0;
- int numericPoints = 0;
- var nullTicks = new List();
- var zeroTicks = new List();
- var degraded = new List();
-
- // Build Transactions lookup
- var txByTicks = new Dictionary();
- foreach (var metricEl in metricValueArr.EnumerateArray())
- {
- string mName = metricEl.GetProperty("name").GetProperty("value").GetString()!;
- if (!mName.Equals("Transactions", StringComparison.OrdinalIgnoreCase)) continue;
- foreach (var tsEl in metricEl.GetProperty("timeseries").EnumerateArray())
- foreach (var dp in tsEl.GetProperty("data").EnumerateArray())
- {
- long ticks = DateTime.Parse(dp.GetProperty("timeStamp").GetString()!).ToUniversalTime().Ticks;
- double? tot = TryGetDouble(dp, "total");
- if (tot.HasValue)
- txByTicks[ticks] = tot.Value;
- }
- }
-
- // Process Availability
- foreach (var metricEl in metricValueArr.EnumerateArray())
- {
- string mName = metricEl.GetProperty("name").GetProperty("value").GetString()!;
- if (!mName.Equals("Availability", StringComparison.OrdinalIgnoreCase)) continue;
- foreach (var tsEl in metricEl.GetProperty("timeseries").EnumerateArray())
- foreach (var dp in tsEl.GetProperty("data").EnumerateArray())
- {
- long ticks = DateTime.Parse(dp.GetProperty("timeStamp").GetString()!).ToUniversalTime().Ticks;
- bool hasTx = txByTicks.TryGetValue(ticks, out double txVal) && txVal > 0;
- double? minVal = TryGetDouble(dp, "minimum");
-
- if (hasTx && minVal.HasValue)
- {
- double norm = minVal.Value / 100.0;
- numericPoints++;
- if (norm == 0.0)
- {
- zeroTicks.Add(ticks);
- }
- else
- {
- availSum += norm;
- if (norm < 1.0)
- degraded.Add(new MetricValueSample(ticks, norm));
- }
- }
- else if (hasTx && !minVal.HasValue)
- {
- nullTicks.Add(ticks);
- }
- else if (!hasTx)
- {
- zeroTxMin++;
- }
- }
- }
-
- int gapMinutes = nullTicks.Count + zeroTicks.Count;
- int degradedMinutes = degraded.Count;
- bool exclude = numericPoints == 0 && nullTicks.Count == 0 && zeroTicks.Count == 0 && degradedMinutes == 0;
-
- return new MetricScalars(
- availSum, gapMinutes, zeroTxMin, exclude,
- nullTicks.Count > 0 ? nullTicks.ToArray() : null,
- zeroTicks.Count > 0 ? zeroTicks.ToArray() : null,
- degradedMinutes,
- degraded.Count > 0 ? degraded.ToArray() : null);
- }
-
- ///
- /// Processes Web App batch response using MemoryWorkingSet (Average, bytes).
- /// Non-null value >0 = available (1.0), exactly 0 = suspect zero, null = suspect null.
- ///
- private static MetricScalars ProcessWebAppBatch(JsonElement metricValueArr)
- {
- double availSum = 0;
- int numericPoints = 0;
- var nullTicks = new List();
- var zeroTicks = new List();
-
- foreach (var metricEl in metricValueArr.EnumerateArray())
- {
- string mName = metricEl.GetProperty("name").GetProperty("value").GetString()!;
- if (!mName.Equals("MemoryWorkingSet", StringComparison.OrdinalIgnoreCase)) continue;
-
- foreach (var tsEl in metricEl.GetProperty("timeseries").EnumerateArray())
- foreach (var dp in tsEl.GetProperty("data").EnumerateArray())
- {
- long ticks = DateTime.Parse(dp.GetProperty("timeStamp").GetString()!).ToUniversalTime().Ticks;
- double? avgVal = TryGetDouble(dp, "average");
-
- if (avgVal.HasValue)
- {
- numericPoints++;
- if (avgVal.Value > 0)
- availSum += 1.0;
- else
- zeroTicks.Add(ticks);
- }
- else
- {
- nullTicks.Add(ticks);
- }
- }
- }
-
- int gapMinutes = nullTicks.Count + zeroTicks.Count;
- bool exclude = numericPoints == 0 && nullTicks.Count == 0 && zeroTicks.Count == 0;
-
- return new MetricScalars(
- availSum, gapMinutes, 0, exclude,
- nullTicks.Count > 0 ? nullTicks.ToArray() : null,
- zeroTicks.Count > 0 ? zeroTicks.ToArray() : null,
- 0, null);
- }
-
- /// Tries to read a double from a JSON data-point element. Returns null if the property is absent or not a number.
- private static double? TryGetDouble(JsonElement element, string propertyName)
- {
- if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number)
- return prop.GetDouble();
- return null;
- }
-
- private static void EmitExcludedResults(
- List resources,
- ConcurrentDictionary results)
- {
- foreach (var r in resources)
- results[r.ResourceId.ToLowerInvariant()] = new MetricScalars(0, 0, 0, ExcludeFromAvailability: true);
- }
-
- private static string ShortKind(string kind) => kind switch
- {
- "VirtualMachine" => "VM",
- "AzureSqlDatabase" => "SQL",
- "StorageAccount" => "Storage",
- "WebApp" => "Web",
- _ => kind,
- };
-
- private static string Truncate(string s, int max) =>
- s.Length <= max ? s : string.Concat(s.AsSpan(0, max - 3), "...");
-
- ///
- /// Builds the JSON body for the batch request without reflection (AOT-safe).
- /// Produces: {"resourceids":["id1","id2",...]}
- ///
- private static string BuildResourceIdsJson(string[] resourceIds)
- {
- using var ms = new System.IO.MemoryStream();
- using (var writer = new Utf8JsonWriter(ms))
- {
- writer.WriteStartObject();
- writer.WriteStartArray("resourceids");
- foreach (var id in resourceIds)
- writer.WriteStringValue(id);
- writer.WriteEndArray();
- writer.WriteEndObject();
- }
- return System.Text.Encoding.UTF8.GetString(ms.ToArray());
- }
-
- private readonly record struct BatchMetricConfig(string Namespace, string MetricNames, string Aggregation);
- private readonly record struct BatchWorkItem(string SubscriptionId, string Location, string Kind, BatchMetricConfig Config, List Resources);
-}
diff --git a/Old/GetAvailability/Services/LogAnalyticsService.cs b/Old/GetAvailability/Services/LogAnalyticsService.cs
deleted file mode 100644
index dd0050d..0000000
--- a/Old/GetAvailability/Services/LogAnalyticsService.cs
+++ /dev/null
@@ -1,368 +0,0 @@
-using Azure.Core;
-using System.Globalization;
-using System.Net.Http.Headers;
-using System.Text.Json;
-
-namespace GetAvailability.Services;
-
-///
-/// Fetches Activity Log lifecycle events and Resource Health transitions from a
-/// Log Analytics workspace via a single bulk KQL query against the AzureActivity table.
-/// Returns per-resource data keyed by lowercase resource ID.
-///
-/// Activity Log events are used directly for lifecycle classification (VM start/stop, SQL pause/resume).
-/// Health transitions undergo incident-based post-processing to replicate the REST API's curated
-/// behaviour: multiple lifecycle events per incident (Activated → Updated → InProgress → Resolved)
-/// are consolidated into clean state transitions with retroactively corrected cause classification.
-///
-public static class LogAnalyticsService
-{
- ///
- /// Executes a single KQL query that fetches both Activity Log lifecycle events and
- /// Resource Health transitions for all specified subscriptions. Returns a dictionary
- /// keyed by lowercase resource ID, each containing pre-parsed activity events and
- /// consolidated health transitions.
- ///
- public static async Task> FetchAsync(
- TokenCredential credential,
- string workspaceId,
- string[] subscriptionIds,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd)
- {
- var token = await credential.GetTokenAsync(
- new TokenRequestContext(["https://api.loganalytics.io/.default"]), default);
-
- string startIso = periodStart.ToString("O");
- string endIso = periodEnd.ToString("O");
- string subList = string.Join(", ", subscriptionIds.Select(s => $"'{s}'"));
-
- string kql = $"""
- let subs = dynamic([{subList}]);
- let actOps = dynamic([
- 'MICROSOFT.COMPUTE/VIRTUALMACHINES/START/ACTION',
- 'MICROSOFT.COMPUTE/VIRTUALMACHINES/DEALLOCATE/ACTION',
- 'MICROSOFT.COMPUTE/VIRTUALMACHINES/POWEROFF/ACTION',
- 'MICROSOFT.COMPUTE/VIRTUALMACHINES/RESTART/ACTION',
- 'MICROSOFT.SQL/SERVERS/DATABASES/PAUSE/ACTION',
- 'MICROSOFT.SQL/SERVERS/DATABASES/RESUME/ACTION',
- 'MICROSOFT.WEB/SITES/STOP/ACTION',
- 'MICROSOFT.WEB/SITES/START/ACTION',
- 'MICROSOFT.WEB/SITES/RESTART/ACTION',
- 'MICROSOFT.COMPUTE/VIRTUALMACHINES/WRITE',
- 'MICROSOFT.COMPUTE/VIRTUALMACHINES/DELETE',
- 'MICROSOFT.SQL/SERVERS/DATABASES/WRITE',
- 'MICROSOFT.SQL/SERVERS/DATABASES/DELETE',
- 'MICROSOFT.STORAGE/STORAGEACCOUNTS/WRITE',
- 'MICROSOFT.STORAGE/STORAGEACCOUNTS/DELETE',
- 'MICROSOFT.WEB/SITES/WRITE',
- 'MICROSOFT.WEB/SITES/DELETE'
- ]);
- let actData = AzureActivity
- | where SubscriptionId in (subs)
- | where CategoryValue == 'Administrative'
- | where OperationNameValue in~ (actOps)
- | where ActivityStatusValue == 'Success'
- | where TimeGenerated >= datetime({startIso}) and TimeGenerated <= datetime({endIso})
- | project TimeGenerated, ResourceId=tolower(_ResourceId),
- OperationName=OperationNameValue, CorrelationId,
- Source='Activity';
- let healthData = AzureActivity
- | where SubscriptionId in (subs)
- | where CategoryValue == 'ResourceHealth'
- | where ResourceProviderValue in ('MICROSOFT.COMPUTE', 'MICROSOFT.SQL', 'MICROSOFT.STORAGE', 'MICROSOFT.WEB')
- | project TimeGenerated, ResourceId=tolower(_ResourceId),
- Source='Health', OperationName=OperationNameValue,
- Properties=todynamic(Properties);
- actData | union healthData
- """;
-
- string laUrl = $"https://api.loganalytics.io/v1/workspaces/{workspaceId}/query";
- string body = BuildQueryJson(kql);
-
- using var http = new HttpClient();
- http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
- http.Timeout = TimeSpan.FromMinutes(5);
-
- using var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json");
- using var response = await http.PostAsync(laUrl, content);
- response.EnsureSuccessStatusCode();
-
- string json = await response.Content.ReadAsStringAsync();
-
- return ParseResponse(json);
- }
-
- private static string BuildQueryJson(string kql)
- {
- using var ms = new System.IO.MemoryStream();
- using (var writer = new Utf8JsonWriter(ms))
- {
- writer.WriteStartObject();
- writer.WriteString("query", kql);
- writer.WriteEndObject();
- }
- return System.Text.Encoding.UTF8.GetString(ms.ToArray());
- }
-
- private static Dictionary ParseResponse(string json)
- {
- using var doc = JsonDocument.Parse(json);
- var table = doc.RootElement.GetProperty("tables")[0];
-
- var columns = table.GetProperty("columns").EnumerateArray()
- .Select(c => c.GetProperty("name").GetString()!)
- .ToArray();
-
- int iTime = Array.IndexOf(columns, "TimeGenerated");
- int iResId = Array.IndexOf(columns, "ResourceId");
- int iOp = Array.IndexOf(columns, "OperationName");
- int iCorr = Array.IndexOf(columns, "CorrelationId");
- int iSource = Array.IndexOf(columns, "Source");
- int iProps = Array.IndexOf(columns, "Properties");
-
- var dataByRes = new Dictionary(StringComparer.OrdinalIgnoreCase);
- var rawHealthEvents = new Dictionary>(StringComparer.OrdinalIgnoreCase);
- int rowCount = 0;
-
- foreach (var row in table.GetProperty("rows").EnumerateArray())
- {
- rowCount++;
- string resId = row[iResId].GetString() ?? "";
- if (string.IsNullOrEmpty(resId)) continue;
-
- if (!dataByRes.TryGetValue(resId, out var entry))
- {
- entry = new LogAnalyticsResourceData();
- dataByRes[resId] = entry;
- }
-
- string timeStr = row[iTime].GetString() ?? "";
- if (!DateTimeOffset.TryParse(timeStr, CultureInfo.InvariantCulture,
- DateTimeStyles.AssumeUniversal, out var ts))
- continue;
- ts = ts.ToUniversalTime();
-
- string source = row[iSource].GetString() ?? "";
-
- if (source == "Activity")
- {
- entry.ActivityEvents.Add(new LogAnalyticsActivityEvent(
- ts,
- row[iOp].GetString() ?? "",
- iCorr >= 0 ? (row[iCorr].GetString() ?? "") : ""));
- }
- else if (source == "Health")
- {
- ParseHealthEvent(row, iOp, iProps, resId, ts, rawHealthEvents);
- }
- }
-
- // Post-process raw health events into clean incident-based transitions
- PostProcessHealthEvents(dataByRes, rawHealthEvents);
-
- Console.WriteLine($"Log Analytics: fetched {rowCount} events for {dataByRes.Count} resource(s)");
- return dataByRes;
- }
-
- private static void ParseHealthEvent(
- JsonElement row,
- int iOp,
- int iProps,
- string resId,
- DateTimeOffset ts,
- Dictionary> rawHealthEvents)
- {
- var propsEl = row[iProps];
-
- // Properties may arrive as a JSON string or as an already-parsed object
- JsonElement props;
- JsonDocument? propsDoc = null;
- try
- {
- if (propsEl.ValueKind == JsonValueKind.String)
- {
- var propsStr = propsEl.GetString();
- if (string.IsNullOrEmpty(propsStr)) return;
- propsDoc = JsonDocument.Parse(propsStr);
- props = propsDoc.RootElement;
- }
- else if (propsEl.ValueKind == JsonValueKind.Object)
- {
- props = propsEl;
- }
- else return;
-
- // Extract health state — newer events use 'currentHealthStatus',
- // older ones use 'availabilityState'
- string state = GetPropString(props, "currentHealthStatus");
- if (string.IsNullOrEmpty(state))
- state = GetPropString(props, "availabilityState");
-
- string rawCause = GetPropString(props, "cause");
-
- if (!string.IsNullOrEmpty(state))
- {
- // Determine incident lifecycle phase from OperationNameValue
- string opName = row[iOp].GetString() ?? "";
- string opType =
- opName.Contains("/Activated/", StringComparison.OrdinalIgnoreCase) ? "Activated" :
- opName.Contains("/Resolved/", StringComparison.OrdinalIgnoreCase) ? "Resolved" :
- opName.Contains("/InProgress/", StringComparison.OrdinalIgnoreCase) ? "InProgress" :
- "Updated";
-
- if (!rawHealthEvents.TryGetValue(resId, out var evtList))
- {
- evtList = [];
- rawHealthEvents[resId] = evtList;
- }
- evtList.Add(new RawHealthEvent(ts, state, rawCause, opType));
- }
- }
- finally
- {
- propsDoc?.Dispose();
- }
- }
-
- ///
- /// Consolidates raw LA health events into clean incident-based transitions.
- /// AzureActivity ResourceHealth events include lifecycle phases (Activated, Updated,
- /// InProgress, Resolved) for each health incident. This post-processing replicates the
- /// REST API's curated behaviour:
- /// 1. Only create transitions when the health state actually changes.
- /// 2. Track incidents (Activated/InProgress → Resolved) and collect the latest
- /// non-Unknown cause within each incident.
- /// 3. On Resolved, retroactively apply the final cause to all transitions in the incident.
- /// 4. Skip orphan Updated events outside any incident.
- ///
- private static void PostProcessHealthEvents(
- Dictionary dataByRes,
- Dictionary> rawHealthEvents)
- {
- foreach (var (resId, events) in rawHealthEvents)
- {
- if (!dataByRes.TryGetValue(resId, out var entry))
- continue;
-
- var sorted = events.OrderBy(e => e.Timestamp).ToList();
- string trackedState = "Available";
- int lastTransitionIdx = -1;
- bool inIncident = false;
- var incidentTransitionIndices = new List();
- string incidentCause = "";
-
- foreach (var evt in sorted)
- {
- // Open incident on Activated or InProgress
- if (evt.OperationType is "Activated" or "InProgress" && !inIncident)
- {
- inIncident = true;
- incidentTransitionIndices = [];
- incidentCause = "";
- }
-
- // Track the latest non-Unknown cause within the incident
- if (inIncident && !string.IsNullOrEmpty(evt.RawCause) && evt.RawCause != "Unknown")
- incidentCause = evt.RawCause;
-
- // Skip orphan Updated events (stale / out-of-incident)
- if (evt.OperationType is not ("Activated" or "InProgress" or "Resolved") && !inIncident)
- continue;
-
- // Only create a transition when the state actually changes
- if (!evt.State.Equals(trackedState, StringComparison.OrdinalIgnoreCase))
- {
- var (reasonType, context, healthEventCause) = MapCause(evt.RawCause);
- entry.HealthTransitions.Add(new HealthTransition(
- evt.Timestamp, evt.State, reasonType, context, healthEventCause));
- trackedState = evt.State;
- lastTransitionIdx = entry.HealthTransitions.Count - 1;
-
- // Track non-Available transitions for retroactive cause fix on Resolved
- if (inIncident && !evt.State.Equals("Available", StringComparison.OrdinalIgnoreCase))
- incidentTransitionIndices.Add(lastTransitionIdx);
- }
- else
- {
- // Same state — update cause on last transition if a more specific
- // cause arrived (e.g. Updated event reveals 'UserInitiated')
- if (lastTransitionIdx >= 0 && !string.IsNullOrEmpty(evt.RawCause) && evt.RawCause != "Unknown")
- {
- var last = entry.HealthTransitions[lastTransitionIdx];
- if (string.IsNullOrEmpty(last.ReasonType) || last.ReasonType == "Unknown")
- {
- var (reasonType, context, healthEventCause) = MapCause(evt.RawCause);
- entry.HealthTransitions[lastTransitionIdx] = new HealthTransition(
- last.OccurredOn, last.State, reasonType, context, healthEventCause);
- }
- }
- }
-
- // Close incident on Resolved — retroactively apply the final determined
- // cause to ALL transitions in this incident
- if (evt.OperationType == "Resolved")
- {
- if (!string.IsNullOrEmpty(incidentCause) && incidentCause != "Unknown"
- && incidentTransitionIndices.Count > 0)
- {
- var (reasonType, context, healthEventCause) = MapCause(incidentCause);
- foreach (int idx in incidentTransitionIndices)
- {
- var t = entry.HealthTransitions[idx];
- entry.HealthTransitions[idx] = new HealthTransition(
- t.OccurredOn, t.State, reasonType, context, healthEventCause);
- }
- }
- inIncident = false;
- incidentTransitionIndices = [];
- incidentCause = "";
- }
- }
-
- // Handle open incident at end of event stream — apply best known cause
- if (inIncident && incidentTransitionIndices.Count > 0
- && !string.IsNullOrEmpty(incidentCause) && incidentCause != "Unknown")
- {
- var (reasonType, context, healthEventCause) = MapCause(incidentCause);
- foreach (int idx in incidentTransitionIndices)
- {
- var t = entry.HealthTransitions[idx];
- entry.HealthTransitions[idx] = new HealthTransition(
- t.OccurredOn, t.State, reasonType, context, healthEventCause);
- }
- }
- }
- }
-
- /// Maps LA raw cause names to the REST API's multi-field format.
- private static (string ReasonType, string Context, string HealthEventCause) MapCause(string rawCause)
- => rawCause switch
- {
- "UserInitiated" => ("Customer Initiated", "Customer Initiated", "UserInitiated"),
- "PlatformInitiated" => ("Platform Initiated", "", ""),
- _ => ("", "", "")
- };
-
- private static string GetPropString(JsonElement props, string name)
- => props.TryGetProperty(name, out var el) ? el.GetString() ?? "" : "";
-
- private readonly record struct RawHealthEvent(
- DateTimeOffset Timestamp,
- string State,
- string RawCause,
- string OperationType);
-}
-
-/// Per-resource data fetched from Log Analytics.
-public sealed class LogAnalyticsResourceData
-{
- public List ActivityEvents { get; } = [];
- public List HealthTransitions { get; } = [];
-}
-
-/// A parsed Activity Log lifecycle event from Log Analytics.
-public sealed record LogAnalyticsActivityEvent(
- DateTimeOffset Timestamp,
- string OperationName,
- string CorrelationId);
diff --git a/Old/GetAvailability/Services/MetricsService.cs b/Old/GetAvailability/Services/MetricsService.cs
deleted file mode 100644
index d547136..0000000
--- a/Old/GetAvailability/Services/MetricsService.cs
+++ /dev/null
@@ -1,338 +0,0 @@
-using Azure.Monitor.Query;
-using Azure.Monitor.Query.Models;
-using GetAvailability.Models;
-using System.Collections.Concurrent;
-
-namespace GetAvailability.Services;
-
-///
-/// Fetches Azure Monitor metrics per resource in parallel and computes available minutes inline.
-/// Uses Parallel.ForEachAsync for concurrent metric API calls with configurable parallelism.
-/// Each resource's metrics are processed entirely within the parallel task — only scalar results
-/// (AvailableSum, GapMinutes, ZeroTxMin, DegradedMinutes) are returned, minimising cross-thread
-/// data and GC pressure. Suspect minutes are collected in three forms:
-/// - null datapoints (GapTicks)
-/// - exactly 0%-valued datapoints (ZeroAvailTicks)
-/// - positive datapoints below 100% (DegradedSamples)
-/// Resources are only flagged for N/A exclusion when the metric API returns no usable datapoints
-/// at all for the full window.
-///
-/// Web Apps use MemoryWorkingSet (Average, bytes) instead of an availability percentage:
-/// >0 = available (1.0), =0 = suspect zero, null = suspect null.
-///
-public static class MetricsService
-{
- ///
- /// Queries metrics for all resources in parallel. Returns a dictionary keyed by lowercase resource ID.
- /// Progress is reported via console overwrite (\r) every 10 resources.
- ///
- public static async Task> QueryAsync(
- MetricsQueryClient metricsClient,
- IReadOnlyList resources,
- DateTimeOffset startDate,
- DateTimeOffset endDate,
- int parallelism)
- {
- int total = resources.Count;
- Console.WriteLine($"Querying metrics for {total} resource(s) with parallelism {parallelism}...");
-
- var results = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
- int done = 0;
-
- await Parallel.ForEachAsync(resources,
- new ParallelOptions { MaxDegreeOfParallelism = parallelism },
- async (resource, ct) =>
- {
- var scalars = await QuerySingleResourceAsync(metricsClient, resource, startDate, endDate, ct);
- results[resource.ResourceId.ToLowerInvariant()] = scalars;
-
- int current = Interlocked.Increment(ref done);
- if (current % 10 == 0 || current == total)
- Console.Write($"\r [{current} / {total}] {resource.Name,-50}");
- });
-
- Console.WriteLine();
- return results;
- }
-
- ///
- /// Fetches metrics for a single resource with retry logic, then computes available minutes.
- /// Metrics requested per resource type:
- /// VM: VmAvailabilityMetric (0–1) — 1 metric
- /// SQL DB: Availability (0–100, normalised to 0–1) — 1 metric
- /// Storage: Availability (0–100) + Transactions — 2 metrics, 1 API call
- /// WebApp: MemoryWorkingSet (bytes, Average) — >0 = available (1.0), =0 = suspect zero
- /// Retries up to 5 times on 429 (throttle) and 5xx errors with exponential backoff.
- ///
- private static async Task QuerySingleResourceAsync(
- MetricsQueryClient metricsClient,
- TrackedResource resource,
- DateTimeOffset startDate,
- DateTimeOffset endDate,
- CancellationToken ct)
- {
- bool isVm = resource.Kind == "VirtualMachine";
- bool isStorage = resource.Kind == "StorageAccount";
- bool isWebApp = resource.Kind == "WebApp";
-
- string[] metricNames;
- if (isVm)
- metricNames = ["VmAvailabilityMetric"];
- else if (isStorage)
- metricNames = ["Availability", "Transactions"];
- else if (isWebApp)
- metricNames = ["MemoryWorkingSet"];
- else
- metricNames = ["Availability"];
-
- var options = new MetricsQueryOptions
- {
- Granularity = TimeSpan.FromMinutes(1),
- TimeRange = new QueryTimeRange(startDate, endDate),
- };
- if (isStorage)
- {
- options.Aggregations.Add(MetricAggregationType.Minimum);
- options.Aggregations.Add(MetricAggregationType.Total);
- }
- else if (isWebApp)
- {
- options.Aggregations.Add(MetricAggregationType.Average);
- }
- else
- {
- options.Aggregations.Add(MetricAggregationType.Minimum);
- }
-
- // Retry loop with exponential backoff for throttling (429) and transient server errors (5xx)
- MetricsQueryResult response;
- for (int attempt = 1; ; attempt++)
- {
- try
- {
- response = await metricsClient.QueryResourceAsync(
- resource.ResourceId, metricNames, options, ct);
- break;
- }
- catch (Azure.RequestFailedException ex) when (attempt < 5 &&
- (ex.Status == 429 || ex.Status >= 500))
- {
- await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 1 << attempt)), ct);
- }
- catch (Exception ex) when (attempt < 5 &&
- (ex.Message.Contains("transport", StringComparison.OrdinalIgnoreCase) ||
- ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) ||
- ex.Message.Contains("timed out", StringComparison.OrdinalIgnoreCase)))
- {
- await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 1 << attempt)), ct);
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($" WARNING: Metric query failed for '{resource.Name}': {ex.Message}");
- return default;
- }
- }
-
- double availSum = 0.0;
- int gapMinutes = 0;
- int zeroTxMin = 0;
- int degradedMinutes = 0;
- bool excludeFromAvailability = false;
- long[]? gapTicks = null;
- long[]? zeroAvailTicks = null;
- MetricValueSample[]? degradedSamples = null;
-
- if (isStorage)
- ProcessStorage(response, ref availSum, ref zeroTxMin, ref gapMinutes, ref degradedMinutes, out gapTicks, out zeroAvailTicks, out excludeFromAvailability, out degradedSamples);
- else if (isWebApp)
- ProcessWebApp(response, ref availSum, ref gapMinutes, out gapTicks, out zeroAvailTicks, out excludeFromAvailability);
- else
- ProcessVmOrSql(response, isVm, ref availSum, ref gapMinutes, ref degradedMinutes, out gapTicks, out zeroAvailTicks, out excludeFromAvailability, out degradedSamples);
-
- return new MetricScalars(availSum, gapMinutes, zeroTxMin, excludeFromAvailability, gapTicks, zeroAvailTicks, degradedMinutes, degradedSamples);
- }
-
- ///
- /// Processes Storage Account metrics. A storage minute is only counted toward availability
- /// if there were actual transactions (Transactions > 0). Minutes with zero transactions
- /// have no availability signal and are tracked as zeroTxMin — later subtracted from eligibility.
- /// Availability values are 0–100, normalised to 0.0–1.0.
- /// Null availability with transactions is tracked as a null suspect minute.
- /// Exactly 0% availability with transactions is tracked separately as a 0%-valued suspect minute.
- /// Non-zero values below 100% are tracked as degraded suspect minutes.
- ///
- private static void ProcessStorage(MetricsQueryResult response,
- ref double availSum, ref int zeroTxMin, ref int gapMinutes, ref int degradedMinutes,
- out long[]? gapTicks, out long[]? zeroAvailTicks, out bool excludeFromAvailability,
- out MetricValueSample[]? degradedSamples)
- {
- var nullTicks = new List();
- var zeroTicks = new List();
- var degraded = new List();
- int numericAvailabilityPoints = 0;
-
- // Build a lookup of transaction counts by minute (ticks)
- var txByTicks = new Dictionary();
- var txMetric = response.Metrics.FirstOrDefault(m =>
- string.Equals(m.Name, "Transactions", StringComparison.OrdinalIgnoreCase));
- if (txMetric != null)
- {
- foreach (var ts in txMetric.TimeSeries)
- foreach (var val in ts.Values)
- {
- if (val.Total.HasValue)
- txByTicks[val.TimeStamp.UtcTicks] = val.Total.Value;
- }
- }
-
- var availMetric = response.Metrics.FirstOrDefault(m =>
- string.Equals(m.Name, "Availability", StringComparison.OrdinalIgnoreCase));
- if (availMetric != null)
- {
- foreach (var ts in availMetric.TimeSeries)
- foreach (var val in ts.Values)
- {
- long ticks = val.TimeStamp.UtcTicks;
- bool hasTx = txByTicks.TryGetValue(ticks, out double txVal) && txVal > 0;
-
- if (hasTx && val.Minimum.HasValue)
- {
- double norm = val.Minimum.Value / 100.0;
- numericAvailabilityPoints++;
- if (norm == 0.0)
- {
- // Exactly 0% with transactions — tracked as a distinct suspect minute.
- zeroTicks.Add(ticks);
- }
- else
- {
- availSum += norm;
- if (norm < 1.0)
- {
- degradedMinutes++;
- degraded.Add(new MetricValueSample(ticks, norm));
- }
- }
- }
- else if (hasTx && !val.Minimum.HasValue)
- {
- // Transactions present but availability metric null — suspect minute.
- nullTicks.Add(ticks);
- }
- else if (!hasTx)
- {
- zeroTxMin++;
- }
- }
- }
-
- gapMinutes = nullTicks.Count + zeroTicks.Count;
- gapTicks = nullTicks.Count > 0 ? nullTicks.ToArray() : null;
- zeroAvailTicks = zeroTicks.Count > 0 ? zeroTicks.ToArray() : null;
- excludeFromAvailability = numericAvailabilityPoints == 0 && nullTicks.Count == 0 && zeroTicks.Count == 0 && degraded.Count == 0;
- degradedSamples = degraded.Count > 0 ? degraded.ToArray() : null;
- }
-
- ///
- /// Processes Web App metrics using MemoryWorkingSet (Average, bytes).
- /// A non-null value >0 means the app process is running (available = 1.0).
- /// A value of exactly 0 is suspect (stopped app). Null means no data (suspect null).
- /// Web Apps have no degraded state — they are either running or not.
- ///
- private static void ProcessWebApp(MetricsQueryResult response,
- ref double availSum, ref int gapMinutes,
- out long[]? gapTicks, out long[]? zeroAvailTicks, out bool excludeFromAvailability)
- {
- var nullTicks = new List();
- var zeroTicks = new List();
- int numericPoints = 0;
-
- foreach (var metric in response.Metrics)
- {
- if (!string.Equals(metric.Name, "MemoryWorkingSet", StringComparison.OrdinalIgnoreCase))
- continue;
-
- foreach (var ts in metric.TimeSeries)
- foreach (var val in ts.Values)
- {
- if (val.Average.HasValue)
- {
- numericPoints++;
- if (val.Average.Value > 0)
- availSum += 1.0;
- else
- zeroTicks.Add(val.TimeStamp.UtcTicks);
- }
- else
- {
- nullTicks.Add(val.TimeStamp.UtcTicks);
- }
- }
- }
-
- gapMinutes = nullTicks.Count + zeroTicks.Count;
- gapTicks = nullTicks.Count > 0 ? nullTicks.ToArray() : null;
- zeroAvailTicks = zeroTicks.Count > 0 ? zeroTicks.ToArray() : null;
- excludeFromAvailability = numericPoints == 0 && nullTicks.Count == 0 && zeroTicks.Count == 0;
- }
-
- ///
- /// Processes VM or SQL DB metrics. Null datapoints are collected as null suspect minutes and
- /// 0%-valued datapoints as zero-valued suspect minutes. Non-zero values below 100% are counted
- /// as degraded suspect minutes and tracked so lifecycle/customer explanations can later exclude
- /// them from eligibility and available-minute math.
- /// VM availability is 0.0–1.0 natively; SQL is 0–100, normalised to 0.0–1.0.
- ///
- private static void ProcessVmOrSql(MetricsQueryResult response,
- bool isVm, ref double availSum, ref int gapMinutes, ref int degradedMinutes,
- out long[]? gapTicks, out long[]? zeroAvailTicks, out bool excludeFromAvailability,
- out MetricValueSample[]? degradedSamples)
- {
- var nullTicks = new List();
- var zeroTicks = new List();
- var degraded = new List();
- int numericAvailabilityPoints = 0;
-
- foreach (var metric in response.Metrics)
- {
- bool isPrimary = string.Equals(metric.Name, "VmAvailabilityMetric", StringComparison.OrdinalIgnoreCase)
- || string.Equals(metric.Name, "Availability", StringComparison.OrdinalIgnoreCase);
- if (!isPrimary) continue;
-
- foreach (var ts in metric.TimeSeries)
- foreach (var val in ts.Values)
- {
- if (val.Minimum.HasValue)
- {
- numericAvailabilityPoints++;
- double v = val.Minimum.Value;
- if (!isVm) v /= 100.0; // SQL Availability is 0–100, normalise to 0–1
- if (v == 0.0)
- {
- // Exactly 0 — tracked as a distinct suspect minute.
- zeroTicks.Add(val.TimeStamp.UtcTicks);
- }
- else
- {
- availSum += v;
- if (v < 1.0)
- {
- degradedMinutes++;
- degraded.Add(new MetricValueSample(val.TimeStamp.UtcTicks, v));
- }
- }
- }
- else
- {
- nullTicks.Add(val.TimeStamp.UtcTicks);
- }
- }
- }
-
- gapMinutes = nullTicks.Count + zeroTicks.Count;
- gapTicks = nullTicks.Count > 0 ? nullTicks.ToArray() : null;
- zeroAvailTicks = zeroTicks.Count > 0 ? zeroTicks.ToArray() : null;
- excludeFromAvailability = numericAvailabilityPoints == 0 && nullTicks.Count == 0 && zeroTicks.Count == 0 && degraded.Count == 0;
- degradedSamples = degraded.Count > 0 ? degraded.ToArray() : null;
- }
-}
diff --git a/Old/GetAvailability/Services/ResourceHealthService.cs b/Old/GetAvailability/Services/ResourceHealthService.cs
deleted file mode 100644
index 23125de..0000000
--- a/Old/GetAvailability/Services/ResourceHealthService.cs
+++ /dev/null
@@ -1,558 +0,0 @@
-using Azure.Core;
-using GetAvailability.Models;
-using System.Collections.Concurrent;
-using System.Net.Http.Headers;
-using System.Text.Json;
-
-namespace GetAvailability.Services;
-
-///
-/// Investigates suspect availability minutes using Azure Activity Log and Resource Health.
-/// Suspect minutes are any metric datapoints that are null or below 100%.
-///
-/// Classification precedence is:
-/// 1. Platform fault from Resource Health wins over any other explanation.
-/// 2. Customer/admin lifecycle activity from Activity Log or customer-initiated
-/// health states excludes the minute from eligibility.
-/// 3. Unknown health states excuse null/0% suspect minutes as monitoring artifacts.
-/// 4. Remaining null minutes are treated as metric issues and excluded from eligibility.
-/// 5. Remaining 0% minutes are treated as downtime.
-/// 6. Remaining positive degraded datapoints stay as degraded availability.
-///
-/// When Log Analytics data is provided (hybrid mode), Activity Log events come from the
-/// pre-fetched LA data and Resource Health uses a hybrid merge: LA transitions older than
-/// the REST API's ~30-day retention cutoff are combined with REST API transitions (which
-/// are authoritative for the last ~30 days due to curated synthetic entries and retroactive
-/// cause correction). Without LA data, both sources use REST APIs directly.
-///
-public static class ResourceHealthService
-{
- ///
- /// For resources with suspect metric minutes, investigates null/0% suspect minutes plus
- /// positive degraded datapoints. Activity Log is gathered first for supported kinds, then
- /// Resource Health is consulted for the portion of the observation window still within the
- /// current retention window (full period when using Log Analytics hybrid mode, ~30 days otherwise).
- /// Returns a dictionary keyed by lowercase resource ID.
- ///
- public static async Task> InvestigateSuspectGapsAsync(
- TokenCredential credential,
- IReadOnlyList<(TrackedResource Res, long[] AllGapTicks, HashSet? ZeroTicks, MetricValueSample[]? DegradedSamples)> candidates,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd,
- int parallelism,
- int activityGraceMinutes,
- Dictionary? laData = null)
- {
- Console.WriteLine($"Investigating suspect gaps for {candidates.Count} resource(s)...");
-
- var token = await credential.GetTokenAsync(
- new TokenRequestContext(["https://management.azure.com/.default"]), default);
-
- using var http = new HttpClient();
- http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
-
- bool useLogAnalytics = laData is not null;
- var restCutoff = GetRestHealthCutoff();
-
- var results = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
- int done = 0;
- int total = candidates.Count;
-
- await Parallel.ForEachAsync(candidates,
- new ParallelOptions { MaxDegreeOfParallelism = parallelism },
- async (candidate, ct) =>
- {
- var (res, allGapTicks, zeroTicks, degradedSamples) = candidate;
- var resKey = res.ResourceId.ToLowerInvariant();
- LogAnalyticsResourceData? resLaData = null;
- laData?.TryGetValue(resKey, out resLaData);
-
- var classification = await ClassifySuspectGapsAsync(
- http,
- res,
- allGapTicks,
- zeroTicks,
- degradedSamples,
- periodStart,
- periodEnd,
- activityGraceMinutes,
- useLogAnalytics,
- resLaData,
- restCutoff,
- ct);
- results[resKey] = classification;
-
- int current = Interlocked.Increment(ref done);
- if (current % 10 == 0 || current == total)
- Console.Write($"\r [{current} / {total}] {res.Name,-50}");
- });
-
- Console.WriteLine();
- return results;
- }
-
- ///
- /// Fetches lifecycle activity and health history for a single resource, then classifies
- /// each suspect minute using the precedence documented on the class.
- /// When Log Analytics data is available, uses pre-fetched activity events and merges
- /// LA health transitions (pre-cutoff) with REST API transitions (post-cutoff).
- ///
- private static async Task ClassifySuspectGapsAsync(
- HttpClient http,
- TrackedResource resource,
- long[] allGapTicks,
- HashSet? zeroTicks,
- MetricValueSample[]? degradedSamples,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd,
- int activityGraceMinutes,
- bool useLogAnalytics,
- LogAnalyticsResourceData? resLaData,
- DateTimeOffset restCutoff,
- CancellationToken ct)
- {
- var activityIntervals = new List<(DateTimeOffset From, DateTimeOffset To)>();
- if (allGapTicks.Length > 0 || (degradedSamples?.Length ?? 0) > 0)
- {
- try
- {
- if (resLaData is not null && resLaData.ActivityEvents.Count > 0)
- {
- // Log Analytics path: use pre-fetched events
- activityIntervals = ActivityLogService.BuildLifecycleIntervalsFromEvents(
- resLaData.ActivityEvents,
- resource.Kind,
- periodStart,
- periodEnd,
- activityGraceMinutes);
- }
- else if (!useLogAnalytics)
- {
- // REST API path (no -Workspace)
- activityIntervals = await ActivityLogService.BuildLifecycleIntervalsAsync(
- http,
- resource,
- periodStart,
- periodEnd,
- activityGraceMinutes,
- ct);
- }
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($" WARNING: Activity Log query failed for '{resource.Name}': {ex.Message}");
- }
- }
-
- var healthCoverageStart = GetHealthCoverageStart(periodStart, useLogAnalytics);
- bool healthHistoryApplied = healthCoverageStart < periodEnd;
-
- List transitions = [];
- if (healthHistoryApplied)
- {
- try
- {
- // In hybrid mode, add LA health transitions older than the REST cutoff
- if (resLaData is not null && resLaData.HealthTransitions.Count > 0)
- {
- foreach (var ht in resLaData.HealthTransitions)
- {
- if (ht.OccurredOn < restCutoff)
- transitions.Add(ht);
- }
- }
-
- // REST API health transitions: sole source without -Workspace;
- // covers last ~30 days with curated authoritative data in hybrid mode.
- var restTransitions = await FetchHealthHistoryAsync(http, resource.ResourceId, ct);
- transitions.AddRange(restTransitions);
-
- // Sort chronologically (REST returns newest-first → already reversed,
- // but merge with LA data requires re-sorting)
- transitions.Sort((a, b) => a.OccurredOn.CompareTo(b.OccurredOn));
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($" WARNING: Resource Health query failed for '{resource.Name}': {ex.Message}");
- // Conservative fallback: keep Activity Log results, but do not excuse any
- // remaining suspect minutes via Resource Health when the query fails.
- transitions = [];
- }
- }
-
- var faultIntervals = healthHistoryApplied
- ? BuildFaultIntervals(transitions, healthCoverageStart, periodEnd)
- : [];
- var unknownIntervals = healthHistoryApplied
- ? BuildUnknownIntervals(transitions, healthCoverageStart, periodEnd)
- : [];
- var customerIntervals = healthHistoryApplied
- ? BuildCustomerIntervals(transitions, healthCoverageStart, periodEnd)
- : [];
-
- int platformFaultGapMin = 0;
- int unresolvedZeroDowntimeMin = 0;
- int healthExplainedGapMin = 0;
- int metricIssueNullMin = 0;
- int activityLogExcludedGapMin = 0;
- int customerExcusedDegradedMin = 0;
- double customerExcusedDegradedAvail = 0;
- int activityLogDegradedMin = 0;
- int healthConfirmedDegradedMin = 0;
-
- foreach (long tick in allGapTicks)
- {
- bool isZero = zeroTicks is not null && zeroTicks.Contains(tick);
- bool inActivity = IsInInterval(tick, activityIntervals);
- bool inHealthCoverage = healthHistoryApplied && tick >= healthCoverageStart.UtcTicks;
- bool inFault = inHealthCoverage && IsInInterval(tick, faultIntervals);
- bool inUnknown = inHealthCoverage && IsInInterval(tick, unknownIntervals);
- bool inCustomer = inHealthCoverage && IsInInterval(tick, customerIntervals);
-
- if (inFault)
- {
- platformFaultGapMin++;
- }
- else if (inActivity)
- {
- activityLogExcludedGapMin++;
- }
- else if (inCustomer || inUnknown)
- {
- healthExplainedGapMin++;
- }
- else if (isZero)
- {
- unresolvedZeroDowntimeMin++;
- }
- else
- {
- metricIssueNullMin++;
- }
- }
-
- if (degradedSamples is not null)
- {
- foreach (var sample in degradedSamples)
- {
- bool inActivity = IsInInterval(sample.Tick, activityIntervals);
- bool inHealthCoverage = healthHistoryApplied && sample.Tick >= healthCoverageStart.UtcTicks;
- bool inFault = inHealthCoverage && IsInInterval(sample.Tick, faultIntervals);
- bool inCustomer = inHealthCoverage && IsInInterval(sample.Tick, customerIntervals);
-
- if (inFault)
- {
- healthConfirmedDegradedMin++;
- continue;
- }
-
- if (inActivity || inCustomer)
- {
- customerExcusedDegradedMin++;
- customerExcusedDegradedAvail += sample.Value;
- if (inActivity)
- activityLogDegradedMin++;
- }
- }
- }
-
- return new SuspectGapClassification(
- healthHistoryApplied,
- activityLogExcludedGapMin,
- healthExplainedGapMin,
- metricIssueNullMin,
- platformFaultGapMin,
- unresolvedZeroDowntimeMin,
- customerExcusedDegradedMin,
- customerExcusedDegradedAvail,
- activityLogDegradedMin,
- healthConfirmedDegradedMin);
- }
-
- ///
- /// Returns the effective start of the Resource Health coverage window.
- /// When using Log Analytics (hybrid mode), coverage extends to periodStart.
- /// Without Log Analytics, coverage is limited to ~30 days.
- ///
- public static DateTimeOffset GetHealthCoverageStart(DateTimeOffset periodStart, bool useLogAnalytics = false)
- {
- if (useLogAnalytics) return periodStart;
- var now = DateTimeOffset.UtcNow;
- var currentMinute = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, TimeSpan.Zero);
- var retentionStart = currentMinute.AddDays(-30);
- return retentionStart > periodStart ? retentionStart : periodStart;
- }
-
- ///
- /// Returns the REST API Resource Health retention cutoff (~30 days from now).
- /// In hybrid mode, LA transitions older than this cutoff are kept and REST
- /// transitions supplement the last ~30 days with authoritative curated data.
- ///
- public static DateTimeOffset GetRestHealthCutoff()
- {
- var now = DateTimeOffset.UtcNow;
- var currentMinute = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, TimeSpan.Zero);
- return currentMinute.AddDays(-30);
- }
-
- private static bool IsInInterval(long tick, List<(DateTimeOffset From, DateTimeOffset To)> intervals)
- {
- foreach (var (from, to) in intervals)
- {
- if (tick >= from.UtcTicks && tick < to.UtcTicks)
- return true;
- }
- return false;
- }
-
- ///
- /// Calls the Resource Health REST API to list availability statuses for a resource.
- /// GET {resourceUri}/providers/Microsoft.ResourceHealth/availabilityStatuses?api-version=2025-05-01
- /// Returns transitions ordered by OccurredOn ascending.
- /// Parses multiple fields for robust customer-vs-platform classification:
- /// - availabilityState: Available | Unavailable | Degraded | Unknown
- /// - reasonType: "Customer Initiated", "User Initiated", "Unplanned", "Planned", etc.
- /// - context: "Customer Initiated" or "Platform Initiated"
- /// - healthEventCause: "UserInitiated" or "PlatformInitiated"
- /// Retries up to 5 times on 429/5xx with exponential backoff.
- ///
- private static async Task> FetchHealthHistoryAsync(
- HttpClient http,
- string resourceId,
- CancellationToken ct)
- {
- var transitions = new List();
-
- string? url = $"https://management.azure.com{resourceId}" +
- "/providers/Microsoft.ResourceHealth/availabilityStatuses" +
- "?api-version=2025-05-01";
-
- while (url != null)
- {
- string json = await GetWithRetryAsync(http, url, ct);
- using var doc = JsonDocument.Parse(json);
-
- if (doc.RootElement.TryGetProperty("value", out var value) &&
- value.ValueKind == JsonValueKind.Array)
- {
- foreach (var item in value.EnumerateArray())
- {
- if (!item.TryGetProperty("properties", out var props))
- continue;
-
- string? occurredStr = props.TryGetProperty("occuredTime", out var occurredEl)
- ? occurredEl.GetString() : null;
-
- if (occurredStr is null || !DateTimeOffset.TryParse(occurredStr, out var occurred))
- continue;
-
- transitions.Add(new HealthTransition(
- occurred.ToUniversalTime(),
- GetPropString(props, "availabilityState"),
- GetPropString(props, "reasonType"),
- GetPropString(props, "context"),
- GetPropString(props, "healthEventCause")));
- }
- }
-
- url = doc.RootElement.TryGetProperty("nextLink", out var next) &&
- next.ValueKind == JsonValueKind.String
- ? next.GetString()
- : null;
- }
-
- // API returns newest-first; reverse to chronological order
- transitions.Reverse();
- return transitions;
- }
-
- /// Reads a string property from a JsonElement, returning empty if absent or null.
- private static string GetPropString(System.Text.Json.JsonElement props, string name)
- => props.TryGetProperty(name, out var el) ? el.GetString() ?? "" : "";
-
- ///
- /// HTTP GET with retry on 429 and 5xx errors. Uses Retry-After header when available,
- /// otherwise exponential backoff.
- ///
- private static async Task GetWithRetryAsync(HttpClient http, string url, CancellationToken ct)
- {
- for (int attempt = 0; ; attempt++)
- {
- using var response = await http.GetAsync(url, ct);
-
- if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests ||
- (int)response.StatusCode >= 500)
- {
- if (attempt >= 5) response.EnsureSuccessStatusCode();
- var delay = response.Headers.RetryAfter?.Delta
- ?? TimeSpan.FromSeconds(1 << attempt);
- await Task.Delay(delay, ct);
- continue;
- }
-
- response.EnsureSuccessStatusCode();
- return await response.Content.ReadAsStringAsync(ct);
- }
- }
-
- ///
- /// Builds fault intervals from a chronologically-ordered list of health transitions.
- /// Only Unavailable and Degraded states open a fault interval. Non-fault states that
- /// close any open interval:
- /// - Available: resource confirmed healthy
- /// - Unknown: Azure cannot determine health (typically an Azure Monitor issue)
- /// - Customer-initiated: detected via reasonType, context, or healthEventCause fields
- /// If still faulted at the end, the interval extends to periodEnd.
- ///
- private static List<(DateTimeOffset From, DateTimeOffset To)> BuildFaultIntervals(
- List transitions,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd)
- {
- var intervals = new List<(DateTimeOffset, DateTimeOffset)>();
- DateTimeOffset? faultStart = null;
-
- foreach (var t in transitions)
- {
- bool isAvailable = t.State.Equals("Available", StringComparison.OrdinalIgnoreCase);
- bool isUnknown = t.State.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
- bool isCustomer = IsCustomerInitiated(t);
-
- // Non-fault states that close any open fault interval:
- // - Available: resource confirmed healthy
- // - Unknown: Azure cannot determine health (typically Azure Monitor issue, not a real fault)
- // - Customer-initiated: stop/deallocate/restart — gap minutes subtracted from eligible
- // Only Unavailable and Degraded open fault intervals.
- bool isFault = !isAvailable && !isUnknown && !isCustomer;
-
- if (isFault && faultStart is null)
- {
- faultStart = t.OccurredOn < periodStart ? periodStart : t.OccurredOn;
- }
- else if (!isFault && faultStart is not null)
- {
- var end = t.OccurredOn > periodEnd ? periodEnd : t.OccurredOn;
- if (end > faultStart.Value)
- intervals.Add((faultStart.Value, end));
- faultStart = null;
- }
- }
-
- if (faultStart is not null)
- intervals.Add((faultStart.Value, periodEnd));
-
- return intervals;
- }
-
- ///
- /// Builds intervals where the resource was in Unknown state (Azure Monitor issues).
- /// 0% metric values during these intervals are treated as monitoring artifacts, not real faults.
- ///
- private static List<(DateTimeOffset From, DateTimeOffset To)> BuildUnknownIntervals(
- List transitions,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd)
- {
- var intervals = new List<(DateTimeOffset, DateTimeOffset)>();
- DateTimeOffset? unknownStart = null;
-
- foreach (var t in transitions)
- {
- bool isUnknown = t.State.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
-
- if (isUnknown && unknownStart is null)
- {
- unknownStart = t.OccurredOn < periodStart ? periodStart : t.OccurredOn;
- }
- else if (!isUnknown && unknownStart is not null)
- {
- var end = t.OccurredOn > periodEnd ? periodEnd : t.OccurredOn;
- if (end > unknownStart.Value)
- intervals.Add((unknownStart.Value, end));
- unknownStart = null;
- }
- }
-
- if (unknownStart is not null)
- intervals.Add((unknownStart.Value, periodEnd));
-
- return intervals;
- }
-
- ///
- /// Builds intervals caused by customer/user activity. Any non-perfect datapoint inside
- /// these windows is excluded from eligibility because it reflects a deliberate action,
- /// not platform downtime.
- ///
- private static List<(DateTimeOffset From, DateTimeOffset To)> BuildCustomerIntervals(
- List transitions,
- DateTimeOffset periodStart,
- DateTimeOffset periodEnd)
- {
- var intervals = new List<(DateTimeOffset, DateTimeOffset)>();
- DateTimeOffset? customerStart = null;
-
- foreach (var t in transitions)
- {
- bool isCustomer = IsCustomerInitiated(t);
-
- if (isCustomer && customerStart is null)
- {
- customerStart = t.OccurredOn < periodStart ? periodStart : t.OccurredOn;
- }
- else if (!isCustomer && customerStart is not null)
- {
- var end = t.OccurredOn > periodEnd ? periodEnd : t.OccurredOn;
- if (end > customerStart.Value)
- intervals.Add((customerStart.Value, end));
- customerStart = null;
- }
- }
-
- if (customerStart is not null)
- intervals.Add((customerStart.Value, periodEnd));
-
- return intervals;
- }
-
- ///
- /// Determines whether a health transition was caused by a customer/user action
- /// using multiple API fields for robust detection. Any positive signal is sufficient.
- ///
- private static bool IsCustomerInitiated(HealthTransition t)
- => t.ReasonType.Equals("Customer Initiated", StringComparison.OrdinalIgnoreCase)
- || t.ReasonType.Equals("User Initiated", StringComparison.OrdinalIgnoreCase)
- || t.Context.Equals("Customer Initiated", StringComparison.OrdinalIgnoreCase)
- || t.HealthEventCause.Equals("UserInitiated", StringComparison.OrdinalIgnoreCase);
-}
-
-/// Parsed health status transition from the Resource Health API or Log Analytics.
-public readonly record struct HealthTransition(
- DateTimeOffset OccurredOn,
- string State,
- string ReasonType,
- string Context,
- string HealthEventCause);
-
-/// Result of investigating suspect metric minutes for a resource.
-/// Whether Resource Health was available for any part of the observation window.
-/// Null/0% suspect minutes excused by supported lifecycle operations in Activity Log.
-/// Null/0% suspect minutes excused by Resource Health Unknown or customer-initiated windows.
-/// Remaining null suspect minutes treated as metric issues and removed from eligibility.
-/// Null/0% suspect minutes confirmed as platform issues by Resource Health fault intervals.
-/// Remaining 0% suspect minutes trusted as downtime because no valid explanation was found.
-/// Positive degraded datapoints excused by lifecycle activity or customer-initiated health windows.
-/// Fractional available minutes contributed by customer-excused degraded datapoints and removed from AvailableMinutes.
-/// Subset of CustomerExcusedDegradedMinutes explained specifically by supported Activity Log lifecycle operations.
-/// Positive degraded datapoints confirmed as platform issues by Resource Health fault intervals.
-public readonly record struct SuspectGapClassification(
- bool HealthHistoryApplied,
- int ActivityLogExcludedGapMinutes,
- int HealthExplainedGapMinutes,
- int MetricIssueNullMinutes,
- int PlatformFaultGapMinutes,
- int UnresolvedZeroDowntimeMinutes,
- int CustomerExcusedDegradedMinutes,
- double CustomerExcusedDegradedAvailableSum,
- int ActivityLogDegradedMinutes,
- int HealthConfirmedDegradedMinutes);
diff --git a/Old/GetAvailability/Services/ResourceInventoryService.cs b/Old/GetAvailability/Services/ResourceInventoryService.cs
deleted file mode 100644
index 20e8273..0000000
--- a/Old/GetAvailability/Services/ResourceInventoryService.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using Azure.ResourceManager;
-using Azure.ResourceManager.ResourceGraph;
-using Azure.ResourceManager.ResourceGraph.Models;
-using GetAvailability.Models;
-using System.Text.Json;
-
-namespace GetAvailability.Services;
-
-///
-/// Queries the Resource Graph resources table for VMs, SQL DBs, Storage Accounts,
-/// and Web Apps (excluding Function Apps which share the microsoft.web/sites type).
-///
-public static class ResourceInventoryService
-{
- /// Maps CLI kind abbreviations to Azure Resource Graph type identifiers.
- private static readonly Dictionary KindToType = new(StringComparer.OrdinalIgnoreCase)
- {
- ["vm"] = "microsoft.compute/virtualmachines",
- ["sql"] = "microsoft.sql/servers/databases",
- ["storage"] = "microsoft.storage/storageaccounts",
- ["webapp"] = "microsoft.web/sites",
- };
-
- ///
- /// Builds and executes the inventory KQL query with server-side kind and resource name filters.
- ///
- public static async Task> QueryAsync(
- ArmClient client, string[] subscriptionIds, Dictionary subIdToName,
- string[] kinds, string? resourceName)
- {
- var unsupportedKinds = kinds
- .Where(k => !KindToType.ContainsKey(k))
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
- .ToArray();
- if (unsupportedKinds.Length > 0)
- throw new ArgumentException($"Unsupported kind(s): {string.Join(", ", unsupportedKinds)}. Allowed values: vm, sql, storage, webapp.");
-
- // Build the type filter clause from selected kinds
- var types = kinds
- .Select(k => KindToType.TryGetValue(k, out var t) ? t : null)
- .Where(t => t != null)
- .ToArray();
- if (types.Length == 0)
- throw new ArgumentException("At least one supported kind must be specified.");
-
- string typeFilter = types.Length == 1
- ? $"type =~ '{types[0]}'"
- : string.Join(" or ", types.Select(t => $"type =~ '{t}'"));
-
- string escapedResourceName = resourceName?.Replace("'", "''", StringComparison.Ordinal) ?? "";
- string nameFilter = resourceName != null
- ? $"| where displayName =~ '{escapedResourceName}' or name =~ '{escapedResourceName}'\n"
- : "";
-
- string query = $"""
- resources
- | where {typeFilter}
- | where not(type =~ 'microsoft.web/sites' and kind contains 'functionapp')
- | extend idParts = split(id, '/')
- | extend sqlServerName = iff(type =~ 'microsoft.sql/servers/databases', tostring(idParts[8]), '')
- | extend databaseName = iff(type =~ 'microsoft.sql/servers/databases', tostring(idParts[10]), '')
- | where not(type =~ 'microsoft.sql/servers/databases' and databaseName =~ 'master')
- | extend displayName = iff(type =~ 'microsoft.sql/servers/databases', strcat(sqlServerName, '/', databaseName), name)
- {nameFilter}
- | extend resourceKind = case(
- type =~ 'microsoft.compute/virtualmachines', 'VirtualMachine',
- type =~ 'microsoft.sql/servers/databases', 'AzureSqlDatabase',
- type =~ 'microsoft.storage/storageaccounts', 'StorageAccount',
- type =~ 'microsoft.web/sites', 'WebApp',
- 'Other'
- )
- | project id, name, displayName, type, subscriptionId, resourceGroup, location, resourceKind,
- sqlServerName, databaseName
- """;
-
- var resources = new List();
- string? skipToken = null;
-
- do
- {
- var content = new ResourceQueryContent(query)
- {
- Options = new ResourceQueryRequestOptions { ResultFormat = ResultFormat.ObjectArray }
- };
- if (skipToken != null) content.Options.SkipToken = skipToken;
- foreach (var id in subscriptionIds) content.Subscriptions.Add(id);
-
- var tenant = client.GetTenants().First();
- var response = await tenant.GetResourcesAsync(content);
- var result = response.Value;
-
- // Parse JSON response using System.Text.Json (AOT-safe, no reflection)
- using var doc = JsonDocument.Parse(result.Data);
- var data = doc.RootElement;
- if (data.ValueKind == JsonValueKind.Array)
- {
- foreach (var row in data.EnumerateArray())
- {
- var kind = row.GetProperty("resourceKind").GetString()!;
- var subId = row.GetProperty("subscriptionId").GetString()!;
-
- string name = row.GetProperty("displayName").GetString() ?? row.GetProperty("name").GetString()!;
-
- resources.Add(new TrackedResource
- {
- Name = name,
- Kind = kind,
- ResourceId = row.GetProperty("id").GetString()!,
- SubscriptionId = subId,
- SubscriptionName = subIdToName.TryGetValue(subId, out var sn) ? sn : subId,
- ResourceGroupName = row.GetProperty("resourceGroup").GetString()!,
- Location = row.GetProperty("location").GetString()!,
- });
- }
- }
-
- skipToken = result.SkipToken;
- } while (!string.IsNullOrEmpty(skipToken));
-
- resources.Sort((a, b) =>
- {
- int c = string.Compare(a.SubscriptionName, b.SubscriptionName, StringComparison.OrdinalIgnoreCase);
- if (c != 0) return c;
- c = string.Compare(a.Kind, b.Kind, StringComparison.OrdinalIgnoreCase);
- return c != 0 ? c : string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
- });
-
- return resources;
- }
-}
diff --git a/Old/GetAvailability/Services/SubscriptionResolver.cs b/Old/GetAvailability/Services/SubscriptionResolver.cs
deleted file mode 100644
index fc760e4..0000000
--- a/Old/GetAvailability/Services/SubscriptionResolver.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using Azure.ResourceManager;
-using Azure.ResourceManager.Resources;
-
-namespace GetAvailability.Services;
-
-/// Resolves Azure subscription display names to subscription IDs.
-public static class SubscriptionResolver
-{
- ///
- /// Lists all subscriptions visible to the authenticated identity, then matches each
- /// requested display name. Throws if a name isn't found or matches more than one.
- ///
- public static async Task> ResolveAsync(
- ArmClient client, string[] subscriptionNames)
- {
- var allSubs = new List();
- await foreach (var sub in client.GetSubscriptions().GetAllAsync())
- allSubs.Add(sub);
-
- var resolved = new List<(string Id, string Name)>();
- foreach (var name in subscriptionNames)
- {
- var matches = allSubs.Where(s =>
- string.Equals(s.Data.DisplayName, name, StringComparison.OrdinalIgnoreCase)).ToList();
-
- if (matches.Count == 0)
- throw new ArgumentException($"Subscription '{name}' not found.");
- if (matches.Count > 1)
- throw new ArgumentException($"Multiple subscriptions named '{name}'.");
-
- var sub = matches[0];
- resolved.Add((sub.Data.SubscriptionId!, sub.Data.DisplayName!));
- }
- return resolved;
- }
-}
diff --git a/Old/README.md b/Old/README.md
deleted file mode 100644
index 5973ade..0000000
--- a/Old/README.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Get-Availability — C# Version
-
-Native AOT implementation of Get-Availability using .NET 10. Produces a ~15 MB standalone binary with no runtime dependency.
-
-For the full pipeline description, classification rules, output format, and invariants shared with the PowerShell version, see the [main README](../README.md).
-
-## Prerequisites
-
-| Requirement | Detail |
-|---|---|
-| .NET SDK | 10.0 or later (build from source only) |
-| Azure auth | `az login` or any method supported by `DefaultAzureCredential` |
-
-The published binary (`GetAvailability.exe`) requires no .NET runtime — it is a Native AOT self-contained executable.
-
-If Azure authentication fails, the tool prints the SDK exception message directly. Re-run `az login` to fix.
-
-## Parameters
-
-| Option | Short | Default | Description |
-|---|---|---|---|
-| `--subscriptions` | `-s` | *(required)* | One or more Azure subscription display names |
-| `--month` | `-m` | *(required)* | Observation month in UTC, format `YYYYMM` |
-| `--kinds` | `-k` | `vm sql storage webapp` | Resource kinds to process |
-| `--resource` | `-r` | *(all)* | Filter to a single resource name |
-| `--parallelism` | `-p` | *(auto)* | Max concurrent API calls (scales to CPU cores) |
-| `--activity-grace-minutes` | `-g` | `10` | Post-operation grace window for Activity Log lifecycle events |
-| `--batch` | `-b` | off | Use the regional Metrics Batch API instead of per-resource calls |
-| `--batch-size` | | `10` | Max resources per batch call (1–50); implies `--batch` |
-| `--workspace` | `-w` | *(none)* | Log Analytics workspace ID (GUID). Fetches Activity Log via bulk KQL; Resource Health uses hybrid approach (KQL for older + REST for last ~30 days) |
-| `--version` | `-v` | | Print version and exit |
-
-## Build
-
-```bash
-cd Old/GetAvailability
-
-# Debug (JIT, for development)
-dotnet build
-
-# Release Native AOT binary
-dotnet publish -c Release -r win-x64 # output in bin/Release/net10.0/win-x64/publish/
-```
-
-## Examples
-
-```bash
-# Single subscription
-./GetAvailability --subscriptions Contoso-Production --month 202603
-
-# Multiple subscriptions, filtered by kind
-./GetAvailability --subscriptions Contoso-Development Contoso-Production --month 202603 --kinds vm sql
-
-# Single resource (SQL database by server/database name)
-./GetAvailability --subscriptions Contoso-Development --month 202603 --resource sqlserver01/sqldb01
-
-# Batch API with custom batch size
-./GetAvailability --subscriptions Contoso-Production Contoso-Development --month 202603 --batch-size 20
-
-# Use Log Analytics for Activity Log + Resource Health (faster, extended retention)
-./GetAvailability --subscriptions Contoso-Production --month 202603 --workspace b233a4b7-3c43-433c-ac60-1f6ff217ddd4
-
-# Run directly without publishing
-cd Old/GetAvailability
-dotnet run -- --subscriptions Contoso-Production --month 202603
-```
-
-## Implementation Notes
-
-- **`Parallel.ForEachAsync`** for concurrent metric, Activity Log, and Resource Health queries with configurable parallelism.
-- **`System.Text.Json`** for AOT-safe, efficient JSON parsing.
-- **Native AOT** — ~15 MB standalone binary, no .NET runtime required.
-- **O(1) JSON property access** — `TryGetProperty` hash lookup instead of `EnumerateObject` linear scan (~44k calls per resource per month).
-- **Ticks-based metric keying** — `long` instead of `DateTime` for zero-allocation per data point.
-- **HashSet-based interval containment** — suspect-minute classification pre-expands intervals into `HashSet` tick sets for O(1) lookups instead of linear scans.
-
-> **Note:** The C# version does not currently support the Log Analytics ingestion feature (`-DceEndpoint`/`-DcrImmutableId`). Use the PowerShell version for that capability.
diff --git a/README.md b/README.md
index 43d3a8e..45cada2 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Get-Availability reports month-scoped availability for Azure Virtual Machines, Azure SQL Databases, Azure Storage Accounts, and Azure Web Apps across one or more Azure subscriptions.
-It runs either as a standalone PowerShell 7 script or as a timer-triggered Azure Function. Log Analytics ingestion is optional. The legacy C# implementation is preserved in [Old/README.md](Old/README.md) and is not actively maintained.
+It runs either as a standalone PowerShell 7.6 script or as a timer-triggered Azure Function. Log Analytics ingestion is optional.
For each resource, the tool reports:
@@ -23,7 +23,7 @@ The relationship `Suspect = Faults + Excused + Unresolved` always holds.
| Requirement | Detail |
|---|---|
-| PowerShell | 7.0 or later (`pwsh`) |
+| PowerShell | 7.6 or later (`pwsh`) |
| Az.Accounts | `Install-Module Az.Accounts` |
| Az.ResourceGraph | `Install-Module Az.ResourceGraph` |
| Azure sign-in | `Connect-AzAccount` |
@@ -32,7 +32,7 @@ The relationship `Suspect = Faults + Excused + Unresolved` always holds.
| Parameter | Default | Purpose |
|---|---|---|
-| `-Subscriptions` | required | Azure subscription names or IDs to inspect |
+| `-Subscriptions` | required | Azure subscription display names to inspect |
| `-Month` | required | Observation month in UTC, format `YYYYMM` |
| `-Kinds` | `vm,sql,storage,webapp` | Resource kinds to include |
| `-Resource` | all | Limit the run to one resource |
@@ -160,6 +160,8 @@ See [Bicep/parameters.dev.bicepparam](Bicep/parameters.dev.bicepparam) for a com
| `dnsZonesResourceGroupName` | Resource group containing shared private DNS zones |
| `getavailSubscriptions` | Comma-separated subscriptions to monitor |
| `getavailKinds` | Resource kinds to monitor |
+| `getavailBatch` | Use regional Metrics Batch API requests (default: `true`) |
+| `getavailBatchSize` | Maximum resources per batch request (default: `10`) |
| `sourceWorkspaceId` | Optional source workspace for Activity Log and Resource Health history |
| `timerSchedule` | CRON expression for the timer trigger |