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.2.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. Required and cannot be null or empty.
 
.PARAMETER ProgramName
Friendly name for the program that is logging. Required 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
A new Logger instance.
 
.EXAMPLE
# Creates a new logger that writes to "C:\logs\app.log" for
# "My Application" with default parameters.
New-Logger -Path "C:\logs\app.log" -ProgramName "My Application"
 
 
.EXAMPLE
# Creates a logger named "MyLogger" that overwrites any existing log
# file at "C:\logs\app.log".
New-Logger `
        -Path "C:\logs\app.log" `
        -ProgramName "My Application" `
        -LoggerName "MyLogger" `
        -Overwrite `
        -Force
 
.LINK
Write-Log
 
.LINK
Close-Log
 
.LINK
Import-Log
 
.LINK
Convert-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. Required 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
The Message parameter accepts pipeline input.
 
.OUTPUTS
None (writes output to disk).
 
.EXAMPLE
# Logs an message with the default level of INFO.
Write-Log "Hello, World!"
 
.EXAMPLE
# Logs a warning message.
Write-Log -Level "W" -Message "This is a warning message."
 
.EXAMPLE
# Logs an error message along with additional context information.
$context = [Ordered]@{
    Name = "John Doe"
    Age = 42
}
"This is an error message with context." | Write-Log -Err -Context $context
 
 
.EXAMPLE
# Logs a FATAL error that will close the log cause the script to exit.
Write-Log -F "An unrecoverable error has occurred. Exiting."
 
.LINK
New-Logger
 
.LINK
Close-Log
 
.LINK
Import-Log
 
.LINK
Convert-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 (writes output to disk).
 
.EXAMPLE
# Closes the default logger with the message, "All Done!".
Close-Log "All Done!"
 
.LINK
New-Logger
 
.LINK
Write-Log
 
.LINK
Convert-Log
 
.LINK
Import-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)
}

<#
.SYNOPSIS
Imports a log file created by ps-jsonlogger.
 
.DESCRIPTION
Imports a log file created by ps-jsonlogger and returns an object containing
the log entries and the following metadata:
- startTime
- endTime
- duration
- programName
- PSVersion
- jsonLoggerVersion
- hasWarning
- hasError
- hasFatal
 
.PARAMETER Path
The path to the log file. Required 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
 
.INPUTS
The Path parameter accepts pipeline input.
 
.OUTPUTS
System.Management.Automation.PSCustomObject
- Contains the properties described in DESCRIPTION.
 
.EXAMPLE
# Basic import
$log = Import-Log -Path "C:\logs\session.log"
 
.EXAMPLE
# From pipeline
$log = "C:\logs\session.log" | Import-Log
 
.EXAMPLE
# Specify encoding
$log = Import-Log -Path "C:\logs\session.log" -Encoding utf8
 
.LINK
New-Logger
 
.LINK
Write-Log
 
.LINK
Close-Log
 
.LINK
Convert-Log
 
.LINK
https://github.com/BryanCuneo/ps-jsonlogger
#>

function Import-Log {
    param(
        [Parameter(Mandatory, ValueFromPipeline = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [ValidateScript({
                if ($_ -in $_ValidEncodings) { $true }
                else { throw "'$_' is not a valid encoding. Please try again with a supported encoding: $($_ValidEncodings -join ", ")" }
            })]
        [string]$Encoding
    )

    if (-not $Encoding -and $PSVersionTable.PSVersion.Major -ge 7) {
        $Encoding = "utf8BOM"
    }
    elseif (-not $Encoding) {
        $Encoding = "utf8"
    }

    $content = Get-Content -Path $Path -Encoding $Encoding -ErrorAction Stop
    $end_time = ($content[-1] | ConvertFrom-Json).timestamp

    $log = $content[0] `
    | ConvertFrom-Json `
    | Select-Object `
    @{Name = "startTime"; Expression = { $_.timestamp } },
    @{Name = "endTime"; Expression = { $end_time } },
    @{Name = "duration"; Expression = { (New-TimeSpan -Start $_.timestamp -End $end_time).ToString("hh\:mm\:ss\.fff") } },
    programName, PSVersion, jsonLoggerVersion,
    @{Name = "hasWarning"; Expression = { $_.hasWarning -eq $true } },
    @{Name = "hasError"; Expression = { $_.hasError -eq $true } },
    @{Name = "hasFatal"; Expression = { $_.hasFatal -eq $true } }

    $entries = @()
    $content | Select-Object -Skip 1 -First $($content.Count - 2) | ForEach-Object { $entries += $_ | ConvertFrom-Json }
    $log | Add-Member -MemberType NoteProperty -Name "entries" -Value $entries


    return $log
}

