ScriptLog.psm1

#Region './Enum/ScriptLogMessageSeverity.ps1' 0
# Define log message severity types
enum ScriptLogMessageSeverity
{
    Information
    Verbose
    Warning
    Error
}
#EndRegion './Enum/ScriptLogMessageSeverity.ps1' 9
#Region './Enum/ScriptLogType.ps1' 0
# Define log types
enum ScriptLogType
{
    CMTrace
    Memory
}
#EndRegion './Enum/ScriptLogType.ps1' 7
#Region './Classes/01-LogMessage.ps1' 0
# Declare class for individual log messages
class LogMessage {
    [datetime]$DateTime
    [ScriptLogMessageSeverity]$Severity
    [string]$Source
    [string]$Context
    [int]$ProcessId
    [string]$Message

    LogMessage([datetime]$DateTime, [ScriptLogMessageSeverity]$Severity, [string]$Source, [string]$Context, [int]$ProcessId, [string]$Message) {
        $this.DateTime = $DateTime
        $this.Severity = $Severity
        $this.Source = $Source
        $this.Context = $Context
        $this.ProcessId = $ProcessId
        $this.Message = $Message
    }
}
#EndRegion './Classes/01-LogMessage.ps1' 19
#Region './Classes/02-ScriptLog.ps1' 0
# Declare class for a log object
class ScriptLog {
    [String] $Name
    [String] $FilePath
    [ScriptLogType] $LogType
    [String] $Source = $null
    [ScriptLogMessageSeverity[]] $MessagesOnConsole
    [DateTime] $StartTimeStamp
    [System.Collections.Generic.List[LogMessage]] $Messages
    hidden [String] $TimeZoneOffset

    ScriptLog([String] $Name, [String] $Path, [String] $BaseName, [Boolean] $AppendDateTime, [ScriptLogType] $LogType, [ScriptLogMessageSeverity[]] $MessagesOnConsole) {
        $this.Name = $Name
        $this.LogType = $LogType
        $this.MessagesOnConsole = $MessagesOnConsole
        $this.StartTimeStamp = Get-Date
        $this.Messages = [System.Collections.Generic.List[LogMessage]]::new()
        $Offset = [timezone]::CurrentTimeZone.GetUtcOffset([datetime]::Now).TotalMinutes
        if ($Offset -ge 0) {
            $this.TimeZoneOffset = "+$Offset"
        }
        else {
            $this.TimeZoneOffset = [string]"$Offset"
        }
        if ($LogType -eq 'Memory') {
            $this.FilePath = $null
        }
        else {
            $ConstructedPath = $Path + '\' + $BaseName
            if ($AppendDateTime) {
                $ConstructedPath += '-' + (Get-Date -Format 'yyyyMMddHHmmss')
            }
            switch ($LogType) {
                CMTrace {
                    $ConstructedPath += '.log'
                }
            }
            # Ensure path is not already used by another ScriptLog
            if ($Script:ScriptLogs.count -gt 0) {
                if ($ConstructedPath -in $Script:ScriptLogs.FilePath) {
                    throw "Another active ScriptLog is already using the file '$ConstructedPath'"
                }
            }
            $this.FilePath = $ConstructedPath
        }
    }
}
#EndRegion './Classes/02-ScriptLog.ps1' 48
#Region './Private/00-Initialization.ps1' 0

$PSDefaultParameterValues.Clear()
Set-StrictMode -Version 3

# Prepare value defining the default ScriptLog to log messages to
$DefaultScriptLog = $null

