Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions modules/Sentry/Sentry.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions modules/Sentry/Sentry.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions modules/Sentry/private/Get-AttachmentContentType.ps1
Original file line number Diff line number Diff line change
@@ -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()]
}
61 changes: 61 additions & 0 deletions modules/Sentry/public/Add-SentryAttachment.ps1
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
cursor[bot] marked this conversation as resolved.
} else {
$resolvedContentType = if ($PSBoundParameters.ContainsKey('ContentType')) { $ContentType } else { Get-AttachmentContentType $FileName }
Edit-SentryScope { $_.AddAttachment($Bytes, $FileName, $Type, $resolvedContentType) }.GetNewClosure()
}
}
}
5 changes: 5 additions & 0 deletions samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
46 changes: 46 additions & 0 deletions samples/send-attachment.ps1
Original file line number Diff line number Diff line change
@@ -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'

Comment thread
vaind marked this conversation as resolved.
# 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
}
101 changes: 101 additions & 0 deletions tests/scope.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading