LibreDevOpsHelpers.Logger/LibreDevOpsHelpers.Logger.psm1

Set-StrictMode -Version Latest

# Canonical level vocabulary, ordered for threshold comparison. Messages below the configured
# minimum are suppressed. TRACE and DEBUG are developer diagnostics; SUCCESS is a presentation
# alias that collapses to INFO severity. Matches the Libre DevOps logging standard.
$script:LdoLogLevels = @{ TRACE = 0; DEBUG = 1; INFO = 2; SUCCESS = 2; WARN = 3; ERROR = 4; FATAL = 5 }

# OpenTelemetry SeverityNumber for each level (SUCCESS collapses to INFO = 9). Emitted as the
# severity_number field so backends can sort and filter by severity without parsing text.
$script:LdoSeverityNumbers = @{ TRACE = 1; DEBUG = 5; INFO = 9; SUCCESS = 9; WARN = 13; ERROR = 17; FATAL = 21 }

# Minimum level and output format. Both can be seeded from the environment so that
# operators can control logging in CI/CD without touching code, and both fall back to
# sensible defaults (INFO floor; OTLP) when unset or invalid.
$script:LdoMinLogLevel = if ($env:LDO_LOG_LEVEL -and $script:LdoLogLevels.ContainsKey($env:LDO_LOG_LEVEL.ToUpperInvariant())) {
    $env:LDO_LOG_LEVEL.ToUpperInvariant()
}
else {
    'INFO'
}

$script:LdoLogFormat = switch -Regex ($env:LDO_LOG_FORMAT) {
    '^(?i)otlpindented$' { 'OtlpIndented'; break }
    '^(?i)jsonindented$' { 'JsonIndented'; break }
    '^(?i)json$' { 'Json'; break }
    '^(?i)text$' { 'Text'; break }
    default { 'Otlp' }  # covers 'otlp', unset, and any unrecognised value
}

# InstrumentationScope name stamped on every OTLP record. This identifies the LOGGER, not the
# service: service.name is a resource attribute and is set separately from LDO_SERVICE_NAME.
$script:LdoOtlpScopeName = 'LibreDevOpsHelpers.Logger'

# Ambient trace context stamped onto every record when set. Seeded from the environment so a
# parent process or CI step can propagate a trace across process boundaries (W3C-style), and
# refreshable at runtime via Set-LdoTraceContext. Empty values are omitted from the record.
$script:LdoTraceContext = @{
    trace_id = if ($env:LDO_TRACE_ID) { $env:LDO_TRACE_ID } else { '' }
    span_id = if ($env:LDO_SPAN_ID) { $env:LDO_SPAN_ID } else { '' }
    correlation_id = if ($env:LDO_CORRELATION_ID) { $env:LDO_CORRELATION_ID } else { '' }
}

function Set-LdoLogLevel {
    <#
    .SYNOPSIS
        Sets the minimum level that Write-LdoLog will emit.

    .DESCRIPTION
        Messages below the configured level are dropped. The default floor is INFO, so TRACE and
        DEBUG are suppressed until you lower the level (and DEBUG additionally requires
        $DebugPreference to be set, since it is routed through Write-Debug). The initial value can
        also be supplied via the LDO_LOG_LEVEL environment variable.

    .PARAMETER Level
        One of TRACE, DEBUG, INFO, WARN, ERROR, FATAL. SUCCESS is treated at the same
        threshold as INFO.

    .EXAMPLE
        Set-LdoLogLevel -Level WARN

        Suppresses TRACE, DEBUG, INFO and SUCCESS messages, leaving only WARN, ERROR and FATAL.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL')]
        [string]$Level
    )

    $script:LdoMinLogLevel = $Level
}

function Get-LdoLogLevel {
    <#
    .SYNOPSIS
        Returns the current minimum level that Write-LdoLog will emit.

    .DESCRIPTION
        Returns the threshold set by Set-LdoLogLevel (or seeded from the LDO_LOG_LEVEL
        environment variable). Messages below this level are suppressed.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    $script:LdoMinLogLevel
}

