ps-jsonlogger.psm1
# Copyright (c) 2025 Bryan Cuneo # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. enum Levels { INFO SUCCESS WARNING ERROR FATAL DEBUG VERBOSE } if ($PSVersionTable.PSVersion.Major -ge 7) { $_ValidEncodings = @("ansi", "ascii", "bigendianunicode", "bigendianutf32", "oem", "unicode", "utf7", "utf8", "utf8BOM", "utf8NoBOM", "utf32") # Before 7.4, "ansi" was not a valid encoding if ($PSVersionTable.PSVersion.Minor -lt 4) { $_ValidEncodings.Remove("ansi") } } else { $_ValidEncodings = @("Ascii", "BigEndianUnicode", "BigEndianUTF32", "Byte", "Default", "Oem", "String", "Unicode", "Unknown", "UTF7", "UTF8", "UTF32") } $_Loggers = [ordered]@{} class Logger { [string]$Path [string]$ProgramName [string]$Encoding [bool]$Overwrite [bool]$WriteToHost [bool]$hasWarning = $false [bool]$hasError = $false static [string]$JsonLoggerVersion = "1.1.0" static [hashtable]$ShortLevels = @{ "INFO" = "INF" "SUCCESS" = "SCS" "WARNING" = "WRN" "ERROR" = "ERR" "FATAL" = "FTL" "DEBUG" = "DBG" "VERBOSE" = "VRB" } Logger([string]$path, [string]$programName, [string]$encoding, [bool]$overwrite = $false, [bool]$writeToHost = $false) { $this.Path = $path $this.ProgramName = $programName $this.Encoding = $encoding $this.Overwrite = $overwrite $this.WriteToHost = $writeToHost if ($this.Overwrite -or -not (Test-Path -Path $this.Path)) { New-Item -Path $this.Path -ItemType File -Force | Out-Null } elseif ((Get-Item -Path $this.Path).Length -gt 0) { throw "The file '$path' already exists and is not empty. Use -Overwrite to overwrite it." } elseif (-not (Get-Item -Path $this.Path).PSIsContainer) { throw "The path '$path' is not a valid file." } $initialEntry = [ordered]@{ timestamp = (Get-Date).ToString("o") level = "START" programName = $this.ProgramName PSVersion = $global:PSVersionTable.PSVersion.ToString() jsonLoggerVersion = [Logger]::JsonLoggerVersion } try { $initialEntryJson = $initialEntry | ConvertTo-Json -Compress Add-Content -Path $this.Path -Value $initialEntryJson -Encoding $this.Encoding -ErrorAction Stop if ($this.WriteToHost) { Write-Host "[$($initialEntry.level)][$(Get-Date $initialEntry.timestamp -f "yyyy-MM-dd HH:mm:ss")] $($this.ProgramName)" } } catch { throw "Failed to convert initial log entry to JSON: $_" } } hidden [void] AddToInitialEntry([string]$newFieldName, [object]$value) { $file = Get-Content -Path $this.Path -Encoding $this.Encoding if ($global:PSVersionTable.PSVersion.Major -ge 6) { $newInitialEntry = ($file[0] | ConvertFrom-Json -AsHashtable) } else { # PowerShell v5 doesn't support -AsHashtable, so we have to do it manually $json = ($file[0] | ConvertFrom-Json) $newInitialEntry = [ordered]@{} $json.PSObject.Properties | ForEach-Object { $newInitialEntry[$_.Name] = $_.Value } } $newInitialEntry.$newFieldName = $value $file[0] = $newInitialEntry | ConvertTo-Json -Compress $file | Set-Content -Path $this.Path -Encoding $this.Encoding } [void] Log([Levels]$level, [string]$message, [string]$calledFrom, [array]$context, [bool]$includeCallStack) { try { if ($null -ne $context) { $logEntry = [LogEntry]::new($level, $message, $calledFrom, $context, $includeCallStack) try { $logEntryJson = $logEntry | ConvertTo-Json -Compress -Depth 100 } catch { Write-Warning "Failed to fully convert full context object to JSON. Falling back simplifed JSON." $logEntryJson = $logEntry | ConvertTo-Json -Compress } } else { $logEntry = [LogEntry]::new($level, $message, $calledFrom, $null, $includeCallStack) $logEntryJson = $logEntry | ConvertTo-Json -Compress } } catch { throw $_ } Add-Content -Path $this.Path -Value $logEntryJson -Encoding $this.Encoding -ErrorAction Stop if ($this.WriteToHost) { switch ($level) { "SUCCESS" { Write-Host $logEntry.ToString() -ForegroundColor Green } "WARNING" { Write-Host $logEntry.ToString() -ForegroundColor Yellow } "ERROR" { Write-Host $logEntry.ToString() -ForegroundColor Red } "FATAL" { Write-Host $logEntry.ToString() -ForegroundColor Red } default { Write-Host $logEntry.ToString() } } } if (-not $this.hasWarning -and $level -eq [Levels]::WARNING) { $this.AddToInitialEntry("hasWarning", $true) $this.hasWarning = $true } elseif (-not $this.HasError -and $level -eq [Levels]::ERROR) { $this.AddToInitialEntry("hasError", $true) $this.hasError = $true } elseif ($level -eq [Levels]::FATAL) { $this.AddToInitialEntry("hasFatal", $true) $this.Close() Cleanup exit 1 } } [void] Close() { $this.Close("") } [void] Close($message) { $finalEntry = [ordered]@{ timestamp = (Get-Date).ToString("o") level = "END" } if (-not [string]::IsNullOrEmpty($message)) { $finalEntry | Add-Member -MemberType NoteProperty -Name "message" -Value $message } if ($this.WriteToHost) { $friendlyString = "[$($finalEntry.level)][$(Get-Date $finalEntry.timestamp -f "yyyy-MM-dd HH:mm:ss")]" if (-not [string]::IsNullOrEmpty($message)) { $friendlyString += " $message" } Write-Host $friendlyString } $finalEntryJson = $finalEntry | ConvertTo-Json -Compress Add-Content -Path $this.Path -Value $finalEntryJson -Encoding $this.Encoding -ErrorAction Stop } } class LogEntry { [string]$timestamp = (Get-Date).ToString("o") [string]$level [string]$message LogEntry([Levels]$level, [string]$message, [string]$calledFrom, [array]$context, [bool]$includeCallStack) { $this.level = [Levels].GetEnumName($level) $this.message = $message if ($null -ne $context) { $this | Add-Member -MemberType NoteProperty -Name "context" -Value $context } $this | Add-Member -MemberType NoteProperty -Name "calledFrom" -Value $calledFrom if ($this.level -eq [Levels]::VERBOSE -or $this.level -eq [Levels]::FATAL -or $includeCallStack) { $this | Add-Member -MemberType NoteProperty -Name "callStack" -Value ([string](Get-PSCallStack)) } } [string] ToString() { return "[$([Logger]::ShortLevels[$this.level])] $($this.message)" } } <# .SYNOPSIS Creates a new Logger instance. .DESCRIPTION The New-Logger function initializes a Logger that writes JSON entries to a sepcified file. You can have multiple loggers in the same script by utilizing the -LoggerName parameter, and you can use any of PowerShell's supported encoding options with the -Encoding parameter (default: utf8). .PARAMETER Path The file path where the log file will be written. It is mandatory and cannot be null or empty. .PARAMETER ProgramName Friendly name for the program that is logging. It is mandatory and cannot be null or empty. .PARAMETER Encoding Text encoding used for the log file. PowerShell v7 encodings: "ascii", "bigendianunicode", "bigendianutf32", "oem", "unicode", "utf7", "utf8", "utf8BOM", "utf8NoBOM", "utf32" Additionally, 7.4+ supports "ansi" as an option. Default: utf8BOM PowerShell v5 encodings: "Ascii", "BigEndianUnicode", "BigEndianUTF32", "Byte", "Default", "Oem", "String", "Unicode", "Unknown", "UTF7", "UTF8", "UTF32" Default: utf8 .PARAMETER LoggerName An optional parameter to use if you want to create multiple loggers. By default, it is set to "default" and you can safely ignore it. .PARAMETER Overwrite A switch that, when set, allows overwriting existing log files. Default: off .PARAMETER WriteToHost A switch that, when set, allows log messages to be output to the host via the Write-Host cmdlet (this is in addition to being written to disk). Default: off .PARAMETER Force A switch that, when set, allows the creation of a logger that has the same name as an existing logger. Default: off .INPUTS None. .OUTPUTS None. .EXAMPLE New-Logger -Path "C:\logs\app.log" -ProgramName "MyApplication" Creates a new logger that writes to "C:\logs\app.log" for "MyApplication" with default parameters. .EXAMPLE New-Logger ` -Path "C:\logs\app.log" ` -ProgramName "MyApplication" ` -LoggerName "MyLogger" ` -Overwrite ` -Force Creates a logger named "MyLogger" that overwrites any existing log file at "C:\logs\app.log". .LINK Write-Log .LINK Close-Log .LINK https://github.com/BryanCuneo/ps-jsonlogger #> function New-Logger { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ProgramName, [ValidateScript({ if ($_ -in $_ValidEncodings) { $true } else { throw "'$_' is not a valid encoding. Please try again with a supported encoding: $($_ValidEncodings -join ", ")" } })] [string]$Encoding, [ValidateNotNullOrEmpty()] [string]$LoggerName = "default", [switch]$Overwrite, [switch]$WriteToHost, [switch]$Force ) if (-not $Encoding -and $PSVersionTable.PSVersion.Major -ge 7) { $Encoding = "utf8BOM" } elseif (-not $Encoding) { $Encoding = "utf8" } if ($_Loggers.Contains($LoggerName) -and -not $Force) { throw "Unable to create logger '$LoggerName'. Use -LoggerName <name> to create a new logger with a different name or -Force to override this." } if ($PSCmdlet.ShouldProcess($Path, "Create logger '$LoggerName'")) { $_Loggers[$LoggerName] = [Logger]::new($Path, $ProgramName, $Encoding, $Overwrite, $WriteToHost) } } Register-ArgumentCompleter -CommandName New-Logger -ParameterName Encoding -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $_ValidEncodings | Where-Object { $_ -like "$wordToComplete*" } } <# .SYNOPSIS Writes structured JSON log entries using a Logger instance from New-Logger. .DESCRIPTION The Write-Log function allows you to log messages with different severity levels: INFO, WARNING, ERROR, DEBUG, VERBOSE, and FATAL. You can also include contextual information and/or call stack details. .PARAMETER Message The log message to be recorded. This parameter is mandatory and cannot be null or empty. It can be piped to the function, given as as positional parameter, or given explicitly as -Message. .PARAMETER Context An optional array of PowerShell objects to provide additional contextual info about to the log entry. .PARAMETER WithCallStack A switch that, when set, includes the full call stack from Get-PSCallStack in the log entry. .PARAMETER Logger If you have more than one logger instance, this parameter allows you to specify which one to write to. If not specified, the default logger will be used. .PARAMETER Level The severity level of the log message. Valid options are INFO, I, SUCCESS, S WARNING, W, ERROR, E, DEBUG, D, VERBOSE, V, FATAL, and F. Default: INFO .PARAMETER Inf A switch that can be used to specify the log level as INFO. .PARAMETER Scs A switch that can be used to specify the log level as SUCCESS. .PARAMETER Wrn A switch that can be used to specify the log level as WARNING. .PARAMETER Err A switch that can be used to specify the log level as ERROR. .PARAMETER Dbg A switch that can be used to specify the log level as DEBUG. .PARAMETER Vrb A switch that can be used to specify the log level as VERBOSE. .PARAMETER Ftl A switch that can be used to specify the log level as FATAL. .INPUTS A string message. .OUTPUTS None. .EXAMPLE Write-Log "Hello, World!" Logs an message with the default level of INFO. .EXAMPLE Write-Log -Level "W" -Message "This is a warning message." Logs a warning message. .EXAMPLE $context = [Ordered]@{ Name = "John Doe" Age = 42 } "This is an error message with context." | Write-Log -Err -Context $context Logs an error message along with additional context information. .EXAMPLE Write-Log -F "An unrecoverable error has occurred. Exiting." Logs a FATAL error that will close the log cause the script to exit. .LINK New-Logger .LINK Close-Log .LINK https://github.com/BryanCuneo/ps-jsonlogger #> function Write-Log { [CmdletBinding(DefaultParameterSetName = "LevelParam")] param( [Parameter(ParameterSetName = "LevelParam")] [Parameter(ParameterSetName = "Info")] [Parameter(ParameterSetName = "Success")] [Parameter(ParameterSetName = "Warning")] [Parameter(ParameterSetName = "Error")] [Parameter(ParameterSetName = "Debug")] [Parameter(ParameterSetName = "Verbose")] [Parameter(ParameterSetName = "Fatal")] [Parameter(Mandatory, ValueFromPipeline = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Message, [Parameter(ParameterSetName = "LevelParam")] [Parameter(ParameterSetName = "Info")] [Parameter(ParameterSetName = "Success")] [Parameter(ParameterSetName = "Warning")] [Parameter(ParameterSetName = "Error")] [Parameter(ParameterSetName = "Debug")] [Parameter(ParameterSetName = "Verbose")] [Parameter(ParameterSetName = "Fatal")] [array]$Context = $null, [Parameter(ParameterSetName = "LevelParam")] [Parameter(ParameterSetName = "Info")] [Parameter(ParameterSetName = "Success")] [Parameter(ParameterSetName = "Warning")] [Parameter(ParameterSetName = "Error")] [Parameter(ParameterSetName = "Debug")] [Parameter(ParameterSetName = "Verbose")] [Parameter(ParameterSetName = "Fatal")] [switch]$WithCallStack, [Parameter(ParameterSetName = "LevelParam")] [Parameter(ParameterSetName = "Info")] [Parameter(ParameterSetName = "Success")] [Parameter(ParameterSetName = "Warning")] [Parameter(ParameterSetName = "Error")] [Parameter(ParameterSetName = "Debug")] [Parameter(ParameterSetName = "Verbose")] [Parameter(ParameterSetName = "Fatal")] [ValidateNotNullOrEmpty()] [string]$Logger = "default", [Parameter(ParameterSetName = "LevelParam")] [ValidateSet("INFO", "I", "SUCCESS", "S", "WARNING", "W", "ERROR", "E", "DEBUG", "D", "VERBOSE", "V", "FATAL", "F")] [string]$Level = "INFO", [Parameter(Mandatory, ParameterSetName = "Info")] [Alias("I")] [switch]$Inf, [Parameter(Mandatory, ParameterSetName = "Success")] [Alias("S")] [switch]$Scs, [Parameter(Mandatory, ParameterSetName = "Warning")] [Alias("W")] [switch]$Wrn, [Parameter(Mandatory, ParameterSetName = "Error")] [Alias("E")] [switch]$Err, [Parameter(Mandatory, ParameterSetName = "Debug")] [Alias("D")] [switch]$Dbg, [Parameter(Mandatory, ParameterSetName = "Verbose")] [Alias("V")] [switch]$Vrb, [Parameter(Mandatory, ParameterSetName = "Fatal")] [Alias("F")] [switch]$Ftl ) if ($($PSCmdlet.ParameterSetName) -ne "LevelParam") { if ($Inf) { $Level = [Levels]::INFO } elseif ($Scs) { $Level = [Levels]::SUCCESS } elseif ($Wrn) { $Level = [Levels]::WARNING } elseif ($Err) { $Level = [Levels]::ERROR } elseif ($Dbg) { $Level = [Levels]::DEBUG } elseif ($Vrb) { $Level = [Levels]::VERBOSE } elseif ($Ftl) { $Level = [Levels]::FATAL } } elseif ($Level -in @("I", "W", "E", "D", "V", "F")) { switch ($Level) { "I" { $Level = [Levels]::INFO } "S" { $Level = [Levels]::SUCCESS } "W" { $Level = [Levels]::WARNING } "E" { $Level = [Levels]::ERROR } "D" { $Level = [Levels]::DEBUG } "V" { $Level = [Levels]::VERBOSE } "F" { $Level = [Levels]::FATAL } } } if ($_Loggers.Count -eq 0) { throw "No existing loggers. Use 'New-Logger' to create one." } if (-not $_Loggers.Contains($Logger)) { Write-Warning "'$Logger' does not match any existing loggers ('$($_Loggers.Keys -join ", '")'). Falling back to '$($_Loggers.Keys[0])'." $Logger = $_Loggers.Keys[0] } $_Loggers[$Logger].Log($Level, $Message, (Get-PSCallStack)[1].ToString(), $Context, $WithCallStack) } <# .SYNOPSIS Closes a logger instance with an optional message. .DESCRIPTION The Close-Log function is used to close an existing logger instance. It will write a closing entry (with an optional message) to the file and then remove the logger from the active logger pool. .PARAMETER Message An optional message to log when closing the logger. It can be piped to the function, given as as positional parameter, or given explicitly as -Message. .PARAMETER Logger If you have more than one logger instance, this parameter allows you to specify which one to close. If not specified, the default logger will be closed. .PARAMETER All A switch that, when set, closes all loggers. This parameter cannot be used with other parameters. .INPUTS A string message. .OUTPUTS None. .EXAMPLE Close-Log "All Done!" Closes the default logger with thes message, "All Done!". .LINK New-Logger .LINK Write-Log .LINK https://github.com/BryanCuneo/ps-jsonlogger #> function Close-Log { param( [Parameter(Mandatory, ParameterSetName = "WithMessage", Position = 0, ValueFromPipeline = $true)] [string]$Message, [Parameter(ParameterSetName = "WithMessage")] [Parameter(ParameterSetName = "WithoutMessage")] [ValidateNotNullOrEmpty()] [string]$Logger = "default", [Parameter(Mandatory, ParameterSetName = "CloseAll")] [switch]$All ) if ($_Loggers.Count -eq 0) { return } if ($All) { Cleanup } if (-not $_Loggers.Contains($Logger)) { throw "'$Logger' does not match any existing loggers ('$($_Loggers.Keys -join ", '")')." } $_Loggers[$Logger].Close($Message) $_Loggers.Remove($Logger) } function Cleanup { $_Loggers.Clear() } # PS module lifecycle management kind of sucks. The PowerShell.Exiting and # OnRemove events are unreliable and don't fire in most scenarious you would # assume they do. However, they're the best options we have to attempt to clean # up the loggers if the user doesn't call Close-Log. Register-EngineEvent -SourceIdentifier PowerShell.Exiting -SupportEvent -Action { Cleanup } $OnRemoveScript = { Cleanup } $ExecutionContext.SessionState.Module.OnRemove += $OnRemoveScript Export-ModuleMember -Function New-Logger, Write-Log, Close-Log |