Private/Collector.ps1

#Requires -Version 5.1

function Get-SMBeatCimInstanceSafe {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ClassName,
        $CimSession
    )

    $params = @{
        ClassName   = $ClassName
        ErrorAction = 'Stop'
    }
    if ($CimSession) {
        $params['CimSession'] = $CimSession
    }

    try {
        Get-CimInstance @params
    }
    catch {
        Write-Verbose ("CIM class {0} not available: {1}" -f $ClassName, $_.Exception.Message)
        @()
    }
}

function Get-SMBeatRawLong {
    param(
        $Object,
        [string[]]$PropertyNames
    )

    foreach ($name in $PropertyNames) {
        $prop = $Object.PSObject.Properties[$name]
        if ($prop -and $null -ne $prop.Value) {
            return [uint64]$prop.Value
        }
    }

    return [uint64]0
}

function New-SMBeatCounterSnapshot {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Kind,
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [uint64]$ReadRaw,
        [uint64]$WriteRaw,
        [uint64]$SentRaw,
        [uint64]$ReceivedRaw,
        [string]$Client,
        [string]$User
    )

    [PSCustomObject]@{
        Kind        = $Kind
        Name        = $Name
        ReadRaw     = $ReadRaw
        WriteRaw    = $WriteRaw
        SentRaw     = $SentRaw
        ReceivedRaw = $ReceivedRaw
        Client      = $Client
        User        = $User
    }
}

function Get-SMBeatNicSnapshots {
    param(
        $CimSession
    )

    $items = New-Object System.Collections.Generic.List[object]
    $nicRows = @(Get-SMBeatCimInstanceSafe -ClassName 'Win32_PerfRawData_Tcpip_NetworkInterface' -CimSession $CimSession)
    foreach ($row in $nicRows) {
        $name = [string]$row.Name
        if (Test-SMBeatSkipInstance -Name $name -Kind 'nic') {
            continue
        }
        if ($name -like '*Loopback*' -or $name -like '*isatap*' -or $name -like '*Teredo*') {
            continue
        }

        $items.Add((New-SMBeatCounterSnapshot -Kind 'nic' -Name $name `
                -ReadRaw 0 -WriteRaw 0 `
                -SentRaw (Get-SMBeatRawLong -Object $row -PropertyNames @('BytesSentPersec')) `
                -ReceivedRaw (Get-SMBeatRawLong -Object $row -PropertyNames @('BytesReceivedPersec')))) | Out-Null
    }

    return $items
}

function Get-SMBeatNicDeltaRecords {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$NicPrev,
        [Parameter(Mandatory = $true)]
        [string]$Server,
        [Parameter(Mandatory = $true)]
        [datetime]$Utc,
        [Parameter(Mandatory = $true)]
        [int]$IntervalSec,
        $CimSession
    )

    $emitted = New-Object System.Collections.Generic.List[object]
    $snaps = @(Get-SMBeatNicSnapshots -CimSession $CimSession)
    foreach ($snap in $snaps) {
        $key = 'nic:{0}' -f $snap.Name
        $sent = [uint64]$snap.SentRaw
        $recv = [uint64]$snap.ReceivedRaw
        if ($NicPrev.ContainsKey($key)) {
            $prev = $NicPrev[$key]
            $record = ConvertTo-SMBeatSampleRecord -Snapshot $snap -Server $Server -Utc $Utc -IntervalSec $IntervalSec `
                -ReadDelta 0 -WriteDelta 0 `
                -SentDelta (Get-SMBeatByteDelta -Current $sent -Previous ([uint64]$prev.SentRaw)) `
                -ReceivedDelta (Get-SMBeatByteDelta -Current $recv -Previous ([uint64]$prev.ReceivedRaw))
            $emitted.Add($record) | Out-Null
        }
        $NicPrev[$key] = @{
            SentRaw     = $sent
            ReceivedRaw = $recv
        }
    }

    return $emitted.ToArray()
}

function Save-SMBeatHeartbeat {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [datetime]$Utc,
        [int]$IntervalSec,
        [string]$ComputerName,
        [string]$LastError,
        [switch]$Success,
        [int]$EmittedCount,
        [int]$ProcessId,
        [string]$Mode
    )

    if ($ProcessId -le 0) {
        $ProcessId = $PID
    }

    $now = ConvertTo-SMBeatUtcString -DateTime $Utc
    $existingJson = Read-SMBeatTextFile -Path (Get-SMBeatHeartbeatPath -DataRoot $DataRoot)
    $successCount = 0
    $lastSuccess = $null
    if ($existingJson) {
        $existing = ConvertFrom-SMBeatJson -Json $existingJson
        if ($existing.SuccessCount) {
            $successCount = [int]$existing.SuccessCount
        }
        if ($existing.LastSuccessUtc) {
            $lastSuccess = $existing.LastSuccessUtc
        }
    }

    if ($Success) {
        $successCount++
        $lastSuccess = $now
    }

    $resolvedMode = 'tcp445'
    if (-not [string]::IsNullOrWhiteSpace($Mode)) {
        $resolvedMode = $Mode
    }

    $obj = [PSCustomObject]@{
        LastAttemptUtc = $now
        LastSuccessUtc = $lastSuccess
        LastError      = $LastError
        IntervalSec    = $IntervalSec
        ComputerName   = $ComputerName
        SuccessCount   = $successCount
        EmittedCount   = $EmittedCount
        Success        = [bool]$Success
        Pid            = $ProcessId
        Mode           = $resolvedMode
    }

    Write-SMBeatTextFile -Path (Get-SMBeatHeartbeatPath -DataRoot $DataRoot) -Text (ConvertTo-SMBeatJson -InputObject $obj)
}

