ShoutOut.psm1
# START: source\.bootstrap.ps1 function _buildBasicFileLogger { param( [string]$FilePath ) return { param($Record) if (-not (Test-Path $FilePath -PathType Leaf)) { New-Item -Path $FilePath -ItemType File -Force -ErrorAction Stop | Out-Null } $Record | Out-File $FilePath -Encoding utf8 -Append -Force }.GetNewClosure() } $script:_ShoutOutSettings = @{ DefaultMsgType="Info" LogFileRedirection=@{} MsgStyles=@{ Success = @{ ForegroundColor="Green" } Exception = @{ ForegroundColor="Red"; BackgroundColor="Black" } Error = @{ ForegroundColor="Red" } Warning = @{ ForegroundColor="Yellow"; BackgroundColor="Black" } Info = @{ ForegroundColor="Cyan" } Result = @{ ForegroundColor="White" } } LogContext=$true Disabled=$false } $defaultLogFilename = "{0}.{1}.{2:yyyyMMddHHmmss}.log" -f $env:COMPUTERNAME, $pid, [datetime]::Now $defaultLogFile = switch ((whoami).split('\')[0]) { System { "{0}\Logs\shoutOut\{1}" -f $env:windir, $defaultLogFilename } default { "{0}\shoutOut\Logs\{1}" -f $env:APPDATA, $defaultLogFilename } } $script:_ShoutOutSettings.DefaultLog = _buildBasicFileLogger $defaultLogFile # END: source\.bootstrap.ps1 # START: source\_ensureShoutOutLogFile.ps1 function _ensureShoutOutLogFile { param( [string]$logFile, [string]$MsgType = "*" ) if (!(Test-Path $logFile -PathType Leaf)) { try { return new-Item $logFile -ItemType File -Force -ErrorAction Stop } catch { "Unable to create log file '{0}' for '{1}'." -f $logFile, $msgType | shoutOut -MsgType Error "Messages marked with '{0}' will be redirected." -f $msgType | shoutOut -MsgType Error shoutOut $_ Error throw ("Unable to use log file '{0}', the file cannot be created." -f $logFile) } } return gi $logFile } # END: source\_ensureShoutOutLogFile.ps1 # START: source\_validateShoutOutLogHandler.ps1 function _validateShoutOutLogHandler { param( [scriptblock]$LogHandler, [string]$msgType = "*" ) # Valid/recognizable parameters and their expected typing: $validParams = @{ '$Message' = [Object] '$Record' = [String] '$details' = [hashtable] } $params = $LogHandler.Ast.ParamBlock.Parameters if ($params.count -eq 0) { "Invalid handler, no parameters found: {0}" -f $LogHandler | shoutOut -MsgType Error "Messages marked with '{0}' will not be redirected using this handler." -f $msgType | shoutOut -MsgType Error throw "No parameters declared by the given handler." } $recognizedParams = $params | Where-Object { $_.Name.Extent.Text -in $validParams.Keys } if ($null -eq $recognizedParams) { "Invalid handler, none of the expeted parameters found (expected any of {0}): {1}" -f ($paramNames -join ', '), $LogHandler | shoutOut -MsgType Error "Messages marked with '{0}' will not be redirected using this handler." -f $msgType | shoutOut -MsgType Error throw ("None of {0} parameters declared by the given handler." -f ($paramNames -join ', ')) } foreach ($param in $recognizedParams) { $paramType = $validParams[$param.Name.Extent.Text] if (($t = $param.StaticType) -and !($t.IsAssignableFrom($paramType)) ) { "Invalid handler, the '{0}' parameter should accept values of type '{1}' (found '{2}' which is not assignable from '{1}')." -f $param.Name, $paramType.Name, $t.Name | shoutOut -MsgType Error "Messages marked with '{0}' will not be redirected using this handler." -f $msgType | shoutOut -MsgType Error throw ("'{0}' parameter on the given handler is of invalid type (not assignable from [string])." -f $paramNames) } } return $LogHandler } # END: source\_validateShoutOutLogHandler.ps1 # START: source\Clear-ShoutOutRedirect.ps1 function Clear-ShoutOutRedirect { param( [Parameter(HelpMessage="Mesage Type to remove redirection of.")] [string]$msgType ) $_ShoutOutSettings.LogFileRedirection.Remove($msgType) } # END: source\Clear-ShoutOutRedirect.ps1 # START: source\Get-ShoutOutConfig.ps1 function Get-ShoutOutConfig { return $_ShoutOutSettings } # END: source\Get-ShoutOutConfig.ps1 # START: source\Get-ShoutOutDefaultLog.ps1 function Get-ShoutOutDefaultLog { param() return $script:_ShoutOutSettings.DefaultLog } # END: source\Get-ShoutOutDefaultLog.ps1 # START: source\Get-ShoutOutRedirect.ps1 function Get-ShoutOutRedirect { param( [Parameter(HelpMessage="Message Type to retrieve redirection information for.")] [string]$msgType ) return $script:_ShoutOutSettings.LogFileRedirection[$msgType] } # END: source\Get-ShoutOutRedirect.ps1 # START: source\Invoke-ShoutOut.ps1 <# .WISHLIST - Update so that that output is fed to shoutOut as it is generated rather than using the result output. The goal is to generate logging data continuously so that it's clear whether the script has hung or not. [Done] .SYNOPSIS Helper function to execute commands (strings or blocks) with error-handling/reporting. .DESCRIPTION Helper function to execute commands (strings or blocks) with error-handling/reporting. If a scriptblock is passed as the operation, the function will attempt make any variables referenced by the scriptblock available to the scriptblock when it is resolved (using variables available in the scope that called Run-Operation). The variables used in the command are identified using [scriptblock].Ast.FindAll method, and are imported from the parent scope using $PSCmdlet.SessionState.PSVariable.Get. The following variable-names are restricted and may cause errors if they are used in the operation: - $__thisOperation: The operation being run. - $__inputVariables: List of the variables being imported to run the operation. .NOTES - Transforms ScriptBlocks to Strings prior to execution because of a quirk in iex where it will not allow the evaluation of ScriptBlocks without an input (a 'param' statement in the block). iex is used because it yields output as each line is evaluated, rather than waiting for the entire $OPeration to complete as would be the case with <ScriptBlock>.Invoke(). #> function Invoke-ShoutOut { param( [parameter(ValueFromPipeline=$true, position=1)] $Operation, [parameter()][Switch] $OutNull, [parameter()][Switch] $NotStrict, [parameter()][Switch] $LogErrorsOnly ) $color = "Result" if (!$NotStrict) { # Switch error action preference to catch any errors that might pop up. # Works so long as the internal operation doesn't also change the preference. $OldErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop } if ($Operation -is [string]) { $OPeration = [scriptblock]::create($Operation) } if (!$LogErrorsOnly) { $msg = "Running '$Operation'..." $msg | shoutOut -MsgType Info -ContextLevel 1 } $r = try { # Step 1: Get any variables in the parent scope that are referenced by the operation. $localVarNames = Get-variable -Scope 0 | % Name if ($Operation -is [scriptblock]) { $variableNames = $Operation.Ast.FindAll( {param($o) $o -is [System.Management.Automation.Language.VariableExpressionAst]}, $true ) | % { $_.VariablePath.UserPath } | ? { $_ -notin $localVarNames } $variables = foreach ($vn in $variableNames) { $PSCmdlet.SessionState.PSVariable.Get($vn) } } # Step 2: Convert the scriptblock if necessary. if ($Operation -is [scriptblock]) { # Under certain circumstances the iex cmdlet will not allow # the evaluation of ScriptBlocks without an input. However it will evaluate strings # just fine so we perform the transformation before evaluation. $Operation = $Operation.ToString() } # Step 3: inject the operation and the variables into a new isolated scope and resolve # the operation there. & { param( $thisOperation, $inputVariables ) $__thisOperation = $thisOperation $__inputVariables = $inputVariables Remove-Variable "thisOperation" Remove-Variable "inputVariables" $__ = $null foreach ( $__ in $__inputVariables ) { if ($null -eq $__) { continue } Set-Variable $__.Name $__.Value } Remove-Variable "__" # Invoke-Expression allows us to receive # and handle output as it is generated, # rather than wait for the operation to finish # as opposed to <[scriptblock]>.invoke(). Invoke-Expression $__thisOperation | ForEach-Object { if (!$LogErrorsOnly) { shoutOut "`t| $_" "Result" -ContextLevel 2; } return $_ } } $Operation $variables } catch { $color = "Error" "An error occured while executing the operation:" | shoutOUt -MsgType Error -ContextLevel 1 $_.Exception, $_.CategoryInfo, $_.InvocationInfo, $_.ScriptStackTrace | Out-string | % { $_.Split("`n`r", [System.StringSplitOptions]::RemoveEmptyEntries).TrimEnd("`n`r") } | % { shoutOut "`t| $_" $color -ContextLevel 2 } $_ } if (!$NotStrict) { $ErrorActionPreference = $OldErrorActionPreference } if ($OutNull) { return } return $r } # END: source\Invoke-ShoutOut.ps1 # START: source\Set-ShoutOutConfig.ps1 function Set-ShoutOutConfig { param( [Parameter(HelpMessage="The default Message Type that ShoutOut should apply to messages.")] [string]$DefaultMsgType, [Parameter(HelpMessage="Default log handler to use for Messages Types without redirection.")] [Alias("LogFile")] $Log, [Parameter(HelpMessage="Enable/Disable Context logging.")] [Alias("LogContext")] [boolean]$EnableContextLogging, [Parameter(HelpMessage="Disable/Enable ShoutOut.")] [Alias("Disabled")] [boolean]$DisableLogging ) if ($PSBoundParameters.ContainsKey("DefaultMsgType")) { $_shoutOutSettings.DefaultMsgType = $DefaultMsgType } if ($PSBoundParameters.ContainsKey("Log")) { Set-ShoutOutDefaultLog $Log | Out-Null } if ($PSBoundParameters.ContainsKey("LogContext")) { $_shoutOutSettings.LogContext = $LogContext } if ($PSBoundParameters.ContainsKey("Disabled")) { $_shoutOutSettings.Disabled = $Disabled } } # END: source\Set-ShoutOutConfig.ps1 # START: source\Set-ShoutOutDefaultLog.ps1 function Set-ShoutOutDefaultLog { param( [parameter(Mandatory=$true, ValueFromPipeline=$true, Position=1, ParameterSetName="LogFilePath")][String]$LogFilePath, [parameter(Mandatory=$true, ValueFromPipeline=$true, Position=1, ParameterSetName="LogFile")][System.IO.FileInfo]$LogFile, [parameter(Mandatory=$true, ValueFromPipeline=$true, Position=1, ParameterSetName="LogHandler")][scriptblock]$LogHandler ) switch ($PSCmdlet.ParameterSetName) { "LogFilePath" { try { _ensureShoutOutLogFile $LogFilePath -ErrorAction Stop | Out-Null $LogHandler = _buildBasicFileLogger $LogFilePath } catch { return $_ } } "LogFile" { try { _ensureShoutOutLogFile $LogFile.FullName -ErrorAction Stop | Out-Null $LogHandler = _buildBasicFileLogger $LogFile.FullName } catch { return $_ } } } try { $_shoutOutSettings.DefaultLog = _validateShoutOutLogHandler $LogHandler -ErrorAction Stop } catch { return $_ } } # END: source\Set-ShoutOutDefaultLog.ps1 # START: source\Set-ShoutOutRedirect.ps1 function Set-ShoutOutRedirect { [CmdletBinding()] param( [parameter(Mandatory=$true, Position=1, HelpMessage="Message type to redirect.")][string]$MsgType, [Parameter(ParameterSetName="FilePath", ValueFromPipeline=$true, Mandatory=$true, Position=2, HelpMessage="Path to log file.")][string]$LogFilePath, [Parameter(ParameterSetName="FileInfo", ValueFromPipeline=$true, Mandatory=$true, Position=2, HelpMessage="FileInfo object.")][System.IO.FileInfo]$LogFile, [Parameter(ParameterSetName="Scriptblock", ValueFromPipeline=$true, Mandatory=$true, Position=2, HelpMessage="ScriptBlock to use as log handler.")][scriptblock]$LogHandler ) switch ($PSCmdlet.ParameterSetName) { "FileInfo" { try { _ensureShoutOutLogFile $LogFile.FullName $msgType | Out-Null $LogHandler = _buildBasicFileLogger $LogFile.FullName } catch { return $_ } $log = $LogFile.FullName } "FilePath" { try { _ensureShoutOutLogFile $LogFilePath $msgType | Out-Null $LogHandler = _buildBasicFileLogger $LogFilePath } catch { return $_ } } } try { $_ShoutOutSettings.LogFileRedirection[$msgType] = _validateShoutOutLogHandler $LogHandler $MsgType } catch { return $_ } } # END: source\Set-ShoutOutRedirect.ps1 # START: source\ShoutOut.ps1 # ShoutOut.ps1 # First-things first: Logging function (is the realest, push the message and let the harddrive feel it.) <# .SYNOPSIS Pushes a message of the given $MsgType to the given $Log file with attached invocation metadata. .DESCRIPTION Logging function, used to push a message to a corresponding log-file. The message is prepended with meta data about the invocation to shoutOut as: <MessageType>|<Computer name>|<PID>|<calling Context>|<Date & time>|$Message The default values for the parameters can be set using the Set-ShoutOutConfig, Set-ShoutOutDefaultLog, and Set-ShotOutRedirect functions. #> function shoutOut { [CmdletBinding()] param( [parameter(Mandatory=$false, position=1, ValueFromPipeline=$true, ParameterSetName="Message")] [Object]$Message, [Alias("ForegroundColor")] [parameter(Mandatory=$false, position=2)][String]$MsgType=$null, [parameter(Mandatory=$false, position=3)]$Log=$null, [parameter(Mandatory=$false, position=4)][Int32]$ContextLevel=1, # The number of levels to proceed up the call # stack when reporting the calling script. [parameter(Mandatory=$false)] [bool] $LogContext=$true, [parameter(Mandatory=$false)] [Switch] $NoNewline, [parameter(Mandatory=$false)] [Switch] $Quiet ) begin { $settings = $_ShoutOutSettings # If shoutOut is disabled, return to caller. if ($settings.ContainsKey("Disabled") -and ($settings.Disabled)) { Write-Debug "Call to Shoutout, but Shoutout is disabled. Turn back on with 'Set-ShoutOutConfig -Disabled `$false'." return } <# Applying global variables #> if (!$Log -and $settings.containsKey("DefaultLog")) { $Log = $settings.DefaultLog } if (!$PSBoundParameters.ContainsKey('LogContext') -and $_ShoutOutSettings.ContainsKey("LogContext")) { $LogContext = $_ShoutOutSettings.LogContext } } process { $details = @{ Message = $Message Computer = $env:COMPUTERNAME LogTime = [datetime]::Now PID = $pid } $msgObjectType = if ($null -ne $Message) { $Message.GetType() } else { $null } $details.ObjectType = if ($null -ne $msgObjectType) { $msgObjectType.Name } else { "NULL" } if ( (-not $PSBoundParameters.ContainsKey("MsgType")) -or ($null -eq $PSBoundParameters["MsgType"]) ) { switch ($details.ObjectType) { "ErrorRecord" { $MsgType = "Error" } default { if ([System.Exception].IsAssignableFrom($msgObjectType)) { $MsgType = "Exception" } else { $MsgType = $script:_ShoutOutSettings.DefaultMsgType } } } } $details.MessageType = $MsgType if ($settings.LogFileRedirection.ContainsKey($details.MessageType)) { $Log = $settings.LogFileRedirection[$details.MessageType] } # Hard-coded defaults just in case. if (!$Log) { $Log = ".\shoutout.log" } # If the log is a string, assume that it is a file path: $logHandler = Switch ($log.GetType().NAme) { String { _buildBasicFileLogger $Log } ScriptBlock { $Log } } $messageString = $null # Apply formatting to make output more readable. switch ($details.ObjectType) { "String" { # No transformation necessary. $messageString = $details.Message } "NULL" { # No Transformation necessary. $messageString = "" } "ErrorRecord" { if ($null -ne $details.Message.Exception) { shoutOut $details.Message.Exception } if ($null -ne $details.Message.InnerException) { shoutOut $details.Message.InnerException } $m = $details.Message $MessageString = $m.Exception, $m.CategoryInfo, $m.InvocationInfo, $m.ScriptStackTrace | Out-string | ForEach-Object Split "`n`r" | Where-Object { $_ } $MessageString = $MessageString | Out-String | ForEach-Object TrimEnd "`n`r" } default { $messageString = $Message | Out-String | ForEach-Object TrimEnd "`n`r" } } $details.MessageString = $MessageString # Print to console if necessary if ([Environment]::UserInteractive -and !$Quiet) { if ($settings.containsKey("MsgStyles") -and ($settings.MsgStyles -is [hashtable]) -and $settings.MsgStyles.containsKey($details.MessageType)) { $msgStyle = $settings.MsgStyles[$details.MessageType] } if (!$msgStyle) { if ($details.MessageType -in [enum]::GetNames([System.ConsoleColor])) { $msgStyle = @{ ForegroundColor=$details.MessageType } } else { $msgStyle = @{ ForegroundColor="White" } } } $p = @{ Object = $details.MessageString NoNewline = $NoNewline } if ($msgStyle.ForegroundColor) { $p.ForegroundColor = $msgStyle.ForegroundColor } if ($msgStyle.BAckgroundColor) { $p.BackgroundColor = $msgStyle.BackgroundColor } Write-Host @p } # Calculate parent/calling context $details.Caller = if ($LogContext) { $cs = Get-PSCallStack $csd = @($cs).Length # CallStack Depth, should always be greater than or equal to 2. 1 would indicate that we # are running the directly on the command line, but since we are inside the shoutOut # function there should always be at least one level to the callstack in addition to the # calling context. switch ($csd) { 2 { "[{0}]<commandline>" -f $csd } default { $parentCall = $cs[$ContextLevel] if ($parentCall.ScriptName) { "[{0}]{1}:{2}" -f $csd, $parentCall.ScriptName,$parentCall.ScriptLineNumber } else { for($i = $ContextLevel; $i -lt $cs.Length; $i++) { $level = $cs[$i] if ($level.ScriptName) { break; } } if ($level.ScriptName) { "[{0}]{1}:{2}\<scriptblock>" -f $csd, $level.ScriptName,$level.ScriptLineNumber } else { "[{0}]<commandline>\<scriptblock>" -f $csd } } } } } else { "[context logging disabled]" } $createRecord = { param($details) "{0}|{1}|{2}|{3}|{4}|{5}|{6}" -f @( $details.MessageType, $details.Computer, $details.PID, $details.Caller, $details.LogTime.toString('o'), $details.ObjectType, $details.MessageString ) } try { $handlerArgs = @{} $LogHandler.Ast.ParamBlock.Parameters | ForEach-Object { $n = $_.Name.Extent.Text.TrimStart('$') switch ($n) { Message { $handlerArgs.$n = $details.Message } Record { $handlerArgs.$n = & $createRecord $details } Details { $handlerArgs.$n = $details } } } & $LogHandler @handlerArgs } catch { "Failed to log: {0}" -f ($handlerArgs | Out-String) | Write-Error "using log handler: {0}" -f $logHandler | Write-Error $_ | Out-string | Write-Error } } } # END: source\ShoutOut.ps1 |