Public/Invoke-TcsCommand.ps1
|
<# .SYNOPSIS Runs the body of a tcs command and records anonymous telemetry for it. .DESCRIPTION The Invoke-TcsCommand function replaces the telemetry boilerplate in tcs module commands. Wrap the body of a command in it: function Get-Widget { [CmdletBinding()] param([string]$Name) Invoke-TcsCommand -ScriptBlock { Get-Item -Path $Name } } The script block runs in the scope of the calling command (it is dot-sourced), so it reads and sets the command's variables as if it were written inline, and $PSCmdlet, $_ and ShouldProcess work as usual. Two automatic variables are different inside the script block: $PSBoundParameters and $MyInvocation describe the script block, not the command. Copy them to a variable before Invoke-TcsCommand if the body needs them. What it does: - Writes exactly what the script block outputs, unchanged (collections are not unrolled or wrapped), and nothing else. - Reports the run as failed when the script block throws a terminating error or writes a non-terminating error (Write-Error or a cmdlet error) that reaches the error stream. Errors that are caught, or silenced with -ErrorAction SilentlyContinue/Ignore, do not count. Errors written directly with $PSCmdlet.WriteError() bypass it; use the -Token form and Complete-TcsTelemetry -Failed for those. - Rethrows terminating errors unchanged, and passes non-terminating errors through. - Reports only the outermost run: when an exported command calls another exported command of the same module, the inner run sends nothing (it is skipped, not tagged). - Never fails or changes the command because of telemetry; telemetry problems are written to the verbose stream only. Without -Token, one call is one run: it starts and completes the telemetry itself. The command, module and version are taken from the calling command. With -Token (from Start-TcsTelemetry), it only records errors on that token. Use this in the process block of pipeline functions, and complete the token in the end block. If the script block throws, or the pipeline is stopped early (for example by Select-Object -First), the token is completed here, because the end block will not run. .PARAMETER ScriptBlock The body of the command. .PARAMETER CommandName The name reported for the command. Defaults to the name of the calling command. .PARAMETER ModuleName The module the command belongs to. Defaults to the module of the calling command. .PARAMETER ModuleVersion The module version. Defaults to the version of the calling command's module. .PARAMETER Token A token from Start-TcsTelemetry. Errors are recorded on it and the token is completed only if the script block throws or the pipeline is stopped. .INPUTS None This function does not accept pipeline input. .OUTPUTS System.Object The output of the script block. .EXAMPLE function Remove-Widget { [CmdletBinding(SupportsShouldProcess)] param([Parameter(Mandatory)][string]$Name) $bound = $PSBoundParameters Invoke-TcsCommand -ScriptBlock { if ($PSCmdlet.ShouldProcess($Name, 'Remove widget')) { Invoke-RestMethod -Method Delete -Uri "https://api.example.com/widgets/$Name" } Write-Verbose "Parameters: $($bound.Keys -join ', ')" } } A simple function. $PSBoundParameters is copied to $bound first because inside the script block it describes the script block. .EXAMPLE function Get-Widget { [CmdletBinding()] param([Parameter(ValueFromPipeline)][string]$Name) begin { $telemetry = Start-TcsTelemetry } process { Invoke-TcsCommand -Token $telemetry -ScriptBlock { Invoke-RestMethod -Uri "https://api.example.com/widgets/$Name" } } end { Complete-TcsTelemetry -Token $telemetry } } A pipeline function: one event for the whole pipeline run, failed if any item failed. .NOTES Author: Nigel Tatschner Company: TheCodeSaiyan Invoke-TelemetryCollection is unchanged and still works for existing commands. Lines that native programs in the script block write to stderr are passed on through the error stream (without the wrapper, PowerShell 7 writes them straight to the console) and never mark the run as failed. On Windows PowerShell 5.1, a native program that writes to stderr while $ErrorActionPreference is 'Stop' raises a NativeCommandError, as it does whenever its errors are redirected; set $ErrorActionPreference = 'Continue' inside the script block before such calls. .LINK Start-TcsTelemetry .LINK Complete-TcsTelemetry #> function Invoke-TcsCommand { [CmdletBinding(DefaultParameterSetName = 'Command')] [OutputType([System.Object])] param( [Parameter(Mandatory, Position = 0, HelpMessage = 'The body of the command.')] [scriptblock]$ScriptBlock, [Parameter(ParameterSetName = 'Command', HelpMessage = 'Name reported for the command.')] [string]$CommandName, [Parameter(ParameterSetName = 'Command', HelpMessage = 'Module the command belongs to.')] [string]$ModuleName, [Parameter(ParameterSetName = 'Command', HelpMessage = 'Version of the module.')] [string]$ModuleVersion, [Parameter(Mandatory, ParameterSetName = 'Token', HelpMessage = 'Token from Start-TcsTelemetry.')] [PSTypeName('Tcs.TelemetryToken')] [PSCustomObject]$Token ) $ownsToken = $PSCmdlet.ParameterSetName -eq 'Command' if ($ownsToken) { $Token = $null try { $invocation = $null $callStack = @(Get-PSCallStack) if ($callStack.Count -gt 1) { $invocation = $callStack[1].InvocationInfo } $Token = New-TcsTelemetryToken -Invocation $invocation -CommandName $CommandName -ModuleName $ModuleName -ModuleVersion $ModuleVersion } catch { Write-Verbose "Telemetry could not be started: $($_.Exception.Message)" } } # The error stream is merged into the output only to watch it. An ErrorRecord that # PowerShell also recorded as a written error (-ErrorVariable) is written back to the error # stream unchanged, as are stderr lines of native programs (which never count as # failures); everything else, including ErrorRecords output as data, is written to the # output as it is (never unrolled). Errors that were caught or silenced inside the script # block are recorded but never reach the stream, so they do not count. # The errors already passed the calling command's error action, so pass them on as they are. $ErrorActionPreference = 'Continue' $recordedErrors = $null $firstWrittenError = $null $terminatingError = $null $finished = $false try { Invoke-TcsScriptBlock -ScriptBlock $ScriptBlock -ErrorVariable recordedErrors 2>&1 | ForEach-Object -Process { $item = $_ $isError = $false if ($item -is [System.Management.Automation.ErrorRecord]) { if (Test-NativeCommandErrorRecord -ErrorRecord $item) { $isError = $true } else { foreach ($recorded in @($recordedErrors)) { if ([object]::ReferenceEquals($recorded, $item)) { $isError = $true if ($null -eq $firstWrittenError) { $firstWrittenError = $item } break } } } } if ($isError) { $PSCmdlet.WriteError($item) } else { $PSCmdlet.WriteObject($item) } } $finished = $true } catch { $terminatingError = $_ throw } finally { # Runs for success, errors and a stopped pipeline alike; telemetry must never fail the command try { if ($Token) { if ($null -ne $terminatingError) { Set-TcsTelemetryFailure -Token $Token -ErrorRecord $terminatingError } elseif ($null -ne $firstWrittenError) { Set-TcsTelemetryFailure -Token $Token -ErrorRecord $firstWrittenError } if ($ownsToken -or -not $finished) { Complete-TcsTelemetryToken -Token $Token } } } catch { Write-Verbose "Telemetry could not be completed: $($_.Exception.Message)" } } } |