Log4NetParse.psm1


enum LogLevel {
    INFO
    DEBUG
    VERBOSE
    WARN
    ERROR
  }
class Log4NetLog {
  [int]$Thread
  [System.Collections.Generic.List[Log4NetLogLine]]$LogLines
  [datetime]$StartTime
  [datetime]$EndTime
  [string]$FilePath

  static [hashtable[]] $MemberDefinitions = @(
    @{
      MemberType = 'AliasProperty'
      MemberName = 'logs'
      Value = 'LogLines'
    }
  )

  static Log4NetLog() {
    $TypeName = [Log4NetLog].Name
    foreach ($Definition in [Log4NetLog]::MemberDefinitions) {
      Update-TypeData -TypeName $TypeName @Definition
    }
  }

  Log4NetLog(
    [int]$Thread,
    [datetime]$StartTime,
    [string]$FilePath
  ) {
    $this.Thread = $Thread
    $this.StartTime = $StartTime
    $this.LogLines = [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.LogLines[-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.LogLines.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.LogLines[-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 $_)
}