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.StackTraceFrames)
        {
            $sentryFrames.Capacity = $this.StackTraceFrames.Count + 1
        }
        else
        {
            $sentryFrames.Capacity = $this.StackTraceString.Count + 1
        }

        if ($null -ne $this.StackTraceFrames)
        {
            # Note: if InvocationInfo is present, use it to fill the first frame. This is the case for ErrroRecord handling
            # and has the information about the actual script file and line that have thrown the exception.
            if ($null -ne $this.InvocationInfo)
            {
                $sentryFrames.Add($this.CreateFrame($this.InvocationInfo))
            }

            foreach ($frame in $this.StackTraceFrames)
            {
                $sentryFrames.Add($this.CreateFrame($frame))
            }
        }
        else
        {
            # 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)
            }
        }

        foreach ($sentryFrame in $sentryFrames)
        {
            # Update module info
            $this.SetModule($sentryFrame)
            $sentryFrame.InApp = $null -eq $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)
        {
            $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 SetScriptInfo([Sentry.SentryStackFrame] $sentryFrame, [System.Management.Automation.CallStackFrame] $frame)
    {
        if ($null -ne $frame.ScriptName)
        {
            $sentryFrame.AbsolutePath = $frame.ScriptName
            $sentryFrame.LineNumber = $frame.ScriptLineNumber
        }
        elseif ($null -ne $frame.Position -and $null -ne $frame.Position.File)
        {
            $sentryFrame.AbsolutePath = $frame.Position.File
            $sentryFrame.LineNumber = $frame.Position.StartLineNumber
            $sentryFrame.ColumnNumber = $frame.Position.StartColumnNumber
        }
    }

    hidden SetModule([Sentry.SentryStackFrame] $sentryFrame)
    {
        if ($null -ne $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 ($null -eq $sentryFrame.AbsolutePath -and $frame.FunctionName -eq '<ScriptBlock>' -and $null -ne $frame.Position)
        {
            $sentryFrame.Function = $frame.Position.Text
        }
        else
        {
            $sentryFrame.Function = $frame.FunctionName
        }
    }

    hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame)
    {
        if ($null -ne $sentryFrame.AbsolutePath -and $sentryFrame.LineNumber -ge 1 -and (Test-Path $sentryFrame.AbsolutePath -PathType Leaf))
        {
            try
            {
                $lines = Get-Content $sentryFrame.AbsolutePath -TotalCount ($sentryFrame.LineNumber + 5)
                if ($null -eq $sentryFrame.ContextLine)
                {
                    $sentryFrame.ContextLine = $lines[$sentryFrame.LineNumber - 1]
                }
                if ($sentryFrame.LineNumber -gt 6)
                {
                    $lines = $lines | Select-Object -Skip ($sentryFrame.LineNumber - 6)
                }
                # 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 5 | ForEach-Object { $sentryFrame.PreContext.Add($_) }
                $sentryFrame.PostContext.Clear()
                $lines | Select-Object -Last 5  | ForEach-Object { $sentryFrame.PostContext.Add($_) }
            }
            catch
            {
                Write-Warning "Failed to read context lines for $($sentryFrame.AbsolutePath): $_"
            }
        }
    }
}