ps-jsonlogger is a small, dependency-free structured logging module for PowerShell that offers both compact JSON logs on-disk and human-readble console output. It supports log levels, context objects, full call stack inclusion, and more. I designed this module with my current corporate environment in mind:
-
No additional dependencies - All 3rd party libraries need to go through a security review, which can take a while. To reduce the time required for review, this module exclusively uses the PS standard library and is itself quite small.
-
No modifications to existing PS constructs - We have a lot of existing scripts that we want to integrate this logging with, not just utilize it for new development. To avoid steppng on toes in existing code, this library doesn't repurpose or modify the behaviour any existing cmdlets/functions (e.g.
Write-Warning,Write-Verbose, etc.). -
Windows Powershell v5 backwards compatibility - Of those existing scripts, some require PSv5 so backwards compatibility is a must. See the notes on v7 vs. v5 for details.
-
Simple structure for log files - To make it easy and fast to write parsers/visualizrs for these logs, a simple output format is prioritized.
-
Human-readable console output - In addition to log file file output, easy-to-read console output is also available.
If these features match your needs, ps-jsonlogger is for you! You can get started by checking out the usage instructions below.
It is recommend to ps-jsonlogger from PowerShell Gallery with the following command:
Install-Module -Name ps-jsonlogger
Alternatively, you may download the latest .nupkg file directly from the releases page and install it using a local repository:
PS > $myRepoParams = @{
Name = "MyPsRepo"
SourceLocation = "/path/to/MyPsRepo/"
ScriptSourceLocation = "/path/to/MyPsRepo/"
InstallationPolicy = "Trusted"
}
PS > Register-PSRepository @myRepoParams
PS > Copy-Item -Path "/path/to/ps-jsonlogger.1.3.0.nupkg" -Destination "/path/to/MyPsRepo/"
PS > Install-Module -Name "ps-jsonlogger" -Repository "MyPsRepo"Import-Module ps-jsonlogger
New-Logger -Path "./basic_logging.log" -ProgramName "Basic Logging Example"
Write-Log "Hello, World!"{"timestamp":"2025-10-12T21:10:25.6091212-05:00","level":"START","programName":"Basic Logging Example","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0"}
{"timestamp":"2025-10-12T21:10:25.6163049-05:00","level":"INFO","message":"Hello, World!","calledFrom":"at <ScriptBlock>, C:\\basic_logging.ps1: line 4"}The first entry in a log file, which is created when you run New-Logger, always contains the program name, the timestmap of when the log was initialized, your PowerShell version and your ps-jsonlogger version.
It is recommended that you call Close-Log at the end of your script so that a final entry is written to the log file. This will also remove it from the pool of loggers and it can no longer be written to.
Import-Module ps-jsonlogger
New-Logger -Path "./close.log" -ProgramName "Close-Log Example"
Write-Log "Hello, World!"
Close-Log{"timestamp":"2025-10-12T21:14:02.3854661-05:00","level":"START","programName":"Close-Log Example","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0"}
{"timestamp":"2025-10-12T21:14:02.3860781-05:00","level":"INFO","message":"Hello, World!","calledFrom":"at <ScriptBlock>, C:\\close.ps1: line 4"}
{"timestamp":"2025-10-12T21:14:02.3998504-05:00","level":"END"}You can also call Close-Log with a message string. E.g. Close-Log "Done!" will result in a closing line like this instead:
{"timestamp":"2025-10-12T21:16:20.8704909-05:00","level":"END","message":"Goodbye, Cruel World!"}If you have multiple loggers open, you can close all of them at once by calling Close-Log -All.
The following log levels are available: INFO, SUCCESS, WARNING, ERROR, FATAL, DEBUG, VERBOSE. Both FATAL and VERBOSE always includes the full call stack in the log entry. Additionally, FATAL will close the log and call exit 1, terminating the script. You can specify which log level you want to use like so:
Import-Module ps-jsonlogger
New-Logger -Path "./log_levels_part_1.log" -ProgramName "Log Levels Example 1"
Write-Log -Level "INFO" "Info level test"
Write-Log -Level "SUCCESS" "Success level test"
Write-Log -Level "WARNING" "Level test - warning"
Write-Log -Level "ERROR" "Level test - error"
Write-Log -Level "DEBUG" "Level test - debug"
Write-Log -Level "VERBOSE" "Level test - verbose"
Write-Log -Level "FATAL" "For terminating errors, FATAL-level logs will exit the script with 'exit 1'."
Write-Log -Level "DEBUG" "This line will never be logged because the preceeding line exited the program."{"timestamp":"2025-10-17T14:17:42.8001352-05:00","level":"START","programName":"Log Levels Example 1","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0","hasWarning":true,"hasError":true,"hasFatal":true}
{"timestamp":"2025-10-17T14:17:42.8086297-05:00","level":"INFO","message":"Info level test","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 5"}
{"timestamp":"2025-10-17T14:17:42.8393823-05:00","level":"SUCCESS","message":"Success level test","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 6"}
{"timestamp":"2025-10-17T14:17:42.8548153-05:00","level":"WARNING","message":"Level test - warning","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 7"}
{"timestamp":"2025-10-17T14:17:42.8824776-05:00","level":"ERROR","message":"Level test - error","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 8"}
{"timestamp":"2025-10-17T14:17:42.9094522-05:00","level":"DEBUG","message":"Level test - debug","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 9"}
{"timestamp":"2025-10-17T14:17:42.9219740-05:00","level":"VERBOSE","message":"Level test - verbose","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 10","callStack":"at LogEntry, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 217 at Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 138 at Write-Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 552 at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 10 at <ScriptBlock>, <No file>: line 1"}
{"timestamp":"2025-10-17T14:17:42.9389231-05:00","level":"FATAL","message":"For terminating errors, FATAL-level logs will exit the script with 'exit 1'.","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 12","callStack":"at LogEntry, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 217 at Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 138 at Write-Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 552 at <ScriptBlock>, C:\\log_levels_part_1.ps1: line 12 at <ScriptBlock>, <No file>: line 1"}
{"timestamp":"2025-10-17T14:17:42.9677497-05:00","level":"END"}There are a couple ways to specify the log level, all are case-insensitive, and they are functionally equivalent, so you can use whichever you prefer:
- The
-Levelparameter (e.g.-Level "ERROR"or-Level "E") - Per-level parameters (e.g.
-Err,-E) - If no level is set, the default is
INFO
Note: All per-level parameters are shortened versions of the full level names. This is because Error, Verbose, and Debug are all reserved words of one kind or another in PowerShell. The full list is -Inf, -Scs, -Wrn, -Err, -Dbg, -Vrb, and -Ftl.
Import-Module ps-jsonlogger
New-Logger -Path "./log_levels_part_2.log" -ProgramName "Log Levels Example 2"
Write-Log "If you don't specify a level, INFO is the default"
Write-Log -Level "SUCCESS" "The full level name is always an option"
Write-Log -Level "W" "All levels can be shortened to their first letter"
Write-Log -Level "error" "Level arguments are case-insensitive"
Write-Log -Dbg "Instead of -Level, you can use the per-level parameters"
Write-Log -V "If you want to be REALLY consice, you can also shorten the per-level parameters"
Close-Log{"timestamp":"2025-10-17T14:17:48.0170936-05:00","level":"START","programName":"Log Levels Example 2","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0","hasWarning":true,"hasError":true}
{"timestamp":"2025-10-17T14:17:48.0177299-05:00","level":"INFO","message":"If you don't specify a level, INFO is the default","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_2.ps1: line 5"}
{"timestamp":"2025-10-17T14:17:48.0423497-05:00","level":"SUCCESS","message":"The full level name is always an option","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_2.ps1: line 6"}
{"timestamp":"2025-10-17T14:17:48.0617364-05:00","level":"WARNING","message":"All levels can be shortened to their first letter","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_2.ps1: line 7"}
{"timestamp":"2025-10-17T14:17:48.0836619-05:00","level":"ERROR","message":"Level arguments are case-insensitive","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_2.ps1: line 8"}
{"timestamp":"2025-10-17T14:17:48.1090591-05:00","level":"DEBUG","message":"Instead of -Level, you can use the per-level parameters","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_2.ps1: line 9"}
{"timestamp":"2025-10-17T14:17:48.1216305-05:00","level":"VERBOSE","message":"If you want to be REALLY consice, you can also shorten the per-level parameters","calledFrom":"at <ScriptBlock>, C:\\log_levels_part_2.ps1: line 10","callStack":"at LogEntry, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 217 at Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 138 at Write-Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 552 at <ScriptBlock>, C:\\log_levels_part_2.ps1: line 10 at <ScriptBlock>, <No file>: line 1"}
{"timestamp":"2025-10-17T14:17:48.1343098-05:00","level":"END"}
When a log file contains a warning, an error, and/or a fatal entry, the initial entry will be updated to include "hasWarning":true, "hasError":true, and/or "hasFatal":true respectively:
{"timestamp":"2025-10-17T14:17:42.8001352-05:00","level":"START","programName":"Log Levels Example 1","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0","hasWarning":true,"hasError":true,"hasFatal":true}You can pass the -WriteToHost <style> parameter to New-Logger and write out human-readable versions of the log entries to the console using the Write-Host cmdlet (this is in addition to the on-disk log file). Three styles are supported: Simple, TimeSpan, and Timestamp and the outputs are as follows:
- Simple - The log entry is written to the console as
[LVL] message - TimeSpan - Include the amount of time that's passed since the logger started
- Timestamp - Include the timestamp of when the log entry was written
Here's a full example using -WriteToHost Simple:
Import-Module ps-jsonlogger
New-Logger -Path "./write_to_host.log" -ProgramName "Write to Host Example" -WriteToHost Simple
Write-Log -Level "INFO" "Level test - info"
Write-Log -Level "SUCCESS" "Level test - success"
Write-Log -Level "WARNING" "Level test - warning"
Write-Log -Level "ERROR" "Level test - error"
Write-Log -Level "DEBUG" "Level test - debug"
Write-Log -Level "VERBOSE" "Level test - verbose"
Write-Log -Level "FATAL" "For terminating errors, FATAL-level logs will exit the script with 'exit 1'."
Write-Log -Level "DEBUG" "This line will never be logged because the preceeding line exited the program."
[INF 00:00.04] Level test - info
[INF 21:38:34.27] Level test - info
Normally when you try to write to an existing file, you'll receive an error:
PS > New-Logger -Path "./overwrite_test.log" -ProgramName "Overwrite Example"Exception: The file './overwrite_test.log' already exists and is not empty. Use -Overwrite to overwrite it.
You can pass the -Overwrite flag to New-Logger to overwrite the existing file:
PS > New-Logger -Path "./overwrite_test.log" -ProgramName "Overwrite Example" -OverwriteThe calledFrom field in each log entries tells you from where the Write-Log function was called. If it was called from a function, you'll see the function name, script source, and what line in that script source the call to Write-Log happened:
Import-Module ps-jsonlogger
function main {
New-Logger -Path "./called_from_function.log" -ProgramName "Called From Function Example"
Write-Log "Check out the 'calledFrom' attribute of this log entry!"
Close-Log
}
main{"timestamp":"2025-10-12T22:08:53.1801031-05:00","level":"INFO","message":"Check out the 'calledFrom' attribute of this log entry!","calledFrom":"at main, C:\\called_within_function.ps1: line 5"}If instead you call Write-Log outside a function, you will see the text at <ScriptBlock> instead of at FunctionName:
Import-Module ps-jsonlogger
New-Logger -Path "./called_outside_function.log" -ProgramName "Called Outside Function Example"
Write-Log "Check out the 'calledFrom' attribute of this log entry!"
Close-Log{"timestamp":"2025-10-12T22:06:06.4923488-05:00","level":"INFO","message":"Check out the 'calledFrom' attribute of this log entry!","calledFrom":"at <ScriptBlock>, C:\\called_outside_function.ps1: line 4"}Write-Log provides two additional options:
- Include one or more additional objects for context
- Include the full call stack
You can use one of the additional options or both at the same time:
# One context object
Write-Log -Level $level -Message $message -Context $obj
# An array of multiple objects
Write-Log -Level $level -Message $message -Context @($obj1, $obj2, $obj3)
# Include the full call stack
Write-Log -Level $level -Message $message -WithCallStack
# Do both
Write-Log -Level $level -Message $message -Context $obj -WithCallStackYou can pass any PowerShell object, or an array of multiple objects, to New-Logger -Context to have them appear in the log entry. Here's an example:
Import-Module ps-jsonlogger
class Ctx {
[string]$Name
[object]$NestedObj1
[object]$NestedObj2
Ctx([string]$name) {
$this.Name = $name
$this.NestedObj1 = [ordered]@{
Id = 1
Value = "Nested object 1"
}
$this.NestedObj2 = [ordered]@{
Id = 2
Value = "Nested object 2"
}
}
}
function main {
$level = "DEBUG"
$context = [Ctx]::new("Sample Context Object")
New-Logger -Path "./context_object.log" -ProgramName "Context Object Example"
Write-Log -Level $level "Current object state" -Context $context
Close-Log
}
main{"timestamp":"2025-10-12T22:15:32.1680644-05:00","level":"DEBUG","message":"Current object state","context":[{"Name":"Sample Context Object","NestedObj1":{"Id":1,"Value":"Nested object 1"},"NestedObj2":{"Id":2,"Value":"Nested object 2"}}],"calledFrom":"at main, C:\\context_object.ps1: line 26"}If you wish to include the full call stack (taken from PowerShell's Get-PSCallStack), you can do that like this:
Import-Module ps-jsonlogger
function Another-Function {
Write-Log -Dbg "Full call stack, second function" -WithCallStack
}
function main {
New-Logger -Path "./call_stack.log" -ProgramName "Including the full call stack."
Write-Log -Dbg "Full call stack, first function" -WithCallStack
Another-Function
Close-Log
}
main{"timestamp":"2025-10-12T22:17:44.2150565-05:00","level":"START","programName":"Including the full call stack.","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0"}
{"timestamp":"2025-10-12T22:17:44.2157187-05:00","level":"DEBUG","message":"Full call stack, first function","calledFrom":"at main, C:\\call_stack.ps1: line 9","callStack":"at LogEntry, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 193 at Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 116 at Write-Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 329 at main, C:\\call_stack.ps1: line 9 at <ScriptBlock>, C:\\call_stack.ps1: line 16 at <ScriptBlock>, <No file>: line 1"}
{"timestamp":"2025-10-12T22:17:44.2263534-05:00","level":"DEBUG","message":"Full call stack, second function","calledFrom":"at Another-Function, C:\\call_stack.ps1: line 4","callStack":"at LogEntry, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 193 at Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 116 at Write-Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 329 at Another-Function, C:\\call_stack.ps1: line 4 at main, C:\\call_stack.ps1: line 11 at <ScriptBlock>, C:\\call_stack.ps1: line 16 at <ScriptBlock>, <No file>: line 1"}
{"timestamp":"2025-10-12T22:17:44.2421852-05:00","level":"END"}You can create multiple loggers in the same script by calling New-Logger with the -LoggerName parameter. This will create a new logger with a different name, and you can use it in Write-Log and Close-Log by specifying the logger name as the -Logger parameter. Under the hood, the default logger is always named "default" and you can safely omit the -LoggerName parameter if you don't need multiple loggers.
Import-Module ps-jsonlogger
function DoSomething {
Write-Log -Level "INFO" -Message "This will go to the default logger."
}
function DoSomethingDangerous {
throw "Be careful!"
}
function DoSomethingREALLYDangerous {
throw "Now you've done it..."
}
function main {
New-Logger -Path "./multiple_loggers_default.log" -ProgramName "Multiple Loggers Example"
New-Logger -Path "./multiple_loggers_errors.log" -ProgramName "Multiple Loggers Example" -LoggerName "errors"
DoSomething
try {
DoSomethingDangerous
}
catch {
Write-Log -Logger "errors"-Level "ERROR" -Message "This will go to the errors logger."
}
try {
DoSomethingREALLYDangerous
}
catch {
# If you don't specify -Logger, the default logger will be closed.
Close-Log -Message "Error encountered. Closing."
# FATAL errors will both close the associated logger and exit the script.
Write-Log -Logger "errors" -Level "FATAL" -Message "Whoops..."
}
}
main{"timestamp":"2025-10-13T11:03:34.6137242-05:00","level":"START","programName":"Multiple Loggers Example","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0"}
{"timestamp":"2025-10-13T11:03:34.6219973-05:00","level":"INFO","message":"This will go to the default logger.","calledFrom":"at DoSomething, C:\\multiple_loggers.ps1: line 4"}
{"timestamp":"2025-10-13T11:03:34.6571757-05:00","level":"END","message":"Error encountered. Closing."}{"timestamp":"2025-10-13T11:03:34.6179096-05:00","level":"START","programName":"Multiple Loggers Example","PSVersion":"7.5.3","jsonLoggerVersion":"1.3.0","hasError":true,"hasFatal":true}
{"timestamp":"2025-10-13T11:03:34.6391387-05:00","level":"ERROR","message":"This will go to the errors logger.","calledFrom":"at main, C:\\multiple_loggers.ps1: line 25"}
{"timestamp":"2025-10-13T11:03:34.6646649-05:00","level":"FATAL","message":"Whoops...","calledFrom":"at main, C:\\multiple_loggers.ps1: line 36","callStack":"at LogEntry, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 193 at Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 116 at Write-Log, C:\\PowerShell\\Modules\\ps-jsonlogger\\1.3.0\\ps-jsonlogger.psm1: line 487 at main, C:\\multiple_loggers.ps1: line 36 at <ScriptBlock>, C:\\multiple_loggers.ps1: line 40 at <ScriptBlock>, <No file>: line 1"}
{"timestamp":"2025-10-13T11:03:34.6908570-05:00","level":"END"}While ps-jsonlogger is compatible with Windows PowerShell v5, it does have some inherent differences.
PowerShelll 7 and PowerShell 5 support different encoding options. New-Logger -Encoding will accept encodings appropriately depending on your current version. Below is a list of support encodings, for full details, see the official documentation.
v7: ascii, bigendianunicode, bigendianutf32, oem, unicode, utf7, utf8, utf8BOM, utf8NoBOM, utf32
Additionally, v7.4+ supports ansi as an option.
Default: utf8BOM
v5: Ascii, BigEndianUnicode, BigEndianUTF32, Byte, Default, Oem, String, Unicode, Unknown, UTF7, UTF8, UTF32
Default: UTF8
Powershell v5 does not convert all special characters in JSON strings the same way as PowerShell v7. This means that you may see some characters like \u003c in your log files in v5 instead of < in v7 or \u0027 instead of '. PowerShell v5 will still import these files just fine and the initial log entry includes the powerShellVersion property that can be utilized in any parsers to ensure proper JSON deserialization.
The examples folder contains all the scripts used in this README.
Logs are no good if you can't read them! See parsing-and-visualization for more information.