Private/Write-RaidinessLog.ps1

<#
    The run's log.

    Before this there was none. A collector that failed produced a warning that
    scrolled away, and Invoke-Raidiness then reduced the whole list of skip
    reasons to a count (".Skipped.Count") -- so the one question an operator
    asks afterwards, "why is this not measured?", had no answer anywhere on
    disk.

    Two files, on purpose:

      raidiness.log what a person reads and pastes into a ticket
      raidiness.jsonl one object per event, for the run summary and the report

    Both are appended with Out-File. Not Add-Content or Set-Content: "Add-" and
    "Set-" are write verbs and scripts/ps-lint.ts refuses them without a
    documented allow marker, which a log file does not deserve. Out- is not a
    write verb, which is also why the evidence writer already uses it.

    Every function no-ops when no log has been opened, so the private helpers
    can call Write-RaidinessLog unconditionally -- including when someone runs
    Invoke-RaidinessModule on its own.
#>

# Set-StrictMode refuses to read a variable that was never assigned, and the
# whole point of the helpers below is that they can be called before a log is
# opened. Declaring it here, at dot-source time, is what makes that safe.
$script:RaidinessLog = $null

function Open-RaidinessLog {
    [CmdletBinding()]
    param(
        # The run folder; the two files are written inside it.
        [Parameter(Mandatory = $true)]
        [string] $Path,

        [ValidateSet('Quiet', 'Normal', 'Verbose')]
        [string] $Level = 'Normal'
    )

    $script:RaidinessLog = @{
        Text    = Join-Path $Path 'raidiness.log'
        Events  = Join-Path $Path 'raidiness.jsonl'
        Level   = $Level
        Started = Get-Date
        Counts  = @{ info = 0; warn = 0; error = 0 }
    }

    $version = (Get-Module -Name Raidiness | Select-Object -First 1).Version
    Write-RaidinessLog -Phase 'run' -Message "Raidiness $version on PowerShell $($PSVersionTable.PSVersion) -- read-only run started"
    $script:RaidinessLog
}

function Write-RaidinessLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $Message,

        [ValidateSet('info', 'warn', 'error', 'debug')]
        [string] $Level = 'info',

        # Which part of the run this belongs to: run, connect, collect, module, intake, export.
        [string] $Phase = 'run',

        # The collector or module the line is about, when there is one.
        [string] $Source,

        [Nullable[double]] $DurationMs,

        # Anything else worth keeping; goes to the jsonl only.
        [hashtable] $Data
    )

    $log = $script:RaidinessLog
    if (-not $log) { return }
    if ($Level -eq 'debug' -and $log.Level -ne 'Verbose') { return }

    if ($log.Counts.ContainsKey($Level)) { $log.Counts[$Level]++ }

    $now = Get-Date
    $line = '{0} {1,-5} {2,-8} {3}' -f $now.ToString('yyyy-MM-dd HH:mm:ss'), $Level.ToUpperInvariant(), $Phase, $Message
    if ($Source) { $line = '{0} {1,-5} {2,-8} {3} {4}' -f $now.ToString('yyyy-MM-dd HH:mm:ss'), $Level.ToUpperInvariant(), $Phase, $Source, $Message }
    if ($null -ne $DurationMs) { $line += ' ({0:n0} ms)' -f $DurationMs }

    $line | Out-File -FilePath $log.Text -Append -Encoding utf8

    $record = [ordered]@{
        ts      = $now.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
        level   = $Level
        phase   = $Phase
        message = $Message
    }
    if ($Source) { $record.source = $Source }
    if ($null -ne $DurationMs) { $record.durationMs = [math]::Round($DurationMs) }
    if ($Data) { foreach ($key in $Data.Keys) { $record[$key] = $Data[$key] } }

    ConvertTo-Json -InputObject $record -Depth 6 -Compress | Out-File -FilePath $log.Events -Append -Encoding utf8
}

function Close-RaidinessLog {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    $log = $script:RaidinessLog
    if (-not $log) { return $null }

    $elapsed = (Get-Date) - $log.Started
    Write-RaidinessLog -Phase 'run' -Message ('Finished in {0:n0}s -- {1} warning(s), {2} error(s)' -f $elapsed.TotalSeconds, $log.Counts.warn, $log.Counts.error)

    $summary = [pscustomobject]@{
        PSTypeName = 'Raidiness.LogSummary'
        Log        = $log.Text
        Events     = $log.Events
        Warnings   = $log.Counts.warn
        Errors     = $log.Counts.error
        Seconds    = [math]::Round($elapsed.TotalSeconds, 1)
    }
    $script:RaidinessLog = $null
    $summary
}

function Get-RaidinessLogPath {
    [CmdletBinding()]
    [OutputType([string])]
    param()
    $script:RaidinessLog ? $script:RaidinessLog.Text : $null
}