diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f06645847..854ac869fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -237,7 +237,7 @@ jobs: - name: Upload build logs if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ matrix.rid }}-build-logs path: | @@ -270,7 +270,7 @@ jobs: - name: Upload build and test outputs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ matrix.rid }}-verify-test-results path: "**/*.received.*" @@ -280,7 +280,7 @@ jobs: - name: Archive NuGet Packages if: env.CI_PUBLISHING_BUILD == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ github.sha }} if-no-files-found: error @@ -302,7 +302,7 @@ jobs: - name: Fetch NuGet Packages if: env.CI_PUBLISHING_BUILD == 'true' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ github.sha }} path: src @@ -365,7 +365,7 @@ jobs: - name: Upload logs if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ runner.os }}-msbuild-logs path: | @@ -403,7 +403,7 @@ jobs: uses: ./.github/actions/buildnative - name: Fetch NuGet Packages - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ github.sha }} path: src diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index fd7cf4d080..6960c152ee 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -16,5 +16,5 @@ permissions: jobs: changelog-preview: if: false - uses: getsentry/craft/.github/workflows/changelog-preview.yml@4e164274d61ad2606b88b0aef000b2f4b1829745 # 2.33.3 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@3f2abdc5703191d12ef2cc013f792f1bd220f44d # 2.34.1 secrets: inherit diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8293a38fa9..2ca3af60fd 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/actions/environment - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: csharp @@ -49,6 +49,6 @@ jobs: run: dotnet build Sentry-CI-CodeQL.slnf --no-restore --nologo - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: '/language:csharp' diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index 1495d256d5..5c376e17c1 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -80,7 +80,7 @@ jobs: run: pwsh ./scripts/device-test.ps1 android -Build -Tfm ${{ matrix.tfm }} - name: Upload Android Test App (${{ matrix.tfm }}) - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: device-test-android-${{ matrix.tfm }} if-no-files-found: error @@ -123,7 +123,7 @@ jobs: submodules: recursive - name: Download test app artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: device-test-android-${{ matrix.tfm }} path: bin @@ -197,7 +197,7 @@ jobs: - name: Upload results if: success() || failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: device-test-android-${{ matrix.api-level }}-${{ matrix.tfm }}-results path: | diff --git a/.github/workflows/device-tests-ios.yml b/.github/workflows/device-tests-ios.yml index 4218cd1252..1d3ec5a451 100644 --- a/.github/workflows/device-tests-ios.yml +++ b/.github/workflows/device-tests-ios.yml @@ -101,7 +101,7 @@ jobs: - name: Upload results if: success() || failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: device-test-ios-results path: | diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 1c150cd3bf..77b8783e20 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -51,7 +51,7 @@ jobs: - name: Upload Format Report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: archive: false path: ./dotnet-format-report/ diff --git a/.github/workflows/playwright-blazor-wasm.yml b/.github/workflows/playwright-blazor-wasm.yml index 11a676b0db..ce4d6305b5 100644 --- a/.github/workflows/playwright-blazor-wasm.yml +++ b/.github/workflows/playwright-blazor-wasm.yml @@ -59,7 +59,7 @@ jobs: - name: Upload Test Results if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: playwright-blazor-wasm-results path: test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/TestResults/ diff --git a/.github/workflows/pr-risk.yml b/.github/workflows/pr-risk.yml index a2c8e2f483..96ce9a0e95 100644 --- a/.github/workflows/pr-risk.yml +++ b/.github/workflows/pr-risk.yml @@ -30,7 +30,7 @@ jobs: apply-label: "true" - name: Upload risk result - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: risk-pr-result path: risk-pr-result.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb702a976b..47a01839e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,9 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: - app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + client-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - name: Check out current commit (${{ github.sha }}) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release ${{ github.event.inputs.version }} - uses: getsentry/craft@4e164274d61ad2606b88b0aef000b2f4b1829745 # 2.33.3 + uses: getsentry/craft@3f2abdc5703191d12ef2cc013f792f1bd220f44d # 2.34.1 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/verify-api.yml b/.github/workflows/verify-api.yml index 1feffc45cd..ed461e4b14 100644 --- a/.github/workflows/verify-api.yml +++ b/.github/workflows/verify-api.yml @@ -96,7 +96,7 @@ jobs: - name: Upload Received API Files if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: api-verify-received-${{ matrix.rid }} path: received-${{ matrix.rid }}.tar.gz @@ -138,7 +138,7 @@ jobs: # download-artifact's pattern branch tolerates zero matches without erroring, so # we don't need `continue-on-error` here — that would mask genuine download failures. - name: Download Received API Files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: api-verify-received-* merge-multiple: true diff --git a/Directory.Build.props b/Directory.Build.props index 2238e29b3d..e108919814 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,6 +2,8 @@ 7.0.0 + + prerelease 14.0 true true diff --git a/integration-test/cli.Tests.ps1 b/integration-test/cli.Tests.ps1 index 401addbb11..f40defbe59 100644 --- a/integration-test/cli.Tests.ps1 +++ b/integration-test/cli.Tests.ps1 @@ -38,6 +38,22 @@ Describe 'Console apps () - normal build' -ForEach @( $result = RunDotnetWithSentryCLI 'build' 'console-app' $False $False $framework $result.UploadedDebugFiles() | Should -BeNullOrEmpty } + + It "uploads symbols when an earlier trimmed publish left a linked directory" { + RunDotnetWithSentryCLI 'build' 'console-app' $False $False $framework | Out-Null + $linked = "./console-app/obj/Release/$framework/linked" + New-Item -ItemType Directory $linked -Force | Out-Null + Copy-Item "./console-app/bin/Release/$framework/console-app.pdb" $linked + try + { + $result = RunDotnetWithSentryCLI 'build' 'console-app' $True $False $framework + $result.UploadedDebugFiles() | Sort-Object -Unique | Should -Be @('console-app.pdb') + } + finally + { + Remove-Item $linked -Recurse -Force + } + } } # The dummy Sentry server doesn't implement the releases API, so these tests record the CLI arguments instead. @@ -289,6 +305,31 @@ Describe 'MAUI ()' -ForEach @( } AddPackageReference $name 'Sentry.Maui' + + # Match on the PDB GUID: IL stripping rewrites the stamp in the bundle's CodeView entries + function UploadedPdbsNotInAppBundle($result, [string] $targetFramework) + { + $shippedGuids = Get-ChildItem "$name/bin/Release/$targetFramework" -Recurse -Directory -Filter "$name.app" ` + | Get-ChildItem -Recurse -Filter '*.dll' ` + | ForEach-Object { + $reader = [System.Reflection.PortableExecutable.PEReader]::new([System.IO.File]::OpenRead($_.FullName)) + try + { + $reader.ReadDebugDirectory() | Where-Object Type -EQ CodeView | ForEach-Object { + "$($reader.ReadCodeViewDebugDirectoryData($_).Guid)" + } + } + finally + { + $reader.Dispose() + } + } + $result.ScriptOutput ` + | Select-String 'json body: (\{.*"debug_id".*\})$' ` + | ForEach-Object { ($_.Matches[0].Groups[1].Value | ConvertFrom-Json -AsHashtable).Values } ` + | Where-Object { $_.name -like '*.pdb' -and $shippedGuids -notcontains $_.debug_id.Substring(0, 36) } ` + | ForEach-Object { $_.name } + } } It "uploads symbols and sources for an Android build" { @@ -338,10 +379,40 @@ Describe 'MAUI ()' -ForEach @( 'Microsoft.Maui.pdb', 'SentryObjC' ) - # Names alone can't tell the trimmer's linked/ PDBs from the postprocessed ones that actually ship - $result.ScriptOutput | Should -Not -AnyElementMatch 'linked/\*\.pdb' + UploadedPdbsNotInAppBundle $result "$framework-ios$iosTpv" | Should -BeNullOrEmpty # The specific number of debug information files seems to change with different SDK - so we just check for non-zero $nonZeroNumberRegex = '[1-9][0-9]*'; $result.ScriptOutput | Should -AnyElementMatch "Found $nonZeroNumberRegex debug information files \($nonZeroNumberRegex with embedded sources\)" } + + It "uploads symbols and sources for a Mac Catalyst build" -Skip:(!$IsMacOS) { + $result = RunDotnetWithSentryCLI 'build' 'maui-app' $True $True "$framework-maccatalyst$iosTpv" + Write-Host "UploadedDebugFiles: $($result.UploadedDebugFiles() | Out-String)" + $result.UploadedDebugFiles() | Sort-Object -Unique | Should -Be @( + 'libclrjit.dylib', + 'libcoreclr.dylib', + 'libmscordaccore.dylib', + 'libmscordbi.dylib', + 'libSystem.Globalization.Native.dylib', + 'libSystem.IO.Compression.Native.dylib', + 'libSystem.Native.dylib', + 'libSystem.Net.Security.Native.dylib', + 'libSystem.Security.Cryptography.Native.Apple.dylib', + 'libxamarin-dotnet-coreclr-debug.dylib', + 'libxamarin-dotnet-coreclr.dylib', + 'libxamarin-dotnet-debug.dylib', + 'libxamarin-dotnet.dylib', + 'maui-app', + 'maui-app.pdb', + 'maui-app.r2r.dylib', + 'Microsoft.MacCatalyst.pdb', + 'Microsoft.Maui.Controls.pdb', + 'Microsoft.Maui.Controls.Xaml.pdb', + 'Microsoft.Maui.Essentials.pdb', + 'Microsoft.Maui.Graphics.pdb', + 'Microsoft.Maui.pdb', + 'SentryObjC' + ) + UploadedPdbsNotInAppBundle $result "$framework-maccatalyst$iosTpv" | Should -BeNullOrEmpty + } } diff --git a/integration-test/common.ps1 b/integration-test/common.ps1 index c1f3bf3a26..48c708d3b0 100644 --- a/integration-test/common.ps1 +++ b/integration-test/common.ps1 @@ -33,54 +33,31 @@ BeforeAll { function GetSentryPackageVersion() { - # Read version directly from Directory.Build.props - $propsFile = Join-Path $PSScriptRoot '..\Directory.Build.props' - - if (-not (Test-Path $propsFile)) { - throw "Directory.Build.props not found at $propsFile" - } - - # Parse the props file using PowerShell XML parsing - Write-Host "Parsing props file as XML..." - [xml]$propsXml = Get-Content $propsFile - - # Look for VersionPrefix and VersionSuffix in PropertyGroup elements - $versionPrefix = "" - $versionSuffix = "" - - foreach ($propGroup in $propsXml.Project.PropertyGroup) { - if ($propGroup.PSObject.Properties["VersionPrefix"]) { - $versionPrefix = $propGroup.VersionPrefix - Write-Host "Found VersionPrefix: '$versionPrefix'" - } - - # For VersionSuffix, we need to be careful about conditions - # Only use VersionSuffix if it's not in a conditional PropertyGroup - # or if it's explicitly set (not the 'dev' fallback for non-Release) - if ($propGroup.PSObject.Properties["VersionSuffix"]) { - $condition = $null - if ($propGroup.PSObject.Properties["Condition"]) { - $condition = $propGroup.Condition - Write-Host "Ignoring VersionSuffix: '$($propGroup.VersionSuffix)' with condition: '$condition'" - # Skip conditional VersionSuffix as we're building in Release mode + if (-not $script:SentryPackageVersion) + { + if (Test-Path env:CI) + { + # The publishing build runs these tests in a sparse checkout holding only the built packages + $package = Get-ChildItem "$PSScriptRoot/../src/Sentry/bin/Release/Sentry.*.nupkg" -File | Select-Object -First 1 + if (-not $package) + { + throw "No Sentry package found in src/Sentry/bin/Release" } - else { - # No condition - this is the explicit VersionSuffix we want - $versionSuffix = $propGroup.VersionSuffix - Write-Host "Found VersionSuffix: '$versionSuffix'" + $version = $package.Name -replace '^Sentry\.(.+)\.nupkg$', '$1' + } + else + { + $output = dotnet msbuild "$PSScriptRoot/../src/Sentry/Sentry.csproj" -p:Configuration=Release -getProperty:Version 2>&1 | ForEach-Object { "$_".Trim() } + $version = $output | Where-Object { $_ -match '^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$' } | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0 -or -not $version) + { + throw "Could not evaluate the Sentry package version (exit code $LASTEXITCODE):`n$($output -join "`n")" } } + $script:SentryPackageVersion = $version + Write-Host "Sentry package version: $script:SentryPackageVersion" } - - if (-not $versionPrefix) { - throw "Could not find VersionPrefix in $propsFile" - } - - # Combine prefix and suffix - $fullVersion = if ($versionSuffix) { "$versionPrefix-$versionSuffix" } else { $versionPrefix } - Write-Host "Full Version: '$fullVersion'" - - return $fullVersion + return $script:SentryPackageVersion } function RegisterLocalPackage([string] $name) diff --git a/modules/sentry-cocoa b/modules/sentry-cocoa index 4f135c52af..4bb2282661 160000 --- a/modules/sentry-cocoa +++ b/modules/sentry-cocoa @@ -1 +1 @@ -Subproject commit 4f135c52afeeb1d6937eb93824c7ec1cddcaa372 +Subproject commit 4bb2282661ee6ca2212aa01d19a1214394cd3c45 diff --git a/modules/sentry-native b/modules/sentry-native index fb2a04d4a9..740313523e 160000 --- a/modules/sentry-native +++ b/modules/sentry-native @@ -1 +1 @@ -Subproject commit fb2a04d4a9ca78d83c9cc5d10e33cfac79eed77f +Subproject commit 740313523edd50e3a0cfe2f8f931e893ed7423a5 diff --git a/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj b/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj index 0e7bb4c83a..cd5c00d74c 100644 --- a/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj +++ b/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj @@ -1,7 +1,7 @@ $(LatestAndroidTfm);$(PreviousAndroidTfm) - 8.60.0 + 8.61.0 $(BaseIntermediateOutputPath)sdks\$(TargetFramework)\Sentry\Android\$(SentryAndroidSdkVersion)\ diff --git a/src/Sentry.Bindings.Android/Transforms/Metadata.xml b/src/Sentry.Bindings.Android/Transforms/Metadata.xml index 7d71770ad6..9833aed535 100644 --- a/src/Sentry.Bindings.Android/Transforms/Metadata.xml +++ b/src/Sentry.Bindings.Android/Transforms/Metadata.xml @@ -104,6 +104,9 @@ --> + + + diff --git a/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs b/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs index 8d096d4002..fee90a9359 100644 --- a/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs @@ -19,6 +19,25 @@ public static IGlobalConfiguration UseSentry(this IGlobalConfiguration configura return configuration; } + /// + /// Adds the Sentry filter that captures check-ins for jobs marked with . + /// + /// The Hangfire configuration. + /// Configures the Sentry Hangfire integration. + /// The Hangfire configuration. + public static IGlobalConfiguration UseSentry(this IGlobalConfiguration configuration, Action configureOptions) + { + if (configureOptions is null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + var options = new SentryHangfireOptions(); + configureOptions(options); + configuration.UseFilter(new SentryServerFilter(null, null, options)); + return configuration; + } + /// /// For testing /// diff --git a/src/Sentry.Hangfire/Sentry.Hangfire.csproj b/src/Sentry.Hangfire/Sentry.Hangfire.csproj index 4e2f001c85..ff3b319e63 100644 --- a/src/Sentry.Hangfire/Sentry.Hangfire.csproj +++ b/src/Sentry.Hangfire/Sentry.Hangfire.csproj @@ -19,6 +19,11 @@ + + + + + diff --git a/src/Sentry.Hangfire/SentryHangfireOptions.cs b/src/Sentry.Hangfire/SentryHangfireOptions.cs new file mode 100644 index 0000000000..fc5419a84d --- /dev/null +++ b/src/Sentry.Hangfire/SentryHangfireOptions.cs @@ -0,0 +1,26 @@ +namespace Sentry.Hangfire; + +/// +/// Options for the Sentry Hangfire integration. +/// +public class SentryHangfireOptions +{ + /// + /// When enabled, the in-progress check-in of a recurring job includes the job's cron expression and time zone + /// as the monitor config, so Sentry creates the monitor or updates its schedule. Defaults to true. + /// Set to false to manage the monitor's schedule in Sentry instead. + /// + /// + /// + /// The monitor slug still comes from . When several recurring jobs use the + /// same monitor slug with different cron expressions or time zones, for example one recurring job per region that + /// runs the same method, no monitor config is sent for them. Give each its own monitor slug to send it. + /// + /// + /// Schedules that Sentry can't represent are not sent. This includes crons that use seconds other than a fixed + /// value, and time zones that can't be converted to an IANA ID. On .NET Framework, Windows time zone IDs can't be + /// converted, so only UTC and IANA IDs are sent. + /// + /// + public bool SendMonitorConfig { get; set; } = true; +} diff --git a/src/Sentry.Hangfire/SentryMonitorSlugAttribute.cs b/src/Sentry.Hangfire/SentryMonitorSlugAttribute.cs index 7415414337..3253e6cfdb 100644 --- a/src/Sentry.Hangfire/SentryMonitorSlugAttribute.cs +++ b/src/Sentry.Hangfire/SentryMonitorSlugAttribute.cs @@ -11,6 +11,8 @@ public class SentryMonitorSlugAttribute(string monitorSlug) : JobFilterAttribute { private readonly string? _monitorSlug = monitorSlug; + internal string? MonitorSlug => _monitorSlug; + /// public void OnCreating(CreatingContext context) { diff --git a/src/Sentry.Hangfire/SentryServerFilter.cs b/src/Sentry.Hangfire/SentryServerFilter.cs index fab9a64212..fed803cc24 100644 --- a/src/Sentry.Hangfire/SentryServerFilter.cs +++ b/src/Sentry.Hangfire/SentryServerFilter.cs @@ -1,5 +1,9 @@ +using Hangfire.Common; using Hangfire.Server; +using Hangfire.Storage; using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Internal; namespace Sentry.Hangfire; @@ -7,16 +11,29 @@ internal class SentryServerFilter : IServerFilter { internal const string SentryMonitorSlugKey = "SentryMonitorSlug"; internal const string SentryCheckInIdKey = "SentryCheckInIdKey"; + internal const string RecurringJobIdKey = "RecurringJobId"; + + // How long the slugs of all recurring jobs are reused before they're read from storage again + internal static readonly TimeSpan RecurringJobSlugsTtl = TimeSpan.FromMinutes(1); private readonly IHub _hub; private readonly IDiagnosticLogger? _logger; + private readonly SentryHangfireOptions _options; + private readonly ISystemClock _clock; + private readonly object _recurringJobSlugsLock = new(); + private RecurringJobSlugs? _recurringJobSlugs; + + internal SentryHangfireOptions Options => _options; public SentryServerFilter() : this(null, null) { } - internal SentryServerFilter(IHub? hub, IDiagnosticLogger? logger) + internal SentryServerFilter(IHub? hub, IDiagnosticLogger? logger, SentryHangfireOptions? options = null, + ISystemClock? clock = null) { _hub = hub ?? HubAdapter.Instance; + _options = options ?? new SentryHangfireOptions(); + _clock = clock ?? SystemClock.Clock; #pragma warning disable CS0618 // Type or member is obsolete _logger = logger ?? _hub.GetInternalSentryOptions()?.DiagnosticLogger; #pragma warning restore CS0618 // Type or member is obsolete @@ -35,7 +52,7 @@ public void OnPerforming(PerformingContext context) return; } - var checkInId = _hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress); + var checkInId = CaptureInProgressCheckIn(context, monitorSlug); // Note that we may be overwriting context.Items[SentryCheckInIdKey] here, which is intentional. If that happens // then implicitly OnPerforming was called previously with the same context, but we never made it to OnPerformed @@ -43,6 +60,239 @@ public void OnPerforming(PerformingContext context) context.Items[SentryCheckInIdKey] = checkInId; } + private SentryId CaptureInProgressCheckIn(PerformingContext context, string monitorSlug) + { + Action? configureMonitorOptions = null; + if (_options.SendMonitorConfig && GetRecurringJobSchedule(context, monitorSlug) is (var crontab, var timeZone)) + { + configureMonitorOptions = options => + { + options.Interval(crontab); + options.TimeZone = timeZone; + }; + } + + // Created here, so the final check-in has the same ID even when this one isn't sent + var checkInId = SentryId.Create(); + _hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress, checkInId, configureMonitorOptions: configureMonitorOptions); + return checkInId; + } + + private (string Crontab, string TimeZone)? GetRecurringJobSchedule(PerformingContext context, string monitorSlug) + { + string? recurringJobId = null; + try + { + recurringJobId = context.GetJobParameter(RecurringJobIdKey); + if (string.IsNullOrEmpty(recurringJobId)) + { + return null; + } + + var recurringJob = context.Connection.GetRecurringJobs([recurringJobId]).SingleOrDefault(); + if (recurringJob is null || recurringJob.Removed) + { + _logger?.LogDebug("Not sending the schedule of recurring job '{0}'. The job no longer exists.", recurringJobId); + return null; + } + + var crontab = ToCrontab(recurringJob.Cron); + var timeZone = ToIanaTimeZoneId(recurringJob.TimeZoneId); + if (crontab is null || timeZone is null) + { + _logger?.LogDebug("Not sending the schedule of recurring job '{0}'. Sentry doesn't support " + + "the cron expression '{1}' with time zone '{2}'.", recurringJobId, recurringJob.Cron, recurringJob.TimeZoneId); + return null; + } + + var conflicting = GetRecurringJobSlugs(context.Connection, recurringJobId!) + .WithDifferentSchedule(monitorSlug, recurringJobId!, recurringJob.Cron, recurringJob.TimeZoneId); + if (conflicting.Count > 0) + { + _logger?.LogDebug("Not sending the schedule of recurring job '{0}'. Recurring jobs '{1}' use the same " + + "monitor slug '{2}' with a different schedule. Give each its own monitor slug.", + recurringJobId, string.Join("', '", conflicting), monitorSlug); + return null; + } + + return (crontab, timeZone); + } + catch (Exception e) + { + _logger?.LogError(e, "Failed to read the schedule of recurring job '{0}'.", recurringJobId); + return null; + } + } + + // Reading every recurring job takes one storage query per job, so the result is reused for a minute. + private RecurringJobSlugs GetRecurringJobSlugs(IStorageConnection connection, string recurringJobId) + { + lock (_recurringJobSlugsLock) + { + var now = _clock.GetUtcNow(); + if (_recurringJobSlugs is not { } slugs || slugs.ReadAt + RecurringJobSlugsTtl <= now + || !slugs.Contains(recurringJobId)) + { + slugs = RecurringJobSlugs.Read(connection, now); + _recurringJobSlugs = slugs; + } + + return slugs; + } + } + + private sealed class RecurringJobSlugs + { + private readonly Dictionary> _jobsBySlug; + private readonly HashSet _ids; + + public DateTimeOffset ReadAt { get; } + + private RecurringJobSlugs(DateTimeOffset readAt, + Dictionary> jobsBySlug, HashSet ids) + { + ReadAt = readAt; + _jobsBySlug = jobsBySlug; + _ids = ids; + } + + public static RecurringJobSlugs Read(IStorageConnection connection, DateTimeOffset readAt) + { + var jobsBySlug = new Dictionary>(); + var ids = new HashSet(); + foreach (var recurringJob in connection.GetRecurringJobs()) + { + ids.Add(recurringJob.Id); + if (recurringJob.Removed || recurringJob.Job is null || GetMonitorSlug(recurringJob.Job) is not { } slug) + { + continue; + } + + if (!jobsBySlug.TryGetValue(slug, out var jobs)) + { + jobs = new List<(string Id, string? Cron, string TimeZoneId)>(); + jobsBySlug[slug] = jobs; + } + jobs.Add((recurringJob.Id, recurringJob.Cron, NormalizeTimeZoneId(recurringJob.TimeZoneId))); + } + + return new RecurringJobSlugs(readAt, jobsBySlug, ids); + } + + public bool Contains(string recurringJobId) => _ids.Contains(recurringJobId); + + public List WithDifferentSchedule(string monitorSlug, string recurringJobId, string? cron, string? timeZoneId) + { + if (!_jobsBySlug.TryGetValue(monitorSlug, out var jobs)) + { + return new List(); + } + + var normalizedTimeZoneId = NormalizeTimeZoneId(timeZoneId); + return jobs + .Where(job => job.Id != recurringJobId && (job.Cron != cron || job.TimeZoneId != normalizedTimeZoneId)) + .Select(job => job.Id) + .ToList(); + } + + private static string? GetMonitorSlug(Job job) => + JobFilterProviders.Providers.GetFilters(job) + .Select(filter => filter.Instance) + .OfType() + .LastOrDefault()?.MonitorSlug; + + private static string NormalizeTimeZoneId(string? timeZoneId) => + string.IsNullOrEmpty(timeZoneId) ? "UTC" : timeZoneId!; + } + + // Hangfire also accepts a leading seconds field, which Sentry doesn't support. + internal static string? ToCrontab(string? cron) + { + if (string.IsNullOrWhiteSpace(cron)) + { + return null; + } + + var fields = cron!.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length == 6 + && int.TryParse(fields[0], NumberStyles.None, CultureInfo.InvariantCulture, out var seconds) + && seconds < 60) + { + fields = fields.Skip(1).ToArray(); + } + + if (fields.Length != 5) + { + return null; + } + + // Hangfire runs a job only on days that match both day fields, while Sentry runs it on days that match + // either one unless one of them starts with '*'. + if (!fields[2].StartsWith("*") && !fields[4].StartsWith("*")) + { + return null; + } + + var crontab = string.Join(" ", fields); + return CrontabValidator.IsValid(crontab) && !HasReversedRange(fields) ? crontab : null; + } + + private static readonly string[] DayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; + + // Sentry rejects ranges whose start is after their end, such as 5-1 or SAT-SUN + private static bool HasReversedRange(string[] fields) + { + foreach (var field in fields) + { + foreach (var item in field.Split(',')) + { + var range = item.Split('/')[0].Split('-'); + if (range.Length == 2 && ToNumber(range[0]) > ToNumber(range[1])) + { + return true; + } + } + } + + return false; + + static int ToNumber(string value) => + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var number) + ? number + : Array.FindIndex(DayNames, name => string.Equals(name, value, StringComparison.OrdinalIgnoreCase)); + } + + internal static string? ToIanaTimeZoneId(string? timeZoneId) + { + // Hangfire's default is TimeZoneInfo.Utc, whose ID is "UTC" on every platform + if (string.IsNullOrWhiteSpace(timeZoneId) || timeZoneId == "UTC") + { + return "UTC"; + } + +#if NET6_0_OR_GREATER + TimeZoneInfo timeZone; + try + { + timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId!); + } + catch (Exception) + { + return null; + } + + if (timeZone.HasIanaId) + { + return timeZone.Id; + } + + return TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZone.Id, out var ianaId) ? ianaId : null; +#else + // .NET Framework can neither look up nor convert IANA IDs, so only pass on IDs that look like one + return timeZoneId!.Contains("/") ? timeZoneId : null; +#endif + } + public void OnPerformed(PerformedContext context) { var monitorSlug = context.GetJobParameter(SentryMonitorSlugKey); diff --git a/src/Sentry.Maui/Sentry.Maui.csproj b/src/Sentry.Maui/Sentry.Maui.csproj index f15e751ccb..d4721a365d 100644 --- a/src/Sentry.Maui/Sentry.Maui.csproj +++ b/src/Sentry.Maui/Sentry.Maui.csproj @@ -17,6 +17,8 @@ See https://learn.microsoft.com/dotnet/core/tools/sdk-errors/netsdk1100 --> true + + false false diff --git a/src/Sentry/Internal/CrontabValidator.cs b/src/Sentry/Internal/CrontabValidator.cs new file mode 100644 index 0000000000..8af7d9a90d --- /dev/null +++ b/src/Sentry/Internal/CrontabValidator.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; + +namespace Sentry.Internal; + +/// +/// Validates the crontab format Sentry accepts for monitor schedules. +/// +/// +/// Also compiled into Sentry.Hangfire, which can't see Sentry's internals because it isn't strong-named. +/// +internal static partial class CrontabValidator +{ + // Breakdown of the validation regex pattern: + // For each time field (minute, hour, day, month, weekday): + // - Allows * for "any value" + // - Allows */n for step values where n must be any positive integer (except zero) + // - Allows single values within their valid ranges + // - Allows ranges (e.g., 8-10) + // - Allows step values with ranges (e.g., 8-18/4) + // - Allows lists of values and ranges (e.g., 6,8,9 or 8-10,12-14) + // - Allows weekday names (MON, TUE, WED, THU, FRI, SAT, SUN) + // + // Valid ranges for each field: + // - Minutes: 0-59 + // - Hours: 0-23 + // - Days: 1-31 + // - Months: 1-12 + // - Weekdays: 0-7 (0 and 7 both represent Sunday) or MON-SUN + private const string ValidCrontabPattern = @"^(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?))*$"; + +#if NET9_0_OR_GREATER + [GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)] + private static partial Regex ValidCrontab { get; } +#elif NET8_0 + [GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)] + private static partial Regex ValidCrontabRegex(); + private static readonly Regex ValidCrontab = ValidCrontabRegex(); +#else + private static readonly Regex ValidCrontab = new(ValidCrontabPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture); +#endif + + public static bool IsValid(string crontab) => ValidCrontab.IsMatch(crontab); +} diff --git a/src/Sentry/Internal/Extensions/StreamExtensions.cs b/src/Sentry/Internal/Extensions/StreamExtensions.cs index 20ddf21a92..aeb6f98b82 100644 --- a/src/Sentry/Internal/Extensions/StreamExtensions.cs +++ b/src/Sentry/Internal/Extensions/StreamExtensions.cs @@ -110,7 +110,7 @@ public static Task WriteNewlineAsync(this Stream stream, CancellationToken cance { try { - return stream.Length; + return stream.CanSeek ? stream.Length : null; } catch { diff --git a/src/Sentry/SentryMonitorOptions.cs b/src/Sentry/SentryMonitorOptions.cs index 8d566beedc..e814c89a0c 100644 --- a/src/Sentry/SentryMonitorOptions.cs +++ b/src/Sentry/SentryMonitorOptions.cs @@ -1,4 +1,5 @@ using Sentry.Extensibility; +using Sentry.Internal; using Sentry.Internal.Extensions; namespace Sentry; @@ -49,42 +50,13 @@ public enum SentryMonitorInterval /// /// Sentry's options for monitors /// -public partial class SentryMonitorOptions : ISentryJsonSerializable +public class SentryMonitorOptions : ISentryJsonSerializable { - // Breakdown of the validation regex pattern: - // For each time field (minute, hour, day, month, weekday): - // - Allows * for "any value" - // - Allows */n for step values where n must be any positive integer (except zero) - // - Allows single values within their valid ranges - // - Allows ranges (e.g., 8-10) - // - Allows step values with ranges (e.g., 8-18/4) - // - Allows lists of values and ranges (e.g., 6,8,9 or 8-10,12-14) - // - Allows weekday names (MON, TUE, WED, THU, FRI, SAT, SUN) - // - // Valid ranges for each field: - // - Minutes: 0-59 - // - Hours: 0-23 - // - Days: 1-31 - // - Months: 1-12 - // - Weekdays: 0-7 (0 and 7 both represent Sunday) or MON-SUN - private const string ValidCrontabPattern = @"^(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?))*$"; - private SentryMonitorScheduleType _type = SentryMonitorScheduleType.None; private string? _crontab; private int? _interval; private SentryMonitorInterval? _unit; -#if NET9_0_OR_GREATER - [GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)] - private static partial Regex ValidCrontab { get; } -#elif NET8_0 - [GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)] - private static partial Regex ValidCrontabRegex(); - private static readonly Regex ValidCrontab = ValidCrontabRegex(); -#else - private static readonly Regex ValidCrontab = new(ValidCrontabPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture); -#endif - /// /// Set Interval /// @@ -96,7 +68,7 @@ public void Interval(string crontab) throw new ArgumentException("You tried to set the interval twice. The Check-Ins interval is supposed to be set only once."); } - if (!ValidCrontab.IsMatch(crontab)) + if (!CrontabValidator.IsValid(crontab)) { throw new ArgumentException("The provided crontab does not match the expected format of '* * * * *' " + "translating to 'minute', 'hour', 'day of the month', 'month', and 'day of the week'."); diff --git a/src/Sentry/buildTransitive/Sentry.targets b/src/Sentry/buildTransitive/Sentry.targets index 6bfdd6bb76..9535506846 100644 --- a/src/Sentry/buildTransitive/Sentry.targets +++ b/src/Sentry/buildTransitive/Sentry.targets @@ -225,34 +225,37 @@ $(SentryCLIUploadDirectory) - - $(SentryCLIUploadItems) $(IntermediateOutputPath)linked/*.pdb - $(SentryCLIUploadItems) $(IntermediateOutputPath)postprocessed-assemblies/*.pdb $(SentryCLIUploadItems) @(AndroidNativeSymbolFilesExceptDll -> '%(Identity)', ' ') + <_SentryCLIUploadTrimmedPdbs Condition="Exists('$(IntermediateOutputPath)linked')">$(IntermediateOutputPath)linked/*.pdb + <_SentryCLIUploadTrimmedPdbs Condition="Exists('$(IntermediateOutputPath)postprocessed-assemblies')">$(IntermediateOutputPath)postprocessed-assemblies/*.pdb + <_SentryCLIIncludeSources Condition="'$(SentryUploadSources)' == 'true'">--include-sources + + <_SentryCLIUploadSymbolType Include="@(SentryCLIUploadSymbolType)" /> + + + + <_SentryCLIUploadSymbolType Remove="pdb;portablepdb" Condition="'$(_SentryCLIUploadTrimmedPdbs)' != '' or ('$(RuntimeIdentifiers)' != '' and '$(RuntimeIdentifier)' == '')" /> + - + + Text="Preparing upload to Sentry for project '$(MSBuildProjectName)' ($(Configuration)/$(TargetFramework)): collecting debug symbols and referenced source code from $(SentryCLIUploadItems) $(_SentryCLIUploadTrimmedPdbs)" /> + - - - + @@ -277,7 +280,7 @@ - + diff --git a/test/Sentry.Hangfire.Tests/HangfireFixture.cs b/test/Sentry.Hangfire.Tests/HangfireFixture.cs index 949f02c693..3b9f5cd0f9 100644 --- a/test/Sentry.Hangfire.Tests/HangfireFixture.cs +++ b/test/Sentry.Hangfire.Tests/HangfireFixture.cs @@ -26,9 +26,16 @@ public HangfireFixture() _monitoringApi = JobStorage.Current.GetMonitoringApi(); } - public Task Enqueue(Expression> methodCall) + public Task Enqueue(Expression> methodCall) => WaitForJob(BackgroundJob.Enqueue(methodCall)); + + public Task TriggerRecurringJob(string recurringJobId, Expression> methodCall, string cron, TimeZoneInfo timeZone) + { + RecurringJob.AddOrUpdate(recurringJobId, methodCall, cron, new RecurringJobOptions { TimeZone = timeZone }); + return WaitForJob(RecurringJob.TriggerJob(recurringJobId)); + } + + private Task WaitForJob(string jobId) { - var jobId = BackgroundJob.Enqueue(methodCall); var checkJobState = Task.Run(() => { while (true) diff --git a/test/Sentry.Hangfire.Tests/HangfireTests.cs b/test/Sentry.Hangfire.Tests/HangfireTests.cs index 0e4601363e..2efbc1a391 100644 --- a/test/Sentry.Hangfire.Tests/HangfireTests.cs +++ b/test/Sentry.Hangfire.Tests/HangfireTests.cs @@ -1,3 +1,5 @@ +using Hangfire; + namespace Sentry.Hangfire.Tests; public class HangfireTests : IClassFixture @@ -12,28 +14,24 @@ public HangfireTests(HangfireFixture hangfireFixture) [Fact] public async Task ExecuteJobWithAttribute_CapturesCheckInInProgressAndOkWithDuration() { - var sentryId = SentryId.Create(); - _fixture.Hub.CaptureCheckIn(Arg.Any(), Arg.Any()).Returns(sentryId); - await _fixture.Enqueue(job => job.ExecuteJobWithAttribute()); _fixture.Hub.Received(1).CaptureCheckIn( Arg.Is("test-job"), Arg.Is(status => status == CheckInStatus.InProgress), Arg.Any()); + var checkInId = _fixture.Hub.ReceivedInProgressCheckInId("test-job"); + checkInId.Should().NotBeNull().And.NotBe(SentryId.Empty); _fixture.Hub.Received(1).CaptureCheckIn( Arg.Is("test-job"), Arg.Is(status => status == CheckInStatus.Ok), - Arg.Is(id => id == sentryId), + checkInId, Arg.Is(duration => duration != null), Arg.Any()); } [Fact] public async Task ExecuteJobWithException_CapturesCheckInInProgressAndErrorWithDuration() { - var sentryId = SentryId.Create(); - _fixture.Hub.CaptureCheckIn(Arg.Any(), Arg.Any()).Returns(sentryId); - await _fixture.Enqueue(job => job.ExecuteJobWithException()); await Task.Delay(1000); @@ -42,10 +40,12 @@ public async Task ExecuteJobWithException_CapturesCheckInInProgressAndErrorWithD Arg.Is("test-job-with-exception"), Arg.Is(status => status == CheckInStatus.InProgress), Arg.Any()); + var checkInId = _fixture.Hub.ReceivedInProgressCheckInId("test-job-with-exception"); + checkInId.Should().NotBeNull().And.NotBe(SentryId.Empty); _fixture.Hub.Received(1).CaptureCheckIn( Arg.Is("test-job-with-exception"), Arg.Is(status => status == CheckInStatus.Error), - Arg.Is(id => id == sentryId), + checkInId, Arg.Is(duration => duration != null), Arg.Any()); } @@ -60,6 +60,53 @@ public async Task ExecuteJobWithoutAttribute_DoesNotCapturesCheckInButLogs() _fixture.Hub.DidNotReceive().CaptureCheckIn(Arg.Any(), Arg.Any(), Arg.Is(id => id == sentryId)); _fixture.Logger.Received(1).Log(SentryLevel.Debug, Arg.Is(message => message.Contains("Skipping creating a check-in for")), null, Arg.Any(), Arg.Any()); } + + [SkippableFact] + public async Task ExecuteRecurringJob_SendMonitorConfigEnabled_CapturesCheckInWithMonitorConfig() + { + Skip.If(!TestEnvironment.HasTimeZone("Europe/Berlin"), "No time zone database"); + + var timeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + + await _fixture.TriggerRecurringJob("test-recurring-job-id", job => job.ExecuteRecurringJob(), "30 0 0 1 1 *", timeZone); + + var configureMonitorOptions = _fixture.Hub.ReceivedConfigureMonitorOptions("test-recurring-job"); + configureMonitorOptions.Should().NotBeNull(); + var monitorConfig = MonitorConfigJson.Render(configureMonitorOptions!); + monitorConfig.GetProperty("schedule").GetProperty("type").GetString().Should().Be("crontab"); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 0 1 1 *"); + monitorConfig.GetProperty("timezone").GetString().Should().Be("Europe/Berlin"); + } + + // In this class so it runs serially with the fixture's jobs, which would also pick up the filter it adds + [Fact] + public void UseSentry_ConfigureOptions_AddsFilterWithOptions() + { + var configuration = Substitute.For(); + var existingFilters = SentryFilters().ToList(); + + configuration.UseSentry(options => options.SendMonitorConfig = false); + + var addedFilters = SentryFilters().Except(existingFilters).ToList(); + foreach (var filter in addedFilters) + { + GlobalJobFilters.Filters.Remove(filter); + } + addedFilters.Should().ContainSingle().Which.Options.SendMonitorConfig.Should().BeFalse(); + + static IEnumerable SentryFilters() => + GlobalJobFilters.Filters.Select(filter => filter.Instance).OfType(); + } + + [Fact] + public void UseSentry_NullConfigureOptions_Throws() + { + var configuration = Substitute.For(); + + var act = () => configuration.UseSentry((Action)null!); + + act.Should().Throw(); + } } public class TestJob @@ -76,4 +123,8 @@ public void ExecuteJobWithException() public void ExecuteJobWithoutAttribute() { } + + [SentryMonitorSlug("test-recurring-job")] + public void ExecuteRecurringJob() + { } } diff --git a/test/Sentry.Hangfire.Tests/MonitorConfigJson.cs b/test/Sentry.Hangfire.Tests/MonitorConfigJson.cs new file mode 100644 index 0000000000..27b06cb280 --- /dev/null +++ b/test/Sentry.Hangfire.Tests/MonitorConfigJson.cs @@ -0,0 +1,32 @@ +namespace Sentry.Hangfire.Tests; + +internal static class MonitorConfigJson +{ + public static JsonElement Render(Action configureMonitorOptions) + { + var options = new SentryMonitorOptions(); + configureMonitorOptions(options); + var checkIn = new SentryCheckIn("monitor-slug", CheckInStatus.InProgress) { MonitorOptions = options }; + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + checkIn.WriteTo(writer, null); + } + + using var document = JsonDocument.Parse(stream.ToArray()); + return document.RootElement.GetProperty("monitor_config").Clone(); + } + + public static Action? ReceivedConfigureMonitorOptions(this IHub hub, string monitorSlug) => + hub.ReceivedInProgressCheckIn(monitorSlug)[5] as Action; + + public static SentryId? ReceivedInProgressCheckInId(this IHub hub, string monitorSlug) => + (SentryId?)hub.ReceivedInProgressCheckIn(monitorSlug)[2]; + + private static object?[] ReceivedInProgressCheckIn(this IHub hub, string monitorSlug) => + hub.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IHub.CaptureCheckIn)) + .Select(call => call.GetArguments()) + .Single(args => (string?)args[0] == monitorSlug && (CheckInStatus?)args[1] == CheckInStatus.InProgress); +} diff --git a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs index 0ac1a1f8a5..172432aa64 100644 --- a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs +++ b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs @@ -2,6 +2,7 @@ using Hangfire.Common; using Hangfire.Server; using Hangfire.Storage; +using Sentry.Infrastructure; namespace Sentry.Hangfire.Tests; @@ -30,7 +31,6 @@ public void OnPerforming_IsReentrant() var performingContext = new PerformingContext(performContext); var hub = Substitute.For(); - hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress).Returns(SentryId.Create()); var logger = Substitute.For(); var filter = new SentryServerFilter(hub, logger); @@ -47,6 +47,453 @@ public void OnPerforming_IsReentrant() // Assert performContext.Items.ContainsKey(SentryServerFilter.SentryCheckInIdKey).Should().BeTrue(); - performingContext.Items[SentryServerFilter.SentryCheckInIdKey].Should().NotBeSameAs(firstKey); + performingContext.Items[SentryServerFilter.SentryCheckInIdKey].Should().NotBe(firstKey); + } + + [Fact] + public void OnPerformed_InProgressCheckInNotCaptured_FinishesWithSameNonEmptyId() + { + // Arrange + var fixture = new RecurringJobFixture { CheckInId = SentryId.Empty }; + var filter = fixture.GetSut(); + filter.OnPerforming(fixture.PerformingContext); + var performedContext = new PerformedContext(fixture.PerformingContext, null, false, null); + + // Act + filter.OnPerformed(performedContext); + + // Assert + var checkInId = fixture.Hub.ReceivedInProgressCheckInId(RecurringJobFixture.MonitorSlug); + checkInId.Should().NotBeNull().And.NotBe(SentryId.Empty); + fixture.Hub.Received(1).CaptureCheckIn(RecurringJobFixture.MonitorSlug, CheckInStatus.Ok, checkInId, Arg.Any()); + } + + [SkippableTheory] + [InlineData("0 */2 * * *", "Europe/Berlin", "0 */2 * * *", "Europe/Berlin")] + [InlineData("30 0 */2 * * *", "UTC", "0 */2 * * *", "UTC")] + [InlineData("0 0 * * MON", null, "0 0 * * MON", "UTC")] + public void OnPerforming_RecurringJobWithSendMonitorConfigEnabled_SendsMonitorConfig( + string cron, string? timeZoneId, string expectedCrontab, string expectedTimeZone) + { + Skip.If(timeZoneId is not (null or "UTC") && !TestEnvironment.HasTimeZone(timeZoneId), "No time zone database"); + + // Arrange + var fixture = new RecurringJobFixture { Cron = cron, TimeZoneId = timeZoneId }; + var filter = fixture.GetSut(sendMonitorConfig: true); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + var monitorConfig = fixture.GetSentMonitorConfig(); + monitorConfig.Should().NotBeNull(); + monitorConfig!.Value.GetProperty("schedule").GetProperty("type").GetString().Should().Be("crontab"); + monitorConfig.Value.GetProperty("schedule").GetProperty("value").GetString().Should().Be(expectedCrontab); + monitorConfig.Value.GetProperty("timezone").GetString().Should().Be(expectedTimeZone); + fixture.PerformingContext.Items[SentryServerFilter.SentryCheckInIdKey].Should().Be(fixture.Hub.ReceivedInProgressCheckInId(RecurringJobFixture.MonitorSlug)); + } + + [Fact] + public void OnPerforming_DefaultOptions_SendsMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture(); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + var monitorConfig = fixture.GetSentMonitorConfig(); + monitorConfig.Should().NotBeNull(); + monitorConfig!.Value.GetProperty("schedule").GetProperty("value").GetString().Should().Be(fixture.Cron); + monitorConfig.Value.GetProperty("timezone").GetString().Should().Be("UTC"); + } + + [Fact] + public void OnPerforming_SendMonitorConfigDisabled_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture(); + var filter = fixture.GetSut(sendMonitorConfig: false); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + fixture.Connection.DidNotReceive().GetAllEntriesFromHash(Arg.Any()); + } + + [Fact] + public void OnPerforming_NotRecurringJob_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture { RecurringJobId = null }; + var filter = fixture.GetSut(sendMonitorConfig: true); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + [Fact] + public void OnPerforming_RecurringJobDeleted_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture { RecurringJobExists = false }; + var filter = fixture.GetSut(sendMonitorConfig: true); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + [Theory] + [InlineData("0 0 L * *", "UTC")] + [InlineData("60 0 * * * *", "UTC")] + [InlineData("*/10 * * * * *", "UTC")] + [InlineData("0 * * * *", "Not A Time Zone")] + public void OnPerforming_ScheduleNotSupportedBySentry_SendsCheckInWithoutMonitorConfig(string cron, string timeZoneId) + { + // Arrange + var fixture = new RecurringJobFixture { Cron = cron, TimeZoneId = timeZoneId }; + var filter = fixture.GetSut(sendMonitorConfig: true); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + fixture.Logger.DidNotReceive().Log(SentryLevel.Error, Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public void OnPerforming_StorageThrows_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture(); + var filter = fixture.GetSut(sendMonitorConfig: true); + fixture.Connection.GetAllEntriesFromHash(Arg.Any()).Throws(new InvalidOperationException()); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + [Fact] + public void OnPerforming_RecurringJobsShareSlugWithDifferentCron_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture { Cron = "0 2 * * *", Job = ReportJob("eu") }; + fixture.OtherRecurringJobs.Add(("report-us", "0 8 * * *", null, ReportJob("us"))); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + fixture.Logger.Received(1).Log(SentryLevel.Debug, Arg.Is(m => m.Contains("with a different schedule")), + null, Arg.Any()); + } + + [Fact] + public void OnPerforming_RecurringJobsShareSlugWithDifferentTimeZone_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture { Cron = "0 2 * * *", Job = ReportJob("eu") }; + fixture.OtherRecurringJobs.Add(("report-us", "0 2 * * *", "America/New_York", ReportJob("us"))); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + [Fact] + public void OnPerforming_RecurringJobsShareSlugWithSameSchedule_SendsMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture { Cron = "0 2 * * *", Job = ReportJob("eu") }; + fixture.OtherRecurringJobs.Add(("report-us", "0 2 * * *", "UTC", ReportJob("us"))); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.GetSentMonitorConfig().Should().NotBeNull(); + } + + [Fact] + public void OnPerforming_ClassSlugSharedByMethodsWithDifferentCron_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture + { + Slug = SlugOnClassJob.Slug, + Cron = "0 2 * * *", + Job = Job.FromExpression(job => job.First()) + }; + fixture.OtherRecurringJobs.Add(("second", "0 8 * * *", null, Job.FromExpression(job => job.Second()))); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + [Fact] + public void OnPerforming_OtherRecurringJobHasItsOwnSlug_SendsMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture { Cron = "0 2 * * *", Job = ReportJob("eu") }; + fixture.OtherRecurringJobs.Add(("other", "0 8 * * *", null, Job.FromExpression(job => job.First()))); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.GetSentMonitorConfig().Should().NotBeNull(); + } + + [Fact] + public void OnPerforming_WithinTtl_ReadsRecurringJobsOnce() + { + // Arrange + var fixture = new RecurringJobFixture(); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + fixture.Clock.GetUtcNow().Returns(fixture.Now + SentryServerFilter.RecurringJobSlugsTtl - TimeSpan.FromSeconds(1)); + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.Connection.Received(1).GetAllItemsFromSet("recurring-jobs"); + } + + [Fact] + public void OnPerforming_AfterTtl_ReadsRecurringJobsAgain() + { + // Arrange + var fixture = new RecurringJobFixture(); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + fixture.Clock.GetUtcNow().Returns(fixture.Now + SentryServerFilter.RecurringJobSlugsTtl); + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.Connection.Received(2).GetAllItemsFromSet("recurring-jobs"); + } + + [Fact] + public void OnPerforming_CronChangedWithinTtl_SendsNewCron() + { + // Arrange + var fixture = new RecurringJobFixture { Cron = "0 2 * * *", Job = ReportJob("eu") }; + var filter = fixture.GetSut(); + filter.OnPerforming(fixture.PerformingContext); + fixture.Connection.GetAllEntriesFromHash($"recurring-job:{fixture.RecurringJobId}") + .Returns(new Dictionary + { + ["Cron"] = "0 8 * * *", + ["Job"] = InvocationData.SerializeJob(fixture.Job).SerializePayload() + }); + fixture.Hub.ClearReceivedCalls(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + var monitorConfig = fixture.GetSentMonitorConfig(); + monitorConfig.Should().NotBeNull(); + monitorConfig!.Value.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 8 * * *"); + } + + [Fact] + public void OnPerforming_ReadingAllRecurringJobsThrows_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture(); + var filter = fixture.GetSut(); + fixture.Connection.GetAllItemsFromSet("recurring-jobs").Throws(new InvalidOperationException()); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + private static Job ReportJob(string region) => Job.FromExpression(job => job.Run(region)); + + [Theory] + [InlineData("0 */2 * * *", "0 */2 * * *")] + [InlineData("30 0 */2 * * *", "0 */2 * * *")] + [InlineData("0 0 * * 1", "0 0 * * 1")] + [InlineData("60 0 * * * *", null)] + [InlineData("*/10 * * * * *", null)] + [InlineData("0,30 * * * * *", null)] + [InlineData("0 0 L * *", null)] + [InlineData("@daily", null)] + [InlineData("", null)] + [InlineData("0 9 * * MON", "0 9 * * MON")] + [InlineData("0 9 1-7 * *", "0 9 1-7 * *")] + [InlineData("0 9 */2 * MON", "0 9 */2 * MON")] + [InlineData("0 9 1-7 * */2", "0 9 1-7 * */2")] + [InlineData("0 9 1-7 * MON", null)] + [InlineData("0 0 9 15 * 1-5", null)] + [InlineData("0 9 * * MON-FRI", "0 9 * * MON-FRI")] + [InlineData("0 9 * * 5-1", null)] + [InlineData("0 9 * * SAT-SUN", null)] + [InlineData("0 9 * * mon-sun", null)] + [InlineData("0 22-2 * * *", null)] + [InlineData("0 9 * * 1-3,6-0", null)] + [InlineData("0 8-18/2 * * *", "0 8-18/2 * * *")] + public void ToCrontab_HangfireCron_ReturnsSentryCrontabOrNull(string cron, string? expected) + { + SentryServerFilter.ToCrontab(cron).Should().Be(expected); + } + + [SkippableTheory] + [InlineData(null, "UTC")] + [InlineData("", "UTC")] + [InlineData("UTC", "UTC")] + [InlineData("Etc/UTC", "Etc/UTC")] + [InlineData("Europe/Berlin", "Europe/Berlin")] + [InlineData("W. Europe Standard Time", "Europe/Berlin")] + [InlineData("Not A Time Zone", null)] + [InlineData("Not/A_Time_Zone", null)] + public void ToIanaTimeZoneId_HangfireTimeZoneId_ReturnsIanaIdOrNull(string? timeZoneId, string? expected) + { + Skip.If(expected is not (null or "UTC") && !TestEnvironment.HasTimeZone(timeZoneId!), "No time zone database"); + + SentryServerFilter.ToIanaTimeZoneId(timeZoneId).Should().Be(expected); + } + + public class SharedSlugJob + { + [SentryMonitorSlug(RecurringJobFixture.MonitorSlug)] + public void Run(string region) + { } + } + + [SentryMonitorSlug(Slug)] + public class SlugOnClassJob + { + public const string Slug = "slug-on-class"; + + public void First() + { } + + public void Second() + { } + } + + private class RecurringJobFixture + { + public const string MonitorSlug = "test-monitor-slug"; + private const string JobId = "test-id"; + + public string? RecurringJobId { get; set; } = "test-recurring-job"; + public bool RecurringJobExists { get; set; } = true; + public string Cron { get; set; } = "0 * * * *"; + public string? TimeZoneId { get; set; } + public Job? Job { get; set; } + public List<(string Id, string Cron, string? TimeZoneId, Job Job)> OtherRecurringJobs { get; } = new(); + public ISystemClock Clock { get; } = Substitute.For(); + public DateTimeOffset Now { get; } = new(2026, 10, 13, 0, 0, 0, TimeSpan.Zero); + + public IStorageConnection Connection { get; } = Substitute.For(); + public IHub Hub { get; } = Substitute.For(); + public IDiagnosticLogger Logger { get; } = Substitute.For(); + public SentryId CheckInId { get; set; } = SentryId.Create(); + public PerformingContext PerformingContext { get; private set; } = null!; + + public SentryServerFilter GetSut(bool? sendMonitorConfig = null) + { + Logger.IsEnabled(Arg.Any()).Returns(true); + Clock.GetUtcNow().Returns(Now); + Connection.GetJobParameter(JobId, SentryServerFilter.SentryMonitorSlugKey) + .Returns(SerializationHelper.Serialize(Slug)); + var recurringJobIds = new HashSet(OtherRecurringJobs.Select(job => job.Id)); + if (RecurringJobId is not null) + { + Connection.GetJobParameter(JobId, SentryServerFilter.RecurringJobIdKey) + .Returns(SerializationHelper.Serialize(RecurringJobId)); + if (RecurringJobExists) + { + recurringJobIds.Add(RecurringJobId); + Connection.GetAllEntriesFromHash($"recurring-job:{RecurringJobId}") + .Returns(RecurringJobHash(Cron, TimeZoneId, Job)); + } + } + foreach (var (id, cron, timeZoneId, job) in OtherRecurringJobs) + { + Connection.GetAllEntriesFromHash($"recurring-job:{id}").Returns(RecurringJobHash(cron, timeZoneId, job)); + } + Connection.GetAllItemsFromSet("recurring-jobs").Returns(recurringJobIds); + + Hub.CaptureCheckIn( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()) + .Returns(CheckInId); + + var backgroundJob = new BackgroundJob(JobId, null, DateTime.Now); + var performContext = new PerformContext(null, Connection, backgroundJob, Substitute.For()); + PerformingContext = new PerformingContext(performContext); + + var options = new SentryHangfireOptions(); + if (sendMonitorConfig is { } send) + { + options.SendMonitorConfig = send; + } + return new SentryServerFilter(Hub, Logger, options, Clock); + } + + public string Slug { get; set; } = MonitorSlug; + + private static Dictionary RecurringJobHash(string cron, string? timeZoneId, Job? job) + { + var hash = new Dictionary { ["Cron"] = cron }; + if (timeZoneId is not null) + { + hash["TimeZoneId"] = timeZoneId; + } + if (job is not null) + { + hash["Job"] = InvocationData.SerializeJob(job).SerializePayload(); + } + return hash; + } + + public JsonElement? GetSentMonitorConfig() => + Hub.ReceivedConfigureMonitorOptions(Slug) is { } configure ? MonitorConfigJson.Render(configure) : null; + + public void AssertCheckInSentWithoutMonitorConfig() + { + Hub.Received(1).CaptureCheckIn(Slug, CheckInStatus.InProgress, Arg.Any()); + Hub.ReceivedConfigureMonitorOptions(Slug).Should().BeNull(); + PerformingContext.Items[SentryServerFilter.SentryCheckInIdKey].Should().Be(Hub.ReceivedInProgressCheckInId(Slug)); + } } } diff --git a/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj b/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj index ef8c3fbe71..9624dd23dd 100644 --- a/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj +++ b/test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj @@ -48,6 +48,9 @@ $(NoWarn);XA4218 + + $(NoWarn);IL2037 + diff --git a/test/Sentry.Testing/TestEnvironment.cs b/test/Sentry.Testing/TestEnvironment.cs index e70c571fd8..42e426863d 100644 --- a/test/Sentry.Testing/TestEnvironment.cs +++ b/test/Sentry.Testing/TestEnvironment.cs @@ -13,4 +13,20 @@ public static bool IsGitHubActions return isGitHubActions?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; } } + + /// + /// Some containers (e.g. Alpine) ship without a time zone database. + /// + public static bool HasTimeZone(string id) + { + try + { + _ = TimeZoneInfo.FindSystemTimeZoneById(id); + return true; + } + catch (Exception e) when (e is TimeZoneNotFoundException or InvalidTimeZoneException) + { + return false; + } + } } diff --git a/test/Sentry.Tests/Internals/Extensions/StreamExtensionsTests.cs b/test/Sentry.Tests/Internals/Extensions/StreamExtensionsTests.cs index 9928b9426f..de8e833deb 100644 --- a/test/Sentry.Tests/Internals/Extensions/StreamExtensionsTests.cs +++ b/test/Sentry.Tests/Internals/Extensions/StreamExtensionsTests.cs @@ -46,4 +46,61 @@ public async Task ReadLineAsync_NoMaxLength_ReadsToTheEnd() // Assert Encoding.UTF8.GetString(result).Should().Be(line); } + + [Fact] + public void TryGetLength_SeekableStream_ReturnsLength() + { + // Arrange + using var stream = new MemoryStream(new byte[42]); + + // Act + var result = stream.TryGetLength(); + + // Assert + result.Should().Be(42); + } + + [Fact] + public void TryGetLength_NonSeekableStream_ReturnsNull() + { + // Arrange + using var stream = new LengthlessStream(); + + // Act + var result = stream.TryGetLength(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetLength_NonSeekableStreamReportingLength_ReturnsNull() + { + // Arrange + using var stream = new NonSeekableMemoryStream(new byte[42]); + + // Act + var result = stream.TryGetLength(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetLength_NullStream_ReturnsNull() + { + // Arrange + Stream stream = null; + + // Act + var result = stream.TryGetLength(); + + // Assert + result.Should().BeNull(); + } + + private class NonSeekableMemoryStream(byte[] buffer) : MemoryStream(buffer) + { + public override bool CanSeek => false; + } }