Skip to content

Grace-Solutions/PSOTLP

Repository files navigation

PSOTLP

PowerShell binary module (C#, .NET Standard 2.0) that emits OpenTelemetry Protocol (OTLP) logs, traces, and metric stubs to any OTLP/HTTP endpoint from Windows PowerShell 5.1 or PowerShell 7+.

Highlights

  • Synchronous C# (no async/await).
  • Single -Headers dictionary on Connect-OTLP carries every authentication header (bearer tokens, API keys, custom headers). Pass plain String or SecureString values — plain strings are converted to SecureString at parameter binding and are only materialized as plaintext at the HTTP request boundary.
  • Centralized redaction with default patterns plus user-supplied Regex[].
  • In-memory script capture via Invoke-OTLPScript — no transcript, no file locks, no interference with any transcript the caller may already have running.
  • Spans, span events, and trace batch export.
  • Pluggable wire encoding: Json (OTLP/HTTP), Protobuf (OTLP/HTTP), or NDJson (Rootprint /api/ingest/ndjson).
  • Single build.ps1 drives build, package, release, and publish.

Install

From the PowerShell Gallery (once the next release is published):

Install-Module -Name PSOTLP -Scope CurrentUser

From source:

git clone https://github.com/Grace-Solutions/PSOTLP.git
cd PSOTLP
./build.ps1 -Configuration Release
Import-Module ./Module/PSOTLP/PSOTLP.psd1 -Force

Quick start

Connect-OTLP -EndpointUri 'https://otel.example.com' -ServiceName 'my-script'

Write-OTLPLog -Body 'Bootstrap started' -Severity Information

Invoke-OTLPScript -ScriptBlock {
    Write-Verbose 'Configuring services'
    Get-Service | Select-Object -First 5
}

Disconnect-OTLP

Custom redaction:

$patterns = @([regex]'(?i)x-custom-secret\s*[:=]\s*\S+')
Connect-OTLP -EndpointUri 'https://otel.example.com' -RedactPattern $patterns

See docs/ for per-cmdlet reference and docs/DesignSpec.md for the full design specification.

Wrapping existing scripts in a loop

Invoke-OTLPScript captures every PowerShell stream emitted inside the wrapped script — Write-Output, Write-Verbose, Write-Information, Write-Warning, Write-Error, Write-Debug, and the script's success output — and ships each record to the connected OTLP endpoint. The wrapped script does not need to call any OTLP cmdlets; existing scripts run unmodified via & $Path.

Connect-OTLP -EndpointUri 'https://otel.example.com' -ServiceName 'maintenance-runner'

$ScriptFiles = Get-ChildItem -Path 'C:\Ops\Scripts' -Filter '*.ps1' -File

for ($i = 0; $i -lt $ScriptFiles.Count; $i++)
{
    $Counter = $i + 1
    $ScriptFile = $ScriptFiles[$i]

    Write-Progress -Activity 'Running maintenance scripts' -Status "$Counter of $($ScriptFiles.Count): $($ScriptFile.Name)" -PercentComplete (($Counter / $ScriptFiles.Count) * 100)

    Write-OTLPLog -Body "Attempting to run script $Counter of $($ScriptFiles.Count)" -Severity Information
    Write-OTLPLog -Body "Script path: $($ScriptFile.FullName)" -Severity Information

    $SharedState = [hashtable]::Synchronized((New-Object -TypeName 'System.Collections.Hashtable'))
        $SharedState.Success = $False
        $SharedState.ExitCode = $Null
        $SharedState.Error = $Null

    try
    {
        $InvokeOTLPScriptParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
            $InvokeOTLPScriptParameters.ScriptBlock = {
                                                            param($Path)

                                                            $ErrorActionPreference = 'Stop'

                                                            try
                                                                {
                                                                    & $Path
                                                                    $SharedState.ExitCode = $LASTEXITCODE
                                                                    $SharedState.Success = $True
                                                                }
                                                            catch
                                                                {
                                                                    $SharedState.Success = $False
                                                                    $SharedState.Error = $_.Exception.Message
                                                                    throw
                                                                }
                                                      }

            $InvokeOTLPScriptParameters.ArgumentList = New-Object -TypeName 'System.Collections.Generic.List[System.Object]'
                $InvokeOTLPScriptParameters.ArgumentList.Add($ScriptFile.FullName)
            $InvokeOTLPScriptParameters.SessionName = $ScriptFile.BaseName
            $InvokeOTLPScriptParameters.Attribute = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
                $InvokeOTLPScriptParameters.Attribute['script.name'] = $ScriptFile.Name
                $InvokeOTLPScriptParameters.Attribute['script.runner'] = 'maintenance-runner'
                $InvokeOTLPScriptParameters.Attribute['script.index'] = $Counter
            $InvokeOTLPScriptParameters.SharedState = $SharedState
            $InvokeOTLPScriptParameters.BatchSize = 100
            $InvokeOTLPScriptParameters.PassThru = $True
            $InvokeOTLPScriptParameters.ErrorAction = 'Stop'
            $InvokeOTLPScriptParameters.Verbose = $True

        $InvokeOTLPScriptResult = Invoke-OTLPScript @InvokeOTLPScriptParameters
    }
    catch
    {
        Write-OTLPLog -Body "Script $Counter of $($ScriptFiles.Count) threw: $($ScriptFile.FullName) - $($_.Exception.Message)" -Severity Error
    }
    finally
    {
        if ($SharedState.Success)
        {
            Write-OTLPLog -Body "Completed script $Counter of $($ScriptFiles.Count): $($ScriptFile.Name) (exit code: $($SharedState.ExitCode))" -Severity Information
        }
        else
        {
            Write-OTLPLog -Body "Failed script $Counter of $($ScriptFiles.Count): $($ScriptFile.Name) - $($SharedState.Error)" -Severity Error
        }
    }
}

Write-Progress -Activity 'Running maintenance scripts' -Completed

Disconnect-OTLP

-SharedState injects a [hashtable]::Synchronized dictionary as $SharedState inside the child runspace. The wrapped script sets Success, ExitCode, and Error on it so the parent loop can decide pass/fail per script. Combined with $ErrorActionPreference = 'Stop' inside the script and -ErrorAction Stop on Invoke-OTLPScript, infrastructure failures and script throws both surface in the parent catch, while non-terminating Write-Error calls still flow to the OTLP backend as Severity=Error log records.

Each captured record is tagged with the script.path, script.runner, and script.index attributes above plus powershell.stream, powershell.session.id, and powershell.session.name, so per-script logs are easy to filter at the backend.

Backends

Any OTLP/HTTP-compatible backend works. Two are called out below because their authentication and content-type rules differ slightly.

HyperDX

HyperDX accepts standard OTLP/HTTP at https://in-otel.hyperdx.io/v1/logs and authenticates with the authorization header set to the raw API key (no Bearer prefix). See the HyperDX OpenTelemetry docs and the cURL walkthrough.

Plain-string values in -Headers are accepted; PSOTLP converts them to SecureString at parameter binding. Use a SecureString (for example from Read-Host -AsSecureString) when you want to keep the value out of the session's command history or transcript.

$ConnectOTLPParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $ConnectOTLPParameters.EndpointUri = 'https://in-otel.hyperdx.io'
    $ConnectOTLPParameters.Headers = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
        $ConnectOTLPParameters.Headers['authorization'] = $Env:HYPERDX_API_KEY
    $ConnectOTLPParameters.ServiceName = 'my-script'
    $ConnectOTLPParameters.Compression = [PSOTLP.Common.OTLPCompression]::Gzip

Connect-OTLP @ConnectOTLPParameters

$InvokeOTLPScriptParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $InvokeOTLPScriptParameters.ScriptBlock = {
                                                    Write-Information 'Bootstrap started' -InformationAction Continue
                                                    Get-Service | Select-Object -First 5
                                              }

Invoke-OTLPScript @InvokeOTLPScriptParameters

Disconnect-OTLP

Rootprint

Rootprint's OTLP endpoint is POST https://<your-rootprint>/v1/logs and authenticates with Authorization: Bearer <ingest-token>. The target index is pinned by the ingest API key (create one in Settings → API keys); the exporter does not pick the index. See the Rootprint OTLP reference and Manage indexes.

Rootprint requires Content-Type: application/x-protobuf at /v1/logs and rejects JSON with HTTP 415. Use -Encoding Protobuf:

$ConnectOTLPParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $ConnectOTLPParameters.EndpointUri = 'https://rootprint.example.com'
    $ConnectOTLPParameters.Headers = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
        $ConnectOTLPParameters.Headers['Authorization'] = "Bearer $($Env:ROOTPRINT_INGEST_TOKEN)"
    $ConnectOTLPParameters.ServiceName = 'my-script'
    $ConnectOTLPParameters.Encoding = [PSOTLP.Common.OTLPEncoding]::Protobuf
    $ConnectOTLPParameters.Compression = [PSOTLP.Common.OTLPCompression]::Gzip

Connect-OTLP @ConnectOTLPParameters

$InvokeOTLPScriptParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $InvokeOTLPScriptParameters.ScriptBlock = {
                                                    Write-Information 'Bootstrap started' -InformationAction Continue
                                              }

Invoke-OTLPScript @InvokeOTLPScriptParameters

Disconnect-OTLP

Rootprint also accepts a flat NDJSON document per line at POST /api/ingest/ndjson with Content-Type: application/x-ndjson. Select it with -Encoding NDJson; the URI builder routes the logs signal to the /api/ingest/ndjson path automatically and each log record is emitted as a single snake_case JSON document terminated by \n. NDJSON is logs-only and will throw on trace export.

$Headers = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $Headers['Authorization'] = "Bearer $($Env:ROOTPRINT_INGEST_TOKEN)"

Connect-OTLP `
    -EndpointUri 'https://rootprint.example.com' `
    -Headers $Headers `
    -ServiceName 'my-script' `
    -Encoding NDJson

Write-OTLPLog -Body 'Bootstrap started' -Severity Information
Send-OTLPLogBatch -InputObject (Get-Content .\events.json | ConvertFrom-Json)

Disconnect-OTLP

Encoding reference

-Encoding Content-Type Default logs path Traces Metrics
Json (default) application/json /v1/logs Supported Supported
Protobuf application/x-protobuf /v1/logs Supported Supported
NDJson application/x-ndjson /api/ingest/ndjson Not supported Not supported

The OpenTelemetry .proto files live under src/PSOTLP/Proto/ and are compiled into the PSOTLP.dll at build time by Grpc.Tools. They are not shipped with the module and do not need to be distributed alongside it.

Metrics

Metrics use the standard OTLP /v1/metrics path and support Gauge and Sum instrument types with Delta or Cumulative aggregation temporality.

Connect-OTLP -EndpointUri 'https://otel.example.com' -ServiceName 'cloudbase-init'

# Gauge (point-in-time value)
$GaugeAttribute = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $GaugeAttribute['state'] = 'used'
Write-OTLPMetric -Name 'system.memory.usage' -Unit 'By' -Value 1.42e9 -Attribute $GaugeAttribute

# Monotonic cumulative counter
$CounterAttribute = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $CounterAttribute['result'] = 'success'
Write-OTLPMetric -Name 'driver.install.count' -Type Sum -Temporality Cumulative -IsMonotonic `
    -IntValue 1 -AsInt -Attribute $CounterAttribute

# Batch
$metrics = 1..5 | ForEach-Object {
    $m = New-Object PSOTLP.Models.OTLPMetric
    $m.Name = 'sample.gauge'; $m.DoubleValue = $_; $m
}
$metrics | Send-OTLPMetricBatch

Cmdlets

Cmdlet Purpose
Connect-OTLP Establish a reusable OTLP connection.
Disconnect-OTLP Clear the current connection.
Get-OTLPConnection Return sanitized connection metadata.
Write-OTLPLog Emit a single structured log record.
Send-OTLPLogBatch Send a batch of log records over OTLP/HTTP.
Invoke-OTLPScript Run a script block in a hosted runspace and emit every captured stream as an OTLP log.
Start-OTLPSpan / Stop-OTLPSpan Manage trace spans.
Write-OTLPSpanEvent Attach an event to the current span.
Send-OTLPTraceBatch Send a batch of trace spans.
Write-OTLPMetric Emit a single Gauge or Sum metric data point.
Send-OTLPMetricBatch Send a batch of metric data points.

Build

./build.ps1 -Configuration Release              # build + stage module
./build.ps1 -Configuration Release -RunTests    # build + run Pester 5 unit tests
./build.ps1 -Configuration Release -CreateRelease -Force
./build.ps1 -Configuration Release -CreateRelease -Publish -PublishPowerShellGallery -Sign -Force

Signing is opt-in and fail-close: -Sign requires SIGNING_CERTIFICATE_BASE64 and SIGNING_CERTIFICATE_PASSWORD; if either is missing the build aborts before publishing.

CI / Release

The .github/workflows/release.yml workflow:

  1. Builds and packages on every push to main and every v* tag.
  2. Publishes to the PowerShell Gallery on tag pushes, or on workflow_dispatch with publish=true.
  3. Optionally signs when workflow_dispatch is invoked with sign=true and the signing secrets are configured.

Tests are not executed in CI; run them locally before opening a PR.

Contributing

main is protected — direct pushes are disabled and all changes must land through a pull request. Before opening a PR:

  1. Run ./build.ps1 -Configuration Release -RunTests and confirm tests pass.
  2. Keep commits scoped and use descriptive messages.
  3. Update docs/ when the public surface changes.

Branch protection policy

Apply the following rules to main in the Git server admin UI:

  • Disable direct pushes (pull requests only).
  • Require at least one approving review.
  • Require the PSOTLP Release / Build and Package check to pass before merge.
  • Allow repository administrators to bypass the above for break-glass scenarios.

License

Copyright (c) Grace Solutions. All rights reserved.

About

PowerShell binary module (C#, .NET Standard 2.0) that emits OpenTelemetry Protocol (OTLP) logs, traces, and metric stubs to any OTLP/HTTP endpoint from Windows PowerShell 5.1 or PowerShell 7+.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors