Wednesday, March 8, 2017

All the 7 principles of the LEAN methodology in a single line of PowerShell code

As you now know, PowerShell is ten years old. This language has been adopted as the management standard for a lot of platforms (Azure, NetApp, VMware, AWS, just to mention a few). As time goes on, the Windows system administrator has rediscovered his developer-self and the joy of doing things from the command line.

POWERSHELL IS EVERYWHERE

The result is that, today, everybody is writing more and more PowerShell code in the form of scripts, functions, modules. I myself am writing code to manage my pellet stove, my security camera as well as whatever that has an IP address.

Keeping track with all of that code tends to get more difficult and time-expensive. Over time we increase the complexity of our scripts: we add new functions, we modify parts of code, copy/paste other parts from existing scripts. We do also introduce new cmdlets but we also keep old parts of code that are difficult to rewrite without breaking something.

This kind of complexity calcifies some badly or quickly developed lines of code into originally well-thought advanced functions, and can make your scripts a mess.

But, smile, LEAN development is here, and in this short post I am going to show you how you have to think about your lines of code so that your scripts stay easy to maintain, to reuse and to share with others. Be it your colleagues, or the Community.

GO KAIZEN

Basically we need to introduce today a concept named with the Japanese word 'Kaizen', which means 'change for better'. LEAN development has been thought of as a modelling of Kaizen, and has been summarized in seven easy to remember principles, which, once adopted, will improve you way of writing PowerShell all across the board.


To start with, here's an overview of those seven principles, with a quick explanation of how they should be implemented in the process of developing a single line of code that does a specific job. This could for sure be extended to the writing of advanced functions, but a lot has already been written and published on the best practices to adopt during complex scripting, and much less on how to keep a state-of-the-art single line of code.

THE MAGNIFICENT SEVEN

First principle. Eliminate waste. LEAN philosophy regards everything not adding value as waste. You should think the same:
  • keep your line of code as short and simple as possible
  • use the right module for the job
  • rely on modules auto-loading
  • rely on default parameters
  • rely on positional parameters
  • do not (over)use variables
  • send down the pipeline only the needed objects or object properties

Second principle. Amplify learning. LEAN philosophy states that it is necessary to have a reasonable failure rate in order to generate a reasonable amount of new information. This new information is tagged 'feedback'. You, as a PowerShell developer, need that feedback, so:
  • work on your code one pipe at the time and accurately review the output, its method and its properties
  • iterate through your code in the quest for errors
  • amplify any warning you get with a strong Erroraction
  • test with Try/Catch if relevant

Third principle. Decide as late as possible. LEAN philosophy says that you should manage uncertainty by delaying decisions so to be left in the end with more options. This translates to one of the most easily forgotten PowerShell rules:
  • sort and then format the output in a textual way only at the rightmost end of your line of code

Fourth principle. Deliver as fast as possible. In LEAN philosophy, the sooner your deliver, the sooner you get feedback. This means writing your line of code in a way that it doesn't get stuck in a queue that make your cycle time way too long:
  • reduce the scope of your part of your line of code to the functional minimum
  • filter left
  • use jobs when appropriate
  • use runspaces when appropriate

Fifth principle. Empower the team. In LEAN philosophy, the Team is central. So:
  • keep your line of PowerShell code concise
  • don't get lost in details so that anyone can re-use your code without impediments
  • keep the logic of your line of code so clear that anyone feels encouraged to reuse it instead of spending energies reinventing the wheel
  • don't use aliases
  • beware of ambiguous parameters
  • do write concise documentation in form of per-line comments

Sixth principle. Build integrity in. LEAN philosophy regards the conceptual integrity of code as crucial. In PowerShell you need to learn how to balance the code between your pipes in a way that it results in increased:
  • maintainability
  • efficiency
  • flexibility
  • responsiveness

Seventh principle. See the whole. LEAN organization seeks to optimize the whole value stream. So keep in mind that:
  • a task-effective line of code consists of interdependent and interacting parts joined by a purpose
  • the ability of a line of code to achieve its purpose depends on how well the object flow down the pipeline

As you can see, LEAN development emphasizes minimizing waste while optimizing efficiency and maintenability. Adopt it and you'll soon see a positive trend in the quality of your scripts.

Let's now try to achieve this in a real world scenario.