function Save-SMBeatWatcherState {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [datetime]$Utc,
        [string]$Mode
    )

    if ([string]::IsNullOrWhiteSpace($Mode)) {
        $Mode = 'tcp445'
    }

    $obj = [PSCustomObject]@{
        LastSampleUtc = (ConvertTo-SMBeatUtcString -DateTime $Utc)
        Pid           = $PID
        Mode          = $Mode
    }
    Write-SMBeatTextFile -Path (Get-SMBeatStatePath -DataRoot $DataRoot) -Text (ConvertTo-SMBeatJson -InputObject $obj)
}

function Add-SMBeatSampleLines {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [Parameter(Mandatory = $true)]
        [datetime]$Utc,
        [AllowEmptyCollection()]
        [object[]]$Records
    )

    if ($null -eq $Records -or $Records.Count -eq 0) {
        return
    }

    $path = Get-SMBeatSampleFilePath -DataRoot $DataRoot -Utc $Utc
    $sb = New-Object System.Text.StringBuilder
    foreach ($record in $Records) {
        $line = (ConvertTo-Json -InputObject $record -Depth 6 -Compress)
        [void]$sb.AppendLine($line)
    }

    Add-SMBeatTextFile -Path $path -Text $sb.ToString()
}

function Invoke-SMBeatRetention {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [int]$RetentionDays = 90
    )

    if ($RetentionDays -le 0) {
        return
    }

    $dir = Get-SMBeatSampleDirectory -DataRoot $DataRoot
    if (-not (Test-Path -LiteralPath $dir)) {
        return
    }

    $cutoff = (Get-SMBeatUtcNow).Date.AddDays(-1 * $RetentionDays)
    Get-ChildItem -LiteralPath $dir -Filter '*.jsonl' -ErrorAction SilentlyContinue | ForEach-Object {
        $dayPart = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
        $parsed = [datetime]::MinValue
        if ([datetime]::TryParseExact($dayPart, 'yyyy-MM-dd', $script:SMBeatInvariant, [System.Globalization.DateTimeStyles]::None, [ref]$parsed)) {
            if ($parsed -lt $cutoff) {
                Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
            }
        }
    }
}

function ConvertTo-SMBeatSampleRecord {
    param(
        [Parameter(Mandatory = $true)]
        $Snapshot,
        [Parameter(Mandatory = $true)]
        [string]$Server,
        [Parameter(Mandatory = $true)]
        [datetime]$Utc,
        [Parameter(Mandatory = $true)]
        [int]$IntervalSec,
        [int64]$ReadDelta,
        [int64]$WriteDelta,
        [int64]$SentDelta,
        [int64]$ReceivedDelta
    )

    [PSCustomObject]@{
        ts                = (ConvertTo-SMBeatUtcString -DateTime $Utc)
        server            = $Server
        kind              = $Snapshot.Kind
        name              = $Snapshot.Name
        client            = $Snapshot.Client
        user              = $Snapshot.User
        readBytesDelta    = $ReadDelta
        writeBytesDelta   = $WriteDelta
        sentBytesDelta    = $SentDelta
        receivedBytesDelta = $ReceivedDelta
        sampleIntervalSec = $IntervalSec
    }
}

function Invoke-SMBeatSampleInternal {
    [CmdletBinding()]
    param(
        [string]$Path,
        [int]$WaitTimeoutSec = 15
    )

    $pack = Resolve-SMBeatWatcherDataRoot -Path $Path
    $dataRoot = $pack.DataRoot
    if (-not (Test-SMBeatWatcherAlive -Path $dataRoot)) {
        throw 'Collector must be registered (resident). Use Register-SMBeatCollector -Wait.'
    }

    Request-SMBeatWatcherFlush -DataRoot $dataRoot
    $deadline = (Get-SMBeatUtcNow).AddSeconds($WaitTimeoutSec)
    $flushPath = Get-SMBeatFlushRequestPath -DataRoot $dataRoot
    do {
        if (-not (Test-Path -LiteralPath $flushPath)) {
            return (Get-SMBeatStatusInternal -Path $dataRoot)
        }
        Start-Sleep -Milliseconds 250
    } while ((Get-SMBeatUtcNow) -lt $deadline)

    throw 'Collector did not flush in time. Check Get-SMBeatStatus and collector.log.'
}