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