I have been recently contacted by someone who needed help with a script he was writing to retrieve some information from his Active Directory. Though this person had been using PowerShell for quite a few months, he was tackling the task in a confusing way, with a lot of copy/pasted lines of code, Vbs-style variables, and without a clear logic in mind so that he was not getting the needed result.

THE TASK

Check if you have any user whose given name is John and whose user account is enabled and if so return their surname, given name, SID and phone number. Present the list in a dynamic table that allows the user to select the user account to export and save them to a semi-colon separated CSV that has the following fields: Surname, GivenName, UserPrincipalName, DistinguishedName, OfficePhone, SID. That list must be sorted by Surname.

A CONFUSING SOLUTION

$MystrFilter = "(&(objectCategory=User)(GivenName=John)"
$MyDomain = New-Object System.DirectoryServices.DirectoryEntry
$Searcher = New-Object System.DirectoryServices.DirectorySearcher
$Searcher.SearchRoot = $MyDomain
$Searcher.PageSize = 1000
$Searcher.Filter = $MystrFilter
$Searcher.SearchScope = "Subtree"
[void] $search.PropertiesToLoad.Add("GivenName")
$Searcher.FindAll()| %{
         New-Object PSObject -Property @{Name = $_.Properties.GivenName}
} | Out-String | Format-Table * | ConvertTo-CSV | Out-File c:\users.csv
As you can see there is way too much happening here, just to get the first bit of information. Also, this code uses an old syntax which has been superseded by the introduction of the ActiveDirectory module. In other words, we are far from the LEAN principles:


THE WAY TO A LEAN SOLUTION

The first action is to load the module, which you would do with

Import-Module ActiveDirectory
but actually you don't need that because Windows PowerShell has a feature named module auto-loading, which makes this explicit call useless. That's what LEAN calls waste, because it doesn't bring any added value. Just call the right cmdlet for the job and PowerShell will load the corresponding module in the background:

Get-Aduser -Filter * | Where-Object { $_.GivenName -eq 'John' }
Now we are using the right cmdlet for the task, but we are actually breaking the fourth LEAN rule 'Deliver as fast as possible': by filtering right we are retrieving the whole Active Directory before actually doing the filtering. So this should be rewritten as:

Get-ADUser -Filter {(GivenName -eq "John")}
Oh, that's fast.

Now let's try to retrieve the properties we have been asked for:

Get-ADUser -Filter {(GivenName -eq "John")} | Select-Object -Property Surname, GivenName, SID, OfficePhone
Not bad, but we don't need to explicitly name the -Property parameter because it's positional:

-Property
    
    Required?                    false
    Position?                    0
    Default value                None
    Accept pipeline input?       False
    Accept wildcard characters?  false
So this line of code can be improved by removing waste:

Get-ADUser -Filter {(GivenName -eq "John")} | Select-Object Surname, GivenName, SID, OfficePhone
If we run this line of code we can see that the we are going against the second principle: the 'feedback' of this line of code is that the OfficePhone property is empty, so it must be not returned as part of the standard set of properties for a user.

Somehow, we have to force Get-AdUser to return this property:

Get-ADUser -Filter {(GivenName -eq "John")} -Properties *
Right, we got the OfficePhone porperty now, but a lot of other unneeded properties as well. Waste again. And we are also going against the fourth principle because our script becomes slower.

To respect the first and the fourth principle we have to write:

Get-ADUser -Filter {(GivenName -eq "John")} -Properties OfficePhone
Ok, now we have all the users whose given name is John, but we were also asked to filter out those user accounts that are not enabled. This could be achieved with one of these three syntaxes:

Get-ADUser -Filter {(GivenName -eq "John")} | ? enabled
Get-ADUser -Filter {(GivenName -eq "John")} -Properties OfficePhone | Where-Object { $_.enabled -eq '$true' }
Get-ADUser -Filter {(GivenName -eq "John")} | Where-Object enabled
but none is good because
  • in the first case we are using the question mark alias (fifth principle)
  • in the second case, we are using an old redundant syntax, and that's a waste (first principle)
  • in the third case we are filtering twice, on identity on the left of the pipe, and on the Enabled properties on the right of the pipe, so the integrity of our line of code is gone (sixth principle)

Instead we could come up with:

Get-ADUser -Properties OfficePhone -Filter {(GivenName -eq "John") -and (enabled -eq "true")}
but the logic used for parameter positioning is confusing (fifth principle). We better go with:

