Remote event log tailer

This script can be used to watch events being written in the logs on several machines in real-time. For example, if a user logs on to a remote session the events recorded for this on several machines such as the RDS/VDA machine, Storefront and Delivery Controller can all be displayed as they are being written.
Run the script with an account or Shared Credential with sufficient privileges on all targeted machines to read the event logs.
NOTE: this script should be run in the Real-time Console. Due to a dependcy on Gridview it cannot be run in Solve.
Version 1.3.10
Created on 2022-02-07
Modified on 2022-02-08
Created by Guy Leech
Downloads: 63

The Script Copy Script Copied to clipboard
#requires -version 3.0

<#
    Monitor specified event logs on specified machines via event notifications


    Modification History:

    @guyrleech 16/07/2021  Initial version
    @guyrleech 19/07/2021  Zero or negative duration means run forever
    @guyrleech 20/07/2021  Added markers for start and end of tailing
    @guyrleech 20/07/2021  Added highlight options
    @guyrleech 21/07/2021  Added ability to pattern match with * on event log names. Added enabling of disabled logs
    @guyrleech 23/07/2021  Added parameter to ignore analytic/debug logs as cannot have watchers. Fixed script not exiting when grid view closed when no end time
    @guyrleech 26/11/2021  Added thread to monitor grid view and exit main loop if closed (via window title)
#>

[CmdletBinding()]

Param
(
 [string[]]$computers ,
 [decimal]$runForMinutes = 1 ,
 [ValidateSet('yes', 'no')]
 [string]$problemsOnly = 'no' ,
 [ValidateSet('yes', 'no')]
 [string]$enableDisabledLogs = 'no' ,
 [ValidateSet('yes', 'no')]
 [string]$ignoreAnalyticAndDebugLogs = 'yes' ,
 [string[]]$eventLogs = @( 
  '*-TerminalServices-*/*' ,
  '*-User Profile Service*/Operational' ,
  '*-GroupPolicy*/Operational' ,
  '*-RemoteDesktopServices*/Operational' ,
  'Application' ,
  'System' ) ,
 [string]$highlightProvider ,
 [string]$highlightMessage ,
 [string]$highlightId ,
 [string]$marker = '------' ,
 [string]$highlightBefore = 'V ' ,
 [string]$highlightAfter = '^ '
)

[datetime]$startTime = [datetime]::Now
[string]$eventquery = '*'
[string]$sourceIdentifierBase = 'GLEvent'
$sourceIdentifiers = New-Object -TypeName System.Collections.Generic.List[string] -ArgumentList @()
$watchers = New-Object -TypeName System.Collections.Generic.List[object] -ArgumentList @()
## key on computer name to store sessions so we can both enumerate at end to dispose but also so we can get session later to disable any logs we enabled
[hashtable]$sessions = @{}
$enabledLogs = New-Object -TypeName System.Collections.Generic.List[object] -ArgumentList @()
[string]$sourceIdentifier = $null

if ( $problemsOnly -and $problemsOnly[0] -ieq 'y') {
 $eventquery = '*[System[(Level=1 or Level=2 or Level=3)]]'
}

if ( $computers -and $computers.Count -eq 1 -and $computers[0].IndexOf( ',' ) -ge 0 ) {
 $computers = @( $computers -split ',' )
}

if ( $eventLogs -and $eventLogs.Count -eq 1 -and $eventLogs[0].IndexOf( ',' ) -ge 0 ) {
 $eventLogs = @( $eventLogs -split ',' )
}