function Set-LdoLogFormat {
    <#
    .SYNOPSIS
        Sets the default output format that Write-LdoLog will emit.

    .DESCRIPTION
        Controls how every log message is rendered unless a call overrides it with its
        own -Format. The default is Otlp, the OpenTelemetry wire format: one complete
        OTLP/JSON export request per line, which a collector ingests directly with no
        parser stack. Json emits one compact FLAT object per line instead, which is
        easier to query in a backend that is not OTel-aware, such as Azure Monitor via
        KQL. Text emits a human-readable, coloured line for interactive CLI use. The
        initial value can also be supplied via the LDO_LOG_FORMAT environment variable.

    .PARAMETER Format
        Otlp (default; one complete OTLP/JSON export request per line), OtlpIndented
        (pretty-printed OTLP, local debugging only - not newline-delimited), Json
        (compact flat object, one per line), JsonIndented (pretty-printed, local
        debugging only), or Text.

    .EXAMPLE
        Set-LdoLogFormat -Format Text

        Switches subsequent log output to the human-readable coloured format.

    .EXAMPLE
        Set-LdoLogFormat -Format Json

        Switches subsequent log output to the flat record, for a backend that is not
        OTel-aware and would need a parser stack to read OTLP.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Otlp', 'OtlpIndented', 'Json', 'JsonIndented', 'Text')]
        [string]$Format
    )

    $script:LdoLogFormat = $Format
}

function Get-LdoLogFormat {
    <#
    .SYNOPSIS
        Returns the current default output format.

    .DESCRIPTION
        One of Otlp (the default), OtlpIndented, Json, JsonIndented or Text. See
        Set-LdoLogFormat.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    $script:LdoLogFormat
}

function Set-LdoTraceContext {
    <#
    .SYNOPSIS
        Sets the ambient trace context stamped onto every log record.

    .DESCRIPTION
        Sets the trace_id, span_id and correlation_id that Write-LdoLog adds to each structured
        record while a trace context is active. Only the supplied values are changed; omit a
        parameter to leave it untouched. Pass -Generate to fill any currently-empty value with
        a fresh cryptographically strong identifier (trace_id and correlation_id are 32 hex
        characters, span_id is 16). Call this once at a process entry point to start a trace,
        and call it again with a new -SpanId for each unit of work (for example each Terraform
        stack) so spans nest under the one trace.

    .PARAMETER TraceId
        W3C trace id (32 hex characters).

    .PARAMETER SpanId
        W3C span id (16 hex characters).

    .PARAMETER CorrelationId
        Correlation id tying together all records from a single run.

    .PARAMETER Generate
        Fill any value that is currently empty (and not supplied explicitly) with a freshly
        generated identifier.

    .EXAMPLE
        Set-LdoTraceContext -Generate

        Starts a new trace, generating a trace_id, span_id and correlation_id.

    .EXAMPLE
        Set-LdoTraceContext -SpanId (New-LdoSpanId)

        Starts a new span under the current trace (for example, per Terraform stack).

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [string]$TraceId,
        [string]$SpanId,
        [string]$CorrelationId,
        [switch]$Generate
    )

    if ($PSBoundParameters.ContainsKey('TraceId')) { $script:LdoTraceContext.trace_id = $TraceId }
    if ($PSBoundParameters.ContainsKey('SpanId')) { $script:LdoTraceContext.span_id = $SpanId }
    if ($PSBoundParameters.ContainsKey('CorrelationId')) { $script:LdoTraceContext.correlation_id = $CorrelationId }

    if ($Generate) {
        if (-not $script:LdoTraceContext.trace_id) { $script:LdoTraceContext.trace_id = New-LdoTraceId }
        if (-not $script:LdoTraceContext.span_id) { $script:LdoTraceContext.span_id = New-LdoSpanId }
        if (-not $script:LdoTraceContext.correlation_id) { $script:LdoTraceContext.correlation_id = New-LdoCorrelationId }
    }
}

function Get-LdoTraceContext {
    <#
    .SYNOPSIS
        Returns a copy of the current ambient trace context.

    .DESCRIPTION
        Returns a hashtable with the trace_id, span_id and correlation_id currently stamped
        onto log records. Empty strings mean the corresponding field is not set and is omitted
        from the record.

    .EXAMPLE
        (Get-LdoTraceContext).trace_id

    .OUTPUTS
        System.Collections.Hashtable
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    return @{
        trace_id = $script:LdoTraceContext.trace_id
        span_id = $script:LdoTraceContext.span_id
        correlation_id = $script:LdoTraceContext.correlation_id
    }
}

