Public/Get-AACTagUsage.ps1

function Get-AACTagUsage {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Shows how tags are really used across resources, resource groups
        and subscriptions - each tag name, its coverage and values - and
        what is inconsistent: names that differ only in casing,
        near-duplicates, values in mixed casing, required tags missing.
    .DESCRIPTION
        Counts, read-only and in Azure Resource Graph, every tag name on
        resources, resource groups and subscriptions, with its distinct
        values, and how many of each carry any tag at all. Azure's own
        hidden tags are left out.
 
        Each tag name as spelled (AAC.TagUsage): uses per kind of scope,
        Coverage (the share of resources that carry it), the number of
        distinct values and a sample, and findings:
          Medium the same name in other casing - Azure treats tag names
                  case-insensitively, so 'Environment' and 'environment'
                  collide in cost reports and policies; a -RequiredTag on
                  fewer than -CoverageTarget percent of resources
          Low near-duplicates ('cost-center', 'CostCenter',
                  'cost_center'); the same value in different casing
          Info a tag used once
        Reader is enough.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER RequiredTag
        Tags every resource should carry: each gets a row, used or not, with
        its coverage.
    .PARAMETER CoverageTarget
        A required tag on fewer than this percentage of resources is a
        finding. Default 90.
    .PARAMETER WithFinding
        Only tags with a finding.
    .PARAMETER CsvPath
        Write the tags to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the tags.
    .PARAMETER NoDisplay
        Return the tags without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACTagUsage
        Every tag name, the inconsistent ones first.
    .EXAMPLE
        Get-AACTagUsage -RequiredTag 'env', 'owner', 'costcenter' -HtmlPath .\Tags.html
        How far the required tags reach, as a report.
    .EXAMPLE
        Get-AACTagUsage -WithFinding -NoDisplay | Select-Object Tag, Findings
        Only the tags to tidy up.
    .OUTPUTS
        AAC.TagUsage
    #>

    [CmdletBinding()]
    [OutputType('AAC.TagUsage')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [string[]] $RequiredTag,

        [ValidateRange(1, 100)]
        [int] $CoverageTarget = 90,

        [switch] $WithFinding,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Tag usage',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); RequiredTag = @($RequiredTag | Where-Object { $_ }); CoverageTarget = $CoverageTarget }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Tag usage' -Color 'gold1' }
    $outcome = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $query = Get-AACTagUsageQuery
        Update-AACProgress -Id 'read' -Total $query.Count -Description 'Counting the tags'
        $read = Invoke-AACGraphBatch -Query $query -SubscriptionId $scope.GraphScope -AllowFailure @($query.Keys) -OnProgress {
            param($QueryName, $Done, $Total)
            Update-AACProgress -Id 'read' -Increment 1 -Description "Counted the $QueryName ($Done of $Total)"
        }
        Update-AACProgress -Id 'read' -Complete -Description ('Counted {0:N0} tag name(s)' -f (@($read.Rows['resourceTags']).Count + @($read.Rows['groupTags']).Count))
        @{ Result = (ConvertTo-AACTagUsage -Read $read -RequiredTag $request.RequiredTag -CoverageTarget $request.CoverageTarget); Scope = $scope }
    }

    $tags = @($outcome.Result.Tags)
    if ($WithFinding) { $tags = @($tags | Where-Object Findings) }
    $t = $outcome.Result.Totals
    $stats = $outcome.Result.Stats
    $rank = Get-AACSeverityRank
    $findingCount = @($tags | Where-Object Findings).Count
    $report = @{
        Subtitle = 'Tag usage: every tag name on resources, resource groups and subscriptions, its coverage and values, and what is inconsistent'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; Resources = ('{0:N0} ({1:N0} tagged)' -f $t.Resources, $t.TaggedResources); 'Resource groups' = ('{0:N0} ({1:N0} tagged)' -f $t.ResourceGroups, $t.TaggedResourceGroups); Required = $(if ($RequiredTag) { $RequiredTag -join ', ' } else { 'none given (-RequiredTag)' }) }
        Status   = $(if (@($tags | Where-Object Severity -EQ 'Medium').Count) { 'Warning' } else { 'Success' })
        Headline = "$($stats.Tags) tag name(s); $($stats.Coverage)% of resources carry a tag; $findingCount with a finding$(if ($stats.CaseClashes) { ", $($stats.CaseClashes) clashing in casing" })"
        Tiles    = @(
            @{ Value = '{0:N0}' -f $stats.Tags; Label = 'tag names'; Tone = 'info' }
            @{ Value = "$($stats.Coverage)%"; Label = 'resources tagged'; Tone = $(if ($stats.Coverage -ge $CoverageTarget) { 'good' } else { 'warn' }) }
            @{ Value = '{0:N0}' -f $stats.CaseClashes; Label = 'casing clashes'; Tone = $(if ($stats.CaseClashes) { 'warn' } else { 'good' }); Table = 'tags'; Filters = @{ Severity = 'Medium' } }
            @{ Value = '{0:N0}' -f $findingCount; Label = 'with a finding'; Tone = $(if ($findingCount) { 'warn' } else { 'good' }) }
        )
        Notices  = @($outcome.Result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'Most used tags'; Items = @($tags | Sort-Object { $_.Resources + $_.ResourceGroups + $_.Subscriptions } -Descending | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Tag; Value = $_.Resources + $_.ResourceGroups + $_.Subscriptions; Filter = $_.Tag } }); Table = 'tags'; Column = 'Tag'; Tone = 'info'; Console = $true }
            @{ Title = 'Coverage of resources (%)'; Items = @($tags | Where-Object { $_.Required -or $_.Coverage -gt 0 } | Sort-Object Coverage -Descending | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Tag; Value = $_.Coverage } }); Tone = 'good' }
        )
        Tables   = @(
            @{ Id = 'tags'; Title = 'Tags'; Section = 'Tags'; Rows = $tags; Noun = 'tags'; ConsoleLimit = 30
                Empty = 'No tags found.'; EmptyStatus = 'Info'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Tag'; Label = 'Tag'; Console = $true; Pdf = $true }
                    @{ Key = 'Resources'; Label = 'Resources'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'ResourceGroups'; Label = 'Groups'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Subscriptions'; Label = 'Subs'; Type = 'number'; Console = $true }
                    @{ Key = 'Coverage'; Label = 'Coverage'; Type = 'score'; Format = 'N1'; Console = $true; Pdf = $true }
                    @{ Key = 'Values'; Label = 'Values'; Type = 'number'; Console = $true }
                    @{ Key = 'Findings'; Label = 'Findings'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'SampleValues'; Label = 'Sample values'; Type = 'wide' }
                    @{ Key = 'Required'; Label = 'Required'; Facet = $true }
                ) }
        )
        Hint     = '-RequiredTag checks coverage of the tags you need; -WithFinding shows only what to tidy; -NoDisplay returns the tags; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $tags -Noun 'tag' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $tags -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}