Public/Get-AzLocalUpdateRunFailures.ps1

function Get-AzLocalUpdateRunFailures {
    <#
    .SYNOPSIS
        Returns deep error information for Azure Local cluster update runs by
        walking up to nine levels deep into the `properties.progress` step tree
        in Azure Resource Graph - the "Verbose Error Information" that has
        been missing from update-status pipeline output.
 
    .DESCRIPTION
        Every Azure Local cluster persists its update-run history on the
        `microsoft.azurestackhci/clusters/updates/updateruns` child resource,
        which is queryable across thousands of clusters via Azure Resource
        Graph's `extensibilityresources` table. The `properties.progress`
        object on each run contains a recursively nested
        `steps[].steps[].steps[]...` tree (often 5 to 8 levels deep on real
        failures) where the actual error message lives several levels below
        the top-level step.
 
        Today the existing `Get-AzLocalUpdateRuns` cmdlet returns the
        top-level "currentStep" name only and makes one ARM call per cluster.
        On a 9-cluster sub it takes >250 seconds and never surfaces the
        deepest error message.
 
        This cmdlet replicates the "Update Run Errors" KQL pattern used by
        the Azure Resource Graph cluster updateSummaries extensibility resource:
 
          - mv-expand the progress tree across s1..s7 (seven explicit levels)
          - reach into s7.steps[0] for the eighth level
          - coalesce the deepest non-empty `errorMessage` field upward
          - fall back to the step `description` when `errorMessage` is empty
          - regex-extract `raised an exception:...` from the raw progressJson
            for stack-trace recovery on rare malformed payloads
          - bucket the result into an ErrorCategory (HealthCheck, SecuredCore,
            CAU, RotateSecrets, ArcPrereqs, Certificates, AdminBlocked,
            PreparationTerminated, Other, Unclassified)
 
        The entire extraction happens server-side in KQL. The cmdlet returns
        flat PSCustomObject rows with already-resolved scalars - safe to
        JSON-export without `ConvertTo-Json -Depth` games, safe to push
        through CSV, safe to use as JUnit input.
 
        For investigations that need the original step tree, pass
        `-IncludeRawProgress` and the raw progressJson string is included
        on each row (capped at 200 KB by KQL `substring` so a single huge
        payload cannot bloat the result).
 
        Two views are supported:
 
          - 'Detail' (default): one row per failed update run.
          - 'Summary' : aggregated by ErrorCategory. Sorted by
                                  ClusterCount desc (most widespread first)
                                  so admins can target the highest-impact
                                  failure pattern first. AffectedClusters
                                  lists every cluster hit by that category.
 
    .PARAMETER SubscriptionId
        Optional. Limit the query to a specific Azure subscription ID. If
        not specified, queries across all accessible subscriptions (default
        mode) - applies a fleet-wide ARG-first scan.
 
    .PARAMETER UpdateRingTag
        Optional. When supplied, only update runs from clusters whose
        'UpdateRing' tag value matches this string are returned. Accepts
        the same semicolon-delimited list and `***` wildcard syntax as
        every other ARG-scoped cmdlet in the module.
 
    .PARAMETER ClusterName
        Optional. Limit results to a single cluster by name (case-insensitive
        exact match on the cluster resource segment of the update-run ID).
        Combinable with -UpdateRingTag for further narrowing.
 
    .PARAMETER State
        Filter applied server-side. Defaults to 'Failed' (which is the only
        state surfaced by name). Other values include:
 
          - 'InProgress' - currently-running update runs (useful for live ops)
          - 'Succeeded' - returns succeeded runs too (deep error fields will
                            be empty)
          - 'All' - every state
 
    .PARAMETER Since
        Optional. Only return update runs whose StartTime is on or after this
        UTC timestamp. Defaults to 30 days ago. Pass an earlier `[datetime]`
        for a full-history sweep, or a later one for a recent-only view.
 
    .PARAMETER OnlyUnresolved
        When specified, filters Failed runs to those that do NOT have a later
        Succeeded run for the same (ClusterResourceId, UpdateName). Useful
        for "what is still broken right now?" reporting. Adds one small ARG
        query for the latest-Succeeded summary.
 
    .PARAMETER IncludeRawProgress
        When specified, includes the raw `progressJson` string column (the
        full original `properties.progress` blob, KQL-truncated to 200 KB
        per row). Off by default because individual rows can exceed 50 KB.
 
    .PARAMETER View
        'Detail' (default) or 'Summary'. See DESCRIPTION above.
 
    .PARAMETER ExportPath
        Optional. Path to export the result. Format auto-detected from the
        file extension (.csv or .json). For JSON exports, the raw
        progressJson string (when -IncludeRawProgress is set) round-trips
        losslessly without `ConvertTo-Json -Depth` truncation.
 
    .PARAMETER PassThru
        Return objects to the pipeline even when -ExportPath is specified.
 
    .OUTPUTS
        PSCustomObject[] - Detail view columns:
          ClusterName, ResourceGroup, SubscriptionId, ClusterResourceId,
          UpdateName, RunId, State, StartTime, EndTime, DurationMinutes,
          DeepestStepDepth (1-8 or 0), DeepestStepName, DeepestErrMsg,
          StackTracePreview, ErrorCategory, ProgressJsonBytes,
          IsUnresolved (only when -OnlyUnresolved is used),
          ProgressJson (only when -IncludeRawProgress is used)
 
        Summary view columns:
          ErrorCategory, ClusterCount, FailureCount, AffectedClusters,
          LatestFailure, SampleErrMsg, SampleStepName
 
    .EXAMPLE
        # All failed runs in the fleet across every accessible subscription
        Get-AzLocalUpdateRunFailures
 
    .EXAMPLE
        # "What patterns are biting the most clusters?" - prioritised view
        Get-AzLocalUpdateRunFailures -View Summary
 
    .EXAMPLE
        # Currently unresolved failures only (no later Succeeded run for
        # the same cluster+update), in the Prod ring, last 90 days
        Get-AzLocalUpdateRunFailures -UpdateRingTag Prod -Since (Get-Date).AddDays(-90) -OnlyUnresolved
 
    .EXAMPLE
        # Single cluster, raw progress JSON for deep-dive investigation
        Get-AzLocalUpdateRunFailures -ClusterName Arizona -IncludeRawProgress |
            Where-Object { $_.DeepestStepDepth -ge 5 } |
            Select-Object UpdateName, RunId, DeepestStepName, DeepestErrMsg
 
    .EXAMPLE
        # CI/CD pipeline: detail for JUnit/CSV, summary for the markdown summary
        $detail = Get-AzLocalUpdateRunFailures -View Detail -ExportPath .\reports\update-failures-detail.json -PassThru
        $summary = Get-AzLocalUpdateRunFailures -View Summary -ExportPath .\reports\update-failures-summary.csv -PassThru
 
    .NOTES
        Author: Neil Bird, Microsoft.
        Added: v0.7.68
        Module: AzLocal.UpdateManagement
 
        Architectural reference: this cmdlet treats ARG
        `extensibilityresources` as the single source of truth for update
        run history and pioneered the nine-level mv-expand pattern this
        cmdlet ports to PowerShell.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $false)]
        [string]$SubscriptionId,

        [Parameter(Mandatory = $false)]
        [ValidatePattern('^(\*\*\*|[A-Za-z0-9_-]{1,64}(;[A-Za-z0-9_-]{1,64})*)$')]
        [string]$UpdateRingTag,

        [Parameter(Mandatory = $false)]
        [ValidatePattern('^[A-Za-z0-9_-]{1,64}$')]
        [string]$ClusterName,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Failed', 'InProgress', 'Succeeded', 'All')]
        [string]$State = 'Failed',

        [Parameter(Mandatory = $false)]
        [datetime]$Since = (Get-Date).ToUniversalTime().AddDays(-30),

        [Parameter(Mandatory = $false)]
        [switch]$OnlyUnresolved,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeRawProgress,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Detail', 'Summary')]
        [string]$View = 'Detail',

        [Parameter(Mandatory = $false)]
        [string]$ExportPath,

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    # Pre-flight: validate export path before issuing the ARG query
    if ($ExportPath) {
        try { Test-ExportPathWritable -Path $ExportPath | Out-Null }
        catch { throw "ExportPath is not writable: $($_.Exception.Message)" }
    }

    # Verify Azure CLI is installed and logged in
    Test-AzCliAvailable | Out-Null
    try {
        $null = az account show 2>$null
        if ($LASTEXITCODE -ne 0) {
            throw "Azure CLI is not logged in. Please run 'az login' first."
        }
    }
    catch {
        Write-Log -Message "Azure CLI is not logged in. Please run 'az login' first." -Level Error
        return
    }

    # Resource-graph extension is required for ARG queries
    if (-not (Install-AzGraphExtension)) {
        Write-Log -Message "Failed to install Azure CLI 'resource-graph' extension." -Level Error
        return
    }

    # Build server-side filter clauses. The State filter is applied AFTER the
    # raw projection so it operates on the renamed `State` column. The Since
    # filter is pushed down BEFORE the heavy `progress` projection for
    # efficiency.

    # ISO-8601 UTC representation safe for embedding in KQL (no quoting needed
    # inside datetime() literal). Since the helper now normalises multi-line
    # KQL to single-line we can keep the query as a here-string for clarity.
    $sinceUtc = $Since.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')

    # ClusterName narrowing is pushed server-side as a where filter on the
    # parsed segment so we don't pull `progress` for uninteresting clusters.
    $clusterClause = if ($PSBoundParameters.ContainsKey('ClusterName')) {
        "| where ClusterName =~ '$ClusterName'"
    } else { '' }

    # v0.7.76: KQL no longer does the 7-level `mv-expand` of the step tree.
    # Each level of ARG `mv-expand` silently caps at 128 expanded child rows
    # per parent (the same cap that caused the v0.7.76 P0 bug in
    # Get-AzLocalFleetHealthFailures). On nested operator trees the cap
    # compounds at every level, so any step with >128 siblings risked having
    # its deepest error silently dropped. The query now projects the raw
    # `progress.steps` array; the deepest-error walk runs client-side via
    # Resolve-AzLocalUpdateRunDeepestError. The ErrorCategory bucketing
    # likewise moves client-side. `stackTraceMatch` is computed server-side
    # because `extract()` is a scalar regex over the single (capped)
    # progressJson string and is not affected by mv-expand truncation.
    $kql = @"