Get-ADUser -Filter {(GivenName -eq "John") -and (enabled -eq "true")} -Properties OfficePhone
That's all till the first pipe. Now we need to explicitly declare the properties we want to show, add the sorting and let the user choose the users he wants to export.

Select-Object -Property Surname,GivenName,UserPrincipalName,DistinguishedName,OfficePhone,SID | Sort-Object -Property GivenName
Here above we have a special type of waste: even do the fifth principle states that you should not use aliases, there is a de facto rule through PowerShell scripters that allows Select-Object and Sort-Object to be shortened to their verb only: Select and Sort. So this time we can transgress the fifth principle and remove the -Object noun:

Select Surname,GivenName,UserPrincipalName,DistinguishedName,OfficePhone,SID | Sort GivenName
We can now pipe this into Out-Gridview with the -Passthru parameter, so that the end user can click on the users he wants to export and then press the Enter key to send them down to Export-CSV:

Out-GridView -PassThru | Export-Csv -Path C:\users.csv -Delimiter ';'
Since the first principle says that we can reduce waste by relying on positional parameters, we can shorten the code of Export-CSV. Luckily in fact, both -Path and -Delimiter are positional parameters:

-Path 
    
    Required?                    false
    Position?                    0
    Default value                None
    Accept pipeline input?       False
    Accept wildcard characters?  false
-Delimiter 
    
    Required?                    false
    Position?                    1
    Default value                None
    Accept pipeline input?       False
    Accept wildcard characters?  false
Here's what we got:

Out-GridView -PassThru | Export-Csv C:\users.csv ';'
Now the whole code is working but the resulting line of code is way too long. We can improve its reusability by splitting it at the pipes, which will also give us the occasion to put some concise comments:

Get-ADUser -Filter {(GivenName -eq "John") -and (enabled -eq "true")} -Properties OfficePhone |

    select Surname,GivenName,UserPrincipalName,DistinguishedName,OfficePhone,SID |
    
    sort Surname |
    
    Out-GridView -PassThru | # this allows the user to select some items and hand them over to the next cmdlet
    
    Export-Csv C:\users.csv ';'
As you can see, we have been able to apply all the LEAN principles to a script that was just a broken and confusing piece of code. And during this process, we have engineered our solution according to the second principle: we have iterated through our code trying to make it work error-free and we have amplified our knowledge of the whole process.

The result is a piece of code that can be easily reused without impediment: Lean Development applied to PowerShell.

Now I am really looking forward to feedback on this article: take it as a draft that I am willing to improve with the help of the Community.

Be Agile. Be PowerShell.

Thursday, March 2, 2017

A PowerShell function to rapidly gather system events for sysadmin eyes only with some tips

I suppose that we, sysadmins, have all been through that moment when an application developer bursts in behind your back just to tell you that his perfectly-coded application is getting stuck and he goes on stating that for sure something has happened at system level and you should already be checking.

