Skip to content

Commit bc2d65b

Browse files
authored
feat: add stacktrace context to inline code (#65)
* cleanup stacktrace processor * cleanup integration test * collect stacktrace context for inline scripts * fixes * try to fix ci * chore: update changelog * support piped input and invoking powershell with AddScript()+invoke() api * fix windows powershell
1 parent 045d74e commit bc2d65b

File tree

7 files changed

+277
-63
lines changed

7 files changed

+277
-63
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
- Send events to Sentry fully synchronously ([#59](https://github.com/SummitHosting/sentry-powershell/pull/59), [#62](https://github.com/SummitHosting/sentry-powershell/pull/62))
8+
- Add StackTrace context to frames coming from inline script/command ([#65](https://github.com/getsentry/sentry-powershell/pull/65))
89

910
### Fixes
1011

modules/Sentry/private/StackTraceProcessor.ps1

Lines changed: 125 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -114,33 +114,11 @@ class StackTraceProcessor : SentryEventProcessor
114114
hidden [Sentry.SentryStackTrace]GetStackTrace()
115115
{
116116
# We collect all frames and then reverse them to the order expected by Sentry (caller->callee).
117-
# Do not try to make this code go backwards, because it relies on the InvocationInfo from the previous frame.
117+
# Do not try to make this code go backwards because it relies on the InvocationInfo from the previous frame.
118118
$sentryFrames = New-Object System.Collections.Generic.List[Sentry.SentryStackFrame]
119-
if ($null -ne $this.StackTraceFrames)
120-
{
121-
$sentryFrames.Capacity = $this.StackTraceFrames.Count + 1
122-
}
123-
elseif ($null -ne $this.StackTraceString)
119+
if ($null -ne $this.StackTraceString)
124120
{
125121
$sentryFrames.Capacity = $this.StackTraceString.Count + 1
126-
}
127-
128-
if ($null -ne $this.StackTraceFrames)
129-
{
130-
# Note: if InvocationInfo is present, use it to fill the first frame. This is the case for ErrroRecord handling
131-
# and has the information about the actual script file and line that have thrown the exception.
132-
if ($null -ne $this.InvocationInfo)
133-
{
134-
$sentryFrames.Add($this.CreateFrame($this.InvocationInfo))
135-
}
136-
137-
foreach ($frame in $this.StackTraceFrames)
138-
{
139-
$sentryFrames.Add($this.CreateFrame($frame))
140-
}
141-
}
142-
elseif ($null -ne $this.StackTraceString)
143-
{
144122
# Note: if InvocationInfo is present, use it to update:
145123
# - the first frame (in case of `$_ | Out-Sentry` in a catch clause).
146124
# - the second frame (in case of `write-error` and `$_ | Out-Sentry` in a trap).
@@ -167,10 +145,21 @@ class StackTraceProcessor : SentryEventProcessor
167145
}
168146
$sentryFrames.Add($sentryFrame)
169147
}
148+
170149
if ($null -ne $sentryFrameInitial)
171150
{
172151
$sentryFrames.Insert(0, $sentryFrameInitial)
173152
}
153+
154+
$this.EnhanceTailFrames($sentryFrames)
155+
}
156+
elseif ($null -ne $this.StackTraceFrames)
157+
{
158+
$sentryFrames.Capacity = $this.StackTraceFrames.Count + 1
159+
foreach ($frame in $this.StackTraceFrames)
160+
{
161+
$sentryFrames.Add($this.CreateFrame($frame))
162+
}
174163
}
175164

176165
foreach ($sentryFrame in $sentryFrames)
@@ -213,7 +202,10 @@ class StackTraceProcessor : SentryEventProcessor
213202
$regex = 'at (?<Function>[^,]*), (?<AbsolutePath>.*): line (?<LineNumber>\d*)'
214203
if ($frame -match $regex)
215204
{
216-
$sentryFrame.AbsolutePath = $Matches.AbsolutePath
205+
if ($Matches.AbsolutePath -ne '<No file>')
206+
{
207+
$sentryFrame.AbsolutePath = $Matches.AbsolutePath
208+
}
217209
$sentryFrame.LineNumber = [int]$Matches.LineNumber
218210
$sentryFrame.Function = $Matches.Function
219211
}
@@ -224,6 +216,54 @@ class StackTraceProcessor : SentryEventProcessor
224216
return $sentryFrame
225217
}
226218

219+
hidden EnhanceTailFrames([Sentry.SentryStackFrame[]] $sentryFrames)
220+
{
221+
if ($null -eq $this.StackTraceFrames)
222+
{
223+
return
224+
}
225+
226+
# The last frame is usually how the PowerShell was invoked. We need to get this info from $this.StackTraceFrames
227+
# - for pwsh scriptname.ps1 it would be something like `. scriptname.ps1`
228+
# - for pwsh -c `& {..}` it would be the `& {..}` code block. And in this case, the next frame would also be
229+
# just a scriptblock without a filename so we need to get the source code from the StackTraceFrames too.
230+
$i = 0;
231+
for ($j = $sentryFrames.Count - 1; $j -ge 0; $j--)
232+
{
233+
$sentryFrame = $sentryFrames[$j]
234+
$frame = $this.StackTraceFrames | Select-Object -Last 1 -Skip $i
235+
$i++
236+
237+
if ($null -eq $frame)
238+
{
239+
break
240+
}
241+
242+
if ($null -eq $sentryFrame.AbsolutePath -and $null -eq $frame.ScriptName)
243+
{
244+
if ($frame.ScriptLineNumber -gt 0 -and $frame.ScriptLineNumber -eq $sentryFrame.LineNumber)
245+
{
246+
$this.SetScriptInfo($sentryFrame, $frame)
247+
$this.SetModule($sentryFrame)
248+
$this.SetFunction($sentryFrame, $frame)
249+
}
250+
$this.SetContextLines($sentryFrame, $frame)
251+
252+
# Try to match following frames that are part of the same codeblock.
253+
while ($j -gt 0)
254+
{
255+
$nextSentryFrame = $sentryFrames[$j - 1]
256+
if ($nextSentryFrame.AbsolutePath -ne $sentryFrame.AbsolutePath)
257+
{
258+
break
259+
}
260+
$this.SetContextLines($nextSentryFrame, $frame)
261+
$j--
262+
}
263+
}
264+
}
265+
}
266+
227267
hidden SetScriptInfo([Sentry.SentryStackFrame] $sentryFrame, [System.Management.Automation.CallStackFrame] $frame)
228268
{
229269
if (![string]::IsNullOrEmpty($frame.ScriptName))
@@ -268,13 +308,42 @@ class StackTraceProcessor : SentryEventProcessor
268308
if ([string]::IsNullOrEmpty($sentryFrame.AbsolutePath) -and $frame.FunctionName -eq '<ScriptBlock>' -and ![string]::IsNullOrEmpty($frame.Position))
269309
{
270310
$sentryFrame.Function = $frame.Position.Text
311+
312+
# $frame.Position.Text may be a multiline command (e.g. when executed with `pwsh -c '& { ... \n ... \n ... }`)
313+
# So we need to trim it to a single line.
314+
if ($sentryFrame.Function.Contains("`n"))
315+
{
316+
$lines = $sentryFrame.Function -split "[`r`n]+"
317+
$sentryFrame.Function = $lines[0] + ' '
318+
if ($lines.Count -gt 2)
319+
{
320+
$sentryFrame.Function += ' ...<multiline script content omitted>... '
321+
}
322+
$sentryFrame.Function += $lines[$lines.Count - 1]
323+
}
271324
}
272325
else
273326
{
274327
$sentryFrame.Function = $frame.FunctionName
275328
}
276329
}
277330

331+
hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame, [System.Management.Automation.CallStackFrame] $frame)
332+
{
333+
if ($sentryFrame.LineNumber -gt 0)
334+
{
335+
try
336+
{
337+
$lines = $frame.InvocationInfo.MyCommand.ScriptBlock.ToString() -split "`n"
338+
$this.SetContextLines($sentryFrame, $lines)
339+
}
340+
catch
341+
{
342+
Write-Warning "Failed to read context lines for frame with function '$($sentryFrame.Function)': $_"
343+
}
344+
}
345+
}
346+
278347
hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame)
279348
{
280349
if ([string]::IsNullOrEmpty($sentryFrame.AbsolutePath) -or $sentryFrame.LineNumber -lt 1)
@@ -287,26 +356,42 @@ class StackTraceProcessor : SentryEventProcessor
287356
try
288357
{
289358
$lines = Get-Content $sentryFrame.AbsolutePath -TotalCount ($sentryFrame.LineNumber + 5)
290-
if ($null -eq $sentryFrame.ContextLine)
291-
{
292-
$sentryFrame.ContextLine = $lines[$sentryFrame.LineNumber - 1]
293-
}
294-
$preContextCount = [math]::Min(5, $sentryFrame.LineNumber - 1)
295-
$postContextCount = [math]::Min(5, $lines.Count - $sentryFrame.LineNumber)
296-
if ($sentryFrame.LineNumber -gt 6)
297-
{
298-
$lines = $lines | Select-Object -Skip ($sentryFrame.LineNumber - 6)
299-
}
300-
# Note: these are read-only in sentry-dotnet so we just update the underlying lists instead of replacing.
301-
$sentryFrame.PreContext.Clear()
302-
$lines | Select-Object -First $preContextCount | ForEach-Object { $sentryFrame.PreContext.Add($_) }
303-
$sentryFrame.PostContext.Clear()
304-
$lines | Select-Object -Last $postContextCount | ForEach-Object { $sentryFrame.PostContext.Add($_) }
359+
$this.SetContextLines($sentryFrame, $lines)
305360
}
306361
catch
307362
{
308363
Write-Warning "Failed to read context lines for $($sentryFrame.AbsolutePath): $_"
309364
}
310365
}
311366
}
367+
368+
hidden SetContextLines([Sentry.SentryStackFrame] $sentryFrame, [string[]] $lines)
369+
{
370+
if ($lines.Count -lt $sentryFrame.LineNumber)
371+
{
372+
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))."
373+
return
374+
}
375+
376+
$numContextLines = 5
377+
378+
if ($null -eq $sentryFrame.ContextLine)
379+
{
380+
$sentryFrame.ContextLine = $lines[$sentryFrame.LineNumber - 1]
381+
}
382+
383+
$preContextCount = [math]::Min($numContextLines, $sentryFrame.LineNumber - 1)
384+
$postContextCount = [math]::Min($numContextLines, $lines.Count - $sentryFrame.LineNumber)
385+
386+
if ($sentryFrame.LineNumber -gt $numContextLines + 1)
387+
{
388+
$lines = $lines | Select-Object -Skip ($sentryFrame.LineNumber - $numContextLines - 1)
389+
}
390+
391+
# Note: these are read-only in sentry-dotnet so we just update the underlying lists instead of replacing.
392+
$sentryFrame.PreContext.Clear()
393+
$lines | Select-Object -First $preContextCount | ForEach-Object { $sentryFrame.PreContext.Add($_) }
394+
$sentryFrame.PostContext.Clear()
395+
$lines | Select-Object -First $postContextCount -Skip ($preContextCount + 1) | ForEach-Object { $sentryFrame.PostContext.Add($_) }
396+
}
312397
}

