Can I get detailed exception stacktrace in PowerShell? - exception

Runing such script:
1: function foo()
2: {
3: bar
4: }
5:
6: function bar()
7: {
8: throw "test"
9: }
10:
11: foo
I see
test
At C:\test.ps1:8 char:10
Can I get a detailed stack trace instead?
At bar() in C:\test.ps1:8
At foo() in C:\test.ps1:3
At C:\test.ps1:11

There is a function up on the PowerShell Team blog called Resolve-Error which will get you all kinds of details
Note that $error is an array of all the errors you have encountered in your PSSession. This function will give you details on the last error you encountered.
function Resolve-Error ($ErrorRecord=$Error[0])
{
$ErrorRecord | Format-List * -Force
$ErrorRecord.InvocationInfo |Format-List *
$Exception = $ErrorRecord.Exception
for ($i = 0; $Exception; $i++, ($Exception = $Exception.InnerException))
{ "$i" * 80
$Exception |Format-List * -Force
}
}

There is the automatic variable $StackTrace but it seems to be a little more specific to internal PS details than actually caring about your script, so that won't be of much help.
There is also Get-PSCallStack but that's gone as soon as you hit the exception, unfortunately. You could, however, put a Get-PSCallStack before every throw in your script. That way you get a stack trace immediately before hitting an exception.
I think one could script such functionality by using the debugging and tracing features of Powershell but I doubt it'd be easy.

Powershell 3.0 adds a ScriptStackTrace property to the ErrorRecord object. I use this function for error reporting:
function Write-Callstack([System.Management.Automation.ErrorRecord]$ErrorRecord=$null, [int]$Skip=1)
{
Write-Host # blank line
if ($ErrorRecord)
{
Write-Host -ForegroundColor Red "$ErrorRecord $($ErrorRecord.InvocationInfo.PositionMessage)"
if ($ErrorRecord.Exception)
{
Write-Host -ForegroundColor Red $ErrorRecord.Exception
}
if ((Get-Member -InputObject $ErrorRecord -Name ScriptStackTrace) -ne $null)
{
#PS 3.0 has a stack trace on the ErrorRecord; if we have it, use it & skip the manual stack trace below
Write-Host -ForegroundColor Red $ErrorRecord.ScriptStackTrace
return
}
}
Get-PSCallStack | Select -Skip $Skip | % {
Write-Host -ForegroundColor Yellow -NoNewLine "! "
Write-Host -ForegroundColor Red $_.Command $_.Location $(if ($_.Arguments.Length -le 80) { $_.Arguments })
}
}
The Skip parameter lets me leave Write-Callstack or any number of error-handling stack frames out of the Get-PSCallstack listing.
Note that if called from a catch block, Get-PSCallstack will miss any frames between the throw site and the catch block. Hence I prefer the PS 3.0 method even though we have fewer details per frame.

You can not get a stack trace from exceptions of the PowerShell code of scripts, only from .NET objects. To do that, you will need to get the Exception object like one of these:
$Error[0].Exception.StackTrace
$Error[0].Exception.InnerException.StackTrace
$Error[0].StackTrace

