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()
    }
}

function ConvertTo-SafeFilePart {
    <#
    .SYNOPSIS
        Converts a string into a filesystem-safe value.
 
    .DESCRIPTION
        Replaces characters that are unsafe or undesirable in filenames with underscores.
 
    .PARAMETER Value
        The string value to sanitize.
 
    .EXAMPLE
        ConvertTo-SafeFilePart -Value "endpoint/001:health check"
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Value
    )

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return "Unknown"
    }

    return ($Value -replace '[^a-zA-Z0-9\-_\.]', '_')
}

function ConvertTo-EndpointEventLevel {
    <#
    .SYNOPSIS
        Normalizes an endpoint event level.
 
    .DESCRIPTION
        Converts common level aliases into consistent uppercase endpoint event levels.
 
    .PARAMETER Level
        The event level value to normalize.
 
    .EXAMPLE
        ConvertTo-EndpointEventLevel -Level "warning"
    #>


    [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() }
    }
}

function ConvertTo-EndpointEventData {
    <#
    .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" }
    #>


    [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
    }
}

function Get-EndpointIdentity {
    <#
    .SYNOPSIS
        Gets basic endpoint identity information.
 
    .DESCRIPTION
        Collects common endpoint identity fields from WMI/CIM.
 
        Returned fields:
        - ComputerName
        - SerialNumber
        - DeviceId
        - Manufacturer
        - Model
        - OSVersion
        - OSBuild
        - Domain
 
        CIM verbose output is suppressed so parent scripts using -Verbose do not get noisy
        Get-CimInstance messages.
 
    .EXAMPLE
        Get-EndpointIdentity
    #>


    [CmdletBinding()]
    param()

    $previousVerbosePreference = $VerbosePreference
    $VerbosePreference = "SilentlyContinue"

    $serialNumber = "UnknownSerial"
    $manufacturer = $null
    $model = $null
    $osVersion = $null
    $osBuild = $null
    $domain = $null

    try {
        try {
            $bios = Get-CimInstance `
                -ClassName Win32_BIOS `
                -ErrorAction Stop `
                -Verbose:$false

            if (-not [string]::IsNullOrWhiteSpace($bios.SerialNumber)) {
                $serialNumber = $bios.SerialNumber
            }
        }
        catch {}

        try {
            $cs = Get-CimInstance `
                -ClassName Win32_ComputerSystem `
                -ErrorAction Stop `
                -Verbose:$false

            $manufacturer = $cs.Manufacturer
            $model = $cs.Model
            $domain = $cs.Domain
        }
        catch {}

        try {
            $os = Get-CimInstance `
                -ClassName Win32_OperatingSystem `
                -ErrorAction Stop `
                -Verbose:$false

            $osVersion = $os.Version
            $osBuild = $os.BuildNumber
        }
        catch {}

        $safeSerialNumber = ($serialNumber -replace '[^a-zA-Z0-9\-_]', '_')
        $computerName = $env:COMPUTERNAME

        $deviceId = if (-not [string]::IsNullOrWhiteSpace($safeSerialNumber) -and $safeSerialNumber -ne "UnknownSerial") {
            $safeSerialNumber
        }
        else {
            $computerName
        }

        [pscustomobject]@{
            ComputerName = $computerName
            SerialNumber = $safeSerialNumber
            DeviceId     = $deviceId
            Manufacturer = $manufacturer
            Model        = $model
            OSVersion    = $osVersion
            OSBuild      = $osBuild
            Domain       = $domain
        }
    }
    finally {
        $VerbosePreference = $previousVerbosePreference
    }
}

function New-EndpointEventLogPath {
    <#
    .SYNOPSIS
        Creates a standard OpenEndpointEvents NDJSON log file path.
 
    .DESCRIPTION
        Generates a log file path under the specified log root.
 
    .PARAMETER Name
        Base log filename. Defaults to endpoint-events.ndjson.
 
    .PARAMETER LogRoot
        Directory where logs are written.
 
    .PARAMETER IncludeDate
        Adds current date to the filename.
 
    .PARAMETER IncludeComputerName
        Adds computer name to the filename.
 
    .PARAMETER IncludeSerialNumber
        Adds BIOS serial number to the filename.
 
    .EXAMPLE
        New-EndpointEventLogPath -Name "health" -IncludeDate -IncludeSerialNumber -IncludeComputerName
    #>


    [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
}

function Add-EndpointIdentityToEvent {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Specialized.OrderedDictionary]$Entry
    )

    $identity = Get-EndpointIdentity

    $Entry["ComputerName"]  = $identity.ComputerName
    $Entry["SerialNumber"]  = $identity.SerialNumber
    $Entry["DeviceId"]      = $identity.DeviceId
    $Entry["Manufacturer"]  = $identity.Manufacturer
    $Entry["Model"]         = $identity.Model
    $Entry["OSVersion"]     = $identity.OSVersion
    $Entry["OSBuild"]       = $identity.OSBuild
    $Entry["Domain"]        = $identity.Domain
}

function Write-EndpointEvent {
    <#
    .SYNOPSIS
        Writes a generic structured endpoint event to an NDJSON file.
 
    .DESCRIPTION
        Writes one compressed JSON object per line to an NDJSON file.
 
        In v1.2.0, endpoint identity is included by default.
        Use -NoEndpointIdentity to opt out.
 
    .PARAMETER NoEndpointIdentity
        Prevents ComputerName, SerialNumber, DeviceId, Manufacturer, Model, OSVersion,
        OSBuild, and Domain from being added to the event.
 
    .PARAMETER IncludeEndpointIdentity
        Backward-compatible switch. Endpoint identity is now included by default.
 
    .EXAMPLE
        Write-EndpointEvent -Level INFO -Message "Script started"
 
    .EXAMPLE
        Write-EndpointEvent -Level INFO -Message "Anonymous local test" -NoEndpointIdentity
    #>


    [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]$NoEndpointIdentity,

        [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 (-not $NoEndpointIdentity) {
        Add-EndpointIdentityToEvent -Entry $entry
    }

    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
}

function Write-EndpointInfo {
    <#
    .SYNOPSIS
        Writes an INFO-level endpoint event.
 
    .DESCRIPTION
        Convenience wrapper around Write-EndpointEvent.
 
        Endpoint identity is included by default.
        Use -NoEndpointIdentity to opt out.
 
    .EXAMPLE
        Write-EndpointInfo -Message "Script started"
    #>


    [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]$NoEndpointIdentity,
        [switch]$IncludeProcessInfo
    )

    Write-EndpointEvent `
        -Path $Path `
        -Name $Name `
        -LogRoot $LogRoot `
        -Level "INFO" `
        -Message $Message `
        -Data $Data `
        -EventName $EventName `
        -Source $Source `
        -CorrelationId $CorrelationId `
        -IncludeEndpointIdentity:$IncludeEndpointIdentity `
        -NoEndpointIdentity:$NoEndpointIdentity `
        -IncludeProcessInfo:$IncludeProcessInfo
}

function Write-EndpointWarn {
    <#
    .SYNOPSIS
        Writes a WARN-level endpoint event.
 
    .DESCRIPTION
        Convenience wrapper around Write-EndpointEvent.
 
        Endpoint identity is included by default.
        Use -NoEndpointIdentity to opt out.
 
    .EXAMPLE
        Write-EndpointWarn -Message "Disk space below threshold"
    #>


    [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]$NoEndpointIdentity,
        [switch]$IncludeProcessInfo
    )

    Write-EndpointEvent `
        -Path $Path `
        -Name $Name `
        -LogRoot $LogRoot `
        -Level "WARN" `
        -Message $Message `
        -Data $Data `
        -EventName $EventName `
        -Source $Source `
        -CorrelationId $CorrelationId `
        -IncludeEndpointIdentity:$IncludeEndpointIdentity `
        -NoEndpointIdentity:$NoEndpointIdentity `
        -IncludeProcessInfo:$IncludeProcessInfo
}

function Write-EndpointError {
    <#
    .SYNOPSIS
        Writes an ERROR-level endpoint event.
 
    .DESCRIPTION
        Convenience wrapper around Write-EndpointEvent.
 
        Can accept a PowerShell ErrorRecord and flatten useful error details into
        the JSON event.
 
        Endpoint identity is included by default.
        Use -NoEndpointIdentity to opt out.
 
    .EXAMPLE
        Write-EndpointError -Message "Upload failed"
    #>


    [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]$NoEndpointIdentity,
        [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 `
        -NoEndpointIdentity:$NoEndpointIdentity `
        -IncludeProcessInfo:$IncludeProcessInfo
}

Export-ModuleMember -Function `
    ConvertTo-SafeFilePart, `
    ConvertTo-EndpointEventLevel, `
    ConvertTo-EndpointEventData, `
    Get-EndpointIdentity, `
    New-EndpointEventLogPath, `
    Write-EndpointEvent, `
    Write-EndpointInfo, `
    Write-EndpointWarn, `
    Write-EndpointError