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; structured JSON) 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)jsonindented$' { 'JsonIndented'; break }
    '^(?i)text$' { 'Text'; break }
    default { 'Json' }  # covers 'json', unset, and any unrecognised value
}

# 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 is DEBUG, which
        shows everything (DEBUG still also requires $DebugPreference to be set, as 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 Json, which emits one compact JSON object per line
        (newline-delimited JSON) suitable for ingestion by log aggregators such as
        Splunk, Elasticsearch or Azure Monitor. 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
        Json (compact, one object per line), JsonIndented (pretty-printed, for local
        debugging only - not newline-delimited), or Text.

    .EXAMPLE
        Set-LdoLogFormat -Format Text

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

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

    $script:LdoLogFormat = $Format
}

function Get-LdoLogFormat {
    <#
    .SYNOPSIS
        Returns the current default output format (Json or Text).
    #>

    [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 JSON
        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 compact JSON object (newline-delimited JSON) aligned to the
        OpenTelemetry log data model: a UTC ISO-8601 timestamp, level, severity_number, message,
        service.name, and the trace_id / span_id / correlation_id from the ambient trace context
        when set (see Set-LdoTraceContext). The lean "invocation" field is kept as an extra
        attribute. Additional fields can be merged via -Data. Pass -Format Text (or call
        Set-LdoLogFormat) 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.

        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 JSON
        record (for example resource names or durations). Ignored in Text mode.

    .PARAMETER Format
        Overrides the module default output format for this call only. Json (compact),
        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('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
    }
    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
    }
}

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