If you are working with PowerShell frequently, you will often run into the question of logging. How do I want to write logs, where to write them and which format should they have. We wont go into these questions here, however, we will take a look at how to implement PowerShell logging in a non-blocking (async) way.

Table of Contents

  1. Introduction
  2. Logging logic
  3. Logging runspace
  4. Logging class
    1. Example

Introduction

PowerShell is generally single threaded. If we want to write some logs into a file, we would probably use something like this:

1
...
2
Write-Output "$([Datetime]::UtcNow) (Error): This output is blocking my shell" | Out-File C:\temp\test.log -Append
3
...

Maybe we would write a log function like this:

1
function global:WriteLog
2
{
3
    param(
4
        [Parameter(Mandatory=$true)]
5
        [ValidateSet('Information', 'Warning', 'Error')]
6
        [String[]]
7
        $type,
8
        [Parameter(Mandatory=$true)]
9
        [string]$message
10
    )
11
    
12
    $logMessage = "$([Datetime]::UtcNow) ($type): $message"
13
    Write-Output $logMessage
14
    Add-content -Path "C:\temp\test.log" -Value $logMessage
15
}

And there are a lot of reference implementations and variations of varying complexity out there. The problem is that it is still blocking the main PowerShell thread.
To overcome these limitation, there are a few ways in PowerShell:

  • PowerShell Jobs
  • Timer objects
  • Runspace factory

As PowerShell jobs are much too clunky and don’t have a intuitive way of exchanging data between the job scope and the current scope, we will focus on timer objects and runspaces.

There are lots of good articles out there about PowerShell runspaces:

Beginning use of PowerShell runspaces: Part 1
Beginning use of PowerShell runspaces: Part 2
Beginning use of PowerShell runspaces: Part 3
RunspaceFactory Class

So we will create a separate runspace - aka. a thread - to handle all the logging logic for us.

Logging logic

So first we write a scriptblock that will provide the logging functionality we need. As I said before, we will also use timers on this. What the following script does, is checking for a new message in the logging queue and handling it.

1
loggingScript =
2
{
3
    function Start-Logging
4
    {
5
        $loggingTimer = new-object Timers.Timer
6
        $action = {logging}
7
        $loggingTimer.Interval = 1000
8
        $null = Register-ObjectEvent -InputObject $loggingTimer -EventName elapsed -Sourceidentifier loggingTimer -Action $action
9
        $loggingTimer.start()
10
    }
11
12
    function logging
13
    {
14
        $sw = $logFile.AppendText()
15
        while (-not $logEntries.IsEmpty)
16
        {
17
            $entry = ''
18
            $null = $logEntries.TryDequeue([ref]$entry)
19
            $sw.WriteLine($entry)
20
        }
21
        $sw.Flush()
22
        $sw.Close()
23
    }
24
25
    $logFile = New-Item -ItemType File -Name "$($env:COMPUTERNAME)_$([DateTime]::UtcNow.ToString(`"yyyyMMddTHHmmssZ`")).log" -Path $logLocation
26
27
    Start-Logging
28
}

First we create a timer object to check the log queue for new log messages frequently. What logging queue you ask? This one:

1
$logEntries = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()

We use a concurrent queue, because all objects inside of the System.Collections.Concurrent namespace already handles threat locks by themselves. That means that we don’t have to care about both threads (main and logging thread) accessing the object at the same time and causing race conditions. Thats also the reason why we don’t use Synchronized objects, because they are not completely thread safe and could lead to performance degradation.

If you want to learn more about thread safety in .NET, I recommend this article: Thread-Safe Collections

The time calls the function logging every 1 second. By calling AppendText() on the earlier created $logFile object, we get a Stream Writer back and save it into the $sw variable.
Then we try to dequeue all messages from our queue until it’s empty, appending every single entry to our log file.

This is very basic and should only show the concept, feel free to add all the logic you need.

Logging runspace

To be able to launch the above code in a separate runspace, we first need a runspace. We create it by using the RunspaceFactory class.

1
$loggingRunspace = [runspacefactory]::CreateRunspace()
2
$loggingRunspace.ThreadOptions = "ReuseThread"
3
$loggingRunspace.Open()
4
$loggingRunspace.SessionStateProxy.SetVariable("logEntries", $logEntries)
5
$loggingRunspace.SessionStateProxy.SetVariable("logLocation", $logLocation)
6
$cmd = [PowerShell]::Create().AddScript($loggingScript)
7
$cmd.Runspace = $loggingRunspace
8
$null = $cmd.BeginInvoke()

We set the ThreadOptions on the runspace object to ReuseThread.
According to the PSThreadOptions Enum, ReuseThread defines that the runspace *”Creates a new thread for the first invocation and then re-uses that thread in subsequent invocations.”*.
Then we open the runspace synchronously by calling Open() to be able to interact with it.
Now we can use a neat property called SessionStateProxy to add objects that we want to use for communication.
It basically declares and initializes variables in the remote runspace, in our case we want the logEntries and the logLocation variables to be accessible from the runspace scope.

The $logLocation variable is not thread safe. As long as you set it initially and only read it in the logging runspace there should be no problem. If you want to do more with it, considering using a thread safe type or at least implement some locks with e.g. [System.Threading.Monitor]::Enter/Exit

Logging class

As I love PowerShell classes for their extensibility and reusability, I obviously also created a class to reuse the logging construct.

PsLogger.ps1link
1
enum SyslogSeverity
2
{
3
    Emergency = 0
4
    Alert = 1
5
    Critical = 2
6
    Error = 3
7
    Warning = 4
8
    Notice = 5
9
    Informational = 6
10
    Debug = 7
11
}
12
13
enum SyslogFacility
14
{
15
kern = 0
16
user = 1
17
mail = 2
18
daemon = 3
19
auth = 4
20
syslog = 5
21
lpr = 6
22
news = 7
23
uucp = 8
24
cron = 9
25
authpriv = 10
26
ftp = 11
27
ntp = 12
28
audit = 13
29
alert = 14
30
clockdaemon = 15
31
local0 = 16
32
local1 = 17
33
local2 = 18
34
local3 = 19
35
local4 = 20
36
local5 = 21
37
local6 = 22
38
local7 = 23
39
}
40
41
Class PsLogger
42
{
43
    hidden $loggingScript =
44
    {
45
        function Start-Logging
46
        {
47
            $loggingTimer = new-object Timers.Timer
48
            $action = {logging}
49
            $loggingTimer.Interval = 1000
50
            $null = Register-ObjectEvent -InputObject $loggingTimer -EventName elapsed -Sourceidentifier loggingTimer -Action $action
51
            $loggingTimer.start()
52
        }
53
    
54
        function logging
55
        {
56
            $sw = $logFile.AppendText()
57
            while (-not $logEntries.IsEmpty)
58
            {
59
                $entry = ''
60
                $null = $logEntries.TryDequeue([ref]$entry)
61
                $sw.WriteLine($entry)
62
            }
63
            $sw.Flush()
64
            $sw.Close()
65
        }
66
    
67
        $logFile = New-Item -ItemType File -Name "$($env:COMPUTERNAME)_$([DateTime]::UtcNow.ToString(`"yyyyMMddTHHmmssZ`")).log" -Path $logLocation