modules/Sentry/public/Out-Sentry.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function Out-Sentry
9494
return
9595
}
9696

97-
if ($options.AttachStackTrace -and $null -eq $processor.StackTraceFrames -and $null -eq $processor.StackTraceString)
97+
if ($options.AttachStackTrace -and $null -eq $processor.StackTraceFrames)
9898
{
9999
$processor.StackTraceFrames = Get-PSCallStack | Select-Object -Skip 1
100100
}

tests/integration-test-script.ps1

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@ Set-StrictMode -Version latest
22
$ErrorActionPreference = 'Stop'
33
$PSNativeCommandUseErrorActionPreference = $true
44

5-
Import-Module ../modules/Sentry/Sentry.psd1
6-
. ./utils.ps1
7-
. ./throwingshort.ps1
5+
Import-Module ./modules/Sentry/Sentry.psd1
6+
. ./tests/utils.ps1
7+
. ./tests/throwingshort.ps1
8+
9+
function funcA
10+
{
11+
# Call to another file
12+
funcC
13+
}
814

915
$events = [System.Collections.Generic.List[Sentry.SentryEvent]]::new();
1016
$transport = [RecordingTransport]::new()
1117
StartSentryForEventTests ([ref] $events) ([ref] $transport)
1218

1319
try
1420
{
15-
funcC
21+
funcA
1622
}
1723
catch
1824
{
@@ -29,7 +35,7 @@ $thread.Stacktrace.Frames | ForEach-Object {
2935
$value = $frame.$prop | Out-String -Width 500
3036
if ("$value" -ne '')
3137
{
32-
"$($prop): $value"
38+
"$($prop): $value".TrimEnd()
3339
}
3440
}
3541
}

0 commit comments

Comments
 (0)