I took what I found here as inspiration and created a nice function anyone can drop into their code.
This is how I call it:
Write-Host "Failed to write to the log file `n$(Resolve-Error)" -ForegroundColor Red
Function Resolve-Error
{
<#
.SYNOPSIS
Enumerate error record details.
.DESCRIPTION
Enumerate an error record, or a collection of error record, properties. By default, the details
for the last error will be enumerated.
.PARAMETER ErrorRecord
The error record to resolve. The default error record is the lastest one: $global:Error[0].
This parameter will also accept an array of error records.
.PARAMETER Property
The list of properties to display from the error record. Use "*" to display all properties.
Default list of error properties is: Message, FullyQualifiedErrorId, ScriptStackTrace, PositionMessage, InnerException
Below is a list of all of the possible available properties on the error record:
Error Record: Error Invocation: Error Exception: Error Inner Exception(s):
$_ $_.InvocationInfo $_.Exception $_.Exception.InnerException
------------- ----------------- ---------------- ---------------------------
writeErrorStream MyCommand ErrorRecord Data
PSMessageDetails BoundParameters ItemName HelpLink
Exception UnboundArguments SessionStateCategory HResult
TargetObject ScriptLineNumber StackTrace InnerException
CategoryInfo OffsetInLine WasThrownFromThrowStatement Message
FullyQualifiedErrorId HistoryId Message Source
ErrorDetails ScriptName Data StackTrace
InvocationInfo Line InnerException TargetSite
ScriptStackTrace PositionMessage TargetSite
PipelineIterationInfo PSScriptRoot HelpLink
PSCommandPath Source
InvocationName HResult
PipelineLength
PipelinePosition
ExpectingInput
CommandOrigin
DisplayScriptPosition
.PARAMETER GetErrorRecord
Get error record details as represented by $_
Default is to display details. To skip details, specify -GetErrorRecord:$false
.PARAMETER GetErrorInvocation
Get error record invocation information as represented by $_.InvocationInfo
Default is to display details. To skip details, specify -GetErrorInvocation:$false
.PARAMETER GetErrorException
Get error record exception details as represented by $_.Exception
Default is to display details. To skip details, specify -GetErrorException:$false
.PARAMETER GetErrorInnerException
Get error record inner exception details as represented by $_.Exception.InnerException.
Will retrieve all inner exceptions if there is more then one.
Default is to display details. To skip details, specify -GetErrorInnerException:$false
.EXAMPLE
Resolve-Error
Get the default error details for the last error
.EXAMPLE
Resolve-Error -ErrorRecord $global:Error[0,1]
Get the default error details for the last two errors
.EXAMPLE
Resolve-Error -Property *
Get all of the error details for the last error
.EXAMPLE
Resolve-Error -Property InnerException
Get the "InnerException" for the last error
.EXAMPLE
Resolve-Error -GetErrorInvocation:$false
Get the default error details for the last error but exclude the error invocation information
.NOTES
.LINK
#>
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$false, Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
[ValidateNotNullorEmpty()]
[array]$ErrorRecord,
[Parameter(Mandatory=$false, Position=1)]
[ValidateNotNullorEmpty()]
[string[]]$Property = ('Message','InnerException','FullyQualifiedErrorId','ScriptStackTrace','PositionMessage'),
[Parameter(Mandatory=$false, Position=2)]
[switch]$GetErrorRecord = $true,
[Parameter(Mandatory=$false, Position=3)]
[switch]$GetErrorInvocation = $true,
[Parameter(Mandatory=$false, Position=4)]
[switch]$GetErrorException = $true,
[Parameter(Mandatory=$false, Position=5)]
[switch]$GetErrorInnerException = $true
)
Begin
{
## If function was called without specifying an error record, then choose the latest error that occured
If (-not $ErrorRecord)
{
If ($global:Error.Count -eq 0)
{
# The `$Error collection is empty
Return
}
Else
{
[array]$ErrorRecord = $global:Error[0]
}
}
## Define script block for selecting and filtering the properties on the error object
[scriptblock]$SelectProperty = {
Param
(
[Parameter(Mandatory=$true)]
[ValidateNotNullorEmpty()]
$InputObject,
[Parameter(Mandatory=$true)]
[ValidateNotNullorEmpty()]
[string[]]$Property
)
[string[]]$ObjectProperty = $InputObject | Get-Member -MemberType *Property | Select-Object -ExpandProperty Name
ForEach ($Prop in $Property)
{
If ($Prop -eq '*')
{
[string[]]$PropertySelection = $ObjectProperty
Break
}
ElseIf ($ObjectProperty -contains $Prop)
{
[string[]]$PropertySelection += $Prop
}
}
Write-Output $PropertySelection
}
# Initialize variables to avoid error if 'Set-StrictMode' is set
$LogErrorRecordMsg = $null
$LogErrorInvocationMsg = $null
$LogErrorExceptionMsg = $null
$LogErrorMessageTmp = $null
$LogInnerMessage = $null
}
Process
{
ForEach ($ErrRecord in $ErrorRecord)
{
## Capture Error Record
If ($GetErrorRecord)
{
[string[]]$SelectedProperties = &$SelectProperty -InputObject $ErrRecord -Property $Property
$LogErrorRecordMsg = $ErrRecord | Select-Object -Property $SelectedProperties
}
## Error Invocation Information
If ($GetErrorInvocation)
{
If ($ErrRecord.InvocationInfo)
{
[string[]]$SelectedProperties = &$SelectProperty -InputObject $ErrRecord.InvocationInfo -Property $Property
$LogErrorInvocationMsg = $ErrRecord.InvocationInfo | Select-Object -Property $SelectedProperties
}
}
## Capture Error Exception
If ($GetErrorException)
{
If ($ErrRecord.Exception)
{
[string[]]$SelectedProperties = &$SelectProperty -InputObject $ErrRecord.Exception -Property $Property
$LogErrorExceptionMsg = $ErrRecord.Exception | Select-Object -Property $SelectedProperties
}
}
## Display properties in the correct order
If ($Property -eq '*')
{
# If all properties were chosen for display, then arrange them in the order
# the error object displays them by default.
If ($LogErrorRecordMsg) {[array]$LogErrorMessageTmp += $LogErrorRecordMsg }
If ($LogErrorInvocationMsg) {[array]$LogErrorMessageTmp += $LogErrorInvocationMsg}
If ($LogErrorExceptionMsg) {[array]$LogErrorMessageTmp += $LogErrorExceptionMsg }
}
Else
{
# Display selected properties in our custom order
If ($LogErrorExceptionMsg) {[array]$LogErrorMessageTmp += $LogErrorExceptionMsg }
If ($LogErrorRecordMsg) {[array]$LogErrorMessageTmp += $LogErrorRecordMsg }
If ($LogErrorInvocationMsg) {[array]$LogErrorMessageTmp += $LogErrorInvocationMsg}
}
If ($LogErrorMessageTmp)
{
$LogErrorMessage = 'Error Record:'
$LogErrorMessage += "`n-------------"
$LogErrorMsg = $LogErrorMessageTmp | Format-List | Out-String
$LogErrorMessage += $LogErrorMsg
}
## Capture Error Inner Exception(s)
If ($GetErrorInnerException)
{
If ($ErrRecord.Exception -and $ErrRecord.Exception.InnerException)
{
$LogInnerMessage = 'Error Inner Exception(s):'
$LogInnerMessage += "`n-------------------------"
$ErrorInnerException = $ErrRecord.Exception.InnerException
$Count = 0
While ($ErrorInnerException)
{
$InnerExceptionSeperator = '~' * 40
[string[]]$SelectedProperties = &$SelectProperty -InputObject $ErrorInnerException -Property $Property
$LogErrorInnerExceptionMsg = $ErrorInnerException | Select-Object -Property $SelectedProperties | Format-List | Out-String
If ($Count -gt 0)
{
$LogInnerMessage += $InnerExceptionSeperator
}
$LogInnerMessage += $LogErrorInnerExceptionMsg
$Count++
$ErrorInnerException = $ErrorInnerException.InnerException
}
}
}
If ($LogErrorMessage) { $Output += $LogErrorMessage }
If ($LogInnerMessage) { $Output += $LogInnerMessage }
Write-Output $Output
If (Test-Path -Path 'variable:Output' ) { Clear-Variable -Name Output }
If (Test-Path -Path 'variable:LogErrorMessage' ) { Clear-Variable -Name LogErrorMessage }
If (Test-Path -Path 'variable:LogInnerMessage' ) { Clear-Variable -Name LogInnerMessage }
If (Test-Path -Path 'variable:LogErrorMessageTmp') { Clear-Variable -Name LogErrorMessageTmp }
}
}
End {}
}

