Private/ConvertTo-AACServiceBusAssessment.ps1
|
function ConvertTo-AACServiceBusAssessment { <# .SYNOPSIS Turns what Invoke-AACServiceBusAssessment read into per-namespace health, capacity, messages, posture and resilience, per-entity message counts, findings and a heat map. .DESCRIPTION No Azure calls. Per namespace (AAC.ServiceBusAssessment): health over the period: IncomingRequests, SuccessfulRequests, ServerErrors, UserErrors, ThrottledRequests, IncomingMessages, OutgoingMessages, ErrorRate capacity Premium: CpuMax and MemoryMax (% of the messaging units) messages the entities' counts now: Queues, Topics, Subscriptions, ActiveMessages, DeadLetters, Scheduled, SizeMB, FullestPct posture Tier, MessagingUnits, MinTls, LocalAuth (SAS keys allowed), Network (Private, Firewall, Public), PrivateEndpoints, Encryption, Identity, ManageKeys (namespace-wide SAS rules with Manage rights), LogsTo resilience ZoneRedundant, GeoDr (an alias, or geo-replication) Per queue, topic and subscription (AAC.ServiceBusEntity): status, active, dead-lettered, transfer dead-lettered and scheduled messages, size against its maximum, delivery and lock settings, when it was last used. Findings (AAC.ServiceBusFinding: Severity, Namespace, Entity, Signal, Finding, Detail, Recommendation) - Signal is the heat map's column: Health, Capacity, Messages, Security, Resilience, Cost. Returns @{ Namespaces; Entities; Findings; HeatMap; Messages; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Namespace, # Read-AACServiceBusEntity's result, by namespace ID. [hashtable] $Entity = @{}, # Read-AACNetworkHealthMetric's result (Core, Premium), by namespace ID. [hashtable] $Metric = @{}, # Workspace resource ID (lower case) -> name. [hashtable] $WorkspaceName = @{}, [hashtable] $SubscriptionName = @{}, [datetime] $Now = [datetime]::UtcNow ) $at = { param($Row, [string[]] $Keys) $value = $Row foreach ($key in $Keys) { if ($value -isnot [System.Collections.IDictionary]) { return $null } if ($value.Contains($key)) { $value = $value[$key]; continue } $found = $null foreach ($name in $value.Keys) { if ($name -eq $key) { $found = $value[$name]; break } } $value = $found } $value } $sum = { param([object[]] $Points, [string] $Field) $t = 0.0; foreach ($p in $Points) { $v = & $at $p $Field; if ($null -ne $v) { $t += [double]$v } }; $t } $peak = { param([object[]] $Points, [string] $Field) $m = $null; foreach ($p in $Points) { $v = & $at $p $Field; if ($null -ne $v -and ($null -eq $m -or [double]$v -gt $m)) { $m = [double]$v } }; $m } # Measure-Object returns nothing for no input (and .Sum then fails under StrictMode): this returns 0. $sumOf = { param([object[]] $Rows, [string] $Property) $t = 0.0; foreach ($row in $Rows) { if ($null -ne $row.$Property) { $t += [double]$row.$Property } }; $t } $round = { param($Value, [int] $Digits = 1) if ($null -eq $Value) { $null } else { [Math]::Round([double]$Value, $Digits) } } $subName = { param([string] $Id) if ($SubscriptionName.Contains($Id)) { $SubscriptionName[$Id] } else { $Id } } $call = { param([string] $Id, [string] $Key) if ($Metric.Contains($Id) -and $Metric[$Id].Contains($Key)) { $Metric[$Id][$Key] } else { $null } } $pointsOf = { param($Answer, [string] $Name) @(if ($Answer -and -not $Answer.Error) { foreach ($s in $Answer.Series) { if ($s.Name -eq $Name) { $s.Points } } }) } $itemsOf = { param($Answer) @(if ($Answer -and -not $Answer['Error']) { $Answer['Items'] | Where-Object { $_ } }) } $readOk = { param($Answer) [bool]($Answer -and -not $Answer['Error']) } # accessedAt: never used reads 0001-01-01; ConvertFrom-Json may already have made it a DateTime. $when = { param($Value) $d = if ($Value -is [datetime]) { $Value } elseif ($Value) { try { [datetime]::Parse([string]$Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal) } catch { $null } } else { $null } if ($d -and $d.Year -gt 2000) { $d.ToUniversalTime() } else { $null } } # An ISO 8601 duration; the maximum (P10675199D...) means messages never expire. $expires = { param($Ttl) $t = [string]$Ttl; if (-not $t -or $t -like 'P10675199D*') { return $false }; try { $null = [System.Xml.XmlConvert]::ToTimeSpan($t); $true } catch { $false } } $findings = [System.Collections.Generic.List[object]]::new() $namespaceRows = [System.Collections.Generic.List[object]]::new() $entityRows = [System.Collections.Generic.List[object]]::new() $heatRows = [System.Collections.Generic.List[object]]::new() $signals = @('Health', 'Capacity', 'Messages', 'Security', 'Resilience', 'Cost') $messageTotals = [ordered]@{ Active = 0.0; 'Dead-lettered' = 0.0; Scheduled = 0.0; 'Transfer dead-lettered' = 0.0 } foreach ($n in @($Namespace | Sort-Object -Property { [string]$_['name'] })) { $id = [string]$n['id'] $name = [string]$n['name'] $p = $n['properties'] $tier = [string]$n['tier'] $premium = $tier -eq 'Premium' $read = if ($Entity.Contains($id)) { $Entity[$id] } else { @{ Queues = $null; Topics = $null; Network = $null; Rules = $null; GeoDr = $null; Logs = $null; Subscriptions = @{} } } $find = { param([string] $Severity, [string] $Signal, [string] $Title, [string] $Detail, [string] $Fix, [string] $EntityName = '') $findings.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.ServiceBusFinding'; Severity = $Severity; Namespace = $name; Entity = $EntityName; Signal = $Signal; Finding = $Title; Detail = $Detail; Recommendation = $Fix; ResourceGroup = [string]$n['resourceGroup']; Subscription = & $subName ([string]$n['subscriptionId']); ResourceId = $id }) } # --- Health and capacity: Azure Monitor over the period ------------------------------------------------------- $core = & $call $id 'Core' $metricsRead = [bool]($core -and -not $core.Error) $requests = [long](& $sum (& $pointsOf $core 'IncomingRequests') 'total') $successful = [long](& $sum (& $pointsOf $core 'SuccessfulRequests') 'total') $serverErrors = [long](& $sum (& $pointsOf $core 'ServerErrors') 'total') $userErrors = [long](& $sum (& $pointsOf $core 'UserErrors') 'total') $throttled = [long](& $sum (& $pointsOf $core 'ThrottledRequests') 'total') $incoming = [long](& $sum (& $pointsOf $core 'IncomingMessages') 'total') $outgoing = [long](& $sum (& $pointsOf $core 'OutgoingMessages') 'total') $rateOf = { param([double] $Count) if ($requests) { 100.0 * $Count / $requests } else { 0 } } $premiumMetrics = & $call $id 'Premium' $cpu = & $round (& $peak (& $pointsOf $premiumMetrics 'NamespaceCpuUsage') 'maximum') $memory = & $round (& $peak (& $pointsOf $premiumMetrics 'NamespaceMemoryUsage') 'maximum') # --- Entities: queues, topics and subscriptions, as they are now ---------------------------------------------- $mine = [System.Collections.Generic.List[object]]::new() $addEntity = { param([string] $Kind, [string] $EntityName, $Properties, [int] $SubscriptionCount = -1) $counts = & $at $Properties 'countDetails' $active = [long](& $at $counts 'activeMessageCount') $dead = [long](& $at $counts 'deadLetterMessageCount') $transfer = [long](& $at $counts 'transferDeadLetterMessageCount') $scheduled = [long](& $at $counts 'scheduledMessageCount') $sizeMb = if ($null -ne (& $at $Properties 'sizeInBytes')) { & $round ([double](& $at $Properties 'sizeInBytes') / 1MB) 2 } else { $null } $maxMb = & $at $Properties 'maxSizeInMegabytes' $usedPct = if ($null -ne $sizeMb -and $maxMb) { & $round (100.0 * $sizeMb / [double]$maxMb) 1 } else { $null } $lastUsed = & $when (& $at $Properties 'accessedAt') $idle = if ($lastUsed) { [int][Math]::Floor(($Now - $lastUsed).TotalDays) } else { $null } $status = [string](& $at $Properties 'status') $row = [pscustomobject][ordered]@{ PSTypeName = 'AAC.ServiceBusEntity' Namespace = $name Entity = $EntityName Type = $Kind Status = $status ActiveMessages = $active DeadLetters = $dead TransferDeadLetters = $transfer Scheduled = $scheduled SizeMB = $sizeMb MaxSizeMB = $maxMb UsedPct = $usedPct Subscriptions = $(if ($SubscriptionCount -ge 0) { $SubscriptionCount } else { $null }) MaxDeliveryCount = & $at $Properties 'maxDeliveryCount' LockDuration = [string](& $at $Properties 'lockDuration') Sessions = [string](& $at $Properties 'requiresSession') -eq 'True' DuplicateDetection = [string](& $at $Properties 'requiresDuplicateDetection') -eq 'True' DeadLetterOnExpiration = [string](& $at $Properties 'deadLetteringOnMessageExpiration') -eq 'True' MessagesExpire = & $expires (& $at $Properties 'defaultMessageTimeToLive') ForwardTo = [string](& $at $Properties 'forwardTo') LastUsed = $lastUsed IdleDays = $idle Findings = 0 ResourceGroup = [string]$n['resourceGroup'] Subscription = & $subName ([string]$n['subscriptionId']) } $mine.Add($row) $before = $findings.Count $what = "$Kind '$EntityName'" if ($dead -gt 0) { & $find $(if ($dead -ge 1000) { 'High' } else { 'Medium' }) 'Messages' 'Dead-lettered messages' ('{0} holds {1:N0} dead-lettered message(s): messages that failed {2} delivery attempts, expired, or broke a filter.' -f $what, $dead, $(if ($null -ne $row.MaxDeliveryCount) { $row.MaxDeliveryCount } else { 'the maximum' })) 'Read the dead-letter queue (DeadLetterReason, DeadLetterErrorDescription), fix the cause - often a poison message or a failing handler - then resubmit or purge; alert on DeadletteredMessages.' $EntityName } if ($transfer -gt 0) { & $find 'Medium' 'Messages' 'Transfer dead-lettered messages' ('{0} has {1:N0} message(s) that couldn''t be forwarded (auto-forward or send-via).' -f $what, $transfer) 'Check the forwarding target exists, is active and isn''t full; then resubmit from the transfer dead-letter queue.' $EntityName } if ($null -ne $usedPct -and $usedPct -ge 50) { & $find $(if ($usedPct -ge 80) { 'High' } else { 'Medium' }) 'Messages' 'Entity filling up' ('{0} is at {1}% of its {2:N0} MB maximum: at 100% sends fail (QuotaExceeded).' -f $what, $usedPct, [double]$maxMb) 'Drain the backlog (more or faster receivers), raise the maximum size, or partition the entity; alert on Size.' $EntityName } if ($status -and $status -ne 'Active') { & $find 'Medium' 'Messages' 'Entity not active' "$what is $status." 'Re-enable it when it should send and receive again - or delete it if it is no longer used.' $EntityName } if ($Kind -ne 'Topic' -and $active -ge 10000) { & $find 'Medium' 'Messages' 'Backlog of active messages' ('{0} has {1:N0} active message(s) waiting.' -f $what, $active) 'Check the receivers are running and keeping up: scale them out, use prefetch and concurrent processing, or batch receives.' $EntityName } if ($SubscriptionCount -eq 0) { & $find 'Medium' 'Messages' 'Topic with no subscriptions' "$what has no subscription: every message sent to it is dropped." 'Add the subscriptions it needs, or delete the topic.' $EntityName } if ($Kind -ne 'Topic' -and -not $row.DeadLetterOnExpiration -and $row.MessagesExpire) { & $find 'Low' 'Messages' 'Expired messages are dropped' "$what lets messages expire, without dead-lettering them." 'Turn on dead-lettering on message expiration, so expired messages can be inspected instead of lost.' $EntityName } if ($active -eq 0 -and $dead -eq 0 -and ($null -eq $idle -or $idle -ge 30)) { & $find 'Low' 'Cost' 'Not used for 30 days or more' "$what $(if ($null -eq $idle) { 'has never been used' } else { "was last used $idle days ago" }), and is empty." 'Delete it if nothing uses it any more - unused entities clutter the namespace and its quotas.' $EntityName } $row.Findings = $findings.Count - $before } foreach ($q in (& $itemsOf $read.Queues)) { & $addEntity 'Queue' ([string]$q['name']) $q['properties'] } foreach ($t in (& $itemsOf $read.Topics)) { $topicId = ([string]$t['id']).ToLowerInvariant() $subs = if ($read.Subscriptions.Contains($topicId)) { $read.Subscriptions[$topicId] } else { $null } $count = if (& $readOk $subs) { @(& $itemsOf $subs).Count } else { [int](& $at $t 'properties', 'subscriptionCount') } & $addEntity 'Topic' ([string]$t['name']) $t['properties'] $count foreach ($s in (& $itemsOf $subs)) { & $addEntity 'Subscription' "$([string]$t['name'])/$([string]$s['name'])" $s['properties'] } } $entityRows.AddRange([object[]]$mine.ToArray()) $queued = @($mine | Where-Object { $_.Type -ne 'Topic' }) $active = [long](& $sumOf $queued 'ActiveMessages') $dead = [long](& $sumOf $queued 'DeadLetters') $scheduledTotal = [long](& $sumOf $queued 'Scheduled') $transferTotal = [long](& $sumOf $queued 'TransferDeadLetters') $messageTotals['Active'] += $active; $messageTotals['Dead-lettered'] += $dead; $messageTotals['Scheduled'] += $scheduledTotal; $messageTotals['Transfer dead-lettered'] += $transferTotal $fullest = @($mine | Where-Object { $null -ne $_.UsedPct } | ForEach-Object { $_.UsedPct } | Sort-Object -Descending | Select-Object -First 1) $entitiesRead = (& $readOk $read.Queues) -or (& $readOk $read.Topics) # --- Posture ------------------------------------------------------------------------------------------------ $tls = [string](& $at $p 'minimumTlsVersion') $localAuth = [string](& $at $p 'disableLocalAuth') -ne 'True' $rules = if (& $readOk $read.Network) { & $at $read.Network 'Body', 'properties' } else { $null } $network = if ([string](& $at $p 'publicNetworkAccess') -eq 'Disabled' -or [string](& $at $rules 'publicNetworkAccess') -eq 'Disabled') { 'Private' } elseif ([string](& $at $rules 'defaultAction') -eq 'Deny' -or @(& $at $rules 'ipRules').Count -or @(& $at $rules 'virtualNetworkRules').Count) { 'Firewall' } else { 'Public' } $endpoints = @(@(& $at $p 'privateEndpointConnections') | Where-Object { $_ -and [string](& $at $_ 'properties', 'privateLinkServiceConnectionState', 'status') -eq 'Approved' }).Count $cmk = [string](& $at $p 'encryption', 'keySource') -eq 'Microsoft.KeyVault' $identity = [string](& $at $n 'identity', 'type') $manageKeys = @(& $itemsOf $read.Rules | Where-Object { @(& $at $_ 'properties', 'rights') -contains 'Manage' }).Count $logsTo = $null foreach ($diagnostic in (& $itemsOf $read.Logs)) { $dp = & $at $diagnostic 'properties' $workspace = ([string](& $at $dp 'workspaceId')).ToLowerInvariant() if (-not $workspace) { continue } $on = @(@(& $at $dp 'logs') | Where-Object { $_ -and (& $at $_ 'enabled') -eq $true -and ([string](& $at $_ 'category') -in 'OperationalLogs', 'RuntimeAuditLogs', 'VNetAndIPFilteringLogs' -or [string](& $at $_ 'categoryGroup') -in 'audit', 'allLogs') }).Count if ($on) { $logsTo = $workspace; break } } $logsRead = & $readOk $read.Logs $zoneRedundant = [string](& $at $p 'zoneRedundant') -eq 'True' $alias = @(& $itemsOf $read.GeoDr).Count -gt 0 $replicas = @(& $at $p 'geoDataReplication', 'locations').Count -gt 1 $geoDr = $alias -or $replicas $units = [int]$n['capacity'] # --- Findings: namespace ------------------------------------------------------------------------------------ if ($throttled -gt 0) { $r = & $rateOf $throttled $fix = if ($premium) { 'Add messaging units - or scale them automatically on CPU usage - and spread the load over more entities or partitions.' } else { 'Standard namespaces share capacity and are throttled when the credits run out: retry with backoff, and move busy workloads to Premium.' } & $find $(if ($r -ge 1 -or $throttled -ge 100) { 'High' } else { 'Medium' }) 'Health' 'Requests throttled' ('{0:N0} request(s) throttled ({1:N2}% of requests).' -f $throttled, $r) $fix } if ($serverErrors -gt 0 -and (& $rateOf $serverErrors) -ge 0.1) { & $find 'Medium' 'Health' 'Server errors' ('{0:N0} request(s) failed on the service side.' -f $serverErrors) 'Retry transient errors (the Service Bus SDKs do); check Resource Health and Azure status for the region if they persist.' } if ($userErrors -gt 0 -and (& $rateOf $userErrors) -ge 5) { & $find 'Low' 'Health' 'Many client errors' ('{0:N0} request(s) ({1:N1}%) failed because of the client: lost message locks, entities not found, quota exceeded or bad requests.' -f $userErrors, (& $rateOf $userErrors)) 'Look at the operational logs: renew locks for long processing (or lengthen LockDuration), and fix the callers using wrong entity names.' } if ($premium -and $null -ne $cpu -and $cpu -ge 60) { & $find $(if ($cpu -ge 80) { 'High' } else { 'Medium' }) 'Capacity' 'CPU close to its limit' "CPU peaked at $cpu% of $units messaging unit(s)." 'Add messaging units (or scale on CPU usage) before requests are throttled.' } if ($premium -and $null -ne $memory -and $memory -ge 60) { & $find $(if ($memory -ge 80) { 'High' } else { 'Medium' }) 'Capacity' 'Memory close to its limit' "Memory peaked at $memory% of $units messaging unit(s)." 'Add messaging units, and drain large backlogs - messages held in memory count against it.' } if ($tls -and $tls -notin '1.2', '1.3') { & $find 'High' 'Security' 'TLS below 1.2 accepted' "Minimum TLS version: $tls." 'Set the minimum TLS version to 1.2.' } if ($localAuth) { & $find 'Medium' 'Security' 'SAS keys allowed (local authentication)' "Shared access keys - $manageKeys namespace-wide with Manage rights - grant access that can't be traced to a user or app." 'Give apps Microsoft Entra ID identities with the Azure Service Bus Data Sender / Receiver roles, then turn off local authentication.' } if ($localAuth -and $manageKeys -gt 1) { & $find 'Low' 'Security' 'More than one namespace-wide Manage key' "$manageKeys SAS rules on the namespace have Manage rights - full control of every entity." 'Keep RootManageSharedAccessKey for emergencies only; give apps entity-level Send or Listen rules (or better, Entra ID roles).' } if ($network -eq 'Public' -and -not $endpoints) { & $find 'Medium' 'Security' 'Open to every network' 'Public network access is on, with no IP or virtual network rules and no private endpoint.' $(if ($premium) { 'Use private endpoints and disable public network access - or allow only the networks that need it.' } else { 'Allow only the IP ranges that need it (IP firewall); for private endpoints, move to Premium.' }) } if ($logsRead -and -not $logsTo) { & $find 'Medium' 'Security' 'No logs to Log Analytics' 'Operational and runtime audit logs aren''t sent to a workspace: who did what can''t be investigated.' 'Add a diagnostic setting sending the audit category group (or OperationalLogs and RuntimeAuditLogs) to a Log Analytics workspace.' } if ($premium -and -not $zoneRedundant) { & $find 'Medium' 'Resilience' 'Not zone redundant' 'A zone outage takes the namespace down.' 'Create Premium namespaces as zone redundant (in regions with availability zones); move the entities over.' } if ($premium -and -not $geoDr) { & $find 'Low' 'Resilience' 'No geo-disaster recovery' 'No alias pairs the namespace with one in another region, and no geo-replication.' 'Pair it with a namespace in another region (Geo-DR alias for metadata, or geo-replication for messages too) and connect through the alias.' } if ($tier -eq 'Basic') { & $find 'Low' 'Resilience' 'Basic tier' 'Basic has no topics, sessions, transactions, duplicate detection or auto-forwarding.' 'Use Standard (or Premium for production isolation and predictable performance).' } if ($premium -and $units -gt 1 -and $null -ne $cpu -and $cpu -lt 20 -and ($null -eq $memory -or $memory -lt 20)) { & $find 'Low' 'Cost' 'More messaging units than needed' "CPU peaked at $cpu% and memory at $(if ($null -ne $memory) { "$memory%" } else { '-' }) on $units messaging units." 'Scale down the messaging units - or scale them automatically on CPU usage.' } if ($metricsRead -and $requests -eq 0) { & $find 'Low' 'Cost' 'No requests in the period' 'Nothing sent to or received from the namespace.' 'Confirm it is still needed; delete it if not - Premium namespaces cost the same idle or busy.' } # --- Heat map: each signal 0-100 ---------------------------------------------------------------------------- $cell = { param($Score) if ($null -eq $Score) { $null } else { @{ Score = [Math]::Round([Math]::Max(0, [Math]::Min(100, [double]$Score)), 1) } } } $share = { param([object[]] $Checks) 100.0 * @($Checks | Where-Object { $_ }).Count / [Math]::Max(1, $Checks.Count) } $messageIssues = @($findings | Where-Object { $_.ResourceId -eq $id -and $_.Signal -eq 'Messages' } | ForEach-Object { $_.Entity } | Select-Object -Unique).Count $securityChecks = @(($tls -notin '1.0', '1.1'), (-not $localAuth), ($network -ne 'Public' -or $endpoints -gt 0)) if ($logsRead) { $securityChecks += [bool]$logsTo } $resilienceChecks = @(($tier -ne 'Basic')) if ($premium) { $resilienceChecks += @($zoneRedundant, $geoDr) } $idleEntities = @($findings | Where-Object { $_.ResourceId -eq $id -and $_.Finding -eq 'Not used for 30 days or more' }).Count $costChecks = @((-not ($metricsRead -and $requests -eq 0)), ($idleEntities -eq 0)) if ($premium -and $null -ne $cpu) { $costChecks += -not ($units -gt 1 -and $cpu -lt 20 -and ($null -eq $memory -or $memory -lt 20)) } $usage = @(@($cpu, $memory) | Where-Object { $null -ne $_ }) $scores = [ordered]@{ Health = $(if ($requests) { 100 - (& $rateOf $throttled) * 20 - (& $rateOf $serverErrors) * 10 } else { $null }) Capacity = $(if ($usage.Count) { 100 - [Math]::Max(0, ($usage | Measure-Object -Maximum).Maximum - 50) * 2 } else { $null }) Messages = $(if ($entitiesRead -and $mine.Count) { 100.0 * ($mine.Count - $messageIssues) / $mine.Count } else { $null }) Security = & $share $securityChecks Resilience = & $share $resilienceChecks Cost = & $share $costChecks } $cells = @(foreach ($signal in $signals) { & $cell $scores[$signal] }) $scored = @($cells | Where-Object { $_ }) $overall = if ($scored.Count) { [Math]::Round((@($scored | ForEach-Object { $_.Score }) | Measure-Object -Average).Average, 1) } else { $null } $heatRows.Add(@{ Label = $name; Filter = $name; Cells = $cells; Total = $(if ($null -ne $overall) { @{ Score = $overall } } else { $null }); Overall = $overall }) $namespaceRows.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.ServiceBusAssessment' Namespace = $name Score = $overall Tier = $tier MessagingUnits = $(if ($premium) { $units } else { $null }) Queues = @($mine | Where-Object { $_.Type -eq 'Queue' }).Count Topics = @($mine | Where-Object { $_.Type -eq 'Topic' }).Count Subscriptions = @($mine | Where-Object { $_.Type -eq 'Subscription' }).Count ActiveMessages = $active DeadLetters = $dead Scheduled = $scheduledTotal SizeMB = & $round (& $sumOf @($mine | Where-Object { $_.Type -ne 'Subscription' }) 'SizeMB') 2 FullestPct = $(if ($fullest.Count) { $fullest[0] } else { $null }) Requests = $requests Successful = $successful Throttled = $throttled ServerErrors = $serverErrors UserErrors = $userErrors ErrorRate = $(if ($requests) { & $round (100.0 * ($throttled + $serverErrors) / $requests) 2 } else { $null }) IncomingMessages = $incoming OutgoingMessages = $outgoing CpuMax = $cpu MemoryMax = $memory MinTls = $tls LocalAuth = $localAuth ManageKeys = $manageKeys Network = $network PrivateEndpoints = $endpoints Encryption = $(if ($cmk) { 'Customer-managed key' } else { 'Microsoft-managed key' }) Identity = $identity ZoneRedundant = $zoneRedundant GeoDr = $(if ($replicas) { 'Geo-replication' } elseif ($alias) { 'Alias' } else { '' }) LogsTo = $(if ($logsTo) { if ($WorkspaceName.Contains($logsTo)) { $WorkspaceName[$logsTo] } else { ($logsTo -split '/')[-1] } } else { '' }) MetricsError = $(if ($core -and $core.Error) { [string]$core.Error } else { '' }) Findings = @($findings | Where-Object { $_.ResourceId -eq $id }).Count Location = [string]$n['location'] ResourceGroup = [string]$n['resourceGroup'] Subscription = & $subName ([string]$n['subscriptionId']) ResourceId = $id }) } $rank = @{ High = 0; Medium = 1; Low = 2; Info = 3 } $worst = @($heatRows | Sort-Object -Property @{ Expression = { if ($null -eq $_.Overall) { 101 } else { $_.Overall } } }, Label | Select-Object -First 40) $total = { param([string] $Property) [long](& $sumOf $namespaceRows.ToArray() $Property) } @{ Namespaces = $namespaceRows.ToArray() Entities = $entityRows.ToArray() Findings = @($findings | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, Namespace, Entity, Finding) HeatMap = @{ Title = 'Service Bus namespaces by signal'; RowHeader = 'Namespace'; Columns = $signals; Rows = $worst; Table = 'findings'; RowKey = 'Namespace'; ColumnKey = 'Signal'; ConsoleColumns = 6 Note = "0-100 per signal, worst namespaces first$(if ($heatRows.Count -gt 40) { " (40 of $($heatRows.Count))" }): health, capacity (Premium), messages, security, resilience, cost. Click a cell for its findings." } Messages = $messageTotals Stats = @{ Namespaces = $namespaceRows.Count Entities = $entityRows.Count Requests = & $total 'Requests' Throttled = & $total 'Throttled' Active = [long]$messageTotals['Active'] DeadLetters = [long]$messageTotals['Dead-lettered'] Public = @($namespaceRows | Where-Object { $_.Network -eq 'Public' -and -not $_.PrivateEndpoints }).Count LocalAuth = @($namespaceRows | Where-Object { $_.LocalAuth }).Count High = @($findings | Where-Object { $_.Severity -eq 'High' }).Count } } } |