68
    
69
        Start-Logging
70
    }
71
    hidden $_loggingRunspace = [runspacefactory]::CreateRunspace()
72
    hidden $_logEntries = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()
73
    hidden $_processId = $pid
74
    hidden $_processName
75
    hidden $_logLocation = $env:temp
76
    hidden $_fqdn
77
    hidden [SyslogFacility]$_facility = [SyslogFacility]::local7
78
    
79
    PsLogger([string]$logLocation)
80
	{
81
        $this._logLocation = $logLocation
82
        $this._processName = (Get-process -Id $this._processId).processname
83
        $comp = Get-CimInstance -ClassName win32_computersystem
84
        $this._fqdn = "$($comp.DNSHostName).$($comp.Domain)"
85
86
        # Add Script Properties for all severity levels
87
        foreach ($enum in [SyslogSeverity].GetEnumNames()) 
88
        {
89
            $this._AddSeverities($enum)
90
        }
91
92
        # Start Logging runspace
93
        $this._StartLogging()
94
    }
95
    
96
    hidden _LogMessage([string]$message, [string]$severity)
97
    {
98
        $addResult = $false
99
        while ($addResult -eq $false)
100
        {
101
            $msg = '<{0}>1 {1} {2} {3} {4} - - {5}' -f ($this._facility*8+[SyslogSeverity]::$severity), [DateTime]::UtcNow.tostring('yyyy-MM-ddTHH:mm:ss.fffK'), $this._fqdn, $this._processName, $this._processId, $message
102
            $addResult = $this._logEntries.TryAdd($msg)
103
        }
104
    }
105
106
    hidden _StartLogging()
107
    {
108
        $this._LoggingRunspace.ThreadOptions = "ReuseThread"
109
        $this._LoggingRunspace.Open()
110
        $this._LoggingRunspace.SessionStateProxy.SetVariable("logEntries", $this._logEntries)
111
        $this._LoggingRunspace.SessionStateProxy.SetVariable("logLocation", $this._logLocation)
112
        $cmd = [PowerShell]::Create().AddScript($this.loggingScript)
113
      
114
        $cmd.Runspace = $this._LoggingRunspace
115
        $null = $cmd.BeginInvoke()
116
    }
117
118
    hidden _AddSeverities([string]$propName)
119
    {
120
        $property = new-object management.automation.PsScriptMethod $propName, {param($value) $propname = $propname; $this._LogMessage($value, $propname)}.GetNewClosure()
121
        $this.psobject.methods.add($property)
122
    }
123
}

The only things I added here were the two enums for syslog severity and facilities and a little bit of logic to achieve a syslog like log output. If you would like to combine this method with a full featured syslog implementation, I recommend you take a look at the Posh-SYSLOG module by Kieran Jacobsen.

For better accessability and a log framework like usability, I also added a method called _AddSeverities. It is called with every enum name returned by the GetEnumNames() method to add as PSScriptMethod for each.
That enables us to use syntax like this to log something:

1
$psLogger.Alert("Test Alert")

Example

Here, we create an instance of the PsLogger class and write some logs to the “C:\temp” folder.

1
. 'c:\temp\PSLogger.ps1'
2
$logger = [PSLogger]::new("C:\temp")
3
$logger.Alert("Async logging is awesome")
4
$logger.Informational("It really is")
5
$logger.Error("Critical error")

Now lets take a look at the output file.

1
<185>1 2019-07-04T18:38:27.687Z DESKTOP-XXXXXXX.WORKGROUP pwsh 10168 - - Async logging is awesome
2
<190>1 2019-07-04T18:38:27.711Z DESKTOP-XXXXXXX.WORKGROUP pwsh 10168 - - It really is
3
<187>1 2019-07-04T18:38:28.346Z DESKTOP-XXXXXXX.WORKGROUP pwsh 10168 - - Critical error

And thats it! You can now extend and rewrite the logging class for your needs and don’t forget to check in frequently for my next post about logging into Azure append blobs 😃