Register-ArgumentCompleter -CommandName Import-Log -ParameterName Encoding -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    $_ValidEncodings | Where-Object { $_ -like "$wordToComplete*" }
}

<#
.SYNOPSIS
Converts a ps-jsonlogger log file. Currently supports CSV and CLIXML.
 
.DESCRIPTION
Parses a ps-jsonlogger using Import-Log and writes it to a new file in the
chosen format.
 
Supported conversions:
- CSV
- CLIXML
 
.PARAMETER Path
The path to the log file. Required and cannot be null or empty.
 
.PARAMETER Destination
Path to the output file. Required and cannot be null or empty.
 
.PARAMETER Encoding
Text encoding used for the log file and the output 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 ConvertTo
Specifies the target format. Acceptable values: "CSV", "CLIXML".
Alias: To
 
.PARAMETER Overwrite
Switch. If present, existing Destination file will be overwritten.
 
.INPUTS
The Path parameter accepts pipeline input.
 
.OUTPUTS
None (writes output to disk).
 
.EXAMPLE
# Converts a log file to CSV.
Convert-Log -Path "C:\logs\session.log" -Destination "C:\logs\session.csv" -ConvertTo "CSV"
 
.EXAMPLE
# Converts a log file to CLIXML.
"C:\logs\session.log" | Convert-Log -Destination "C:\logs\session.clixml" -ConvertTo "CLIXML"
 
.LINK
New-Logger
 
.LINK
Write-Log
 
.LINK
Close-Log
 
.LINK
Import-Log
 
.LINK
Import-Clixml
 
.LINK
https://github.com/BryanCuneo/ps-jsonlogger
#>

function Convert-Log {
    param(
        [Parameter(Mandatory, ValueFromPipeline = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Destination,

        [ValidateScript({
                if ($_ -in $_ValidEncodings) { $true }
                else { throw "'$_' is not a valid encoding. Please try again with a supported encoding: $($_ValidEncodings -join ", ")" }
            })]
        [string]$Encoding,

        [Parameter(Mandatory)]
        [Alias("To")]
        [ValidateSet("CSV", "CLIXML")]
        [string]$ConvertTo,

        [switch]$Overwrite
    )

    if (-not $Encoding -and $PSVersionTable.PSVersion.Major -ge 7) {
        $Encoding = "utf8BOM"
    }
    elseif (-not $Encoding) {
        $Encoding = "utf8"
    }

    $log = Import-Log -Path $Path -Encoding $Encoding

    switch ($ConvertTo) {
        "csv" {
            $log.entries `
            | Select-Object "timestamp", "level", "message", "calledFrom", "context", "callStack" `
            | Export-Csv -Path $Destination -NoTypeInformation -Encoding $Encoding -Force:$Overwrite
        }
        "clixml" {
            $log | Export-Clixml -Path $Destination -Force:$Overwrite
        }
    }
}

Register-ArgumentCompleter -CommandName Import-Log -ParameterName Encoding -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    $_ValidEncodings | Where-Object { $_ -like "$wordToComplete*" }
}

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, Import-Log, Convert-Log