# Prepare collection to hold ScriptLog objects
[System.Collections.Generic.List[ScriptLog]]$ScriptLogs = @()
#EndRegion './Private/00-Initialization.ps1' 10
#Region './Public/Get-ScriptLog.ps1' 0
function Get-ScriptLog {
    <#
        .SYNOPSIS
            Returns active ScriptLogs.

        .DESCRIPTION
            Returns a list of all active ScriptLogs.

        .EXAMPLE
            Get-ScriptLog

            Returns a list of all active ScriptLogs

        .EXAMPLE
            Get-ScriptLog -Name "SomeLog"

            Returns the ScriptLog named "SomeLog"

        .EXAMPLE
            Get-ScriptLog -Default

            Returns the default ScriptLog

        .NOTES
            Author: kovergard
    #>

    [CmdletBinding(DefaultParameterSetName = 'AllLogs')]
    [OutputType([ScriptLog[]])]
    Param (
        # Find ScriptLog object by name
        [Parameter(ValueFromPipeline, ParameterSetName = 'SpecificLog')]
        [String]
        $Name,

        # If specified, return only the default ScriptLog
        [Parameter(ParameterSetName = 'DefaultLog')]
        [switch]
        $Default
    )

    process {
        if ($Default) {
            if ($Script:ScriptLogs.Count -eq 0) {
                throw 'No ScriptLogs exists, cannot return default ScriptLog'
            }
            elseif (-not $DefaultScriptLog) {
                throw 'No default ScriptLog has been defined'
            }
            return $DefaultScriptLog
        }
        if ($Name) {
            $Log = $Script:ScriptLogs | Where-Object { $_.Name -eq $Name }
            if (-not $Log) {
                throw "Log with name '$Name' not found"
            }
            Return $Log
        }
        return $ScriptLogs
    }
}
#EndRegion './Public/Get-ScriptLog.ps1' 61
#Region './Public/New-ScriptLog.ps1' 0
function New-ScriptLog {
    <#
        .SYNOPSIS
            Returns a new ScriptLog object

        .DESCRIPTION
            Creates a new ScriptLog object with the settings provided and returns it through the pipeline so it can be used for logging during script execution using the Out-ScriptLog cmdlet.

        .EXAMPLE
            New-ScriptLog

            Create a new ScriptLog object with default settings. File will be created in the temp folder, with the name ScriptLog.log and will be written in the CMTrace format.

        .EXAMPLE
            $MemoryLog = New-ScriptLog -Name "TempLog" -LogType Memory -MessagesOnConsole @("Error","Verbose")

            Create an in-memory SriptLog instance to allow for collection of log messages during runtime. Only errors and verbose messages will be written to the console (Warnings will not, they will only be written to the in-memory log)

        .EXAMPLE
            $CriticalFileLog = New-ScriptLog -Name "Critical" -Path "C:\Logs" -BaseName "CriticalErrors" -AppendDateTime; $VerboseLog = New-ScriptLog -Name "Verbose" -Path "C:\Logs" -BaseName "Verbose" -MessagesOnConsole "Verbose"

            Create two separate ScriptLog objects to log messages in different formats to two different files.

        .NOTES
            Author: kovergard
    #>

    [CmdletBinding()]
    [OutputType([ScriptLog])]
    Param (
        # The name of the log
        [Parameter()]
        [string]
        $Name = (New-Guid).Guid,

        # Directory in which to create the logfile
        [Parameter()]
        [string]
        $Path = $env:TEMP,

        # Name of the log file without extension
        [Parameter()]
        [string]
        $BaseName = 'ScriptLog',

        # Indicates if a datetime should be suffixed on the log base name.
        [Parameter()]
        [switch]
        $AppendDateTime,

        # Type of log
        [Parameter()]
        [ScriptLogType]
        $LogType = 'CMTrace',

        # Determines which messages (if any) should be written to the console.
        [Parameter()]
        [ScriptLogMessageSeverity[]]
        $MessagesOnConsole = @('Error', 'Warning')
    )

    process {
        if ($Script:ScriptLogs.Count -gt 0) {
            if ($Script:ScriptLogs.Name -contains $Name) {
                throw "A ScriptLog with the name '$Name' already exists. Active ScriptLogs must have unique names."
            }
        }
        $NewScriptLog = [ScriptLog]::New($Name, $Path, $BaseName, $AppendDateTime, $LogType, $MessagesOnConsole)
        $Script:ScriptLogs.Add($NewScriptLog)
        if (-not $DefaultScriptLog) {
            Set-Variable -Name DefaultScriptLog -Value $NewScriptLog -Scope Script -Force
        }
        Write-Output $NewScriptLog
    }
}
#EndRegion './Public/New-ScriptLog.ps1' 75
#Region './Public/Out-ScriptLog.ps1' 0
function Out-ScriptLog {

    <#
        .SYNOPSIS
            Adds log messages to a ScriptLog.

        .DESCRIPTION
            Adds one or more log messages to a ScriptLog object. If multiple messages are sent via the pipleline, each message will get its own message entry in the log.

            A single messages can have multiple lines, these will be writting to the log file with line changes. If a message is longer than 7500 characters, it will be broken into multiple messages as longer messages will break the CMTrace format.

        .EXAMPLE
            Out-ScriptLog -Message "Starting script execution"

            Write a log message to the information channel in the default ScriptLog instance.

        .EXAMPLE
            Out-ScriptLog -Log $VerboseLog -Message "Starting script execution" -Severity Verbose

            Write a log message to the verbose channel in the ScriptLog $VerboseLog

        .EXAMPLE
            $Dir = Get-ChildItem -Path c:\temp; Out-ScriptLog -Message $Dir -Log $Log

            Write an object with multiple lines in it to the log file. This will be writtin as a single log message, since the message is not passed through the pipeline.

        .EXAMPLE
            "One","Two","Three" | Out-ScriptLog -Severity Warning

            Send multiple messages to the log using the pipeline. Each message will get its own log message.

        .NOTES
            Author: kovergard
    #>

    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    Param (
        # One or more messages to add to the log
        [Parameter(Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $false)]
        $Message,

        # The Name of the ScriptLog to add the messages to. If no ScriptLog is supplied, logging is done to the default ScriptLog.
        [Parameter(ParameterSetName = 'ByName')]
        [String]
        $Name,

        # The ScriptLog object to add the messages to. If no ScriptLog is supplied, logging is done to the default ScriptLog.
        [Parameter(ParameterSetName = 'ByObject')]
        [ScriptLog]
        $Log,

        # The severity of the messages
        [Parameter()]
        [ScriptLogMessageSeverity]
        $Severity = 'Information'
    )

    process {
        # If no ScriptLog is specified, point to default ScriptLog.
        if (-not $PSBoundParameters.ContainsKey('Log') -and -not $PSBoundParameters.ContainsKey('Name')) {
            if (-not $DefaultScriptLog) {
                throw 'No default ScriptLog has been defined, please use -Name or -Log parameter to target log'
            }
            $Log = $DefaultScriptLog
        }
        else {
            if ($PSBoundParameters.ContainsKey('Name')) {
                $Log = $Script:ScriptLogs | Where-Object { $_.Name -eq $Name }
                if (-not $Log) {
                    throw "Log with name '$Name' not found"
                }
            }
            else {
                #TODO: Detect if Log exists
            }
        }

        # Convert message to string if necessary
        if ($Message.GetType() -ne 'System.String') {
            $Message = ($Message | Out-String).TrimEnd("`r`n")
        }

        # Determine log time and source of message
        $LogTime = Get-Date
        if ($Log.Source) {
            $Source = $Log.Source
            if ($MyInvocation.ScriptLineNumber) {
                $Source += ":$($MyInvocation.ScriptLineNumber)"
            }
        }
        else {
            Try {
                If ($MyInvocation.ScriptName) {
                    [string]$Source = "$(Split-Path -Path $MyInvocation.ScriptName -Leaf -ErrorAction 'Stop'):$($MyInvocation.ScriptLineNumber)"
                }
                Else {
                    $Source = 'interactive'
                }
            }
            Catch {
                $Source = 'unknown'
            }
        }

        # Get context and PID of message
        $Context = [Security.Principal.WindowsIdentity]::GetCurrent().Name
        $ProcessId = $global:PID

        # Add message to in-memory log.
        $Log.Messages.Add([LogMessage]::New($LogTime, $Severity, $Source, $Context, $ProcessId, $Message))

        # If message should be written to a file, convert to proper format and write.
        switch ($Log.LogType) {
            CMTrace {
                if ($Message.Length -gt 7500) {
                    $CMMessage = $Message.Substring(0, 7500)
                }
                else {
                    $CMMessage = $Message
                }
                $CmLogLine = '<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="{4}" type="{5}" thread="{6}" file="{7}">'
                $CmMessageType = Switch ($Severity) {
                    Error { 3 }
                    Warning { 2 }
                    Default { 1 }
                }
                $CmTime = ($LogTime | Get-Date -Format 'HH\:mm\:ss.fff').ToString() + $Log.TimeZoneOffset
                $CmDate = ($LogTime | Get-Date -Format 'MM-dd-yyyy')
                $CmFile = 'ScriptLog'
                $CmLogLineFormat = $CMMessage, $CmTime, $CmDate, $Source, $Context, $CmMessageType, $ProcessId, $CmFile
                $LogLine = $CmLogLine -f $CmLogLineFormat
                $LogLine | Out-File -FilePath $Log.FilePath -Append -Encoding utf8 -NoClobber
            }
        }

        # Write output to console, if applicable
        if ($Severity -in $Log.MessagesOnConsole) {
            Switch ($Severity) {
                Information {
                    Write-Information -MessageData $Message -InformationAction Continue
                }
                Verbose {
                    $VerbosePreference = 'Continue'; Write-Verbose -Message $Message
                }
                Warning {
                    Write-Warning -Message $Message
                }
                Error {
                    Write-Error -Message $Message
                }
            }
        }
    }
}
#EndRegion './Public/Out-ScriptLog.ps1' 158
#Region './Public/Remove-ScriptLog.ps1' 0
function Remove-ScriptLog {
    <#
        .SYNOPSIS
            Removes a ScriptLog from memory.

        .DESCRIPTION
            Removes one (or all) active ScriptLogs from memory, without removing the log files that has been used by the log(s).

        .EXAMPLE
            Remove-ScriptLog -Name "MyLog"

            Removes the ScriptLog named "MyLog"

        .EXAMPLE
            Remove-ScriptLog -All

            Removes all ScriptLogs

        .NOTES
            Author: kovergard
    #>

    [CmdletBinding(DefaultParameterSetName = 'SpecificLog')]
    [OutputType()]
    Param (
        # ScriptLog object to remove
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'SpecificLog')]
        [String]
        $Name,

        # If specified, removes all ScriptLog objects
        [Parameter(ParameterSetName = 'AllLogs')]
        [switch]
        $All
    )
 
    process {
        # Remove all ScriptLogs if requested
        if ($All) {
            $Script:ScriptLogs | ForEach-Object { $_.Messages.Clear() }
            $Script:ScriptLogs.Clear()
            $Script:DefaultScriptLog = $null
        }
        else {
            $Log = $Script:ScriptLogs | Where-Object { $_.Name -eq $Name }
            if (-not $Log) {
                throw "Log with name '$Name' not found"
            }
            if ($Script:DefaultScriptLog -eq $Log) {
                $Script:DefaultScriptLog = $null
            }
            $Script:ScriptLogs.Remove($Log) | Out-Null
            $Log.Messages.Clear()
        }
    }
}

#EndRegion './Public/Remove-ScriptLog.ps1' 57