This code:
try {
...
}
catch {
Write-Host $_.Exception.Message -Foreground "Red"
Write-Host $_.ScriptStackTrace -Foreground "DarkGray"
exit 1
}
Will echo an error in a following format:
No match was found for the specified search criteria and module names 'psake'.
at Get-InstalledModule<Process>, ...\PSModule.psm1: line 9251
at Import-ModuleThirdparty, ...\Import-ModuleThirdparty.psm1: line 3
at <ScriptBlock>, ...\index.ps1: line 13

Here's a way: Tracing the script stack
The core of it is this code:
1..100 | %{ $inv = &{ gv -sc $_ myinvocation }

I just figured it out. The $_ is the exception caught in the catch block.
$errorString= $_ | Out-String

You can also change the default formatting for the error object to include the stack trace. Basically, make your format file by copying the chunk for System.Management.Automation.ErrorRecord from $PSHOME\PowerShellCore.format.ps1xml and add your own
element that adds the trace. Then load it with Update-FormatData. For more details, I've just written a blog post about it: https://blogs.msdn.microsoft.com/sergey_babkins_blog/2016/12/28/getting-a-stack-trace-in-powershell/
Oh, one more thing: this doesn't propagate automatically into the remote sessions. The objects get formatted into strings on the remote side. For stack traces in the remote sessions you'll have to upload this file there and call Update-FormatData there again.

Stumbled upon this looking for a built in solution. I am going with simple solution. Just add trace block before using any powershell. This will ensure a call stack is shown. Down side of this is the stack will be displayed before the error message.
Trace {
$_.ScriptStackTrace
}

Maybe I've misunderstood something, but my issue here is that I've not been seeing powershell script stack traces for inner exceptions.
In the end I ended up searching $Global:Error for an exception's Error Eecord object to retrieve the script stack trace.
<#
.SYNOPSIS
Expands all inner exceptions and provides Script Stack Traces where available by mapping Exceptions to ErrorRecords
.NOTES
Aggregate exceptions aren't full supported, and their child exceptions won't be traversed like regular inner exceptions
#>
function Get-InnerErrors ([Parameter(ValueFrompipeline)] $ErrorObject=$Global:Error[0])
{
# Get the first exception object from the input error
$ex = $null
if( $ErrorObject -is [System.Management.Automation.ErrorRecord] ){
$ex = $ErrorObject.Exception
}
elseif( $ErrorObject -is [System.Exception] ){
$ex = $ErrorObject
}
else
{
throw "Unexpected error type for Get-InnerErrors: $($ErrorObject.GetType()):`n $ErrorObject"
}
Write-Debug "Walking inner exceptions from exception"
for ($i = 0; $ex; $i++, ($ex = $ex.InnerException))
{
$ErrorRecord = $null
if( $ex -is [System.Management.Automation.IContainsErrorRecord] ){
Write-Debug "Inner Exception $i : Skipping search for ErrorRecord in `$Global:Error, exception type implements IContainsErrorRecord"
$ErrorRecord = ([System.Management.Automation.IContainsErrorRecord]$ex).ErrorRecord
}
else {
# Find ErrorRecord for exception by mapping exception to ErrorRecrods in $Global:Error
ForEach( $err in $Global:Error ){# Need to use Global scope when referring from a module
if( $err -is [System.Management.Automation.ErrorRecord] ){
if( $err.Exception -eq $ex ){
$ErrorRecord = $err
Write-Debug "Inner Exception $i : Found ErrorRecord in `$Global:Error"
break
}
}
elseif( $err -is [System.Management.Automation.IContainsErrorRecord] ) {
if( $err -eq $ex -or $err.ErrorRecord.Exception -eq $ex ){
$ErrorRecord = $err.ErrorRecord
Write-Debug "Inner Exception $i : Found ErrorRecord in `$Global:Error"
break
}
}
else {
Write-Warning "Unexpected type in `$Global:Error[]. Expected `$Global:Error to always be an ErrorRecrod OR exception implementing IContainsErrorRecord but found type: $($err.GetType())"
}
}
}
if( -not($ErrorRecord) ){
Write-Debug "Inner Exception $i : No ErrorRecord could be found for exception of type: $($ex.GetType())"
}
# Return details as custom object
[PSCustomObject] #{
ExceptionDepth = $i
Message = $ex.Message
ScriptStackTrace = $ErrorRecord.ScriptStackTrace # Note ErrorRecord will be null if exception was not from Powershell
ExceptionType = $ex.GetType().FullName
ExceptionStackTrace = $ex.StackTrace
}
}
}
Example Usage:
function Test-SqlConnection
{
$ConnectionString = "ThisConnectionStringWillFail"
try{
$sqlConnection = New-Object System.Data.SqlClient.SqlConnection $ConnectionString;
$sqlConnection.Open();
}
catch
{
throw [System.Exception]::new("Sql connection failed with connection string: '$ConnectionString'", $_.Exception)
}
finally
{
if($sqlConnection){
$sqlConnection.Close();
}
}
}
try{
Test-SqlConnection
}
catch {
Get-InnerErrors $_
}
Example output:
ExceptionDepth : 0
Message : Sql connection failed with connection string: 'ThisConnectionStringWillFail'
ScriptStackTrace : at Test-SqlConnection, <No file>: line 11
at <ScriptBlock>, <No file>: line 23
ExceptionType : System.Exception
ExceptionStackTrace :
ExceptionDepth : 1
Message : Exception calling ".ctor" with "1" argument(s): "Format of the initialization string does not conform to specification starting at index 0."
ScriptStackTrace :
ExceptionType : System.Management.Automation.MethodInvocationException
ExceptionStackTrace : at System.Management.Automation.DotNetAdapter.AuxiliaryConstructorInvoke(MethodInformation methodInformation, Object[] arguments, Object[] originalArguments)
at System.Management.Automation.DotNetAdapter.ConstructorInvokeDotNet(Type type, ConstructorInfo[] constructors, Object[] arguments)
at Microsoft.PowerShell.Commands.NewObjectCommand.CallConstructor(Type type, ConstructorInfo[] constructors, Object[] args)
ExceptionDepth : 2
Message : Format of the initialization string does not conform to specification starting at index 0.
ScriptStackTrace :
ExceptionType : System.ArgumentException
ExceptionStackTrace : at System.Data.Common.DbConnectionOptions.GetKeyValuePair(String connectionString, Int32 currentPosition, StringBuilder buffer, Boolean useOdbcRules, String& keyname, String&
keyvalue)
at System.Data.Common.DbConnectionOptions.ParseInternal(Hashtable parsetable, String connectionString, Boolean buildChain, Hashtable synonyms, Boolean firstKey)
at System.Data.Common.DbConnectionOptions..ctor(String connectionString, Hashtable synonyms, Boolean useOdbcRules)
at System.Data.SqlClient.SqlConnectionString..ctor(String connectionString)
at System.Data.SqlClient.SqlConnectionFactory.CreateConnectionOptions(String connectionString, DbConnectionOptions previous)
at System.Data.ProviderBase.DbConnectionFactory.GetConnectionPoolGroup(DbConnectionPoolKey key, DbConnectionPoolGroupOptions poolOptions, DbConnectionOptions& userConnectionOptions)
at System.Data.SqlClient.SqlConnection.ConnectionString_Set(DbConnectionPoolKey key)
at System.Data.SqlClient.SqlConnection.set_ConnectionString(String value)
at System.Data.SqlClient.SqlConnection..ctor(String connectionString, SqlCredential credential)

