private/StackTraceProcessor.ps1
class StackTraceProcessor : SentryEventProcessor { [Sentry.Protocol.SentryException]$SentryException [System.Management.Automation.InvocationInfo]$InvocationInfo [System.Management.Automation.CallStackFrame[]]$StackTraceFrames [string[]]$StackTraceString hidden [string[]] $modulePaths hidden [hashtable] $pwshModules = @{} StackTraceProcessor() { 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 '^(?<Assembly>[^,]+), Version=(?<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 (?<Function>[^,]*), (?<AbsolutePath>.*): line (?<LineNumber>\d*)' if ($frame -match $regex) { if ($Matches.AbsolutePath -ne '<No file>') { $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 '<ScriptBlock>' -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 += ' ...<multiline script content omitted>... ' } $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)': $_" } } } 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): $_" } } } hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame, [string[]] $lines) { if ($lines.Count -lt $sentryFrame.LineNumber) { Write-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($_) } } } |