Private/Telemetry.ps1

<#
.SYNOPSIS
    Internal helpers for Invoke-TelemetryCollection.

.NOTES
    Private helpers for the tcs.core module.
#>


# Stopwatches for in-flight commands, keyed by ExecutionID
$script:TelemetryTimers = @{}
$script:TelemetryHttpClient = $null
$script:TelemetryInstallationId = $null

# Settings read from disk for modules that have not called Get-ModuleConfig in this session
$script:TelemetryConfigCache = @{}

function Get-TelemetryModuleConfig {
    <#
    .SYNOPSIS
        Returns the settings that decide whether and where a module sends telemetry.

    .DESCRIPTION
        Uses the session configuration loaded by Get-ModuleConfig when there is one. Otherwise
        reads the module's settings file (<config root>/<ModuleName>/Module.Config.json) over the
        tcs.core defaults, so a module's Telemetry = $false is honoured even when the module
        never called Get-ModuleConfig. The module path is not needed.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$ModuleName
    )

    $config = $script:ModuleConfigCache[$ModuleName]
    if ($config) {
        return $config
    }
    if ($script:TelemetryConfigCache.ContainsKey($ModuleName)) {
        return $script:TelemetryConfigCache[$ModuleName]
    }

    $config = Get-DefaultModuleConfig
    if (Test-ModuleNameValid -Name $ModuleName) {
        $configFile = Join-Path -Path (Join-Path -Path (Get-ModuleConfigRoot) -ChildPath $ModuleName) -ChildPath 'Module.Config.json'
        if (Test-Path -LiteralPath $configFile) {
            try {
                $stored = Read-JsonFileAsHashtable -Path $configFile
                foreach ($key in $stored.Keys) {
                    $config[$key] = ConvertTo-ConfigValueType -Value $stored[$key] -DefaultValue $config[$key] -Key $key
                }
            }
            catch {
                Write-Verbose "Could not read '$configFile' for telemetry settings: $($_.Exception.Message)"
            }
        }
    }

    if ($script:TelemetryConfigCache.Count -gt 100) {
        $script:TelemetryConfigCache.Clear()
    }
    $script:TelemetryConfigCache[$ModuleName] = $config
    return $config
}

function Resolve-TelemetryApiKey {
    <#
    .SYNOPSIS
        Returns the telemetry API key in plain text. Values stored with Protect-ConfigValue
        ('tcs:v1:...') are decrypted; plain-text values written by tcs.core 0.3.0 are used as they are.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Value
    )

    if ([string]::IsNullOrEmpty($Value) -or -not $Value.StartsWith("$($script:ProtectedValuePrefix):")) {
        return $Value
    }
    return (Unprotect-ConfigValue -EncryptedValue $Value -ErrorAction Stop)
}

function Test-TelemetryOptOut {
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    return ([string]$env:TCS_TELEMETRY_OPTOUT -in @('1', 'true', 'yes'))
}

function Get-TelemetryInstallationId {
    <#
    .SYNOPSIS
        Returns a random, anonymous ID for this user profile, created on first use.
        It is not derived from any hardware, user or machine identifier.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    if ($script:TelemetryInstallationId) {
        return $script:TelemetryInstallationId
    }

    $idPath = Join-Path -Path (Join-Path -Path (Get-ModuleConfigRoot) -ChildPath 'tcs.core') -ChildPath 'Telemetry.json'
    $id = $null
    try {
        if (Test-Path -LiteralPath $idPath) {
            $id = [string](Read-JsonFileAsHashtable -Path $idPath)['InstallationId']
        }
        if ([string]::IsNullOrWhiteSpace($id)) {
            $id = [guid]::NewGuid().ToString()
            Write-JsonFile -Path $idPath -Data @{ InstallationId = $id }
        }
    }
    catch {
        # Fall back to a per-session ID if the profile is not writable
        if ([string]::IsNullOrWhiteSpace($id)) {
            $id = [guid]::NewGuid().ToString()
        }
        Write-Verbose "Using a per-session telemetry ID: $($_.Exception.Message)"
    }

    $script:TelemetryInstallationId = $id
    return $id
}

function Get-TelemetryPlatform {
    [CmdletBinding()]
    [OutputType([string])]
    param()

    if (Test-IsWindowsPlatform) { return 'Windows' }
    if ($IsMacOS) { return 'macOS' }
    if ($IsLinux) { return 'Linux' }
    return 'Unknown'
}

function Send-TelemetryPayload {
    <#
    .SYNOPSIS
        Posts a JSON payload without waiting for the response (fire and forget).
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,

        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$Body
    )

    if (-not $script:TelemetryHttpClient) {
        if (-not ('System.Net.Http.HttpClient' -as [type])) {
            Add-Type -AssemblyName System.Net.Http
        }
        if ($PSVersionTable.PSEdition -eq 'Desktop') {
            # .NET Framework may not enable TLS 1.2 by default; add it without removing anything
            [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
        }
        $client = New-Object System.Net.Http.HttpClient
        $client.Timeout = [TimeSpan]::FromSeconds(5)
        $script:TelemetryHttpClient = $client
    }

    $request = New-Object System.Net.Http.HttpRequestMessage -ArgumentList ([System.Net.Http.HttpMethod]::Post), $Uri
    if (-not [string]::IsNullOrEmpty($ApiKey)) {
        $null = $request.Headers.TryAddWithoutValidation('X-API-Key', $ApiKey)
    }
    $request.Content = New-Object System.Net.Http.StringContent -ArgumentList $Body, ([System.Text.Encoding]::UTF8), 'application/json'

    # The request runs on the thread pool; the caller never waits and failures are ignored
    $null = $script:TelemetryHttpClient.SendAsync($request)
}