There are cases where PowerShell doesn't seem to keep a backtrace, like calling a method or calling a function with .Invoke(). For that, Set-PSDebug -Trace 2 may come in handy. It will print every executed line of the running script.
Try flipping # on (1) and (2) and running WrapStackTraceLog({ function f{ 1/0 } ; & f }) # let's divide by zero
Function WrapStackTraceLog($func) {
try {
# return $func.Invoke($args) # (1)
return (& $func $args) # (2)
} catch {
Write-Host ('=' * 70)
Write-Host $_.Exception.Message
Write-Host ('-' * 70)
Write-Host $_.ScriptStackTrace
Write-Host ('-' * 70)
Write-Host "$StackTrace"
Write-Host ('=' * 70)
}
}
Branch (1) exception caught:
Exception calling "Invoke" with "1" argument(s): "Attempted to divide by zero."
Branch (2) is more informative:
at f, <No file>: line 1
at <ScriptBlock>, <No file>: line 1
at global:WrapStackTraceLog, <No file>: line 4
at <ScriptBlock>, <No file>: line 1
But, you can still trace your Invokes with tracing on, branch (1):
DEBUG: ! CALL function 'f'
DEBUG: 1+ WrapStackTraceLog({ function f{ >>>> 1/0 } ; & f })
DEBUG: 6+ >>>> Write-Host ('=' * 70)
======================================================================
DEBUG: 7+ >>>> Write-Host $_.Exception.Message
Exception calling "Invoke" with "1" argument(s): "Attempted to divide by zero."

Related

Use a parameter switch to change how a function behaves

My main PowerShell code runs a function that logs to the Windows eventlog. If the level is error it uses a separate event ID which then our monitoring will pick up that exact ID and run an action. However, if I want to specify in the parameter of the main script (not the function) that this time running it use a different Event ID so it will NOT action monitoring, I don't know where to even start on that.
Is there a way to provide a switch parameter in the main script like $NoAlert which then changes the Event ID in the function?
The function of logging lives in a PowerShell module I created. I am importing the module at the beginning of the script and then calling the function during the main script body.
Here is the function:
function WriteLog-SRTProd {
Param(
[string]$logT,
[Parameter(Mandatory=$true)][string]$level,
[String]$LogFileDirT = "\\ServerA\Logs"
)
$RSLogfileT = (Get-ChildItem -Path $LogFileDirT |
sort LastWriteTime |
select -Last 1).Name
## make sure a level is correctly selected (mandatory)
if ("Error","Info","Warn" -NotContains $Level) {
throw "$($Environment) is not a valid name! Please use 'Error', 'Warn', or 'Info'"
}
if ($Level -eq "Info") {
Add-Content -Path "$LogFileDirT\$RSLogFileT" -Value "$(Get-Date -format MM-dd-yyyy::HH:mm:ss) INFO $logT"
Write-EventLog -LogName Application -Source TEST_MAINT -EntryType Information -EventId 100 -Message $logT -Category 0
}
if ($Level -eq "Warn") {
Add-Content -Path "$LogFileDirT\$RSLogFileT" -Value "$(Get-Date -format MM-dd-yyyy::HH:mm:ss) WARN $logT"
Write-EventLog -LogName Application -Source TEST_MAINT -EntryType Warning -EventId 200 -Message $logT -Category 0
}
if ($Level -eq "Error") {
Add-Content -Path "$LogFileDirT\$RSLogFileT" -Value "$(Get-Date -format MM-dd-yyyy::HH:mm:ss) ERROR $logT"
Write-EventLog -LogName Application -Source TEST_MAINT -EntryType Error -EventId 300 -Message $logT -Category 0
}
}
I'd like to run my script like this. When the $NoAlert is passed, it will send that switch to the function. Is this possible? Can I just add the switch in both places and use an if statement in the function for when the NoAlert switch is used?
PS C:\> .\Maintenance.ps1 -NoAlert
Param(
[switch]$NoAlert
)
WriteLog-SRTProd -level Error -logT "Custom Error Message"
I have created own function for logging and stored/installed as module, below is the part of my log module :
you can customize the write statements and add your code for event log. I have added 'NoAction' enum member as per your requirements.
I have used one Enum to separate the log levels
Enum Severity
{
Error = 3
Warning = 4
Informational = 6
Debug = 7
Verbose = 8
NoAction = 0 # AS PER YOUR REQUIREMENTS
}
function Write-Log()
{
[cmdletbinding()]
param
(
[Parameter(Position=0,mandatory=$true)]
[Severity] $LogLevel,
[Parameter(Position=1,mandatory=$true)]
[String] $Message
)
$TimeStamp = "$(Get-Date -format HH:mm:ss)" ;
Switch($LogLevel)
{
([Severity]::Error.ToString())
{
Write-Error "`t$TimeStamp : $Message`n" -ErrorAction Stop
break;
}
([Severity]::Warning.ToString())
{
Write-Warning "`t$TimeStamp : $Message`n" -WarningAction Continue
break;
}
([Severity]::Informational.ToString())
{
Write-Information "INROMATION:`t$TimeStamp : $Message`n" -InformationAction Continue
break;
}
([Severity]::Verbose.ToString())
{
Write-Verbose "`t$TimeStamp : $Message`n"
break;
}
([Severity]::NoAction.ToString())
{
Write-Verbose "`t$TimeStamp : $Message`n"
break;
}
} # END OF SWITCH
} # END OF FUNCTION
Sample Call :
Write-Log -LogLevel ([Severity]::Informational) -Message "test log message using info level"
Output :
INROMATION: 09:40:15 : test log message using info level
I have decided to just add a new parameter to both function and main script named $NoAlert. I have added an If($NoAlert){WriteLog-SRPProd -NoAlert} to the main script (messy, but its what I needed done). then in the Function, If($NoAlert){EventID 111}. so basically I am using the switch in the main script that then calls the NoAlert switch in the function. This is all done with a few added If/Else statements.
Hopefully that makes sense. Like I said its not the best answer, but I wanted to get it done and still provide an answer here in this post.

