OtelCollector.psm1

using namespace System.Collections.Generic

class OtelConfig {
    [string]$Endpoint
    [hashtable]$Headers
    [string]$ServiceName
    [string]$ServiceVersion
    [hashtable]$ResourceAttributes

    OtelConfig([string]$endpoint, [string]$serviceName) {
        $this.Endpoint = $endpoint
        $this.ServiceName = $serviceName
        $this.ServiceVersion = "1.0.0"
        $this.Headers = @{
            "Content-Type" = "application/json"
        }
        $this.ResourceAttributes = @{}
    }
}

$script:OtelConfig = $null

function Initialize-OtelCollector {
    <#
    .SYNOPSIS
        Initializes the OpenTelemetry collector configuration.

    .DESCRIPTION
        Sets up the OTEL collector endpoint and service information for subsequent telemetry operations.
        The Endpoint and ServiceName can be provided as parameters or through environment variables
        (OTEL_ENDPOINT and SERVICE_NAME). Parameters take priority over environment variables.

    .PARAMETER Endpoint
        The base URL of the OTEL collector (e.g., http://localhost:4318).
        If not provided, will check for OTEL_ENDPOINT environment variable.

    .PARAMETER ServiceName
        The name of your service.
        If not provided, will check for SERVICE_NAME environment variable.

    .PARAMETER ServiceVersion
        The version of your service (default: 1.0.0)

    .PARAMETER Headers
        Additional HTTP headers to include in requests

    .PARAMETER ResourceAttributes
        Additional resource attributes to include with all telemetry

    .EXAMPLE
        Initialize-OtelCollector -Endpoint "http://localhost:4318" -ServiceName "MyApp"

    .EXAMPLE
        # Using environment variables
        $env:OTEL_ENDPOINT = "http://localhost:4318"
        $env:SERVICE_NAME = "MyApp"
        Initialize-OtelCollector
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Endpoint,

        [Parameter(Mandatory = $false)]
        [string]$ServiceName,

        [Parameter(Mandatory = $false)]
        [string]$ServiceVersion = "1.0.0",

        [Parameter(Mandatory = $false)]
        [hashtable]$Headers = @{},

        [Parameter(Mandatory = $false)]
        [hashtable]$ResourceAttributes = @{}
    )

    # Resolve Endpoint: parameter > env var > exception
    if ([string]::IsNullOrWhiteSpace($Endpoint)) {
        $Endpoint = $env:OTEL_ENDPOINT
        if ([string]::IsNullOrWhiteSpace($Endpoint)) {
            throw "Endpoint is required. Provide it as a parameter or set the OTEL_ENDPOINT environment variable."
        }
    }

    # Resolve ServiceName: parameter > env var > exception
    if ([string]::IsNullOrWhiteSpace($ServiceName)) {
        $ServiceName = $env:SERVICE_NAME
        if ([string]::IsNullOrWhiteSpace($ServiceName)) {
            throw "ServiceName is required. Provide it as a parameter or set the SERVICE_NAME environment variable."
        }
    }

    $script:OtelConfig = [OtelConfig]::new($Endpoint, $ServiceName)
    $script:OtelConfig.ServiceVersion = $ServiceVersion

    foreach ($key in $Headers.Keys) {
        $script:OtelConfig.Headers[$key] = $Headers[$key]
    }

    foreach ($key in $ResourceAttributes.Keys) {
        $script:OtelConfig.ResourceAttributes[$key] = $ResourceAttributes[$key]
    }

    Write-Verbose "OTEL Collector initialized for service '$ServiceName' at '$Endpoint'"
}

