LumaTrack.psm1
|
<# LumaTrack PowerShell module. Records automation runs, including the ones that failed, against the LumaTrack ingest API (POST /api/v1/runs, bearer authenticated). Reporting never throws. A scheduled task that dies because a telemetry endpoint was slow is worse than having no telemetry, so every failure path here writes a warning and returns a result object the caller can inspect. #> Set-StrictMode -Version Latest # Server-side maxima from the /runs request schema. $script:MaxExternalId = 200 $script:MaxFailureReason = 200 function New-LumaTrackResult { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object, is private to the module, and changes no state.')] param( [bool] $Ok, [System.Nullable[int]] $StatusCode = $null, [string] $Message = '', [bool] $Held = $false, [bool] $Deduplicated = $false ) [pscustomobject]@{ Ok = $Ok StatusCode = $StatusCode Message = $Message Held = $Held Deduplicated = $Deduplicated } } function New-LumaTrackRunPayload { <# .SYNOPSIS Build the /api/v1/runs body. Unset optional fields are omitted rather than sent as null, so the server's own defaults apply. #> [OutputType([hashtable])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory hashtable, is private to the module, and changes no state.')] param( [Parameter(Mandatory)] [string] $Automation, [string] $Status = 'success', [double] $DurationSeconds = -1, [int] $Units = -1, [string] $ExternalId, [string] $FailureReason, [hashtable] $Metadata ) $body = @{ automation = $Automation status = $Status source = 'powershell' } if ($DurationSeconds -ge 0) { $body.duration_seconds = [int][math]::Round($DurationSeconds) } if ($Units -ge 0) { $body.units = $Units } if ($ExternalId) { $body.external_id = $ExternalId.Substring(0, [math]::Min($ExternalId.Length, $script:MaxExternalId)) } if ($FailureReason) { $body.failure_reason = $FailureReason.Substring(0, [math]::Min($FailureReason.Length, $script:MaxFailureReason)) } if ($Metadata -and $Metadata.Count -gt 0) { $body.metadata = $Metadata } $body } function Send-LumaTrackRun { <# .SYNOPSIS Record one automation run in LumaTrack. .DESCRIPTION One HTTP call per execution. Report failures as well as successes: they cost money and save nothing, and a ledger that only hears about the good runs is marketing. This function does not throw. Inspect the returned object's Ok property when you care whether the report landed. .PARAMETER Automation The automation's slug, as shown in LumaTrack. .PARAMETER Status success, failure, skipped or cancelled. A skipped run deliberately did no work; a cancelled one was interrupted. Neither earns value and neither counts as a failure. .PARAMETER ExternalId Your system's run id. Makes retries idempotent: LumaTrack answers 200 and records nothing new when it has seen this id before. .PARAMETER Url Your LumaTrack host. Defaults to $env:LUMATRACK_URL, and to https://lumatrack.io when neither is set. Pass it only when you run LumaTrack on your own domain. .PARAMETER ApiKey A LumaTrack API key. Defaults to $env:LUMATRACK_KEY. An ingest-scope key is enough and cannot read your ledger back. .EXAMPLE Send-LumaTrackRun -Automation 'ad-account-cleanup' -Units 14 -DurationSeconds 42 .EXAMPLE Send-LumaTrackRun -Automation 'ad-account-cleanup' -Status failure -FailureReason 'auth/credential' #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Automation, [ValidateSet('success', 'failure', 'skipped', 'cancelled')] [string] $Status = 'success', [double] $DurationSeconds = -1, [int] $Units = -1, [string] $ExternalId, [string] $FailureReason, [hashtable] $Metadata, [string] $Url = $env:LUMATRACK_URL, [string] $ApiKey = $env:LUMATRACK_KEY, [int] $TimeoutSec = 15 ) if ([string]::IsNullOrWhiteSpace($ApiKey)) { $message = 'LumaTrack: set -ApiKey or $env:LUMATRACK_KEY. Nothing was reported.' Write-Warning $message return New-LumaTrackResult -Ok $false -Message $message } # Almost every script reports to the hosted service, so an unset host is # that rather than a mistake. A custom domain still passes -Url. if ([string]::IsNullOrWhiteSpace($Url)) { $Url = 'https://lumatrack.io' } $payload = New-LumaTrackRunPayload -Automation $Automation -Status $Status ` -DurationSeconds $DurationSeconds -Units $Units -ExternalId $ExternalId ` -FailureReason $FailureReason -Metadata $Metadata $endpoint = '{0}/api/v1/runs' -f $Url.TrimEnd('/') try { $statusCode = 0 $response = Invoke-RestMethod -Method Post -Uri $endpoint ` -Headers @{ Authorization = "Bearer $ApiKey" } ` -ContentType 'application/json' ` -Body ($payload | ConvertTo-Json -Depth 10 -Compress) ` -TimeoutSec $TimeoutSec ` -StatusCodeVariable statusCode $held = $false $deduplicated = $false if ($response.PSObject.Properties.Name -contains 'run') { $run = $response.run if ($run.PSObject.Properties.Name -contains 'held') { $held = [bool]$run.held } } if ($response.PSObject.Properties.Name -contains 'deduplicated') { $deduplicated = [bool]$response.deduplicated } if ($held) { Write-Warning "LumaTrack: run recorded but held, the plan's run cap is reached." } return New-LumaTrackResult -Ok $true -StatusCode $statusCode -Held $held -Deduplicated $deduplicated } catch { # Telemetry must never take down the job it measures. $code = $null $detail = $_.Exception.Message # Not every exception reaching here is an HTTP one. A DNS failure or a # refused connection surfaces as an exception type with no Response # property at all, and StrictMode makes touching it a terminating # error, which would defeat the whole point of this catch block. if ($_.Exception.PSObject.Properties.Name -contains 'Response') { $response = $_.Exception.Response if ($null -ne $response) { try { $code = [int]$response.StatusCode } catch { $code = $null } } } if ($_.ErrorDetails -and $_.ErrorDetails.Message) { # Read out here, while $_ is still the HTTP failure. Inside the # catch below it is the parse failure instead, which carries no # ErrorDetails, and asking one for a Message under StrictMode would # terminate the very call this block exists to keep alive. $rawDetail = $_.ErrorDetails.Message try { $parsed = $rawDetail | ConvertFrom-Json if ($parsed.PSObject.Properties.Name -contains 'error') { $detail = $parsed.error } } catch { $detail = $rawDetail } } Write-Warning "LumaTrack report failed: $detail" return New-LumaTrackResult -Ok $false -StatusCode $code -Message $detail } } function Invoke-LumaTrackTrackedCommand { <# .SYNOPSIS Run a scriptblock, time it, and record the outcome in LumaTrack. .DESCRIPTION Wraps the work so both paths report: a clean run records status=success with its duration, and a throwing one records status=failure with the exception as the failure reason, then rethrows so the caller still sees the job fail. This is the whole point of the wrapper. Hand-written try/catch reporting tends to grow a success call and never a failure call, and the resulting number is one nobody should present. .EXAMPLE Invoke-LumaTrackTrackedCommand -Automation 'ad-account-cleanup' -ScriptBlock { Disable-StaleADAccounts } .EXAMPLE Invoke-LumaTrackTrackedCommand -Automation 'mailbox-archive' -ExternalId $env:TaskRunId -ScriptBlock { Start-MailboxArchive } #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Automation, [Parameter(Mandatory)] [scriptblock] $ScriptBlock, [int] $Units = -1, [string] $ExternalId, [hashtable] $Metadata, [string] $Url = $env:LUMATRACK_URL, [string] $ApiKey = $env:LUMATRACK_KEY, [int] $TimeoutSec = 15 ) $timer = [System.Diagnostics.Stopwatch]::StartNew() try { $output = & $ScriptBlock $timer.Stop() Send-LumaTrackRun -Automation $Automation -Status success ` -DurationSeconds $timer.Elapsed.TotalSeconds -Units $Units ` -ExternalId $ExternalId -Metadata $Metadata ` -Url $Url -ApiKey $ApiKey -TimeoutSec $TimeoutSec | Out-Null return $output } catch { $timer.Stop() Send-LumaTrackRun -Automation $Automation -Status failure ` -DurationSeconds $timer.Elapsed.TotalSeconds -Units $Units ` -ExternalId $ExternalId -FailureReason $_.Exception.Message -Metadata $Metadata ` -Url $Url -ApiKey $ApiKey -TimeoutSec $TimeoutSec | Out-Null throw } } Export-ModuleMember -Function Send-LumaTrackRun, Invoke-LumaTrackTrackedCommand |