Private/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
Successful create writes across all enabled directories — one per directory, not per
person (0 in ReadOnly).

.PARAMETER UpdateCount
Successful update/rename/move writes across all enabled directories — one per directory,
not per person (0 in ReadOnly).

.PARAMETER DeactivateCount
Successful deactivate writes across all enabled directories — one per directory, so a
person deactivated in both AD and Google counts twice (0 in ReadOnly).

.PARAMETER GroupAddCount
Group memberships added across all enabled directories (0 in ReadOnly, and a
directory contributes 0 while its group processing is off or in WhatIf).

.PARAMETER GroupRemoveCount
Group memberships removed across all enabled directories (0 in ReadOnly, and a
directory contributes 0 while its group processing is off or in WhatIf).

.PARAMETER WriteFailureCount
Number of individual writes that failed this run (a count only - which users/groups
failed never leaves the machine; see the RunResult Applied list locally).

.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 -GroupAddCount 5 -GroupRemoveCount 2

.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,
        [int]$GroupAddCount = 0,
        [int]$GroupRemoveCount = 0,
        [int]$WriteFailureCount = 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
        groupAddCount    = $GroupAddCount
        groupRemoveCount = $GroupRemoveCount
        writeFailureCount = $WriteFailureCount
        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
}