function Send-OtelLog {
    <#
    .SYNOPSIS
        Sends a log entry to the OTEL collector.

    .DESCRIPTION
        Sends structured log data to the configured OpenTelemetry collector endpoint.

    .PARAMETER Message
        The log message body

    .PARAMETER Severity
        The severity level (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)

    .PARAMETER Attributes
        Additional attributes to include with the log

    .PARAMETER TraceId
        Optional trace ID to correlate logs with traces

    .PARAMETER SpanId
        Optional span ID to correlate logs with specific spans

    .EXAMPLE
        Send-OtelLog -Message "User logged in" -Severity "INFO" -Attributes @{ userId = "123" }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$Message,

        [Parameter(Mandatory = $false)]
        [ValidateSet("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL")]
        [string]$Severity = "INFO",

        [Parameter(Mandatory = $false)]
        [hashtable]$Attributes = @{},

        [Parameter(Mandatory = $false)]
        [string]$TraceId,

        [Parameter(Mandatory = $false)]
        [string]$SpanId
    )

    if ($null -eq $script:OtelConfig) {
        throw "OTEL Collector not initialized. Call Initialize-OtelCollector first."
    }

    $severityMap = @{
        "TRACE" = 1
        "DEBUG" = 5
        "INFO"  = 9
        "WARN"  = 13
        "ERROR" = 17
        "FATAL" = 21
    }

    $timeUnixNano = [string]([long]((Get-Date).ToUniversalTime() - [DateTime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).TotalMilliseconds * 1000000)

    $attributesArray = @()
    foreach ($key in $Attributes.Keys) {
        $attributesArray += @{
            key   = $key
            value = @{ stringValue = [string]$Attributes[$key] }
        }
    }

    $logRecord = @{
        timeUnixNano   = $timeUnixNano
        severityNumber = $severityMap[$Severity]
        severityText   = $Severity
        body           = @{ stringValue = $Message }
        attributes     = $attributesArray
    }

    if ($TraceId) { $logRecord.traceId = $TraceId }
    if ($SpanId) { $logRecord.spanId = $SpanId }

    $resourceAttributes = @(
        @{
            key   = "service.name"
            value = @{ stringValue = $script:OtelConfig.ServiceName }
        },
        @{
            key   = "service.version"
            value = @{ stringValue = $script:OtelConfig.ServiceVersion }
        }
    )

    foreach ($key in $script:OtelConfig.ResourceAttributes.Keys) {
        $resourceAttributes += @{
            key   = $key
            value = @{ stringValue = [string]$script:OtelConfig.ResourceAttributes[$key] }
        }
    }

    $payload = @{
        resourceLogs = @(
            @{
                resource = @{
                    attributes = $resourceAttributes
                }
                scopeLogs = @(
                    @{
                        scope = @{
                            name = "powershell-otel"
                        }
                        logRecords = @($logRecord)
                    }
                )
            }
        )
    }

    $endpoint = "$($script:OtelConfig.Endpoint)/v1/logs"

    try {
        $response = Invoke-RestMethod -Uri $endpoint -Method Post -Headers $script:OtelConfig.Headers -Body ($payload | ConvertTo-Json -Depth 20) -ErrorAction Stop
        Write-Verbose "Log sent successfully to $endpoint"
        return $response
    }
    catch {
        Write-Error "Failed to send log to OTEL collector: $_"
        throw
    }
}

