Log4NetParse.psm1
enum LogLevel { INFO DEBUG VERBOSE WARN ERROR } class Log4NetLog : System.IComparable { hidden [System.Collections.Generic.List[Log4NetLogLine]]$_logs [int]$Thread # [System.Collections.ArrayList]$LogLines [datetime]$StartTime [datetime]$EndTime [string]$FilePath static [hashtable[]] $MemberDefinitions = @( @{ MemberType = 'AliasProperty' MemberName = 'logs' Value = 'LogLines' } @{ MemberType = 'ScriptProperty' MemberName = 'LogLines' Value = { $this._logs.Sort(); $this._logs } # Getter SecondValue = { # Setter $this.AddLogLine($args[0]) } } ) static Log4NetLog() { $TypeName = [Log4NetLog].Name foreach ($Definition in [Log4NetLog]::MemberDefinitions) { Update-TypeData -TypeName $TypeName @Definition } } Log4NetLog( [int]$Thread, [string]$FilePath ) { $this.Thread = $Thread $this._logs = [System.Collections.Generic.List[Log4NetLogLine]]::new() $this.FilePath = $FilePath } # This parses all the logs for entries that are part of the class [void] ParseSpecialLogs() { $this._logs.Sort() $this.StartTime = $this._logs[0].time $this.EndTime = $this._logs[-1].time } [void]AddLogLine( [Log4NetLogLine]$line ) { $this._logs.Add($line) } [void]AppendLastLogLine( [string]$Line ) { $this._logs[-1].AppendMessage($line) } # Setup the comparable method. [int] CompareTo([object]$Other) { # If the other object's null, consider this instance "greater than" it if ($null -eq $Other) { return 1 } # If the other object isn't a temperature, we can't compare it. $OtherLog = $Other -as [Log4NetLog] if ($null -eq $OtherLog) { throw [System.ArgumentException]::new( "Object must be of type 'Temperature'." ) } # Compare the temperatures as Kelvin. return $this.Thread.CompareTo($OtherLog.Thread) } } class Log4NetLogLine : System.IComparable { [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 ' ' } [int] CompareTo([object]$Other) { # If the other object's null, consider this instance "greater than" it if ($null -eq $Other) { return 1 } # If the other object isn't a temperature, we can't compare it. $OtherLog = $Other -as [Log4NetLogLine] if ($null -eq $OtherLog) { throw [System.ArgumentException]::new( "Object must be of type 'Log4NetLogLine'." ) } # Compare the temperatures as Kelvin. return $($this.ToString() -eq $OtherLog.ToString()) } } 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 { [CmdletBinding()] [OutputType([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 } Write-Verbose "Found files: $($files -join ',')" $parsed = @{} # Get the regex for the Log4Net PatternLayout $RegularExpression = Convert-PatternLayout -PatternLayout $PatternLayout $files | ForEach-Object -Process { $file = $_ Write-Verbose "Reading over file: $file" $raw = [System.IO.File]::ReadAllLines($file.FullName) Write-Verbose "Lines read: $($raw.Count)" # Iterate over each line foreach ($line in $raw) { Write-Debug $line $m = $RegularExpression.match($line) if ($m.Success) { [int]$threadMatch = $m.Groups['thread'].Value # Replace comma with period to make it a valid datetime [datetime]$currentDateTime = $m.Groups['date'].Value -replace ',', '.' # Check if thread exists, if not make it. if (-not ($parsed.ContainsKey($threadMatch))) { Write-Verbose "New thread detected: $threadMatch" $null = $parsed.Add( $threadMatch, [Log4NetLog]::new( $threadMatch, $file ) ) } Write-Verbose "Adding new log line to thread $threadMatch" $parsed.Item($threadMatch).AddLogLine( [Log4NetLogLine]::new( $currentDateTime, $threadMatch, $m.Groups['level'].Value, $m.Groups['message'].Value )) } else { Write-Verbose "Line did not match regex" Write-Debug $line # if it doesn't match regex, append to the previous if ($threadMatch) { Write-Verbose "Appending to existing thread: $threadMatch" $parsed.Item($threadMatch).AppendLastLogLine($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" } } } } # Doing this at the end since threads can get mixed $parsed.Keys | ForEach-Object { Write-Verbose "Parsing special logs for: $_" # This updates fields like: cli, environment, and configuration $parsed.Item($_).ParseSpecialLogs() } # Return the whole parsed object Write-Verbose "Returning results in desceding order. Count: $($parsed.Count)" $parsed.Values | Sort-Object -Descending StartTime } # 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 $_) } # Define the types to export with type accelerators. $ExportableTypes = @( [LogLevel] [Log4NetLog] [Log4NetLogLine] ) # Get the internal TypeAccelerators class to use its static methods. $TypeAcceleratorsClass = [psobject].Assembly.GetType( 'System.Management.Automation.TypeAccelerators' ) # Ensure none of the types would clobber an existing type accelerator. # If a type accelerator with the same name exists, throw an exception. $ExistingTypeAccelerators = $TypeAcceleratorsClass::Get foreach ($Type in $ExportableTypes) { if ($Type.FullName -in $ExistingTypeAccelerators.Keys) { $Message = @( "Unable to register type accelerator '$($Type.FullName)'" 'Accelerator already exists.' ) -join ' - ' throw [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($Message), 'TypeAcceleratorAlreadyExists', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Type.FullName ) } } # Add type accelerators for every exportable type. foreach ($Type in $ExportableTypes) { [void]$TypeAcceleratorsClass::Add($Type.FullName, $Type) } # Remove type accelerators when the module is removed. $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { foreach ($Type in $ExportableTypes) { [void]$TypeAcceleratorsClass::Remove($Type.FullName) } }.GetNewClosure() |