SATLogger.psm1

#TODO
# - Check LM config when it is set up (at least auth)
# - Add this import and initial config to the template builder
# - Add LM connection parameters to Azure Auto config and test script there

#DONE
# - If no log file is provided, and append is false, logs will be created based
# on the job name provided and date stamped. A logs folder will be created in
# the script directory to hold the logs.
# - File Extensions are automatically set based on the selected Format unless
# a log file is specified.
# - Added the LogToFile parameter to indicate if file output should be written.
# - Added log pruning capability for SATLogger managed jobs



# Global configuration variables
$Global:LogConfig = $null
$Global:LogicMonitorConfig = $null

function Set-LogicMonitorConfiguration{
<#
        .SYNOPSIS
            Defines a parameter set for connecting to Logic Monitor's log ingest API
 
        .DESCRIPTION
            Set-LogicMonitorConfiguration is a function that defines a global hash table
            which contains the connection parameters for Logic Monitor's ingest API. All
            parameters are required.
 
        .PARAMETER Url
            [string] The base URL for the Logic Monitor rest API.
 
        .PARAMETER ApiKey
            [string] Logic Monitor API Key.
 
        .PARAMETER ApiSecret
            [string] Logic Monitor API Secret value.
 
        .EXAMPLE
            Set-LogicMonitorConfiguration -ApiKey <API_KEY> -ApiSecret <API_Secret> -Url "https://mycompany.logicmonitor.com/rest"
 
 
        .OUTPUTS
            None
#>

    param (
        [Parameter(HelpMessage="The base URL for the Logic Monitor API.", Mandatory=$true)]
        [string]$Url,
        [Parameter(HelpMessage="Logic Monitor API Key.", Mandatory=$true)]
        [string]$ApiKey,
        [Parameter(HelpMessage="Logic Monitor API Secret value.", Mandatory=$true)]
        [string]$ApiSecret 
    )

    $Global:LogicMonitorConfig =  @{
        "BaseURL" = $Url
        "ApiKey" = $ApiKey
        "ApiSecret" = $ApiSecret
    }
}

