From ae8467f74a8bae1b97ca808a3b6eec727d15fc7e Mon Sep 17 00:00:00 2001 From: Howard Wolosky Date: Mon, 1 Jun 2020 13:56:03 -0700 Subject: [PATCH] Removing binary dependencies for telemetry (#186) This reverse engineers the REST API for Application Insights so that we no longer need to download / depend on the 3 .dll files that were necessary to use the Application Insights .NET SDK. As a result, this also removes the `AssemblyPath` configuration property since there are no longer any assemblies that this module needs. --- .gitignore | 3 - GitHubConfiguration.ps1 | 9 - GitHubCore.ps1 | 22 +- GitHubIssues.ps1 | 21 +- NugetTools.ps1 | 383 ----------------------- PowerShellForGitHub.psd1 | 1 - Telemetry.ps1 | 657 +++++++++++++++++++++------------------ 7 files changed, 369 insertions(+), 727 deletions(-) delete mode 100644 NugetTools.ps1 diff --git a/.gitignore b/.gitignore index 3f2d6014..8e0d3cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ Tests/Config/Settings.ps1 -Microsoft.ApplicationInsights.dll -Microsoft.Diagnostics.Tracing.EventSource.dll -Microsoft.Threading.Tasks.dll diff --git a/GitHubConfiguration.ps1 b/GitHubConfiguration.ps1 index 0d6e9f21..dcd2b90a 100644 --- a/GitHubConfiguration.ps1 +++ b/GitHubConfiguration.ps1 @@ -76,11 +76,6 @@ function Set-GitHubConfiguration Change the Application Insights instance that telemetry will be reported to (if telemetry hasn't been disabled via DisableTelemetry). - .PARAMETER AssemblyPath - The location that any dependent assemblies that this module depends on can be located. - If the assemblies can't be found at this location, nor in a temporary cache or in - the module's directory, the assemblies will be downloaded and temporarily cached. - .PARAMETER DefaultNoStatus Control if the -NoStatus switch should be passed-in by default to all methods. @@ -182,8 +177,6 @@ function Set-GitHubConfiguration [string] $ApplicationInsightsKey, - [string] $AssemblyPath, - [switch] $DefaultNoStatus, [string] $DefaultOwnerName, @@ -279,7 +272,6 @@ function Get-GitHubConfiguration [ValidateSet( 'ApiHostName', 'ApplicationInsightsKey', - 'AssemblyPath', 'DefaultNoStatus', 'DefaultOwnerName', 'DefaultRepositoryName', @@ -617,7 +609,6 @@ function Import-GitHubConfiguration $config = [PSCustomObject]@{ 'apiHostName' = 'github.com' 'applicationInsightsKey' = '66d83c52-3070-489b-886b-09860e05e78a' - 'assemblyPath' = [String]::Empty 'disableLogging' = ([String]::IsNullOrEmpty($logPath)) 'disablePiiProtection' = $false 'disableSmarterObjects' = $false diff --git a/GitHubCore.ps1 b/GitHubCore.ps1 index dbbe1f2f..a324a5bb 100644 --- a/GitHubCore.ps1 +++ b/GitHubCore.ps1 @@ -98,8 +98,7 @@ function Invoke-GHRestMethod .NOTES This wraps Invoke-WebRequest as opposed to Invoke-RestMethod because we want access to the headers - that are returned in the response (specifically 'MS-ClientRequestId') for logging purposes, and - Invoke-RestMethod drops those headers. + that are returned in the response, and Invoke-RestMethod drops those headers. #> [CmdletBinding(SupportsShouldProcess)] param( @@ -144,10 +143,7 @@ function Invoke-GHRestMethod # Telemetry-related $stopwatch = New-Object -TypeName System.Diagnostics.Stopwatch - $localTelemetryProperties = @{ - 'UriFragment' = $UriFragment - 'WaitForCompletion' = ($WaitForCompletion -eq $true) - } + $localTelemetryProperties = @{} $TelemetryProperties.Keys | ForEach-Object { $localTelemetryProperties[$_] = $TelemetryProperties[$_] } $errorBucket = $TelemetryExceptionBucket if ([String]::IsNullOrEmpty($errorBucket)) @@ -198,13 +194,14 @@ function Invoke-GHRestMethod return } + $NoStatus = Resolve-ParameterWithDefaultConfigurationValue -Name NoStatus -ConfigValueName DefaultNoStatus + try { Write-Log -Message $Description -Level Verbose Write-Log -Message "Accessing [$Method] $url [Timeout = $(Get-GitHubConfiguration -Name WebRequestTimeoutSec))]" -Level Verbose $result = $null - $NoStatus = Resolve-ParameterWithDefaultConfigurationValue -Name NoStatus -ConfigValueName DefaultNoStatus if ($NoStatus) { $params = @{} @@ -293,7 +290,8 @@ function Invoke-GHRestMethod Write-Log -Message "Unable to retrieve the raw HTTP Web Response:" -Exception $_ -Level Warning } - throw (ConvertTo-Json -InputObject $ex -Depth 20) + $jsonConversionDepth = 20 # Seems like it should be more than sufficient + throw (ConvertTo-Json -InputObject $ex -Depth $jsonConversionDepth) } } @@ -326,7 +324,7 @@ function Invoke-GHRestMethod if (-not [String]::IsNullOrEmpty($TelemetryEventName)) { $telemetryMetrics = @{ 'Duration' = $stopwatch.Elapsed.TotalSeconds } - Set-TelemetryEvent -EventName $TelemetryEventName -Properties $localTelemetryProperties -Metrics $telemetryMetrics + Set-TelemetryEvent -EventName $TelemetryEventName -Properties $localTelemetryProperties -Metrics $telemetryMetrics -NoStatus:$NoStatus } $finalResult = $result.Content @@ -454,14 +452,14 @@ function Invoke-GHRestMethod { # Will be thrown if $ex.Message isn't JSON content Write-Log -Exception $_ -Level Error - Set-TelemetryException -Exception $ex -ErrorBucket $errorBucket -Properties $localTelemetryProperties + Set-TelemetryException -Exception $ex -ErrorBucket $errorBucket -Properties $localTelemetryProperties -NoStatus:$NoStatus throw } } else { Write-Log -Exception $_ -Level Error - Set-TelemetryException -Exception $_.Exception -ErrorBucket $errorBucket -Properties $localTelemetryProperties + Set-TelemetryException -Exception $_.Exception -ErrorBucket $errorBucket -Properties $localTelemetryProperties -NoStatus:$NoStatus throw } @@ -524,7 +522,7 @@ function Invoke-GHRestMethod $newLineOutput = ($output -join [Environment]::NewLine) Write-Log -Message $newLineOutput -Level Error - Set-TelemetryException -Exception $ex -ErrorBucket $errorBucket -Properties $localTelemetryProperties + Set-TelemetryException -Exception $ex -ErrorBucket $errorBucket -Properties $localTelemetryProperties -NoStatus:$NoStatus throw $newLineOutput } } diff --git a/GitHubIssues.ps1 b/GitHubIssues.ps1 index c12aefd6..d27bf93e 100644 --- a/GitHubIssues.ps1 +++ b/GitHubIssues.ps1 @@ -322,16 +322,21 @@ function Get-GitHubIssue 'NoStatus' = (Resolve-ParameterWithDefaultConfigurationValue -Name NoStatus -ConfigValueName DefaultNoStatus) } - $result = Invoke-GHRestMethodMultipleResult @params - - if ($IgnorePullRequests) + try { - return ($result | Where-Object { $null -eq (Get-Member -InputObject $_ -Name pull_request) }) - } - else - { - return $result + $result = Invoke-GHRestMethodMultipleResult @params + + if ($IgnorePullRequests) + { + return ($result | Where-Object { $null -eq (Get-Member -InputObject $_ -Name pull_request) }) + } + else + { + return $result + } + } + finally {} } function Get-GitHubIssueTimeline diff --git a/NugetTools.ps1 b/NugetTools.ps1 deleted file mode 100644 index 3c6755f7..00000000 --- a/NugetTools.ps1 +++ /dev/null @@ -1,383 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -# The cached location of nuget.exe -$script:nugetExePath = [String]::Empty - -# The directory where we'll store the assemblies that we dynamically download during this session. -$script:tempAssemblyCacheDir = [String]::Empty - -function Get-NugetExe -{ -<# - .SYNOPSIS - Downloads nuget.exe from http://nuget.org to a new local temporary directory - and returns the path to the local copy. - - .DESCRIPTION - Downloads nuget.exe from http://nuget.org to a new local temporary directory - and returns the path to the local copy. - - The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - - .EXAMPLE - Get-NugetExe - Creates a new directory with a GUID under $env:TEMP and then downloads - http://nuget.org/nuget.exe to that location. - - .OUTPUTS - System.String - The path to the newly downloaded nuget.exe -#> - [CmdletBinding(SupportsShouldProcess)] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] - param() - - if ([String]::IsNullOrEmpty($script:nugetExePath)) - { - $sourceNugetExe = "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" - $script:nugetExePath = Join-Path $(New-TemporaryDirectory) "nuget.exe" - - Write-Log -Message "Downloading $sourceNugetExe to $script:nugetExePath" -Level Verbose - Invoke-WebRequest $sourceNugetExe -OutFile $script:nugetExePath - } - - return $script:nugetExePath -} - -function Get-NugetPackage -{ -<# - .SYNOPSIS - Downloads a nuget package to the specified directory. - - .DESCRIPTION - Downloads a nuget package to the specified directory (or the current - directory if no TargetPath was specified). - - The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - - .PARAMETER PackageName - The name of the nuget package to download - - .PARAMETER TargetPath - The nuget package will be downloaded to this location. - - .PARAMETER Version - If provided, this indicates the version of the package to download. - If not specified, downloads the latest version. - - .PARAMETER NoStatus - If this switch is specified, long-running commands will run on the main thread - with no commandline status update. When not specified, those commands run in - the background, enabling the command prompt to provide status information. - - .EXAMPLE - Get-NugetPackage "Microsoft.AzureStorage" -Version "6.0.0.0" -TargetPath "c:\foo" - Downloads v6.0.0.0 of the Microsoft.AzureStorage nuget package to the c:\foo directory. - - .EXAMPLE - Get-NugetPackage "Microsoft.AzureStorage" "c:\foo" - Downloads the most recent version of the Microsoft.AzureStorage - nuget package to the c:\foo directory. -#> - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory)] - [string] $PackageName, - - [Parameter(Mandatory)] - [ValidateScript({if (Test-Path -Path $_ -PathType Container) { $true } else { throw "$_ does not exist." }})] - [string] $TargetPath, - - [string] $Version, - - [switch] $NoStatus - ) - - Write-Log -Message "Downloading nuget package [$PackageName] to [$TargetPath]" -Level Verbose - - $nugetPath = Get-NugetExe - - if ($NoStatus) - { - if ($PSCmdlet.ShouldProcess($PackageName, $nugetPath)) - { - if (-not [System.String]::IsNullOrEmpty($Version)) - { - & $nugetPath install $PackageName -o $TargetPath -version $Version -source nuget.org -NonInteractive | Out-Null - } - else - { - & $nugetPath install $PackageName -o $TargetPath -source nuget.org -NonInteractive | Out-Null - } - } - } - else - { - $jobName = "Get-NugetPackage-" + (Get-Date).ToFileTime().ToString() - - if ($PSCmdlet.ShouldProcess($jobName, "Start-Job")) - { - [scriptblock]$scriptBlock = { - param($NugetPath, $PackageName, $TargetPath, $Version) - - if (-not [System.String]::IsNullOrEmpty($Version)) - { - & $NugetPath install $PackageName -o $TargetPath -version $Version -source nuget.org - } - else - { - & $NugetPath install $PackageName -o $TargetPath -source nuget.org - } - } - - Start-Job -Name $jobName -ScriptBlock $scriptBlock -Arg @($nugetPath, $PackageName, $TargetPath, $Version) | Out-Null - - if ($PSCmdlet.ShouldProcess($jobName, "Wait-JobWithAnimation")) - { - Wait-JobWithAnimation -Name $jobName -Description "Retrieving nuget package: $PackageName" - } - - if ($PSCmdlet.ShouldProcess($jobName, "Receive-Job")) - { - Receive-Job $jobName -AutoRemoveJob -Wait -ErrorAction SilentlyContinue -ErrorVariable remoteErrors | Out-Null - } - } - - if ($remoteErrors.Count -gt 0) - { - throw $remoteErrors[0].Exception - } - } -} - -function Test-AssemblyIsDesiredVersion -{ - <# - .SYNOPSIS - Checks if the specified file is the expected version. - - .DESCRIPTION - Checks if the specified file is the expected version. - - Does a best effort match. If you only specify a desired version of "6", - any version of the file that has a "major" version of 6 will be considered - a match, where we use the terminology of a version being: - Major.Minor.Build.PrivateInfo. - - The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - - .PARAMETER AssemblyPath - The full path to the assembly file being tested. - - .PARAMETER DesiredVersion - The desired version of the assembly. Specify the version as specifically as - necessary. - - .EXAMPLE - Test-AssemblyIsDesiredVersion "c:\Microsoft.WindowsAzure.Storage.dll" "6" - - Returns back $true if "c:\Microsoft.WindowsAzure.Storage.dll" has a major version - of 6, regardless of its Minor, Build or PrivateInfo numbers. - - .OUTPUTS - Boolean - $true if the assembly at the specified path exists and meets the specified - version criteria, $false otherwise. -#> - param( - [Parameter(Mandatory)] - [ValidateScript( { if (Test-Path -PathType Leaf -Path $_) { $true } else { throw "'$_' cannot be found." } })] - [string] $AssemblyPath, - - [Parameter(Mandatory)] - [ValidateScript( { if ($_ -match '^\d+(\.\d+){0,3}$') { $true } else { throw "'$_' not a valid version format." } })] - [string] $DesiredVersion - ) - - $splitTargetVer = $DesiredVersion.Split('.') - - $file = Get-Item -Path $AssemblyPath -ErrorVariable ev - if (($null -ne $ev) -and ($ev.Count -gt 0)) - { - Write-Log "Problem accessing [$Path]: $($ev[0].Exception.Message)" -Level Warning - return $false - } - - $versionInfo = $file.VersionInfo - $splitSourceVer = @( - $versionInfo.ProductMajorPart, - $versionInfo.ProductMinorPart, - $versionInfo.ProductBuildPart, - $versionInfo.ProductPrivatePart - ) - - # The cmdlet contract states that we only care about matching - # as much of the version number as the user has supplied. - for ($i = 0; $i -lt $splitTargetVer.Count; $i++) - { - if ($splitSourceVer[$i] -ne $splitTargetVer[$i]) - { - return $false - } - } - - return $true -} - -function Get-NugetPackageDllPath -{ -<# - .SYNOPSIS - Makes sure that the specified assembly from a nuget package is available - on the machine, and returns the path to it. - - .DESCRIPTION - Makes sure that the specified assembly from a nuget package is available - on the machine, and returns the path to it. - - This will first look for the assembly in the module's script directory. - - Next it will look for the assembly in the location defined by the configuration - property AssemblyPath. - - If not found there, it will look in a temp folder established during this - PowerShell session. - - If still not found, it will download the nuget package - for it to a temp folder accessible during this PowerShell session. - - The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - - .PARAMETER NugetPackageName - The name of the nuget package to download - - .PARAMETER NugetPackageVersion - Indicates the version of the package to download. - - .PARAMETER AssemblyPackageTailDirectory - The sub-path within the nuget package download location where the assembly should be found. - - .PARAMETER AssemblyName - The name of the actual assembly that the user is looking for. - - .PARAMETER NoStatus - If this switch is specified, long-running commands will run on the main thread - with no commandline status update. When not specified, those commands run in - the background, enabling the command prompt to provide status information. - - .EXAMPLE - Get-NugetPackageDllPath "WindowsAzure.Storage" "6.0.0" "WindowsAzure.Storage.6.0.0\lib\net40\" "Microsoft.WindowsAzure.Storage.dll" - - Returns back the path to "Microsoft.WindowsAzure.Storage.dll", which is part of the - "WindowsAzure.Storage" nuget package. If the package has to be downloaded via nuget, - the command prompt will show a time duration status counter while the package is being - downloaded. - - .EXAMPLE - Get-NugetPackageDllPath "WindowsAzure.Storage" "6.0.0" "WindowsAzure.Storage.6.0.0\lib\net40\" "Microsoft.WindowsAzure.Storage.dll" -NoStatus - - Returns back the path to "Microsoft.WindowsAzure.Storage.dll", which is part of the - "WindowsAzure.Storage" nuget package. If the package has to be downloaded via nuget, - the command prompt will appear to hang during this time. - - .OUTPUTS - System.String - The full path to $AssemblyName. -#> - [CmdletBinding(SupportsShouldProcess)] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] - param( - [Parameter(Mandatory)] - [string] $NugetPackageName, - - [Parameter(Mandatory)] - [string] $NugetPackageVersion, - - [Parameter(Mandatory)] - [string] $AssemblyPackageTailDirectory, - - [Parameter(Mandatory)] - [string] $AssemblyName, - - [switch] $NoStatus - ) - - Write-Log -Message "Looking for $AssemblyName" -Level Verbose - - # First we'll check to see if the user has cached the assembly into the module's script directory - $moduleAssembly = Join-Path -Path $PSScriptRoot -ChildPath $AssemblyName - if (Test-Path -Path $moduleAssembly -PathType Leaf -ErrorAction Ignore) - { - if (Test-AssemblyIsDesiredVersion -AssemblyPath $moduleAssembly -DesiredVersion $NugetPackageVersion) - { - Write-Log -Message "Found $AssemblyName in module directory ($PSScriptRoot)." -Level Verbose - return $moduleAssembly - } - else - { - Write-Log -Message "Found $AssemblyName in module directory ($PSScriptRoot), but its version number [$moduleAssembly] didn't match required [$NugetPackageVersion]." -Level Verbose - } - } - - # Next, we'll check to see if the user has defined an alternate path to get the assembly from - $alternateAssemblyPath = Get-GitHubConfiguration -Name AssemblyPath - if (-not [System.String]::IsNullOrEmpty($alternateAssemblyPath)) - { - $assemblyPath = Join-Path -Path $alternateAssemblyPath -ChildPath $AssemblyName - if (Test-Path -Path $assemblyPath -PathType Leaf -ErrorAction Ignore) - { - if (Test-AssemblyIsDesiredVersion -AssemblyPath $assemblyPath -DesiredVersion $NugetPackageVersion) - { - Write-Log -Message "Found $AssemblyName in alternate directory ($alternateAssemblyPath)." -Level Verbose - return $assemblyPath - } - else - { - Write-Log -Message "Found $AssemblyName in alternate directory ($alternateAssemblyPath), but its version number [$moduleAssembly] didn't match required [$NugetPackageVersion]." -Level Verbose - } - } - } - - # Then we'll check to see if we've previously cached the assembly in a temp folder during this PowerShell session - if ([System.String]::IsNullOrEmpty($script:tempAssemblyCacheDir)) - { - $script:tempAssemblyCacheDir = New-TemporaryDirectory - } - else - { - $cachedAssemblyPath = Join-Path -Path $(Join-Path $script:tempAssemblyCacheDir $AssemblyPackageTailDirectory) $AssemblyName - if (Test-Path -Path $cachedAssemblyPath -PathType Leaf -ErrorAction Ignore) - { - if (Test-AssemblyIsDesiredVersion -AssemblyPath $cachedAssemblyPath -DesiredVersion $NugetPackageVersion) - { - Write-Log -Message "Found $AssemblyName in temp directory ($script:tempAssemblyCacheDir)." -Level Verbose - return $cachedAssemblyPath - } - else - { - Write-Log -Message "Found $AssemblyName in temp directory ($script:tempAssemblyCacheDir), but its version number [$moduleAssembly] didn't match required [$NugetPackageVersion]." -Level Verbose - } - } - } - - # Still not found, so we'll go ahead and download the package via nuget. - Write-Log -Message "$AssemblyName is needed and wasn't found. Acquiring it via nuget..." -Level Verbose - Get-NugetPackage -PackageName $NugetPackageName -Version $NugetPackageVersion -TargetPath $script:tempAssemblyCacheDir -NoStatus:$NoStatus - - $cachedAssemblyPath = Join-Path -Path $(Join-Path -Path $script:tempAssemblyCacheDir -ChildPath $AssemblyPackageTailDirectory) -ChildPath $AssemblyName - if (Test-Path -Path $cachedAssemblyPath -PathType Leaf -ErrorAction Ignore) - { - Write-Log -Message @( - "To avoid this download delay in the future, copy the following file:", - " [$cachedAssemblyPath]", - "either to:", - " [$PSScriptRoot]", - "or to:", - " a directory of your choosing, and store that directory as 'AssemblyPath' with 'Set-GitHubConfiguration'") - - return $cachedAssemblyPath - } - - $message = "Unable to acquire a reference to $AssemblyName." - Write-Log -Message $message -Level Error - throw $message -} diff --git a/PowerShellForGitHub.psd1 b/PowerShellForGitHub.psd1 index 33392da2..5ef27773 100644 --- a/PowerShellForGitHub.psd1 +++ b/PowerShellForGitHub.psd1 @@ -42,7 +42,6 @@ 'GitHubRepositoryTraffic.ps1', 'GitHubTeams.ps1', 'GitHubUsers.ps1', - 'NugetTools.ps1', 'Telemetry.ps1', 'UpdateCheck.ps1') diff --git a/Telemetry.ps1 b/Telemetry.ps1 index 6128e825..02296ae2 100644 --- a/Telemetry.ps1 +++ b/Telemetry.ps1 @@ -1,9 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -# Singleton telemetry client. Don't directly access this though....always get it -# by calling Get-TelemetryClient to ensure that the singleton is properly initialized. -$script:GHTelemetryClient = $null +# Singleton. Don't directly access this though....always get it +# by calling Get-BaseTelemetryEvent to ensure that it has been initialized and that you're always +# getting a fresh copy. +$script:GHBaseTelemetryEvent = $null function Get-PiiSafeString { @@ -51,278 +52,327 @@ function Get-PiiSafeString } } -function Get-ApplicationInsightsDllPath +function Get-BaseTelemetryEvent { -<# + <# .SYNOPSIS - Makes sure that the Microsoft.ApplicationInsights.dll assembly is available - on the machine, and returns the path to it. + Returns back the base object for an Application Insights telemetry event. .DESCRIPTION - Makes sure that the Microsoft.ApplicationInsights.dll assembly is available - on the machine, and returns the path to it. - - This will first look for the assembly in the module's script directory. - - Next it will look for the assembly in the location defined by - $SBAlternateAssemblyDir. This value would have to be defined by the user - prior to execution of this cmdlet. - - If not found there, it will look in a temp folder established during this - PowerShell session. - - If still not found, it will download the nuget package - for it to a temp folder accessible during this PowerShell session. + Returns back the base object for an Application Insights telemetry event. The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - .PARAMETER NoStatus - If this switch is specified, long-running commands will run on the main thread - with no commandline status update. When not specified, those commands run in - the background, enabling the command prompt to provide status information. - .EXAMPLE - Get-ApplicationInsightsDllPath - - Returns back the path to the assembly as found. If the package has to - be downloaded via nuget, the command prompt will show a time duration - status counter while the package is being downloaded. + Get-BaseTelemetryEvent - .EXAMPLE - Get-ApplicationInsightsDllPath -NoStatus - - Returns back the path to the assembly as found. If the package has to - be downloaded via nuget, the command prompt will appear to hang during - this time. + Returns back a base telemetry event, populated with the minimum properties necessary + to correctly report up to this project's telemetry. Callers can then add on to the + event as nececessary. .OUTPUTS - System.String - The path to the Microsoft.ApplicationInsights.dll assembly. + [PSCustomObject] #> - [CmdletBinding(SupportsShouldProcess)] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] - param( - [switch] $NoStatus - ) - - $nugetPackageName = "Microsoft.ApplicationInsights" - $nugetPackageVersion = "2.0.1" - $assemblyPackageTailDir = "Microsoft.ApplicationInsights.2.0.1\lib\net45" - $assemblyName = "Microsoft.ApplicationInsights.dll" - - return Get-NugetPackageDllPath -NugetPackageName $nugetPackageName -NugetPackageVersion $nugetPackageVersion -AssemblyPackageTailDirectory $assemblyPackageTailDir -AssemblyName $assemblyName -NoStatus:$NoStatus -} - -function Get-DiagnosticsTracingDllPath -{ -<# - .SYNOPSIS - Makes sure that the Microsoft.Diagnostics.Tracing.EventSource.dll assembly is available - on the machine, and returns the path to it. - - .DESCRIPTION - Makes sure that the Microsoft.Diagnostics.Tracing.EventSource.dll assembly is available - on the machine, and returns the path to it. - - This will first look for the assembly in the module's script directory. - - Next it will look for the assembly in the location defined by - $SBAlternateAssemblyDir. This value would have to be defined by the user - prior to execution of this cmdlet. - - If not found there, it will look in a temp folder established during this - PowerShell session. - - If still not found, it will download the nuget package - for it to a temp folder accessible during this PowerShell session. - - The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - - .PARAMETER NoStatus - If this switch is specified, long-running commands will run on the main thread - with no commandline status update. When not specified, those commands run in - the background, enabling the command prompt to provide status information. - - .EXAMPLE - Get-DiagnosticsTracingDllPath - - Returns back the path to the assembly as found. If the package has to - be downloaded via nuget, the command prompt will show a time duration - status counter while the package is being downloaded. - - .EXAMPLE - Get-DiagnosticsTracingDllPath -NoStatus + [CmdletBinding()] + param() - Returns back the path to the assembly as found. If the package has to - be downloaded via nuget, the command prompt will appear to hang during - this time. + if ($null -eq $script:GHBaseTelemetryEvent) + { + if (-not (Get-GitHubConfiguration -Name SuppressTelemetryReminder)) + { + Write-Log -Message 'Telemetry is currently enabled. It can be disabled by calling "Set-GitHubConfiguration -DisableTelemetry". Refer to USAGE.md#telemetry for more information. Stop seeing this message in the future by calling "Set-GitHubConfiguration -SuppressTelemetryReminder".' + } - .OUTPUTS - System.String - The path to the Microsoft.ApplicationInsights.dll assembly. -#> - [CmdletBinding(SupportsShouldProcess)] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] - param( - [switch] $NoStatus - ) + $username = Get-PiiSafeString -PlainText $env:USERNAME - $nugetPackageName = "Microsoft.Diagnostics.Tracing.EventSource.Redist" - $nugetPackageVersion = "1.1.24" - $assemblyPackageTailDir = "Microsoft.Diagnostics.Tracing.EventSource.Redist.1.1.24\lib\net35" - $assemblyName = "Microsoft.Diagnostics.Tracing.EventSource.dll" + $script:GHBaseTelemetryEvent = [PSCustomObject] @{ + 'name' = 'Microsoft.ApplicationInsights.66d83c523070489b886b09860e05e78a.Event' + 'time' = (Get-Date).ToUniversalTime().ToString("O") + 'iKey' = (Get-GitHubConfiguration -Name ApplicationInsightsKey) + 'tags' = [PSCustomObject] @{ + 'ai.user.id' = $username + 'ai.session.id' = [System.GUID]::NewGuid().ToString() + 'ai.application.ver' = $MyInvocation.MyCommand.Module.Version.ToString() + 'ai.internal.sdkVersion' = '2.0.1.33027' # The version this schema was based off of. + } + + 'data' = [PSCustomObject] @{ + 'baseType' = 'EventData' + 'baseData' = [PSCustomObject] @{ + 'ver' = 2 + 'properties' = [PSCustomObject] @{ + 'DayOfWeek' = (Get-Date).DayOfWeek.ToString() + 'Username' = $username + } + } + } + } + } - return Get-NugetPackageDllPath -NugetPackageName $nugetPackageName -NugetPackageVersion $nugetPackageVersion -AssemblyPackageTailDirectory $assemblyPackageTailDir -AssemblyName $assemblyName -NoStatus:$NoStatus + return $script:GHBaseTelemetryEvent.PSObject.Copy() # Get a new instance, not a reference } -function Get-ThreadingTasksDllPath +function Invoke-SendTelemetryEvent { <# .SYNOPSIS - Makes sure that the Microsoft.Threading.Tasks.dll assembly is available - on the machine, and returns the path to it. + Sends an event to Application Insights directly using its REST API. .DESCRIPTION - Makes sure that the Microsoft.Threading.Tasks.dll assembly is available - on the machine, and returns the path to it. - - This will first look for the assembly in the module's script directory. - - Next it will look for the assembly in the location defined by - $SBAlternateAssemblyDir. This value would have to be defined by the user - prior to execution of this cmdlet. - - If not found there, it will look in a temp folder established during this - PowerShell session. + Sends an event to Application Insights directly using its REST API. - If still not found, it will download the nuget package - for it to a temp folder accessible during this PowerShell session. + A very heavy wrapper around Invoke-WebRequest that understands Application Insights and + how to perform its requests with and without console status updates. It also + understands how to parse and handle errors from the REST calls. The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub + .PARAMETER TelemetryEvent + The raw object representing the event data to send to Application Insights. + .PARAMETER NoStatus If this switch is specified, long-running commands will run on the main thread with no commandline status update. When not specified, those commands run in the background, enabling the command prompt to provide status information. - .EXAMPLE - Get-ThreadingTasksDllPath - - Returns back the path to the assembly as found. If the package has to - be downloaded via nuget, the command prompt will show a time duration - status counter while the package is being downloaded. - - .EXAMPLE - Get-ThreadingTasksDllPath -NoStatus - - Returns back the path to the assembly as found. If the package has to - be downloaded via nuget, the command prompt will appear to hang during - this time. - .OUTPUTS - System.String - The path to the Microsoft.ApplicationInsights.dll assembly. + [PSCustomObject] - The result of the REST operation, in whatever form it comes in. + + .NOTES + This mirrors Invoke-GHRestMethod extensively, however the error handling is slightly + different. There wasn't a clear way to refactor the code to make both of these + Invoke-* methods share a common base code. Leaving this as-is to make this file + easier to share out with other PowerShell projects. #> [CmdletBinding(SupportsShouldProcess)] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "", Justification="We use global variables sparingly and intentionally for module configuration, and employ a consistent naming convention.")] param( + [Parameter(Mandatory)] + [PSCustomObject] $TelemetryEvent, + [switch] $NoStatus ) - $nugetPackageName = "Microsoft.Bcl.Async" - $nugetPackageVersion = "1.0.168.0" - $assemblyPackageTailDir = "Microsoft.Bcl.Async.1.0.168\lib\net40" - $assemblyName = "Microsoft.Threading.Tasks.dll" - - return Get-NugetPackageDllPath -NugetPackageName $nugetPackageName -NugetPackageVersion $nugetPackageVersion -AssemblyPackageTailDirectory $assemblyPackageTailDir -AssemblyName $assemblyName -NoStatus:$NoStatus -} - -function Get-TelemetryClient -{ -<# - .SYNOPSIS - Returns back the singleton instance of the Application Insights TelemetryClient for - this module. - - .DESCRIPTION - Returns back the singleton instance of the Application Insights TelemetryClient for - this module. - - If the singleton hasn't been initialized yet, this will ensure all dependent assemblies - are available on the machine, create the client and initialize its properties. - - This will first look for the dependent assemblies in the module's script directory. + # Temporarily forcing NoStatus to always be true to see if it improves user experience. + $NoStatus = $true - Next it will look for the assemblies in the location defined by - $SBAlternateAssemblyDir. This value would have to be defined by the user - prior to execution of this cmdlet. + $jsonConversionDepth = 20 # Seems like it should be more than sufficient + $uri = 'https://dc.services.visualstudio.com/v2/track' + $method = 'POST' + $headers = @{'Content-Type' = 'application/json; charset=UTF-8'} - If not found there, it will look in a temp folder established during this - PowerShell session. + $body = ConvertTo-Json -InputObject $TelemetryEvent -Depth $jsonConversionDepth -Compress + $bodyAsBytes = [System.Text.Encoding]::UTF8.GetBytes($body) - If still not found, it will download the nuget package - for it to a temp folder accessible during this PowerShell session. - - The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - - .PARAMETER NoStatus - If this switch is specified, long-running commands will run on the main thread - with no commandline status update. When not specified, those commands run in - the background, enabling the command prompt to provide status information. - - .EXAMPLE - Get-TelemetryClient - - Returns back the singleton instance to the TelemetryClient for the module. - If any nuget packages have to be downloaded in order to load the TelemetryClient, the - command prompt will show a time duration status counter during the download process. - - .EXAMPLE - Get-TelemetryClient -NoStatus - - Returns back the singleton instance to the TelemetryClient for the module. - If any nuget packages have to be downloaded in order to load the TelemetryClient, the - command prompt will appear to hang during this time. + try + { + Write-Log -Message "Sending telemetry event data to $uri [Timeout = $(Get-GitHubConfiguration -Name WebRequestTimeoutSec))]" -Level Verbose - .OUTPUTS - Microsoft.ApplicationInsights.TelemetryClient -#> - [CmdletBinding(SupportsShouldProcess)] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] - param( - [switch] $NoStatus - ) + if ($NoStatus) + { + if ($PSCmdlet.ShouldProcess($url, "Invoke-WebRequest")) + { + $params = @{} + $params.Add("Uri", $uri) + $params.Add("Method", $method) + $params.Add("Headers", $headers) + $params.Add("UseDefaultCredentials", $true) + $params.Add("UseBasicParsing", $true) + $params.Add("TimeoutSec", (Get-GitHubConfiguration -Name WebRequestTimeoutSec)) + $params.Add("Body", $bodyAsBytes) + + $result = Invoke-WebRequest @params + } + } + else + { + $jobName = "Invoke-SendTelemetryEvent-" + (Get-Date).ToFileTime().ToString() + + if ($PSCmdlet.ShouldProcess($jobName, "Start-Job")) + { + [scriptblock]$scriptBlock = { + param($Uri, $Method, $Headers, $BodyAsBytes, $TimeoutSec, $ScriptRootPath) + + # We need to "dot invoke" Helpers.ps1 and GitHubConfiguration.ps1 within + # the context of this script block since we're running in a different + # PowerShell process and need access to Get-HttpWebResponseContent and + # config values referenced within Write-Log. + . (Join-Path -Path $ScriptRootPath -ChildPath 'Helpers.ps1') + . (Join-Path -Path $ScriptRootPath -ChildPath 'GitHubConfiguration.ps1') + + $params = @{} + $params.Add("Uri", $Uri) + $params.Add("Method", $Method) + $params.Add("Headers", $Headers) + $params.Add("UseDefaultCredentials", $true) + $params.Add("UseBasicParsing", $true) + $params.Add("TimeoutSec", $TimeoutSec) + $params.Add("Body", $BodyAsBytes) + + try + { + Invoke-WebRequest @params + } + catch [System.Net.WebException] + { + # We need to access certain headers in the exception handling, + # but the actual *values* of the headers of a WebException don't get serialized + # when the RemoteException wraps it. To work around that, we'll extract the + # information that we actually care about *now*, and then we'll throw our own exception + # that is just a JSON object with the data that we'll later extract for processing in + # the main catch. + $ex = @{} + $ex.Message = $_.Exception.Message + $ex.StatusCode = $_.Exception.Response.StatusCode + $ex.StatusDescription = $_.Exception.Response.StatusDescription + $ex.InnerMessage = $_.ErrorDetails.Message + try + { + $ex.RawContent = Get-HttpWebResponseContent -WebResponse $_.Exception.Response + } + catch + { + Write-Log -Message "Unable to retrieve the raw HTTP Web Response:" -Exception $_ -Level Warning + } + + $jsonConversionDepth = 20 # Seems like it should be more than sufficient + throw (ConvertTo-Json -InputObject $ex -Depth $jsonConversionDepth) + } + } + + $null = Start-Job -Name $jobName -ScriptBlock $scriptBlock -Arg @( + $uri, + $method, + $headers, + $bodyAsBytes, + (Get-GitHubConfiguration -Name WebRequestTimeoutSec), + $PSScriptRoot) + + if ($PSCmdlet.ShouldProcess($jobName, "Wait-JobWithAnimation")) + { + $description = 'Sending telemetry data' + Wait-JobWithAnimation -Name $jobName -Description $Description + } + + if ($PSCmdlet.ShouldProcess($jobName, "Receive-Job")) + { + $result = Receive-Job $jobName -AutoRemoveJob -Wait -ErrorAction SilentlyContinue -ErrorVariable remoteErrors + } + } + + if ($remoteErrors.Count -gt 0) + { + throw $remoteErrors[0].Exception + } + } - if ($null -eq $script:GHTelemetryClient) + return $result + } + catch { - if (-not (Get-GitHubConfiguration -Name SuppressTelemetryReminder)) + # We only know how to handle WebExceptions, which will either come in "pure" when running with -NoStatus, + # or will come in as a RemoteException when running normally (since it's coming from the asynchronous Job). + $ex = $null + $message = $null + $statusCode = $null + $statusDescription = $null + $innerMessage = $null + $rawContent = $null + + if ($_.Exception -is [System.Net.WebException]) { - Write-Log -Message 'Telemetry is currently enabled. It can be disabled by calling "Set-GitHubConfiguration -DisableTelemetry". Refer to USAGE.md#telemetry for more information. Stop seeing this message in the future by calling "Set-GitHubConfiguration -SuppressTelemetryReminder".' + $ex = $_.Exception + $message = $_.Exception.Message + $statusCode = $ex.Response.StatusCode.value__ # Note that value__ is not a typo. + $statusDescription = $ex.Response.StatusDescription + $innerMessage = $_.ErrorDetails.Message + try + { + $rawContent = Get-HttpWebResponseContent -WebResponse $ex.Response + } + catch + { + Write-Log -Message "Unable to retrieve the raw HTTP Web Response:" -Exception $_ -Level Warning + } + } + elseif (($_.Exception -is [System.Management.Automation.RemoteException]) -and + ($_.Exception.SerializedRemoteException.PSObject.TypeNames[0] -eq 'Deserialized.System.Management.Automation.RuntimeException')) + { + $ex = $_.Exception + try + { + $deserialized = $ex.Message | ConvertFrom-Json + $message = $deserialized.Message + $statusCode = $deserialized.StatusCode + $statusDescription = $deserialized.StatusDescription + $innerMessage = $deserialized.InnerMessage + $rawContent = $deserialized.RawContent + } + catch [System.ArgumentException] + { + # Will be thrown if $ex.Message isn't the JSON content we prepared + # in the System.Net.WebException handler earlier in $scriptBlock. + Write-Log -Exception $_ -Level Error + throw + } + } + else + { + Write-Log -Exception $_ -Level Error + throw } - Write-Log -Message "Initializing telemetry client." -Level Verbose + $output = @() + $output += $message - $dlls = @( - (Get-ThreadingTasksDllPath -NoStatus:$NoStatus), - (Get-DiagnosticsTracingDllPath -NoStatus:$NoStatus), - (Get-ApplicationInsightsDllPath -NoStatus:$NoStatus) - ) + if (-not [string]::IsNullOrEmpty($statusCode)) + { + $output += "$statusCode | $($statusDescription.Trim())" + } - foreach ($dll in $dlls) + if (-not [string]::IsNullOrEmpty($innerMessage)) { - $bytes = [System.IO.File]::ReadAllBytes($dll) - [System.Reflection.Assembly]::Load($bytes) | Out-Null + try + { + $innerMessageJson = ($innerMessage | ConvertFrom-Json) + if ($innerMessageJson -is [String]) + { + $output += $innerMessageJson.Trim() + } + elseif (-not [String]::IsNullOrWhiteSpace($innerMessageJson.itemsReceived)) + { + $output += "Items Received: $($innerMessageJson.itemsReceived)" + $output += "Items Accepted: $($innerMessageJson.itemsAccepted)" + if ($innerMessageJson.errors.Count -gt 0) + { + $output += "Errors:" + $output += ($innerMessageJson.errors | Format-Table | Out-String) + } + } + else + { + # In this case, it's probably not a normal message from the API + $output += ($innerMessageJson | Out-String) + } + } + catch [System.ArgumentException] + { + # Will be thrown if $innerMessage isn't JSON content + $output += $innerMessage.Trim() + } } - $username = Get-PiiSafeString -PlainText $env:USERNAME + # It's possible that the API returned JSON content in its error response. + if (-not [String]::IsNullOrWhiteSpace($rawContent)) + { + $output += $rawContent + } - $script:GHTelemetryClient = New-Object Microsoft.ApplicationInsights.TelemetryClient - $script:GHTelemetryClient.InstrumentationKey = (Get-GitHubConfiguration -Name ApplicationInsightsKey) - $script:GHTelemetryClient.Context.User.Id = $username - $script:GHTelemetryClient.Context.Session.Id = [System.GUID]::NewGuid().ToString() - $script:GHTelemetryClient.Context.Properties['Username'] = $username - $script:GHTelemetryClient.Context.Properties['DayOfWeek'] = (Get-Date).DayOfWeek - $script:GHTelemetryClient.Context.Component.Version = $MyInvocation.MyCommand.Module.Version.ToString() + $output += "Original body: $body" + $newLineOutput = ($output -join [Environment]::NewLine) + Write-Log -Message $newLineOutput -Level Error + throw $newLineOutput } - - return $script:GHTelemetryClient } function Set-TelemetryEvent @@ -400,25 +450,38 @@ function Set-TelemetryEvent try { - $telemetryClient = Get-TelemetryClient -NoStatus:$NoStatus + $telemetryEvent = Get-BaseTelemetryEvent - $propertiesDictionary = New-Object 'System.Collections.Generic.Dictionary[string, string]' - $propertiesDictionary['DayOfWeek'] = (Get-Date).DayOfWeek - $Properties.Keys | ForEach-Object { $propertiesDictionary[$_] = $Properties[$_] } + Add-Member -InputObject $telemetryEvent.data.baseData -Name 'name' -Value $EventName -MemberType NoteProperty -Force - $metricsDictionary = New-Object 'System.Collections.Generic.Dictionary[string, double]' - $Metrics.Keys | ForEach-Object { $metricsDictionary[$_] = $Metrics[$_] } + # Properties + foreach ($property in $Properties.GetEnumerator()) + { + Add-Member -InputObject $telemetryEvent.data.baseData.properties -Name $property.Key -Value $property.Value -MemberType NoteProperty -Force + } - $telemetryClient.TrackEvent($EventName, $propertiesDictionary, $metricsDictionary); + # Measurements + if ($Metrics.Count -gt 0) + { + $measurements = @{} + foreach ($metric in $Metrics.GetEnumerator()) + { + $measurements[$metric.Key] = $metric.Value + } - # Flushing should increase the chance of success in uploading telemetry logs - Flush-TelemetryClient -NoStatus:$NoStatus + Add-Member -InputObject $telemetryEvent.data.baseData -Name 'measurements' -Value ([PSCustomObject] $measurements) -MemberType NoteProperty -Force + } + + $null = Invoke-SendTelemetryEvent -TelemetryEvent $telemetryEvent -NoStatus:$NoStatus } catch { - # Telemetry should be best-effort. Failures while trying to handle telemetry should not - # cause exceptions in the app itself. - Write-Log -Message "Set-TelemetryEvent failed:" -Exception $_ -Level Error + Write-Log -Level Warning -Message @( + "Encountered a problem while trying to record telemetry events.", + "This is non-fatal, but it would be helpful if you could report this problem", + "to the PowerShellForGitHub team for further investigation:" + "", + $_.Exception) } } @@ -487,8 +550,6 @@ function Set-TelemetryException [hashtable] $Properties = @{}, - [switch] $NoFlush, - [switch] $NoStatus ) @@ -502,99 +563,73 @@ function Set-TelemetryException try { - $telemetryClient = Get-TelemetryClient -NoStatus:$NoStatus + $telemetryEvent = Get-BaseTelemetryEvent - $propertiesDictionary = New-Object 'System.Collections.Generic.Dictionary[string,string]' - $propertiesDictionary['Message'] = $Exception.Message - $propertiesDictionary['HResult'] = "0x{0}" -f [Convert]::ToString($Exception.HResult, 16) - $Properties.Keys | ForEach-Object { $propertiesDictionary[$_] = $Properties[$_] } + $telemetryEvent.data.baseType = 'ExceptionData' + Add-Member -InputObject $telemetryEvent.data.baseData -Name 'handledAt' -Value 'UserCode' -MemberType NoteProperty -Force + # Properties if (-not [String]::IsNullOrWhiteSpace($ErrorBucket)) { - $propertiesDictionary['ErrorBucket'] = $ErrorBucket + Add-Member -InputObject $telemetryEvent.data.baseData.properties -Name 'ErrorBucket' -Value $ErrorBucket -MemberType NoteProperty -Force } - $telemetryClient.TrackException($Exception, $propertiesDictionary); - - # Flushing should increase the chance of success in uploading telemetry logs - if (-not $NoFlush) + Add-Member -InputObject $telemetryEvent.data.baseData.properties -Name 'Message' -Value $Exception.Message -MemberType NoteProperty -Force + Add-Member -InputObject $telemetryEvent.data.baseData.properties -Name 'HResult' -Value ("0x{0}" -f [Convert]::ToString($Exception.HResult, 16)) -MemberType NoteProperty -Force + foreach ($property in $Properties.GetEnumerator()) { - Flush-TelemetryClient -NoStatus:$NoStatus + Add-Member -InputObject $telemetryEvent.data.baseData.properties -Name $property.Key -Value $property.Value -MemberType NoteProperty -Force } - } - catch - { - # Telemetry should be best-effort. Failures while trying to handle telemetry should not - # cause exceptions in the app itself. - Write-Log -Message "Set-TelemetryException failed:" -Exception $_ -Level Error - } -} - -function Flush-TelemetryClient -{ -<# - .SYNOPSIS - Flushes the buffer of stored telemetry events to the configured Applications Insights instance. - - .DESCRIPTION - Flushes the buffer of stored telemetry events to the configured Applications Insights instance. - - The Git repo for this module can be found here: http://aka.ms/PowerShellForGitHub - - .PARAMETER NoStatus - If this switch is specified, long-running commands will run on the main thread - with no commandline status update. When not specified, those commands run in - the background, enabling the command prompt to provide status information. - - .EXAMPLE - Flush-TelemetryClient - - Attempts to push all buffered telemetry events for this telemetry client immediately to - Application Insights. If the telemetry client needs to be created to accomplish this, - and the required assemblies are not available on the local machine, the download status - will be presented at the command prompt. - .EXAMPLE - Flush-TelemetryClient -NoStatus - - Attempts to push all buffered telemetry events for this telemetry client immediately to - Application Insights. If the telemetry client needs to be created to accomplish this, - and the required assemblies are not available on the local machine, the command prompt - will appear to hang while they are downloaded. -#> - [CmdletBinding(SupportsShouldProcess)] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Internal-only helper method. Matches the internal method that is called.")] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] - param( - [switch] $NoStatus - ) - - Write-InvocationLog - - if (Get-GitHubConfiguration -Name DisableTelemetry) - { - Write-Log -Message "Telemetry has been disabled via configuration. Skipping flushing of the telemetry client." -Level Verbose - return - } + # Re-create the stack. We'll start with what's in Invocation Info since it's already + # been broken down for us (although it doesn't supply the method name). + $parsedStack = @( + [PSCustomObject] @{ + 'assembly' = $MyInvocation.MyCommand.Module.Name + 'method' = '' + 'fileName' = $Exception.ErrorRecord.InvocationInfo.ScriptName + 'level' = 0 + 'line' = $Exception.ErrorRecord.InvocationInfo.ScriptLineNumber + } + ) - $telemetryClient = Get-TelemetryClient -NoStatus:$NoStatus + # And then we'll try to parse ErrorRecord's ScriptStackTrace and make this as useful + # as possible. + $stackFrames = $Exception.ErrorRecord.ScriptStackTrace -split [Environment]::NewLine + for ($i = 0; $i -lt $stackFrames.Count; $i++) + { + $frame = $stackFrames[$i] + if ($frame -match '^at (.+), (.+): line (\d+)$') + { + $parsedStack += [PSCustomObject] @{ + 'assembly' = $MyInvocation.MyCommand.Module.Name + 'method' = $Matches[1] + 'fileName' = $Matches[2] + 'level' = $i + 1 + 'line' = $Matches[3] + } + } + } - try - { - $telemetryClient.Flush() - } - catch [System.Net.WebException] - { - Write-Log -Message "Encountered exception while trying to flush telemetry events:" -Exception $_ -Level Warning + # Finally, we'll build up the Exception data object. + $exceptionData = [PSCustomObject] @{ + 'id' = (Get-Date).ToFileTime() + 'typeName' = $Exception.GetType().FullName + 'message' = $Exception.Message + 'hasFullStack' = $true + 'parsedStack' = $parsedStack + } - Set-TelemetryException -Exception ($_.Exception) -ErrorBucket "TelemetryFlush" -NoFlush -NoStatus:$NoStatus + Add-Member -InputObject $telemetryEvent.data.baseData -Name 'exceptions' -Value @($exceptionData) -MemberType NoteProperty -Force + $null = Invoke-SendTelemetryEvent -TelemetryEvent $telemetryEvent -NoStatus:$NoStatus } catch { - # Any other scenario is one that we want to identify and fix so that we don't miss telemetry - Write-Log -Level Warning -Exception $_ -Message @( + Write-Log -Level Warning -Message @( "Encountered a problem while trying to record telemetry events.", "This is non-fatal, but it would be helpful if you could report this problem", - "to the PowerShellForGitHub team for further investigation:") + "to the PowerShellForGitHub team for further investigation:", + "", + $_.Exception) } }