Method invocation failed because [System.Collections.ObjectModel.Collection`1[[]] does not contain a method which I added to pscustomobject

We have a json file which contains an array with products with sell price and for some (the products we have to buy) a part number and buy price. For example:
{
"Prices":[
{
"Product":"AD User",
"Price":3.52
},
{
"Product":"Skype for Business Plus SAL",
"Price":4.68,
"Part number":"6SH-00002",
"Buy price":3.90
},
{
"Product":"Citrix CAL",
"Price":6.40,
"Part number":"ROYSPLACXABASE",
"Buy price":5.33
}
]
}
I use the following code to retrieve the contents of the json and for each object with a part number i put an object to an array of PSCustomObjects and add an int OrderCount and a function to increment this OrderCount:
$PurchaseOrder = #()
[object[]]$Prices = Get-Content 'C:\Pricelist.json' | Out-String | ConvertFrom-Json
foreach($_price in ($Prices.prices | Where-Object {$_."Part Number"})){
$PurchaseOrderItem = $_price.psobject.copy()
$PurchaseOrderItem | Add-Member -Name "OrderCount" -MemberType NoteProperty -Value 0
$PurchaseOrderItem | Add-Member -MemberType ScriptMethod -Name IncrementOrderCount {
param([int]$amount)
$this.OrderCount = $this.OrderCount + $amount
}
$PurchaseOrder += $PurchaseOrderItem
}
If figured out the following function to update the $PurchaseOrder:
function Update-PurchaseOrder{
[Cmdletbinding()]
param(
[ref]$PurchaseOrder,
[string]$ProductName,
[int]$Count
)
$PurchaseOrder.Value.where({$_.Product -eq $ProductName}).IncrementOrderCount($Count)
}
Instead I get the following error when I run this function
Update-PurchaseOrder -PurchaseOrder ([ref]$PurchaseOrder) -ProductName "Citrix CAL" -Count 5
Method invocation failed because [System.Collections.ObjectModel.Collection`1[[System.Management.Automation.PSObject, System.Management.Automation, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35]]] does not contain a
method named 'IncrementOrderCount'.
At line:9 char:5
+ $PurchaseOrder.Value.where({$_.Name -eq $ProductName}).IncrementO ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : MethodNotFound
If i run this code with a $Prices object which is created manually, for example:
$Prices = #([pscustomobject]#{Product="Product1";Price=1},[pscustomobject]#{Product="Product2";Price=2})
It does run without any problem. I've tried to cast several objects to different types, but I'm out of options.
I've added an additional piece of code to check if the Product exists in my PurchaseOrder, now it runs without problem.
Write-Verbose "Updating PurchaseOrder for product $Title "
if($PurchaseOrder.Value.where({$_.Product -eq $Title})){
try{
$PurchaseOrder.Value.where({$_.Product -eq $Title}).IncrementOrderCount($Count)
} catch {
Write-Warning "Error while incrementing OrderCount for product $($Title): $($_.Exception.Message)"
}
} else {
Write-Verbose "Product $Title not found in PurchaseOrder."
}
Use ForEach-Object to iterate over the collection and then call the method on each object.
Also the property with the Product name is Product rather than Name.
function Update-PurchaseOrder{
[Cmdletbinding()]
param(
[ref]$PurchaseOrder,
[string]$ProductName,
[int]$Count
)
$PurchaseOrder.Value.where({$_.Product -eq $ProductName}) | ForEach-Object {$_.IncrementOrderCount($Count)}
}
Also I believe $PurchaseOrder would already be passed by reference so you could skip the extra hassle and just pass $PurchaseOrder rather than ([ref]$PurchaseOrder). And then not need the extra step of unpacking the value in the function.

When using Powershell's Dynamic parameters can I suppress Errors?

