Public/Merge-FlightRecorderDumps.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Merge-FlightRecorderDumps { <# .SYNOPSIS Merges paired client + server flight recorder dumps into a single interleaved JSONL timeline. .DESCRIPTION Takes a dumpId (or explicit file paths) and merges the client and server JSONL dumps into one file sorted by wall-clock timestamp. Each event gets an _source field ('client' or 'server') for visual distinction. Context records from both files are merged into a combined snapshot. Handles single-file scenarios gracefully — if only the client or server dump exists, outputs what's available without error. .PARAMETER DumpId The paired dump correlation ID. Resolves to client-{DumpId}.jsonl and server-{DumpId}.jsonl in the dump directory. .PARAMETER ClientPath Explicit path to a client dump file. Use with -ServerPath for arbitrary file pairs, or alone for a single file. .PARAMETER ServerPath Explicit path to a server dump file. Use with -ClientPath for arbitrary file pairs, or alone for a single file. .PARAMETER DumpDir Directory containing paired dumps. Default: auto-detected from Electron userData path under admin/flight-recorder-dumps/. .PARAMETER OutputPath Path to write the merged JSONL file. If omitted, writes to merged-{DumpId}.jsonl in the dump directory (or current dir for explicit paths). .PARAMETER PassThru Return the output file info object instead of just writing. .EXAMPLE # Merge by dumpId Merge-FlightRecorderDumps -DumpId 'a1b2c3d4' .EXAMPLE # Merge explicit files Merge-FlightRecorderDumps -ClientPath ./client-dump.jsonl -ServerPath ./server-dump.jsonl .EXAMPLE # Single file — just adds _source tagging Merge-FlightRecorderDumps -ClientPath ./flight-recorder-2026-06-24.jsonl .EXAMPLE # Merge and pipe to report Merge-FlightRecorderDumps -DumpId 'a1b2c3d4' -PassThru | Get-FlightRecorderReport .LINK Show-AITriadHelp .LINK Get-AzureFlightRecorder .LINK Get-FlightRecorderDump .LINK Get-FlightRecorderReport .LINK Request-FlightRecorderDump .LINK Show-FlightRecorder #> [CmdletBinding(DefaultParameterSetName = 'ByDumpId')] param( [Parameter(Mandatory, ParameterSetName = 'ByDumpId', Position = 0)] [string]$DumpId, [Parameter(ParameterSetName = 'ByPath')] [string]$ClientPath, [Parameter(ParameterSetName = 'ByPath')] [string]$ServerPath, [Parameter(ParameterSetName = 'ByDumpId')] [string]$DumpDir, [Parameter()] [string]$OutputPath, [Parameter()] [switch]$PassThru ) # ── Resolve file paths ─────────────────────────────────────────────── if ($PSCmdlet.ParameterSetName -eq 'ByDumpId') { if (-not $DumpDir) { $DumpDir = Get-FlightRecorderDir # shared resolver (t/1712) $pairedDir = Join-Path (Split-Path $DumpDir) 'admin/flight-recorder-dumps' if (Test-Path $pairedDir) { $DumpDir = $pairedDir } } if (-not (Test-Path $DumpDir)) { throw (New-ActionableError ` -Goal 'Merge flight recorder dumps' ` -Problem "Dump directory not found: $DumpDir" ` -Location 'Merge-FlightRecorderDumps' ` -NextSteps @( 'Verify the dump directory exists' 'Use -DumpDir to specify a custom path' 'Run Get-FlightRecorderDump to list available dumps' )) } $ClientPath = Join-Path $DumpDir "client-${DumpId}.jsonl" $ServerPath = Join-Path $DumpDir "server-${DumpId}.jsonl" # Fall back to legacy naming if paired naming not found if (-not (Test-Path $ClientPath) -and -not (Test-Path $ServerPath)) { $legacyClient = Join-Path $DumpDir "flight-recorder-${DumpId}.jsonl" $legacyServer = Join-Path $DumpDir "server-flight-recorder-${DumpId}.jsonl" if (Test-Path $legacyClient) { $ClientPath = $legacyClient } if (Test-Path $legacyServer) { $ServerPath = $legacyServer } } if (-not $OutputPath) { $OutputPath = Join-Path $DumpDir "merged-${DumpId}.jsonl" } } else { if (-not $ClientPath -and -not $ServerPath) { throw (New-ActionableError ` -Goal 'Merge flight recorder dumps' ` -Problem 'At least one of -ClientPath or -ServerPath must be provided' ` -Location 'Merge-FlightRecorderDumps' ` -NextSteps @( 'Provide -ClientPath, -ServerPath, or both' 'Use -DumpId instead to resolve files by correlation ID' )) } if (-not $OutputPath) { $baseDir = if ($ClientPath) { Split-Path $ClientPath } else { Split-Path $ServerPath } $baseName = if ($ClientPath) { [System.IO.Path]::GetFileNameWithoutExtension($ClientPath) } else { [System.IO.Path]::GetFileNameWithoutExtension($ServerPath) } $OutputPath = Join-Path $baseDir "merged-${baseName}.jsonl" } } $hasClient = $ClientPath -and (Test-Path $ClientPath) $hasServer = $ServerPath -and (Test-Path $ServerPath) if (-not $hasClient -and -not $hasServer) { throw (New-ActionableError ` -Goal 'Merge flight recorder dumps' ` -Problem 'No dump files found' ` -Location 'Merge-FlightRecorderDumps' ` -NextSteps @( "Client path: $ClientPath — $(if (Test-Path $ClientPath) {'exists'} else {'NOT FOUND'})" "Server path: $ServerPath — $(if (Test-Path $ServerPath) {'exists'} else {'NOT FOUND'})" 'Use Get-FlightRecorderDump to list available files' )) } $sources = @() if ($hasClient) { $sources += 'client' } if ($hasServer) { $sources += 'server' } Write-Verbose "Merging sources: $($sources -join ' + ')" # ── Parse both files ───────────────────────────────────────────────── $headers = @{} $dictionaries = @{} $contexts = @{} $triggers = @{} $allEvents = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($source in $sources) { $filePath = if ($source -eq 'client') { $ClientPath } else { $ServerPath } Write-Verbose "Reading $source dump: $filePath" $lineNum = 0 foreach ($line in [System.IO.File]::ReadLines($filePath)) { $lineNum++ if (-not $line.Trim()) { continue } try { $obj = $line | ConvertFrom-Json } catch { Write-Warning "$source line $lineNum — skipped unparseable line" continue } $recType = $null if ($obj.PSObject.Properties['_type']) { $recType = $obj._type } switch ($recType) { 'header' { $headers[$source] = $obj } 'dictionary' { $dictionaries[$source] = $obj } 'context' { $contexts[$source] = $obj } 'trigger' { $triggers[$source] = $obj } 'event' { $obj | Add-Member -NotePropertyName '_source' -NotePropertyValue $source -Force $allEvents.Add($obj) } } } $eventCount = @($allEvents | Where-Object { $_._source -eq $source }).Count Write-Verbose "${source}: $eventCount events" } # ── Build merged header ────────────────────────────────────────────── $mergedHeader = [ordered]@{ _type = 'header' _version = 1 schema_version = '1.0.0' merged = $true merge_timestamp = (Get-Date -Format 'o') sources = $sources total_events = $allEvents.Count } foreach ($source in $sources) { if ($headers.ContainsKey($source)) { $h = $headers[$source] $mergedHeader["${source}_timestamp"] = $h.timestamp $mergedHeader["${source}_uptime_ms"] = $h.uptime_ms $mergedHeader["${source}_capacity"] = $h.ring_buffer_capacity $mergedHeader["${source}_retained"] = $h.ring_buffer_events_retained $mergedHeader["${source}_lost"] = $h.events_lost } } # ── Merge dictionaries ─────────────────────────────────────────────── $mergedEntries = [System.Collections.Generic.List[object]]::new() $seenValues = @{} foreach ($source in $sources) { if (-not $dictionaries.ContainsKey($source)) { continue } $dict = $dictionaries[$source] if (-not $dict.PSObject.Properties['entries']) { continue } foreach ($entry in $dict.entries) { $key = "$($entry.category):$($entry.value)" if (-not $seenValues.ContainsKey($key)) { $seenValues[$key] = $true $mergedEntries.Add([ordered]@{ handle = $mergedEntries.Count category = $entry.category value = $entry.value registered_at = $entry.registered_at source = $source }) } } } $mergedDictionary = [ordered]@{ _type = 'dictionary' entries = @($mergedEntries) } # ── Merge context records ──────────────────────────────────────────── $mergedContext = [ordered]@{ _type = 'context' } foreach ($source in $sources) { if (-not $contexts.ContainsKey($source)) { continue } $ctx = $contexts[$source] foreach ($prop in $ctx.PSObject.Properties) { if ($prop.Name -eq '_type') { continue } $mergedContext[$prop.Name] = $prop.Value } # Tag which source contributed which sections $mergedContext["_${source}_fields"] = @($ctx.PSObject.Properties | Where-Object { $_.Name -ne '_type' } | ForEach-Object { $_.Name }) } # ── Sort events by wall clock ──────────────────────────────────────── $sorted = $allEvents | Sort-Object { $_._wall } # Re-sequence merged events $seq = 0 foreach ($evt in $sorted) { $evt | Add-Member -NotePropertyName '_merged_seq' -NotePropertyValue $seq -Force $seq++ } # ── Write merged JSONL ─────────────────────────────────────────────── $outputDir = Split-Path $OutputPath if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } $writer = [System.IO.StreamWriter]::new($OutputPath, $false, [System.Text.UTF8Encoding]::new($false)) try { $writer.WriteLine(($mergedHeader | ConvertTo-Json -Depth 10 -Compress)) $writer.WriteLine(($mergedDictionary | ConvertTo-Json -Depth 10 -Compress)) if ($mergedContext.Count -gt 1) { $writer.WriteLine(($mergedContext | ConvertTo-Json -Depth 10 -Compress)) } foreach ($evt in $sorted) { $writer.WriteLine(($evt | ConvertTo-Json -Depth 10 -Compress)) } foreach ($source in $sources) { if ($triggers.ContainsKey($source)) { $trig = $triggers[$source] $trig | Add-Member -NotePropertyName '_source' -NotePropertyValue $source -Force $writer.WriteLine(($trig | ConvertTo-Json -Depth 10 -Compress)) } } } finally { $writer.Dispose() } $fileInfo = Get-Item $OutputPath $clientCount = @($sorted | Where-Object { $_._source -eq 'client' }).Count $serverCount = @($sorted | Where-Object { $_._source -eq 'server' }).Count Write-Host "Merged $($allEvents.Count) events ($clientCount client + $serverCount server) → $OutputPath" -ForegroundColor Green Write-Host " File size: $([math]::Round($fileInfo.Length / 1024, 1)) KB" -ForegroundColor DarkGray if ($PassThru) { $fileInfo | Add-Member -NotePropertyName Events -NotePropertyValue $allEvents.Count -Force $fileInfo | Add-Member -NotePropertyName ClientEvents -NotePropertyValue $clientCount -Force $fileInfo | Add-Member -NotePropertyName ServerEvents -NotePropertyValue $serverCount -Force $fileInfo | Add-Member -NotePropertyName Sources -NotePropertyValue $sources -Force $fileInfo } } |