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 |