This is the moment when being good at PowerShell comes to the rescue. Because with PowerShell you can quickly write a tool that allows you to check your event logs and extract just the right kind of information to show that the system has no issues (as it's often the case) and that he better be reviewing his software configuration.

PowerShell basically is your Splunk, but without the price tag.




That's the topic I am going to talk about in this post: I am going to show you how you can use PowerShell to gather event logs quickly from one or more computers, no matter the Windows version, and build a report of recent system issues, excluding each and every event coming from the upper application layers.

The first step here is to understand that there are two worlds: servers running Windows versions till Windows 2003 R2, and servers running Windows 2008 and above. These two types of servers have different engines for event logging and therefore Microsoft has provided two different cmdlets.

AN HISTORY OF TWO CMDLETS

The first cmdlet is Get-EventLog and is used with servers running Windows 2003 R2. I am sure you still have a few of those running. I do, so I have to take them into consideration when developing my function.

The second cmdlet is Get-WinEvent, which is used on newer system and, despite the fact that it runs much faster than Get-EventLog, it can't be used to check older systems.

So you have to write this function in a way that it first checks for the possibility to query the remote server using Get-WinEvent, and if it fails, it has to fail back to Get-EventLog.

LOGTYPE AND LOGISOLATION FOR THE WIN

Before you write the Get-WinEvent part, there are a few things to understand. As I said the aim of the function is to allow the system administrator to extract only the events that are related to the operating system. To do so, you have first to build a query to retrieve all of your logs:


After researching a bit, I have come to understanding that I have to rely mainly on two properties which I have highlighted in the screenshot above: LogType and LogIsolation.

LogType tells you the type of events that are logged in each log and in our case we want to just stick to Administrative events. This includes events from classic event logs, like System, Security or Application, and other interesting logs such as 'Microsoft-Windows-Hyper-V-Worker-Admin' or 'Microsoft-Windows-Kernel-EventTracing/Admin'.

Now unfortunately there are dozens of event logs that record administrative events, and we need to refine that list more if we want our function to include only events that actually may indicate a system issue.

This is where enters the game the LogIsolation property. This property indicates which ACL and which ETW sessions each event log is using: in our case we want to filter out all the logs that share the access control list with the Application event log as well as all the event logs that share the ETW session with other logs that have Application isolation.

Setting Get-WinEvent to filter on LogIsolation -eq 'system' will guarantee that we are not checking events that have been written by the applications.

Once we filter on those two properties, with the following line of code

Get-WinEvent -ListLog *| ? {($_.logtype -eq 'administrative') -and ($_.logisolation -eq 'system')}
we get a much shorter list of logs, and all of them are clearly under the responsibility of the system administrator:


No we have to find a way to perform this very same operation with Get-EventLog. That's easily accomplished, since we just have to choose one between three classic event logs: System, Application or Security.

For our purpose, System is the log we need.

Now there is a well-known issue with Get-EventLog: it is dramatically slow since it's been designed in a way that it retrieves the whole event log starting from the oldest record each time it's queried. That can lead to a painfully slow checkup of your environment if you are running your function against a large number of servers.

BOOSTING GET-EVENTLOG PERFORMANCE

The hint I can give you here is make it work the other way around by adding the -Newest parameter followed by the number of records you want to get: this forces Get-EventLog to start from the most recent event and to access the most recent ones by their unique index until it gets to fetch the asked number of items:

As you can test, with the -Newest parameter the query will be executed very fast. In my function I set its value to 1000 so I am pretty sure no recent critical events are being left over.

FILTER RIGHT (Yes, you read well)

But there is a problem: as you have understood, we are trying to build a tool that allows the system administrator to check if there is any kind of system issue, so we have to be able to limit the search window to the most recent hours. In Get-EventLog this is normally achieved with the -After parameter but the drawback of adding it is that it overrides the functioning of -Newest by bringing back the older-to-newer query mechanism. The performance impact is impressive:

"Newest With -after"
(Measure-Command { Get-EventLog -LogName System -Newest 1000 -After (Get-Date).AddHours(-24) }).TotalSeconds

"Newest with a Where-Object filtering"
(Measure-Command { Get-EventLog -LogName System -Newest 1000 | ? TimeGenerated -gt (Get-Date).AddHours(-24) }).TotalSeconds

Newest With -after
30.50407
Newest with a Where-Object filtering
0.8548345
That's one of the rare cases where right-filtering is faster than left-filtering.

Now we have all the required knowledge around Get-EventLog and Get-WinEvent to retrieve administrative events pretty quickly.

Before we continue, we have to understand one more thing: these two cmdlets bring back different object types which have different properties. Since our tool must be able to consolidate events from systems running possibly different versions of Windows, we need a way to match the property names brought back from those cmdlets.

CALCULATED PROPERTIES

A mechanism known as 'Calculated properties' is our ally here. We can use Select-Object to translate property names for objects returned from Get-EventLog to property names returned by Get-WinEvent:


Here we are basically translating TimeGenerated to TimeCreated, Source to ProviderName, EventId to Id and EntryType to LevelDisplayName. This way all the objects coming through our function will have the same property set and filtering will be done in a breeze.

EXCEPTIONS HANDLING

Any tool must be able to handle exceptions. This is achieved in PowerShell with Try/Catch. My experience is that the use of Get-WinEvent can raise three main exceptions. Here's the first two:

A target server that can't be reached:

Get-WinEvent -LogName System -ComputerName nobody
Get-WinEvent : The RPC server is unavailable
At line:1 char:1
+ Get-WinEvent -LogName System -ComputerName nobody
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Get-WinEvent], EventLogException
A target server that runs Windows 2003:

Get-WinEvent -LogName System -ComputerName IamWindows2003
Get-WinEvent : There are no more endpoints available from the endpoint mapper
At line:1 char:1
+ Get-WinEvent -LogName System -ComputerName IamWIndows2003
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Get-WinEvent], EventLogException
Both these exceptions are of type [System.Diagnostics.Eventing.Reader.EventLogException].