function Clear-LdoTraceContext {
    <#
    .SYNOPSIS
        Clears the ambient trace context.

    .DESCRIPTION
        Resets trace_id, span_id and correlation_id to empty so subsequent log records carry no
        trace fields.

    .EXAMPLE
        Clear-LdoTraceContext

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param()

    $script:LdoTraceContext.trace_id = ''
    $script:LdoTraceContext.span_id = ''
    $script:LdoTraceContext.correlation_id = ''
}

function Write-LdoLog {
    <#
    .SYNOPSIS
        Writes a levelled, timestamped log message to the correct PowerShell stream.

    .DESCRIPTION
        The single logging entry point for all LibreDevOpsHelpers modules. By default each
        message is rendered as one complete OTLP/JSON ExportLogsServiceRequest per line, carrying
        exactly one record: the OpenTelemetry wire format, which the collector-contrib
        otlpjsonfile receiver ingests directly with no parser stack, no severity mapping and no
        timestamp layout to configure. Additional fields can be merged via -Data, and become
        typed OTLP attributes.

        Pass -Format Json (or call Set-LdoLogFormat) for the older flat record: one compact JSON
        object per line carrying a UTC ISO-8601 timestamp, level, severity_number, message,
        service.name, invocation and the ambient trace context. It borrows OTel's vocabulary
        without being OTLP, so an OTLP endpoint will not accept it and ingesting it needs a
        parser stack, typically a filelog receiver with json_parser and severity_parser. It is
        still the better choice for a backend that is not OTel-aware, such as Azure Monitor
        queried with KQL, where a flat object is far easier to work with and OTLP is roughly
        three times the bytes.

        Pass -Format Text for a human-readable coloured line instead.

        service.name defaults to the LDO_SERVICE_NAME environment variable, falling back to the
        invocation name when unset; service.version (LDO_SERVICE_VERSION) and
        deployment.environment (LDO_DEPLOYMENT_ENVIRONMENT) are added when their environment
        variables are set. In Otlp mode these three are resource attributes rather than fields on
        the record, because that is where the OTel data model puts them, and the ambient trace
        context (see Set-LdoTraceContext) becomes traceId and spanId.

        Each level is routed to a stream that never touches the success (output) pipeline, so the
        function is safe to call from inside other functions without corrupting their return
        values:

            TRACE -> Write-Verbose (shown when $VerbosePreference is Continue)
            DEBUG -> Write-Debug (shown when $DebugPreference is Continue)
            INFO -> Write-Information (information stream; coloured Write-Host in Text mode)
            SUCCESS -> Write-Information (information stream; coloured Write-Host in Text mode)
            WARN -> Write-Warning
            ERROR -> Write-Error (non-terminating; the caller decides whether to throw)
            FATAL -> Write-Error (non-terminating; the caller decides whether to exit)

        Messages below the level set by Set-LdoLogLevel are suppressed.

    .PARAMETER Level
        Severity of the message. One of TRACE, DEBUG, INFO, SUCCESS, WARN, ERROR, FATAL.

    .PARAMETER Message
        The text to log. Keep it constant; put variable data in -Data fields, not interpolated
        into the message, so records stay groupable and alertable.

    .PARAMETER InvocationName
        Name of the calling command, used as the JSON "invocation" field and the text
        prefix. Defaults to the immediate caller's command name when not supplied.

    .PARAMETER Data
        Optional hashtable of additional structured properties merged into the record (for
        example resource names or durations). Ignored in Text mode.

    .PARAMETER Format
        Overrides the module default output format for this call only. Otlp (default; one
        complete OTLP/JSON export request per line), OtlpIndented, Json (compact flat object),
        JsonIndented (pretty-printed, for local debugging), or Text.

    .EXAMPLE
        Write-LdoLog -Level INFO -Message 'Starting deployment'

    .EXAMPLE
        Write-LdoLog -Level ERROR -Message "Failed: $($_.Exception.Message)"

    .EXAMPLE
        Write-LdoLog -Level INFO -Message 'Created resource group' -Data @{ resource_group = 'rg-prod' }
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('TRACE', 'DEBUG', 'INFO', 'SUCCESS', 'WARN', 'ERROR', 'FATAL')]
        [string]$Level,

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

        [string]$InvocationName,

        [hashtable]$Data,

        [ValidateSet('Otlp', 'OtlpIndented', 'Json', 'JsonIndented', 'Text')]
        [string]$Format
    )

    if (-not $InvocationName) {
        $caller = (Get-PSCallStack)[1]
        $InvocationName = if ($caller -and $caller.Command) { $caller.Command } else { '<script>' }
    }

    if ($script:LdoLogLevels[$Level] -lt $script:LdoLogLevels[$script:LdoMinLogLevel]) {
        return
    }

    if (-not $Format) {
        $Format = $script:LdoLogFormat
    }

    $now = Get-Date

    if ($Format -eq 'Text') {
        $timestamp = $now.ToString('yyyy-MM-dd HH:mm:ss')
        $line = '{0} [{1}] [{2}] {3}' -f $timestamp, $Level, $InvocationName, $Message
    }
    elseif ($Format -like 'Otlp*') {
        $line = Format-LdoOtlpLogRecord -Level $Level -Message $Message -InvocationName $InvocationName `
            -Timestamp $now -Data $Data -Format $Format
    }
    else {
        # ISO-8601 in UTC ("o" round-trip format) so downstream log systems can parse
        # an unambiguous, timezone-correct timestamp. Field order follows the OTel log model:
        # timestamp, level, severity_number, message, then resource/service attributes.
        # service.name falls back to the invocation name when LDO_SERVICE_NAME is unset, since
        # in many scripts the calling command is the logical service emitting the record.
        $serviceName = if ($env:LDO_SERVICE_NAME) { $env:LDO_SERVICE_NAME } else { $InvocationName }

        $record = [ordered]@{
            timestamp = $now.ToUniversalTime().ToString('o')
            level = $Level
            severity_number = $script:LdoSeverityNumbers[$Level]
            message = $Message
            'service.name' = $serviceName
            invocation = $InvocationName
        }
        if ($env:LDO_SERVICE_VERSION) { $record['service.version'] = $env:LDO_SERVICE_VERSION }
        if ($env:LDO_DEPLOYMENT_ENVIRONMENT) { $record['deployment.environment'] = $env:LDO_DEPLOYMENT_ENVIRONMENT }

        # Stamp the ambient trace context when set, so logs join to a trace. Omitted when empty.
        if ($script:LdoTraceContext.trace_id) { $record['trace_id'] = $script:LdoTraceContext.trace_id }
        if ($script:LdoTraceContext.span_id) { $record['span_id'] = $script:LdoTraceContext.span_id }
        if ($script:LdoTraceContext.correlation_id) { $record['correlation_id'] = $script:LdoTraceContext.correlation_id }

        if ($Data) {
            foreach ($key in $Data.Keys) {
                $record[[string]$key] = $Data[$key]
            }
        }
        # Compact (one object per line) is the default for log ingestion. JsonIndented is an
        # opt-in for local debugging and is not newline-delimited.
        if ($Format -eq 'JsonIndented') {
            $line = $record | ConvertTo-Json -Depth 10
        }
        else {
            $line = $record | ConvertTo-Json -Depth 10 -Compress
        }
    }

    switch ($Level) {
        'TRACE' { Write-Verbose $line }
        'DEBUG' { Write-Debug $line }
        'INFO' { Write-LdoInfoLine -Line $line -Level $Level -Format $Format -Color Cyan }
        'SUCCESS' { Write-LdoInfoLine -Line $line -Level $Level -Format $Format -Color Green }
        'WARN' { Write-Warning $line }
        # Explicitly non-terminating: logging an error or a fatal must never throw on its own,
        # even when the caller has $ErrorActionPreference = 'Stop'. The caller decides whether
        # to throw or exit.
        'ERROR' { Write-Error $line -ErrorAction Continue }
        'FATAL' { Write-Error $line -ErrorAction Continue }
    }
}

function Write-LdoInfoLine {
    # Emits INFO/SUCCESS lines without ever touching the success (output) stream.
    # JSON goes through Write-Information so it lands on the information stream as a
    # tagged, capturable InformationRecord with no ANSI colour to corrupt parsing.
    # Text uses coloured Write-Host for readable interactive CLI output.
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Line,
        [Parameter(Mandatory)][string]$Level,
        [Parameter(Mandatory)][string]$Format,
        [Parameter(Mandatory)][System.ConsoleColor]$Color
    )

    if ($Format -eq 'Text') {
        Write-Host $Line -ForegroundColor $Color
    }
    else {
        Write-Information -MessageData $Line -Tags $Level -InformationAction Continue
    }
}

function Get-LdoOtlpHexId {
    # Normalises a trace or span id to OTLP's hex encoding, or returns empty.
    #
    # OTLP/JSON encodes traceId and spanId as lowercase hex strings of a fixed width, 32
    # characters for a trace and 16 for a span. This is a deliberate departure from the proto3
    # JSON mapping, which would base64 a bytes field, and it is stated as such in the OTLP
    # specification. A collector rejects the whole payload when the width or the alphabet is
    # wrong, so an id set by hand through LDO_TRACE_ID must not reach the wire unchecked.
    #
    # Returning empty rather than throwing is the point: the field is then omitted and the
    # record is still valid. Logging must never fail the thing it is logging about.
    #
    # Dashes are stripped because a GUID is 16 bytes, which is exactly a trace id, so a run id
    # from a CI system becomes a usable trace id without the caller reformatting it.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Value,
        [Parameter(Mandatory)][ValidateSet(16, 32)][int]$Length
    )

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

    $candidate = $Value.Trim().Replace('-', '').ToLowerInvariant()

    if ($candidate -match ('^[0-9a-f]{' + $Length + '}$')) {
        return $candidate
    }

    return ''
}

function ConvertTo-LdoOtlpAnyValue {
    # Wraps a PowerShell value in an OTLP AnyValue.
    #
    # OTLP does not carry bare JSON values. Every attribute value is an AnyValue: a single-field
    # object naming its own type, so a backend never has to guess whether 5 was an integer or a
    # string.
    #
    # Two encodings are not the obvious ones and both come from proto3's JSON mapping. Sixty-four
    # bit integers cross the wire as STRINGS, because a JSON number is a double and a double
    # cannot hold the full int64 range without rounding. Doubles stay numbers.
    #
    # Order of the type tests is load-bearing. A [string] is also [IEnumerable], so the string
    # test must come before the enumerable one or every message becomes an array of characters.
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)][AllowNull()]$Value
    )

    # An AnyValue with no field set is the OTLP representation of an absent value, and is what a
    # null must become. An empty string would assert that the caller reported "", which is a
    # different fact.
    if ($null -eq $Value) {
        return [ordered]@{}
    }

    if ($Value -is [bool]) {
        return [ordered]@{ boolValue = $Value }
    }

    if ($Value -is [string]) {
        return [ordered]@{ stringValue = $Value }
    }

    if ($Value -is [byte] -or $Value -is [int16] -or $Value -is [int] -or $Value -is [long]) {
        return [ordered]@{ intValue = [string][long]$Value }
    }

    if ($Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) {
        return [ordered]@{ doubleValue = [double]$Value }
    }

    if ($Value -is [System.Collections.IDictionary]) {
        $pairs = [System.Collections.Generic.List[object]]::new()
        foreach ($key in $Value.Keys) {
            $pairs.Add([ordered]@{
                    key = [string]$key
                    value = (ConvertTo-LdoOtlpAnyValue -Value $Value[$key])
                })
        }
        return [ordered]@{ kvlistValue = [ordered]@{ values = $pairs } }
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        $items = [System.Collections.Generic.List[object]]::new()
        foreach ($item in $Value) {
            $items.Add((ConvertTo-LdoOtlpAnyValue -Value $item))
        }
        return [ordered]@{ arrayValue = [ordered]@{ values = $items } }
    }

    return [ordered]@{ stringValue = [string]$Value }
}

function New-LdoOtlpAttribute {
    # Builds one OTLP KeyValue pair.
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)][string]$Key,
        [Parameter(Mandatory)][AllowNull()]$Value
    )

    return [ordered]@{
        key = $Key
        value = (ConvertTo-LdoOtlpAnyValue -Value $Value)
    }
}

function Format-LdoOtlpLogRecord {
    # Renders one log record as a complete OTLP/JSON ExportLogsServiceRequest.
    #
    # Unlike the Json format this IS the wire format. Every line is a whole, self-contained
    # export request carrying exactly one record, which is what the collector-contrib
    # otlpjsonfile receiver expects, so a collector ingests it with no parser stack, no severity
    # mapping and no timestamp layout to get wrong.
    #
    # One record per line is more envelope per byte than batching many records into one request.
    # It is still the right shape for a logger, because output is written line by line to a host
    # that may truncate or interleave it, and a partial batch is an unparseable document whereas
    # a lost line is just a lost line.
    #
    # Field placement follows the OTel log data model rather than the flat record's convenience.
    # service.* are RESOURCE attributes, because they describe the thing emitting the telemetry.
    # invocation and correlation_id are LOG RECORD attributes, because they describe one event.
    # The message becomes body, not an attribute called message.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Level,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Message,
        [Parameter(Mandatory)][string]$InvocationName,
        [Parameter(Mandatory)][datetime]$Timestamp,
        [hashtable]$Data,
        [Parameter(Mandatory)][string]$Format
    )

    $serviceName = if ($env:LDO_SERVICE_NAME) { $env:LDO_SERVICE_NAME } else { $InvocationName }

    # OTLP timestamps are nanoseconds since the Unix epoch and, being a uint64, cross the wire as
    # a string. DateTime ticks are 100ns units, so the multiply by 100 is the full precision the
    # platform has rather than a rounding. ToUniversalTime first: DateTime subtraction ignores
    # Kind, so subtracting the (UTC) epoch from a local timestamp would silently offset it.
    $nanos = [string]([long](($Timestamp.ToUniversalTime() - [datetime]::UnixEpoch).Ticks) * 100L)

    $resourceAttributes = [System.Collections.Generic.List[object]]::new()
    $resourceAttributes.Add((New-LdoOtlpAttribute -Key 'service.name' -Value $serviceName))
    if ($env:LDO_SERVICE_VERSION) {
        $resourceAttributes.Add((New-LdoOtlpAttribute -Key 'service.version' -Value $env:LDO_SERVICE_VERSION))
    }
    if ($env:LDO_DEPLOYMENT_ENVIRONMENT) {
        $resourceAttributes.Add((New-LdoOtlpAttribute -Key 'deployment.environment' -Value $env:LDO_DEPLOYMENT_ENVIRONMENT))
    }

    $logAttributes = [System.Collections.Generic.List[object]]::new()
    $logAttributes.Add((New-LdoOtlpAttribute -Key 'invocation' -Value $InvocationName))

    # correlation_id stays an attribute even when it also seeds traceId below. The two are not
    # interchangeable: a backend that drops a trace id it does not like would otherwise lose the
    # only field tying one run's records together.
    if ($script:LdoTraceContext.correlation_id) {
        $logAttributes.Add((New-LdoOtlpAttribute -Key 'correlation_id' -Value $script:LdoTraceContext.correlation_id))
    }

    if ($Data) {
        foreach ($key in $Data.Keys) {
            $logAttributes.Add((New-LdoOtlpAttribute -Key ([string]$key) -Value $Data[$key]))
        }
    }

    $logRecord = [ordered]@{
        timeUnixNano = $nanos
        observedTimeUnixNano = $nanos
        severityNumber = $script:LdoSeverityNumbers[$Level]
        severityText = $Level
        body = [ordered]@{ stringValue = $Message }
        attributes = $logAttributes
    }

    # An explicit trace id wins. Failing that the correlation id is used, so a run whose entry
    # point only set a correlation id is still one trace. Either is dropped when it is not a
    # valid id, rather than emitted and rejected.
    $traceId = Get-LdoOtlpHexId -Value $script:LdoTraceContext.trace_id -Length 32
    if (-not $traceId) {
        $traceId = Get-LdoOtlpHexId -Value $script:LdoTraceContext.correlation_id -Length 32
    }
    $spanId = Get-LdoOtlpHexId -Value $script:LdoTraceContext.span_id -Length 16

    if ($traceId) { $logRecord['traceId'] = $traceId }
    if ($spanId) { $logRecord['spanId'] = $spanId }

    $payload = [ordered]@{
        resourceLogs = @(
            [ordered]@{
                resource = [ordered]@{ attributes = $resourceAttributes }
                scopeLogs = @(
                    [ordered]@{
                        scope = [ordered]@{ name = $script:LdoOtlpScopeName }
                        logRecords = @($logRecord)
                    }
                )
            }
        )
    }

    # Depth 20, not the 10 the flat record uses. The envelope alone is 7 levels deep before a
    # single attribute is added, and ConvertTo-Json silently replaces anything past its depth
    # limit with a type name string, producing a well-formed document no collector can read.
    if ($Format -eq 'OtlpIndented') {
        return ($payload | ConvertTo-Json -Depth 20)
    }

    return ($payload | ConvertTo-Json -Depth 20 -Compress)
}

Export-ModuleMember -Function `
    Write-LdoLog, `
    Set-LdoLogLevel, `
    Get-LdoLogLevel, `
    Set-LdoLogFormat, `
    Get-LdoLogFormat, `
    Set-LdoTraceContext, `
    Get-LdoTraceContext, `
    Clear-LdoTraceContext