function Send-OtelMetric {
    <#
    .SYNOPSIS
        Sends a metric to the OTEL collector.

    .DESCRIPTION
        Sends metric data to the configured OpenTelemetry collector endpoint.

    .PARAMETER Name
        The name of the metric

    .PARAMETER Value
        The numeric value of the metric

    .PARAMETER Type
        The metric type (Gauge, Counter, Histogram)

    .PARAMETER Unit
        The unit of measurement (e.g., "ms", "bytes", "count")

    .PARAMETER Attributes
        Additional attributes to include with the metric

    .EXAMPLE
        Send-OtelMetric -Name "http.request.duration" -Value 245 -Type "Histogram" -Unit "ms" -Attributes @{ method = "GET"; status = "200" }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,

        [Parameter(Mandatory = $true)]
        [double]$Value,

        [Parameter(Mandatory = $false)]
        [ValidateSet("Gauge", "Counter", "Histogram")]
        [string]$Type = "Gauge",

        [Parameter(Mandatory = $false)]
        [string]$Unit = "",

        [Parameter(Mandatory = $false)]
        [hashtable]$Attributes = @{}
    )

    if ($null -eq $script:OtelConfig) {
        throw "OTEL Collector not initialized. Call Initialize-OtelCollector first."
    }

    $timeUnixNano = [string]([long]((Get-Date).ToUniversalTime() - [DateTime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).TotalMilliseconds * 1000000)

    $attributesArray = @()
    foreach ($key in $Attributes.Keys) {
        $attributesArray += @{
            key   = $key
            value = @{ stringValue = [string]$Attributes[$key] }
        }
    }

    $dataPoint = @{
        timeUnixNano = $timeUnixNano
        attributes   = $attributesArray
    }

    switch ($Type) {
        "Gauge" {
            $dataPoint.asDouble = $Value
            $metricData = @{
                gauge = @{
                    dataPoints = @($dataPoint)
                }
            }
        }
        "Counter" {
            $dataPoint.asDouble = $Value
            $metricData = @{
                sum = @{
                    dataPoints              = @($dataPoint)
                    aggregationTemporality = 2
                    isMonotonic            = $true
                }
            }
        }
        "Histogram" {
            $dataPoint.count = 1
            $dataPoint.sum = $Value
            $metricData = @{
                histogram = @{
                    dataPoints              = @($dataPoint)
                    aggregationTemporality = 2
                }
            }
        }
    }

    $resourceAttributes = @(
        @{
            key   = "service.name"
            value = @{ stringValue = $script:OtelConfig.ServiceName }
        },
        @{
            key   = "service.version"
            value = @{ stringValue = $script:OtelConfig.ServiceVersion }
        }
    )

    foreach ($key in $script:OtelConfig.ResourceAttributes.Keys) {
        $resourceAttributes += @{
            key   = $key
            value = @{ stringValue = [string]$script:OtelConfig.ResourceAttributes[$key] }
        }
    }

    $metric = @{
        name = $Name
        unit = $Unit
    }

    $metric += $metricData

    $payload = @{
        resourceMetrics = @(
            @{
                resource = @{
                    attributes = $resourceAttributes
                }
                scopeMetrics = @(
                    @{
                        scope = @{
                            name = "powershell-otel"
                        }
                        metrics = @($metric)
                    }
                )
            }
        )
    }

    $endpoint = "$($script:OtelConfig.Endpoint)/v1/metrics"

    try {
        $response = Invoke-RestMethod -Uri $endpoint -Method Post -Headers $script:OtelConfig.Headers -Body ($payload | ConvertTo-Json -Depth 20) -ErrorAction Stop
        Write-Verbose "Metric sent successfully to $endpoint"
        return $response
    }
    catch {
        Write-Error "Failed to send metric to OTEL collector: $_"
        throw
    }
}

