diff --git a/sentry-api-client/Public/Get-SentrySpans.ps1 b/sentry-api-client/Public/Get-SentrySpans.ps1 new file mode 100644 index 0000000..a06a38e --- /dev/null +++ b/sentry-api-client/Public/Get-SentrySpans.ps1 @@ -0,0 +1,110 @@ +function Get-SentrySpans { + <# + .SYNOPSIS + Retrieves spans from Sentry. + + .DESCRIPTION + Fetches Sentry spans matching specified criteria. + Supports filtering by query, trace ID, and time range. + Uses the Sentry Discover API with the 'spans' dataset. + Transactions are spans with is_transaction=true. + + .PARAMETER Query + Search query string using Sentry search syntax (e.g., 'span.op:http.client', 'is_transaction:true'). + + .PARAMETER TraceId + Filter spans by specific trace ID. + + .PARAMETER StatsPeriod + Relative time period (e.g., '24h', '7d', '14d'). Default is '24h'. + + .PARAMETER Limit + Maximum number of spans to return. Default is 100. + + .PARAMETER Cursor + Pagination cursor for retrieving subsequent pages of results. + + .PARAMETER Fields + Specific fields to return. Default includes: id, trace, span.op, span.description, span.duration, is_transaction, timestamp, transaction.event_id. + + .EXAMPLE + Get-SentrySpans -TraceId 'abc123def456789012345678901234ab' + + .EXAMPLE + Get-SentrySpans -TraceId 'abc123def456' -Query 'is_transaction:true' + + .EXAMPLE + Get-SentrySpans -Query 'span.op:http.client' -StatsPeriod '7d' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$Query, + + [Parameter(Mandatory = $false)] + [string]$TraceId, + + [Parameter(Mandatory = $false)] + [string]$StatsPeriod = '24h', + + [Parameter(Mandatory = $false)] + [int]$Limit = 100, + + [Parameter(Mandatory = $false)] + [string]$Cursor, + + [Parameter(Mandatory = $false)] + [string[]]$Fields + ) + + # Build the query string combining Query and TraceId if provided + $QueryParts = @() + if ($Query) { + $QueryParts += $Query + } + if ($TraceId) { + $QueryParts += "trace:$TraceId" + } + $FinalQuery = $QueryParts -join ' ' + + # Default fields for spans if not specified + if (-not $Fields -or $Fields.Count -eq 0) { + $Fields = @( + 'id', + 'trace', + 'span.op', + 'span.description', + 'span.duration', + 'is_transaction', + 'timestamp', + 'transaction.event_id' + ) + } + + $QueryParams = @{ + dataset = 'spans' + statsPeriod = $StatsPeriod + per_page = $Limit + field = $Fields + } + + if ($FinalQuery) { + $QueryParams.query = $FinalQuery + } + + if ($Cursor) { + $QueryParams.cursor = $Cursor + } + + $QueryString = Build-QueryString -Parameters $QueryParams + $Uri = Get-SentryOrganizationUrl -Resource "events/" -QueryString $QueryString + + try { + $Response = Invoke-SentryApiRequest -Uri $Uri -Method 'GET' + return $Response + } + catch { + Write-Error "Failed to retrieve spans - $_" + throw + } +} diff --git a/sentry-api-client/README.md b/sentry-api-client/README.md index ed77241..4fdbccd 100644 --- a/sentry-api-client/README.md +++ b/sentry-api-client/README.md @@ -30,6 +30,15 @@ Get-SentryLogs -Query 'severity:error' -StatsPeriod '24h' # Get logs by attribute Get-SentryLogsByAttribute -AttributeName 'test_id' -AttributeValue 'abc123' +# Get metrics +Get-SentryMetrics -Query 'metric.name:my.counter' + +# Get metrics by attribute +Get-SentryMetricsByAttribute -MetricName 'my.counter' -AttributeName 'test_id' -AttributeValue 'abc123' + +# Get spans by trace ID +Get-SentrySpans -TraceId 'abc123def456789012345678901234ab' + # Download Sentry CLI Get-SentryCLI -Version 'latest' -DownloadDirectory './bin' @@ -133,6 +142,57 @@ Get-SentryLogsByAttribute -AttributeName 'test_id' -AttributeValue 'integration- Get-SentryLogsByAttribute -AttributeName 'user_id' -AttributeValue '12345' -StatsPeriod '7d' ``` +### Get-SentryMetrics + +Retrieves metrics from Sentry using the `tracemetrics` dataset. + +```powershell +# Query metrics by name +Get-SentryMetrics -Query 'metric.name:my.counter' + +# With custom time period and fields +Get-SentryMetrics -Query 'metric.name:my.counter' -StatsPeriod '7d' -Fields @('id', 'metric.name', 'value') +``` + +Parameters: + +- `Query`: Search query using Sentry search syntax +- `StatsPeriod`: Time period (default: '24h') +- `Limit`: Maximum metrics to return (default: 100) +- `Fields`: Fields to include in response (default: id, metric.name, metric.type, value, timestamp) + +### Get-SentryMetricsByAttribute + +Convenience wrapper for filtering metrics by metric name and a specific attribute. + +```powershell +# Filter by metric name and test ID +Get-SentryMetricsByAttribute -MetricName 'test.integration.counter' -AttributeName 'test_id' -AttributeValue 'abc-123' +``` + +### Get-SentrySpans + +Retrieves spans from Sentry using the `spans` dataset. Transactions are spans with `is_transaction=true`. + +```powershell +# Get all spans for a trace +Get-SentrySpans -TraceId 'abc123def456789012345678901234ab' + +# Get only transactions for a trace +Get-SentrySpans -TraceId 'abc123def456' -Query 'is_transaction:true' + +# Query by span operation +Get-SentrySpans -Query 'span.op:http.client' -StatsPeriod '7d' +``` + +Parameters: + +- `Query`: Search query using Sentry search syntax +- `TraceId`: Filter spans by specific trace ID +- `StatsPeriod`: Time period (default: '24h') +- `Limit`: Maximum spans to return (default: 100) +- `Fields`: Fields to include in response (default: id, trace, span.op, span.description, span.duration, is_transaction, timestamp, transaction.event_id) + ### Get-SentryCLI Downloads the Sentry CLI executable for the current platform. diff --git a/sentry-api-client/SentryApiClient.psd1 b/sentry-api-client/SentryApiClient.psd1 index 77236a2..d04ee5b 100644 --- a/sentry-api-client/SentryApiClient.psd1 +++ b/sentry-api-client/SentryApiClient.psd1 @@ -39,6 +39,7 @@ 'Get-SentryLogsByAttribute', 'Get-SentryMetrics', 'Get-SentryMetricsByAttribute', + 'Get-SentrySpans', 'Invoke-SentryCLI' ) diff --git a/sentry-api-client/Tests/Fixtures/SentrySpansResponses.json b/sentry-api-client/Tests/Fixtures/SentrySpansResponses.json new file mode 100644 index 0000000..14b9aeb --- /dev/null +++ b/sentry-api-client/Tests/Fixtures/SentrySpansResponses.json @@ -0,0 +1,48 @@ +{ + "spans_list": { + "data": [ + { + "id": "3b17400e50f24a27", + "trace": "140df6870c0f406faf87cff1f0d9e280", + "span.op": "perform-checkout", + "span.description": "Mac checkout", + "span.duration": 404.76, + "is_transaction": true, + "timestamp": "2025-02-17T07:56:06+00:00", + "transaction.event_id": "f4a523482c8a427ab7fc831e3ddca6b0" + }, + { + "id": "0a7ca53c31b547c6", + "trace": "140df6870c0f406faf87cff1f0d9e280", + "span.op": "validation", + "span.description": "validating shopping cart", + "span.duration": 188.99, + "is_transaction": false, + "timestamp": "2025-02-17T07:56:06+00:00", + "transaction.event_id": "f4a523482c8a427ab7fc831e3ddca6b0" + } + ], + "meta": { + "fields": { + "id": "string", + "trace": "string", + "span.op": "string", + "span.description": "string", + "span.duration": "duration", + "is_transaction": "boolean", + "timestamp": "string", + "transaction.event_id": "string" + }, + "units": { + "span.duration": "millisecond" + } + } + }, + "spans_empty": { + "data": [], + "meta": { + "fields": {}, + "units": {} + } + } +} diff --git a/sentry-api-client/Tests/SentryApiClient.Spans.Tests.ps1 b/sentry-api-client/Tests/SentryApiClient.Spans.Tests.ps1 new file mode 100644 index 0000000..a259c9c --- /dev/null +++ b/sentry-api-client/Tests/SentryApiClient.Spans.Tests.ps1 @@ -0,0 +1,116 @@ +BeforeAll { + $ModulePath = Join-Path $PSScriptRoot '..' 'SentryApiClient.psd1' + Import-Module $ModulePath -Force + + # Load test fixtures + $FixturesPath = Join-Path $PSScriptRoot 'Fixtures' 'SentrySpansResponses.json' + $Script:SpansFixtures = Get-Content $FixturesPath | ConvertFrom-Json -AsHashtable +} + +AfterAll { + Remove-Module SentryApiClient -Force +} + +Describe 'SentryApiClient Spans Functions' { + Context 'Module Export' { + It 'Should export Get-SentrySpans function' { + Get-Command Get-SentrySpans -Module SentryApiClient | Should -Not -BeNullOrEmpty + } + } + + Context 'Get-SentrySpans' { + BeforeAll { + Mock -ModuleName SentryApiClient Invoke-WebRequest { + return @{ Content = ($Script:SpansFixtures.spans_list | ConvertTo-Json -Depth 10) } + } + + Connect-SentryApi -ApiToken 'test-token' -Organization 'test-org' -Project 'test-project' + } + + It 'Should retrieve spans from spans dataset' { + $result = Get-SentrySpans -TraceId '140df6870c0f406faf87cff1f0d9e280' + + $result | Should -Not -BeNullOrEmpty + $result.data | Should -HaveCount 2 + + Assert-MockCalled -ModuleName SentryApiClient Invoke-WebRequest -ParameterFilter { + $Uri -match 'dataset=spans' -and + $Uri -match 'organizations/test-org/events/' + } + } + + It 'Should include default fields when none specified' { + Get-SentrySpans -TraceId '140df6870c0f406faf87cff1f0d9e280' + + Assert-MockCalled -ModuleName SentryApiClient Invoke-WebRequest -ParameterFilter { + $Uri -match 'field=id' -and + $Uri -match 'field=trace' -and + $Uri -match 'field=span\.op' -and + $Uri -match 'field=span\.description' -and + $Uri -match 'field=span\.duration' -and + $Uri -match 'field=is_transaction' -and + $Uri -match 'field=timestamp' -and + $Uri -match 'field=transaction\.event_id' + } + } + + It 'Should use custom fields when specified' { + Get-SentrySpans -Query 'span.op:http.client' -Fields @('id', 'trace', 'custom_field') + + Assert-MockCalled -ModuleName SentryApiClient Invoke-WebRequest -ParameterFilter { + $Uri -match 'field=id' -and + $Uri -match 'field=trace' -and + $Uri -match 'field=custom_field' + } + } + + It 'Should append trace filter to query' { + Get-SentrySpans -TraceId '140df6870c0f406faf87cff1f0d9e280' + + Assert-MockCalled -ModuleName SentryApiClient Invoke-WebRequest -ParameterFilter { + $Uri -match 'query=trace%3A140df6870c0f406faf87cff1f0d9e280' + } + } + + It 'Should combine query and trace ID' { + Get-SentrySpans -Query 'is_transaction:true' -TraceId '140df6870c0f406faf87cff1f0d9e280' + + Assert-MockCalled -ModuleName SentryApiClient Invoke-WebRequest -ParameterFilter { + $Uri -match 'query=is_transaction%3Atrue' -and + $Uri -match 'trace%3A140df6870c0f406faf87cff1f0d9e280' + } + } + + It 'Should pass stats period parameter' { + Get-SentrySpans -TraceId '140df6870c0f406faf87cff1f0d9e280' -StatsPeriod '7d' + + Assert-MockCalled -ModuleName SentryApiClient Invoke-WebRequest -ParameterFilter { + $Uri -match 'statsPeriod=7d' + } + } + } + + Context 'Error Handling' { + BeforeAll { + Connect-SentryApi -ApiToken 'test-token' -Organization 'test-org' -Project 'test-project' + } + + It 'Should handle API errors gracefully' { + Mock -ModuleName SentryApiClient Invoke-WebRequest { + throw [System.Net.WebException]::new('401 Unauthorized') + } + + { Get-SentrySpans } | Should -Throw '*Sentry API request*failed*' + } + } + + Context 'Connection Validation' { + BeforeEach { + Disconnect-SentryApi + } + + It 'Should throw when organization is not configured' { + { Get-SentrySpans } | Should -Throw '*Organization not configured*' + } + } +} diff --git a/utils/Integration.TestUtils.psm1 b/utils/Integration.TestUtils.psm1 index 774912a..db0ea7e 100644 --- a/utils/Integration.TestUtils.psm1 +++ b/utils/Integration.TestUtils.psm1 @@ -392,5 +392,68 @@ function Get-SentryTestMetric { throw "Expected at least $ExpectedCount metric(s) $MetricName with $AttributeName=$AttributeValue but found $foundCount within $TimeoutSeconds seconds. Last error: $lastError" } +function Get-SentryTestTransaction { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TraceId, + + [Parameter()] + [int]$TimeoutSeconds = 300, + + [Parameter()] + [string]$StatsPeriod = '24h' + ) + + Write-Host "Fetching Sentry transaction by trace ID: $TraceId" -ForegroundColor Yellow + $progressActivity = "Waiting for Sentry transaction with trace $TraceId" + + $startTime = Get-Date + $endTime = $startTime.AddSeconds($TimeoutSeconds) + $lastError = $null + $elapsedSeconds = 0 + + try { + do { + $sentryEvent = $null + $elapsedSeconds = [int]((Get-Date) - $startTime).TotalSeconds + $percentComplete = [math]::Min(100, ($elapsedSeconds / $TimeoutSeconds) * 100) + + Write-Progress -Activity $progressActivity -Status "Elapsed: $elapsedSeconds/$TimeoutSeconds seconds" -PercentComplete $percentComplete + + try { + $response = Get-SentrySpans -TraceId $TraceId -Query 'is_transaction:true' -Limit 1 -StatsPeriod $StatsPeriod + if ($response.data -and $response.data.Count -ge 1) { + $eventId = $response.data[0].'transaction.event_id' + if ($eventId) { + $sentryEvent = Get-SentryEvent -EventId $eventId + } + } + } catch { + $lastError = $_.Exception.Message + Write-Debug "Transaction with trace $TraceId not found yet: $lastError" + } + + if ($sentryEvent) { + Write-Host "Transaction $($sentryEvent.id) fetched from Sentry" -ForegroundColor Green + $entries = $sentryEvent.entries + $sentryEvent = $sentryEvent | Select-Object -ExcludeProperty 'entries' + foreach ($entry in $entries) { + $sentryEvent | Add-Member -MemberType NoteProperty -Name $entry.type -Value $entry.data -Force + } + $sentryEvent | ConvertTo-Json -Depth 10 | Out-File -FilePath (Get-OutputFilePath "transaction-$($sentryEvent.id).json") + return $sentryEvent + } + + Start-Sleep -Milliseconds 500 + $currentTime = Get-Date + } while ($currentTime -lt $endTime) + } finally { + Write-Progress -Activity $progressActivity -Completed + } + + throw "Transaction with trace $TraceId not found in Sentry within $TimeoutSeconds seconds: $lastError" +} + # Export module functions -Export-ModuleMember -Function Invoke-CMakeConfigure, Invoke-CMakeBuild, Set-OutputDir, Get-OutputFilePath, Get-EventIds, Get-SentryTestEvent, Get-SentryTestLog, Get-SentryTestMetric, Get-PackageAumid +Export-ModuleMember -Function Invoke-CMakeConfigure, Invoke-CMakeBuild, Set-OutputDir, Get-OutputFilePath, Get-EventIds, Get-SentryTestEvent, Get-SentryTestLog, Get-SentryTestMetric, Get-SentryTestTransaction, Get-PackageAumid