OpenEndpointEvents.psm1
|
# OpenEndpointEvents.psm1 # Lightweight PowerShell module for writing user-defined endpoint events as daily NDJSON files. Set-StrictMode -Version Latest $script:DefaultLogRoot = "C:\ProgramData\OpenEndpointEvents\Logs" $script:DefaultLogName = "endpoint-events.ndjson" function Get-EndpointEventMutexName { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Path ) $normalizedPath = $Path.ToLowerInvariant() $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalizedPath) $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $hashBytes = $sha256.ComputeHash($bytes) $hash = ([System.BitConverter]::ToString($hashBytes)).Replace("-", "") return "Global\OpenEndpointEvents_$hash" } finally { $sha256.Dispose() } } <# .SYNOPSIS Converts a string into a filesystem-safe value. .DESCRIPTION Replaces characters that are unsafe or undesirable in filenames with underscores. Useful when generating log filenames from dynamic values such as computer name, serial number, source name, event name, room name, site name, or asset tag. .PARAMETER Value The string value to sanitize. .EXAMPLE ConvertTo-SafeFilePart -Value "endpoint/001:health check" Returns a sanitized value suitable for use in a filename. .EXAMPLE $safeComputerName = ConvertTo-SafeFilePart -Value $env:COMPUTERNAME Converts the local computer name into a safe filename component. .OUTPUTS System.String #> function ConvertTo-SafeFilePart { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Value ) if ([string]::IsNullOrWhiteSpace($Value)) { return "Unknown" } return ($Value -replace '[^a-zA-Z0-9\-_\.]', '_') } <# .SYNOPSIS Normalizes an endpoint event level. .DESCRIPTION Converts common level aliases into consistent uppercase endpoint event levels. Normalized values include: - INFO - WARN - ERROR - DEBUG - TRACE - FATAL .PARAMETER Level The event level value to normalize. .EXAMPLE ConvertTo-EndpointEventLevel -Level "warning" Returns: WARN .EXAMPLE ConvertTo-EndpointEventLevel -Level "Information" Returns: INFO .EXAMPLE ConvertTo-EndpointEventLevel -Level "critical" Returns: FATAL .OUTPUTS System.String #> function ConvertTo-EndpointEventLevel { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Level ) switch -Regex ($Level.Trim().ToUpperInvariant()) { '^(INFO|INFORMATION)$' { return "INFO" } '^(WARN|WARNING)$' { return "WARN" } '^(ERR|ERROR)$' { return "ERROR" } '^(DEBUG)$' { return "DEBUG" } '^(TRACE)$' { return "TRACE" } '^(FATAL|CRITICAL)$' { return "FATAL" } default { return $Level.Trim().ToUpperInvariant() } } } <# .SYNOPSIS Converts structured input data into ordered endpoint event data. .DESCRIPTION Accepts hashtables, ordered dictionaries, PSCustomObjects, or simple objects and converts them into a consistent ordered structure suitable for merging into an endpoint event. .PARAMETER Data Structured data to include in an endpoint event. .EXAMPLE ConvertTo-EndpointEventData -Data @{ AssetTag = "C001HT"; Room = "B12" } Converts a hashtable into ordered endpoint event data. .EXAMPLE $object = [pscustomobject]@{ MachineName = "endpoint-001" AssetTag = "C001HT" } ConvertTo-EndpointEventData -Data $object Converts a PSCustomObject into ordered endpoint event data. .EXAMPLE ConvertTo-EndpointEventData -Data "simple value" Converts a simple value into an object with a Data property. .OUTPUTS System.Collections.Specialized.OrderedDictionary #> function ConvertTo-EndpointEventData { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [object]$Data ) if ($null -eq $Data) { return [ordered]@{} } if ($Data -is [hashtable]) { $ordered = [ordered]@{} foreach ($key in $Data.Keys) { $ordered[[string]$key] = $Data[$key] } return $ordered } if ($Data -is [System.Collections.Specialized.OrderedDictionary]) { $ordered = [ordered]@{} foreach ($key in $Data.Keys) { $ordered[[string]$key] = $Data[$key] } return $ordered } if ($Data -is [pscustomobject]) { $ordered = [ordered]@{} foreach ($property in $Data.PSObject.Properties) { $ordered[$property.Name] = $property.Value } return $ordered } return [ordered]@{ Data = $Data } } <# .SYNOPSIS Gets basic endpoint identity information. .DESCRIPTION Collects common endpoint identity fields from WMI/CIM, including: - ComputerName - SerialNumber - Manufacturer - Model - OSVersion - OSBuild - Domain This function is used by Write-EndpointEvent when -IncludeEndpointIdentity is specified. .EXAMPLE Get-EndpointIdentity Returns endpoint identity information for the current machine. .EXAMPLE $identity = Get-EndpointIdentity $identity.SerialNumber Gets the local endpoint serial number. .EXAMPLE $identity = Get-EndpointIdentity Write-EndpointInfo ` -Message "Identity captured" ` -Data @{ ComputerName = $identity.ComputerName SerialNumber = $identity.SerialNumber Model = $identity.Model } Writes endpoint identity details as a custom event. .OUTPUTS PSCustomObject #> function Get-EndpointIdentity { [CmdletBinding()] param() $serialNumber = "UnknownSerial" $manufacturer = $null $model = $null $osVersion = $null $osBuild = $null $domain = $null try { $bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($bios.SerialNumber)) { $serialNumber = $bios.SerialNumber } } catch {} try { $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop $manufacturer = $cs.Manufacturer $model = $cs.Model $domain = $cs.Domain } catch {} try { $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop $osVersion = $os.Version $osBuild = $os.BuildNumber } catch {} [pscustomobject]@{ ComputerName = $env:COMPUTERNAME SerialNumber = ($serialNumber -replace '[^a-zA-Z0-9\-_]', '_') Manufacturer = $manufacturer Model = $model OSVersion = $osVersion OSBuild = $osBuild Domain = $domain } } <# .SYNOPSIS Creates a standard OpenEndpointEvents NDJSON log file path. .DESCRIPTION Generates a log file path under the specified log root. The filename can optionally include: - Current date - BIOS serial number - Computer name The generated file extension is always .ndjson if no .ndjson extension is provided. .PARAMETER Name The base log filename. Defaults to endpoint-events.ndjson. .PARAMETER LogRoot The directory where logs are written. Defaults to C:\ProgramData\OpenEndpointEvents\Logs. .PARAMETER IncludeDate Adds the current date to the filename using yyyyMMdd format. .PARAMETER IncludeComputerName Adds the local computer name to the filename. .PARAMETER IncludeSerialNumber Adds the BIOS serial number to the filename. .EXAMPLE New-EndpointEventLogPath Creates a default path: C:\ProgramData\OpenEndpointEvents\Logs\endpoint-events.ndjson .EXAMPLE New-EndpointEventLogPath -Name "health.ndjson" -IncludeDate -IncludeComputerName Creates a path similar to: C:\ProgramData\OpenEndpointEvents\Logs\20260619-LAB-PC-001-health.ndjson .EXAMPLE $logPath = New-EndpointEventLogPath ` -Name "asset-inventory" ` -IncludeDate ` -IncludeSerialNumber ` -IncludeComputerName Creates a date, serial, and computer-specific NDJSON log path. .EXAMPLE $logPath = New-EndpointEventLogPath -Name "room-b12" -LogRoot "D:\EndpointEvents" -IncludeDate Creates a custom daily log path under D:\EndpointEvents. .OUTPUTS System.String #> function New-EndpointEventLogPath { [CmdletBinding()] param( [string]$Name = $script:DefaultLogName, [string]$LogRoot = $script:DefaultLogRoot, [switch]$IncludeDate, [switch]$IncludeComputerName, [switch]$IncludeSerialNumber ) if (-not (Test-Path -Path $LogRoot)) { New-Item -Path $LogRoot -ItemType Directory -Force | Out-Null } $safeName = ConvertTo-SafeFilePart -Value $Name if ($safeName -notmatch '\.ndjson$') { $safeName = "$safeName.ndjson" } $nameWithoutExtension = [System.IO.Path]::GetFileNameWithoutExtension($safeName) $extension = [System.IO.Path]::GetExtension($safeName) $parts = New-Object System.Collections.Generic.List[string] if ($IncludeDate) { $parts.Add((Get-Date -Format "yyyyMMdd")) } if ($IncludeSerialNumber -or $IncludeComputerName) { $identity = Get-EndpointIdentity if ($IncludeSerialNumber) { $parts.Add((ConvertTo-SafeFilePart -Value $identity.SerialNumber)) } if ($IncludeComputerName) { $parts.Add((ConvertTo-SafeFilePart -Value $identity.ComputerName)) } } $parts.Add($nameWithoutExtension) $fileName = (($parts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join "-") + $extension Join-Path -Path $LogRoot -ChildPath $fileName } <# .SYNOPSIS Writes a generic structured endpoint event to an NDJSON file. .DESCRIPTION Writes one compressed JSON object per line to an NDJSON file. Supports: - INFO, WARN, ERROR, DEBUG, TRACE, FATAL levels - Plain text messages - Arbitrary structured data - Endpoint identity enrichment - Process/user enrichment - Shared correlation IDs - Mutex-based safe concurrent writes - Daily endpoint event files The function returns the path to the log file used. .PARAMETER Path Explicit path to the NDJSON log file. If omitted, a standard daily path is generated using New-EndpointEventLogPath. .PARAMETER LogRoot Directory where logs are written when Path is not specified. Defaults to C:\ProgramData\OpenEndpointEvents\Logs. .PARAMETER Name Base log filename when Path is not specified. Defaults to endpoint-events.ndjson. .PARAMETER Level Event level. Common values: - INFO - WARN - ERROR - DEBUG - TRACE - FATAL Aliases such as Information, Warning, Err, and Critical are normalized. .PARAMETER Message Human-readable message for the endpoint event. .PARAMETER Data Optional structured data. Accepts: - Hashtable - Ordered hashtable - PSCustomObject - Simple object .PARAMETER EventName Optional event name, such as DiskCheck, UploadStarted, InventoryCollected, or TaskFailed. .PARAMETER Source Optional source name, such as HealthCheck, BlobUploader, InventoryScript, ScheduledTask, or ClassroomBaseline. .PARAMETER CorrelationId Optional shared ID used to group related events. If omitted, a new GUID is generated for the event. .PARAMETER IncludeEndpointIdentity Adds endpoint identity fields: - ComputerName - SerialNumber - Manufacturer - Model - OSVersion - OSBuild - Domain .PARAMETER IncludeProcessInfo Adds process execution fields: - ProcessId - ProcessName - UserName .PARAMETER Depth JSON serialization depth. Defaults to 20. .EXAMPLE Write-EndpointEvent -Level INFO -Message "Script started" Writes a basic INFO event to the default daily NDJSON log path. .EXAMPLE Write-EndpointEvent -Level WARN -Message "Disk space below threshold" Writes a basic WARN event. .EXAMPLE Write-EndpointEvent -Level ERROR -Message "Blob upload failed" Writes a basic ERROR event. .EXAMPLE Write-EndpointEvent ` -Level INFO ` -Message "Asset inventory captured" ` -Data @{ MachineName = "endpoint-001" AssetTag = "C001HT" Room = "B12" Site = "Auckland" } Writes a structured inventory event. .EXAMPLE Write-EndpointEvent ` -Level INFO ` -Source "HealthCheck" ` -EventName "DiskCheckCompleted" ` -Message "Disk check completed" ` -IncludeEndpointIdentity ` -Data @{ Drive = "C:" FreeGB = 42.7 TotalGB = 237.8 PercentFree = 17.9 Status = "Healthy" } Writes a structured health check event with endpoint identity. .EXAMPLE $correlationId = "20260619-DAILY-HEALTHCHECK" Write-EndpointEvent ` -Level INFO ` -Source "HealthCheck" ` -EventName "Started" ` -Message "Daily health check started" ` -CorrelationId $correlationId Write-EndpointEvent ` -Level INFO ` -Source "HealthCheck" ` -EventName "Completed" ` -Message "Daily health check completed" ` -CorrelationId $correlationId ` -Data @{ Status = "Success" } Writes multiple related events with the same correlation ID. .EXAMPLE $logPath = New-EndpointEventLogPath ` -Name "asset-inventory" ` -IncludeDate ` -IncludeSerialNumber ` -IncludeComputerName Write-EndpointEvent ` -Path $logPath ` -Level INFO ` -Message "Asset inventory captured" ` -Data @{ AssetTag = "C001HT" } Writes to an explicitly generated log path. .EXAMPLE Write-EndpointEvent ` -Level INFO ` -Message "Scheduled task started" ` -IncludeEndpointIdentity ` -IncludeProcessInfo ` -Data @{ TaskName = "OpenEndpointEvents Upload" } Writes an event with endpoint identity and process context. .OUTPUTS System.String #> function Write-EndpointEvent { [CmdletBinding()] param( [string]$Path, [ValidateNotNullOrEmpty()] [string]$LogRoot = $script:DefaultLogRoot, [string]$Name = $script:DefaultLogName, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Level, [string]$Message, [object]$Data, [string]$EventName, [string]$Source, [string]$CorrelationId, [switch]$IncludeEndpointIdentity, [switch]$IncludeProcessInfo, [int]$Depth = 20 ) if ([string]::IsNullOrWhiteSpace($Path)) { $Path = New-EndpointEventLogPath ` -Name $Name ` -LogRoot $LogRoot ` -IncludeDate ` -IncludeSerialNumber ` -IncludeComputerName } $directory = Split-Path -Path $Path -Parent if (-not (Test-Path -Path $directory)) { New-Item -Path $directory -ItemType Directory -Force | Out-Null } if ([string]::IsNullOrWhiteSpace($CorrelationId)) { $CorrelationId = [guid]::NewGuid().ToString() } $entry = [ordered]@{ Timestamp = (Get-Date).ToString("o") Level = ConvertTo-EndpointEventLevel -Level $Level Message = $Message EventName = $EventName Source = $Source CorrelationId = $CorrelationId } if ($IncludeEndpointIdentity) { $identity = Get-EndpointIdentity $entry["ComputerName"] = $identity.ComputerName $entry["SerialNumber"] = $identity.SerialNumber $entry["Manufacturer"] = $identity.Manufacturer $entry["Model"] = $identity.Model $entry["OSVersion"] = $identity.OSVersion $entry["OSBuild"] = $identity.OSBuild $entry["Domain"] = $identity.Domain } if ($IncludeProcessInfo) { $process = Get-Process -Id $PID -ErrorAction SilentlyContinue $processName = $null if ($null -ne $process) { $processName = $process.ProcessName } $entry["ProcessId"] = $PID $entry["ProcessName"] = $processName $entry["UserName"] = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } $structuredData = ConvertTo-EndpointEventData -Data $Data foreach ($key in $structuredData.Keys) { if ($entry.Contains($key)) { $entry["Data_$key"] = $structuredData[$key] } else { $entry[$key] = $structuredData[$key] } } $json = $entry | ConvertTo-Json -Compress -Depth $Depth $mutexName = Get-EndpointEventMutexName -Path $Path $mutex = New-Object System.Threading.Mutex($false, $mutexName) try { $lockAcquired = $mutex.WaitOne(30000) if (-not $lockAcquired) { throw "Timed out waiting for log file lock: $Path" } $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::AppendAllText($Path, $json + [Environment]::NewLine, $utf8NoBom) } finally { try { $mutex.ReleaseMutex() } catch {} $mutex.Dispose() } return $Path } <# .SYNOPSIS Writes an INFO-level endpoint event. .DESCRIPTION Convenience wrapper around Write-EndpointEvent that writes with Level set to INFO. .PARAMETER Message Human-readable message for the endpoint event. .PARAMETER Data Optional structured data to merge into the JSON event. .PARAMETER Path Explicit path to the NDJSON log file. .PARAMETER Name Base log filename when Path is not specified. .PARAMETER LogRoot Directory where logs are written when Path is not specified. .PARAMETER EventName Optional event name. .PARAMETER Source Optional source name. .PARAMETER CorrelationId Optional shared ID used to group related events. .PARAMETER IncludeEndpointIdentity Adds endpoint identity fields. .PARAMETER IncludeProcessInfo Adds process execution fields. .EXAMPLE Write-EndpointInfo -Message "Script started" Writes a simple INFO event. .EXAMPLE Write-EndpointInfo ` -Source "Inventory" ` -EventName "AssetCaptured" ` -Message "Asset inventory captured" ` -Data @{ MachineName = "endpoint-001" AssetTag = "C001HT" Room = "B12" } Writes a structured INFO event. .EXAMPLE Write-EndpointInfo ` -Message "Scheduled task started" ` -IncludeEndpointIdentity ` -IncludeProcessInfo Writes an INFO event with endpoint and process details. .OUTPUTS System.String #> function Write-EndpointInfo { [CmdletBinding()] param( [string]$Message, [object]$Data, [string]$Path, [string]$Name = $script:DefaultLogName, [string]$LogRoot = $script:DefaultLogRoot, [string]$EventName, [string]$Source, [string]$CorrelationId, [switch]$IncludeEndpointIdentity, [switch]$IncludeProcessInfo ) Write-EndpointEvent ` -Path $Path ` -Name $Name ` -LogRoot $LogRoot ` -Level "INFO" ` -Message $Message ` -Data $Data ` -EventName $EventName ` -Source $Source ` -CorrelationId $CorrelationId ` -IncludeEndpointIdentity:$IncludeEndpointIdentity ` -IncludeProcessInfo:$IncludeProcessInfo } <# .SYNOPSIS Writes a WARN-level endpoint event. .DESCRIPTION Convenience wrapper around Write-EndpointEvent that writes with Level set to WARN. .PARAMETER Message Human-readable warning message. .PARAMETER Data Optional structured data to merge into the JSON event. .PARAMETER Path Explicit path to the NDJSON log file. .PARAMETER Name Base log filename when Path is not specified. .PARAMETER LogRoot Directory where logs are written when Path is not specified. .PARAMETER EventName Optional event name. .PARAMETER Source Optional source name. .PARAMETER CorrelationId Optional shared ID used to group related events. .PARAMETER IncludeEndpointIdentity Adds endpoint identity fields. .PARAMETER IncludeProcessInfo Adds process execution fields. .EXAMPLE Write-EndpointWarn -Message "Disk space below threshold" Writes a simple WARN event. .EXAMPLE Write-EndpointWarn ` -Source "HealthCheck" ` -EventName "LowDiskSpace" ` -Message "Free disk space is below threshold" ` -IncludeEndpointIdentity ` -Data @{ Drive = "C:" FreeGB = 8.2 ThresholdGB = 10 Status = "Warning" } Writes a structured WARN event. .EXAMPLE Write-EndpointWarn ` -Source "ClassroomBaseline" ` -EventName "ConfigurationDriftDetected" ` -Message "Classroom endpoint configuration drift detected" ` -Data @{ Room = "B12" Setting = "WallpaperLock" Expected = "Enabled" Actual = "Disabled" Status = "NonCompliant" } Writes a warning event for an education computer room baseline check. .OUTPUTS System.String #> function Write-EndpointWarn { [CmdletBinding()] param( [string]$Message, [object]$Data, [string]$Path, [string]$Name = $script:DefaultLogName, [string]$LogRoot = $script:DefaultLogRoot, [string]$EventName, [string]$Source, [string]$CorrelationId, [switch]$IncludeEndpointIdentity, [switch]$IncludeProcessInfo ) Write-EndpointEvent ` -Path $Path ` -Name $Name ` -LogRoot $LogRoot ` -Level "WARN" ` -Message $Message ` -Data $Data ` -EventName $EventName ` -Source $Source ` -CorrelationId $CorrelationId ` -IncludeEndpointIdentity:$IncludeEndpointIdentity ` -IncludeProcessInfo:$IncludeProcessInfo } <# .SYNOPSIS Writes an ERROR-level endpoint event. .DESCRIPTION Convenience wrapper around Write-EndpointEvent that writes with Level set to ERROR. Can also accept a PowerShell ErrorRecord and flatten useful error details into the JSON event. .PARAMETER Message Human-readable error message. .PARAMETER Data Optional structured data to merge into the JSON event. .PARAMETER Path Explicit path to the NDJSON log file. .PARAMETER Name Base log filename when Path is not specified. .PARAMETER LogRoot Directory where logs are written when Path is not specified. .PARAMETER EventName Optional event name. .PARAMETER Source Optional source name. .PARAMETER CorrelationId Optional shared ID used to group related events. .PARAMETER IncludeEndpointIdentity Adds endpoint identity fields. .PARAMETER IncludeProcessInfo Adds process execution fields. .PARAMETER ErrorRecord PowerShell error record, usually passed from a catch block using $_. .EXAMPLE Write-EndpointError -Message "Upload failed" Writes a simple ERROR event. .EXAMPLE try { Get-Item "C:\Does\Not\Exist" -ErrorAction Stop } catch { Write-EndpointError ` -Source "FileCheck" ` -EventName "PathAccessFailed" ` -Message "Failed to access path" ` -ErrorRecord $_ ` -Data @{ Path = "C:\Does\Not\Exist" } ` -IncludeEndpointIdentity } Writes an ERROR event with exception details from a catch block. .EXAMPLE Write-EndpointError ` -Source "BlobUploader" ` -EventName "UploadFailed" ` -Message "Blob upload failed" ` -Data @{ BlobName = "endpoint-events.ndjson" StatusCode = 403 Reason = "SAS token expired" } Writes a structured ERROR event. .EXAMPLE $correlationId = "20260619-ROOM-B12-BASELINE" Write-EndpointError ` -Source "ClassroomBaseline" ` -EventName "BaselineFailed" ` -Message "Endpoint failed classroom baseline validation" ` -CorrelationId $correlationId ` -IncludeEndpointIdentity ` -Data @{ Room = "B12" AssetTag = "C001HT" FailedCheck = "RequiredApplication" Application = "Exam Browser" Status = "Failed" } Writes a correlated baseline failure event. .OUTPUTS System.String #> function Write-EndpointError { [CmdletBinding()] param( [string]$Message, [object]$Data, [string]$Path, [string]$Name = $script:DefaultLogName, [string]$LogRoot = $script:DefaultLogRoot, [string]$EventName, [string]$Source, [string]$CorrelationId, [switch]$IncludeEndpointIdentity, [switch]$IncludeProcessInfo, [System.Management.Automation.ErrorRecord]$ErrorRecord ) $errorData = [ordered]@{} if ($Data) { $inputData = ConvertTo-EndpointEventData -Data $Data foreach ($key in $inputData.Keys) { $errorData[$key] = $inputData[$key] } } if ($ErrorRecord) { $errorData["ExceptionMessage"] = $ErrorRecord.Exception.Message $errorData["ExceptionType"] = $ErrorRecord.Exception.GetType().FullName $errorData["CategoryInfo"] = $ErrorRecord.CategoryInfo.ToString() $errorData["FullyQualifiedErrorId"] = $ErrorRecord.FullyQualifiedErrorId $errorData["ScriptStackTrace"] = $ErrorRecord.ScriptStackTrace } Write-EndpointEvent ` -Path $Path ` -Name $Name ` -LogRoot $LogRoot ` -Level "ERROR" ` -Message $Message ` -Data $errorData ` -EventName $EventName ` -Source $Source ` -CorrelationId $CorrelationId ` -IncludeEndpointIdentity:$IncludeEndpointIdentity ` -IncludeProcessInfo:$IncludeProcessInfo } Export-ModuleMember -Function ` ConvertTo-SafeFilePart, ` ConvertTo-EndpointEventLevel, ` ConvertTo-EndpointEventData, ` Get-EndpointIdentity, ` New-EndpointEventLogPath, ` Write-EndpointEvent, ` Write-EndpointInfo, ` Write-EndpointWarn, ` Write-EndpointError |