Public/Export-IntuneAgentDiagnostic.ps1

function Export-IntuneAgentDiagnostic {
    <#
    .EXTERNALHELP IntuneScriptLab-Help.xml
    .SYNOPSIS
        Packs the agent's logs, its registry state and the parsed timelines into one zip for a ticket.
    #>

    [CmdletBinding()]
    [OutputType('IntuneScriptLab.Diagnostic')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Path,

        [string]$LogPath,

        [switch]$SkipRegistry,

        [switch]$SkipTimeline,

        [switch]$Force
    )
    Write-Verbose "Starting $($MyInvocation.MyCommand.Name)"

    if ([System.IO.Path]::GetExtension($Path) -ne '.zip') { $Path = "$Path.zip" }
    $zip = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    if ((Test-Path -LiteralPath $zip) -and -not $Force) {
        throw "$zip exists; use -Force to overwrite it"
    }
    if (-not $LogPath) {
        $programData = if ($env:ProgramData) { $env:ProgramData } else { 'C:\ProgramData' }
        $LogPath = Join-Path -Path $programData -ChildPath 'Microsoft\IntuneManagementExtension\Logs'
    }
    $stagingName = "IntuneScriptLab-diag-$([guid]::NewGuid().ToString('N'))"
    $staging = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $stagingName
    $manifest = [System.Collections.Generic.List[string]]::new()
    $utf8 = [System.Text.UTF8Encoding]::new($false)

    function Write-Section {
        param([string]$Relative, [string[]]$Lines)
        $file = Join-Path -Path $staging -ChildPath $Relative
        $null = New-Item -ItemType Directory -Path (Split-Path -Path $file -Parent) -Force
        [System.IO.File]::WriteAllLines($file, [string[]]$Lines, $utf8)
    }

    # A log the agent holds open copies through a shared-read stream
    function Copy-SharedFile {
        param([string]$Source, [string]$Destination)
        $in = [System.IO.File]::Open($Source, 'Open', 'Read', 'ReadWrite')
        try {
            $out = [System.IO.File]::Create($Destination)
            try { $in.CopyTo($out) } finally { $out.Dispose() }
        }
        finally { $in.Dispose() }
    }

    # A registry key and its subkeys as "key" and " name = value" lines
    function Get-RegistryText {
        param([string]$Key, [int]$Depth)
        if (-not (Test-Path -LiteralPath $Key)) { return @("$Key : not present") }
        $children = @(Get-ChildItem -LiteralPath $Key -Recurse -Depth $Depth -ErrorAction SilentlyContinue)
        $keys = @(Get-Item -LiteralPath $Key) + $children
        foreach ($item in $keys) {
            "[$($item.Name)]"
            $values = Get-ItemProperty -LiteralPath $item.PSPath -ErrorAction SilentlyContinue
            if (-not $values) { continue }
            foreach ($property in $values.PSObject.Properties) {
                if ($property.Name -like 'PS*') { continue }
                $text = if ($property.Value -is [byte[]]) { "byte[$($property.Value.Length)]" }
                else { "$($property.Value)" }
                if ($text.Length -gt 400) { $text = $text.Substring(0, 400) + '...' }
                " $($property.Name) = $text"
            }
        }
    }

    # The staging folder as a zip with forward-slash entry names on every host (.NET Framework's
    # CreateFromDirectory writes backslashes)
    function Compress-Staging {
        param([string]$Folder, [string]$Destination)
        Add-Type -AssemblyName System.IO.Compression.FileSystem
        $archive = [System.IO.Compression.ZipFile]::Open($Destination, 'Create')
        $count = 0
        try {
            foreach ($file in (Get-ChildItem -LiteralPath $Folder -Recurse -File)) {
                $relative = $file.FullName.Substring($Folder.Length).TrimStart('\', '/') -replace '\\', '/'
                $null = [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
                    $archive, $file.FullName, $relative)
                $count++
            }
        }
        finally { $archive.Dispose() }
        $count
    }

    try {
        $null = New-Item -ItemType Directory -Path $staging -Force

        # Logs
        $logFiles = @()
        if (Test-Path -LiteralPath $LogPath -PathType Container) {
            $logFiles = @(Get-ChildItem -LiteralPath $LogPath -Filter '*.log' -File)
            $logFolder = Join-Path -Path $staging -ChildPath 'Logs'
            $null = New-Item -ItemType Directory -Path $logFolder -Force
            foreach ($file in $logFiles) {
                $destination = Join-Path -Path $logFolder -ChildPath $file.Name
                Copy-SharedFile -Source $file.FullName -Destination $destination
            }
            $manifest.Add("Logs: $($logFiles.Count) file(s) from $LogPath")
        }
        else {
            $manifest.Add("Logs: folder not found: $LogPath")
            Write-Warning "Log folder not found: $LogPath"
        }

        # Device
        $system = [System.Collections.Generic.List[string]]::new()
        $system.Add("Computer: $env:COMPUTERNAME")
        $system.Add("Collected: $((Get-Date).ToString('o')) ($([System.TimeZoneInfo]::Local.Id))")
        $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue
        if ($os) { $system.Add("Windows: $($os.Caption) $($os.Version) $($os.OSArchitecture)") }
        $system.Add("PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))")
        $system.Add("Process architecture: $env:PROCESSOR_ARCHITECTURE")
        $module = $MyInvocation.MyCommand.Module
        if ($module) { $system.Add("IntuneScriptLab: $($module.Version)") }
        $service = Get-Service -Name IntuneManagementExtension -ErrorAction SilentlyContinue
        $system.Add("Agent service: $(if ($service) { $service.Status } else { 'not installed' })")
        $agentFolder = Join-Path -Path ${env:ProgramFiles(x86)} -ChildPath 'Microsoft Intune Management Extension'
        $agentExe = Join-Path -Path $agentFolder -ChildPath 'Microsoft.Management.Services.IntuneWindowsAgent.exe'
        if ($agentExe -and (Test-Path -LiteralPath $agentExe)) {
            $system.Add("Agent version: $((Get-Item -LiteralPath $agentExe).VersionInfo.FileVersion)")
        }
        Write-Section -Relative 'Device\system.txt' -Lines $system
        if (Get-Command -Name dsregcmd.exe -ErrorAction SilentlyContinue) {
            $joinState = @(& dsregcmd.exe /status 2>&1 | ForEach-Object { "$_" })
            Write-Section -Relative 'Device\dsregcmd.txt' -Lines $joinState
            $manifest.Add('Device: system.txt, dsregcmd.txt')
        }
        else { $manifest.Add('Device: system.txt (dsregcmd not available)') }

        # Registry
        if (-not $SkipRegistry) {
            $keys = @(
                @{ Name = 'IntuneManagementExtension'; Depth = 6
                    Key = 'HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension' }
                @{ Name = 'Enrollments'; Key = 'HKLM:\SOFTWARE\Microsoft\Enrollments'; Depth = 2 }
                @{ Name = 'EnrollmentStatusTracking'; Depth = 6
                    Key = 'HKLM:\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking' }
                @{ Name = 'Autopilot'; Depth = 1
                    Key = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Diagnostics\AutoPilot' }
            )
            foreach ($entry in $keys) {
                $lines = @(Get-RegistryText -Key $entry.Key -Depth $entry.Depth)
                Write-Section -Relative "Registry\$($entry.Name).txt" -Lines $lines
            }
            $manifest.Add("Registry: $(($keys | ForEach-Object { $_.Name }) -join ', ')")
        }
        else { $manifest.Add('Registry: skipped') }

        # Timeline
        if (-not $SkipTimeline -and $logFiles.Count) {
            $timelineFolder = Join-Path -Path $staging -ChildPath 'Timeline'
            $null = New-Item -ItemType Directory -Path $timelineFolder -Force
            $events = @(Get-IntuneAgentLog -Path $LogPath -Log All | Where-Object Event)
            $iso = @{ Name = 'Time'; Expression = { $_.Time.ToString('o') } }
            $eventRows = $events | Select-Object -Property $iso, Log, Event, Detail, Id, Message
            $csvSplat = @{ NoTypeInformation = $true; Encoding = 'UTF8' }
            $eventsCsv = Join-Path -Path $timelineFolder -ChildPath 'events.csv'
            $eventRows | Export-Csv -LiteralPath $eventsCsv @csvSplat
            $timelines = @(Get-IntuneAgentTimeline -Path $LogPath -Log All)
            $timelineRows = $timelines | Select-Object -Property Id, Name, Kind,
            @{ Name = 'Started'; Expression = { $_.Started.ToString('o') } },
            @{ Name = 'Ended'; Expression = { $_.Ended.ToString('o') } },
            @{ Name = 'Duration'; Expression = { $_.Duration.ToString() } }, Runs, Outcome, Summary
            $timelinesCsv = Join-Path -Path $timelineFolder -ChildPath 'timelines.csv'
            $timelineRows | Export-Csv -LiteralPath $timelinesCsv @csvSplat
            $manifest.Add("Timeline: $($events.Count) event(s), $($timelines.Count) timeline(s)")
        }
        else { $manifest.Add('Timeline: skipped') }

        $manifest.Insert(0, "IntuneScriptLab diagnostic package, $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))")
        Write-Section -Relative 'Manifest.txt' -Lines $manifest

        if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
        $zipFolder = Split-Path -Path $zip -Parent
        if ($zipFolder -and -not (Test-Path -LiteralPath $zipFolder)) {
            $null = New-Item -ItemType Directory -Path $zipFolder -Force
        }
        $entryCount = Compress-Staging -Folder $staging -Destination $zip
    }
    finally {
        Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
    }

    Write-Verbose "Completed $($MyInvocation.MyCommand.Name): $zip"
    [pscustomobject]@{
        PSTypeName = 'IntuneScriptLab.Diagnostic'
        Path       = $zip
        Files      = $entryCount
        Logs       = $logFiles.Count
        Registry   = -not $SkipRegistry
        Timeline   = (-not $SkipTimeline) -and $logFiles.Count -gt 0
        SizeBytes  = (Get-Item -LiteralPath $zip).Length
    }
}