Private/ConvertTo-HDTLogRecord.ps1

function ConvertTo-HDTLogRecord {
    <#
        .SYNOPSIS
            Builds one JSONL record as an ordered dictionary.
 
        .DESCRIPTION
            The structured half of "two formats, one write". HDT.jsonl is the
            source of truth the report renderer and the console's monitoring view
            consume, so every line is one object with the same shape:
 
              ts, runId, seq, level, phase, stepIndex, stepName, stepType,
              component, event, message, durationMs, data
 
            THE TIMESTAMP IS A FORMATTED STRING, NEVER A [datetime]. ConvertTo-Json
            renders a raw [datetime] as ISO 8601 under pwsh 7 and as
            "\/Date(1786579862481)\/" under Windows PowerShell 5.1 - and 5.1 is the
            engine that runs in WinPE, so a raw [datetime] would make the log
            unreadable exactly where it matters. The round-trip 'o' format in the
            invariant culture is identical on both engines.
 
            The step fields are OMITTED, not nulled, when no step is executing,
            and so are durationMs and data when the caller supplied neither: a
            record whose absent fields are absent reads correctly in a JSONL
            viewer and filters correctly in the console.
 
            The function is pure - no clock, no filesystem, no state.
 
        .PARAMETER Context
            The log context, for runId, phase and the step fields.
 
        .PARAMETER Timestamp
            The instant, already read from the injected clock.
 
        .PARAMETER Seq
            The sequence number, already taken from the context.
 
        .PARAMETER Level
            Error, Warning, Info or Debug.
 
        .PARAMETER Event
            One of the controlled vocabulary names.
 
        .PARAMETER Component
            The component the entry came from.
 
        .PARAMETER Message
            The message an administrator reads.
 
        .PARAMETER DurationMs
            How long the thing being reported took. Omitted when not supplied.
 
        .PARAMETER Data
            Step-specific detail, nested rather than polluting the top level.
            Omitted when not supplied.
 
        .OUTPUTS
            System.Collections.Specialized.OrderedDictionary
 
        .EXAMPLE
            ConvertTo-HDTLogRecord -Context $context -Timestamp $now -Seq 417 `
                -Level Info -Event step.complete -Component ImageService `
                -Message 'Applied index 1'
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', 'Event',
        Justification = 'DESIGN 4.4.2 names this field event; it is never PowerShell eventing''s automatic variable.')]
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object] $Context,

        [Parameter(Mandatory = $true)]
        [datetime] $Timestamp,

        [Parameter(Mandatory = $true)]
        [long] $Seq,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Error', 'Warning', 'Info', 'Debug')]
        [string] $Level,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $Event,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string] $Component,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string] $Message,

        [Parameter()]
        [AllowNull()]
        [System.Nullable[long]] $DurationMs,

        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $Data
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $record = [ordered] @{}

    # A [string], never the [datetime] itself - see the description.
    $record['ts'] = $Timestamp.ToUniversalTime().ToString('o', [System.Globalization.CultureInfo]::InvariantCulture)
    $record['runId'] = $Context.RunId
    $record['seq'] = $Seq
    $record['level'] = $Level
    $record['phase'] = $Context.Phase

    if ($Context.StepIndex -gt 0) {
        $record['stepIndex'] = $Context.StepIndex
        $record['stepName'] = $Context.StepName
        $record['stepType'] = $Context.StepType
    }

    $record['component'] = $Component
    $record['event'] = $Event
    $record['message'] = $Message

    if ($null -ne $DurationMs) {
        $record['durationMs'] = $DurationMs
    }

    if ($null -ne $Data) {
        $record['data'] = $Data
    }

    return $record
}