function Send-OtelTrace {
    <#
    .SYNOPSIS
        Sends a trace span to the OTEL collector.

    .DESCRIPTION
        Sends distributed trace data to the configured OpenTelemetry collector endpoint.

    .PARAMETER Name
        The name of the span

    .PARAMETER TraceId
        The trace ID (32 hex characters)

    .PARAMETER SpanId
        The span ID (16 hex characters)

    .PARAMETER ParentSpanId
        The parent span ID if this is a child span

    .PARAMETER Kind
        The span kind (INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER)

    .PARAMETER StartTime
        The start time of the span (defaults to current time)

    .PARAMETER EndTime
        The end time of the span (defaults to current time)

    .PARAMETER Attributes
        Additional attributes to include with the span

    .PARAMETER Status
        The span status (OK, ERROR, UNSET)

    .PARAMETER StatusMessage
        Optional status message for ERROR status

    .EXAMPLE
        Send-OtelTrace -Name "HTTP GET /api/users" -TraceId $traceId -SpanId $spanId -Kind "SERVER" -Attributes @{ "http.method" = "GET"; "http.status_code" = "200" }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,

        [Parameter(Mandatory = $false)]
        [string]$TraceId,

        [Parameter(Mandatory = $false)]
        [string]$SpanId,

        [Parameter(Mandatory = $false)]
        [string]$ParentSpanId,

        [Parameter(Mandatory = $false)]
        [ValidateSet("INTERNAL", "SERVER", "CLIENT", "PRODUCER", "CONSUMER")]
        [string]$Kind = "INTERNAL",

        [Parameter(Mandatory = $false)]
        [DateTime]$StartTime = (Get-Date).ToUniversalTime(),

        [Parameter(Mandatory = $false)]
        [DateTime]$EndTime = (Get-Date).ToUniversalTime(),

        [Parameter(Mandatory = $false)]
        [hashtable]$Attributes = @{},

        [Parameter(Mandatory = $false)]
        [ValidateSet("OK", "ERROR", "UNSET")]
        [string]$Status = "OK",

        [Parameter(Mandatory = $false)]
        [string]$StatusMessage
    )

    if ($null -eq $script:OtelConfig) {
        throw "OTEL Collector not initialized. Call Initialize-OtelCollector first."
    }

    if (-not $TraceId) {
        $TraceId = [System.Guid]::NewGuid().ToString("N")
    }

    if (-not $SpanId) {
        $SpanId = [System.Guid]::NewGuid().ToString("N").Substring(0, 16)
    }

    $kindMap = @{
        "INTERNAL" = 1
        "SERVER"   = 2
        "CLIENT"   = 3
        "PRODUCER" = 4
        "CONSUMER" = 5
    }

    $statusMap = @{
        "UNSET" = 0
        "OK"    = 1
        "ERROR" = 2
    }

    $startTimeUnixNano = [string]([long](($StartTime - [DateTime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).TotalMilliseconds * 1000000))
    $endTimeUnixNano = [string]([long](($EndTime - [DateTime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).TotalMilliseconds * 1000000))

    $attributesArray = @()
    foreach ($key in $Attributes.Keys) {
        $attributesArray += @{
            key   = $key
            value = @{ stringValue = [string]$Attributes[$key] }
        }
    }

    $span = @{
        traceId           = $TraceId
        spanId            = $SpanId
        name              = $Name
        kind              = $kindMap[$Kind]
        startTimeUnixNano = $startTimeUnixNano
        endTimeUnixNano   = $endTimeUnixNano
        attributes        = $attributesArray
        status            = @{
            code = $statusMap[$Status]
        }
    }

    if ($ParentSpanId) {
        $span.parentSpanId = $ParentSpanId
    }

    if ($StatusMessage) {
        $span.status.message = $StatusMessage
    }

    $resourceAttributes = @(
        @{
            key   = "service.name"
            value = @{ stringValue = $script:OtelConfig.ServiceName }
        },
        @{
            key   = "service.version"
            value = @{ stringValue = $script:OtelConfig.ServiceVersion }
        }
    )

    foreach ($key in $script:OtelConfig.ResourceAttributes.Keys) {
        $resourceAttributes += @{
            key   = $key
            value = @{ stringValue = [string]$script:OtelConfig.ResourceAttributes[$key] }
        }
    }

    $payload = @{
        resourceSpans = @(
            @{
                resource = @{
                    attributes = $resourceAttributes
                }
                scopeSpans = @(
                    @{
                        scope = @{
                            name = "powershell-otel"
                        }
                        spans = @($span)
                    }
                )
            }
        )
    }

    $endpoint = "$($script:OtelConfig.Endpoint)/v1/traces"

    try {
        $response = Invoke-RestMethod -Uri $endpoint -Method Post -Headers $script:OtelConfig.Headers -Body ($payload | ConvertTo-Json -Depth 20) -ErrorAction Stop
        Write-Verbose "Trace sent successfully to $endpoint"
        return @{
            TraceId  = $TraceId
            SpanId   = $SpanId
            Response = $response
        }
    }
    catch {
        Write-Error "Failed to send trace to OTEL collector: $_"
        throw
    }
}

function New-OtelTraceId {
    <#
    .SYNOPSIS
        Generates a new trace ID.

    .DESCRIPTION
        Creates a 32-character hexadecimal trace ID for use with distributed tracing.

    .EXAMPLE
        $traceId = New-OtelTraceId
    #>

    [CmdletBinding()]
    param()

    return [System.Guid]::NewGuid().ToString("N")
}

function New-OtelSpanId {
    <#
    .SYNOPSIS
        Generates a new span ID.

    .DESCRIPTION
        Creates a 16-character hexadecimal span ID for use with distributed tracing.

    .EXAMPLE
        $spanId = New-OtelSpanId
    #>

    [CmdletBinding()]
    param()

    return [System.Guid]::NewGuid().ToString("N").Substring(0, 16)
}

Export-ModuleMember -Function Initialize-OtelCollector, Send-OtelLog, Send-OtelMetric, Send-OtelTrace, New-OtelTraceId, New-OtelSpanId