diff --git a/README.md b/README.md index c2d1c94..ec47ee0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,44 @@ Future support planned: - Mobile platforms (iOS, Android) - Desktop platforms (Windows, macOS, Linux) +## Telemetry + +This toolkit supports optional operational telemetry using [Sentry](https://sentry.io) to improve reliability and diagnose issues. When enabled, telemetry helps identify test infrastructure failures, device connection problems, and automation bottlenecks. + +### What's Collected + +Examples of the types of telemetry data collected: + +- Module errors and exceptions with context (platform, session ID, error category) +- Device connection failures and lock acquisition issues +- Test infrastructure problems (missing event captures, polling timeouts) +- Diagnostic operation breadcrumbs showing the sequence of operations leading to failures +- Performance metrics for critical operations (device connections, app deployments) + +### Privacy & Control + +**Telemetry is opt-in and requires explicit configuration:** + +Telemetry is disabled by default. To enable it, set one of the following environment variables with your Sentry DSN: + +**To enable telemetry for app-runner:** +```powershell +$env:SENTRY_APP_RUNNER_DSN = 'https://your-key@o123.ingest.sentry.io/your-project' +``` + +**To enable telemetry for sentry-api-client:** +```powershell +$env:SENTRY_API_CLIENT_DSN = 'https://your-key@o123.ingest.sentry.io/your-project' +``` + +**Note:** You can use the same DSN for both. + +### Dependencies + +The `Sentry` PowerShell module (v0.4.0) is bundled in the `vendor/Sentry` directory, so no installation is required. Telemetry will work automatically when a DSN is configured via environment variable. + +**Learn more:** [sentry-powershell on GitHub](https://github.com/getsentry/sentry-powershell) + ## Requirements ### Platform-Specific Prerequisites diff --git a/app-runner/Private/ErrorHandling.ps1 b/app-runner/Private/ErrorHandling.ps1 deleted file mode 100644 index f90f963..0000000 --- a/app-runner/Private/ErrorHandling.ps1 +++ /dev/null @@ -1,151 +0,0 @@ -# Standardized Error Handling Framework -# Provides consistent error handling patterns across the module - -# Error Categories -enum ConsoleErrorCategory { - SessionManagement - PlatformConnection - ConsoleLifecycle - ApplicationManagement - Diagnostics - Configuration - NetworkTimeout - Authentication - Validation - Unknown -} - -# Custom Exception Classes -class ConsoleException : System.Exception { - [ConsoleErrorCategory]$Category - [string]$Platform - [string]$SessionId - [hashtable]$Context - - ConsoleException([string]$message) : base($message) { - $this.Category = [ConsoleErrorCategory]::Unknown - $this.Context = @{} - } - - ConsoleException([string]$message, [ConsoleErrorCategory]$category) : base($message) { - $this.Category = $category - $this.Context = @{} - } - - ConsoleException([string]$message, [ConsoleErrorCategory]$category, [Exception]$innerException) : base($message, $innerException) { - $this.Category = $category - $this.Context = @{} - } - - [string]GetDetailedMessage() { - $details = @() - $details += "Error: $($this.Message)" - $details += "Category: $($this.Category)" - - if ($this.Platform) { - $details += "Platform: $($this.Platform)" - } - - if ($this.SessionId) { - $details += "Session ID: $($this.SessionId)" - } - - if ($this.Context.Count -gt 0) { - $details += "Context:" - foreach ($key in $this.Context.Keys) { - $details += " ${key}: $($this.Context[$key])" - } - } - - if ($this.InnerException) { - $details += "Inner Exception: $($this.InnerException.Message)" - } - - return $details -join "`n" - } -} - -class SessionException : ConsoleException { - SessionException([string]$message) : base($message, [ConsoleErrorCategory]::SessionManagement) {} - SessionException([string]$message, [Exception]$innerException) : base($message, [ConsoleErrorCategory]::SessionManagement, $innerException) {} -} - -class PlatformException : ConsoleException { - PlatformException([string]$message, [string]$platform) : base($message, [ConsoleErrorCategory]::PlatformConnection) { - $this.Platform = $platform - } - PlatformException([string]$message, [string]$platform, [Exception]$innerException) : base($message, [ConsoleErrorCategory]::PlatformConnection, $innerException) { - $this.Platform = $platform - } -} - -class ConsoleLifecycleException : ConsoleException { - ConsoleLifecycleException([string]$message) : base($message, [ConsoleErrorCategory]::ConsoleLifecycle) {} - ConsoleLifecycleException([string]$message, [Exception]$innerException) : base($message, [ConsoleErrorCategory]::ConsoleLifecycle, $innerException) {} -} - -class ApplicationException : ConsoleException { - ApplicationException([string]$message) : base($message, [ConsoleErrorCategory]::ApplicationManagement) {} - ApplicationException([string]$message, [Exception]$innerException) : base($message, [ConsoleErrorCategory]::ApplicationManagement, $innerException) {} -} - -class ConfigurationException : ConsoleException { - ConfigurationException([string]$message) : base($message, [ConsoleErrorCategory]::Configuration) {} - ConfigurationException([string]$message, [Exception]$innerException) : base($message, [ConsoleErrorCategory]::Configuration, $innerException) {} -} - -# Error Handler Class -class ErrorHandler { - static [hashtable]$ErrorLog = @{} - static [int]$ErrorCount = 0 - - static [void]LogError([ConsoleException]$exception) { - $errorId = [Guid]::NewGuid().ToString() - $timestamp = Get-Date - - [ErrorHandler]::ErrorLog[$errorId] = @{ - Timestamp = $timestamp - Exception = $exception - Category = $exception.Category - Platform = $exception.Platform - ConsoleId = $exception.ConsoleId - Message = $exception.Message - InnerException = $exception.InnerException - Context = $exception.Context - StackTrace = $exception.StackTrace - } - - [ErrorHandler]::ErrorCount++ - - # Log to PowerShell error stream - Write-Error $exception.GetDetailedMessage() -ErrorId $errorId - - # Log to debug stream for troubleshooting - Write-Debug "Error logged with ID: $errorId" - } - - static [void]LogError([string]$message, [ConsoleErrorCategory]$category) { - $exception = [ConsoleException]::new($message, $category) - [ErrorHandler]::LogError($exception) - } - - static [array]GetRecentErrors([int]$count = 10) { - $sortedErrors = [ErrorHandler]::ErrorLog.Values | Sort-Object Timestamp -Descending - return $sortedErrors | Select-Object -First $count - } - - static [array]GetErrorsByCategory([ConsoleErrorCategory]$category) { - return [ErrorHandler]::ErrorLog.Values | Where-Object { $_.Category -eq $category } - } - - static [void]ClearErrorLog() { - [ErrorHandler]::ErrorLog.Clear() - [ErrorHandler]::ErrorCount = 0 - } - - static [int]GetErrorCount() { - return [ErrorHandler]::ErrorCount - } -} - -# Essential Error Handling Functions \ No newline at end of file diff --git a/app-runner/SentryAppRunner.psm1 b/app-runner/SentryAppRunner.psm1 index c1ec76e..9f6d86f 100644 --- a/app-runner/SentryAppRunner.psm1 +++ b/app-runner/SentryAppRunner.psm1 @@ -1,6 +1,15 @@ $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true +# Initialize Sentry telemetry (opt-in) +try { + Import-Module (Join-Path $PSScriptRoot '..\utils\TrySentry.psm1') -ErrorAction Stop + $moduleManifest = Import-PowerShellDataFile (Join-Path $PSScriptRoot 'SentryAppRunner.psd1') + TrySentry\Start-Sentry -Dsn $env:SENTRY_APP_RUNNER_DSN -ModuleName 'SentryAppRunner' -ModuleVersion $moduleManifest.ModuleVersion +} catch { + Write-Debug "Sentry telemetry initialization failed: $_" +} + # Import device providers in the correct order (base provider first, then implementations, then factory) $ProviderFiles = @( "$PSScriptRoot\Private\DeviceProviders\DeviceProvider.ps1", diff --git a/sentry-api-client/SentryApiClient.psm1 b/sentry-api-client/SentryApiClient.psm1 index 0b969e1..16c99f8 100644 --- a/sentry-api-client/SentryApiClient.psm1 +++ b/sentry-api-client/SentryApiClient.psm1 @@ -1,3 +1,12 @@ +# Initialize Sentry telemetry (opt-in) +try { + Import-Module (Join-Path $PSScriptRoot '..\utils\TrySentry.psm1') -ErrorAction Stop + $moduleManifest = Import-PowerShellDataFile (Join-Path $PSScriptRoot 'SentryApiClient.psd1') + TrySentry\Start-Sentry -Dsn $env:SENTRY_API_CLIENT_DSN -ModuleName 'SentryApiClient' -ModuleVersion $moduleManifest.ModuleVersion +} catch { + Write-Debug "Sentry telemetry initialization failed: $_" +} + $Script:SentryApiConfig = @{ BaseUrl = 'https://sentry.io/api/0' ApiToken = $null @@ -18,4 +27,4 @@ foreach ($Function in @($PublicFunctions + $PrivateFunctions)) { } } -Export-ModuleMember -Function $PublicFunctions.BaseName \ No newline at end of file +Export-ModuleMember -Function $PublicFunctions.BaseName diff --git a/utils/TrySentry.psm1 b/utils/TrySentry.psm1 new file mode 100644 index 0000000..fa8e311 --- /dev/null +++ b/utils/TrySentry.psm1 @@ -0,0 +1,509 @@ +# Sentry PowerShell SDK wrapper module +# Provides graceful degradation when Sentry module is unavailable +# Telemetry is opt-in and requires explicit DSN configuration + +# Track initialization state to avoid repeated attempts +$script:InitializationAttempted = $false +$script:SentryAvailable = $false +$script:ConfiguredDsn = $null + +<# +.SYNOPSIS +Internal function to ensure Sentry SDK is ready for use. + +.DESCRIPTION +Checks if Sentry is disabled, loads the module if needed, and initializes the SDK. +All failures are silent (Write-Debug only) to avoid breaking functionality. + +.OUTPUTS +[bool] True if Sentry is ready to use, false otherwise. +#> +function Ensure-SentryReady { + [CmdletBinding()] + [OutputType([bool])] + param() + + # Return cached result if we already attempted initialization + if ($script:InitializationAttempted) { + return $script:SentryAvailable + } + + $script:InitializationAttempted = $true + + # Check if Sentry SDK type is available (module loaded) + $sentryTypeAvailable = $false + try { + $null = [Sentry.SentrySdk] + $sentryTypeAvailable = $true + Write-Debug '[Sentry.SentrySdk] type already available' + } catch { + Write-Debug '[Sentry.SentrySdk] type not available, attempting to load module' + } + + # Try to import Sentry module if type not available + if (-not $sentryTypeAvailable) { + try { + # Load bundled Sentry module from vendor directory + $bundledSentryPath = Join-Path $PSScriptRoot '..\vendor\Sentry\Sentry.psd1' + if (Test-Path $bundledSentryPath) { + Import-Module $bundledSentryPath -ErrorAction Stop + $null = [Sentry.SentrySdk] # Verify type is now available + Write-Debug 'Bundled Sentry module imported successfully' + $sentryTypeAvailable = $true + } else { + Write-Debug "Bundled Sentry module not found at: $bundledSentryPath" + $script:SentryAvailable = $false + return $false + } + } catch { + Write-Debug "Failed to import bundled Sentry module: $_" + $script:SentryAvailable = $false + return $false + } + } + + # Check if already initialized + if ([Sentry.SentrySdk]::IsEnabled) { + Write-Debug 'Sentry SDK already initialized' + $script:SentryAvailable = $true + return $true + } + + # Initialize Sentry SDK + try { + # Use configured DSN (must be explicitly set via Start-Sentry) + $dsn = $script:ConfiguredDsn + + if ([string]::IsNullOrEmpty($dsn)) { + Write-Debug 'Sentry DSN not configured, telemetry disabled' + $script:SentryAvailable = $false + return $false + } + + Write-Debug "Initializing Sentry with DSN: $($dsn -replace '(?<=https://)([^@]+)(?=@)', '***')" + + Sentry\Start-Sentry -Dsn $dsn + + if ([Sentry.SentrySdk]::IsEnabled) { + Write-Debug 'Sentry SDK initialized successfully' + $script:SentryAvailable = $true + return $true + } else { + Write-Debug 'Sentry SDK initialization completed but IsEnabled is false' + $script:SentryAvailable = $false + return $false + } + } catch { + Write-Debug "Failed to initialize Sentry SDK: $_" + $script:SentryAvailable = $false + return $false + } +} + +<# +.SYNOPSIS +Optionally initialize Sentry with module context and tags. + +.DESCRIPTION +Ensures Sentry is ready and sets contextual tags like module name, version, +PowerShell version, and OS. Requires a DSN to be provided for telemetry to be enabled. + +.PARAMETER Dsn +The Sentry DSN (Data Source Name) for telemetry. If not provided, telemetry is disabled. + +.PARAMETER ModuleName +Name of the module using Sentry (e.g., 'SentryAppRunner'). + +.PARAMETER ModuleVersion +Version of the module. + +.PARAMETER Tags +Additional custom tags to set on all events. + +.EXAMPLE +Start-Sentry -Dsn $env:SENTRY_APP_RUNNER_DSN -ModuleName 'SentryAppRunner' -ModuleVersion '1.0.0' + +.EXAMPLE +Start-Sentry -Dsn $env:SENTRY_API_CLIENT_DSN -ModuleName 'MyModule' -ModuleVersion '2.1.0' -Tags @{ + environment = 'ci' + build_id = '12345' +} + +.OUTPUTS +[bool] True if Sentry was initialized successfully, false otherwise. +#> +function Start-Sentry { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $false)] + [string]$Dsn, + + [Parameter(Mandatory = $false)] + [string]$ModuleName, + + [Parameter(Mandatory = $false)] + [string]$ModuleVersion, + + [Parameter(Mandatory = $false)] + [hashtable]$Tags = @{} + ) + + # If DSN is provided, configure it before initialization + if (-not [string]::IsNullOrEmpty($Dsn)) { + $script:ConfiguredDsn = $Dsn + } + + # Telemetry is opt-in - DSN must be explicitly provided + if ([string]::IsNullOrEmpty($script:ConfiguredDsn)) { + Write-Debug 'Sentry DSN not provided, telemetry disabled' + return $false + } + + if (-not (Ensure-SentryReady)) { + return $false + } + + try { + # Set contextual tags + Edit-SentryScope { + if ($ModuleName) { + $_.SetTag('module_name', $ModuleName) + } + + if ($ModuleVersion) { + $_.SetTag('module_version', $ModuleVersion) + } + + # PowerShell version + $_.SetTag('powershell_version', $PSVersionTable.PSVersion.ToString()) + + # Operating system + $_.SetTag('os', $PSVersionTable.OS ?? $PSVersionTable.Platform ?? 'Windows') + + # CI environment detection + if ($env:CI) { + $_.SetTag('ci', 'true') + } + + # Custom tags + foreach ($key in $Tags.Keys) { + $_.SetTag($key, $Tags[$key]) + } + } + + Write-Debug "Sentry context initialized with module: $ModuleName, version: $ModuleVersion" + return $true + } catch { + Write-Debug "Failed to set Sentry context: $_" + return $false + } +} + +<# +.SYNOPSIS +Wrapper for Out-Sentry that fails silently if Sentry is unavailable. + +.DESCRIPTION +Sends an error, exception, or message to Sentry. Automatically ensures Sentry +is ready before sending. Fails silently if Sentry is not available. + +.PARAMETER InputObject +The object to send to Sentry. Can be an ErrorRecord, Exception, or string message. + +.PARAMETER Tag +Optional hashtable of tags to attach to the event. + +.PARAMETER Level +Optional severity level (Debug, Info, Warning, Error, Fatal). + +.EXAMPLE +try { + Get-Item "nonexistent.txt" +} +catch { + $_ | Out-Sentry +} + +.EXAMPLE +"Something important happened" | Out-Sentry -Level Info + +.EXAMPLE +$error[0] | Out-Sentry -Tag @{operation = "device_connect"; platform = "Xbox"} + +.OUTPUTS +[Guid] Event ID if sent successfully, $null otherwise. +#> +function Out-Sentry { + [CmdletBinding()] + [OutputType([System.Nullable[Guid]])] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [object]$InputObject, + + [Parameter(Mandatory = $false)] + [hashtable]$Tag = @{}, + + [Parameter(Mandatory = $false)] + [ValidateSet('Debug', 'Info', 'Warning', 'Error', 'Fatal')] + [string]$Level + ) + + process { + if (-not (Ensure-SentryReady)) { + return $null + } + + try { + # Build Out-Sentry parameters + $outSentryParams = @{} + + if ($Tag.Count -gt 0) { + $outSentryParams['EditScope'] = { + foreach ($key in $Tag.Keys) { + $_.SetTag($key, $Tag[$key]) + } + }.GetNewClosure() + } + + # Send to Sentry based on input type + if ($InputObject -is [System.Management.Automation.ErrorRecord]) { + $eventId = $InputObject | Sentry\Out-Sentry @outSentryParams + } elseif ($InputObject -is [System.Exception]) { + $eventId = Sentry\Out-Sentry -Exception $InputObject @outSentryParams + } else { + # Treat as message + $eventId = Sentry\Out-Sentry -Message $InputObject.ToString() @outSentryParams + } + + if ($eventId) { + Write-Debug "Event sent to Sentry: $eventId" + } + + return $eventId + } catch { + Write-Debug "Failed to send event to Sentry: $_" + return $null + } + } +} + +<# +.SYNOPSIS +Wrapper for Add-SentryBreadcrumb that fails silently if Sentry is unavailable. + +.DESCRIPTION +Adds a breadcrumb to the current Sentry scope. Breadcrumbs provide context +for subsequent events. Automatically ensures Sentry is ready before adding. + +.PARAMETER Message +The breadcrumb message. + +.PARAMETER Category +Optional category for the breadcrumb (e.g., "device", "network", "app"). + +.PARAMETER Data +Optional hashtable of additional data to attach to the breadcrumb. + +.PARAMETER Level +Optional breadcrumb level (Debug, Info, Warning, Error, Critical). + +.EXAMPLE +Add-SentryBreadcrumb -Message "Acquiring device lock" -Category "device" + +.EXAMPLE +Add-SentryBreadcrumb -Message "HTTP request completed" -Category "network" -Data @{ + status_code = 200 + duration_ms = 150 +} + +.EXAMPLE +"Starting application installation" | Add-SentryBreadcrumb -Category "app" +#> +function Add-SentryBreadcrumb { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [string]$Category, + + [Parameter(Mandatory = $false)] + [hashtable]$Data = @{}, + + [Parameter(Mandatory = $false)] + [ValidateSet('Debug', 'Info', 'Warning', 'Error', 'Critical')] + [string]$Level + ) + + process { + if (-not (Ensure-SentryReady)) { + return + } + + try { + $breadcrumbParams = @{ + Message = $Message + } + + if ($Category) { + $breadcrumbParams['Category'] = $Category + } + + if ($Data.Count -gt 0) { + $breadcrumbParams['Data'] = $Data + } + + if ($Level) { + $breadcrumbParams['Level'] = $Level + } + + Sentry\Add-SentryBreadcrumb @breadcrumbParams + Write-Debug "Breadcrumb added: $Message" + } catch { + Write-Debug "Failed to add Sentry breadcrumb: $_" + } + } +} + +<# +.SYNOPSIS +Wrapper for Edit-SentryScope that fails silently if Sentry is unavailable. + +.DESCRIPTION +Modifies the current Sentry scope to add tags, extra data, or change context. +Automatically ensures Sentry is ready before editing. + +.PARAMETER ScopeSetup +Scriptblock that receives the scope object and modifies it. + +.EXAMPLE +Edit-SentryScope { + $_.SetTag('operation', 'device_connect') + $_.SetExtra('target', '192.168.1.100') +} + +.EXAMPLE +Edit-SentryScope { + $_.User = @{ + id = $env:USERNAME + username = $env:USERNAME + } +} +#> +function Edit-SentryScope { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [scriptblock]$ScopeSetup + ) + + if (-not (Ensure-SentryReady)) { + return + } + + try { + Sentry\Edit-SentryScope -ScopeSetup $ScopeSetup + Write-Debug 'Sentry scope edited' + } catch { + Write-Debug "Failed to edit Sentry scope: $_" + } +} + +<# +.SYNOPSIS +Wrapper for Start-SentryTransaction that fails silently if Sentry is unavailable. + +.DESCRIPTION +Starts a performance monitoring transaction to track operation duration and create spans. +Returns a transaction object that can be used to create child spans and finish the transaction. +Automatically ensures Sentry is ready before starting. + +.PARAMETER Name +The name of the transaction (e.g., "Connect-Device", "Deploy-App"). + +.PARAMETER Operation +The operation type (e.g., "device.connect", "app.deploy", "http.request"). + +.PARAMETER CustomSamplingContext +Optional hashtable with additional context for sampling decisions. + +.EXAMPLE +$transaction = Start-SentryTransaction -Name "Connect-Device" -Operation "device.connect" +try { + # Create a span for a sub-operation + $span = $transaction?.StartChild("device.lock.acquire") + # ... perform lock acquisition ... + $span?.Finish() + + # Create another span + $span = $transaction?.StartChild("device.connection.establish") + # ... establish connection ... + $span?.Finish() +} +finally { + # Always finish the transaction + $transaction?.Finish() +} + +.EXAMPLE +$transaction = Start-SentryTransaction -Name "Build-App" -Operation "build" -CustomSamplingContext @{ + target = "Xbox" + preset = "Debug" +} +try { + # ... build operations ... +} +finally { + $transaction?.Finish() +} + +.OUTPUTS +[Sentry.ITransaction] Transaction object if successful, $null otherwise. +#> +function Start-SentryTransaction { + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$Operation, + + [Parameter(Mandatory = $false)] + [hashtable]$CustomSamplingContext = @{} + ) + + if (-not (Ensure-SentryReady)) { + return $null + } + + try { + $transactionParams = @{ + Name = $Name + Operation = $Operation + } + + if ($CustomSamplingContext.Count -gt 0) { + $transactionParams['CustomSamplingContext'] = $CustomSamplingContext + } + + $transaction = Sentry\Start-SentryTransaction @transactionParams + Write-Debug "Sentry transaction started: $Name ($Operation)" + return $transaction + } catch { + Write-Debug "Failed to start Sentry transaction: $_" + return $null + } +} + +# Export public functions +Export-ModuleMember -Function @( + 'Start-Sentry', + 'Out-Sentry', + 'Add-SentryBreadcrumb', + 'Edit-SentryScope', + 'Start-SentryTransaction' +) diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000..ce9b745 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,63 @@ +# Vendored Dependencies + +This directory contains third-party dependencies bundled directly in the repository. + +## Sentry PowerShell Module + +**Version**: 0.4.0 +**Source**: [getsentry/sentry-powershell](https://github.com/getsentry/sentry-powershell) +**Release**: [v0.4.0](https://github.com/getsentry/sentry-powershell/releases/tag/0.4.0) +**License**: MIT + +**Note**: The `lib/net462` directory has been removed from this bundle. This toolkit requires **PowerShell Core 7+** (not Windows PowerShell 5.1) for all functionality including telemetry. + +### Why Bundled? + +The Sentry PowerShell module is bundled rather than installed from PSGallery to ensure: + +1. **Reliability** - No dependency on PSGallery availability during CI runs or in isolated environments +2. **Consistency** - All developers and CI environments use the exact same version +3. **Offline Support** - Works in air-gapped or restricted network environments +4. **Performance** - No first-run installation delay or network overhead +5. **Simplicity** - Zero setup required for telemetry functionality + +Since this toolkit is internal Sentry testing infrastructure, bundling the module provides the best developer experience with guaranteed availability. + +### Updating the Module + +To update to a newer version: + +1. Download the `Sentry.zip` artifact from the [releases page](https://github.com/getsentry/sentry-powershell/releases) +2. Extract to a temporary location +3. Replace the contents of `vendor/Sentry/` with the new version +4. Update the version number in this README +5. Test that telemetry still works with the new version +6. Commit the changes + +```powershell +# Example update process +$version = "0.5.0" +curl -L -o Sentry.zip "https://github.com/getsentry/sentry-powershell/releases/download/$version/Sentry.zip" +Remove-Item -Recurse -Force vendor/Sentry +Expand-Archive -Path Sentry.zip -DestinationPath vendor/Sentry +Remove-Item Sentry.zip + +# Remove Windows PowerShell assemblies (not supported) +Remove-Item -Recurse -Force vendor/Sentry/lib/net462 +``` + +### Contents + +- `Sentry.psd1` - PowerShell module manifest +- `Sentry.psm1` - Main module file +- `assemblies-loader.ps1` - .NET assembly loader +- `lib/net8.0/` - .NET 8.0 assemblies (for PowerShell 7.4+) +- `lib/net9.0/` - .NET 9.0 assemblies (for future PowerShell versions) +- `public/` - Public cmdlets (Start-Sentry, Out-Sentry, Add-SentryBreadcrumb, etc.) +- `private/` - Internal implementation files + +**Note**: `lib/net462` (Windows PowerShell) has been removed as this toolkit requires PowerShell Core 7+. + +### Size + +Approximately 1.6 MB (PowerShell Core assemblies only; net462 removed) diff --git a/vendor/Sentry/Sentry.psd1 b/vendor/Sentry/Sentry.psd1 new file mode 100644 index 0000000..2e9abe4 --- /dev/null +++ b/vendor/Sentry/Sentry.psd1 @@ -0,0 +1,85 @@ +# https://learn.microsoft.com/en-us/powershell/scripting/developer/module/how-to-write-a-powershell-module-manifest +@{ + # Script module or binary module file associated with this manifest. + RootModule = 'Sentry.psm1' + + # Version number of this module. + ModuleVersion = '0.4.0' + + # Supported PSEditions + CompatiblePSEditions = @('Desktop', 'Core') + + # ID used to uniquely identify this module + GUID = '4062b4a0-74d3-4aee-a3ec-9889342d4025' + + # Author of this module + Author = 'Sentry' + + # Company or vendor of this module + CompanyName = 'Sentry' + + # Copyright statement for this module + Copyright = '(c) Sentry. All rights reserved.' + + # Description of the functionality provided by this module + Description = 'An error reporting module that sends reports to Sentry.io' + + # Minimum version of the PowerShell engine required by this module + PowerShellVersion = '5.1' + + # Script files (.ps1) that are run in the caller's environment prior to importing this module. + ScriptsToProcess = @('assemblies-loader.ps1') + + # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. + FunctionsToExport = @( + 'Add-SentryBreadcrumb', + 'Edit-SentryScope', + 'Invoke-WithSentry', + 'Out-Sentry', + 'Start-Sentry', + 'Start-SentryTransaction', + 'Stop-Sentry' + ) + + # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. + CmdletsToExport = @() + + # Variables to export from this module + VariablesToExport = @() + + # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. + AliasesToExport = @() + + # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. + PrivateData = @{ + PSData = @{ + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('Sentry', 'PSEdition_Core', 'PSEdition_Desktop', 'Windows', 'Linux', 'macOS') + + + # A URL to the license for this module. + LicenseUri = 'https://raw.githubusercontent.com/getsentry/sentry-powershell/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/getsentry/sentry-powershell' + + # A URL to an icon representing this module. + IconUri = 'https://raw.githubusercontent.com/getsentry/platformicons/4e407e832f1a2a95d77ca8ca0ea2a195a38eec24/svg/sentry.svg' + + # ReleaseNotes of this module + ReleaseNotes = 'https://raw.githubusercontent.com/getsentry/sentry-powershell/main/CHANGELOG.md' + + # Prerelease string of this module + Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + } # End of PSData hashtable + } # End of PrivateData hashtable + + # HelpInfo URI of this module + HelpInfoURI = 'https://docs.sentry.io/platforms/powershell' +} diff --git a/vendor/Sentry/Sentry.psm1 b/vendor/Sentry/Sentry.psm1 new file mode 100644 index 0000000..810abdd --- /dev/null +++ b/vendor/Sentry/Sentry.psm1 @@ -0,0 +1,13 @@ +$publicDir = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'public' +$privateDir = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'private' +$moduleInfo = Import-PowerShellDataFile (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'Sentry.psd1') + +. "$privateDir/Get-SentryAssembliesDirectory.ps1" +$sentryDllPath = (Join-Path (Get-SentryAssembliesDirectory) 'Sentry.dll') + +Add-Type -TypeDefinition (Get-Content "$privateDir/SentryEventProcessor.cs" -Raw) -ReferencedAssemblies $sentryDllPath -Debug:$false +. "$privateDir/SentryEventProcessor.ps1" + +Get-ChildItem $publicDir -Filter '*.ps1' | ForEach-Object { + . $_.FullName +} diff --git a/vendor/Sentry/assemblies-loader.ps1 b/vendor/Sentry/assemblies-loader.ps1 new file mode 100644 index 0000000..e09d2d0 --- /dev/null +++ b/vendor/Sentry/assemblies-loader.ps1 @@ -0,0 +1,24 @@ +. (Join-Path (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'private') 'Get-SentryAssembliesDirectory.ps1') + +$dir = Get-SentryAssembliesDirectory + +# Check if the assembly is already loaded. +$type = 'Sentry.SentrySdk' -as [type] +if ($type) { + $loadedAsssembly = $type.Assembly + $expectedAssembly = [Reflection.Assembly]::LoadFile((Join-Path $dir 'Sentry.dll')) + + if ($loadedAsssembly.ToString() -ne $expectedAssembly.ToString()) { + throw "Sentry assembly is already loaded but it's not the expected version. + Found: ($loadedAsssembly), location: $($loadedAsssembly.Location) + Expected: ($expectedAssembly), location: $($expectedAssembly.Location)" + } else { + Write-Debug "Sentry assembly is already loaded and at the expected version ($($expectedAssembly.GetName().Version)" + } +} else { + Write-Debug "Loading assemblies from $($dir):" + Get-ChildItem -Path $dir -Filter '*.dll' | ForEach-Object { + Write-Debug "Loading assembly: $($_.Name)" + [Reflection.Assembly]::LoadFrom($_.FullName) | Write-Debug + } +} diff --git a/vendor/Sentry/lib/net8.0/Sentry.license b/vendor/Sentry/lib/net8.0/Sentry.license new file mode 100644 index 0000000..8ab70c0 --- /dev/null +++ b/vendor/Sentry/lib/net8.0/Sentry.license @@ -0,0 +1 @@ +MIT \ No newline at end of file diff --git a/vendor/Sentry/lib/net8.0/Sentry.version b/vendor/Sentry/lib/net8.0/Sentry.version new file mode 100644 index 0000000..c21f961 --- /dev/null +++ b/vendor/Sentry/lib/net8.0/Sentry.version @@ -0,0 +1 @@ +5.16.1.0 \ No newline at end of file diff --git a/vendor/Sentry/lib/net9.0/Sentry.license b/vendor/Sentry/lib/net9.0/Sentry.license new file mode 100644 index 0000000..8ab70c0 --- /dev/null +++ b/vendor/Sentry/lib/net9.0/Sentry.license @@ -0,0 +1 @@ +MIT \ No newline at end of file diff --git a/vendor/Sentry/lib/net9.0/Sentry.version b/vendor/Sentry/lib/net9.0/Sentry.version new file mode 100644 index 0000000..c21f961 --- /dev/null +++ b/vendor/Sentry/lib/net9.0/Sentry.version @@ -0,0 +1 @@ +5.16.1.0 \ No newline at end of file diff --git a/vendor/Sentry/private/DiagnosticLogger.ps1 b/vendor/Sentry/private/DiagnosticLogger.ps1 new file mode 100644 index 0000000..fbfc410 --- /dev/null +++ b/vendor/Sentry/private/DiagnosticLogger.ps1 @@ -0,0 +1,55 @@ +class DiagnosticLogger : Sentry.Extensibility.IDiagnosticLogger { + hidden [Sentry.SentryLevel] $minimalLevel + + DiagnosticLogger([Sentry.SentryLevel] $minimalLevel) { + $this.minimalLevel = $minimalLevel + } + + [bool] IsEnabled([Sentry.SentryLevel] $level) { + return $level -ge $this.minimalLevel + } + + Log([Sentry.SentryLevel] $level, [string] $message, [Exception] $exception = $null, [object[]] $params) { + # Important: Only format the string if there are args passed. + # Otherwise, a pre-formatted string that contains braces can cause a FormatException. + if ($params.Count -gt 0) { + $message = $message -f $params + } + + # Note, linefeed and newline chars are removed to guard against log injection attacks. + $message = $message -replace '[\r\n]+', ' ' + + $message = "[Sentry] $message" + if ($null -ne $exception) { + $message += [Environment]::NewLine + $message += $exception | Out-String + } + + switch ($level) { + ([Sentry.SentryLevel]::Info) { + Write-Verbose $message + } + ([Sentry.SentryLevel]::Warning) { + Write-Warning $message + } + ([Sentry.SentryLevel]::Error) { + Write-Error $message + } + ([Sentry.SentryLevel]::Fatal) { + Write-Error $message + } + default { + # Workaround for Windows Powershell issue of halting and asking for user confirmation. + # see https://github.com/PowerShell/PowerShell/issues/5148 + if ($global:PSVersionTable.PSEdition -eq 'Desktop') { + $pref = Get-Variable DebugPreference + if (($null -ne $pref) -and ($pref.value -eq 'Inquire')) { + $DebugPreference = 'Continue' + } + } + + Write-Debug $message + } + } + } +} diff --git a/vendor/Sentry/private/EventUpdater.ps1 b/vendor/Sentry/private/EventUpdater.ps1 new file mode 100644 index 0000000..0c99fe7 --- /dev/null +++ b/vendor/Sentry/private/EventUpdater.ps1 @@ -0,0 +1,13 @@ +class EventUpdater : SentryEventProcessor { + [Sentry.SentryEvent]DoProcess([Sentry.SentryEvent] $event_) { + $event_.Platform = 'powershell' + + # Clear useless release set by the .NET SDK (referring to the PowerShell assembly version) + # "pwsh@7.4.1 SHA: 6a98b28414948626f1b29a5e8b062e73b7ff165a+6a98b28414948626f1b29a5e8b062e73b7ff165a" + if ($event_.Release -match "pwsh@$($global:PSVersionTable.PSVersion) .*") { + $event_.Release = $null + } + + return $event_ + } +} diff --git a/vendor/Sentry/private/Get-CurrentOptions.ps1 b/vendor/Sentry/private/Get-CurrentOptions.ps1 new file mode 100644 index 0000000..5a0e87f --- /dev/null +++ b/vendor/Sentry/private/Get-CurrentOptions.ps1 @@ -0,0 +1,10 @@ +function Get-CurrentOptions { + $bindingFlags = [System.Reflection.BindingFlags]::Static + [System.Reflection.BindingFlags]::NonPublic + [System.Reflection.BindingFlags]::Public + $currentOptionsProperty = [Sentry.SentrySdk].GetProperty('CurrentOptions', $bindingFlags) + if ($null -eq $currentOptionsProperty) { + return $null + } + + [Sentry.SentryOptions] $options = $currentOptionsProperty.GetValue($null) + return $options +} diff --git a/vendor/Sentry/private/Get-SentryAssembliesDirectory.ps1 b/vendor/Sentry/private/Get-SentryAssembliesDirectory.ps1 new file mode 100644 index 0000000..e8fa049 --- /dev/null +++ b/vendor/Sentry/private/Get-SentryAssembliesDirectory.ps1 @@ -0,0 +1,26 @@ +function GetTFM { + # Source https://learn.microsoft.com/en-us/powershell/scripting/install/powershell-support-lifecycle?view=powershell-7.4#powershell-end-of-support-dates + # PowerShell 7.5 - Built on .NET 9.0 + # PowerShell 7.4 (LTS) - Built on .NET 8.0 + # PowerShell 7.3 - Built on .NET 7.0 + # PowerShell 7.2 (LTS) - Built on .NET 6.0 + # PowerShell 7.1 - Built on .NET 5.0 + # PowerShell 7.0 (LTS) - Built on .NET Core 3.1 + # PowerShell 6.2 - Built on .NET Core 2.1 + # PowerShell 6.1 - Built on .NET Core 2.1 + # PowerShell 6.0 - Built on .NET Core 2.0 + if ($PSVersionTable.PSVersion -ge '7.5') { + return 'net9.0' + } elseif ($PSVersionTable.PSVersion -ge '7.4') { + return 'net8.0' + } else { + return 'net462' + } +} + +function Get-SentryAssembliesDirectory { + $dir = Split-Path -Parent $PSScriptRoot + $dir = Join-Path $dir 'lib' + $dir = Join-Path $dir (GetTFM) + return $dir +} diff --git a/vendor/Sentry/private/New-HttpTransport.ps1 b/vendor/Sentry/private/New-HttpTransport.ps1 new file mode 100644 index 0000000..f3060c0 --- /dev/null +++ b/vendor/Sentry/private/New-HttpTransport.ps1 @@ -0,0 +1,16 @@ +# Wrapper to expose Sentry.Internal.SdkComposer::CreateHttpTransport() +function New-HttpTransport { + [OutputType([Sentry.Extensibility.ITransport])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [Sentry.SentryOptions] $options + ) + + $assembly = [Sentry.SentrySdk].Assembly + $type = $assembly.GetType('Sentry.Internal.SdkComposer') + $composer = [Activator]::CreateInstance($type, @($options)) + + $method = $type.GetMethod('CreateHttpTransport', [System.Reflection.BindingFlags]::Instance + [System.Reflection.BindingFlags]::NonPublic + [System.Reflection.BindingFlags]::Public) + return $method.Invoke($composer, @()) +} diff --git a/vendor/Sentry/private/ScopeIntegration.ps1 b/vendor/Sentry/private/ScopeIntegration.ps1 new file mode 100644 index 0000000..0275a44 --- /dev/null +++ b/vendor/Sentry/private/ScopeIntegration.ps1 @@ -0,0 +1,23 @@ +class ScopeIntegration : Sentry.Integrations.ISdkIntegration { + Register([Sentry.IHub] $hub, [Sentry.SentryOptions] $options) { + $hub.ConfigureScope([System.Action[Sentry.Scope]] { + param([Sentry.Scope]$scope) + + $scope.Sdk.Name = 'sentry.dotnet.powershell' + $scope.Sdk.Version = $moduleInfo.ModuleVersion + $scope.Sdk.AddPackage("ps:$($scope.Sdk.Name)", $scope.Sdk.Version) + + if ($PSVersionTable.PSEdition -eq 'Core') { + $scope.Contexts.Runtime.Name = 'PowerShell' + } else { + $scope.Contexts.Runtime.Name = 'Windows PowerShell' + } + $scope.Contexts.Runtime.Version = $PSVersionTable.PSVersion.ToString() + + $netRuntime = [Sentry.PlatformAbstractions.SentryRuntime]::Current + $scope.Contexts['runtime.net'] = [Sentry.Protocol.Runtime]::new() + $scope.Contexts['runtime.net'].Name = $netRuntime.Name + $scope.Contexts['runtime.net'].Version = $netRuntime.Version + }); + } +} diff --git a/vendor/Sentry/private/SentryEventProcessor.cs b/vendor/Sentry/private/SentryEventProcessor.cs new file mode 100644 index 0000000..de25a2f --- /dev/null +++ b/vendor/Sentry/private/SentryEventProcessor.cs @@ -0,0 +1,13 @@ +// This is an abstract class for any PowerShell event processors. It gets around an issue with Windows PowerShell +// failing to compile scripts that have a method name `Process`, which is a reserved word. +// https://stackoverflow.com/questions/78001695/windows-powershell-implement-c-sharp-interface-with-reserved-words-as-method-n/78001981 +// This way, we can keep the PowerShell implementation of the event processor, with access to System.Management.Automation, etc. +public abstract class SentryEventProcessor_ : Sentry.Extensibility.ISentryEventProcessor +{ + public Sentry.SentryEvent Process(Sentry.SentryEvent event_) + { + return Process_(event_); + } + + protected abstract Sentry.SentryEvent Process_(Sentry.SentryEvent event_); +} diff --git a/vendor/Sentry/private/SentryEventProcessor.ps1 b/vendor/Sentry/private/SentryEventProcessor.ps1 new file mode 100644 index 0000000..0c7b31d --- /dev/null +++ b/vendor/Sentry/private/SentryEventProcessor.ps1 @@ -0,0 +1,18 @@ +class SentryEventProcessor : SentryEventProcessor_ { + [Sentry.SentryEvent]DoProcess([Sentry.SentryEvent] $event_) { + throw [NotImplementedException]::new('You must override SentryEventProcessor::DoProcess()') + } + + [Sentry.SentryEvent]Process_([Sentry.SentryEvent] $event_) { + try { + return $this.DoProcess($event_) + } catch { + $ErrorRecord = $_ + "$($this.GetType()) failed to process event $($event_.EventId):" | Write-Warning + $ErrorRecord | Format-List * -Force | Out-String | Write-Warning + $ErrorRecord.InvocationInfo | Format-List * | Out-String | Write-Warning + $ErrorRecord.Exception | Format-List * -Force | Out-String | Write-Warning + return $event_ + } + } +} diff --git a/vendor/Sentry/private/StackTraceProcessor.ps1 b/vendor/Sentry/private/StackTraceProcessor.ps1 new file mode 100644 index 0000000..a89e6e7 --- /dev/null +++ b/vendor/Sentry/private/StackTraceProcessor.ps1 @@ -0,0 +1,333 @@ +class StackTraceProcessor : SentryEventProcessor { + [Sentry.Protocol.SentryException]$SentryException + [System.Management.Automation.InvocationInfo]$InvocationInfo + [System.Management.Automation.CallStackFrame[]]$StackTraceFrames + [string[]]$StackTraceString + hidden [Sentry.Extensibility.IDiagnosticLogger] $logger + hidden [string[]] $modulePaths + hidden [hashtable] $pwshModules = @{} + + StackTraceProcessor([Sentry.SentryOptions] $options) { + $this.logger = $options.DiagnosticLogger + if ($null -eq $this.logger) { + $this.logger = Get-Variable -Scope script -Name SentryPowerShellDiagnosticLogger -ValueOnly -ErrorAction SilentlyContinue + } + + if ($env:PSModulePath.Contains(';')) { + # Windows + $this.modulePaths = $env:PSModulePath -split ';' + } else { + # Unix + $this.modulePaths = $env:PSModulePath -split ':' + } + } + + [Sentry.SentryEvent]DoProcess([Sentry.SentryEvent] $event_) { + if ($null -ne $this.SentryException) { + $this.ProcessException($event_) + } elseif ($null -ne $event_.Message) { + $this.ProcessMessage($event_) + } + + # Add modules present in PowerShell + foreach ($module in $this.pwshModules.GetEnumerator()) { + $event_.Modules[$module.Name] = $module.Value + } + + # Add .NET modules. Note: we don't let sentry-dotnet do it because it would just add all the loaded assemblies, + # regardless of their presence in a stacktrace. So we set the option ReportAssembliesMode=None in [Start-Sentry]. + foreach ($thread in $event_.SentryThreads) { + foreach ($frame in $thread.Stacktrace.Frames) { + # .NET SDK sets the assembly info to frame.Package, for example: + # "System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e" + if ($frame.Package -match '^(?[^,]+), Version=(?[^,]+), ') { + $event_.Modules[$Matches.Assembly] = $Matches.Version + } + } + } + + return $event_ + } + + hidden ProcessMessage([Sentry.SentryEvent] $event_) { + $this.PrependThread($event_, $this.GetStackTrace()) + } + + hidden ProcessException([Sentry.SentryEvent] $event_) { + $this.SentryException.Stacktrace = $this.GetStackTrace() + if ($this.SentryException.Stacktrace.Frames.Count -gt 0) { + $topFrame = $this.SentryException.Stacktrace.Frames | Select-Object -Last 1 + $this.SentryException.Module = $topFrame.Module + } + + # Add the c# exception to the front of the exception list, followed by whatever is already there. + $newExceptions = New-Object System.Collections.Generic.List[Sentry.Protocol.SentryException] + if ($null -ne $event_.SentryExceptions) { + foreach ($e in $event_.SentryExceptions) { + if ($null -eq $e.Mechanism) { + $e.Mechanism = [Sentry.Protocol.Mechanism]::new() + } + $e.Mechanism.Synthetic = $true + $newExceptions.Add($e) + } + } + $newExceptions.Add($this.SentryException) + $event_.SentryExceptions = $newExceptions + $this.PrependThread($event_, $this.SentryException.Stacktrace) + } + + hidden PrependThread([Sentry.SentryEvent] $event_, [Sentry.SentryStackTrace] $sentryStackTrace) { + $newThreads = New-Object System.Collections.Generic.List[Sentry.SentryThread] + $thread = New-Object Sentry.SentryThread + $thread.Id = 0 + $thread.Name = 'PowerShell Script' + $thread.Crashed = $true + $thread.Current = $true + $thread.Stacktrace = $sentryStackTrace + $newThreads.Add($thread) + if ($null -ne $event_.SentryThreads) { + foreach ($t in $event_.SentryThreads) { + $t.Crashed = $false + $t.Current = $false + $newThreads.Add($t) + } + } + $event_.SentryThreads = $newThreads + } + + hidden [Sentry.SentryStackTrace]GetStackTrace() { + # We collect all frames and then reverse them to the order expected by Sentry (caller->callee). + # Do not try to make this code go backwards because it relies on the InvocationInfo from the previous frame. + $sentryFrames = New-Object System.Collections.Generic.List[Sentry.SentryStackFrame] + if ($null -ne $this.StackTraceString) { + $sentryFrames.Capacity = $this.StackTraceString.Count + 1 + # Note: if InvocationInfo is present, use it to update: + # - the first frame (in case of `$_ | Out-Sentry` in a catch clause). + # - the second frame (in case of `write-error` and `$_ | Out-Sentry` in a trap). + if ($null -ne $this.InvocationInfo) { + $sentryFrameInitial = $this.CreateFrame($this.InvocationInfo) + } else { + $sentryFrameInitial = $null + } + + foreach ($frame in $this.StackTraceString) { + $sentryFrame = $this.CreateFrame($frame) + if ($null -ne $sentryFrameInitial -and $sentryFrames.Count -lt 2) { + if ($sentryFrameInitial.AbsolutePath -eq $sentryFrame.AbsolutePath -and $sentryFrameInitial.LineNumber -eq $sentryFrame.LineNumber) { + $sentryFrame.ContextLine = $sentryFrameInitial.ContextLine + $sentryFrame.ColumnNumber = $sentryFrameInitial.ColumnNumber + $sentryFrameInitial = $null + } + } + $sentryFrames.Add($sentryFrame) + } + + if ($null -ne $sentryFrameInitial) { + $sentryFrames.Insert(0, $sentryFrameInitial) + } + + $this.EnhanceTailFrames($sentryFrames) + } elseif ($null -ne $this.StackTraceFrames) { + $sentryFrames.Capacity = $this.StackTraceFrames.Count + 1 + foreach ($frame in $this.StackTraceFrames) { + $sentryFrames.Add($this.CreateFrame($frame)) + } + } + + foreach ($sentryFrame in $sentryFrames) { + # Update module info + $this.SetModule($sentryFrame) + $sentryFrame.InApp = [string]::IsNullOrEmpty($sentryFrame.Module) + $this.SetContextLines($sentryFrame) + } + + $sentryFrames.Reverse() + $stacktrace_ = [Sentry.SentryStackTrace]::new() + $stacktrace_.Frames = $sentryFrames + return $stacktrace_ + } + + hidden [Sentry.SentryStackFrame] CreateFrame([System.Management.Automation.InvocationInfo] $info) { + $sentryFrame = [Sentry.SentryStackFrame]::new() + $sentryFrame.AbsolutePath = $info.ScriptName + $sentryFrame.LineNumber = $info.ScriptLineNumber + $sentryFrame.ColumnNumber = $info.OffsetInLine + $sentryFrame.ContextLine = $info.Line.TrimEnd() + return $sentryFrame + } + + hidden [Sentry.SentryStackFrame] CreateFrame([System.Management.Automation.CallStackFrame] $frame) { + $sentryFrame = [Sentry.SentryStackFrame]::new() + $this.SetScriptInfo($sentryFrame, $frame) + $this.SetModule($sentryFrame) + $this.SetFunction($sentryFrame, $frame) + return $sentryFrame + } + + hidden [Sentry.SentryStackFrame] CreateFrame([string] $frame) { + $sentryFrame = [Sentry.SentryStackFrame]::new() + # at funcB, C:\dev\sentry-powershell\tests\capture.tests.ps1: line 363 + $regex = 'at (?[^,]*), (?.*): line (?\d*)' + if ($frame -match $regex) { + if ($Matches.AbsolutePath -ne '') { + $sentryFrame.AbsolutePath = $Matches.AbsolutePath + } + $sentryFrame.LineNumber = [int]$Matches.LineNumber + $sentryFrame.Function = $Matches.Function + } else { + Write-Warning "Failed to parse stack frame: $frame" + } + return $sentryFrame + } + + hidden EnhanceTailFrames([Sentry.SentryStackFrame[]] $sentryFrames) { + if ($null -eq $this.StackTraceFrames) { + return + } + + # The last frame is usually how the PowerShell was invoked. We need to get this info from $this.StackTraceFrames + # - for pwsh scriptname.ps1 it would be something like `. scriptname.ps1` + # - for pwsh -c `& {..}` it would be the `& {..}` code block. And in this case, the next frame would also be + # just a scriptblock without a filename so we need to get the source code from the StackTraceFrames too. + $i = 0; + for ($j = $sentryFrames.Count - 1; $j -ge 0; $j--) { + $sentryFrame = $sentryFrames[$j] + $frame = $this.StackTraceFrames | Select-Object -Last 1 -Skip $i + $i++ + + if ($null -eq $frame) { + break + } + + if ($null -eq $sentryFrame.AbsolutePath -and $null -eq $frame.ScriptName) { + if ($frame.ScriptLineNumber -gt 0 -and $frame.ScriptLineNumber -eq $sentryFrame.LineNumber) { + $this.SetScriptInfo($sentryFrame, $frame) + $this.SetModule($sentryFrame) + $this.SetFunction($sentryFrame, $frame) + } + $this.SetContextLines($sentryFrame, $frame) + + # Try to match following frames that are part of the same codeblock. + while ($j -gt 0) { + $nextSentryFrame = $sentryFrames[$j - 1] + if ($nextSentryFrame.AbsolutePath -ne $sentryFrame.AbsolutePath) { + break + } + $this.SetContextLines($nextSentryFrame, $frame) + $j-- + } + } + } + } + + hidden SetScriptInfo([Sentry.SentryStackFrame] $sentryFrame, [System.Management.Automation.CallStackFrame] $frame) { + if (![string]::IsNullOrEmpty($frame.ScriptName)) { + $sentryFrame.AbsolutePath = $frame.ScriptName + $sentryFrame.LineNumber = $frame.ScriptLineNumber + } elseif (![string]::IsNullOrEmpty($frame.Position) -and ![string]::IsNullOrEmpty($frame.Position.File)) { + $sentryFrame.AbsolutePath = $frame.Position.File + $sentryFrame.LineNumber = $frame.Position.StartLineNumber + $sentryFrame.ColumnNumber = $frame.Position.StartColumnNumber + } + } + + hidden SetModule([Sentry.SentryStackFrame] $sentryFrame) { + if (![string]::IsNullOrEmpty($sentryFrame.AbsolutePath)) { + if ($prefix = $this.modulePaths | Where-Object { $sentryFrame.AbsolutePath.StartsWith($_) }) { + $relativePath = $sentryFrame.AbsolutePath.Substring($prefix.Length + 1) + $parts = $relativePath -split '[\\/]' + $sentryFrame.Module = $parts | Select-Object -First 1 + if ($parts.Length -ge 2) { + if (-not $this.pwshModules.ContainsKey($parts[0])) { + $this.pwshModules[$parts[0]] = $parts[1] + } elseif ($this.pwshModules[$parts[0]] -ne $parts[1]) { + $this.pwshModules[$parts[0]] = $this.pwshModules[$parts[0]] + ", $($parts[1])" + } + } + } + } + } + + hidden SetFunction([Sentry.SentryStackFrame] $sentryFrame, [System.Management.Automation.CallStackFrame] $frame) { + if ([string]::IsNullOrEmpty($sentryFrame.AbsolutePath) -and $frame.FunctionName -eq '' -and ![string]::IsNullOrEmpty($frame.Position)) { + $sentryFrame.Function = $frame.Position.Text + + # $frame.Position.Text may be a multiline command (e.g. when executed with `pwsh -c '& { ... \n ... \n ... }`) + # So we need to trim it to a single line. + if ($sentryFrame.Function.Contains("`n")) { + $lines = $sentryFrame.Function -split "[`r`n]+" + $sentryFrame.Function = $lines[0] + ' ' + if ($lines.Count -gt 2) { + $sentryFrame.Function += ' ...... ' + } + $sentryFrame.Function += $lines[$lines.Count - 1] + } + } else { + $sentryFrame.Function = $frame.FunctionName + } + } + + hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame, [System.Management.Automation.CallStackFrame] $frame) { + if ($sentryFrame.LineNumber -gt 0) { + try { + $lines = $frame.InvocationInfo.MyCommand.ScriptBlock.ToString() -split "`n" + $this.SetContextLines($sentryFrame, $lines) + } catch { + Write-Warning "Failed to read context lines for frame with function '$($sentryFrame.Function)': $_" + if ($global:SentryPowershellRethrowErrors -eq $true) { + throw + } + } + } + } + + hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame) { + if ([string]::IsNullOrEmpty($sentryFrame.AbsolutePath) -or $sentryFrame.LineNumber -lt 1) { + return + } + + if ((Test-Path $sentryFrame.AbsolutePath -IsValid) -and (Test-Path $sentryFrame.AbsolutePath -PathType Leaf)) { + try { + $lines = Get-Content $sentryFrame.AbsolutePath -TotalCount ($sentryFrame.LineNumber + 5) + $this.SetContextLines($sentryFrame, $lines) + } catch { + Write-Warning "Failed to read context lines for $($sentryFrame.AbsolutePath): $_" + if ($global:SentryPowershellRethrowErrors -eq $true) { + throw + } + } + } + } + + hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame, [string[]] $lines) { + if ($lines.Count -lt $sentryFrame.LineNumber) { + if ($null -ne $this.logger) { + $this.logger.Log( + [Sentry.SentryLevel]::Debug, + "Couldn't set frame context because the line number ($($sentryFrame.LineNumber)) " + + "is lower than the available number of source code lines ($($lines.Count))." + ) + } + return + } + + $numContextLines = 5 + + if ($null -eq $sentryFrame.ContextLine) { + $sentryFrame.ContextLine = $lines[$sentryFrame.LineNumber - 1] + } + + $preContextCount = [math]::Min($numContextLines, $sentryFrame.LineNumber - 1) + $postContextCount = [math]::Min($numContextLines, $lines.Count - $sentryFrame.LineNumber) + + if ($sentryFrame.LineNumber -gt $numContextLines + 1) { + $lines = $lines | Select-Object -Skip ($sentryFrame.LineNumber - $numContextLines - 1) + } + + # Note: these are read-only in sentry-dotnet so we just update the underlying lists instead of replacing. + $sentryFrame.PreContext.Clear() + $lines | Select-Object -First $preContextCount | ForEach-Object { $sentryFrame.PreContext.Add($_) } + $sentryFrame.PostContext.Clear() + $lines | Select-Object -First $postContextCount -Skip ($preContextCount + 1) | ForEach-Object { $sentryFrame.PostContext.Add($_) } + } +} diff --git a/vendor/Sentry/private/SynchronousTransport.ps1 b/vendor/Sentry/private/SynchronousTransport.ps1 new file mode 100644 index 0000000..6dd027d --- /dev/null +++ b/vendor/Sentry/private/SynchronousTransport.ps1 @@ -0,0 +1,79 @@ +# Take Sentry's SerializableHttpContent, convert it to a string, and send via PowerShell's Invoke-WebRequest, +# then translate the response back to a .NET HttpResponseMessage. +# There are limited options to perform synchronous operations in Windows PowerShell 5.1 on .NET 4.6, so this is a workaround. +class SynchronousTransport : Sentry.Http.HttpTransportBase, Sentry.Extensibility.ITransport { + [Sentry.Extensibility.IDiagnosticLogger] $logger + # PowerShell 7.5.2+ changed how property assignment works in constructors when inheriting from .NET classes. + # Using a hashtable instead of individual [System.Reflection.MethodInfo] properties works around this issue. + # See: https://github.com/PowerShell/PowerShell/releases/tag/v7.5.2 + [hashtable] $reflectionMethods = @{} + + SynchronousTransport([Sentry.SentryOptions] $options) : base($options) { + $this.logger = $options.DiagnosticLogger + if ($null -eq $this.logger) { + $this.logger = Get-Variable -Scope script -Name SentryPowerShellDiagnosticLogger -ValueOnly -ErrorAction SilentlyContinue + } + + # These are internal methods, so we need to use reflection to access them. + $instanceMethod = [System.Reflection.BindingFlags]::Instance + [System.Reflection.BindingFlags]::NonPublic + [System.Reflection.BindingFlags]::Public; + $this.reflectionMethods['ProcessEnvelope'] = [Sentry.Http.HttpTransportBase].GetMethod('ProcessEnvelope', $instanceMethod) + if ($null -eq $this.reflectionMethods['ProcessEnvelope']) { + throw "Failed to find ProcessEnvelope method on Sentry.Http.HttpTransportBase" + } + + $this.reflectionMethods['CreateRequest'] = [Sentry.Http.HttpTransportBase].GetMethod('CreateRequest', $instanceMethod) + if ($null -eq $this.reflectionMethods['CreateRequest']) { + throw "Failed to find CreateRequest method on Sentry.Http.HttpTransportBase" + } + + $EnvelopeHttpContentType = [Sentry.SentrySdk].Assembly.GetType('Sentry.Internal.Http.EnvelopeHttpContent') + if ($null -eq $EnvelopeHttpContentType) { + throw "Failed to find Sentry.Internal.Http.EnvelopeHttpContent type" + } + + $this.reflectionMethods['SerializeToStream'] = $EnvelopeHttpContentType.GetMethod('SerializeToStream', $instanceMethod) + if ($null -eq $this.reflectionMethods['SerializeToStream']) { + throw "Failed to find SerializeToStream method on EnvelopeHttpContent" + } + } + + [System.Threading.Tasks.Task] SendEnvelopeAsync([Sentry.Protocol.Envelopes.Envelope] $envelope, [System.Threading.CancellationToken]$cancellationToken = [System.Threading.CancellationToken]::None) { + $processedEnvelope = $this.reflectionMethods['ProcessEnvelope'].Invoke($this, @($envelope)) + if ($processedEnvelope.Items.count -gt 0) { + $request = $this.reflectionMethods['CreateRequest'].Invoke($this, @($processedEnvelope)) + + $headers = @{} + foreach ($header in $request.Headers) { + $Key = $header.Key + $Value = $header.Value.Trim() -join ', ' + $headers[$Key] = $Value + } + + $memoryStream = [System.IO.MemoryStream]::new() + $this.reflectionMethods['SerializeToStream'].Invoke($request.Content, @($memoryStream, $null, $cancellationToken)) + $memoryStream.Position = 0 + + if ($null -ne $this.logger) { + $this.logger.Log([Sentry.SentryLevel]::Debug, 'Sending content synchronously, Content-Length: {0}', $null, $memoryStream.Length) + } + + $ProgressPreference = 'SilentlyContinue' + $psResponse = Invoke-WebRequest -Uri $request.RequestUri -Method $request.Method.Method -Headers $headers -Body $memoryStream -UseBasicParsing + + $response = [System.Net.Http.HttpResponseMessage]::new($psResponse.StatusCode) + $contentType = $psResponse.Headers['Content-Type'] + if ($null -eq $contentType) { + $contentType = 'application/json' + } + $response.Content = [System.Net.Http.StringContent]::new($psResponse.Content, [System.Text.Encoding]::UTF8, $contentType) + + foreach ($header in $psResponse.Headers.GetEnumerator()) { + $response.Headers.TryAddWithoutValidation($header.Key, $header.Value) + } + + $this.HandleResponse($response, $processedEnvelope) + } + + return [System.Threading.Tasks.Task]::CompletedTask + } +} diff --git a/vendor/Sentry/private/SynchronousWorker.ps1 b/vendor/Sentry/private/SynchronousWorker.ps1 new file mode 100644 index 0000000..9a0a54f --- /dev/null +++ b/vendor/Sentry/private/SynchronousWorker.ps1 @@ -0,0 +1,35 @@ +. "$privateDir/New-HttpTransport.ps1" + +class SynchronousWorker : Sentry.Extensibility.IBackgroundWorker { + hidden [Sentry.Extensibility.ITransport] $transport + hidden [Sentry.SentryOptions] $options + hidden $unfinishedTasks = [System.Collections.Generic.List[System.Threading.Tasks.Task]]::new() + + SynchronousWorker([Sentry.SentryOptions] $options) { + $this.options = $options + + # Start from either the transport given on options, or create a new HTTP transport. + $this.transport = $options.Transport; + if ($null -eq $this.transport) { + $this.transport = New-HttpTransport($options) + } + } + + [bool] EnqueueEnvelope([Sentry.Protocol.Envelopes.Envelope] $envelope) { + $task = $this.transport.SendEnvelopeAsync($envelope, [System.Threading.CancellationToken]::None) + if (-not $task.Wait($this.options.FlushTimeout)) { + $this.unfinishedTasks.Add($task) + } + return $true + } + + [System.Threading.Tasks.Task] FlushAsync([System.TimeSpan] $timeout) { + [System.Threading.Tasks.Task]::WhenAll($this.unfinishedTasks).Wait($timeout) + $this.unfinishedTasks.Clear() + return [System.Threading.Tasks.Task]::CompletedTask + } + + [int] get_QueuedItems() { + return $this.unfinishedTasks.Count + } +} diff --git a/vendor/Sentry/public/Add-SentryBreadcrumb.ps1 b/vendor/Sentry/public/Add-SentryBreadcrumb.ps1 new file mode 100644 index 0000000..9f5e608 --- /dev/null +++ b/vendor/Sentry/public/Add-SentryBreadcrumb.ps1 @@ -0,0 +1,22 @@ +function Add-SentryBreadcrumb { + param( + [Parameter(Mandatory, ValueFromPipeline = $true)] + [string] $Message, + + [string] $Category = $null, + [string] $Type = $null, + [hashtable] $Data = $null, + [Sentry.BreadcrumbLevel] $Level = [Sentry.BreadcrumbLevel]::Info) + + begin { + if ($null -eq $Data) { + $DataDict = $null + } else { + $DataDict = [System.Collections.Generic.Dictionary[string, string]]::new() + $Data.Keys | ForEach-Object { $DataDict.Add($_, $Data[$_]) } + } + } + process { + [Sentry.SentrySdk]::AddBreadcrumb($Message, $Category, $Type, $DataDict, $Level) + } +} diff --git a/vendor/Sentry/public/Edit-SentryScope.ps1 b/vendor/Sentry/public/Edit-SentryScope.ps1 new file mode 100644 index 0000000..b495821 --- /dev/null +++ b/vendor/Sentry/public/Edit-SentryScope.ps1 @@ -0,0 +1,14 @@ +function Edit-SentryScope { + param( + [Parameter(Mandatory)] + [scriptblock] $ScopeSetup + ) + + process { + [Sentry.SentrySdk]::ConfigureScope([System.Action[Sentry.Scope]] { + param([Sentry.Scope]$scope) + # Execute the script block in the caller's scope (nothing to do $scope) & set the automatic $_ variable to the $scope object. + $scope | ForEach-Object $ScopeSetup + }) + } +} diff --git a/vendor/Sentry/public/Invoke-WithSentry.ps1 b/vendor/Sentry/public/Invoke-WithSentry.ps1 new file mode 100644 index 0000000..11915f2 --- /dev/null +++ b/vendor/Sentry/public/Invoke-WithSentry.ps1 @@ -0,0 +1,15 @@ +. "$publicDir/Out-Sentry.ps1" + +function Invoke-WithSentry { + param( + [scriptblock] + $ScriptBlock + ) + + try { + & $ScriptBlock + } catch { + $_ | Out-Sentry + throw + } +} diff --git a/vendor/Sentry/public/Out-Sentry.ps1 b/vendor/Sentry/public/Out-Sentry.ps1 new file mode 100644 index 0000000..4c6167f --- /dev/null +++ b/vendor/Sentry/public/Out-Sentry.ps1 @@ -0,0 +1,97 @@ +. "$privateDir/StackTraceProcessor.ps1" +. "$privateDir/Get-CurrentOptions.ps1" + +function Out-Sentry { + [OutputType([Sentry.SentryId])] + [CmdletBinding(DefaultParameterSetName = 'ErrorRecord')] + param( + [Parameter(ValueFromPipeline = $true, ParameterSetName = 'ErrorRecord')] + [System.Management.Automation.ErrorRecord] + $ErrorRecord, + + [Parameter(ValueFromPipeline = $true, ParameterSetName = 'Exception')] + [System.Exception] + $Exception, + + [Parameter(ValueFromPipeline = $true, ParameterSetName = 'Message')] + [string] + $Message, + + [Parameter(ParameterSetName = 'ErrorRecord')] + [Parameter(ParameterSetName = 'Exception')] + [Parameter(ParameterSetName = 'Message')] + [scriptblock] $EditScope + ) + + begin {} + process { + if (-not [Sentry.SentrySdk]::IsEnabled) { + # Workaround for: + # NullReferenceException: Object reference not set to an instance of an object. + # at Out-Sentry, D:\a\sentry-powershell\sentry-powershell\modules\Sentry\public\Out-Sentry.ps1:32 + try { + Write-Debug 'Sentry is not started: Out-Sentry invocation ignored.' + } catch {} + return + } + + $options = Get-CurrentOptions + [Sentry.SentryEvent]$event_ = $null + $processor = [StackTraceProcessor]::new($options) + + if ($ErrorRecord -ne $null) { + $event_ = [Sentry.SentryEvent]::new($ErrorRecord.Exception) + $processor.SentryException = [Sentry.Protocol.SentryException]::new() + + if ($($ErrorRecord.CategoryInfo.Activity) -eq 'Write-Error') { + # FullyQualifiedErrorId would be "Microsoft.PowerShell.Commands.WriteErrorException,funcB" + $processor.SentryException.Type = 'Write-Error' + } else { + $processor.SentryException.Type = $ErrorRecord.FullyQualifiedErrorId + } + + if (($details = $ErrorRecord.ErrorDetails) -and $null -ne $details.Message) { + $processor.SentryException.Value = $details.Message + } else { + $processor.SentryException.Value = $ErrorRecord.Exception.Message + } + + if ($options.AttachStackTrace) { + # Note: we use ScriptStackTrace even though we need to parse it, becaause it contains actual stack trace + # to the throw, not just the trace to the call to this function. + $processor.StackTraceString = @($ErrorRecord.ScriptStackTrace -split "[`r`n]+") + $processor.InvocationInfo = $ErrorRecord.InvocationInfo + } + + } elseif ($Exception -ne $null) { + $event_ = [Sentry.SentryEvent]::new($Exception) + $processor.SentryException = [Sentry.Protocol.SentryException]::new() + $processor.SentryException.Type = $Exception.GetType().FullName + $processor.SentryException.Value = $Exception.Message + } elseif ($Message -ne $null) { + $event_ = [Sentry.SentryEvent]::new() + $event_.Message = $Message + $event_.Level = [Sentry.SentryLevel]::Info + } else { + Write-Warning 'Out-Sentry: No argument matched, nothing to do' + return + } + + if ($null -eq $event_) { + Write-Debug 'Out-Sentry: Nothing to capture' + return + } + + if ($options.AttachStackTrace -and $null -eq $processor.StackTraceFrames) { + $processor.StackTraceFrames = Get-PSCallStack | Select-Object -Skip 1 + } + + return [Sentry.SentrySdk]::CaptureEvent($event_, [System.Action[Sentry.Scope]] { + param([Sentry.Scope]$scope) + $scope.AddEventProcessor($processor) + + # Execute the script block in the caller's scope (nothing to do $scope) & set the automatic $_ variable to the $scope object. + $scope | ForEach-Object $EditScope + }) + } +} diff --git a/vendor/Sentry/public/Start-Sentry.ps1 b/vendor/Sentry/public/Start-Sentry.ps1 new file mode 100644 index 0000000..3b7476c --- /dev/null +++ b/vendor/Sentry/public/Start-Sentry.ps1 @@ -0,0 +1,73 @@ +. "$privateDir/DiagnosticLogger.ps1" +. "$privateDir/ScopeIntegration.ps1" +. "$privateDir/SynchronousWorker.ps1" +. "$privateDir/SynchronousTransport.ps1" +. "$privateDir/EventUpdater.ps1" + +function Start-Sentry { + [CmdletBinding(DefaultParameterSetName = 'Simple')] + param( + [Parameter(Mandatory, ParameterSetName = 'Simple', Position = 0)] + [Uri] $Dsn, + + [Parameter(Mandatory, ParameterSetName = 'Options', Position = 0)] + [scriptblock] $EditOptions + ) + + begin { + $options = [Sentry.SentryOptions]::new() + $options.FlushTimeout = [System.TimeSpan]::FromSeconds(10) + $options.ShutDownTimeout = $options.FlushTimeout + $options.ReportAssembliesMode = [Sentry.ReportAssembliesMode]::None + $options.IsGlobalModeEnabled = $true + $options.AddIntegration([ScopeIntegration]::new()) + $options.AddEventProcessor([EventUpdater]::new()) + + if ($DebugPreference -eq 'SilentlyContinue') { + $Options.Debug = $false + $options.DiagnosticLevel = [Sentry.SentryLevel]::Info + } else { + $Options.Debug = $true + $options.DiagnosticLevel = [Sentry.SentryLevel]::Debug + } + + if ($EditOptions -eq $null) { + $options.Dsn = $Dsn + } else { + # Execute the script block in the caller's scope & set the automatic $_ variable to the options object. + $options | ForEach-Object $EditOptions + } + + $logger = [DiagnosticLogger]::new($options.DiagnosticLevel) + + # Note: this is currently a no-op if options.debug == false; see https://github.com/getsentry/sentry-dotnet/issues/3212 + # As a workaround, we set the logger as a global variable so that we can reach it in other scripts. + $options.DiagnosticLogger = $logger + $script:SentryPowerShellDiagnosticLogger = $logger + + if ($null -eq $options.Transport) { + try { + $options.Transport = [SynchronousTransport]::new($options) + } catch { + $logger.Log([Sentry.SentryLevel]::Warning, 'Failed to create a PowerShell-specific synchronous transport', $_.Exception, @()) + if ($global:SentryPowershellRethrowErrors -eq $true) { + throw + } + } + } + + if ($null -eq $options.BackgroundWorker) { + try { + $options.BackgroundWorker = [SynchronousWorker]::new($options) + } catch { + $logger.Log([Sentry.SentryLevel]::Warning, 'Failed to create a PowerShell-specific synchronous worker', $_.Exception, @()) + if ($global:SentryPowershellRethrowErrors -eq $true) { + throw + } + } + } + } + process { + [Sentry.SentrySdk]::init($options) | Out-Null + } +} diff --git a/vendor/Sentry/public/Start-SentryTransaction.ps1 b/vendor/Sentry/public/Start-SentryTransaction.ps1 new file mode 100644 index 0000000..6672d9f --- /dev/null +++ b/vendor/Sentry/public/Start-SentryTransaction.ps1 @@ -0,0 +1,57 @@ +function Start-SentryTransaction { + [OutputType([Sentry.ITransactionTracer])] + [CmdletBinding(DefaultParameterSetName = 'Basic')] + param( + [Parameter(Mandatory, ParameterSetName = 'Basic', Position = 0)] + [Parameter(Mandatory, ParameterSetName = 'BasicWithDescription', Position = 0)] + [string] $Name, + + [Parameter(Mandatory, ParameterSetName = 'Basic', Position = 1)] + [Parameter(Mandatory, ParameterSetName = 'BasicWithDescription', Position = 1)] + [string] $Operation, + + [Parameter(ParameterSetName = 'BasicWithDescription', Position = 2)] + [string] $Description = $null, + + [Parameter(Mandatory, ParameterSetName = 'TransactionContext', Position = 0)] + [Sentry.ITransactionContext] $TransactionContext, + + [Parameter(ParameterSetName = 'Basic', Position = 2)] + [Parameter(ParameterSetName = 'BasicWithDescription', Position = 3)] + [Parameter(ParameterSetName = 'TransactionContext', Position = 1)] + [hashtable] $CustomSamplingContext, + + [Parameter(ParameterSetName = 'Basic')] + [Parameter(ParameterSetName = 'BasicWithDescription')] + [Parameter(ParameterSetName = 'TransactionContext')] + [switch] $ForceSampled + ) + + begin { + if ($null -eq $TransactionContext) { + $IsSampled = $null + if ($ForceSampled) { + $IsSampled = $true + } + $TransactionContext = [Sentry.TransactionContext]::new($Name, $Operation, $null, $null, $null, $Description, $null, $IsSampled) + } + + } + process { + if ($CustomSamplingContext -eq $null) { + return [Sentry.SentrySdk]::StartTransaction($TransactionContext) + } else { + $samplingContext = HashTableToDictionary $CustomSamplingContext + return [Sentry.SentrySdk]::StartTransaction($TransactionContext, $samplingContext) + } + } +} + +# Converts [hashtable] to [System.Collections.generic.dictionary] +function HashTableToDictionary([hashtable] $hash) { + $dict = [System.Collections.Generic.Dictionary[string, object]]::new() + foreach ($key in $hash.Keys) { + $dict.Add($key, $hash[$key]) + } + return $dict +} diff --git a/vendor/Sentry/public/Stop-Sentry.ps1 b/vendor/Sentry/public/Stop-Sentry.ps1 new file mode 100644 index 0000000..cee7c66 --- /dev/null +++ b/vendor/Sentry/public/Stop-Sentry.ps1 @@ -0,0 +1,4 @@ +function Stop-Sentry { + [Sentry.SentrySdk]::Close() + Remove-Variable -Scope script -Name SentryPowerShellDiagnosticLogger -ErrorAction SilentlyContinue +}