When using Powershell's Dynamic parameters can I suppress Errors?
Specifically the error being:
f foo
Search-FrequentDirectory : Cannot validate argument on parameter 'dirSearch'. The argument "foo" does not belong to the set
"bin,omega,ehiller,psmodules,deploy,gh.riotgames.com,build-go,vim74,cmder,dzr,vimfiles,src,openssh,git" specified by the ValidateSet attribute. Supply an argument
that is in the set and then try the command again.
At line:1 char:3
+ f foo
+ ~~~
+ CategoryInfo : InvalidData: (:) [Search-FrequentDirectory], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Search-FrequentDirectory
Dynamic parameters being:
DynamicParam {
$dirSearch = new-object -Type System.Collections.ObjectModel.Collection[System.Attribute]
# [parameter(mandatory=...,
# ...
# )]
$dirSearchParamAttribute = new-object System.Management.Automation.ParameterAttribute
$dirSearchParamAttribute.Mandatory = $true
$dirSearchParamAttribute.Position = 1
$dirSearchParamAttribute.HelpMessage = "Enter one or more module names, separated by commas"
$dirSearch.Add($dirSearchParamAttribute)
# [ValidateSet[(...)]
$dirPossibles = #()
$historyFile = (Get-PSReadlineOption).HistorySavePath
# directory Seperating character for the os; \ (escaped to \\) for windows (as C:\Users\); / for linux (as in /var/www/);
# a catch all would be \\\/ ; but this invalidates the whitespace escape character that may be used mid-drectory.
$dirSep = "\\"
# Group[1] = Directory , Group[length-1] = lowest folder
$regex = "^[[:blank:]]*cd ([a-zA-Z\~:]+([$dirSep][^$dirSep]+)*[$dirSep]([^$dirSep]+)[$dirSep]?)$"
# original: ^[[:blank:]]*cd [a-zA-Z\~:\\\/]+([^\\\/]+[\\\/]?)*[\\\/]([^\\\/]+)[\/\\]?$
# test for historyFile existance
if( -not (Test-Path $historyFile )){
Write-Warning "File $historyFile not found, unable to load command history. Exiting.";
return 1;
}
$historyLines = Get-Content $historyFile
# create a hash table, format of ;;; [directory path] = [lowest directory]
$searchHistory = #{}
# create a hash table for the count (number of times the command has been run)
$searchCount = #{}
ForEach ( $line in $historyLines ) {
if( $line -match $regex ){
try {
# since the matches index can change, and a hashtable.count is not a valid way to find the index...
# I need this to figure out the highest integer index
$lowestDirectory = $matches[($matches.keys | sort -Descending | Select-Object -First 1)]
$fullPath = $matches[1]
if($searchHistory.keys -notcontains $matches[1]){
$searchHistory.Add($matches[1],$lowestDirectory)
}
$searchCount[$fullPath] = 1
} catch {
$searchCount[$fullPath]++
}
}
}
# this helps with hashtables
# https://www.simple-talk.com/sysadmin/powershell/powershell-one-liners-collections-hashtables-arrays-and-strings/
$dirPossibles = ( $searchHistory.values | Select -Unique )
$modulesValidated_SetAttribute = New-Object -type System.Management.Automation.ValidateSetAttribute($dirPossibles)
$dirSearch.Add($modulesValidated_SetAttribute)
# Remaining boilerplate
$dirSearchDefinition = new-object -Type System.Management.Automation.RuntimeDefinedParameter("dirSearch", [String[]], $dirSearch)
$paramDictionary = new-object -Type System.Management.Automation.RuntimeDefinedParameterDictionary
$paramDictionary.Add("dirSearch", $dirSearchDefinition)
return $paramDictionary
}
The function works, when I'm in the set everything is great. When I miskey or whatever, I get a rather unpleasant looking (non-user friendly error) - which I would like to style.
Is there a way to do this? To suppress the error? I tried try / catch and it was a no go, and I haven't been able to find much else on this - that is, error suppression in dynamic parameters.
I found a way to do it, but not sure I really recommend its use, and it has some downsides of duplicated code. Maybe there is a way to solve this better, but I did this more as an exercise of if it could be done.
Code Get-DynamicParamTestCustom.ps1
<#
Reference: http://blog.enowsoftware.com/solutions-engine/bid/185867/Powershell-Upping-your-Parameter-Validation-Game-with-Dynamic-Parameters-Part-II
#>
[CmdletBinding()]
param (
)
DynamicParam {
function New-ValidationDynamicParam {
[CmdletBinding()]
[OutputType('System.Management.Automation.RuntimeDefinedParameter')]
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$Name,
[ValidateNotNullOrEmpty()]
[Parameter(Mandatory)]
[array]$ValidateSetOptions,
[Parameter()]
[switch]$Mandatory = $false,
[Parameter()]
[string]$ParameterSetName = '__AllParameterSets',
[Parameter()]
[switch]$ValueFromPipeline = $false,
[Parameter()]
[switch]$ValueFromPipelineByPropertyName = $false
)
$AttribColl = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
$ParamAttrib = New-Object System.Management.Automation.ParameterAttribute
$ParamAttrib.Mandatory = $Mandatory.IsPresent
$ParamAttrib.ParameterSetName = $ParameterSetName
$ParamAttrib.ValueFromPipeline = $ValueFromPipeline.IsPresent
$ParamAttrib.ValueFromPipelineByPropertyName = $ValueFromPipelineByPropertyName.IsPresent
$AttribColl.Add($ParamAttrib)
$AttribColl.Add((New-Object System.Management.Automation.ValidateSetAttribute($Param.ValidateSetOptions)))
$RuntimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter($Param.Name, [string], $AttribColl)
$RuntimeParam
}
function Get-ValidValues
{
# get list of valid values
$validValues = #()
$validValues += 'a'
$validValues += 'b'
$validValues += 'c'
$validValues += $global:dynamic1Value
$validValues
}
# coerce the current passed value into our list, and we detect later
# to customize message
# the heart of this problem is getting the param from the Call Stack
# and stashing it away (a hack, but it IS a solution).
$line = (Get-PSCallStack | Select -First 1 | Select *).InvocationInfo.Line
# parse this for the command line arg
# TODO: make this more robust
$null = $line -match "-Dynamic1 (.*?)(\s+|$)"
$global:dynamic1Value = $Matches[1]
$ParamOptions = #(
#{
'Name' = 'Dynamic1';
'Mandatory' = $true;
'ValidateSetOptions' = Get-ValidValues
}
)
$RuntimeParamDic = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
foreach ($Param in $ParamOptions)
{
$RuntimeParam = New-ValidationDynamicParam #Param
$RuntimeParamDic.Add($Param.Name, $RuntimeParam)
}
return $RuntimeParamDic
}
begin
{
$PsBoundParameters.GetEnumerator() | foreach { New-Variable -Name $_.Key -Value $_.Value -ea 'SilentlyContinue'}
}
process
{
# not sure how else to write this because the function needs to be inside
# DynamicParam{} block for its usage, and here for 'process' usage.
function Get-ValidValuesReal
{
# get list of valid values
$validValues = #()
$validValues += 'a'
$validValues += 'b'
$validValues += 'c'
$validValues
}
function foo
{
}
Write-Output "global:dynamic1Value is: '$($global:dynamic1Value)'."
Write-Output "Dynamic1 is: '$($Dynamic1)'."
$realValues = Get-ValidValuesReal
if ($global:dynamic1Value -notin $realValues)
{
Write-Error "Hey, '$global:dynamic1Value' is not allowed."
}
else
{
Write-Output "Dynamic1 is: '$($Dynamic1)' and is cool."
}
}
end {}
Test Cases
.\Get-DynamicParamTestCustom.ps1 -Dynamic1 t
.\Get-DynamicParamTestCustom.ps1 -Dynamic1 test
.\Get-DynamicParamTestCustom.ps1 -Dynamic1 test -Verbpse
.\Get-DynamicParamTestCustom.ps1 -Dynamic1 a

