Private/ConvertFrom-IslCmTraceLog.ps1
|
function ConvertFrom-IslCmTraceLog { <# .SYNOPSIS Parses a CMTrace-format log (the Intune Management Extension's) into entries. .DESCRIPTION The agent's logs are CMTrace lines: <![LOG[message]LOG]!><time="..." date="..." component="..." context="" type="1" thread="..." file="">. A message can span lines, the time carries seven fractional digits and sometimes a UTC bias suffix, and the date is M-d-yyyy. The file is opened with shared read/write access because the agent keeps it open for writing; a plain ReadAllText fails on a live log. Returns one IntuneScriptLab.AgentLogEntry per entry, in file order: Time (local, as the agent wrote it), Level (Information, Warning, Error from type 1, 2, 3), Component, Thread, Message, Log (the file's base name) and Line (the line the entry starts on). Event, Detail and Id are left empty for Get-IntuneAgentLog to fill. The filters are the ones Get-IntuneAgentLog offers, applied to the raw record before an object is built for it. Building the object is most of what an entry costs: on a 15 MB log of 80,000 entries, one policy by -Id reads in 5 seconds where every entry takes 30. .PARAMETER Path The log file to parse. .PARAMETER Level Keep entries of these levels only: Information, Warning, Error. .PARAMETER After Keep entries at or after this local time. .PARAMETER Before Keep entries before this local time. .PARAMETER Pattern Keep entries whose message matches this regular expression, case-insensitive like -match. .PARAMETER Id Keep entries whose message contains any of these strings, case-insensitive; a policy or app id, usually. .PARAMETER Contains Keep entries whose message contains any of these strings, case-sensitive. Get-IntuneAgentLog passes the literal text the wanted events' patterns start with, so a message that can match none of them is dropped before the pattern runs. .EXAMPLE ConvertFrom-IslCmTraceLog -Path 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\HealthScripts.log' Every entry of the remediation log as objects. .EXAMPLE ConvertFrom-IslCmTraceLog -Path .\AppWorkload.log -Id 9b6543c3-6d66-4cfb-a8fb-d780079278f6 -Level Error The error entries that mention the app, without building the others. #> [CmdletBinding()] [OutputType('IntuneScriptLab.AgentLogEntry')] param( [Parameter(Mandatory)] [string]$Path, [ValidateSet('Information', 'Warning', 'Error')] [string[]]$Level, [datetime]$After, [datetime]$Before, [string]$Pattern, [string[]]$Id, [string[]]$Contains ) $file = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath $stream = [System.IO.File]::Open($file, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8, $true) $text = $reader.ReadToEnd() } finally { $stream.Dispose() } $levels = @{ '1' = 'Information'; '2' = 'Warning'; '3' = 'Error' } $wantedTypes = if ($Level) { @($levels.Keys | Where-Object { $levels[$_] -in $Level }) } else { $null } $hasAfter = $PSBoundParameters.ContainsKey('After') $hasBefore = $PSBoundParameters.ContainsKey('Before') $ordinalIgnoreCase = [System.StringComparison]::OrdinalIgnoreCase $ordinal = [System.StringComparison]::Ordinal # An agent log of 15 MB is 80,000 entries, and every statement here runs once per entry. The # attributes come out of one compiled regex in the order the agent writes them, the timestamp # is read the way ConvertTo-IslCmTraceTime reads it without the call, and the filters run on # the raw strings before anything is built. Lines are counted only up to an entry that is kept $attributeRegex = $script:IslCmTraceAttributeRegex $timeFormat = $script:IslCmTraceTimeFormat $invariant = [cultureinfo]::InvariantCulture $noStyle = [System.Globalization.DateTimeStyles]::None $biasChars = [char[]]@('+', '-') $logName = [System.IO.Path]::GetFileNameWithoutExtension($file) $entryPattern = '<!\[LOG\[(?<message>.*?)\]LOG\]!><time="(?<time>[^"]*)" date="(?<date>[^"]*)"' + '(?<attributes>[^>]*)>' $entryRegex = [regex]::new($entryPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) $line = 1 $counted = 0 foreach ($match in $entryRegex.Matches($text)) { $groups = $match.Groups $attributeMatch = $attributeRegex.Match($groups['attributes'].Value) $type = if ($attributeMatch.Success) { $attributeMatch.Groups[2].Value } else { '1' } if ($wantedTypes -and $type -notin $wantedTypes) { continue } $message = $groups['message'].Value if ($Pattern -and $message -notmatch $Pattern) { continue } if ($Id) { $mentioned = $false foreach ($value in $Id) { if ($message.IndexOf($value, $ordinalIgnoreCase) -ge 0) { $mentioned = $true; break } } if (-not $mentioned) { continue } } if ($Contains) { $present = $false foreach ($value in $Contains) { if ($message.IndexOf($value, $ordinal) -ge 0) { $present = $true; break } } if (-not $present) { continue } } $timeText = $groups['time'].Value $bias = $timeText.IndexOfAny($biasChars, [Math]::Min(7, $timeText.Length)) if ($bias -ge 0) { $timeText = $timeText.Substring(0, $bias) } $time = [datetime]::MinValue $stamp = "$($groups['date'].Value) $timeText" if (-not [datetime]::TryParseExact($stamp, $timeFormat, $invariant, $noStyle, [ref]$time)) { throw "Not a CMTrace timestamp: $stamp ($file)" } if ($hasAfter -and $time -lt $After) { continue } if ($hasBefore -and $time -ge $Before) { continue } # The newlines between the last kept entry and this one, in one .NET call if ($match.Index -gt $counted) { $line += $text.Substring($counted, $match.Index - $counted).Split("`n").Length - 1 $counted = $match.Index } [pscustomobject]@{ PSTypeName = 'IntuneScriptLab.AgentLogEntry' Time = $time Level = if ($levels.ContainsKey($type)) { $levels[$type] } else { 'Information' } Component = if ($attributeMatch.Success) { $attributeMatch.Groups[1].Value } else { '' } Thread = if ($attributeMatch.Success) { [int]$attributeMatch.Groups[3].Value } else { $null } Event = $null Detail = $null Id = $null Message = $message.TrimEnd("`r", "`n") Log = $logName Line = $line } } } # component, then type, then thread, in the order every agent log writes them; context and file # sit between and after them and are not read $script:IslCmTraceAttributeRegex = [regex]::new('component="([^"]*)".*?type="(\d)".*?thread="(\d+)"', [System.Text.RegularExpressions.RegexOptions]::Compiled) |