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()