Passing a cmdlet as a parameter

I need to restart a service in a powershell script. The problem is that this service is a bit buggy and frequently needs to be shut down several times before it gets into the "stopped" state. Because of that I can't seem to use the Restart-Service cmdlet, instead I need to retry the Stop-Service cmdlet a few times. The same applies to starting the service.
So I figure this is a good place to write a function that will take an action (start or stop) and retry it a few times until it works. The problem is I'm not sure how to pass the action in as a parameter. I could just have the action be a String and then say if action == "start" do starcAction, but that won't be very clean. Is there any way I could pass a cmdlet like Stop-Service in as a parameter?
For the scenario you described you'd normally do something like this:
$maxTries = 5
switch ($args(0)) {
'start' {
Start-Service 'MySvc'
}
'stop' {
$i = 0
do {
Stop-Service 'MySvc'
$i++
} until ((Get-Service 'MySvc').Status -eq 'Stopped' -or $i -ge $maxTries)
if ((Get-Service 'MySvc').Status -ne 'Stopped') {
Write-Error "Cannot stop service."
exit 1
}
}
default {
Write-Error "Unknown action: $_"
exit 1
}
}
If you really want to avoid string arguments, you could use parameter sets like this:
[CmdletBinding(DefaultParameterSetName='none')]
Param(
[Parameter(Mandatory=$true,ParameterSetName='start')]
[Switch][bool]$Start = $false,
[Parameter(Mandatory=$true,ParameterSetName='stop')]
[Switch][bool]$Stop = $false
)
$maxTries = 5
switch ($PSCmdlet.ParameterSetName) {
'start' {
Start-Service 'MySvc'
}
'stop' {
$i = 0
do {
Stop-Service 'MySvc'
$i++
} until ((Get-Service 'MySvc').Status -eq 'Stopped' -or $i -ge $maxTries)
if ((Get-Service 'MySvc').Status -ne 'Stopped') {
Write-Error "Cannot stop service."
exit 1
}
}
'none' {
Write-Error "Usage: $($MyInvocation.MyCommand.Name) {-Start|-Stop}"
exit 1
}
}
Param([Parameter(Mandatory)] [ValidateSet('Start','Stop')] [string] $Action)
This allows the user to press Tab to select the possible values and will automatically reject all invalid input.
Passing in a well defined parameter (doesn't matter if it's a string or not) is actually cleaner than "passing in a commandlet" would be if there was such a thing.

Parsing text type log file

