Public/Get-AzureFlightRecorder.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Get-AzureFlightRecorder {
    <#
    .SYNOPSIS
        Retrieves flight recorder dumps from the Azure-hosted Taxonomy Editor.
 
    .DESCRIPTION
        Reads flight recorder dumps from the GitHub data repo (works even when the
        app is not running). Uses 'gh api' for authentication.
 
        Use -TriggerDump to create a fresh dump on the running server (requires
        session cookie for Azure Easy Auth).
 
    .PARAMETER List
        List available dump files without downloading.
 
    .PARAMETER Last
        Download the N most recent dump files. Default: 1.
 
    .PARAMETER TriggerDump
        Trigger a fresh server-side flight recorder dump, then download it.
        Requires the app to be running and a valid session cookie.
 
    .PARAMETER Filename
        Download a specific dump file by name.
 
    .PARAMETER OutputPath
        Directory to save downloaded files. Default: current directory.
 
    .PARAMETER Open
        Open downloaded dumps in the flight recorder viewer after saving.
 
    .PARAMETER DataRepo
        GitHub data repo in owner/repo format.
 
    .PARAMETER SessionCookie
        AppServiceAuthSession cookie value for -TriggerDump. Falls back to
        $env:TAXONOMY_SESSION_COOKIE.
 
    .PARAMETER BaseUrl
        Base URL of the Taxonomy Editor instance (for -TriggerDump).
 
    .EXAMPLE
        Get-AzureFlightRecorder -List
 
    .EXAMPLE
        Get-AzureFlightRecorder -Last 3
 
    .EXAMPLE
        Get-AzureFlightRecorder -TriggerDump
 
    .EXAMPLE
        Get-AzureFlightRecorder -Filename 'server-flight-recorder-2026-06-12T10-30-00.000Z.jsonl'
    #>

    [CmdletBinding(DefaultParameterSetName = 'List')]
    param(
        [Parameter(ParameterSetName = 'List')]
        [switch]$List,

        [Parameter(ParameterSetName = 'Download')]
        [int]$Last = 1,

        [Parameter(ParameterSetName = 'Trigger')]
        [switch]$TriggerDump,

        [Parameter(ParameterSetName = 'ByName', Mandatory)]
        [string]$Filename,

        [Parameter()]
        [string]$OutputPath = '.',

        [Parameter()]
        [switch]$Open,

        [Parameter()]
        [string]$DataRepo = 'jpsnover/ai-triad-data',

        [Parameter()]
        [string]$SessionCookie,

        [Parameter()]
        [string]$BaseUrl = 'https://taxonomy-editor.yellowbush-aeda037d.eastus.azurecontainerapps.io'
    )

    $BaseUrl = $BaseUrl.TrimEnd('/')

    # ── Verify gh CLI is available ──
    function Assert-GhCli {
        if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
            throw (New-ActionableError `
                -Goal 'Read flight recorder dumps from GitHub' `
                -Problem 'The GitHub CLI (gh) is not installed or not in PATH' `
                -Location 'Get-AzureFlightRecorder' `
                -NextSteps @(
                    'Install: winget install GitHub.cli',
                    'Then authenticate: gh auth login'
                ))
        }
    }

    # ── List files from GitHub ──
    function Get-GitHubDumpList {
        Assert-GhCli
        Write-Host "Fetching flight recorder dumps from $DataRepo ..." -ForegroundColor Cyan
        $raw = gh api "repos/$DataRepo/contents/flight-recorder" 2>&1
        if ($LASTEXITCODE -ne 0) {
            if ("$raw" -match '404|Not Found') {
                Write-Warning "No flight-recorder directory in $DataRepo yet."
                Write-Warning 'Dumps appear after the server persists them (requires latest container image).'
                return @()
            }
            throw (New-ActionableError `
                -Goal 'List flight recorder dumps from GitHub' `
                -Problem "gh api failed: $raw" `
                -Location 'Get-AzureFlightRecorder:Get-GitHubDumpList' `
                -NextSteps @(
                    'Check gh auth status: gh auth status',
                    "Verify repo access: gh api repos/$DataRepo"
                ))
        }
        $items = $raw | ConvertFrom-Json
        @($items) |
            Where-Object { $_.name -match '^(server-)?flight-recorder-.+\.jsonl$' } |
            Sort-Object { $_.name } -Descending
    }

    # ── Download a file from GitHub ──
    function Save-GitHubDumpFile {
        param([string]$Name, [int]$Size = 0)
        Assert-GhCli

        $outDir = Resolve-Path $OutputPath -ErrorAction SilentlyContinue
        if (-not $outDir) {
            New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
            $outDir = Resolve-Path $OutputPath
        }
        $outFile = Join-Path $outDir $Name

        Write-Host " Downloading $Name ..." -ForegroundColor Cyan
        gh api "repos/$DataRepo/contents/flight-recorder/$Name" -H 'Accept: application/vnd.github.raw' > $outFile 2>&1
        if ($LASTEXITCODE -ne 0) {
            $errContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
            Remove-Item $outFile -ErrorAction SilentlyContinue
            throw (New-ActionableError `
                -Goal "Download flight recorder dump $Name" `
                -Problem "gh api download failed: $errContent" `
                -Location 'Get-AzureFlightRecorder:Save-GitHubDumpFile' `
                -NextSteps @('Check gh auth status', 'Verify the file exists in the repo'))
        }
        $fileInfo = Get-Item $outFile
        $fileInfo | Add-Member -NotePropertyName Source -NotePropertyValue 'GitHub' -Force

        # Parse header for summary
        $lines = Get-Content $outFile -TotalCount 5
        $events = 0; $trigger = $null; $version = $null; $debateId = $null
        foreach ($line in $lines) {
            try {
                $obj = $line | ConvertFrom-Json
                if ($obj._type -eq 'header') {
                    $events = $obj.ring_buffer_events_retained
                    $version = $obj.app_version
                    $debateId = $obj.active_debate_id
                }
                if ($obj._type -eq 'trigger') { $trigger = $obj.trigger_type }
            } catch { }
        }
        $fileInfo | Add-Member -NotePropertyName Events -NotePropertyValue $events -Force
        $fileInfo | Add-Member -NotePropertyName Trigger -NotePropertyValue $trigger -Force
        $fileInfo | Add-Member -NotePropertyName AppVersion -NotePropertyValue $version -Force
        $fileInfo | Add-Member -NotePropertyName DebateId -NotePropertyValue $debateId -Force

        Write-Host " Saved: $outFile ($([math]::Round($fileInfo.Length / 1024, 1)) KB, $events events)" -ForegroundColor Green
        return $fileInfo
    }

    # ── REST API helper (for TriggerDump only) ──
    function Invoke-FRApi {
        param([string]$Method, [string]$Endpoint, [switch]$Raw)
        $cookie = if ($SessionCookie) { $SessionCookie } else { $env:TAXONOMY_SESSION_COOKIE }
        if (-not $cookie) {
            throw (New-ActionableError `
                -Goal 'Authenticate with Azure Taxonomy Editor' `
                -Problem 'No session cookie provided. -TriggerDump requires Azure Easy Auth.' `
                -Location 'Get-AzureFlightRecorder' `
                -NextSteps @(
                    "Log in at $BaseUrl in your browser",
                    'Open DevTools (F12) > Application > Cookies',
                    "Copy the 'AppServiceAuthSession' cookie value",
                    '$env:TAXONOMY_SESSION_COOKIE = ''<paste cookie value here>'''
                ))
        }
        $url = "$BaseUrl$Endpoint"
        try {
            $headers = @{ Cookie = "AppServiceAuthSession=$cookie" }
            $params = @{
                Uri             = $url
                Method          = $Method
                Headers         = $headers
                UseBasicParsing = $true
                TimeoutSec      = 30
            }
            if ($Method -eq 'POST') {
                $params.ContentType = 'application/json'
                $params.Body = '{}'
            }
            $response = Invoke-WebRequest @params
            if ($Raw) { return $response }
            $ct = $response.Headers['Content-Type']
            if ($ct -and $ct -notmatch 'application/json') {
                if ($response.Content -match '<title>Sign In') {
                    throw (New-ActionableError `
                        -Goal 'Authenticate with Azure Taxonomy Editor' `
                        -Problem 'Session cookie is expired or invalid' `
                        -Location 'Get-AzureFlightRecorder:Invoke-FRApi' `
                        -NextSteps @(
                            "Log in at $BaseUrl in your browser",
                            "Copy a fresh 'AppServiceAuthSession' cookie",
                            '$env:TAXONOMY_SESSION_COOKIE = ''<paste new value>'''
                        ))
                }
                throw "Unexpected response Content-Type: $ct"
            }
            return $response.Content | ConvertFrom-Json
        } catch [Microsoft.PowerShell.Commands.HttpResponseException] {
            $status = [int]$_.Exception.Response.StatusCode
            throw (New-ActionableError `
                -Goal "Call flight recorder API" `
                -Problem "HTTP $status from $Method $url" `
                -Location 'Get-AzureFlightRecorder:Invoke-FRApi' `
                -NextSteps @(
                    "Verify the app is running: open $BaseUrl in a browser",
                    'If 401/403: refresh session cookie',
                    'If 404: deploy the latest container image'
                ))
        }
    }

    # ── TriggerDump (REST API — requires running app) ──
    if ($PSCmdlet.ParameterSetName -eq 'Trigger' -or $TriggerDump) {
        Write-Host 'Triggering server-side flight recorder dump...' -ForegroundColor Cyan
        $result = Invoke-FRApi -Method POST -Endpoint '/api/flight-recorder/server-dump'
        $fname = if ($result.PSObject.Properties['filename']) { $result.filename } else { $null }
        if (-not $fname) {
            throw (New-ActionableError `
                -Goal 'Trigger server flight recorder dump' `
                -Problem 'Server returned OK but no filename in response' `
                -Location 'Get-AzureFlightRecorder' `
                -NextSteps @(
                    "Check server logs: az containerapp logs show -n taxonomy-editor -g rg-aitriad --tail 50",
                    'The server recorder may be uninitialized'
                ))
        }
        Write-Host " Server dump created: $fname" -ForegroundColor Green
        Write-Host ' Waiting for GitHub persistence ...' -ForegroundColor DarkGray
        Start-Sleep -Seconds 5
        $saved = Save-GitHubDumpFile -Name $fname
        if ($Open) { $saved | Show-FlightRecorder }
        return $saved
    }

    # ── GitHub-based operations (work when app is down) ──
    $ghFiles = Get-GitHubDumpList
    if (-not $ghFiles -or @($ghFiles).Count -eq 0) {
        Write-Warning 'No flight recorder dumps found.'
        Write-Warning 'Trigger a dump with: Get-AzureFlightRecorder -TriggerDump'
        return
    }

    # ── ByName ──
    if ($PSCmdlet.ParameterSetName -eq 'ByName') {
        $match = @($ghFiles) | Where-Object { $_.name -eq $Filename }
        if (-not $match) {
            Write-Warning "File '$Filename' not found. Available dumps:"
            @($ghFiles) | ForEach-Object { Write-Warning " $($_.name)" }
            return
        }
        $saved = Save-GitHubDumpFile -Name $Filename -Size $match.size
        if ($Open) { $saved | Show-FlightRecorder }
        return $saved
    }

    # ── List ──
    if ($PSCmdlet.ParameterSetName -eq 'List') {
        Write-Host " $(@($ghFiles).Count) dump file(s) available:" -ForegroundColor Green
        @($ghFiles) | ForEach-Object {
            [PSCustomObject]@{
                Name     = $_.name
                Size     = "$([math]::Round($_.size / 1024, 1)) KB"
                Type     = if ($_.name -match '^server-') { 'server' } else { 'client' }
            }
        } | Format-Table -AutoSize
        return
    }

    # ── Download (most recent N) ──
    $toDownload = @($ghFiles) | Select-Object -First $Last
    Write-Host " Downloading $(@($toDownload).Count) of $(@($ghFiles).Count) dump(s)..." -ForegroundColor Cyan
    $downloaded = @()
    foreach ($f in $toDownload) {
        $downloaded += Save-GitHubDumpFile -Name $f.name -Size $f.size
    }
    if ($Open -and $downloaded.Count -gt 0) {
        $downloaded | ForEach-Object { $_ | Show-FlightRecorder }
    }
    return $downloaded
}