function Set-LogConfiguration{
<#
        .SYNOPSIS
            Defines a parameter set for the SATLogger.
 
        .DESCRIPTION
            SATLogger enables standardized log messages in JSON, CSV, or Text (Pipe delimited)
            Messages can be optionally routed to the console and Logic Monitor.
 
        .PARAMETER Format
            [string] The format of the log message string (JSON, CSV, or Text).
 
        .PARAMETER JobName
            [string] The name of the job being logged. This should be a unique descriptive name
            that can serve as a key for searches.
 
        .PARAMETER LogToFile
            [bool] Boolean to log to a file .
 
        .PARAMETER LogFile
            [string] The full path to the log file, including the file name and extension.
 
        .PARAMETER LogToLogicMonitor
            [bool] Boolean to route log messages to Logic Monitor.
 
        .PARAMETER LogToConsole
            [bool] Boolean to display log messages in the console. This is required for
            azure automation jobs.
 
        .PARAMETER LogLevel
            [int] Log threshold to capture. All logs below the selected threshold will be suppressed.
            Accepted levels: 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=CRITICAL.
 
        .PARAMETER RetentionDays
            [int] Number of days to retain old log files on disk. This is only applicable for SATLogger-
            managed log files. Not supported with the -Append or -LogFile switches.
 
        .PARAMETER Append
            [bool] Boolean to append to an existing log file.
 
        .EXAMPLE
            Set-LogConfiguration -Format JSON -JobName AddressBookUpdate -LogFile .\AddressBookUpdate.json -LogToLogicMonitor:$true -LogToConsole:$true -LogLevel 2
 
        .EXAMPLE
            Set-LogConfiguration -JobName InstallHotfix
 
        .OUTPUTS
            None
#>

    param (
        [Parameter(HelpMessage="Log message format. Options are 'CSV','JSON', or 'Text.'")]
        [ValidateSet('CSV','JSON','Text')]
        [string]$Format = 'Text',
        
        [Parameter(HelpMessage="Name of the Job your are logging.", Mandatory=$true)]
        [string]$JobName,
        
        [Parameter(HelpMessage="Set to TRUE if you would like to output to the console. This should be TRUE for all Azure Automation Jobs")]
        [bool]$LogToConsole = $true,
        
        [Parameter(HelpMessage="Set to TRUE to route logs to Logic Monitor.")]
        [bool]$LogToLogicMonitor = $false,
        
        [Parameter(HelpMessage="Indicates if you would like to output to a file. Default is true. Set to False for most Azure automation jobs.")]
        [bool]$LogToFile = $true,
        
        [Parameter(HelpMessage="A full or relative path to the log file, including the file name and extension.'")]
        [string]$LogFile,
        
        [Parameter(HelpMessage="Log threshold to capture. All logs below the selected threshold will be suppressed, 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=CRITICAL.")]
        [ValidateSet(0,1,2,3,4)]
        [int]$LogLevel = 1,
        
        [Parameter(HelpMessage="Number of days to retain past log files. A value of 0 will retain all logs.")]
        [int]$RetentionDays = 0,
        
        [Parameter(HelpMessage="Append to an existing log file. Default is false.")]
        [bool]$Append = $false
    )
    # If LogToFile is true, define the log file path.
    if($LogToFile){

        if($LogFile -and ($RetentionDays -gt 0)){
            Write-Output "Ambiguous option selected with LogFile path specified: RetentionDays."
            Write-Output "If you would like to enable log pruning, do not specify the LogFile value."
            Write-Output "The logger will automatically generate and maintain log files."
            Write-Output "Setting retention to 0 to disable log pruning."
            $RetentionDays = 0
        }
        if($Append -and ($RetentionDays -gt 0)){
            Write-Output "Ambiguous option selected with log pruning enabled: Append."
            Write-Output "Log pruning is not supported with the Append option."
            Write-Output "Setting retention to 0 to disable log pruning."
            $RetentionDays = 0           
        }

        if(!($LogFile)){

            $LogDirectory = "$(Split-Path $MyInvocation.PSCommandPath)\Logs"

            if($Format -eq "Text"){
                $Extension = ".txt"
            }
            elseif($Format -eq "CSV"){
                $Extension = ".csv"
            }
            elseif($Format -eq "JSON"){
                $Extension = ".json"
            }

            # If the append option is selected, use the job name only.
            # Otherwise, append the date to the log file name.
            if($Append){
                $LogFileName = "$($JobName)$($Extension)"
            }
            else{
                $LogDate = (Get-Date -Format yyy-MM-dd)
                $LogFileName = "$($JobName)_$($LogDate)$($Extension)"
            }
            
            $LogFile = "$logDirectory\$LogFileName"
        }

        if(-not (Test-Path $LogFile)){
            try{

                # Create the file if it doesn't exist.
                New-Item -ItemType File -Path $LogFile -Force -ErrorAction Stop

                # Write the CSV Header for new log files.
                if($Format -eq "CSV"){
                    [PSCustomObject]@{
                        "DateTime" = $Date
                        "JobName" = $LogConfig.JobName
                        "Severity" = $Type
                        "Message" = $Message
                        "Host" = $LogConfig.Host
                        "Script" = $LogConfig.Script
                    } | Select-Object DateTime,JobName,Severity,Message | Export-Csv $LogFile -NoTypeInformation
                }

            }
            catch{
                Write-Output "Unable to create log file. Exception: $($error[0].Exception.Message)"
                Write-Output "Streaming log to console only."
                $LogToFile = $false
            }
        }

        # Clean old log files
        if($RetentionDays -gt 0){

            if($LogDirectory -and $Extension){
                $LogsToPurge = Get-ChildItem $LogDirectory "*$extension" | Where-Object {$_.CreationTime -lt (Get-Date).AddDays(-$RetentionDays)}
                foreach($Item in $LogsToPurge){
                    try{
                        Remove-Item $Item.FullName -Force -ErrorAction Stop
                        Write-Output "Purged log based on retention policy: $($Item.FullName)"
                    }
                    catch{
                        Write-Output "Failed to purge log $($Item.FullName). Error: $($error[0].Exception.message)"
                    }   
                }
            }

        }
    }

    $Global:LogConfig =  @{
        "Format" = $Format
        "JobName" = $JobName
        "LogToFile" = $LogToFile
        "LogFile" = $LogFile
        "LogicMonitor" = $LogToLogicMonitor
        "ConsoleOutput" = $LogToConsole
        "LogLevel" = $LogLevel
        "Host" = $env:COMPUTERNAME
        "Script" = $MyInvocation.PSCommandPath

    }
}