A third exception is raised by Get-WinEvent when no events are found for the given criteria:

Get-WinEvent -FilterHashtable @{LoGName='System';StartTime=(Get-Date).AddHours(-1)}
Get-WinEvent : No events were found that match the specified selection criteria.
At line:1 char:1
+ Get-WinEvent -FilterHashtable @{LoGName='System';StartTime=(Get-Date) ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (:) [Get-WinEvent], Exception
This looks to me like something that should evolve in PowerShell, since I don't see why having no events returned deserves being considered as an error. Here's you can see the source code of Get-WinEvent, which is where the exception is returned:


CONDITIONAL GET-EVENTLOG

As I said at the beginning of this post, we must run Get-EventLog only if Get-WinEvent fails with the exception whose message is 'There are no more endpoints available from the endpoint mapper'. This is how I accomplished this, with Catch then a pattern matching performed with Switch on the exception message:


GET-UNIQUE

There is one thing we really don't want our tool to do: returning three thousands times the same event happening over and over. It would make our report completely useless because no lazy sysadmin is going to scroll through an endless list of repeated events. Ever.

So the last part of our function is in charge of consolidating all identical events for a server into one and return just the date of the most recent occurrence.

The logic is to group by event id, then to sort by generation time in descending order, then select only the first item.

Here's a screenshot of that part of code. It's pretty easy so I won't go deeper in explaining it:


RUNSPACES, AGAIN

I have already showed the power of runspaces and of the PoshRSJob module by Microsoft MVP Boe Prox in a recent blog post. Even in the case of a tool for event retrieval, this is the module to have on your administration box. Nesting my function, which in the end I named Get-AdministrativeEvent into a Runspacepool makes the generation of a report of all my systems run so fast I can't even finish drinking my cup of coffee:

$Report = Start-RSJob -Throttle 20 -Verbose -InputObject ((Get-ADComputer -server dc01 -filter {(name -notlike 'win7*') -AND (OperatingSystem -Like "*Server*")} -searchbase "OU=SRV,DC=Domain,DC=Com").name) -FunctionsToLoad Get-AdministrativeEvent -ScriptBlock {Get-AdministrativeEvent $_ -HoursBack 3 -Credential $using:cred -Verbose} | Wait-RSJob -Verbose -ShowProgress | Receive-RSJob -Verbose

$Report | sort timecreated -descending | Out-GridView

JUST GET ME THE CODE

Here's the whole code for the function (which you can also find on my Github).

function Get-AdministrativeEvent {

<#
.Synopsis
The Get-AdministrativeEvent function retrieves the last critical administrative events on a local or remote computer
.EXAMPLE
Get-AdministrativeEvent -cred (get-credential domain\admin) -ComputerName srv01 -HoursBack 1
.EXAMPLE
$cred = get-credential
Get-AdministrativeEvent -cred $cred -ComputerName srv01 -HoursBack 24 | Sort-Object timecreated -Descending | Out-Gridview
.EXAMPLE
'srv01','srv02' | % { Get-AdministrativeEvent -HoursBack 1 -cred $cred -ComputerName $_ } | Sort-Object timecreated -Descending | ft * -AutoSize
.EXAMPLE
Get-AdministrativeEvent -HoursBack 36 -ComputerName (Get-ADComputer -filter *).name | sort timecreated -Descending | Out-GridView
.EXAMPLE
Get-AdministrativeEvent -cred $cred -ComputerName 'srv01','srv02' -HoursBack 12 | Out-Gridview
.EXAMPLE
$Report = Start-RSJob -Throttle 20 -Verbose -InputObject ((Get-ADComputer -server dc01 -filter {(name -notlike 'win7*') -AND (OperatingSystem -Like "*Server*")} -searchbase "OU=SRV,DC=Domain,DC=Com").name) -FunctionsToLoad Get-AdministrativeEvent -ScriptBlock {Get-AdministrativeEvent $_ -HoursBack 3 -Credential $using:cred -Verbose} | Wait-RSJob -Verbose -ShowProgress | Receive-RSJob -Verbose
$Report | sort timecreated -descending | Out-GridView
.EXAMPLE
$Servers = ((New-Object -typename ADSISearcher -ArgumentList @([ADSI]"LDAP://domain.com/dc=domain,dc=com","(&(&(sAMAccountType=805306369)(objectCategory=computer)(operatingSystem=*Server*)))")).FindAll()).properties.name
$Report = Start-RSJob -Throttle 20 -Verbose -InputObject $Servers -FunctionsToLoad Get-AdministrativeEvent -ScriptBlock {Get-AdministrativeEvent $_ -Credential $using:cred -HoursBack 48 -Verbose} | Wait-RSJob -Verbose -ShowProgress | Receive-RSJob -Verbose
$Report | format-table * -AutoSize
.NOTES
happysysadm.com
@sysadm2010
#>

    [CmdletBinding()]
    Param
    (
        # List of computers
        [Parameter(Mandatory=$true,
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [Alias('Name','CN')] 
        [string[]]$ComputerName,

        # Specifies a user account that has permission to perform this action
        [Parameter(Mandatory=$false)]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [System.Management.Automation.PSCredential]::Empty,

        #Number of hours to go back to when retrieving events
        [int]$HoursBack = 1

    )

    Begin

        {

        Write-Verbose "$(Get-Date) - Started."

        $AllResults = @()

        }
    
    Process
        {

        foreach($Computer in $ComputerName) {
    
            $Result = $Null

            write-verbose "$(Get-Date) - Working on $Computer - Eventlog"

            $starttime = (Get-Date).AddHours(-$HoursBack)
    
            try {

                write-verbose "$(Get-Date) - Trying with Get-WinEvent"
    
                $result = Get-WinEvent -ErrorAction stop -Credential $credential -ComputerName $Computer -filterh @{LogName=(Get-WinEvent -Computername $Computer -ListLog *| ? {($_.logtype -eq 'administrative') -and ($_.logisolation -eq 'system')} | ? recordcount).logname;StartTime=$starttime;Level=1,2} | select machinename,timecreated,providername,logname,id,leveldisplayname,message

                }

            catch [System.Diagnostics.Eventing.Reader.EventLogException] {
        
                switch -regex ($_.Exception.Message) {

                    "RPC" { 
            
                        Write-Warning "$(Get-Date) - RPC error while communicating with $Computer"
                
                        $Result = 'RPC error'
                
                        }
        
                    "Endpoint" { 
            
                        write-verbose "$(Get-Date) - Trying with Get-EventLog for systems older than Windows 2008"

                        try { 
                
                            $sysevents = Get-EventLog -ComputerName $Computer -LogName system -Newest 1000 -EntryType Error -ErrorAction Stop | `

                                            ? TimeGenerated -gt $starttime | `

                                            select MachineName,
                                            
                                                   @{Name='TimeCreated';Expression={$_.TimeGenerated}},
                                                   
                                                   @{Name='ProviderName';Expression={$_.Source}},
                                                   
                                                   LogName,
                                                   
                                                   @{Name='Id';Expression={$_.EventId}},
                                                   
                                                   @{Name='LevelDisplayName';Expression={$_.EntryType}},
                                                   
                                                   Message

                            if($sysevents) {

                                $result = $sysevents

                                }

                            else {

                                Write-Warning "$(Get-Date) - No events found on $Computer"
                        
                                $result = 'none'

                                }
                    
                            }

                        catch { $Result = 'error' }
                
                        }

                    Default { Write-Warning "$(Get-Date) - Error retrieving events from $Computer" }
                
                    }

                }
        
            catch [Exception] {
        
                Write-Warning "$(Get-Date) - No events found on $Computer"
        
                $result = 'none'

                }

        if(($result -ne 'error') -and ($result -ne 'RPC error') -and ($result -ne 'none')) {

            Write-Verbose "$(Get-Date) - Consolidating events for $Computer"
            
            $lastuniqueevents = $null

            $lastuniqueevents = @()
            
            $ids = ($result | select id -unique).id

            foreach($id in $ids){

                $machineevents = $result | ? id -eq $id

                $lastuniqueevents += $machineevents | sort timecreated -Descending | select -first 1

                }

            $AllResults += $lastuniqueevents

            }
    
        }

    }

    End {
        
        Write-Verbose "$(Get-Date) - Finished."
    
        $AllResults
        
        }

}
Let me go if you have any idea on how to improve it, or if you have any question, do not hesitate to ask.
Related Posts Plugin for WordPress, Blogger...