Log4NetParse.psm1
enum LogLevel { INFO DEBUG VERBOSE WARN ERROR } class Log4NetLog { [int]$Thread [System.Collections.Generic.List[Log4NetLogLine]]$logs [datetime]$startTime [datetime]$endTime [string]$filePath Log4NetLog( [int]$Thread, [datetime]$startTime, [string]$filePath ) { $this.Thread = $Thread $this.startTime = $startTime $this.logs = [System.Collections.Generic.List[Log4NetLogLine]]::new() $this.filePath = $filePath } } class Log4NetLogLine { [datetime]$time [int]$thread [LogLevel]$level [string]$message # Constructor that build everything Log4NetLogLine( [datetime]$time, [int]$thread, [LogLevel]$level, [string]$message ) { $this.time = $time $this.thread = $thread $this.level = $level $this.message = $message } [void]AppendMessage([string]$message) { $this.message += "`n$message" } [string]ToString() { return @( $this.time $this.thread "[" + $this.level + "]" $this.message ) -join ' ' } } function Convert-PatternLayout { [OutputType([System.Text.RegularExpressions.Regex])] [CmdletBinding()] param ( [string] $PatternLayout = '%timestamp [%thread] %level %logger %ndc - %message%newline' # This is the DetailPattern https://logging.apache.org/log4net/release/sdk/?topic=html/T_log4net_Layout_PatternLayout.htm#Remarks ) # Regex to identiy pattern names # Conversions are '%' + formater + conversion pattern name # Formatter start with `.` or `-` and numbers or range # https://rubular.com/r/r9jtIzuxJag0HV [regex]$patternRegex = '%(?<right_justify>-)?(?<min_width>\d+)?\.?(?<max_width>\d+)?(?<name>\w+)' # This wonky replace replaces any special characters so they don't mess up the regex later $regExString = "^" + ($PatternLayout -replace '([\\\*\+\?\|\{\}\[\]\(,)\^\$\.\#]\B)', '\$1' ) + "$" $conversions = $patternRegex.Matches($PatternLayout) foreach ($conversion in $conversions) { $name = $conversion.Groups['name'].Value $conRegex = switch ($name) { '%' { '%' } { @('appdomain', 'a') -contains $_ } { '\w+' } { @('logger', 'c') -contains $_ } { '\w+' } { @('type', 'C', 'class') -contains $_ } { '\w+' } { @('date', 'd', 'utcdate') -contains $_ } { # This has it's own modifiers. '\d{4}-\d{2}-\d{2} [0-9:]{8},\d{3}' } { @('file', 'F') -contains $_ } { '\w+' } { @('level', 'p') -contains $_ } { '\w+' } { @('location', 'l') -contains $_ } { '\w+' } { @('line', 'L') -contains $_ } { '\w+' } { @('message', 'm') -contains $_ } { '.*' } { @('method', 'M') -contains $_ } { '\w+' } { @('newline', 'n') -contains $_ } { '\n' } { @('property', 'properties', 'P') -contains $_ } { '\w+' } { @('timestamp', 'r') -contains $_ } { '\d+' } { @('thread', 't') -contains $_ } { '\d+' } { @('identity', 'u') -contains $_ } { '\w+' } { @('mdc', 'X') -contains $_ } { '\w+' } { @('ndc', 'x') -contains $_ } { '\w+' } { @('username', 'w') -contains $_ } { '\w+' } 'aspnet-cache' { '(?<aspnet-cache>\w+)' } 'aspnet-context' { '(?<aspnet-context>\w+)' } 'aspnet-request' { '(?<aspnet-request>\w+)' } 'aspnet-session' { '(?<aspnet-session>\w+)' } 'exception' { '\w+' } 'stacktrace' { '\w+' } 'stacktracedetail' { '\w+' } Default { throw "Unknown conversion pattern name: $name" } } # If we detected any of the formatting bits, let's use them. if ( $conversion.Groups['right_justify'].Success -Or $conversion.Groups['min_width'].Success -Or $conversion.Groups['max_width'].Success ) { $formatString = '{' # Determine the padding/truncating if ($conversion.Groups['min_width'].Success) { $formatString += $conversion.Groups['min_width'].Value } else { $formatString += '0' } $formatString += ',' if ($conversion.Groups['max_width'].Success) { $formatString += $conversion.Groups['max_width'].Value } else { # Nothing, this means it can be as long as it wants } $formatString += '}' # Determine where to add the space if ($conversion.Groups['right_justify'].Success) { $regExGroup = "(?<{0}>(?=.{2}\B){1}\s*)" -F $name, $conRegex, $formatString } else { # If given only a max width & no right justify then there is no spacing # This means truncate after N letters. if ($conversion.Groups['min_width'].Success -eq $False) { $regExGroup = "(?<{0}>(?=.{2}\B){1})" -F $name, $conRegex, $formatString } else { $regExGroup = "(?<{0}>(?=.{2}\B)\s*{1})" -F $name, $conRegex, $formatString } } } else { $regExGroup = "(?<{0}>{1})" -F $name, $conRegex } # Replace the original pattern with our regex $regExString = $regExString.replace($conversion.Value, $regExGroup) } return [regex]$regExString } <# .SYNOPSIS Parses a log4net into an object that is easier to search and filter. .DESCRIPTION Reads log4net log(s) and creates a new set of custom objects. It highlights details that make it easier to search and filter logs. .NOTES Works for Windows PowerShell and PowerShell Core. .LINK TBD .EXAMPLE Read-Log4NetLog This will read a .log file in the current directory. .PARAMETER Path The path to the directory/file you want to parse. .PARAMETER FileLimit How many files should we parse if given a folder path? .PARAMETER Filter The filter passed to Get Child Item. Default to '*.log' .PARAMETER PatternLayout The matching pattern layout. https://logging.apache.org/log4net/release/sdk/?topic=html/T_log4net_Layout_PatternLayout.htm #> function Read-Log4NetLog { # Makes PlatyPS sad [OutputType([System.Collections.Generic.List[Log4NetLog]])] param ( [ValidateScript({ if (-Not ($_ | Test-Path) ) { throw "File or folder does not exist" } return $true })] [string[]] $Path, [int] $FileLimit = 1, [String] $Filter = '*', [String] $PatternLayout = '%date %thread [%-5level] - %message' ) $files = Get-Item -Path $Path if ($files.PSIsContainer) { $files = Get-ChildItem -Path $Path -Filter $Filter | Sort-Object -Property LastWriteTime | Select-Object -Last $FileLimit } [System.Collections.Generic.List[Log4NetLog]]$parsed = @() # Get the regex for the Log4Net PatternLayout $RegularExpression = Convert-PatternLayout -PatternLayout $PatternLayout $files | ForEach-Object -Process { $file = $_ $raw = [System.IO.File]::ReadAllLines($file.FullName) # Iterate over each line foreach ($line in $raw) { # Write-Debug $line $m = $RegularExpression.match($line) if ($m.Success) { # If it matches the regex, tag it if ( $m.Groups['thread'].Value -ne $currentSession.thread) { if ($currentSession) { $currentSession.endTime = $currentSession.logs[-1].time $parsed.Add($currentSession) > $null } # This is a different session $currentSession = [Log4NetLog]::new( $m.Groups['thread'].Value, ($m.Groups['date'].Value -replace ',', '.'), $file ) } $currentSession.logs.Add( [Log4NetLogLine]::new( [Datetime]($m.Groups['date'].Value -replace ',', '.'), $m.Groups['thread'].Value, $m.Groups['level'].Value, $m.Groups['message'].Value )) > $null } else { # if it doesn't match regex, append to the previous if ($currentSession) { $currentSession.logs[-1].AppendMessage($line) } else { # This might happen if the log starts on what should have been a # multiline entry... Not very likely Write-Warning "No currentSession. File: $File; Line: $Line" } } } } # Write out the last log line! if (-Not $parsed.Contains($currentSession)) { $parsed.Add($currentSession) > $null } # Return the whole parsed object $parsed } # This module is combined. Any code in this file as added to the very end. # Add our custom formatters @( 'Log4NetLogLine.format.ps1xml', 'Log4NetLog.format.ps1xml' ) | ForEach-Object { Update-FormatData -PrependPath (Join-Path -Path $PSScriptRoot -ChildPath $_) } |