function New-LogMessage{  
<#
        .SYNOPSIS
            Writes log messages to one or more output channels.
 
        .DESCRIPTION
            New-LogMessage accepts a message string and a severity indicator
            to route to one or more output channels in the log configuration.
 
        .PARAMETER Severity
            [int] The severity of the message being logged. Accepted levels:
            0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=CRITICAL.
 
        .PARAMETER Message
            [string] The message to output.
 
        .EXAMPLE
            New-LogMessage -Severity 4 -Message "AUGGHHHHH!"
 
 
        .OUTPUTS
            None
#>

    param (
        [Parameter(HelpMessage="Severity of the message (0-4), 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=CRITICAL. Default is 1 (INFO).")]
        [ValidateSet(0,1,2,3,4)]
        [int]$Severity = 1,
        [Parameter(HelpMessage="The string you would like to append to the log.", Mandatory=$true)]
        [string]$Message
    )

    if(!($Global:LogConfig)){
        Write-Output "Log configuration undefined. Define your configuration with 'Set-LogConfiguration' to use this function."
        Write-Output "Setting a default log configuration."
        $JobGuid = New-Guid
        $LogConfig = Set-LogConfiguration -Format Text -JobName $JobGuid -LogFile ".\$($JobGuid).txt" -LogicMonitor:$false -ConsoleOutput:$true -LogLevel 1
    }

    $Date = get-date -Format "yyyy-MM-dd hh:mm:ss"
    $Type = $null
    switch ($Severity)
    {
        0 { $Type = "DEBUG"}
        1 { $Type = "INFO" }
        2 { $Type = "WARN" }
        3 { $Type = "ERROR" }
        4 { $Type = "CRITICAL"}
        default {$Type = "INFO" }
    }

    # Only log if the severity is greater than or equal to the severity defined in the config
    if($Severity -ge $LogConfig.LogLevel){
        
        # Set the log string based on the defined output type
        if($LogConfig.Format -eq "JSON"){
            $LogString = @{
                "DateTime" = $Date
                "JobName" = $LogConfig.JobName
                "Severity" = $Type
                "Message" = $Message
                "Host" = $LogConfig.Host
                "Script" = $LogConfig.Script
            } | ConvertTo-Json -Compress -Depth 3
        }
        elseif($LogConfig.Format -eq "CSV"){
            $LogString = [PSCustomObject]@{
                "DateTime" = $Date
                "JobName" = $LogConfig.JobName
                "Severity" = $Type
                "Message" = $Message
                "Host" = $LogConfig.Host
                "Script" = $LogConfig.Script
            } | Select-Object DateTime,JobName,Severity,Message | ConvertTo-Csv -NoHeader
        }
        elseif($LogConfig.Format -eq "Text"){
            $LogString = $Date + " | " + $LogConfig.JobName + " | " + $Type + " | " + $Message + " | " + $LogConfig.Host + " | " + $LogConfig.Script
        }

        # Console and Stream output:
        if($LogConfig.ConsoleOutput){
            Write-Output $LogString
        }

        # File output
        if($LogConfig.LogToFile){
            $LogString | Out-File $LogConfig.LogFile -Append
        }

        # Route message to Logic Monitor
        if($LogConfig.LogicMonitor){
            Write-LogicMonitorLog -LogString $LogString
        }
    }
}

function Write-LogicMonitorLog{

    param (
        [Parameter(HelpMessage="The formatted log string to send to Logic Monitor.", Mandatory=$true)]
        [string]$LogString
    )

    if(!($Global:LogicMonitorConfig)){
        $Global:LogConfig.LogicMonitor = $false
        New-LogMessage -Severity 2 -Message "Logic Monitor is not configured. Define your configuration with 'Set-LogicMonitorConfiguration' to log to Logic Monitor."
        New-LogMessage -Severity 1 -Message "Disabled Logic Monitor logging in the log configuration."
    }

    $method = "POST"
    $base_url = $LogicMonitorConfig.BaseURL
    $path = "/log/ingest"
    #$request_data = ConvertTo-Json @(@{"msg"=$LogString;"_lm.resourceId"=@{"system.deviceId"=$LogicMonitorConfig.ResourceName}}) -Depth 4 -Compress
    $request_data = ConvertTo-Json @(@{"msg"=$LogString;"_lm.resourceId"=@{"system.jobName"=$LogConfig.JobName}}) -Depth 4 -Compress

    $api_key = $LogicMonitorConfig.ApiKey
    $api_secret = $LogicMonitorConfig.ApiSecret

    $credential = New-Object pscredential($api_key, (ConvertTo-SecureString $api_secret -AsPlainText -Force))

    # Get current time in milliseconds
    $epoch = [Math]::Round((New-TimeSpan -start (Get-Date -Date "1/1/1970") -end (Get-Date).ToUniversalTime()).TotalMilliseconds)

    # Concatenate Request Details
    $request_vars = $method + $epoch + $request_data + $path

    # Extract credentials
    $access_id = $credential.GetNetworkCredential().username
    $access_key = $credential.GetNetworkCredential().password

    # Construct Signature
    $hmac = New-Object System.Security.Cryptography.HMACSHA256
    $hmac.Key = [Text.Encoding]::UTF8.GetBytes($access_key)
    $signature_bytes = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($request_vars))
    $signature_hex = [System.BitConverter]::ToString($signature_bytes) -replace '-'
    $signature = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($signature_hex.ToLower()))
    $auth_key = "LMv1 $access_id`:$signature`:$epoch"

    $headers = @{
        'Content-Type'='application/json'
        'Authorization'=$auth_key
    }

    $response = Invoke-RestMethod -Uri "$($base_url)$($path)" -Method $method -Headers $headers -Body $request_data
    if($response.errmsg){
        $Global:LogConfig.LogicMonitor = $false
        New-LogMessage -Severity 2 -Message "Logic Monitor returned an error: $($response.errmsg)."
        New-LogMessage -Severity 1 -Message "Disabled Logic Monitor logging in the log configuration."
    }   
}

#Export-ModuleMember -Function Set-LogicMonitorConfiguration, Set-LogConfiguration, New-LogMessage -Alias setlm, setlog, nlm