diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d5357..46c1f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Features +- Add `Add-SentryAttachment` cmdlet that infers a content type from the file extension (including PowerShell-specific extensions like `.ps1`, `.psm1`, `.psd1`) so the Sentry UI can preview attachments as text/JSON instead of falling back to `application/octet-stream` ([#134](https://github.com/getsentry/sentry-powershell/pull/134)) - Add support for .NET 10 / PowerShell 7.6 ([#133](https://github.com/getsentry/sentry-powershell/pull/133)) - Add `Write-SentryLog` cmdlet, a native PowerShell API for sending structured logs (Sentry Logs) ([#131](https://github.com/getsentry/sentry-powershell/pull/131)) - Add `Add-SentryEventProcessor` cmdlet for registering a global event processor from a PowerShell script block ([#130](https://github.com/getsentry/sentry-powershell/pull/130)) diff --git a/modules/Sentry/Sentry.psd1 b/modules/Sentry/Sentry.psd1 index 2963a13..6f49492 100644 --- a/modules/Sentry/Sentry.psd1 +++ b/modules/Sentry/Sentry.psd1 @@ -32,6 +32,7 @@ # 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-SentryAttachment', 'Add-SentryBreadcrumb', 'Add-SentryEventProcessor', 'Edit-SentryScope', diff --git a/modules/Sentry/Sentry.psm1 b/modules/Sentry/Sentry.psm1 index ab848b6..26d1857 100644 --- a/modules/Sentry/Sentry.psm1 +++ b/modules/Sentry/Sentry.psm1 @@ -23,6 +23,7 @@ function Add-SentryInlineType([string] $sourceFile, [string[]] $extraReferences) Add-SentryInlineType "$privateDir/SentryEventProcessor.cs" @() Add-SentryInlineType "$privateDir/ScriptBlockEventProcessor.cs" @($automationDllPath) . "$privateDir/SentryEventProcessor.ps1" +. "$privateDir/Get-AttachmentContentType.ps1" Get-ChildItem $publicDir -Filter '*.ps1' | ForEach-Object { . $_.FullName diff --git a/modules/Sentry/private/Get-AttachmentContentType.ps1 b/modules/Sentry/private/Get-AttachmentContentType.ps1 new file mode 100644 index 0000000..21e0de1 --- /dev/null +++ b/modules/Sentry/private/Get-AttachmentContentType.ps1 @@ -0,0 +1,45 @@ +# Maps common file extensions that aren't guaranteed to be in OS MIME databases +# to a content type that Sentry will recognize for attachment preview to work. +$script:SentryAttachmentContentTypes = @{ + # PowerShell + '.ps1' = 'text/plain' + '.psm1' = 'text/plain' + '.psd1' = 'text/plain' + '.ps1xml' = 'application/xml' + '.pssc' = 'text/plain' + '.psrc' = 'text/plain' + + # Plain text / logs / config + '.txt' = 'text/plain' + '.log' = 'text/plain' + '.md' = 'text/plain' + '.ini' = 'text/plain' + '.cfg' = 'text/plain' + '.conf' = 'text/plain' + '.yaml' = 'text/plain' + '.yml' = 'text/plain' + '.toml' = 'text/plain' + + # Structured + '.json' = 'application/json' + '.xml' = 'application/xml' + '.csv' = 'text/csv' + '.html' = 'text/html' + '.htm' = 'text/html' + '.css' = 'text/css' + '.js' = 'text/javascript' + '.sql' = 'text/plain' +} + +function Get-AttachmentContentType { + param([string] $FileName) + + if ([string]::IsNullOrEmpty($FileName)) { + return $null + } + $ext = [System.IO.Path]::GetExtension($FileName) + if ([string]::IsNullOrEmpty($ext)) { + return $null + } + return $script:SentryAttachmentContentTypes[$ext.ToLowerInvariant()] +} diff --git a/modules/Sentry/public/Add-SentryAttachment.ps1 b/modules/Sentry/public/Add-SentryAttachment.ps1 new file mode 100644 index 0000000..cf9636a --- /dev/null +++ b/modules/Sentry/public/Add-SentryAttachment.ps1 @@ -0,0 +1,61 @@ +function Add-SentryAttachment { + <# + .SYNOPSIS + Adds a file or byte attachment to the current Sentry scope. + .DESCRIPTION + Wraps Scope.AddAttachment and, when -ContentType is not specified, + infers a sensible content type from the file extension so that the + Sentry UI can preview common text formats (including PowerShell + scripts). + .PARAMETER Path + Path to a file to attach. + .PARAMETER Bytes + Raw bytes to attach. Must be combined with -FileName. Accepts pipeline + input, but note the pipeline unrolls arrays: to pipe a byte[] as a single + attachment, wrap it with the unary comma operator, e.g. + `,$bytes | Add-SentryAttachment -FileName 'data.json'`. + .PARAMETER FileName + File name to associate with byte data. + .PARAMETER ContentType + Optional MIME type. If omitted, a default is chosen based on the + file extension; if no default is known, content-type is left unset. + .PARAMETER Type + Sentry attachment type. Defaults to Default. + .EXAMPLE + PS> Add-SentryAttachment -Path $PSCommandPath + .EXAMPLE + PS> $PSCommandPath | Add-SentryAttachment + .EXAMPLE + PS> Add-SentryAttachment -Bytes $bytes -FileName 'data.json' + #> + [CmdletBinding(DefaultParameterSetName = 'Path')] + param( + [Parameter(Mandatory, Position = 0, ParameterSetName = 'Path', ValueFromPipeline = $true)] + [string] $Path, + + [Parameter(Mandatory, ParameterSetName = 'Bytes', ValueFromPipeline = $true)] + [byte[]] $Bytes, + + [Parameter(Mandatory, ParameterSetName = 'Bytes')] + [string] $FileName, + + [string] $ContentType, + + [Sentry.AttachmentType] $Type = [Sentry.AttachmentType]::Default + ) + + process { + if ($PSCmdlet.ParameterSetName -eq 'Path') { + # Resolve relative paths against PowerShell's $PWD. The Sentry SDK reads + # the file lazily via .NET I/O, which resolves against [Environment]::CurrentDirectory + # — that can diverge from $PWD after Set-Location, so resolve eagerly here. + $Path = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) + $resolvedFileName = [System.IO.Path]::GetFileName($Path) + $resolvedContentType = if ($PSBoundParameters.ContainsKey('ContentType')) { $ContentType } else { Get-AttachmentContentType $resolvedFileName } + Edit-SentryScope { $_.AddAttachment($Path, $Type, $resolvedContentType) }.GetNewClosure() + } else { + $resolvedContentType = if ($PSBoundParameters.ContainsKey('ContentType')) { $ContentType } else { Get-AttachmentContentType $FileName } + Edit-SentryScope { $_.AddAttachment($Bytes, $FileName, $Type, $resolvedContentType) }.GetNewClosure() + } + } +} diff --git a/samples/README.md b/samples/README.md index 1ac0e9f..e018887 100644 --- a/samples/README.md +++ b/samples/README.md @@ -14,4 +14,9 @@ pwsh ./samples/locate-city.ps1 Toronto Or send structured logs to Sentry (see the [Sentry Logs docs](https://docs.sentry.io/platforms/dotnet/logs/)): ```sh pwsh ./samples/send-logs.ps1 +``` + +Or send an event with file/byte attachments (see the [Attachments docs](https://docs.sentry.io/platforms/powershell/enriching-events/attachments/)): +```sh +pwsh ./samples/send-attachment.ps1 ``` \ No newline at end of file diff --git a/samples/send-attachment.ps1 b/samples/send-attachment.ps1 new file mode 100644 index 0000000..d5d4c42 --- /dev/null +++ b/samples/send-attachment.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Demonstrates attaching files and byte data to a Sentry event. +.DESCRIPTION + Shows how to use Add-SentryAttachment to upload supporting files alongside + an event. When -ContentType is not specified, the cmdlet infers one from + the file extension so the Sentry UI can preview common text formats + (including PowerShell scripts) instead of falling back to + application/octet-stream. +.EXAMPLE + PS> ./send-attachment.ps1 +.LINK + https://docs.sentry.io/platforms/powershell/enriching-events/attachments/ +#> + +# Import the Sentry module. In your code, you would just use `Import-Module Sentry`. +Import-Module $PSScriptRoot/../modules/Sentry/Sentry.psd1 + +Start-Sentry { + $_.Dsn = 'https://997874440feaba4ecc65c1e25df7912b@o447951.ingest.us.sentry.io/4508073336176640' + $_.Debug = $true +} + +try { + # 1) Attach a file by path. The extension (.ps1) is recognized as text, + # so Sentry will render it inline in the event's Attachments tab. + # A path can also be supplied from the pipeline: + # $PSCommandPath | Add-SentryAttachment + Add-SentryAttachment -Path $PSCommandPath + + # 2) Attach raw bytes with a filename hint. .json maps to application/json + # so the UI shows a JSON preview. + $payload = @{ host = [System.Net.Dns]::GetHostName(); psVersion = $PSVersionTable.PSVersion.ToString() } | + ConvertTo-Json + $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + Add-SentryAttachment -Bytes $bytes -FileName 'context.json' + + # 3) Explicit -ContentType always wins over the inferred default. + Add-SentryAttachment -Path $PSCommandPath -ContentType 'text/x-powershell' + + # Capture an event so the attachments have something to ride along with. + # All three attachments above are attached to this event via the current scope. + 'Sample event with attachments' | Out-Sentry +} finally { + Stop-Sentry +} diff --git a/tests/scope.tests.ps1 b/tests/scope.tests.ps1 index c7ba07c..c3a221d 100644 --- a/tests/scope.tests.ps1 +++ b/tests/scope.tests.ps1 @@ -41,3 +41,104 @@ Describe 'Edit-SentryScope' { $envelope.Items[1].Header.filename | Should -Be 'filename.bin' } } + +Describe 'Add-SentryAttachment' { + BeforeEach { + $events = [System.Collections.Generic.List[Sentry.SentryEvent]]::new(); + $transport = [RecordingTransport]::new() + StartSentryForEventTests ([ref] $events) ([ref] $transport) + } + + AfterEach { + Stop-Sentry + } + + It 'infers text/plain for a .ps1 file' { + Add-SentryAttachment -Path $PSCommandPath + 'message' | Out-Sentry + $envelope = [Sentry.Protocol.Envelopes.Envelope]$transport.Envelopes.ToArray()[0] + $envelope.Items[1].Header.filename | Should -Be 'scope.tests.ps1' + $envelope.Items[1].Header.content_type | Should -Be 'text/plain' + } + + It 'accepts a file path from the pipeline' { + $PSCommandPath | Add-SentryAttachment + 'message' | Out-Sentry + $envelope = [Sentry.Protocol.Envelopes.Envelope]$transport.Envelopes.ToArray()[0] + $envelope.Items[1].Header.filename | Should -Be 'scope.tests.ps1' + $envelope.Items[1].Header.content_type | Should -Be 'text/plain' + } + + It 'accepts a byte[] from the pipeline via the unary comma operator' { + [byte[]] $data = [System.Text.Encoding]::UTF8.GetBytes('{"hello":"world"}') + # The comma operator wraps $data so the pipeline delivers it as one item + # rather than unrolling it into individual bytes. + , $data | Add-SentryAttachment -FileName 'piped.json' + 'message' | Out-Sentry + $envelope = [Sentry.Protocol.Envelopes.Envelope]$transport.Envelopes.ToArray()[0] + $envelope.Items.Count | Should -Be 2 + $envelope.Items[1].Header.filename | Should -Be 'piped.json' + $envelope.Items[1].Header.length | Should -Be $data.Length + $envelope.Items[1].Header.content_type | Should -Be 'application/json' + } + + It 'infers application/json for a .json byte attachment' { + [byte[]] $data = [System.Text.Encoding]::UTF8.GetBytes('{"hello":"world"}') + Add-SentryAttachment -Bytes $data -FileName 'payload.json' + 'message' | Out-Sentry + $envelope = [Sentry.Protocol.Envelopes.Envelope]$transport.Envelopes.ToArray()[0] + $envelope.Items[1].Header.filename | Should -Be 'payload.json' + $envelope.Items[1].Header.content_type | Should -Be 'application/json' + } + + It 'honors an explicit -ContentType' { + Add-SentryAttachment -Path $PSCommandPath -ContentType 'text/x-powershell' + 'message' | Out-Sentry + $envelope = [Sentry.Protocol.Envelopes.Envelope]$transport.Envelopes.ToArray()[0] + $envelope.Items[1].Header.content_type | Should -Be 'text/x-powershell' + } + + It 'resolves relative paths against PowerShell $PWD, not [Environment]::CurrentDirectory' { + # Simulate the common case where PowerShell's location diverges from the + # process working directory (which is what .NET I/O uses for relative paths). + $originalLocation = Get-Location + $originalEnvCwd = [Environment]::CurrentDirectory + try { + $tempDir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString())) + $relativeName = 'attachment-relative.txt' + $fileContents = 'hello from a relative path' + Set-Content -Path (Join-Path $tempDir $relativeName) -Value $fileContents -NoNewline + Set-Location $tempDir + # Force divergence: leave [Environment]::CurrentDirectory pointed elsewhere. + [Environment]::CurrentDirectory = $originalEnvCwd + + Add-SentryAttachment -Path $relativeName + 'message' | Out-Sentry + + $envelope = [Sentry.Protocol.Envelopes.Envelope]$transport.Envelopes.ToArray()[0] + $envelope.Items[1].Header.filename | Should -Be $relativeName + # The envelope serializer reads the file lazily — if the path didn't resolve, + # we'd see an empty/zero-length payload instead of the real bytes. + $envelope.Items[1].Header.length | Should -Be $fileContents.Length + } finally { + Set-Location $originalLocation + [Environment]::CurrentDirectory = $originalEnvCwd + # On Windows the SDK may still have a handle on the attachment file + # until Stop-Sentry runs in AfterEach, so cleanup can race. The temp + # dir is harmless to leave behind, so don't fail the test over it. + if ($tempDir -and (Test-Path $tempDir)) { + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + + It 'leaves content-type unset for unknown extensions' { + [byte[]] $data = 1, 2, 3 + Add-SentryAttachment -Bytes $data -FileName 'thing.unknownext' + 'message' | Out-Sentry + $envelope = [Sentry.Protocol.Envelopes.Envelope]$transport.Envelopes.ToArray()[0] + $envelope.Items[1].Header.filename | Should -Be 'thing.unknownext' + # When no extension match and no explicit override, we don't set a content type. + [string]::IsNullOrEmpty($envelope.Items[1].Header.content_type) | Should -Be $true + } +}