I have just started PowerShell today
I have this type of log files with any number of tests:
Plan test.pln - 1 error
[#VERSION-TestPlanGenerator#]3.8.0.0018
HW# VS4_1
[#TC#] test 1
\\APPS-EUAUTO1\C$\...\Temp\FXSAPIDebugLogFile.txt - The process cannot access the file because it is being used by another process.
[APPS-EUAUTO1] [prep] Setting agent options and random seed...
[APPS-EUAUTO1] [info] Initial Seed : 124426621
[APPS-EUAUTO1] [info] Current seed : 96010
[APPS-EUAUTO1] [info] rt1 t1
[APPS-EUAUTO1] [debug] rt1 t1
[#WARNING#][APPS-EUAUTO1] [warning] rt1 t1 ( Screen shot : D:\...\[APPS-EUAUTO1] 03-28-14 11-29-22.png)
[#WARNING#][APPS-EUAUTO1] [warning] Unhandled error detected ! ( Screen shot : D:\...\[APPS-EUAUTO1] 03-28-14 11-29-22.png)
[#ERROR#][APPS-EUAUTO1] [error] rt1 t1 ( Screen shot : D:...\[APPS-EUAUTO1] 03-28-14 11-29-22.png)
Occurred in fnMsg at ..\functions\f_common.inc(456)
Called from t1 at test.t(10)
Called from rt1 at test.t(5)
[#TC#] test 2
[APPS-EUAUTO1] [prep] Setting agent options and random seed...
[APPS-EUAUTO1] [info] Current seed : 177041
[APPS-EUAUTO1] [info] rt2 t2
[APPS-EUAUTO1] [debug] rt2 t2
I need to get all the tests in an array in witch each element will have:
a string Name ( ex: test 1)
a boolean Error (ex: true for test 1 because there is a [#WARNING#]
or a [#ERROR#] message present)
a array Messages with all the messages (ex: for test 2 all 4
messages)
and at the end I will like to export this array to a html file.
All tests begins with [#TC#].
I'm having problems with the reading part.
I have tried a couple of things from different sites but, it doesn't not seem to work for me :
Function Import-MyLog1 {
# -----------------------------------------------------------------------
Function Get-Log {
# Reads the log file into memory.
Try {
Get-Content -path "res.txt" -ErrorAction Stop -Delimiter "[#TC#]"
}
Catch {
Write-Error "The data file is not present"
BREAK
}
} # End: Function Get-Log
# -----------------------------------------------------------------------
Function Get-Record {
Param ($Log)
for ($i=1; $i -lt $Log.Length; $i++) { # ignore the header
$Testcase = $Log[$i]
New-Object PSobject -Property #{
Name = $Testcase[0]
Data = $Testcase[3..6]
}
}
} # End: Function Get-Record
# Load the log into memory
$Log = Get-Log
$Records = Get-Record -Log $Log
$Records # Added only to see the current progress.
} #End: Function Import-MyLog1
clear
Import-MyLog1
this is the final code for who might need a example :
Function Get-TxtLog {
Param ($File)
# Reads the log file into memory.
Try {
Get-Content -path $File -ErrorAction Stop -Delimiter "[#TC#]"
} Catch {
Write-Error "The data file is not present"
BREAK
}
} # End: Function Get-TxtLog
# -----------------------------------------------------------------------
Function Get-Testcase {
Param ($TxtLog)
for ($i=1; $i -lt $TxtLog.Count; $i++) { # $i=1 to ignore the header
$Testcase = $TxtLog[$i].split("`n")
$Output = New-Object PSobject -Property #{
Name = $Testcase[0].Trim()
Messages = $Testcase[1..($Testcase.count)] | ?{!($_ -match "\[#TC#]")} | ForEach-Object -process {$_.Trim()}
}
$Error = $( if($Output.Messages -match ("\[#ERROR#]|\[#WARNING#]")) {$true} else {$false} )
$Output|Add-Member -MemberType NoteProperty -Name "Error" -value $Error
$Output|Add-Member -MemberType NoteProperty -Name "Runtime" -value $null # will be added later
$Output|Add-Member -MemberType NoteProperty -Name "Data" -value $null # will be added later
$Output # to pipe the object
}
} # End: Function Get-Testcase
# -----------------------------------------------------------------------
# Load the log into memory
$TxtLog = Get-TxtLog -file "D:\XHostMachine\Results\res.txt"
$Records = Get-Testcase -TxtLog $TxtLog
$Records | Format-Table
If you just started PowerShell today then I can only imagine what you'll be doing with it in a year's time... You have started very well in my opinion.
Now, you seem to want to make everything into a function, which I suppose there is little harm in, but personally it seems overkill. When I pasted your test code into my ISE the first thing I did was comment out the first line and 28th line through the end of it. There just doesn't seem to be a need for all that.
Next I added a parameter to the Get-Log function so that a path can be provided if desired, and if excluded it will default to your res.txt file.
Function Get-Record {
Param ($Log)
for ($i=1; $i -lt $Log.Count; $i++) { # ignore the header
$Testcase = $Log[$i].split("`n")
$Output = New-Object PSobject -Property #{
Name = $Testcase[0]
Data = $Testcase[3..($Testcase.count)]|?{!($_ -match "\[#TC#]")}
}
$Output|Add-Member -MemberType NoteProperty -Name "Error" -value $(if($Output.data -match "^.+?(\[#ERROR#]|\[#WARNING#])"){$true}else{$false})
$Output
}
} # End: Function Get-Record
After that I looked at the value of $Log once it was gotten. You end up with an array with 3 strings in it. That's all fine and good, but what you really want is an array with 3 arrays in it if you ask me. Right now $Log[0] is a string with 4 lines of text, and you'd be better off with an array of 4 strings... so let's go that route first. I modified your Get-Record to accomplish that.
Function Get-Record {
Param ($Log)
for ($i=1; $i -lt $Log.Count; $i++) { # ignore the header
$Testcase = $Log[$i].split("`n")
You'll notice the split is done on n which is the powershell NewLine character. Then I updated the object you created to exclude the [#TC#] which was used as a delimiter, and assigned it a variable instead of just outputting it. Once I had that $Output variable I tested it for[#ERROR#]and[#WARNING#]` using a regex match and added a new Error property to the object depending on if an error was found or not.
$Output = New-Object PSobject -Property #{
Name = $Testcase[0]
Data = $Testcase[3..($Testcase.count)]|?{!($_ -match "\[#TC#]")}
}
$Output|Add-Member -MemberType NoteProperty -Name "Error" -value $(if($Output.data -match "^.+?(\[#ERROR#]|\[#WARNING#])"){$true}else{$false})
$Output
}
} # End: Function Get-Record
Then I pretty much passed the rest off as is except added my path to the log that I made from your example text.
# Load the log into memory
$Log = Get-Log c:\temp\test.log
$Records = Get-Record -Log $Log
$Records # Added only to see the current progress.
#} #End: Function Import-MyLog1
#
#clear
#Import-MyLog1
Now, you could clean it up a bit I suppose by trimming blank space from the beginning of lines if you wanted, but that's just a matter of taste. But it gives you 2 entries in $Records each with the name you wanted, and the data lines, and a boolean Error property.