Public/Stop-MSIXTracing.ps1

function Stop-MSIXTracing {
<#
.SYNOPSIS
    Stops the MSIX ETW trace session and converts the ETL to a readable log.

.DESCRIPTION
    Stops the MsixTrace logman session, locates the ETL file, and — unless
    -SkipParsing is set — converts it to readable text using tracerpt.
    Events with severity Warning or higher are also written to a separate
    warnings file.

    Returns a PSCustomObject with the paths to the generated files:
      EtlPath - path to the raw ETL file
      LogPath - path to the parsed log (empty when -SkipParsing)
      WarningsPath - path to the warnings-only log (empty when none or -SkipParsing)

.PARAMETER LogPath
    Path for the parsed output log file.
    Defaults to %TEMP%\MSIXTrace_<timestamp>.log.

.PARAMETER SkipParsing
    When set, the ETL file is kept as-is and no text log is generated.
    Useful when you want to open the ETL in a tool like WPA or ETLViewer.

.EXAMPLE
    Stop-MSIXTracing

.EXAMPLE
    Stop-MSIXTracing -LogPath "C:\Logs\myapp.log"

.EXAMPLE
    Stop-MSIXTracing -SkipParsing

.NOTES
    Requires elevation (Administrator).
    Andreas Nick, 2024
#>


    [CmdletBinding()]
    param(
        [string] $LogPath = (Join-Path $env:TEMP ("MSIXTrace_{0}.log" -f [datetime]::Now.ToString("yyyy-MM-dd_HHmmss"))),
        [switch] $SkipParsing
    )

    $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
        [Security.Principal.WindowsBuiltInRole]::Administrator)
    if (-not $isAdmin) {
        throw "Stop-MSIXTracing must be run as Administrator."
    }

    # Verify the session exists before trying to stop it.
    $query = logman query MsixTrace 2>$null
    if ($null -eq $query -or $query.Count -lt 4) {
        throw "No active MsixTrace session found. Start tracing first with Start-MSIXTracing."
    }

    Write-Verbose "Stopping MSIX trace session..."
    try {
        $result = logman stop MsixTrace 2>&1
        if ($LASTEXITCODE -ne 0) {
            throw "logman stop failed: $result"
        }
    }
    catch {
        Write-Error "Failed to stop MSIX tracing: $_"
        throw
    }

    # Locate the ETL file from logman query output.
    $etlPath = $null
    $query = logman query MsixTrace 2>$null
    foreach ($line in $query) {
        if ($line -and $line.TrimEnd().EndsWith(".etl")) {
            $tokens = $line.Trim().Split()
            $etlPath = $tokens[$tokens.Count - 1]
        }
    }

    if (-not $etlPath -or -not (Test-Path $etlPath)) {
        Write-Warning "ETL file not found via logman query. Raw trace may be at the path specified during Start-MSIXTracing."
        return [PSCustomObject]@{ EtlPath = $etlPath; LogPath = ''; WarningsPath = '' }
    }

    Write-Host "Raw ETL: $etlPath"

    if ($SkipParsing) {
        Write-Verbose "Skipping log parsing (-SkipParsing specified)."
        return [PSCustomObject]@{ EtlPath = $etlPath; LogPath = ''; WarningsPath = '' }
    }

    # Convert ETL -> XML with tracerpt, then parse XML into readable text.
    $now         = [datetime]::Now.ToString("yyyy-MM-dd_HHmmss")
    $tempXmlPath = Join-Path $env:TEMP ("MSIXTrace_{0}.xml" -f $now)
    $warningsPath = [System.IO.Path]::ChangeExtension($LogPath, $null).TrimEnd('.') + "_warnings.log"

    try {
        Write-Verbose "Converting ETL to XML via tracerpt..."
        $result = tracerpt -l $etlPath -o $tempXmlPath 2>&1
        if ($LASTEXITCODE -ne 0) {
            throw "tracerpt failed: $result"
        }

        if (-not (Test-Path $tempXmlPath)) {
            throw "tracerpt produced no output file at $tempXmlPath"
        }

        Write-Verbose "Parsing XML events..."
        $xmlData = New-Object System.Xml.XmlDocument
        $xmlData.Load($tempXmlPath)

        $logLines     = [System.Collections.Generic.List[string]]::new()
        $warningLines = [System.Collections.Generic.List[string]]::new()

        # Event 0 is the header record — start from index 1.
        for ($i = 1; $i -lt $xmlData.Events.Event.Count; $i++) {
            $event  = $xmlData.Events.Event[$i]
            $level  = [int]$event.System.Level

            $levelLabel = switch ($level) {
                1 { 'Fatal'   }
                2 { 'Error'   }
                3 { 'Warning' }
                4 { 'Info'    }
                5 { 'Verbose' }
                default { "Level $level" }
            }

            $line  = $event.System.TimeCreated.SystemTime
            $line += ", $levelLabel"
            $line += ", " + $event.RenderingInfo.Task

            foreach ($data in $event.EventData.Data) {
                if ($null -eq $data) { continue }

                $name = $data.Name.ToString()
                $line += ", $name"

                if ($name.ToUpper() -eq 'HR') {
                    # Display HResults as hex.
                    $line += ': ' + ('0x{0:x}' -f [System.Convert]::ToInt32($data.'#text'))
                } elseif ($data.'#text') {
                    $line += ': ' + $data.'#text'.Trim()
                } else {
                    $line += ': <null>'
                }
            }

            $logLines.Add($line)

            # Level < 4 means Fatal / Error / Warning.
            if ($level -lt 4) {
                $warningLines.Add($line)
            }
        }

        $logLines | Set-Content -Path $LogPath -Encoding UTF8
        Write-Host "Parsed log: $LogPath"

        $actualWarningsPath = ''
        if ($warningLines.Count -gt 0) {
            $warningLines | Set-Content -Path $warningsPath -Encoding UTF8
            Write-Host "Warnings log: $warningsPath"
            $actualWarningsPath = $warningsPath
        }

        return [PSCustomObject]@{
            EtlPath      = $etlPath
            LogPath      = $LogPath
            WarningsPath = $actualWarningsPath
        }
    }
    catch {
        Write-Error "Failed to parse MSIX trace: $_"
        throw
    }
    finally {
        if (Test-Path $tempXmlPath) {
            Remove-Item $tempXmlPath -Force -ErrorAction SilentlyContinue
        }
    }
}