Public/Core/Send-IDBridgeTelemetry.ps1

<#
.SYNOPSIS
Send anonymous usage telemetry for a completed run (see PRIVACY.md).

.DESCRIPTION
Called from the finally block of Invoke-IDBridge after every run. Builds the payload for
the configured tier (Telemetry.Tier, default 'Basic') and POSTs it to the IDBridge Pulse
ingest endpoint, fire-and-forget:

  - Off — nothing is sent.
  - Basic (default) — anonymous aggregate counts only. No stable identifier is sent,
               so runs from this install cannot be linked to each other.
  - Enhanced — adds the install's random SiteID (Get-IDBridgeSiteID) so runs form a
               timeline in the Pulse dashboard, plus — on failed runs — the exception
               CLASS name and throwing FUNCTION name. Never the exception message,
               which can contain UPNs/DNs.

No names, usernames, email addresses, person IDs, or directory records are transmitted
at any tier. Counts are APPLIED work, so a ReadOnly run reports zeros alongside
readOnly = true. The exact payload is logged at Trace level, so running with
-TraceLogging shows precisely what leaves the machine.

The send uses a short timeout with NO retries (a retry after a client-side timeout could
double-count a Basic event, which has no identifier to dedupe on) and swallows all
errors, logging locally only — telemetry can never fail, delay, or mask a run.

.PARAMETER Success
Whether the run completed without a fatal error.

.PARAMETER DurationSeconds
Total run duration in seconds.

.PARAMETER ManagedCount
Number of source records processed this run.

.PARAMETER CreateCount
Users created across all enabled directories (0 in ReadOnly).

.PARAMETER UpdateCount
Users updated/renamed/moved across all enabled directories (0 in ReadOnly).

.PARAMETER DeactivateCount
Users deactivated across all enabled directories (0 in ReadOnly).

.PARAMETER RunError
The ErrorRecord of a failed run. Only the exception class name and throwing function
name are extracted from it, and only at the Enhanced tier.

.OUTPUTS
None. Side effects: one HTTP POST (Basic/Enhanced) and log output.

.EXAMPLE
Send-IDBridgeTelemetry -Success $true -DurationSeconds 42 -ManagedCount 1250 -CreateCount 3 -UpdateCount 12 -DeactivateCount 1

.NOTES
   Created by: Sam Cattanach
   Modified: 2026-07-06
#>

function Send-IDBridgeTelemetry {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [bool]$Success,

        [int]$DurationSeconds = 0,
        [int]$ManagedCount = 0,
        [int]$CreateCount = 0,
        [int]$UpdateCount = 0,
        [int]$DeactivateCount = 0,

        [System.Management.Automation.ErrorRecord]$RunError
    )

    $IDConfig = Get-IDBridgeConfig

    #region Resolve Tier
    # Missing block/key = 'Basic' (the documented default); an unrecognized value fails safe to Off.
    $tier = 'Basic'
    if ($IDConfig.ContainsKey('Telemetry') -and $IDConfig.Telemetry.Tier) {
        $tier = "$($IDConfig.Telemetry.Tier)"
    }

    if ($tier -notin @('Basic', 'Enhanced', 'Off')) {
        Write-Log -Message "Telemetry: Unrecognized Telemetry.Tier value '$tier' - nothing sent. Valid values: Basic, Enhanced, Off." -Level Warn
        return
    }

    if ($tier -eq 'Off') {
        Write-Log -Message "Telemetry: Off - nothing sent." -Level Trace
        return
    }

    Write-Log -Message "Telemetry: $tier tier - sending anonymous aggregate usage counts (no names, usernames, or IDs - see PRIVACY.md). Set Telemetry.Tier = 'Off' or run with -DisableTelemetry to disable."
    #endregion Resolve Tier

    #region Build Payload
    $directories = @(
        if ($IDConfig.AD.enabled -eq $true) { 'AD' }
        if ($IDConfig.Google.enabled -eq $true) { 'Google' }
    ) -join '+'
    if (-not $directories) { $directories = 'None' }

    $payload = @{
        schemaVersion   = 1
        tier            = $tier
        moduleVersion   = "$($MyInvocation.MyCommand.Module.Version)"
        psVersion       = "$($PSVersionTable.PSVersion)"
        success         = $Success
        readOnly        = ($IDConfig.Debug.readOnly -eq $true)
        testRun         = ($IDConfig.Debug.testRun -eq $true)
        directories     = $directories
        managedCount    = $ManagedCount
        createCount     = $CreateCount
        updateCount     = $UpdateCount
        deactivateCount = $DeactivateCount
        durationSeconds = $DurationSeconds
    }

    if ($tier -eq 'Enhanced') {
        $payload.siteID = Get-IDBridgeSiteID

        if ($RunError) {
            # Exception CLASS and FUNCTION name only - message text can contain UPNs/DNs and never leaves the box.
            $payload.errorType = $RunError.Exception.GetType().Name

            $errorFunction = "$($RunError.InvocationInfo.MyCommand)"
            if (-not $errorFunction -and $RunError.ScriptStackTrace) {
                # A Throw statement has no MyCommand; take the function name from the first stack
                # frame ("at Function-Name, <file>: line N") and drop the file path after the comma.
                $errorFunction = ($RunError.ScriptStackTrace -split "`n")[0] -replace '^at ', '' -replace ',.*$', ''
            }
            $payload.errorFunction = $errorFunction
        }
    }

    $payloadJson = $payload | ConvertTo-Json -Compress
    Write-Log -Message "Telemetry: Payload: $payloadJson" -Level Trace
    #endregion Build Payload

    #region Send
    $endpoint = 'https://pulse.scnlabs.net/api/ingest'
    if ($IDConfig.ContainsKey('Telemetry') -and $IDConfig.Telemetry.Endpoint) {
        $endpoint = $IDConfig.Telemetry.Endpoint
    }

    try {
        # 10s accommodates an ingest-backend cold start; 2s was routinely exceeded by the
        # first request after idle. Still one attempt only — see the no-retries note above.
        Invoke-RestMethod -Uri $endpoint -Method Post -Body $payloadJson -ContentType 'application/json' -TimeoutSec 10 | Out-Null
        Write-Log -Message "Telemetry: Sent." -Level Trace
    }
    catch {
        Write-Log -Message "Telemetry: Send failed (non-fatal, run unaffected): $($_.Exception.GetType().Name)" -Level Trace
    }
    #endregion Send
}