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+.
- Synchronous C# (no
async/await). - Single
-Headersdictionary onConnect-OTLPcarries every authentication header (bearer tokens, API keys, custom headers). Pass plainStringorSecureStringvalues — plain strings are converted toSecureStringat 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), orNDJson(Rootprint/api/ingest/ndjson). - Single
build.ps1drives build, package, release, and publish.
From the PowerShell Gallery (once the next release is published):
Install-Module -Name PSOTLP -Scope CurrentUserFrom source:
git clone https://github.com/Grace-Solutions/PSOTLP.git
cd PSOTLP
./build.ps1 -Configuration Release
Import-Module ./Module/PSOTLP/PSOTLP.psd1 -ForceConnect-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-OTLPCustom redaction:
$patterns = @([regex]'(?i)x-custom-secret\s*[:=]\s*\S+')
Connect-OTLP -EndpointUri 'https://otel.example.com' -RedactPattern $patternsSee docs/ for per-cmdlet reference and
docs/DesignSpec.md for the full design specification.
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.
Any OTLP/HTTP-compatible backend works. Two are called out below because their authentication and content-type rules differ slightly.
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-OTLPRootprint'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-OTLPRootprint 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 |
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 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| 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.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 -ForceSigning is opt-in and fail-close: -Sign requires
SIGNING_CERTIFICATE_BASE64 and SIGNING_CERTIFICATE_PASSWORD; if either is
missing the build aborts before publishing.
The .github/workflows/release.yml workflow:
- Builds and packages on every push to
mainand everyv*tag. - Publishes to the PowerShell Gallery on tag pushes, or on
workflow_dispatchwithpublish=true. - Optionally signs when
workflow_dispatchis invoked withsign=trueand the signing secrets are configured.
Tests are not executed in CI; run them locally before opening a PR.
main is protected — direct pushes are disabled and all changes must land
through a pull request. Before opening a PR:
- Run
./build.ps1 -Configuration Release -RunTestsand confirm tests pass. - Keep commits scoped and use descriptive messages.
- Update
docs/when the public surface changes.
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 Packagecheck to pass before merge. - Allow repository administrators to bypass the above for break-glass scenarios.
Copyright (c) Grace Solutions. All rights reserved.