Logging errors thrown in nested functions

That topic was about the best I could come up with…

I have a script that uses several functions from a custom module. Some of the functions in that module call other functions in the module. Those work going on in those “other” functions are throwing exceptions. Those exceptions are written to the console using Write-Error. What I want to do, though, is pass the exception from the lowest level, through the middle level, to the top level, and then use the top level script to write the exception to a common log file, (and have some logic based off of the type of exception, etc).

How do I get access to that exception from my top level script?

For a very simple example:

Module1.psm1:

function MidLevel{ param ( $ComputerName ) try { LowestLevel -ComputerName $ComputerName } catch { Write-Error "In MidLevel: $_.Message" } }

function LowestLevel {
param ( $ComputerName )
try {
Get-WmiObject -Class Win32_BIOS -ComputerName $ComputerName
}
catch {
Write-Error “In LowestLevel: $_.Message”
}
}

TopLevel.ps1:

Import-Module F:\Storage\Scripts\Windows\Junk\Module1.psm1

function TopLevel {
param ( $ComputerName )
try {
MidLevel -ComputerName $ComputerName
}
catch {
Write-Host “In TopLevel: $_”
}
}

And this is all I get when I try to run it against a MacBook, (obviously no WMI):

PS F:\> . .\toplevel.ps1; TopLevel -ComputerName MacBook01 Get-WmiObject : The RPC server is unavailable. (Exception from HRESULT: 0x800706BA) At F:\Storage\Scripts\Windows\Junk\Module1.psm1:23 char:3 + Get-WmiObject -Class Win32_BIOS -ComputerName $ComputerName + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : InvalidOperation: (:) [Get-WmiObject], COMException + FullyQualifiedErrorId : GetWMICOMException,Microsoft.PowerShell.Commands.GetWmiObjectCommand

And just to show that it works with a Windows 7 machine:

PS F:\Storage\Scripts\Windows\junk> . .\toplevel.ps1; TopLevel -ComputerName Win7Machine01

SMBIOSBIOSVersion : 786G3 v03.54
Manufacturer : Hewlett-Packard
Name : Default System BIOS
SerialNumber : <SSN>
Version : HPQOEM - 20111102

I’d like to have some logic in the catch block of the TopLevel function that, among other things, writes the thrown exception to a log file. But how do I get it/reference it/etc? I know I’m just missing something simple here…

If i understand correctly then you want to process the error that is generated in either of the child functions but do so within the toplevel script. The problem you are running into is scope (get-help about_Scopes) and a parent (eg toplevel) can’t read data from a child (eg midlevel).

A child however can read from a parent. With this in mind you can do the following: in the toplevel script you can assign a variable with the path to your error log file.

$myerrorlog = "C:\Storage\Scripts\errorlog.txt"

Then in the catch block of each of the functions in your module then you can write the error into the file specified in myerrorlog using Out-File $myerrorlog -append. This is the most basic way to handle it. The better way is to pass the myerrorlog variable into the child as a parameter. By passing items into a function using parameters you always know what is being used rather than relying on the parent scope to have the right information.

Write-Error doesn’t write directly to the console. It creates ErrorRecord objects and sends them to the Error stream. Since these are non-terminating errors, you can’t handle them with Try/Catch unless you force them to become terminating errors with -ErrorAction Stop (personal perference whether you do that or not).

Assuming that the mid-level function doesn’t swallow the errors to prevent them from being passed up the call chain, everything in the error stream will eventually find its way to your top-level scope (and from there to the console, if you allow it to display normally). Here’s that code in its simplest form (the function called directly by your top-level code must be an advanced function for this to work, hence the [CmdletBinding()] and empty param() block):

function LowestLevel {
    Write-Error "This is a test."
}

function MidLevel {
    [CmdletBinding()]
    param ()

    LowestLevel
}

MidLevel -ErrorAction SilentlyContinue -ErrorVariable Err

foreach ($errorRecord in $Err) {
    # Handle the error.  Using Write-Host here to demonstrate that the
    # error didn't get output at the console in the usual way.
    
    Write-Host "Error message: $($errorRecord.ToString())"
}

Thanks Matt and Dave!

Matt, your approach would most likely work, and I had thought about doing something like that. But… Those “LowestLevel” functions are essentially utility functions that do one thing and return an object with the results. They’re designed for reuse in other scripts or just as tools at the command line, so I don’t want them writing to a log. To me, that makes them sort of dependent on the calling function and not really standalone.

Dave, your approach is more what I’m looking for, but I haven’t found the right information about using -ErrorVariable. If your method works, I think it will do what I want to do. I just need to figure out how to pass some info back through the error stream. And yes, I am seeing the exceptions written to my console, but I can’t figure out how to give the code access to them.

I’m going to look into using -ErrorVariable, but just because actual logic might make more sense, here are the functions I’m working with right now. Hopefully you can see the flow a little better with real world examples.

The whole script that I’m working on is looking for AV installs across our corporate AD. The main report script checks for a SCCM client, for a Symantec client, for a System Center Endpoint Protection client, etc. To accomplish that, it uses other tool functions that I’ve created. The code below deals with Symantec, but I’ve got several other similar scenarios.

This chunk of code is the part of that main report script that deals with SEP. It would be the “TopLevel” function in my example. You can see here that there is a lot of console and log writing going on based on the output of the Test-SEPInstall function. This is also where I’d like to log, (or modify the logic in some other way), the exceptions thrown at lower levels.

$SEPTest = $AVProcessList | Test-SEPInstall -DebugLevel $DebugLevel -ExpectedSEPVersion $ExpectedSEPVersion foreach ($machine in $SEPTest) { if ($machine.SEPInstalled) { # If the version is returned, and it is at the expected level or higher, set it aside to check against SEP Manager and remove from further processing if ($machine.SEPCurrent) { $AVProcessList.Remove($machine.ComputerName) if ($DebugLevel -ge 2) { Write-Host "Machine removed from AVProcessList - $($machine.ComputerName)" LogWrite -LogString "Machine removed from AVProcessList - $($machine.ComputerName)" -Severity 0 } $SEPProcessList += $machine.ComputerName if ($DebugLevel -ge 2) { Write-Host "Machine added to SEPProcessList - $($machine.ComputerName)" LogWrite -LogString "Machine added to SEPProcessList - $($machine.ComputerName)" -Severity 0 } } # If the version is returned, but it is lower than expected, write it to log as a warning else { if ($DebugLevel -ge 0) { Write-Warning "Machine has old version of Symantec Endpoint Protection installed - ComputerName: $($machine.ComputerName), SEPVersion: $($machine.SEPVersion)" LogWrite -LogString "Machine has old version of Symantec Endpoint Protection installed - ComputerName: $($machine.ComputerName), SEPVersion: $($machine.SEPVersion)" -Severity 1 } } } # SEP is not installed else { if ($DebugLevel -ge 2) { Write-Host "Machine does not have Symantec Endpoint Protection installed - $($machine.ComputerName)" LogWrite -LogString "Machine does not have Symantec Endpoint Protection installed - $($machine.ComputerName)" -Severity 0 } } }

The Test-SEPInstall function does some simple logic based on the expected version of SEP and the version that is actually on the box and then creates a custom object with that information. This is the “MidLevel” function in my example.

function Test-SEPInstall { [CmdletBinding()] Param ( [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=0,Mandatory=$true)][string[]]$ComputerName, [Parameter(ValueFromPipeline=$false,Position=2,Mandatory=$false)][string]$ExpectedSEPVersion = "12" )
BEGIN {
	$SEPProcessList = @()
}
PROCESS {
	foreach ($comp in $ComputerName) {
		try {
			$SEPInfo = $comp | Get-SymantecEndpointProtectionVersion
			
			# If the version number isn't returned, SEP is not installed
			if ($SEPInfo.SEPVersion -eq $null) {
				$SEPInstalled = $false
				$SEPVersion = $null
				$SEPCurrent = $null
				$SEPManaged = $null
			}
			else {
				# If the version is returned, but it is lower than expected, write it to log as a warning
				if ($SEPInfo.SEPVersion -lt $ExpectedSEPVersion) {
					$SEPInstalled = $true
					$SEPVersion = $SEPInfo.SEPVersion
					$SEPCurrent = $false
					$SEPManaged = $null
				}
				# If the version is returned, and it is at the expected level or higher, set it aside to check against SEP Manager and remove from further processing
				else {
					$SEPInstalled = $true
					$SEPVersion = $SEPInfo.SEPVersion
					$SEPCurrent = $true
					# TODO: add code to verify SEP is managed by SEP Manager on JXUTILITY
					$SEPManaged = $null
				}
			}
		}
		catch [Exception] {
			throw $_.Exception
		}
		finally {
			New-Object -TypeName PSObject -Property @{
				ComputerName=$comp
				SEPInstalled=$SEPInstalled
				SEPVersion=$SEPVersion
				SEPCurrent=$SEPCurrent
				SEPManaged=$SEPManaged
			} | Select-Object ComputerName,SEPInstalled,SEPVersion,SEPCurrent
		} 
	}
}
END {
}

}

Get-SymantecEndpointProtectionVersion is the “LowestLevel” function. It queries the registry for the version info of Symantec Endpoint Protection, (big surprise!), and returns a custom object with that information.

function Get-SymantecEndpointProtectionVersion { # ****************************************************************************** # http://www.symantec.com/connect/forums/powershell-script-which-determines-what-version-sep-machine # ******************************************************************************
[CmdletBinding()]
Param(
	[Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=0,Mandatory=$true)][string[]]$ComputerName
)

BEGIN {
}
PROCESS {
	foreach ($comp in $ComputerName)
	{
		$SEPver = ""
		$regkey = ""
		
		try {
			$reg = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey("LocalMachine",$comp)
			$regkey = $reg.opensubkey("SOFTWARE\\Symantec\\Symantec Endpoint Protection\\SMC")

			if ($regkey -ne $null) {
				$SEPver = $regKey.GetValue('ProductVersion')
			}
			else
			{
				# Registry key not found, so no client installed
				$SEPver = $null
			}
			
		}
		catch [Exception] {
			Write-Error "$comp : $_"
			$SEPver = $null
		}
		finally {
			$Results = New-Object -TypeName PSObject -Property @{
				ComputerName=$comp
				SEPVersion=$SEPver
			}
			$Results	
		}
	}
}
END {
}

}