try {
 ForEach ( $computer in $computers ) {
  [int]$counter = 0
  if ( ! ( $session = New-Object -TypeName System.Diagnostics.Eventing.Reader.EventLogSession -ArgumentList $computer ) ) {
   Write-Warning -Message "Failed to start event log session on $computer"
   continue
  }
  try {
   $sessions.Add( $computer , $session )
  }
  catch {
   Write-Warning -Message "Duplicate computer $computer"
   $computer = $null
  }

  if ( $null -ne $computer ) {
   [string[]]$allEventLogs = @()
   try {
    $allEventLogs = @( $session.GetLogNames() )
   }
   catch {
    $allEventLogs = $null
    Write-Warning -Message "Failed to get event log names from $computer : $_"
   }

   ForEach ( $eventLogPattern in $eventLogs ) {
    ## verify each event log exists even if no wildcard so if doesn't exist, we don't set a watcher
    [int]$matchingLogs = 0
    $allEventLogs.Where( { $_ -like $eventLogPattern -and ( [string]::IsNullOrEmpty( $ignoreAnalyticAndDebugLogs ) -or $ignoreAnalyticAndDebugLogs[0] -ieq 'n' -or $_ -notmatch '/(analytic|debug)$' ) } ) | ForEach-Object `
    {
     $eventLog = $_
     $matchingLogs++
     if ( $query = New-Object Diagnostics.Eventing.Reader.EventLogQuery ($eventLog, [Diagnostics.Eventing.Reader.PathType]::LogName, $eventquery ) ) {
      $query.Session = $session
      $query.TolerateQueryErrors = $true

      if ( $watcher = New-Object Diagnostics.Eventing.Reader.EventLogWatcher -ArgumentList $query ) {
       $sourceIdentifier = $sourceIdentifierBase + ".$computer.$counter"
       $counter++
       Write-Verbose -Message "source identifier $sourceIdentifier for $eventlog"
       Unregister-Event -SourceIdentifier $sourceIdentifier -Force -ErrorAction SilentlyContinue
       Register-ObjectEvent -InputObject $watcher -EventName EventRecordWritten -SourceIdentifier $sourceIdentifier

       if ( $eventLogConfiguration = New-Object Diagnostics.Eventing.Reader.EventLogConfiguration -ArgumentList $eventLog , $session ) {
        [bool]$continue = $true

        if ( ! [string]::IsNullOrEmpty( $ignoreAnalyticAndDebugLogs ) -and $ignoreAnalyticAndDebugLogs[0] -ieq 'y' `
          -and ( $eventLogConfiguration.LogType -eq [System.Diagnostics.Eventing.Reader.EventLogType]::Analytical -or $eventLogConfiguration.LogType -eq [System.Diagnostics.Eventing.Reader.EventLogType]::Debug ) ) {
         $continue = $false
         Write-Verbose -Message "Ignoring log `"$eventlog`" on $computer because type is $($eventLogConfiguration.LogType)"
         $watcher.Dispose()
         $watcher = $null
        }
                                
        if ( $continue -and ! [string]::IsNullOrEmpty( $enableDisabledLogs ) -and $enableDisabledLogs[0] -ieq 'y' -and ! $eventLogConfiguration.IsEnabled ) {
         try {
          $eventLogConfiguration.IsEnabled = $true
          $eventLogConfiguration.SaveChanges()
          Write-Warning -Message "Log `"$eventLog`" on $computer was disabled so enabled it"
          $enabledLogs.Add( "$($computer):$eventLog" )
          $watcher.Enabled = $true
          $continue = $true
         }
         catch {
          Write-Warning -Message "Problem enabling log `"$eventLog`" on $computer : $_"
          $watcher.Dispose()
          $watcher = $null
         }
        }

        $eventLogConfiguration.Dispose()
        $eventLogConfiguration = $null
       }
       else {
        Write-Warning -Message "Failed to get configuration for log `"$eventLog`" on $computer so cannot tell if disabled or analytic/debug"
       }

       if ( $watcher ) {
        ## Enabling on some logs throws "The request is not supported" which is probably because they are analytic/debug which don't appear to alow watchers
        try {
         $watcher.Enabled = $true
        }
        catch {
         $exception = $_
         Write-Warning -Message "Problem setting watcher on log `"$eventLog`" on $computer : $exception"
         Unregister-Event -SourceIdentifier $sourceIdentifier -Force -ErrorAction SilentlyContinue
         $watcher.Dispose()
         $watcher = $null
        }
        if ( $watcher ) {
         $watchers.Add( $watcher )
         $sourceIdentifiers.Add( $sourceIdentifier )
        }
       }
       else {
        Unregister-Event -SourceIdentifier $sourceIdentifier -Force -ErrorAction SilentlyContinue
       }
      }
      else {
       Write-Warning -Message "Failed to create watcher for event log $eventlog on $computer with query $eventquery"
      }
     }
     else {
      Write-Warning -Message "Failed to create query for event log $eventlog on $computer"
     }
    }
    if ( ! $matchingLogs ) {
     Write-Warning -Message "No event logs found on $computer matching `"$eventLogPattern`""
    }
   }
  }
  if ( ! $counter -and $computer ) {
   Write-Warning -Message "No event logs found to monitor on $computer"
  }
 }
    
 # Test if any logs are being monitored at all
 if( $watchers.Count -eq 0 )
    {
        Throw "Nothing to monitor. This could be because the machines specified cannot be reached or the logs requested do not exist."
    }

 [string]$message = "Monitoring $($watchers.Count) event logs across $($sessions.Count) machines from $(Get-Date -Format G)"

 $endTime = $null
 if ( $runForMinutes -gt 0 ) {
  $endTime = (Get-Date).AddSeconds( $runForMinutes * 60 )
  $message += " until $(Get-Date -Date $endTime -Format G)"
 }

 ## start a thread to monitor gridview so we can quit if closed
 $monitorThread = $null
 $sharedVariables = $null
 [string]$eventToSignal = 'GuysWindowTitleChangedEventThingy'

 if ( ! ( $SessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() ) ) {
  Write-Warning "Failed to create runspace initial session state to monitor grid view"
 }
 else {
  ## when the thread is ready to exit, it modified this collection which generates an event - courtesy of Bo Prox via https://social.technet.microsoft.com/Forums/SECURITY/en-US/4ce2efd2-1852-44a5-85a7-29659e5bf704/raise-a-powershell-events-from-within-a-runspace-which-has-no-gui-elements-to-the-powershell-host?forum=winserverpowershell
  $observableCollection = New-Object -TypeName System.Collections.ObjectModel.ObservableCollection[string]
  Register-ObjectEvent -InputObject $observableCollection -EventName CollectionChanged -SourceIdentifier $eventToSignal
 
  ## sharing variables with the runspaces so they can signal the main thread
  $sharedVariables = [hashtable]::Synchronized(@{ Exit = $false ; SignalCollection = $observableCollection })
  ## must add shared variables before creating runspace pool
  $sessionState.Variables.Add( (New-Object -TypeName System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sharedVariables' , $sharedVariables , 'Shared Variables hashtable' ) )
        
  if ( $RunspacePool = [runspacefactory]::CreateRunspacePool(
    1, ## Min Runspaces
    2 , ## Max parallel runspaces ,
    $sessionstate ,
    $host.psobject.Copy() )) {
   $RunspacePool.Open()
            ($powerShell = [PowerShell]::Create()).RunspacePool = $RunspacePool

   [void]$powerShell.AddScript({
     Param( [string]$message , [int]$pollPeriodMilliseconds = 10000 , $eventToSignal = 'DoneWatching' )

                
     ## https://www.linkedin.com/pulse/fun-powershell-finding-suspicious-cmd-processes-britton-manahan/

     Add-Type -TypeDefinition  @"
            using System;
            using System.Text;
            using System.Collections.Generic;
            using System.Runtime.InteropServices;

            namespace Api
            {

             public class WinStruct
             {
               public string WinTitle {get; set; }
               public int WinHwnd { get; set; }
               public int PID { get; set; }
             }

             public class ApiDef
             {
               private delegate bool CallBackPtr(int hwnd, int lParam);
               private static CallBackPtr callBackPtr = Callback;
               private static List<WinStruct> _WinStructList = new List<WinStruct>();

               [DllImport("User32.dll")]
               [return: MarshalAs(UnmanagedType.Bool)]
               private static extern bool EnumWindows(CallBackPtr lpEnumFunc, IntPtr lParam);

               [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
               static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
   
               [DllImport("user32.dll")]
               static extern bool IsWindowVisible(IntPtr hWnd);
   
                [DllImport("user32.dll")]
                public static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId);
 
               private static bool Callback(int hWnd, int pid)
               {
                    if( IsWindowVisible( (IntPtr)hWnd ) )
                    {
                        int ipid = 0 ;
                        GetWindowThreadProcessId( (IntPtr)hWnd , out ipid );
                        if( ipid == pid )
                        {
                            StringBuilder sb = new StringBuilder(256);
                            int res = GetWindowText((IntPtr)hWnd, sb, 256);
                            _WinStructList.Add( new WinStruct { WinHwnd = hWnd, WinTitle = sb.ToString() , PID = ipid  });
                        }
                    }
                    return true;
               }   

               public static List<WinStruct> GetWindows( int pid )
               {
                  _WinStructList = new List<WinStruct>();
                  EnumWindows(callBackPtr, (IntPtr)pid );
                  return _WinStructList;
               }

             }
            }
"@
     [bool]$haveSeenTitle = $false
     [int]$waitMilliseconds = 500 ## initially poll frequently to get the window title then we can back off
                
     [System.Diagnostics.Trace]::WriteLine( "Pid is $pid poll $pollPeriodMilliseconds message $message" )
     Do {
      if ( $thisProcess = Get-Process -Id $pid ) {
       if ( [string]::IsNullOrEmpty( $thisProcess.MainWindowTitle) -or $thisProcess.MainWindowTitle -ne $message ) {
        if ( $haveSeenTitle ) {
         ## enumerate all visible windows for this process to see if the grid view is still present but not the foreground window currently
         if ( -Not ( $windows = [Api.Apidef]::GetWindows( $pid ) ) -or -Not $windows.Where( { $_.WinTitle -eq $message } )) {
          [System.Diagnostics.Trace]::WriteLine( "Setting exit as title now `"$($thisProcess.MainWindowTitle)`" and window title not found" )
          [void]$sharedVariables.SignalCollection.Add( "Window title: $($thisProcess.MainWindowTitle)" ) ## main loop is waiting on an event which this will signal
          $sharedVariables.Exit = $true
          break
         }
         else {
          [System.Diagnostics.Trace]::WriteLine( "Process still has a window with title `"$message`" althouhg is now `"$($thisProcess.MainWindowTitle)`"" )
         }
        }
        ## else not seen title yet so don't exit
       }
       elseif ( $thisProcess.MainWindowTitle -eq $message ) {
        $haveSeenTitle = $true ## must see title at least once since we could be checking before grid view is viewable
        [System.Diagnostics.Trace]::WriteLine( "Seen title $message" )
        $waitMilliseconds = $pollPeriodMilliseconds
       }
      }
      else {
       Write-Warning -Message "Failed to get pid $pid"
      }
      [System.Diagnostics.Trace]::WriteLine( "Sleeping for $waitMilliseconds ms" )

      Start-Sleep -Milliseconds $waitMilliseconds
     }
     While ( $true )
     ## implicit exit from thread
    })
        
   [void]$powerShell.AddParameters( @{ message = $message ; eventToSignal = $eventToSignal } )
   $monitorThread = [pscustomobject]@{
    'PowerShell' = $powerShell
    'Handle'     = $powerShell.BeginInvoke( ) 
   }
  }
 }

 Write-Verbose -Message $message

 ## put loop in a scriptblock so it can stream its output before it has finished https://powershell.one/tricks/loops/make-loops-stream
 $selected = & {
  [pscustomobject][ordered]@{  Time = Get-Date -Format 'HH:mm:ss.fff' ; MachineName = $marker ; Id = 0 ; LevelDisplayName = $marker ; OpcodeDisplayName = $marker ; TaskDisplayName = $marker; LogName = $marker; ProviderName = $marker; Message = 'STARTED TAILING EVENT LOGS  ' }
        
  do {
   if ( $eventRaised = Wait-Event -Timeout $( if ( $endTime ) { ($endTime - [datetime]::Now).TotalSeconds } else { -1 } ) ) { ## when no endtime, we must periodically come out of Wait-Event in case grid view has been closed
    if ( $eventRaised.TimeGenerated -ge $startTime ) {
     if ( $sourceIdentifiers -contains $eventRaised.SourceIdentifier -and $eventRaised.SourceEventArgs -and ( $event = $eventRaised.SourceEventArgs | Select-Object -ExpandProperty EventRecord )) { ## could make hash table for speedier lookup
      [string]$messageText = $event.FormatDescription()
      if ( [string]::IsNullOrEmpty( $messageText ) ) {
       $messageText = $event.Properties | Select-Object -ExpandProperty Value
      }
      ## cannot use $PSBoundParameters on script parameters as we are in a scriptblock which has its own
      [bool]$highlight = ! [string]::IsNullOrEmpty( $highlightMessage ) -and $messageText -match $highlightMessage
      if ( ! $highlight ) {
       if ( ! ( $highlight = ! [string]::IsNullOrEmpty( $highlightId ) -and $event.Id -match $highlightId ) ) {
        $highlight = ! [string]::IsNullOrEmpty( $highlightProvider ) -and $event.ProviderName -match $highlightProvider 
       }
      }
      if ( $highlight ) {
       [string]$markertext = $highlightBefore * 3
       [pscustomobject][ordered]@{  Time = Get-Date -Date $event.TimeCreated -Format 'HH:mm:ss.fff' ; MachineName = $markertext ; Id = 0 ; LevelDisplayName = $markertext ; OpcodeDisplayName = $markertext ; TaskDisplayName = $markertext; LogName = $markertext; ProviderName = $markertext; Message = $markertext }
      }
      $event | Select-Object -Property @{n = 'Time'; e = { Get-Date -Date $_.TimeCreated -Format 'HH:mm:ss.fff' } }, MachineName, Id, LevelDisplayName, OpcodeDisplayName, TaskDisplayName, LogName, ProviderName, @{n = 'Message'; e = { $messageText } }
      if ( $highlight ) {
       [string]$markertext = $highlightAfter * 3
       [pscustomobject][ordered]@{  Time = Get-Date -Date $event.TimeCreated -Format 'HH:mm:ss.fff' ; MachineName = $markertext ; Id = 0 ; LevelDisplayName = $markertext ; OpcodeDisplayName = $markertext ; TaskDisplayName = $markertext; LogName = $markertext; ProviderName = $markertext; Message = $markertext }
      }
     }
     elseif ( $eventRaised.SourceIdentifier -eq $eventToSignal ) { ## event from our thread watching the main window
      Write-Verbose -Message "$(Get-Date -Format G) : got event from thread : $($eventRaised.SourceEventArgs | Select-Object -ExpandProperty NewItems)" 
      break
     }
    }
    ## else event raised before we started so ignore
        
    $eventRaised | Remove-Event
    $eventRaised = $null
   }
  } while ( ( ! $endTime -or [datetime]::Now -le $endTime ) -and -not $sharedVariables.Exit )

  [pscustomobject][ordered]@{  Time = Get-Date -Format 'HH:mm:ss.fff' ; MachineName = $marker ; Id = 0 ; LevelDisplayName = $marker ; OpcodeDisplayName = $marker ; TaskDisplayName = $marker; LogName = $marker; ProviderName = $marker; Message = 'FINISHED TAILING EVENT LOGS' }

 } | Out-GridView -Title $message -PassThru

 if ( $null -ne $selected ) {
  $selected | Set-Clipboard
 }
}
catch {
 throw $_
}
finally {
 Write-Verbose -Message "$(Get-Date -Format G) cleaning up"

 if ( $monitorThread ) {
  [void]$monitorThread.PowerShell.Stop()
  [void]$monitorThread.PowerShell.Dispose()
 }

 Unregister-Event -SourceIdentifier $eventToSignal

 ForEach ( $sourceIdentifier in $sourceIdentifiers ) {
  Unregister-Event -SourceIdentifier $sourceIdentifier -Force -ErrorAction SilentlyContinue
 }

 ForEach ( $watcher in $watchers ) {
  $watcher.Enabled = $false
  $watcher.Dispose()
  $watcher = $null
 }

 ForEach ( $enabledLog in $enabledLogs ) {
  ## TODO disable the log
  $computer, $eventlog = $enabledLog -split ':' , 2
  if ( $session = $sessions[ $computer ] ) {
   if ( $eventLogConfiguration = New-Object Diagnostics.Eventing.Reader.EventLogConfiguration -ArgumentList $eventLog , $session ) {
    if ( $eventLogConfiguration.IsEnabled ) {
     try {
      $eventLogConfiguration.IsEnabled = $false
      $eventLogConfiguration.SaveChanges()
      Write-Verbose -Message "Log `"$eventLog`" on $computer disabled ok"
     }
     catch {
      Write-Warning -Message "Problem disabling log `"$eventLog`" on $computer : $_"
     }
    }
    else {
     Write-Warning -Message "Log `"$eventLog`" on $computer is not enabled but we enabled it"
    }
    $eventLogConfiguration.Dispose()
    $eventLogConfiguration = $null
   }
   else {
    Write-Warning -Message "Failed to get configuration for log `"$eventLog`" on $computer"
   }
  }
  else {
   Write-Warning -Message "Failed to get session for $computer to disable log `"$eventLog`""
  }
 }

 ForEach ( $session in $sessions.GetEnumerator() ) {
  $session.Value.Dispose()
  $session.Value = $null
 }

 $watchers.Clear()
 $sessions.Clear()
}