extensibilityresources
| where type =~ 'microsoft.azurestackhci/clusters/updates/updateruns'
| extend segments = split(id, '/')
| extend
    SubscriptionId = tostring(segments[2]),
    ResourceGroup = tostring(segments[4]),
    ClusterName = tostring(segments[8]),
    UpdateName = tostring(segments[10]),
    RunId = tostring(segments[12])
| extend ClusterResourceId = strcat('/subscriptions/', SubscriptionId, '/resourceGroups/', ResourceGroup, '/providers/Microsoft.AzureStackHCI/clusters/', ClusterName)
| extend state = tostring(properties.state)
| extend StartTime = todatetime(properties.timeStarted)
| extend EndTime = todatetime(properties.lastUpdatedTime)
| extend DurationMinutes = iff(isnotnull(StartTime) and isnotnull(EndTime), toreal(datetime_diff('minute', EndTime, StartTime)), real(null))
| where StartTime >= datetime($sinceUtc)
$clusterClause
| extend progressObj = properties.progress
| extend progressStatus = tostring(progressObj.status)
| extend progressDescription = tostring(progressObj.description)
| extend progressJsonFull = tostring(properties.progress)
| extend ProgressJsonBytes = strlen(progressJsonFull)
| extend progressJsonCapped = substring(progressJsonFull, 0, 204800)
| extend stackTraceMatch = extract(@'raised an exception:[^\r\n]{0,500}', 0, progressJsonFull)
| project ClusterName, ResourceGroup, SubscriptionId, ClusterResourceId, UpdateName, RunId, State = state, StartTime, EndTime, DurationMinutes, ProgressSteps = progressObj.steps, StackTracePreview = stackTraceMatch, Status = progressStatus, ProgressDescription = progressDescription, ProgressJsonBytes, ProgressJson = progressJsonCapped
| order by StartTime desc, ClusterName asc
"@


    # Append the State filter AFTER the project line so it operates on the
    # renamed `State` (capital S) column. Skipping the filter when State='All'
    # passes every state through.
    if ($State -ne 'All') {
        $kql = $kql -replace '\| order by StartTime desc, ClusterName asc', "| where State =~ '$State'`n| order by StartTime desc, ClusterName asc"
    }

    Write-Log -Message "Querying Azure Resource Graph for update-run failures (State=$State, View=$View, Since=$($sinceUtc)$(if($UpdateRingTag){", UpdateRingTag=$UpdateRingTag"})..." -Level Info

    try {
        $rows = if ($SubscriptionId) {
            Invoke-AzResourceGraphQuery -Query $kql -SubscriptionId $SubscriptionId
        } else {
            Invoke-AzResourceGraphQuery -Query $kql
        }
    }
    catch {
        Write-Log -Message "Resource Graph query failed: $($_.Exception.Message)" -Level Error
        throw
    }

    if (-not $rows) { $rows = @() }
    Write-Log -Message "Resource Graph returned $($rows.Count) update-run row(s)." -Level Info

    # v0.7.76: Client-side deepest-error walk + ErrorCategory bucketing.
    # The KQL above projects the raw `progress.steps` array as ProgressSteps;
    # the legacy server-side mv-expand chain capped at 128 children per
    # level. We compute DeepestStepDepth / DeepestStepName / DeepestErrMsg /
    # ErrorCategory here so every step in the tree contributes regardless of
    # its sibling count.
    #
    # Tests that mock the already-projected post-ARG schema (rows that
    # arrive with DeepestStepDepth etc. already populated and no
    # ProgressSteps column) are left untouched so backward compatibility
    # is preserved.
    foreach ($r in $rows) {
        if ($null -eq $r) { continue }
        $hasSteps = ($r.PSObject -and ($r.PSObject.Properties.Match('ProgressSteps').Count -gt 0))
        if (-not $hasSteps) { continue }

        $walk = Resolve-AzLocalUpdateRunDeepestError -Steps $r.ProgressSteps
        $deepDepth = [int]$walk.Depth
        $deepName  = [string]$walk.Name
        $deepMsg   = if ($deepDepth -gt 0 -and $walk.Msg) {
            [string]$walk.Msg
        } elseif ($walk.FirstDescription) {
            [string]$walk.FirstDescription
        } else { '' }

        # ErrorCategory bucketing - mirrors the legacy KQL `case(...)`
        # exactly, using PowerShell `-match` (case-insensitive by default)
        # for `has`-style substring tests.
        $deepCategory =
            if     ($deepMsg -match 'UpdateSecuredCore|Secured-core')                                              { 'SecuredCore' }
            elseif ($deepMsg -match 'health check|HealthCheck|Check Update readiness')                             { 'HealthCheck' }
            elseif ($deepMsg -match 'CAU|Cluster-Aware')                                                            { 'CAU' }
            elseif ($deepMsg -match 'RotateSecrets|Rotate Secrets')                                                 { 'RotateSecrets' }
            elseif ($deepMsg -match 'MocArb|CliExtensions|Arc Prereq')                                              { 'ArcPrereqs' }
            elseif ($deepMsg -match 'certificate rotation|Certificate Rotation')                                    { 'Certificates' }
            elseif ($deepMsg -match 'preparation was terminated')                                                   { 'PreparationTerminated' }
            elseif ($deepMsg -match 'administrator operation|blocked by administrator')                             { 'AdminBlocked' }
            elseif ($deepMsg.Length -gt 0)                                                                          { 'Other' }
            else                                                                                                    { 'Unclassified' }

        # Attach the computed columns. Add-Member -Force overwrites if the
        # property already exists (defensive against mocks that pre-populate
        # them).
        $r | Add-Member -NotePropertyName 'DeepestStepDepth' -NotePropertyValue $deepDepth   -Force
        $r | Add-Member -NotePropertyName 'DeepestStepName'  -NotePropertyValue $deepName    -Force
        $r | Add-Member -NotePropertyName 'DeepestErrMsg'    -NotePropertyValue $deepMsg     -Force
        $r | Add-Member -NotePropertyName 'ErrorCategory'    -NotePropertyValue $deepCategory -Force
    }

    # Drop the intermediate ProgressSteps column so callers see the
    # documented schema only. (StackTracePreview, ProgressJson, and
    # ProgressJsonBytes are part of the documented schema and stay.)
    if ($rows.Count -gt 0) {
        $rows = @($rows | ForEach-Object {
            if ($null -eq $_) { return }
            if ($_.PSObject.Properties.Match('ProgressSteps').Count -gt 0) {
                $_ | Select-Object -Property * -ExcludeProperty ProgressSteps
            } else {
                $_
            }
        })
    }

    # Optional UpdateRing tag filter via secondary ARG query. The updateruns
    # resource does NOT carry the cluster's tags, so a second hop is needed.
    if ($UpdateRingTag) {
        $ringFilter = ConvertTo-AzLocalUpdateRingKqlFilter -UpdateRingValue $UpdateRingTag -TagAccessor "tostring(tags['UpdateRing'])"
        $tagKql = @"
resources
| where type =~ 'microsoft.azurestackhci/clusters'
$ringFilter
| project id = tolower(id)
"@

        try {
            $tagRows = if ($SubscriptionId) {
                Invoke-AzResourceGraphQuery -Query $tagKql -SubscriptionId $SubscriptionId
            } else {
                Invoke-AzResourceGraphQuery -Query $tagKql
            }
        }
        catch {
            Write-Log -Message "UpdateRing tag filter query failed: $($_.Exception.Message)" -Level Error
            throw
        }
        $allowed = @{}
        foreach ($r in @($tagRows)) { $allowed[$r.id] = $true }
        $before = $rows.Count
        $rows   = @($rows | Where-Object { $allowed.ContainsKey(($_.ClusterResourceId).ToLower()) })
        Write-Log -Message "Filtered to UpdateRing='$UpdateRingTag': $($rows.Count) of $before row(s) retained." -Level Info
    }

    # Always compute the latest-succeeded lookup (cheap second ARG query)
    # so the IsUnresolved column is populated on Detail-view rows even when
    # -OnlyUnresolved is not passed. This is useful information for pipeline
    # output and JSON exports - downstream consumers can filter client-side.
    $latestSucceededMap = @{}
    if ($View -eq 'Detail') {
        $succeededKql = @"
extensibilityresources
| where type =~ 'microsoft.azurestackhci/clusters/updates/updateruns'
| where tostring(properties.state) =~ 'Succeeded'
| extend segments = split(id, '/')
| extend ClusterResourceId = strcat('/subscriptions/', tostring(segments[2]), '/resourceGroups/', tostring(segments[4]), '/providers/Microsoft.AzureStackHCI/clusters/', tostring(segments[8]))
| extend UpdateName = tostring(segments[10])
| extend StartTime = todatetime(properties.timeStarted)
| summarize LatestSucceededStart = max(StartTime) by ClusterResourceId, UpdateName
"@

        try {
            $succeededRows = if ($SubscriptionId) {
                Invoke-AzResourceGraphQuery -Query $succeededKql -SubscriptionId $SubscriptionId
            } else {
                Invoke-AzResourceGraphQuery -Query $succeededKql
            }
        }
        catch {
            Write-Log -Message "Unresolved-check (latest Succeeded) query failed: $($_.Exception.Message)" -Level Warning
            $succeededRows = @()
        }
        $latestSucceededMap = @{}
        foreach ($s in @($succeededRows)) {
            if (-not $s) { continue }
            if (-not $s.ClusterResourceId) { continue }
            if (-not $s.LatestSucceededStart) { continue }
            $key = "$(($s.ClusterResourceId).ToLower())|$($s.UpdateName)"
            try {
                $latestSucceededMap[$key] = [datetime]$s.LatestSucceededStart
            }
            catch {
                Write-Log -Message "Skipping unresolved-check entry with unparseable LatestSucceededStart '$($s.LatestSucceededStart)' for $key" -Level Verbose
            }
        }
        Write-Log -Message "Latest-succeeded lookup loaded $($latestSucceededMap.Count) (cluster, update) entries." -Level Verbose
    }

    # Build the output the caller asked for.
    if ($View -eq 'Summary') {
        # Aggregate by ErrorCategory. ClusterCount desc puts the most-
        # widespread pattern first - the "fix this first" view.
        $output = @($rows |
            Group-Object -Property ErrorCategory |
            ForEach-Object {
                $first       = $_.Group | Select-Object -First 1
                $clusterList = @($_.Group | Select-Object -ExpandProperty ClusterName -Unique | Sort-Object)
                $latest      = ($_.Group | Measure-Object -Property StartTime -Maximum).Maximum
                [PSCustomObject]@{
                    ErrorCategory    = $_.Name
                    ClusterCount     = $clusterList.Count
                    FailureCount     = $_.Group.Count
                    AffectedClusters = ($clusterList -join ';')
                    LatestFailure    = $latest
                    SampleErrMsg     = if ($first.DeepestErrMsg) {
                        if ($first.DeepestErrMsg.Length -gt 400) {
                            $first.DeepestErrMsg.Substring(0, 400) + '...'
                        } else { $first.DeepestErrMsg }
                    } else { '' }
                    SampleStepName   = $first.DeepestStepName
                }
            } |
            Sort-Object @{Expression={$_.ClusterCount};Descending=$true},
                       @{Expression={$_.FailureCount};Descending=$true}
        )
    } else {
        # Detail view. Tag with IsUnresolved (always populated, see comment
        # above) and optionally filter. Drop ProgressJson when -IncludeRawProgress
        # was not supplied so the default output is pipeline-friendly.
        $rowsTagged = foreach ($r in $rows) {
            $key = "$(($r.ClusterResourceId).ToLower())|$($r.UpdateName)"
            $latestSucc = $null
            if ($latestSucceededMap.ContainsKey($key)) { $latestSucc = $latestSucceededMap[$key] }
            $isUnresolved = $true
            if ($null -ne $latestSucc -and $null -ne $r.StartTime -and ([datetime]$latestSucc) -ge ([datetime]$r.StartTime)) {
                $isUnresolved = $false
            }

            # v0.7.70: Fleet-scale failure-detail columns. The Azure portal SingleInstanceHistoryDetails
            # ReactView deep-link requires the cluster resource ID URL-encoded (the same
            # encoding the Azure portal expects). We URL-encode aggressively (every slash)
            # which the portal accepts as-is.
            $portalLink = ''
            if ($r.ClusterResourceId) {
                $encoded = [System.Uri]::EscapeDataString([string]$r.ClusterResourceId)
                $portalLink = "https://portal.azure.com/#view/Microsoft_AzureStackHCI_PortalExtension/SingleInstanceHistoryDetails.ReactView/resourceId/$encoded/updateName~/null/updateRunName~/null/refresh~/false"
            }

            # CurrentStep is a computed column derived from the deepest in-progress step:
            # Failed -> the deepest failing step name (fall back to ProgressDescription)
            # else -> ProgressDescription
            $currentStep = ''
            if ($r.State -eq 'Failed') {
                if ($r.DeepestStepName) { $currentStep = $r.DeepestStepName }
                elseif ($r.ProgressDescription) { $currentStep = $r.ProgressDescription }
            } elseif ($r.ProgressDescription) {
                $currentStep = $r.ProgressDescription
            }

            # Formatted duration string "Xh Ym Zs" computed from StartTime/EndTime
            # (KQL gives us only DurationMinutes rounded). Skip if either bound is null.
            $durationFormatted = ''
            if ($r.StartTime -and $r.EndTime) {
                try {
                    $ts = ([datetime]$r.EndTime) - ([datetime]$r.StartTime)
                    $parts = @()
                    if ($ts.Days -gt 0)    { $parts += "$($ts.Days)d" }
                    if ($ts.Hours -gt 0)   { $parts += "$($ts.Hours)h" }
                    if ($ts.Minutes -gt 0) { $parts += "$($ts.Minutes)m" }
                    if ($ts.Seconds -gt 0 -or $parts.Count -eq 0) { $parts += "$($ts.Seconds)s" }
                    $durationFormatted = $parts -join ' '
                } catch { $durationFormatted = '' }
            }

            $obj = [PSCustomObject]@{
                ClusterName        = $r.ClusterName
                ResourceGroup      = $r.ResourceGroup
                SubscriptionId     = $r.SubscriptionId
                ClusterResourceId  = $r.ClusterResourceId
                ClusterPortalUrl   = if ($r.ClusterResourceId) { "https://portal.azure.com/#@/resource$($r.ClusterResourceId)" } else { '' }
                UpdateName         = $r.UpdateName
                RunId              = $r.RunId
                State              = $r.State
                Status             = $r.Status
                CurrentStep        = $currentStep
                StartTime          = $r.StartTime
                EndTime            = $r.EndTime
                LastUpdated        = $r.EndTime
                Duration           = $durationFormatted
                DurationMinutes    = $r.DurationMinutes
                DeepestStepDepth   = $r.DeepestStepDepth
                DeepestStepName    = $r.DeepestStepName
                DeepestErrMsg      = $r.DeepestErrMsg
                StackTracePreview  = $r.StackTracePreview
                ErrorCategory      = $r.ErrorCategory
                UpdateRunPortalUrl = $portalLink
                ProgressJsonBytes  = $r.ProgressJsonBytes
                IsUnresolved       = $isUnresolved
            }
            if ($IncludeRawProgress) {
                $obj | Add-Member -NotePropertyName 'ProgressJson' -NotePropertyValue $r.ProgressJson
            }
            $obj
        }

        if ($OnlyUnresolved) {
            $before = @($rowsTagged).Count
            $output = @($rowsTagged | Where-Object { $_.IsUnresolved })
            Write-Log -Message "OnlyUnresolved filter: $($output.Count) of $before row(s) retained." -Level Info
        } else {
            $output = @($rowsTagged)
        }
    }

    # Export if requested.
    if ($ExportPath) {
        try {
            $ExportPath = Resolve-SafeOutputPath -Path $ExportPath
            $exportDir  = Split-Path -Path $ExportPath -Parent
            if ($exportDir -and -not (Test-Path -Path $exportDir)) {
                $null = New-Item -ItemType Directory -Path $exportDir -Force
            }
            $ext = [System.IO.Path]::GetExtension($ExportPath).ToLower()
            switch ($ext) {
                '.json' {
                    # Depth 4 is enough for these flat rows; the raw
                    # progressJson is already a string so no nested depth
                    # is consumed by it.
                    Write-Utf8NoBomFile -Path $ExportPath -Content ($output | ConvertTo-Json -Depth 4)
                    Write-Log -Message "Update-run failures ($View) exported to JSON: $ExportPath" -Level Success
                }
                default {
                    # Exclude ProgressJson from CSV - the multi-line string
                    # breaks most CSV readers even with quoting.
                    $csvRows = $output | Select-Object -Property * -ExcludeProperty ProgressJson
                    $csvRows | ConvertTo-SafeCsvCollection | Export-Csv -Path $ExportPath -NoTypeInformation -Force
                    Write-Log -Message "Update-run failures ($View) exported to CSV: $ExportPath" -Level Success
                }
            }
        }
        catch {
            Write-Log -Message "Failed to export update-run failures ($View): $($_.Exception.Message)" -Level Error
        }
    }

    # Summary log to host stream.
    if ($View -eq 'Summary' -and $output.Count -gt 0) {
        Write-Log -Message "" -Level Info
        Write-Log -Message "Top failure categories:" -Level Header
        foreach ($cat in $output | Select-Object -First 5) {
            Write-Log -Message (" {0,-22} {1,3} clusters, {2,4} failures" -f $cat.ErrorCategory, $cat.ClusterCount, $cat.FailureCount) -Level Info
        }
    }

    # Return objects when -PassThru or when no export was requested.
    if ($PassThru -or -not $ExportPath) {
        return $output
    }
}