diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 7931b4b906..35ad76ec86 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "sharpie.bind.tool": { - "version": "27.0.0.722", + "version": "27.1.0.18", "commands": [ "sharpie" ], diff --git a/.craft.yml b/.craft.yml index 152d563158..1c7a8dc559 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,8 +1,19 @@ -minVersion: 2.26.3 -changelogPolicy: auto -preReleaseCommand: bash scripts/bump-version.sh +minVersion: 3.0.0 +changelog: + policy: auto +versioning: + policy: auto targets: - name: nuget + # Craft reads Sentry.slnx and the project files to find our packages, and publishes each one as + # its own target in dependency order, so a retried release only pushes the packages that failed. + # It doesn't evaluate MSBuild: the test apps with their own Directory.Build.props look packable + # to it, so they're excluded by name. A package that the filters miss is silently not published: + # run `craft targets` after adding a package or a test app and check the list. + workspaces: true + solutionPath: Sentry.slnx + includeWorkspaces: '/^Sentry/' + excludeWorkspaces: '/(TrimTest|CrashableApp)$/' - name: github - name: registry # Every package carries name/packageUrl/mainDocsUrl so craft can create the registry entry @@ -92,3 +103,11 @@ targets: name: Sentry.Profiling packageUrl: https://www.nuget.org/packages/Sentry.Profiling mainDocsUrl: https://docs.sentry.io/platforms/dotnet/profiling/ + nuget:Sentry.Quartz: + name: Sentry.Quartz + packageUrl: https://www.nuget.org/packages/Sentry.Quartz + mainDocsUrl: https://docs.sentry.io/platforms/dotnet/crons/quartz/ + nuget:Sentry.Quartz3: + name: Sentry.Quartz3 + packageUrl: https://www.nuget.org/packages/Sentry.Quartz3 + mainDocsUrl: https://docs.sentry.io/platforms/dotnet/crons/quartz/ diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index 4742240f65..a81cc01fc8 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -119,14 +119,13 @@ + - - @@ -170,6 +169,8 @@ + + @@ -205,6 +206,8 @@ + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 46a8470266..b4ee41765e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -30,6 +30,8 @@ body: - Sentry.NLog - Sentry.OpenTelemetry - Sentry.Profiling + - Sentry.Quartz + - Sentry.Quartz3 - Sentry.Serilog - Other validations: diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index 96450b3906..27d86f95aa 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -1,10 +1,60 @@ name: Setup Environment description: Sets env vars and installs dependencies +inputs: + projects: + description: The solution filters and project files the job builds, separated by spaces, so only the workloads they need are installed. Jobs that build everything pass Sentry.slnx. + required: true runs: using: composite steps: + # The Windows images keep .NET under Program Files, where workloads install as MSI packages, and the NuGet cache + # in the user profile on C:. So .NET goes next to the workspace, where workloads install as plain files. The x64 + # runners put the workspace on D:, so the NuGet packages go there too. The arm64 runners only have C:, so the + # NuGet packages and TEMP go on a Dev Drive there. Defender already excludes C:\ on these images. + - name: Install .NET and NuGet packages on a fast drive + if: runner.os == 'Windows' + shell: pwsh + run: | + $root = Split-Path $env:GITHUB_WORKSPACE + "DOTNET_INSTALL_DIR=$root\.dotnet" >> $env:GITHUB_ENV + if ($root.StartsWith('C:')) + { + try + { + # Only volumes mounted after this get no antivirus filters + fsutil devdrv enable /disallowAv | Out-Host + 'create vdisk file="C:\devdrive.vhdx" maximum=81920 type=expandable', 'select vdisk file="C:\devdrive.vhdx"', 'attach vdisk', 'convert gpt', 'create partition primary' | + Set-Content "$env:RUNNER_TEMP\diskpart.txt" + diskpart /s "$env:RUNNER_TEMP\diskpart.txt" | Out-Host + if ($LASTEXITCODE -ne 0) { throw "diskpart failed with exit code $LASTEXITCODE" } + $partition = Get-DiskImage -ImagePath 'C:\devdrive.vhdx' | Get-Disk | Get-Partition | Where-Object Type -eq 'Basic' + $partition | Format-Volume -DevDrive -Confirm:$false -Force | Out-Host + $partition | Add-PartitionAccessPath -AccessPath 'X:\' + # Right after the mount, trust can fail with "Access is denied" while the volume is still in use + for ($attempt = 1; $attempt -le 5; $attempt++) + { + fsutil devdrv trust X: | Out-Host + if ($LASTEXITCODE -eq 0) { break } + Start-Sleep -Seconds 3 + } + if ($LASTEXITCODE -ne 0) { Write-Warning "Couldn't mark X: as a trusted Dev Drive, so antivirus filters may still scan it" } + fsutil devdrv query X: | Out-Host + New-Item -ItemType Directory 'X:\tmp' | Out-Null + 'TEMP=X:\tmp' >> $env:GITHUB_ENV + 'TMP=X:\tmp' >> $env:GITHUB_ENV + $root = 'X:' + } + catch + { + # The Dev Drive is a speedup, so the job goes on without it + Write-Warning "Couldn't set up the Dev Drive, so the NuGet packages stay next to the workspace: $_" + } + } + "NUGET_PACKAGES=$root\.nuget\packages" >> $env:GITHUB_ENV + $global:LASTEXITCODE = 0 + - name: Set Global Environment Variables shell: bash run: | @@ -36,37 +86,20 @@ runs: # Java 21 is needed by .NET Android - name: Install Java 21 - if: ${{ !matrix.container }} - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + if: ${{ !matrix.container && env.NO_MOBILE != 'true' }} + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: '21' - name: Setup Android SDK - if: ${{ !matrix.container }} + if: ${{ !matrix.container && env.NO_MOBILE != 'true' }} uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 with: # Exclude "tools" because the emulator is not needed (nor available for Windows/Linux ARM64) packages: platform-tools platforms;android-35 platforms;android-36 platforms;android-37.0 build-tools;36.0.0 log-accepted-android-sdk-licenses: false - - name: Install Mono (macOS) - if: runner.os == 'macOS' - shell: bash - # Attempt to install Mono, allowing it to fail silently - # Then ensure mono is correctly linked, overwriting any conflicting symlinks - run: | - brew install mono || true - brew link --overwrite mono - - - name: Install Mono (Ubuntu) - if: ${{ runner.os == 'Linux' && !matrix.container }} - shell: bash - run: | - sudo apt install -y mono-devel - # restore perms for actions/setup-dotnet - sudo chmod -R a+rw /usr/share/dotnet - - name: Install .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -85,14 +118,37 @@ runs: - name: Install .NET Workloads shell: bash + env: + PROJECTS: ${{ inputs.projects }} run: | set -euo pipefail pwd - dotnet workload restore \ - --temp-dir "${{ runner.temp }}" \ - --skip-sign-check - dotnet workload restore test/AndroidTestApp/AndroidTestApp.csproj \ - --temp-dir "${{ runner.temp }}" \ - --skip-sign-check - # Restore the Android test app explicitly to ensure runtime packs are available - dotnet restore test/AndroidTestApp/AndroidTestApp.csproj -r android-x64 --nologo || true + projects=() + for item in $PROJECTS; do + if [[ "$item" == *.slnf ]]; then + # dotnet workload restore doesn't accept a solution filter, but it accepts the projects in one + found=$(grep -o '"[^"]*\.csproj"' "$item" | tr -d '"' | sed 's#\\\\#/#g' || true) + if [[ -z "$found" ]]; then + echo "::error::Found no projects in $item, so there are no workloads to restore" + exit 1 + fi + projects+=($found) + else + projects+=("$item") + fi + done + if [[ ${#projects[@]} -eq 0 ]]; then + echo "::error::Pass the solution filters or project files this job builds as projects, so only their workloads are installed" + exit 1 + fi + dotnet workload restore "${projects[@]}" --temp-dir "${{ runner.temp }}" --skip-sign-check + # Sentry.Android.AssemblyReader.Tests builds the Android test app with an MSBuild task, not a project reference, + # so workload restore doesn't see it. grep reads a here-string because with pipefail, printf piped into grep -q + # fails with SIGPIPE when grep exits before printf has written everything. + if grep -q -E 'Sentry\.slnx$|Sentry\.Android\.AssemblyReader\.Tests|AndroidTestApp' <<< "$(printf '%s\n' "${projects[@]}")"; then + dotnet workload restore test/AndroidTestApp/AndroidTestApp.csproj \ + --temp-dir "${{ runner.temp }}" \ + --skip-sign-check + # Restore the Android test app explicitly to ensure runtime packs are available + dotnet restore test/AndroidTestApp/AndroidTestApp.csproj -r android-x64 --nologo || true + fi diff --git a/.github/alpine/Dockerfile b/.github/alpine/Dockerfile index 6718812e71..55917c9977 100644 --- a/.github/alpine/Dockerfile +++ b/.github/alpine/Dockerfile @@ -5,7 +5,7 @@ FROM ${BASE} RUN apk update # common -RUN apk add bash build-base cmake curl git gpg icu lsb-release-minimal nodejs npm sudo tar wget +RUN apk add bash build-base cmake curl git gpg icu lsb-release-minimal nodejs npm sudo tar tzdata wget # sentry-native RUN apk add curl-dev docker-cli libunwind-dev libunwind-static linux-headers openssl-dev zlib-dev xz-dev xz-static @@ -14,7 +14,7 @@ RUN apk add curl-dev docker-cli libunwind-dev libunwind-static linux-headers ope RUN apk add grpc-plugins openjdk11 powershell ENV PROTOBUF_PROTOC=/usr/bin/protoc ENV GRPC_PROTOC_PLUGIN=/usr/bin/grpc_csharp_plugin -RUN pwsh -Command Install-Module Pester -Scope AllUsers -Force +RUN pwsh -Command "Install-Module Pester -Scope AllUsers -Force -ErrorAction Stop" # mono (3.22+) RUN if ! apk add mono; then \ diff --git a/.github/workflows/alpine.yml b/.github/workflows/alpine.yml index 81bcad7e3c..690a441db8 100644 --- a/.github/workflows/alpine.yml +++ b/.github/workflows/alpine.yml @@ -32,6 +32,9 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + # The action runs this image privileged, so it's pinned like the action + image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9600cb214..d8f18852d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -160,6 +160,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: ${{ matrix.slnf }} # We use macOS for the final publishing build so we get all the iOS/macCatalyst targets in the packages - name: Set Environment Variables @@ -249,7 +251,15 @@ jobs: - name: Build id: build - run: dotnet build ${{ matrix.slnf }} -c Release --no-restore --nologo -v:minimal -flp:logfile=build.log -p:CopyLocalLockFileAssemblies=true -bl:build.binlog + run: dotnet build ${{ matrix.slnf }} -c Release --no-restore --nologo -v:minimal -p:CopyLocalLockFileAssemblies=true -bl:build.binlog + + # Only a completed Cocoa build writes the stamp, so a failed one is never cached. + - name: Save Cocoa SDK + if: ${{ !cancelled() && steps.cache-cocoa.outputs.cache-primary-key != '' && steps.cache-cocoa.outputs.cache-hit != 'true' && hashFiles('modules/sentry-cocoa/Carthage/.built-from-sha') != '' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cache-cocoa.outputs.cache-primary-key }} # Only a completed Cocoa build writes the stamp, so a failed one is never cached. - name: Save Cocoa SDK @@ -264,9 +274,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ matrix.rid }}-build-logs - path: | - build.log - build.binlog + path: build.binlog if-no-files-found: ignore - name: Free disk space before tests @@ -285,7 +293,7 @@ jobs: df -h - name: Test - run: dotnet test ${{ matrix.slnf }} -c Release --no-build --nologo -l GitHubActions -l "trx;LogFilePrefix=testresults_${{ runner.os }}" --collect "XPlat Code Coverage" + run: dotnet test ${{ matrix.slnf }} -c Release --no-build --nologo -l GitHubActions -l "trx;LogFilePrefix=testresults_${{ runner.os }}" --collect "XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute - name: Upload code coverage uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 @@ -333,23 +341,149 @@ jobs: # ContainerBaseImage comes from the workflow-level env block at the top of this file. - name: Integration test - # TODO: Re-enable linux-musl-x64 once we have resolved https://github.com/getsentry/sentry-dotnet/issues/4788 - if: ${{ (matrix.rid != 'linux-musl-x64') && (matrix.rid != 'linux-musl-arm64') }} + if: matrix.rid == 'linux-arm64' || matrix.rid == 'macos' + uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 + # The MAUI cases run in the integration-test job, against packages built there. On release branches they run + # here too, so the packages craft publishes are the ones tested. env: + SKIP_MAUI_TESTS: ${{ runner.os == 'macOS' && !startsWith(github.ref_name, 'release/') }} ContainerBaseImage: ${{ env.CONTAINER_BASE_IMAGE_GLIBC }} - uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 with: path: integration-test - name: Integration test (musl) - # TODO: Include linux-musl-x64 once we have resolved https://github.com/getsentry/sentry-dotnet/issues/4788 - if: ${{ (matrix.rid == 'linux-musl-arm64') }} + if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64' uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 env: ContainerBaseImage: ${{ env.CONTAINER_BASE_IMAGE_MUSL }} with: path: integration-test + # Sentry turns SentryNative off on runtimes that have no native SDK. aot.Tests.ps1 checks that against this + # leg's own packages: linux-arm on the linux-arm64 runner, and win-x86 on the win-x64 runner. + - name: Install Linux ARM 32-bit dependencies + if: matrix.rid == 'linux-arm64' + run: | + sudo dpkg --add-architecture armhf + sudo apt-get update + sudo apt-get install -y gcc-arm-linux-gnueabihf libc6:armhf + + # Some arm64 hosts can't run 32-bit ARM binaries ("Exec format error"), so QEMU runs them on every host. Its + # binfmt_misc handler takes precedence over the kernel's own loader, so it does even where the host could. + - name: Set up QEMU for 32-bit ARM + if: matrix.rid == 'linux-arm64' + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + with: + # The action runs this image privileged, so it's pinned like the action + image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 + platforms: arm + + - name: Check that QEMU handles 32-bit ARM binaries + if: matrix.rid == 'linux-arm64' + run: grep -q '^enabled' /proc/sys/fs/binfmt_misc/qemu-arm + + - name: Test AOT on an unsupported runtime + if: matrix.rid == 'linux-arm64' || matrix.rid == 'win-x64' + uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 + env: + RuntimeIdentifier: ${{ matrix.rid == 'linux-arm64' && 'linux-arm' || 'win-x86' }} + # The glibc tag covers linux/arm/v7; win-x86 skips the Container test. + ContainerBaseImage: ${{ env.CONTAINER_BASE_IMAGE_GLIBC }} + with: + path: integration-test/aot.Tests.ps1 + + # These run integration tests next to a build leg, against packages built here from only the projects the tests + # use, which the Sentry-CI-IntegrationTest filters list. CI_PUBLISHING_BUILD only adds the other platforms' native + # libraries and sentry-cli binaries, which these tests don't use. + # - macos: the MAUI cases build three Release apps, which takes 8-10 minutes. On release branches the macOS leg + # runs them too, against the packages it publishes. + # - linux-x64, win-x64 and win-arm64: every integration test runs here, in parallel with the leg. + integration-test: + needs: build-sentry-native + name: Integration test (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: xcode-27 + rid: macos + slnf: Sentry-CI-IntegrationTest-macOS.slnf + tests: integration-test/cli-maui.Tests.ps1 + - os: ubuntu-24.04 + rid: linux-x64 + slnf: Sentry-CI-IntegrationTest.slnf + tests: integration-test + - os: windows-2025-vs2026 + rid: win-x64 + slnf: Sentry-CI-IntegrationTest.slnf + tests: integration-test + - os: windows-11-vs2026-arm + rid: win-arm64 + slnf: Sentry-CI-IntegrationTest.slnf + tests: integration-test + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Remove unused applications + uses: ./.github/actions/freediskspace + + - name: Setup Environment + uses: ./.github/actions/environment + with: + projects: ${{ matrix.slnf }} + + - name: Download sentry-native (${{ matrix.rid }}) + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: src/Sentry/Platforms/Native/sentry-native + key: sentry-native-${{ matrix.rid }}-${{ hashFiles('scripts/build-sentry-native.ps1') }}-${{ hashFiles('.git/modules/modules/sentry-native/HEAD') }} + fail-on-cache-miss: true + enableCrossOsArchive: true + + - name: Get Cocoa SDK cache key + id: cocoa-key + if: runner.os == 'macOS' + uses: ./.github/actions/cocoa-cache-key + + - name: Restore Cocoa SDK + id: cache-cocoa + if: runner.os == 'macOS' && !startsWith(github.ref_name, 'release/') + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cocoa-key.outputs.key }} + + - name: Build Native Dependencies + uses: ./.github/actions/buildnative + + - name: Build + run: dotnet build ${{ matrix.slnf }} -c Release --nologo -v:minimal + + - name: Save Cocoa SDK + if: ${{ !cancelled() && steps.cache-cocoa.outputs.cache-primary-key != '' && steps.cache-cocoa.outputs.cache-hit != 'true' && hashFiles('modules/sentry-cocoa/Carthage/.built-from-sha') != '' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cache-cocoa.outputs.cache-primary-key }} + + # Like the build legs, pack the build outputs. Packing Sentry.Maui while building it on macOS fails with NU5026, + # looking for the Windows target's .pri file, which isn't generated on macOS. + - name: Create NuGet Packages + run: dotnet pack ${{ matrix.slnf }} -c Release --no-build --nologo + + - name: Integration test + uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 + env: + ContainerBaseImage: ${{ env.CONTAINER_BASE_IMAGE_GLIBC }} + with: + path: ${{ matrix.tests }} + ms-build: needs: build-sentry-native name: MSBuild @@ -371,6 +505,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: Sentry-CI-Build-Windows.slnf samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj - name: Build Native Dependencies uses: ./.github/actions/buildnative @@ -380,7 +516,12 @@ jobs: - name: Run MSBuild id: msbuild - run: msbuild Sentry-CI-Build-Windows.slnf -t:Restore,Build,Pack -p:Configuration=Release --nologo -v:minimal -flp:logfile=msbuild.log -p:CopyLocalLockFileAssemblies=true -bl:msbuild.binlog + run: msbuild Sentry-CI-Build-Windows.slnf -restore -t:Build,Pack -p:Configuration=Release -m -nr:false --nologo -v:minimal -p:CopyLocalLockFileAssemblies=true -bl:msbuild.binlog + + # The app samples build in their own job, with the .NET CLI, so the MAUI sample's Android and Windows targets + # get an MSBuild build here + - name: Build the MAUI sample + run: msbuild samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj -restore -t:Build -p:Configuration=Release -m -nr:false --nologo -v:minimal -p:CopyLocalLockFileAssemblies=true -bl:msbuild-maui.binlog - name: Test MSBuild uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 @@ -393,64 +534,10 @@ jobs: with: name: ${{ runner.os }}-msbuild-logs path: | - msbuild.log msbuild.binlog + msbuild-maui.binlog if-no-files-found: ignore - # Unsupported Native AOT runtimes should have SentryNative auto-disabled - # to avoid native library loading errors on startup. - unsupported-aot: - needs: build-sentry - name: Unsupported AOT (${{ matrix.rid }}) - runs-on: ${{ matrix.os }} - env: - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - DOTNET_NOLOGO: 1 - - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-24.04-arm - rid: linux-arm - - os: windows-2025-vs2026 - rid: win-x86 - - # This job is on the critical path, after the whole build-sentry matrix. aot.Tests.ps1 only publishes a - # console app against the packages from the macOS build, so it needs the .NET SDK but not the full - # Setup Environment (Java, Android SDK, Mono, workloads). - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Linux ARM 32-bit dependencies - if: ${{ matrix.rid == 'linux-arm' }} - run: | - sudo dpkg --add-architecture armhf - sudo apt-get update - sudo apt-get install -y gcc-arm-linux-gnueabihf libc6:armhf - - - name: Install .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json - - - name: Fetch NuGet Packages - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: ${{ github.sha }} - path: src - - - name: Test AOT - uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 - env: - RuntimeIdentifier: ${{ matrix.rid }} - # aot.Tests.ps1 has the Container test, so it needs the same pin. - # The glibc tag covers linux/arm/v7; win-x86 skips the Container test. - ContainerBaseImage: ${{ env.CONTAINER_BASE_IMAGE_GLIBC }} - with: - path: integration-test/aot.Tests.ps1 - trim-analysis: needs: build-sentry-native name: Trim analysis @@ -476,6 +563,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: test/Sentry.TrimTest/Sentry.TrimTest.csproj test/Sentry.MauiTrimTest/Sentry.MauiTrimTest.csproj - name: Build Native Dependencies uses: ./.github/actions/buildnative @@ -492,6 +581,86 @@ jobs: # - name: Publish Test app (iOS) # run: dotnet publish test/Sentry.MauiTrimTest/Sentry.MauiTrimTest.csproj -c Release -f net10.0-ios26 -r ios-arm64 + # Nothing consumes the app samples, but their Release builds trim and AOT-compile each app, which made them the + # last minutes of the Build step in the .NET legs. Here they build next to the legs instead of inside them. + build-samples: + needs: build-sentry-native + name: Samples (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: xcode-27 + rid: macos + samples: Sentry-CI-Build-Samples-macOS.slnf + # The MAUI sample's Windows target, which the MSBuild job also builds with msbuild.exe + - os: windows-2025-vs2026 + rid: win-x64 + samples: samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj + # The Android and MAUI samples' Android targets on a Linux host, as the linux-x64 leg built them + - os: ubuntu-24.04 + rid: linux-x64 + samples: Sentry-CI-Build-Samples-Linux.slnf + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Setup Environment + uses: ./.github/actions/environment + with: + projects: ${{ matrix.samples }} + + - name: Download sentry-native (${{ matrix.rid }}) + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: src/Sentry/Platforms/Native/sentry-native + key: sentry-native-${{ matrix.rid }}-${{ hashFiles('scripts/build-sentry-native.ps1') }}-${{ hashFiles('.git/modules/modules/sentry-native/HEAD') }} + fail-on-cache-miss: true + enableCrossOsArchive: true + + # See build-sentry + - name: Get Cocoa SDK cache key + id: cocoa-key + if: runner.os == 'macOS' + uses: ./.github/actions/cocoa-cache-key + + - name: Restore Cocoa SDK + id: cache-cocoa + if: runner.os == 'macOS' && !startsWith(github.ref_name, 'release/') + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cocoa-key.outputs.key }} + + - name: Build Native Dependencies + uses: ./.github/actions/buildnative + + - name: Restore .NET Dependencies + run: dotnet restore ${{ matrix.samples }} --nologo + + - name: Build + run: dotnet build ${{ matrix.samples }} -c Release --no-restore --nologo -v:minimal -bl:build.binlog + + - name: Save Cocoa SDK + if: ${{ !cancelled() && steps.cache-cocoa.outputs.cache-primary-key != '' && steps.cache-cocoa.outputs.cache-hit != 'true' && hashFiles('modules/sentry-cocoa/Carthage/.built-from-sha') != '' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cache-cocoa.outputs.cache-primary-key }} + + - name: Upload build logs + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: samples-${{ matrix.rid }}-build-logs + path: build.binlog + if-no-files-found: ignore + test-solution-filters: runs-on: ubuntu-24.04 if: ${{ !startsWith(github.ref_name, 'release/') }} diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 6960c152ee..33c234f0a0 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@3f2abdc5703191d12ef2cc013f792f1bd220f44d # 2.34.1 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@0ff75896d43285ca376c7254e8143bac23acd429 # 2.35.1 secrets: inherit diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2ca3af60fd..f5cf5afdc5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -33,22 +33,21 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: Sentry-CI-CodeQL.slnf - name: Initialize CodeQL - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: csharp - name: Restore .NET Dependencies - # We should be able to get rid of the restore here, if we install the correct workloads in actions/environment - run: | - dotnet workload restore - dotnet restore Sentry-CI-CodeQL.slnf --nologo + run: dotnet restore Sentry-CI-CodeQL.slnf --nologo - name: Build run: dotnet build Sentry-CI-CodeQL.slnf --no-restore --nologo - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: '/language:csharp' diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index 5c376e17c1..8972524831 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -11,6 +11,7 @@ on: - 'src/Sentry/**' - 'src/Sentry.Extensions.Logging/**' - 'src/Sentry.Compiler.Extensions/**' + - 'src/Sentry.Analyzers/**' # Mobile-specific packages - 'src/Sentry.Maui/**' - 'src/Sentry.Maui.CommunityToolkit.Mvvm/**' @@ -25,6 +26,7 @@ on: - 'test/Sentry.Maui.CommunityToolkit.Mvvm.Tests/**' - 'test/Sentry.Android.AssemblyReader.Tests/**' - 'test/Sentry.Testing/**' + - 'test/Sentry.Testing.CrashableApp/**' - 'test/AndroidTestApp/**' # Integration tests - 'integration-test/android.Tests.ps1' @@ -41,6 +43,9 @@ on: - 'global.json' - 'Directory.Build.props' - 'Directory.Build.targets' + - 'src/Directory.Build.*' + - 'test/Directory.Build.*' + - '.editorconfig' - 'nuget.config' # Scripts and CI - 'scripts/device-test.ps1' @@ -70,8 +75,11 @@ jobs: with: submodules: recursive + # The device test app references Sentry.Android.AssemblyReader.Tests, which builds the Android test app - name: Setup Environment uses: ./.github/actions/environment + with: + projects: test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj test/AndroidTestApp/AndroidTestApp.csproj - name: Build Native Dependencies uses: ./.github/actions/buildnative @@ -90,17 +98,16 @@ jobs: needs: [build] name: Run Android API-${{ matrix.api-level }} Test (${{ matrix.tfm }}) - # Requires a "larger runner", for nested virtualization support - runs-on: ubuntu-latest-4-cores + runs-on: ubuntu-latest - strategy: + strategy: &android-emulator-matrix fail-fast: false matrix: tfm: [net10.0, net11.0] # Must be 34+ for new apps and app updates as of August 31, 2024. # See https://apilevels.com/ api-level: [34, 36] - env: + env: &android-emulator-env DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 # We don't need the Google APIs, but the default images are not available for 32+ @@ -157,14 +164,38 @@ jobs: uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: *android-emulator-test + - name: Upload results + if: success() || failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: device-test-android-${{ matrix.api-level }}-${{ matrix.tfm }}-results + path: test_output + + # The integration tests build and run their own app, so they run next to the device tests instead of after them + android-integration-tests: + name: Run Android API-${{ matrix.api-level }} Integration Test (${{ matrix.tfm }}) + + runs-on: ubuntu-latest + + strategy: *android-emulator-matrix + env: *android-emulator-env + steps: + # See https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/ + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + - name: Setup Environment uses: ./.github/actions/environment - - - name: Select Java 21 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: - distribution: temurin - java-version: '21' + projects: integration-test/maui-device/Sentry.Maui.Device.IntegrationTestApp.csproj - name: Checkout github-workflows uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -199,7 +230,6 @@ jobs: if: success() || failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: device-test-android-${{ matrix.api-level }}-${{ matrix.tfm }}-results - path: | - test_output - integration-test/mobile-app/test_output + name: device-test-android-integration-${{ matrix.api-level }}-${{ matrix.tfm }}-results + path: integration-test/mobile-app/test_output + if-no-files-found: ignore diff --git a/.github/workflows/device-tests-ios.yml b/.github/workflows/device-tests-ios.yml index 8236aaed40..8b3d652aa8 100644 --- a/.github/workflows/device-tests-ios.yml +++ b/.github/workflows/device-tests-ios.yml @@ -11,6 +11,7 @@ on: - 'src/Sentry/**' - 'src/Sentry.Extensions.Logging/**' - 'src/Sentry.Compiler.Extensions/**' + - 'src/Sentry.Analyzers/**' # Mobile-specific packages - 'src/Sentry.Maui/**' - 'src/Sentry.Maui.CommunityToolkit.Mvvm/**' @@ -25,6 +26,7 @@ on: - 'test/Sentry.Maui.CommunityToolkit.Mvvm.Tests/**' - 'test/Sentry.Android.AssemblyReader.Tests/**' - 'test/Sentry.Testing/**' + - 'test/Sentry.Testing.CrashableApp/**' - 'test/AndroidTestApp/**' # Integration tests - 'integration-test/android.Tests.ps1' @@ -41,6 +43,9 @@ on: - 'global.json' - 'Directory.Build.props' - 'Directory.Build.targets' + - 'src/Directory.Build.*' + - 'test/Directory.Build.*' + - '.editorconfig' - 'nuget.config' # Scripts and CI - 'scripts/device-test.ps1' @@ -69,6 +74,20 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj + + # See build.yml + - name: Get Cocoa SDK cache key + id: cocoa-key + uses: ./.github/actions/cocoa-cache-key + + - name: Restore Cocoa SDK + id: cache-cocoa + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cocoa-key.outputs.key }} # See build.yml - name: Get Cocoa SDK cache key @@ -103,6 +122,54 @@ jobs: timeout-minutes: 40 run: pwsh scripts/device-test.ps1 ios -Run -Verbose + - name: Upload results + if: success() || failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: device-test-ios-results + path: test_output + + # The integration tests build and run their own app, so they run next to the device tests instead of after them + ios-integration-tests: + runs-on: xcode-27 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + NO_ANDROID: true + NO_MACCATALYST: true + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Setup Environment + uses: ./.github/actions/environment + with: + projects: integration-test/maui-device/Sentry.Maui.Device.IntegrationTestApp.csproj + + - name: Get Cocoa SDK cache key + id: cocoa-key + uses: ./.github/actions/cocoa-cache-key + + - name: Restore Cocoa SDK + id: cache-cocoa + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cocoa-key.outputs.key }} + + # ios.Tests.ps1 skips every case unless a simulator is already booted. In the ios-tests job, xharness boots it. + - name: Boot the iOS Simulator + timeout-minutes: 10 + shell: pwsh + run: | + . ./scripts/device-test-utils.ps1 + $udid = Get-IosSimulatorUdid -Verbose + if (-not $udid) { throw 'No iOS simulator found' } + xcrun simctl bootstatus $udid -b + if ($LASTEXITCODE -ne 0) { throw "Couldn't boot simulator $udid" } + - name: Run Integration Tests id: first-integration-test-run continue-on-error: true @@ -118,11 +185,9 @@ jobs: with: path: integration-test/ios.Tests.ps1 - - name: Upload results - if: success() || failure() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + - name: Save Cocoa SDK + if: ${{ !cancelled() && steps.cache-cocoa.outputs.cache-hit != 'true' && hashFiles('modules/sentry-cocoa/Carthage/.built-from-sha') != '' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: device-test-ios-results - path: | - test_output - integration-test/mobile-app/test_output + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cache-cocoa.outputs.cache-primary-key }} diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 77b8783e20..93362f5904 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -7,15 +7,14 @@ on: - 'samples/**' - '.github/workflows/format-code.yml' -# Serialize with verify-api.yml so the two auto-commit workflows can't race -# each other's git push on the same PR branch. -concurrency: - group: pr-auto-commit-${{ github.event.pull_request.number }} - cancel-in-progress: false - jobs: format-code: name: Format Code + # Serialize with the commit job in verify-api.yml so the two auto-commit jobs can't race each + # other's git push on the same PR branch. + concurrency: + group: pr-auto-commit-${{ github.event.pull_request.number }} + cancel-in-progress: false # Run on 'macos' because Linux is missing the `ios` workload: https://github.com/dotnet/runtime/issues/85505 # Pin macos to get the version of XCode that we need: https://github.com/actions/runner-images/issues/10703 runs-on: xcode-27 @@ -29,12 +28,11 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: Sentry.slnx - name: Restore .NET Dependencies - # We should be able to get rid of the restore here, if we install the correct workloads in actions/environment - run: | - dotnet workload restore - dotnet restore Sentry.slnx --nologo + run: dotnet restore Sentry.slnx --nologo - name: Format Code # We exclude `./**/*OptionsSetup.cs` from the format because the tool struggles with diff --git a/.github/workflows/playwright-blazor-wasm.yml b/.github/workflows/playwright-blazor-wasm.yml index ce4d6305b5..f7cd51dd8e 100644 --- a/.github/workflows/playwright-blazor-wasm.yml +++ b/.github/workflows/playwright-blazor-wasm.yml @@ -46,7 +46,7 @@ jobs: global-json-file: global.json - name: Install .NET Workloads - run: dotnet workload restore --temp-dir "${{ runner.temp }}" --skip-sign-check + run: dotnet workload restore test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests.csproj --temp-dir "${{ runner.temp }}" --skip-sign-check - name: Build run: dotnet build test/Sentry.AspNetCore.Blazor.WebAssembly.PlaywrightTests -c Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47a01839e3..454ad20ef7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release ${{ github.event.inputs.version }} - uses: getsentry/craft@3f2abdc5703191d12ef2cc013f792f1bd220f44d # 2.34.1 + uses: getsentry/craft@v3 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/verify-api.yml b/.github/workflows/verify-api.yml index 7b08108c8f..2fe5e70d45 100644 --- a/.github/workflows/verify-api.yml +++ b/.github/workflows/verify-api.yml @@ -7,12 +7,6 @@ on: - 'test/Sentry.Testing/ApiExtensions.cs' - '.github/workflows/verify-api.yml' -# Serialize with format-code.yml so the two auto-commit workflows can't race -# each other's git push on the same PR branch. -concurrency: - group: pr-auto-commit-${{ github.event.pull_request.number }} - cancel-in-progress: false - jobs: run-api-tests: name: Run API Approval Tests (${{ matrix.rid }}) @@ -50,6 +44,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: ${{ matrix.slnf }} - name: Restore sentry-native cache id: cache-native @@ -131,6 +127,11 @@ jobs: needs: run-api-tests runs-on: ubuntu-24.04 if: github.event.pull_request.head.repo.full_name == github.repository + # Serialize with format-code.yml so the two auto-commit jobs can't race each other's git push + # on the same PR branch. Only the pushing job is in the group, so the API tests don't wait. + concurrency: + group: pr-auto-commit-${{ github.event.pull_request.number }} + cancel-in-progress: false permissions: contents: write pull-requests: write diff --git a/.github/workflows/vulnerabilities.yml b/.github/workflows/vulnerabilities.yml index 27407afa7c..0b0ffc4d9d 100644 --- a/.github/workflows/vulnerabilities.yml +++ b/.github/workflows/vulnerabilities.yml @@ -28,6 +28,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: Sentry.slnx # We only need to restore to check for vulnerable packages - name: Restore .NET Dependencies diff --git a/AGENTS.md b/AGENTS.md index c1ffbbead1..1f2f27aeb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,9 +38,13 @@ Use solution filters instead of the full `Sentry.slnx`: | Filter | Use when | |--------|----------| | `SentryNoMobile.slnf` | General development (no Android/iOS build toolchain needed for quicker builds) | -| `Sentry-CI-Build-macOS.slnf` | All macOS projects | -| `Sentry-CI-Build-Windows.slnf` | All Windows projects | -| `Sentry-CI-Build-Linux.slnf` | All Linux projects | +| `Sentry-CI-Build-macOS.slnf` | All macOS projects except the app samples | +| `Sentry-CI-Build-Windows.slnf` | All Windows projects except the app samples | +| `Sentry-CI-Build-Linux.slnf` | All Linux projects except the app samples | +| `Sentry-CI-Build-Samples-macOS.slnf` | The MAUI, Android, iOS, Mac Catalyst and macOS sample apps | +| `Sentry-CI-Build-Samples-Linux.slnf` | The MAUI and Android sample apps, built on Linux | +| `Sentry-CI-IntegrationTest-macOS.slnf` | The packages the integration tests use, on macOS | +| `Sentry-CI-IntegrationTest.slnf` | The packages the integration tests use, on Linux and Windows | > Do **not** edit `*.slnf` files directly. They are generated by `scripts/generate-solution-filters.ps1` from `scripts/generate-solution-filters-config.yaml`. diff --git a/Directory.Build.props b/Directory.Build.props index 13e3ad85c6..52be63cad7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -123,7 +123,7 @@ - 3.8.0 + 3.8.2 $(MSBuildThisFileDirectory)tools\sentry-cli\$(SentryCLIVersion)\ diff --git a/README.md b/README.md index b36c7cdb8d..24b6e21542 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,12 @@ Sentry SDK for .NET | **Sentry.OpenTelemetry** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![NuGet](https://img.shields.io/nuget/v/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry/) | | **Sentry.OpenTelemetry.Exporter** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/v/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry-otlp/) | | **Sentry.Profiling** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/profiling/) | +| **Sentry.Quartz** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/crons/quartz/) | +| **Sentry.Quartz3** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Quartz3.svg)](https://www.nuget.org/packages/Sentry.Quartz3) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Quartz3.svg)](https://www.nuget.org/packages/Sentry.Quartz3) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Quartz3.svg)](https://www.nuget.org/packages/Sentry.Quartz3) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/crons/quartz/) | | **Sentry.Serilog** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Serilog.svg)](https://www.nuget.org/packages/Serilog) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/guides/serilog) | +Use Sentry.Quartz3 with Quartz.NET 3.x (3.6.0 or later) and Sentry.Quartz with Quartz.NET 4.x. + ## More Sentry .NET Integrations Sentry offers other integrations that are not part of this repository: diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index 1e150ccacc..5de7a9b445 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -33,6 +33,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.AspNetCore.Blazor.WebAssembly\\Sentry.AspNetCore.Blazor.WebAssembly.csproj", @@ -51,6 +52,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -73,6 +76,8 @@ "test\\Sentry.OpenTelemetry.Exporter.Tests\\Sentry.OpenTelemetry.Exporter.Tests.csproj", "test\\Sentry.OpenTelemetry.Tests\\Sentry.OpenTelemetry.Tests.csproj", "test\\Sentry.Profiling.Tests\\Sentry.Profiling.Tests.csproj", + "test\\Sentry.Quartz.Tests\\Sentry.Quartz.Tests.csproj", + "test\\Sentry.Quartz3.Tests\\Sentry.Quartz3.Tests.csproj", "test\\Sentry.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index 51e1b0f221..c42e1076d5 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -4,7 +4,6 @@ "projects": [ "benchmarks\\Sentry.Benchmarks\\Sentry.Benchmarks.csproj", "benchmarks\\Sentry.Extensions.Logging.Benchmarks\\Sentry.Extensions.Logging.Benchmarks.csproj", - "samples\\Sentry.Samples.Android\\Sentry.Samples.Android.csproj", "samples\\Sentry.Samples.AspNetCore.Basic\\Sentry.Samples.AspNetCore.Basic.csproj", "samples\\Sentry.Samples.AspNetCore.Blazor.Server\\Sentry.Samples.AspNetCore.Blazor.Server.csproj", "samples\\Sentry.Samples.AspNetCore.Blazor.Wasm\\Sentry.Samples.AspNetCore.Blazor.Wasm.csproj", @@ -26,7 +25,6 @@ "samples\\Sentry.Samples.GraphQL.Server\\Sentry.Samples.GraphQL.Server.csproj", "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", - "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", @@ -35,6 +33,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -57,6 +56,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -82,6 +83,8 @@ "test\\Sentry.OpenTelemetry.Exporter.Tests\\Sentry.OpenTelemetry.Exporter.Tests.csproj", "test\\Sentry.OpenTelemetry.Tests\\Sentry.OpenTelemetry.Tests.csproj", "test\\Sentry.Profiling.Tests\\Sentry.Profiling.Tests.csproj", + "test\\Sentry.Quartz.Tests\\Sentry.Quartz.Tests.csproj", + "test\\Sentry.Quartz3.Tests\\Sentry.Quartz3.Tests.csproj", "test\\Sentry.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", diff --git a/Sentry-CI-Build-Samples-Linux.slnf b/Sentry-CI-Build-Samples-Linux.slnf new file mode 100644 index 0000000000..9ad22dc7c0 --- /dev/null +++ b/Sentry-CI-Build-Samples-Linux.slnf @@ -0,0 +1,9 @@ +{ + "solution": { + "path": "Sentry.slnx", + "projects": [ + "samples\\Sentry.Samples.Android\\Sentry.Samples.Android.csproj", + "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj" + ] + } +} diff --git a/Sentry-CI-Build-Samples-macOS.slnf b/Sentry-CI-Build-Samples-macOS.slnf new file mode 100644 index 0000000000..ed4fbddcef --- /dev/null +++ b/Sentry-CI-Build-Samples-macOS.slnf @@ -0,0 +1,12 @@ +{ + "solution": { + "path": "Sentry.slnx", + "projects": [ + "samples\\Sentry.Samples.Android\\Sentry.Samples.Android.csproj", + "samples\\Sentry.Samples.Ios\\Sentry.Samples.Ios.csproj", + "samples\\Sentry.Samples.MacCatalyst\\Sentry.Samples.MacCatalyst.csproj", + "samples\\Sentry.Samples.MacOS\\Sentry.Samples.MacOS.csproj", + "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj" + ] + } +} diff --git a/Sentry-CI-Build-Windows-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index e57d3e4d8b..24bf843a16 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -27,7 +27,6 @@ "samples\\Sentry.Samples.GraphQL.Server\\Sentry.Samples.GraphQL.Server.csproj", "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", - "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", @@ -36,6 +35,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -59,6 +59,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -81,6 +83,8 @@ "test\\Sentry.OpenTelemetry.Exporter.Tests\\Sentry.OpenTelemetry.Exporter.Tests.csproj", "test\\Sentry.OpenTelemetry.Tests\\Sentry.OpenTelemetry.Tests.csproj", "test\\Sentry.Profiling.Tests\\Sentry.Profiling.Tests.csproj", + "test\\Sentry.Quartz.Tests\\Sentry.Quartz.Tests.csproj", + "test\\Sentry.Quartz3.Tests\\Sentry.Quartz3.Tests.csproj", "test\\Sentry.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index 66699079f9..bd93777bb4 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -27,7 +27,6 @@ "samples\\Sentry.Samples.GraphQL.Server\\Sentry.Samples.GraphQL.Server.csproj", "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", - "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", @@ -36,6 +35,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -59,6 +59,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -84,6 +86,8 @@ "test\\Sentry.OpenTelemetry.Exporter.Tests\\Sentry.OpenTelemetry.Exporter.Tests.csproj", "test\\Sentry.OpenTelemetry.Tests\\Sentry.OpenTelemetry.Tests.csproj", "test\\Sentry.Profiling.Tests\\Sentry.Profiling.Tests.csproj", + "test\\Sentry.Quartz.Tests\\Sentry.Quartz.Tests.csproj", + "test\\Sentry.Quartz3.Tests\\Sentry.Quartz3.Tests.csproj", "test\\Sentry.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index 5a418b6c8c..418ece7c29 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -6,7 +6,6 @@ "benchmarks\\Sentry.Extensions.Logging.Benchmarks\\Sentry.Extensions.Logging.Benchmarks.csproj", "modules\\perfview\\src\\FastSerialization\\FastSerialization.csproj", "modules\\perfview\\src\\TraceEvent\\TraceEvent.csproj", - "samples\\Sentry.Samples.Android\\Sentry.Samples.Android.csproj", "samples\\Sentry.Samples.AspNetCore.Basic\\Sentry.Samples.AspNetCore.Basic.csproj", "samples\\Sentry.Samples.AspNetCore.Blazor.Server\\Sentry.Samples.AspNetCore.Blazor.Server.csproj", "samples\\Sentry.Samples.AspNetCore.Blazor.Wasm\\Sentry.Samples.AspNetCore.Blazor.Wasm.csproj", @@ -27,11 +26,7 @@ "samples\\Sentry.Samples.GraphQL.Client.Http\\Sentry.Samples.GraphQL.Client.Http.csproj", "samples\\Sentry.Samples.GraphQL.Server\\Sentry.Samples.GraphQL.Server.csproj", "samples\\Sentry.Samples.Hangfire\\Sentry.Samples.Hangfire.csproj", - "samples\\Sentry.Samples.Ios\\Sentry.Samples.Ios.csproj", "samples\\Sentry.Samples.Log4Net\\Sentry.Samples.Log4Net.csproj", - "samples\\Sentry.Samples.MacCatalyst\\Sentry.Samples.MacCatalyst.csproj", - "samples\\Sentry.Samples.MacOS\\Sentry.Samples.MacOS.csproj", - "samples\\Sentry.Samples.Maui\\Sentry.Samples.Maui.csproj", "samples\\Sentry.Samples.ME.AI.AspNetCore\\Sentry.Samples.ME.AI.AspNetCore.csproj", "samples\\Sentry.Samples.ME.AI.Console\\Sentry.Samples.ME.AI.Console.csproj", "samples\\Sentry.Samples.ME.Logging\\Sentry.Samples.ME.Logging.csproj", @@ -40,6 +35,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -64,6 +60,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -84,12 +82,13 @@ "test\\Sentry.Log4Net.Tests\\Sentry.Log4Net.Tests.csproj", "test\\Sentry.Log4Net.V3.Tests\\Sentry.Log4Net.V3.Tests.csproj", "test\\Sentry.Maui.CommunityToolkit.Mvvm.Tests\\Sentry.Maui.CommunityToolkit.Mvvm.Tests.csproj", - "test\\Sentry.Maui.Device.TestApp\\Sentry.Maui.Device.TestApp.csproj", "test\\Sentry.Maui.Tests\\Sentry.Maui.Tests.csproj", "test\\Sentry.NLog.Tests\\Sentry.NLog.Tests.csproj", "test\\Sentry.OpenTelemetry.Exporter.Tests\\Sentry.OpenTelemetry.Exporter.Tests.csproj", "test\\Sentry.OpenTelemetry.Tests\\Sentry.OpenTelemetry.Tests.csproj", "test\\Sentry.Profiling.Tests\\Sentry.Profiling.Tests.csproj", + "test\\Sentry.Quartz.Tests\\Sentry.Quartz.Tests.csproj", + "test\\Sentry.Quartz3.Tests\\Sentry.Quartz3.Tests.csproj", "test\\Sentry.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", diff --git a/Sentry-CI-CodeQL.slnf b/Sentry-CI-CodeQL.slnf index 1ef50dd3e2..ac22553e2b 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -23,6 +23,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj" ] diff --git a/Sentry-CI-IntegrationTest-macOS.slnf b/Sentry-CI-IntegrationTest-macOS.slnf new file mode 100644 index 0000000000..d8f0de1480 --- /dev/null +++ b/Sentry-CI-IntegrationTest-macOS.slnf @@ -0,0 +1,13 @@ +{ + "solution": { + "path": "Sentry.slnx", + "projects": [ + "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", + "src\\Sentry.Bindings.Android\\Sentry.Bindings.Android.csproj", + "src\\Sentry.Bindings.Cocoa\\Sentry.Bindings.Cocoa.csproj", + "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", + "src\\Sentry.Maui\\Sentry.Maui.csproj", + "src\\Sentry\\Sentry.csproj" + ] + } +} diff --git a/Sentry-CI-IntegrationTest.slnf b/Sentry-CI-IntegrationTest.slnf new file mode 100644 index 0000000000..5df62f2751 --- /dev/null +++ b/Sentry-CI-IntegrationTest.slnf @@ -0,0 +1,12 @@ +{ + "solution": { + "path": "Sentry.slnx", + "projects": [ + "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", + "src\\Sentry.Bindings.Android\\Sentry.Bindings.Android.csproj", + "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", + "src\\Sentry.Maui\\Sentry.Maui.csproj", + "src\\Sentry\\Sentry.csproj" + ] + } +} diff --git a/Sentry.slnx b/Sentry.slnx index 4742240f65..a81cc01fc8 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -119,14 +119,13 @@ + - - @@ -170,6 +169,8 @@ + + @@ -205,6 +206,8 @@ + + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index b64b079722..bc2f9571bc 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -22,6 +22,8 @@ "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index 9e2060f47b..594f235c8f 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -34,6 +34,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.AspNet\\Sentry.AspNet.csproj", @@ -53,6 +54,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -78,6 +81,8 @@ "test\\Sentry.OpenTelemetry.Exporter.Tests\\Sentry.OpenTelemetry.Exporter.Tests.csproj", "test\\Sentry.OpenTelemetry.Tests\\Sentry.OpenTelemetry.Tests.csproj", "test\\Sentry.Profiling.Tests\\Sentry.Profiling.Tests.csproj", + "test\\Sentry.Quartz.Tests\\Sentry.Quartz.Tests.csproj", + "test\\Sentry.Quartz3.Tests\\Sentry.Quartz3.Tests.csproj", "test\\Sentry.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", diff --git a/SentryNoSamples.slnf b/SentryNoSamples.slnf index 086c2c3d94..ade8a90767 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -25,6 +25,8 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", + "src\\Sentry.Quartz3\\Sentry.Quartz3.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -52,6 +54,8 @@ "test\\Sentry.OpenTelemetry.Exporter.Tests\\Sentry.OpenTelemetry.Exporter.Tests.csproj", "test\\Sentry.OpenTelemetry.Tests\\Sentry.OpenTelemetry.Tests.csproj", "test\\Sentry.Profiling.Tests\\Sentry.Profiling.Tests.csproj", + "test\\Sentry.Quartz.Tests\\Sentry.Quartz.Tests.csproj", + "test\\Sentry.Quartz3.Tests\\Sentry.Quartz3.Tests.csproj", "test\\Sentry.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", diff --git a/integration-test/cli-maui.Tests.ps1 b/integration-test/cli-maui.Tests.ps1 new file mode 100644 index 0000000000..d366bed70e --- /dev/null +++ b/integration-test/cli-maui.Tests.ps1 @@ -0,0 +1,166 @@ +# This file contains test cases for https://pester.dev/ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. $PSScriptRoot/common.ps1 + +Describe 'MAUI ()' -ForEach @( + # Each .NET for iOS SDK pins an Xcode version. Our CI builds use a runner matching + # the latest. Only app builds are affected (not libraries/packages). + @{ framework = $latestFramework } +) -Skip:($env:NO_MOBILE -eq "true" -or $env:SKIP_MAUI_TESTS -eq "true") { + BeforeAll { + ResetLocalPackages + RegisterLocalPackage 'Sentry.Android.AssemblyReader' + RegisterLocalPackage 'Sentry.Bindings.Android' + RegisterLocalPackage 'Sentry.Extensions.Logging' + RegisterLocalPackage 'Sentry.Maui' + if ($IsMacOS) + { + RegisterLocalPackage 'Sentry.Bindings.Cocoa' + } + + $name = 'maui-app' + $androidTpv = GetAndroidTpv $framework + $iosTpv = GetIosTpv $framework + + DotnetNew 'maui' $name $framework + + # Workaround for the missing "ios" workload on Linux, see https://github.com/dotnet/maui/pull/18580 + $tfs = $IsMacos ? "$framework-android$androidTpv;$framework-ios$iosTpv;$framework-maccatalyst$iosTpv" : "$framework-android$androidTpv" + (Get-Content $name/$name.csproj) -replace '[^<]+', "$tfs" | Set-Content $name/$name.csproj + + Push-Location $name + try + { + dotnet remove "${name}.csproj" package 'Microsoft.Extensions.Logging.Debug' | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) + { + throw "Failed to remove package" + } + } + finally + { + Pop-Location + } + + if (Test-Path env:CI) + { + dotnet workload restore $name/$name.csproj | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) + { + throw "Failed to restore workloads." + } + } + + 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" { + $result = RunDotnetWithSentryCLI 'build' 'maui-app' $True $True "$framework-android$androidTpv" + Write-Host "UploadedDebugFiles: $($result.UploadedDebugFiles() | Out-String)" + $result.UploadedDebugFiles() | Sort-Object -Unique | Should -Be @( + '/proguard/a5fb4278-bcb5-4464-8585-d811dc3c3959.txt', + 'libsentry-android.so', + 'libsentry.so', + 'libsentrysupplemental.so', + # .NET 11 splits the debug symbols into a separate .dbg.so + 'libxamarin-app.dbg.so', + 'libxamarin-app.so', + 'maui-app.pdb' + ) + # The exact count differs per SDK version (went from 23 to 25 in .NET 11) + # So only assert it's non-zero (same as we do for iOS) + $nonZeroNumberRegex = '[1-9][0-9]*'; + $result.ScriptOutput | Should -AnyElementMatch "Found $nonZeroNumberRegex debug information files \($nonZeroNumberRegex with embedded sources\)" + } + + It "uploads symbols and sources for an iOS build" -Skip:(!$IsMacOS) { + $result = RunDotnetWithSentryCLI 'build' 'maui-app' $True $True "$framework-ios$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', + 'Microsoft.iOS.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-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/cli.Tests.ps1 b/integration-test/cli.Tests.ps1 index f40defbe59..cc7c523410 100644 --- a/integration-test/cli.Tests.ps1 +++ b/integration-test/cli.Tests.ps1 @@ -254,165 +254,3 @@ Describe 'Console apps () - native AOT publish' -ForEach @( $result.UploadedDebugFiles() | Should -BeNullOrEmpty } } - -Describe 'MAUI ()' -ForEach @( - # Each .NET for iOS SDK pins an Xcode version. Our CI builds use a runner matching - # the latest. Only app builds are affected (not libraries/packages). - @{ framework = $latestFramework } -) -Skip:($env:NO_MOBILE -eq "true") { - BeforeAll { - ResetLocalPackages - RegisterLocalPackage 'Sentry.Android.AssemblyReader' - RegisterLocalPackage 'Sentry.Bindings.Android' - RegisterLocalPackage 'Sentry.Extensions.Logging' - RegisterLocalPackage 'Sentry.Maui' - if ($IsMacOS) - { - RegisterLocalPackage 'Sentry.Bindings.Cocoa' - } - - $name = 'maui-app' - $androidTpv = GetAndroidTpv $framework - $iosTpv = GetIosTpv $framework - - DotnetNew 'maui' $name $framework - - # Workaround for the missing "ios" workload on Linux, see https://github.com/dotnet/maui/pull/18580 - $tfs = $IsMacos ? "$framework-android$androidTpv;$framework-ios$iosTpv;$framework-maccatalyst$iosTpv" : "$framework-android$androidTpv" - (Get-Content $name/$name.csproj) -replace '[^<]+', "$tfs" | Set-Content $name/$name.csproj - - Push-Location $name - try - { - dotnet remove "${name}.csproj" package 'Microsoft.Extensions.Logging.Debug' | ForEach-Object { Write-Host $_ } - if ($LASTEXITCODE -ne 0) - { - throw "Failed to remove package" - } - } - finally - { - Pop-Location - } - - if (Test-Path env:CI) - { - dotnet workload restore $name/$name.csproj | ForEach-Object { Write-Host $_ } - if ($LASTEXITCODE -ne 0) - { - throw "Failed to restore workloads." - } - } - - 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" { - $result = RunDotnetWithSentryCLI 'build' 'maui-app' $True $True "$framework-android$androidTpv" - Write-Host "UploadedDebugFiles: $($result.UploadedDebugFiles() | Out-String)" - $result.UploadedDebugFiles() | Sort-Object -Unique | Should -Be @( - '/proguard/a5fb4278-bcb5-4464-8585-d811dc3c3959.txt', - 'libsentry-android.so', - 'libsentry.so', - 'libsentrysupplemental.so', - # .NET 11 splits the debug symbols into a separate .dbg.so - 'libxamarin-app.dbg.so', - 'libxamarin-app.so', - 'maui-app.pdb' - ) - # The exact count differs per SDK version (went from 23 to 25 in .NET 11) - # So only assert it's non-zero (same as we do for iOS) - $nonZeroNumberRegex = '[1-9][0-9]*'; - $result.ScriptOutput | Should -AnyElementMatch "Found $nonZeroNumberRegex debug information files \($nonZeroNumberRegex with embedded sources\)" - } - - It "uploads symbols and sources for an iOS build" -Skip:(!$IsMacOS) { - $result = RunDotnetWithSentryCLI 'build' 'maui-app' $True $True "$framework-ios$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', - 'Microsoft.iOS.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-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 48c708d3b0..429782d0c2 100644 --- a/integration-test/common.ps1 +++ b/integration-test/common.ps1 @@ -91,7 +91,8 @@ BeforeAll { } # We need to remove the package from cache or it won't re resolved properly - Remove-Item -Path ~/.nuget/packages/$($name.ToLower())/$packageVersion -Recurse -Force -ErrorAction SilentlyContinue + $packagesDir = if ($env:NUGET_PACKAGES) { $env:NUGET_PACKAGES } else { "$HOME/.nuget/packages" } + Remove-Item -Path "$packagesDir/$($name.ToLower())/$packageVersion" -Recurse -Force -ErrorAction SilentlyContinue } function ResetLocalPackages() @@ -110,7 +111,7 @@ BeforeAll { Write-Host "::group::${action}ing $project" try { - dotnet $action $project -flp:logfile=build.log ` + dotnet $action $project ` -c Release ` --nologo ` --framework $TargetFramework ` diff --git a/modules/sentry-cocoa b/modules/sentry-cocoa index 4bb2282661..2f64c77f77 160000 --- a/modules/sentry-cocoa +++ b/modules/sentry-cocoa @@ -1 +1 @@ -Subproject commit 4bb2282661ee6ca2212aa01d19a1214394cd3c45 +Subproject commit 2f64c77f77514acd01780d1aac86ae75b7fccd04 diff --git a/modules/sentry-native b/modules/sentry-native index 740313523e..af9e936791 160000 --- a/modules/sentry-native +++ b/modules/sentry-native @@ -1 +1 @@ -Subproject commit 740313523edd50e3a0cfe2f8f931e893ed7423a5 +Subproject commit af9e9367913edf07f54e4f2f9655ce96af0c1681 diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs new file mode 100644 index 0000000000..e7a55220d7 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Quartz; +using Sentry.Quartz; + +namespace Sentry.Samples.Quartz; + +public static class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.WebHost.UseSentry(); + + builder.Services.AddQuartzHttpApi(); + builder.Services.AddQuartzDashboard(); + + builder.Services.AddQuartz(quartz => + { + // Runs each job in its own scope and captures the exceptions it throws. Jobs with + // [SentryMonitorSlug] also send check-ins, with the trigger's schedule as the monitor config. + quartz.AddSentry(options => + { + options.ConfigureMonitorOptions = (context, monitorOptions) => + { + if (context.JobDetail.Key.Name == nameof(SecondJob)) + { + monitorOptions.FailureIssueThreshold = 10; + monitorOptions.RecoveryThreshold = 10; + } + }; + }); + + var jobKey1 = new JobKey(nameof(FirstJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey1)); + quartz.AddTrigger(opts => opts.ForJob(jobKey1).WithIdentity($"{nameof(FirstJob)}-trigger").WithCronSchedule("0 * * ? * *")); + + var jobKey2 = new JobKey(nameof(SecondJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey2)); + quartz.AddTrigger(opts => opts.ForJob(jobKey2).WithIdentity($"{nameof(SecondJob)}-trigger").WithCronSchedule("0 * * ? * *")); + + var jobKey3 = new JobKey(nameof(ThirdJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey3)); + quartz.AddTrigger(opts => opts.ForJob(jobKey3).WithIdentity($"{nameof(ThirdJob)}-trigger").WithCronSchedule("0 * * ? * *")); + + }).AddQuartzHostedService(); + + var app = builder.Build(); + + // app.UseAuthentication(); + // app.UseAuthorization(); + app.UseAntiforgery(); + app.MapStaticAssets(); + + // No authorization is configured in this sample, so the dashboard is left open. Real + // applications should call RequireAuthorization() with a policy instead of AllowAnonymous(). + app.MapQuartzHttpApi().AllowAnonymous(); + app.MapQuartzDashboard().AllowAnonymous(); + + app.Run(); + } +} + +[SentryMonitorSlug("first-job")] +public class FirstJob : IJob +{ + public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) + { + Console.WriteLine($"Starting to do some heavy work at: {DateTime.Now}"); + await Task.Delay(1000, cancellationToken); + Console.WriteLine($"Finished doing some heavy work at: {DateTime.Now}"); + } +} + +[SentryMonitorSlug("job-that-throws")] +public class SecondJob : IJob +{ + private static readonly Random _random = new Random(); + + public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) + { + Console.WriteLine($"Starting to do some heavy work at: {DateTime.Now}"); + await Task.Delay(1000, cancellationToken); + Console.WriteLine($"Finished doing some heavy work at: {DateTime.Now}"); + if (_random.Next(100) < 10) + { + throw new Exception(); + } + } +} + +// Uses the slug of the job key: "thirdjob" +[SentryMonitorSlug] +public class ThirdJob : IJob +{ + public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) + { + Console.WriteLine($"Starting to do some heavy work at: {DateTime.Now}"); + await Task.Delay(1000, cancellationToken); + Console.WriteLine($"Finished doing some heavy work at: {DateTime.Now}"); + } +} diff --git a/samples/Sentry.Samples.Quartz/Properties/launchSettings.json b/samples/Sentry.Samples.Quartz/Properties/launchSettings.json new file mode 100644 index 0000000000..e667e2ee58 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5138/", + "sslPort": 44386 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "quartz", + "applicationUrl": "http://localhost:5138", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "quartz", + "applicationUrl": "http://localhost:5138", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "quartz", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Sentry.Samples.Quartz/README.md b/samples/Sentry.Samples.Quartz/README.md new file mode 100644 index 0000000000..66811ac5ae --- /dev/null +++ b/samples/Sentry.Samples.Quartz/README.md @@ -0,0 +1,8 @@ +# Sentry for Quartz.NET + +This is a standard ASP.NET Core app with nothing in it but Quartz. +To run this sample: + +```sh +dotnet run +``` diff --git a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj new file mode 100644 index 0000000000..973a202d29 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + + + + + + + + + sentry-sdks + sentry-dotnet + true + true + + + + + + + + diff --git a/samples/Sentry.Samples.Quartz/appsettings.json b/samples/Sentry.Samples.Quartz/appsettings.json new file mode 100644 index 0000000000..462bf7438f --- /dev/null +++ b/samples/Sentry.Samples.Quartz/appsettings.json @@ -0,0 +1,34 @@ +{ + // Example configuration via JSON. Only a subset of the settings below: + // All Sentry settings can also be configured via code or environment variables: + "Sentry": { + // The DSN can also be set via environment variable + // "Dsn": "TODO: Configure your DSN here and uncomment this line", + // Opt-in for payload submission + "MaxRequestBodySize": "Always", + // Sends Cookies, User Id when one is logged on and user IP address to sentry. It's turned off by default. + "SendDefaultPii": true, + // Whether to add System.Diagnostics.Activity data to the event:: + // For more: https://github.com/dotnet/runtime/blob/master/src/libraries/System.Diagnostics.DiagnosticSource/src/ActivityUserGuide.md + "IncludeActivityData": true, + // Record any message with this level or higher as a breadcrumb (default is Information) + "MinimumBreadcrumbLevel": "Information", + // Don't only keep Warnings as Breadcrumb but actually send an event + "MinimumEventLevel": "Warning", + // Send the stack trace of captured messages (e.g: a LogWarning without an exception) + "AttachStackTrace": true, + // The flag below can be used to see the internal logs of the SDK in the applications log (it's off by default) + "Debug": true, + // By default the level is Debug but it can be changed to any level of SentryLevel enum + "DiagnosticLevel": "Error", + "DefaultTags": { + "default-key-in-config": "default-value" + } + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Trace" + } + } +} diff --git a/scripts/bump-version.ps1 b/scripts/bump-version.ps1 deleted file mode 100644 index d8128a3697..0000000000 --- a/scripts/bump-version.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -param([string] $newVersion) - -$utf8NoBomEncoding = New-Object System.Text.UTF8Encoding($False) -function Replace-TextInFile { - param([string] $filePath, [string] $pattern, [string] $replacement) - - $content = [IO.File]::ReadAllText($filePath) - $content = [Text.RegularExpressions.Regex]::Replace($content, $pattern, $replacement) - [IO.File]::WriteAllText($filePath, $content, $utf8NoBomEncoding) -} - -# Version of .NET assemblies: -Replace-TextInFile "$PSScriptRoot/../Directory.Build.props" '(?<=)(.*?)(?=)' $newVersion diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh deleted file mode 100644 index 9e249dd72b..0000000000 --- a/scripts/bump-version.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -set -eux - -# Requires powershell: `brew install powershell` -# craft executes this file by convention, passing the new version as the second argument: -pwsh ./scripts/bump-version.ps1 $2 diff --git a/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index e3eaf125bc..15f8e80279 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -39,6 +39,21 @@ groupConfigs: - "**/*Ios*.csproj" - "**/*MacCatalyst*.csproj" - "**/*Maui*.csproj" + # The build workflow builds these in its own job, off the critical path + appSamples: + - "**/Sentry.Samples.Android.csproj" + - "**/Sentry.Samples.Ios.csproj" + - "**/Sentry.Samples.MacCatalyst.csproj" + - "**/Sentry.Samples.MacOS.csproj" + - "**/Sentry.Samples.Maui.csproj" + # The packages the integration tests use, which the build workflow's integration-test job builds and packs + integrationTestPackages: + - "**/Sentry.csproj" + - "**/Sentry.Extensions.Logging.csproj" + - "**/Sentry.Maui.csproj" + - "**/Sentry.Bindings.Android.csproj" + - "**/Sentry.Android.AssemblyReader.csproj" + - "**/Sentry.Bindings.Cocoa.csproj" filterConfigs: @@ -48,6 +63,7 @@ filterConfigs: - "allProjects" exclude: groups: + - "appSamples" - "macOnly" - "windowsOnly" - "artefacts" @@ -85,12 +101,14 @@ filterConfigs: - "modules/perfview/**/FastSerialization.csproj" exclude: groups: + - "appSamples" - "artefacts" - "trimTests" - "playwrightTests" patterns: - "**/*AndroidTestApp.csproj" - "**/*DeviceTests*.csproj" + - "**/*Sentry.Maui.Device.TestApp.csproj" - "**/*.AspNet.Tests.csproj" - outputPath: Sentry-CI-Build-Windows.slnf @@ -102,6 +120,7 @@ filterConfigs: - "modules/perfview/**/FastSerialization.csproj" exclude: groups: + - "appSamples" - "macOnly" - "artefacts" - "trimTests" @@ -124,6 +143,7 @@ filterConfigs: - "modules/perfview/**/FastSerialization.csproj" exclude: groups: + - "appSamples" - "macOnly" - "artefacts" - "trimTests" @@ -141,6 +161,31 @@ filterConfigs: - "**/*Sentry.DiagnosticSource.Tests.csproj" - "**/*Sentry.EntityFramework.Tests.csproj" + - outputPath: Sentry-CI-Build-Samples-macOS.slnf + include: + groups: + - "appSamples" + + # The app samples the Linux build leg built before they moved to their own job + - outputPath: Sentry-CI-Build-Samples-Linux.slnf + include: + patterns: + - "**/Sentry.Samples.Android.csproj" + - "**/Sentry.Samples.Maui.csproj" + + - outputPath: Sentry-CI-IntegrationTest-macOS.slnf + include: + groups: + - "integrationTestPackages" + + - outputPath: Sentry-CI-IntegrationTest.slnf + include: + groups: + - "integrationTestPackages" + exclude: + groups: + - "macOnly" + - outputPath: Sentry-CI-CodeQL.slnf include: groups: @@ -167,6 +212,8 @@ filterConfigs: - "**/Sentry.csproj" - "**/Sentry.Analyzers.csproj" - "**/Sentry.Hangfire.csproj" + - "**/Sentry.Quartz.csproj" + - "**/Sentry.Quartz3.csproj" - "**/Sentry.Samples.Hangfire.csproj" - "**/Sentry.Testing.csproj" - "**/Sentry.Testing.CrashableApp.csproj" diff --git a/scripts/generate-solution-filters.ps1 b/scripts/generate-solution-filters.ps1 index 6ba5501807..94ba868883 100644 --- a/scripts/generate-solution-filters.ps1 +++ b/scripts/generate-solution-filters.ps1 @@ -14,7 +14,15 @@ while (-not (Get-Module -ListAvailable -Name $yamlModule) -and $retries -gt 0) Start-Sleep -Seconds 10 } Write-Debug "The module '$yamlModule' is not installed. Installing..." - Install-Module -Name $yamlModule -Scope CurrentUser -Force + try + { + Install-Module -Name $yamlModule -Scope CurrentUser -Force + } + catch + { + # $ErrorActionPreference is Stop, so without the catch a failed download ends the script instead of retrying + Write-Warning "Couldn't install '$yamlModule': $_" + } $retries-- } diff --git a/scripts/patch-cocoa-bindings.cs b/scripts/patch-cocoa-bindings.cs index f9d156ddff..2afa6c9e40 100644 --- a/scripts/patch-cocoa-bindings.cs +++ b/scripts/patch-cocoa-bindings.cs @@ -83,6 +83,8 @@ .RemoveProperty("SentryObjCOptions", "BeforeSendWithHint") .RemoveProperty("SentryObjCOptions", "BeforeBreadcrumbWithHint") .RemoveProperty("SentryObjCOptions", "ConfigureProfiling") + // Experimental MetricKit options; bind SentryObjCMetricKitOptions too if .NET ever exposes MetricKit. + .RemoveProperty("SentryObjCExperimentalOptions", "Metrickit") // SentryObjCSDK is both the public entry point and the hybrid-SDK gateway (via `internal`). // Keep the public members the .NET SDK calls plus the `internal` accessor; drop the rest, whose // signatures reference SentryObjC* types we don't whitelist. diff --git a/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj b/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj index cd5c00d74c..5c603681d1 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.61.0 + 8.62.0 $(BaseIntermediateOutputPath)sdks\$(TargetFramework)\Sentry\Android\$(SentryAndroidSdkVersion)\ diff --git a/src/Sentry.Bindings.Cocoa/ApiDefinition.cs b/src/Sentry.Bindings.Cocoa/ApiDefinition.cs index fb657dff8f..cf57715f92 100644 --- a/src/Sentry.Bindings.Cocoa/ApiDefinition.cs +++ b/src/Sentry.Bindings.Cocoa/ApiDefinition.cs @@ -1271,6 +1271,7 @@ interface SentryObjCTraceContext [Internal] interface SentryObjCExperimentalOptions { + // @property (nonatomic) BOOL enableUnhandledCPPExceptionsV2; [Export("enableUnhandledCPPExceptionsV2")] bool EnableUnhandledCPPExceptionsV2 { get; set; } @@ -1805,6 +1806,14 @@ interface SentryObjCInternalSdkApi [Internal] interface SentryObjCInternalSerializerApi { + // -(NSDictionary * _Nonnull)serializeScope:(SentryObjCScope * _Nonnull)scope; + [Export("serializeScope:")] + NSDictionary SerializeScope(SentryObjCScope scope); + + // -(NSDictionary * _Nonnull)serializeDebugMeta:(SentryObjCDebugMeta * _Nonnull)debugMeta; + [Export("serializeDebugMeta:")] + NSDictionary SerializeDebugMeta(SentryObjCDebugMeta debugMeta); + // -(NSDictionary * _Nonnull)serializeEvent:(SentryObjCEvent * _Nonnull)event; [Export("serializeEvent:")] NSDictionary SerializeEvent(SentryObjCEvent @event); diff --git a/src/Sentry.Bindings.Cocoa/StructsAndEnums.cs b/src/Sentry.Bindings.Cocoa/StructsAndEnums.cs index f974ee79a4..6954c0e143 100644 --- a/src/Sentry.Bindings.Cocoa/StructsAndEnums.cs +++ b/src/Sentry.Bindings.Cocoa/StructsAndEnums.cs @@ -121,6 +121,13 @@ internal enum SentryObjCRedactRegionType : long RedactSwiftUI } +[Native] +internal enum SentryObjCMetricKitHangReportingMode : long +{ + Legacy = 0, + FlamegraphAttachment = 1 +} + [Native] internal enum SentryObjCSwizzleMode : long { diff --git a/src/Sentry.Extensions.Logging/SentryLogger.cs b/src/Sentry.Extensions.Logging/SentryLogger.cs index cf0682fca2..f4656a71a4 100644 --- a/src/Sentry.Extensions.Logging/SentryLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryLogger.cs @@ -73,13 +73,13 @@ public void Log( return; } - var message = formatter?.Invoke(state, exception); - if (IsFromSentry() || IsEfExceptionMessage(eventId) || IsFiltered(logLevel, eventId, exception)) { return; } + var message = formatter?.Invoke(state, exception); + if (ShouldCaptureEvent(logLevel)) { var @event = CreateEvent(logLevel, eventId, state, exception, message, CategoryName); @@ -206,7 +206,17 @@ private bool IsFiltered( LogLevel logLevel, EventId eventId, Exception? exception) - => _options.Filters.Any(f => IsFiltered(f, logLevel, eventId, exception)); + { + foreach (var filter in _options.Filters) + { + if (IsFiltered(filter, logLevel, eventId, exception)) + { + return true; + } + } + + return false; + } private bool IsFiltered( ILogEntryFilter filter, diff --git a/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs b/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs index fee90a9359..20738e519d 100644 --- a/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Hangfire/GlobalConfigurationExtensions.cs @@ -20,7 +20,8 @@ public static IGlobalConfiguration UseSentry(this IGlobalConfiguration configura } /// - /// Adds the Sentry filter that captures check-ins for jobs marked with . + /// Adds the Sentry filter that captures check-ins for jobs marked with , + /// or for every recurring job when is enabled. /// /// The Hangfire configuration. /// Configures the Sentry Hangfire integration. @@ -44,10 +45,12 @@ public static IGlobalConfiguration UseSentry(this IGlobalConfiguration configura /// /// /// + /// /// - internal static IGlobalConfiguration UseSentry(this IGlobalConfiguration configuration, IHub hub, IDiagnosticLogger logger) + internal static IGlobalConfiguration UseSentry(this IGlobalConfiguration configuration, IHub hub, IDiagnosticLogger logger, + SentryHangfireOptions? options = null) { - configuration.UseFilter(new SentryServerFilter(hub, logger)); + configuration.UseFilter(new SentryServerFilter(hub, logger, options)); return configuration; } } diff --git a/src/Sentry.Hangfire/Sentry.Hangfire.csproj b/src/Sentry.Hangfire/Sentry.Hangfire.csproj index ff3b319e63..d3c8dfb1aa 100644 --- a/src/Sentry.Hangfire/Sentry.Hangfire.csproj +++ b/src/Sentry.Hangfire/Sentry.Hangfire.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Sentry.Hangfire/SentryHangfireOptions.cs b/src/Sentry.Hangfire/SentryHangfireOptions.cs index fc5419a84d..3d493d6187 100644 --- a/src/Sentry.Hangfire/SentryHangfireOptions.cs +++ b/src/Sentry.Hangfire/SentryHangfireOptions.cs @@ -1,3 +1,5 @@ +using Sentry.Internal; + namespace Sentry.Hangfire; /// @@ -23,4 +25,50 @@ public class SentryHangfireOptions /// /// public bool SendMonitorConfig { get; set; } = true; + + /// + /// When enabled, every recurring job sends check-ins, not only jobs marked with + /// . The monitor slug is the recurring job ID, normalized the way Sentry + /// normalizes monitor slugs: lowercased, accents removed, spaces and dashes turned into -, other characters + /// dropped, and cut to 50 characters. Defaults to false. + /// + /// + /// Recurring jobs whose schedule Sentry can't represent are skipped, and so are recurring jobs that share a monitor + /// slug with another recurring job on a different schedule. lists the schedules + /// Sentry can represent. Jobs marked with keep their monitor slug and + /// still send check-ins. Use to leave out some recurring jobs. + /// + public bool MonitorAllRecurringJobs { get; set; } + + /// + /// Recurring jobs that doesn't send check-ins for. A string matches a + /// recurring job whose ID is equal to it, and a regular expression a recurring job whose ID it matches. + /// + /// + /// Jobs marked with still send check-ins. + /// + public IList ExcludedRecurringJobs { get; set; } = new List(); + + internal string? GetRecurringJobSlug(string recurringJobId) + { + if (!MonitorAllRecurringJobs || IsExcluded(recurringJobId)) + { + return null; + } + + return MonitorSlug.From(recurringJobId); + } + + private bool IsExcluded(string recurringJobId) + { + foreach (var pattern in ExcludedRecurringJobs) + { + if (pattern.Regex is { } regex ? regex.IsMatch(recurringJobId) : pattern.ToString() == recurringJobId) + { + return true; + } + } + + return false; + } } diff --git a/src/Sentry.Hangfire/SentryServerFilter.cs b/src/Sentry.Hangfire/SentryServerFilter.cs index fed803cc24..a7adcf4452 100644 --- a/src/Sentry.Hangfire/SentryServerFilter.cs +++ b/src/Sentry.Hangfire/SentryServerFilter.cs @@ -41,7 +41,7 @@ internal SentryServerFilter(IHub? hub, IDiagnosticLogger? logger, SentryHangfire public void OnPerforming(PerformingContext context) { - var monitorSlug = context.GetJobParameter(SentryMonitorSlugKey); + var (monitorSlug, recurringJobId) = GetMonitorSlug(context); if (monitorSlug is null) { var jobType = context.BackgroundJob.Job.Type; @@ -52,18 +52,53 @@ public void OnPerforming(PerformingContext context) return; } - var checkInId = CaptureInProgressCheckIn(context, monitorSlug); + var monitoredByDefault = recurringJobId is not null; + var schedule = _options.SendMonitorConfig || monitoredByDefault + ? GetRecurringJobSchedule(context, monitorSlug) + : null; + if (monitoredByDefault && schedule is null) + { + _logger?.LogDebug("Skipping creating a check-in for recurring job '{0}'. MonitorAllRecurringJobs only " + + "monitors jobs whose schedule Sentry supports.", recurringJobId); + return; + } + + var checkInId = CaptureInProgressCheckIn(monitorSlug, _options.SendMonitorConfig ? schedule : null); // 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 // This might happen if a Hangfire job failed at least once, with automatic retries configured. context.Items[SentryCheckInIdKey] = checkInId; + context.Items[SentryMonitorSlugKey] = monitorSlug; + } + + // The recurring job ID is set only when MonitorAllRecurringJobs supplies the slug. + private (string? MonitorSlug, string? RecurringJobId) GetMonitorSlug(PerformContext context) + { + var monitorSlug = context.GetJobParameter(SentryMonitorSlugKey); + if (monitorSlug is not null || !_options.MonitorAllRecurringJobs) + { + return (monitorSlug, null); + } + + try + { + var recurringJobId = context.GetJobParameter(RecurringJobIdKey); + return string.IsNullOrEmpty(recurringJobId) || _options.GetRecurringJobSlug(recurringJobId!) is not { } slug + ? (null, null) + : (slug, recurringJobId); + } + catch (Exception e) + { + _logger?.LogError(e, "Failed to read the recurring job ID of job '{0}'.", context.BackgroundJob.Id); + return (null, null); + } } - private SentryId CaptureInProgressCheckIn(PerformingContext context, string monitorSlug) + private SentryId CaptureInProgressCheckIn(string monitorSlug, (string Crontab, string TimeZone)? schedule) { Action? configureMonitorOptions = null; - if (_options.SendMonitorConfig && GetRecurringJobSchedule(context, monitorSlug) is (var crontab, var timeZone)) + if (schedule is (var crontab, var timeZone)) { configureMonitorOptions = options => { @@ -133,7 +168,7 @@ private RecurringJobSlugs GetRecurringJobSlugs(IStorageConnection connection, st if (_recurringJobSlugs is not { } slugs || slugs.ReadAt + RecurringJobSlugsTtl <= now || !slugs.Contains(recurringJobId)) { - slugs = RecurringJobSlugs.Read(connection, now); + slugs = RecurringJobSlugs.Read(connection, now, _options); _recurringJobSlugs = slugs; } @@ -156,14 +191,15 @@ private RecurringJobSlugs(DateTimeOffset readAt, _ids = ids; } - public static RecurringJobSlugs Read(IStorageConnection connection, DateTimeOffset readAt) + public static RecurringJobSlugs Read(IStorageConnection connection, DateTimeOffset readAt, SentryHangfireOptions options) { 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) + if (recurringJob.Removed || recurringJob.Job is null + || (GetMonitorSlug(recurringJob.Job) ?? options.GetRecurringJobSlug(recurringJob.Id)) is not { } slug) { continue; } @@ -295,8 +331,7 @@ static int ToNumber(string value) => public void OnPerformed(PerformedContext context) { - var monitorSlug = context.GetJobParameter(SentryMonitorSlugKey); - if (monitorSlug is null) + if (!context.Items.TryGetValue(SentryMonitorSlugKey, out var monitorSlugObject) || monitorSlugObject is not string monitorSlug) { return; } diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs new file mode 100644 index 0000000000..d2c9698e4d --- /dev/null +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -0,0 +1,405 @@ +using Quartz; +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Internal; + +namespace Sentry.Quartz; + +// Captures the check-ins of jobs with SentryMonitorSlugAttribute, or of every scheduled job with MonitorAllJobs +internal sealed class JobMonitor +{ + // How long the schedules of all monitored triggers are reused before they're read from the scheduler again + internal static readonly TimeSpan TriggerSchedulesTtl = TimeSpan.FromMinutes(1); + + private static readonly ConcurrentDictionary Attributes = new(); + private static readonly SentryMonitorSlugAttribute DefaultAttribute = new(); + + private readonly SentryQuartzOptions _options; + private readonly IHub _hub; + private readonly IDiagnosticLogger? _logger; + private readonly ISystemClock _clock; + private readonly SemaphoreSlim _triggerSchedulesLock = new(1, 1); + private TriggerSchedules? _triggerSchedules; + + public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logger = null, ISystemClock? clock = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _hub = hub; + _logger = logger; + _clock = clock ?? SystemClock.Clock; + } + + internal IDiagnosticLogger? Logger => _logger ?? _hub.GetSentryOptions()?.DiagnosticLogger; + + // Returns null when the job isn't monitored + public async Task StartAsync(IJobExecutionContext context, CancellationToken cancellationToken) + { + string? monitorSlug; + Action? configureMonitorOptions; + try + { + var jobType = QuartzApi.GetJobType(context.JobDetail); + var attribute = GetAttribute(jobType) + ?? (IsMonitoredByDefault(context) ? DefaultAttribute : null); + if (attribute is null) + { + return null; + } + + monitorSlug = ResolveMonitorSlug(context, jobType, attribute); + if (monitorSlug is null) + { + Logger?.LogDebug("Skipping the check-in for job '{0}'. Its name has no characters a monitor slug can use. " + + "Set a monitor slug in its SentryMonitorSlug attribute.", context.JobDetail.Key); + return null; + } + + var monitoredByDefault = ReferenceEquals(attribute, DefaultAttribute); + bool hasNoSchedule; + if (_options.SendMonitorConfig && attribute.SendMonitorConfig) + { + configureMonitorOptions = await GetMonitorConfigAsync(context, monitorSlug, cancellationToken) + .ConfigureAwait(false); + hasNoSchedule = configureMonitorOptions is null; + } + else + { + configureMonitorOptions = null; + hasNoSchedule = monitoredByDefault + && await GetTriggerScheduleAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) is null; + } + + if (monitoredByDefault && hasNoSchedule) + { + Logger?.LogDebug("Skipping the check-in for job '{0}'. MonitorAllJobs only monitors jobs whose " + + "schedule Sentry supports.", context.JobDetail.Key); + return null; + } + } + catch (Exception e) + { + Logger?.LogError(e, "Failed to capture the check-in for job '{0}'.", context.JobDetail.Key); + return null; + } + + // Created here, so the final check-in has the same ID even when this one isn't sent + var checkIn = new JobCheckIn(monitorSlug, SentryId.Create()); + try + { + _hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress, checkIn.Id, configureMonitorOptions: configureMonitorOptions); + } + catch (Exception e) + { + Logger?.LogError(e, "Failed to capture the in-progress check-in for job '{0}'.", context.JobDetail.Key); + } + + return checkIn; + } + + public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) + { + try + { + _hub.CaptureCheckIn(checkIn.MonitorSlug, failed ? CheckInStatus.Error : CheckInStatus.Ok, checkIn.Id, duration); + } + catch (Exception e) + { + Logger?.LogError(e, "Failed to capture the check-in for monitor '{0}'.", checkIn.MonitorSlug); + } + } + + // Returns null when there's no schedule to send + private async Task?> GetMonitorConfigAsync( + IJobExecutionContext context, + string monitorSlug, + CancellationToken cancellationToken) + { + var monitorOptions = new SentryMonitorOptions(); + if (_options.ConfigureMonitorOptions is { } configure) + { + try + { + configure(context, monitorOptions); + } + catch (Exception e) + { + // Don't send whatever the callback set before it threw + monitorOptions = new SentryMonitorOptions(); + Logger?.LogError(e, "ConfigureMonitorOptions threw for job '{0}'.", context.JobDetail.Key); + } + } + + if (!monitorOptions.HasSchedule + && await GetTriggerScheduleAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) is { } schedule) + { + schedule.Apply(monitorOptions); + } + + return monitorOptions.HasSchedule ? monitorOptions.CopyTo : null; + } + + private async Task GetTriggerScheduleAsync( + IJobExecutionContext context, + string monitorSlug, + CancellationToken cancellationToken) + { + var trigger = context.Trigger; + try + { + var schedule = TriggerSchedule.ToSchedule(trigger); + if (schedule is null) + { + Logger?.LogDebug("Not sending the schedule of trigger '{0}'. Sentry can't represent it.", trigger.Key); + return null; + } + + var triggerSchedules = await GetTriggerSchedulesAsync(context.Scheduler, trigger.Key, cancellationToken) + .ConfigureAwait(false); + var conflicting = triggerSchedules.WithDifferentSchedule(monitorSlug, trigger.Key, schedule); + if (conflicting.Count > 0) + { + Logger?.LogDebug("Not sending the schedule of trigger '{0}'. Triggers '{1}' use the same monitor slug " + + "'{2}' with a different schedule. Give each its own monitor slug.", + trigger.Key, string.Join("', '", conflicting), monitorSlug); + return null; + } + + return schedule; + } + catch (Exception e) + { + Logger?.LogError(e, "Failed to read the schedule of trigger '{0}'.", trigger.Key); + return null; + } + } + + private bool IsMonitoredByDefault(IJobExecutionContext context) + { + if (!_options.MonitorAllJobs) + { + return false; + } + + // Only jobs on a schedule Sentry can represent, and whose key stays the same across restarts + var key = context.JobDetail.Key; + if (!HasSentrySchedule(context.Trigger) || !HasIdentity(key)) + { + return false; + } + + if (IsExcluded(key)) + { + Logger?.LogDebug("Skipping the check-in for job '{0}'. It matches ExcludedJobs.", key); + return false; + } + + return true; + } + + // Whether MonitorAllJobs covers the job's scheduled runs + private bool IsMonitoredByDefault(IJobDetail job) => + _options.MonitorAllJobs && HasIdentity(job.Key) && !IsExcluded(job.Key); + + private static bool HasSentrySchedule(ITrigger trigger) => + TriggerSchedule.ToSchedule(trigger) is not null; + + private bool IsExcluded(JobKey key) + { + var fullKey = key.ToString(); + foreach (var pattern in _options.ExcludedJobs) + { + if (pattern._regex is { } regex + ? regex.IsMatch(fullKey) || regex.IsMatch(key.Name) + : pattern._string == fullKey || pattern._string == key.Name) + { + return true; + } + } + + return false; + } + + // Reading every trigger takes several scheduler queries, so the result is reused for a minute. + private async Task GetTriggerSchedulesAsync( + IScheduler scheduler, + TriggerKey triggerKey, + CancellationToken cancellationToken) + { + await _triggerSchedulesLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var now = _clock.GetUtcNow(); + if (_triggerSchedules is not { } schedules || schedules.ReadAt + TriggerSchedulesTtl <= now + || schedules.Scheduler != scheduler || !schedules.Contains(triggerKey)) + { + schedules = await TriggerSchedules.ReadAsync(this, scheduler, now, cancellationToken).ConfigureAwait(false); + _triggerSchedules = schedules; + } + + return schedules; + } + finally + { + _triggerSchedulesLock.Release(); + } + } + + private sealed class TriggerSchedules + { + private readonly Dictionary> _triggersBySlug; + private readonly HashSet _keys; + + public IScheduler Scheduler { get; } + + public DateTimeOffset ReadAt { get; } + + private TriggerSchedules(IScheduler scheduler, DateTimeOffset readAt, + Dictionary> triggersBySlug, HashSet keys) + { + Scheduler = scheduler; + ReadAt = readAt; + _triggersBySlug = triggersBySlug; + _keys = keys; + } + + public static async Task ReadAsync(JobMonitor monitor, IScheduler scheduler, + DateTimeOffset readAt, CancellationToken cancellationToken) + { + var triggersBySlug = new Dictionary>(); + var keys = new HashSet(); + var triggers = await QuartzApi.GetTriggersAsync(scheduler, monitor.IsMonitored, cancellationToken) + .ConfigureAwait(false); + foreach (var (job, trigger) in triggers) + { + keys.Add(trigger.Key); + + // Triggers whose runs aren't monitored, or whose schedule isn't sent, don't change the monitor + var attribute = GetAttribute(QuartzApi.GetJobType(job)) ?? DefaultAttribute; + if (attribute is not { SendMonitorConfig: true } + || TriggerSchedule.ToSchedule(trigger) is not { } schedule + || GetMonitorSlug(job, trigger, attribute) is not { } slug) + { + continue; + } + + if (!triggersBySlug.TryGetValue(slug, out var slugTriggers)) + { + slugTriggers = new List<(TriggerKey Key, MonitorSchedule Schedule)>(); + triggersBySlug[slug] = slugTriggers; + } + slugTriggers.Add((trigger.Key, schedule)); + } + + return new TriggerSchedules(scheduler, readAt, triggersBySlug, keys); + } + + public bool Contains(TriggerKey triggerKey) => _keys.Contains(triggerKey); + + public List WithDifferentSchedule(string monitorSlug, TriggerKey triggerKey, MonitorSchedule schedule) + { + if (!_triggersBySlug.TryGetValue(monitorSlug, out var triggers)) + { + return new List(); + } + + return triggers + .Where(trigger => !trigger.Key.Equals(triggerKey) && trigger.Schedule != schedule) + .Select(trigger => trigger.Key) + .ToList(); + } + } + + // Jobs whose type can't be resolved, such as a deleted job class still in a persistent store, are skipped + private bool IsMonitored(IJobDetail job) + { + try + { + return GetAttribute(QuartzApi.GetJobType(job)) is not null || IsMonitoredByDefault(job); + } + catch (Exception) + { + return false; + } + } + + private static SentryMonitorSlugAttribute? GetAttribute(Type jobType) => + Attributes.GetOrAdd(jobType, type => type.GetCustomAttribute()); + + private string? ResolveMonitorSlug(IJobExecutionContext context, Type jobType, SentryMonitorSlugAttribute attribute) + { + var monitorSlug = GetMonitorSlug(context, attribute, out var fromJobType); + if (monitorSlug is null) + { + return null; + } + + if (fromJobType) + { + Logger?.LogDebug("Job `{0}` has no identity, so its monitor slug is `{1}`. Give the job an identity " + + "(WithIdentity(...)) or set a slug ([SentryMonitorSlug(\"…\")]) to keep the monitor stable.", + jobType.FullName, monitorSlug); + } + + return monitorSlug; + } + + internal static string? GetMonitorSlug(IJobExecutionContext context, SentryMonitorSlugAttribute attribute) => + GetMonitorSlug(context, attribute, out _); + + private static string? GetMonitorSlug( + IJobExecutionContext context, + SentryMonitorSlugAttribute attribute, + out bool fromJobType) + { + context.MergedJobDataMap.TryGetValue(SentryMonitorSlugAttribute.JobDataKey, out var dataMapSlug); + return GetMonitorSlug(context.JobDetail, dataMapSlug, attribute, out fromJobType); + } + + // The slug of a run of the job by the trigger, as its merged JobDataMap would give + private static string? GetMonitorSlug(IJobDetail job, ITrigger trigger, SentryMonitorSlugAttribute attribute) + { + if (!trigger.JobDataMap.TryGetValue(SentryMonitorSlugAttribute.JobDataKey, out var dataMapSlug)) + { + job.JobDataMap.TryGetValue(SentryMonitorSlugAttribute.JobDataKey, out dataMapSlug); + } + + return GetMonitorSlug(job, dataMapSlug, attribute, out _); + } + + private static string? GetMonitorSlug( + IJobDetail job, + object? dataMapValue, + SentryMonitorSlugAttribute attribute, + out bool fromJobType) + { + fromJobType = false; + if (dataMapValue is string dataMapSlug && !string.IsNullOrWhiteSpace(dataMapSlug)) + { + return dataMapSlug; + } + + if (!string.IsNullOrWhiteSpace(attribute.MonitorSlug)) + { + return attribute.MonitorSlug; + } + + var key = job.Key; + if (HasIdentity(key)) + { + return MonitorSlug.From(key.Group == JobKey.DefaultGroup ? key.Name : key.Group + "-" + key.Name); + } + + fromJobType = true; + return MonitorSlug.From(QuartzApi.GetJobType(job).Name); + } + + // Quartz names a job built without an identity with a new GUID + internal static bool HasIdentity(JobKey key) => !Guid.TryParse(key.Name, out _); +} + +internal sealed class JobCheckIn(string monitorSlug, SentryId id) +{ + public string MonitorSlug { get; } = monitorSlug; + + public SentryId Id { get; } = id; +} diff --git a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs new file mode 100644 index 0000000000..10a87a5788 --- /dev/null +++ b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs @@ -0,0 +1,41 @@ +namespace Sentry.Quartz; + +/// +/// Sends a Sentry check-in each time a job of this type runs. +/// +/// +/// +/// Jobs without this attribute are not monitored, unless is enabled. +/// +/// +/// When no monitor slug is given, the slug is the job's group and name joined by a hyphen, normalized the way Sentry +/// normalizes monitor slugs. Jobs in the default group use only the job name: reports.DailyEmail becomes +/// reports-dailyemail, and DEFAULT.Cleanup becomes cleanup. Set in the +/// job's or trigger's JobDataMap to use a different slug for that job or trigger. +/// +/// +/// Quartz names a job built without an identity with a new GUID each time, so such a job uses the name of its class +/// instead. Give the job an identity or a monitor slug to keep its monitor stable. +/// +/// +/// The monitor slug. Defaults to the slug of the job's key. +[AttributeUsage(AttributeTargets.Class)] +public sealed class SentryMonitorSlugAttribute(string? monitorSlug = null) : Attribute +{ + /// + /// The JobDataMap key whose value overrides the monitor slug. + /// + public const string JobDataKey = "SentryMonitorSlug"; + + /// + /// The monitor slug, or null to use the slug of the job's key. + /// + public string? MonitorSlug { get; } = monitorSlug; + + /// + /// When enabled, the in-progress check-in includes the schedule of the trigger that fired the job as the monitor + /// config, so Sentry creates the monitor or updates its schedule. Defaults to true. + /// + /// + public bool SendMonitorConfig { get; set; } = true; +} diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs new file mode 100644 index 0000000000..5bb549f1c1 --- /dev/null +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -0,0 +1,79 @@ +using Quartz; + +namespace Sentry.Quartz; + +/// +/// Options for the Sentry Quartz.NET integration. +/// +public class SentryQuartzOptions +{ + /// + /// When enabled, every job fired by a trigger whose schedule Sentry can represent sends check-ins, as if it had + /// without a monitor slug. Defaults to false. + /// + /// + /// + /// The monitor slug is the slug of the job's key, as described on . Jobs + /// built without an identity are skipped, and so are runs fired by other triggers, such as a job triggered once by + /// hand or a trigger with a calendar. lists the schedules Sentry can represent. + /// Jobs with several triggers on different schedules are skipped, and so are jobs that share a monitor slug with + /// another job on a different schedule. Jobs with keep its settings and + /// still send check-ins. + /// + /// + /// To skip some jobs, add them to . + /// + /// + public bool MonitorAllJobs { get; set; } + + /// + /// Jobs that doesn't monitor. A string matches a job whose name or full key + /// (group.name) is equal to it, and a regular expression a job whose name or full key it matches. + /// + /// + /// Jobs with are monitored even when they match. + /// + public IList ExcludedJobs { get; set; } = new List(); + + /// + /// When enabled, the in-progress check-in of each job with includes + /// the schedule of the trigger that fired the job as the monitor config, so Sentry creates the monitor or updates + /// its schedule. Defaults to true. + /// + /// + /// + /// Only cron triggers and simple triggers that repeat forever at a whole number of minutes, hours or days are + /// sent. Cron expressions are sent when Sentry can represent them exactly: a fixed seconds value, no year, and no + /// L, W or #. Triggers with a calendar are not sent. + /// + /// + /// When several triggers use the same monitor slug with different schedules, for example two triggers of one job, + /// or jobs that share a slug through , no schedule is sent for them. Give + /// each its own monitor slug, for example with in each + /// trigger's JobDataMap, to send it. + /// + /// + /// Time zones that can't be converted to an IANA ID are not sent. On .NET Framework, Windows time zone IDs can't be + /// converted, so only UTC and IANA IDs are sent. + /// + /// + /// To turn this off for one job, set . + /// + /// + public bool SendMonitorConfig { get; set; } = true; + + /// + /// Configures the monitor config of a job, for example its thresholds or . + /// + /// + /// + /// Called with the context of each run before its in-progress check-in, while is + /// enabled. A schedule set here is sent instead of the trigger's, and a time zone set here instead of the + /// trigger's. Exceptions thrown here are logged. + /// + /// + /// The monitor config is only sent when it has a schedule, from the trigger or set here. + /// + /// + public Action? ConfigureMonitorOptions { get; set; } +} diff --git a/src/Sentry.Quartz/Core/TriggerSchedule.cs b/src/Sentry.Quartz/Core/TriggerSchedule.cs new file mode 100644 index 0000000000..1676dd22eb --- /dev/null +++ b/src/Sentry.Quartz/Core/TriggerSchedule.cs @@ -0,0 +1,275 @@ +using Quartz; +using Quartz.Impl.Triggers; + +namespace Sentry.Quartz; + +internal static class TriggerSchedule +{ + private static readonly string[] MonthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + private static readonly string[] DayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; + + // Returns null when Sentry can't represent the trigger's schedule + public static MonitorSchedule? ToSchedule(ITrigger trigger) + { + if (trigger.CalendarName is not null) + { + return null; + } + + switch (trigger) + { + case ICronTrigger cronTrigger: + var crontab = ToCrontab(cronTrigger.CronExpressionString); + var timeZone = ToIanaTimeZoneId(cronTrigger.TimeZone); + return crontab is null || timeZone is null ? null : new MonitorSchedule(crontab, timeZone, 0, default); + case ISimpleTrigger simpleTrigger when ToInterval(simpleTrigger) is (var interval, var unit): + return new MonitorSchedule(null, null, interval, unit); + default: + return null; + } + } + + // Quartz has a seconds field, an optional year field, '?' for an unused day field, and numbers days of the week + // 1 (SUN) to 7 (SAT). Only expressions whose fields mean the same in Sentry's crontab are converted. + internal static string? ToCrontab(string? cronExpression) + { + if (string.IsNullOrWhiteSpace(cronExpression)) + { + return null; + } + + var fields = cronExpression!.ToUpperInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length is < 6 or > 7 + || !TryParseNumber(fields[0], 0, 59, out _) + || (fields.Length == 7 && fields[6] != "*")) + { + return null; + } + + var dayOfMonth = fields[3]; + var dayOfWeek = fields[5]; + if (QuartzApi.UnionsDayFields + ? dayOfMonth == "?" && dayOfWeek == "?" + : (dayOfMonth == "?") == (dayOfWeek == "?")) + { + return null; + } + + // Quartz 4 runs on either day when both day fields restrict. Sentry does too, but only when neither field + // starts with '*', so a '*' with a step is spelled out as a range there. + var bothDaysRestrict = !IsAnyDay(dayOfMonth) && !IsAnyDay(dayOfWeek); + string?[] crontab = + [ + ToCrontabField(fields[1], 0, 59), + ToCrontabField(fields[2], 0, 23), + ToDayField(dayOfMonth, 1, 31, null, 0, bothDaysRestrict), + ToCrontabField(fields[4], 1, 12, MonthNames), + ToDayField(dayOfWeek, 1, 7, DayNames, -1, bothDaysRestrict) + ]; + + return crontab.Any(field => field is null) ? null : string.Join(" ", crontab); + } + + private static bool IsAnyDay(string field) => field is "*" or "?"; + + private static string? ToDayField( + string field, + int min, + int max, + string[]? names, + int offset, + bool spellOutStars) + { + if (IsAnyDay(field)) + { + return "*"; + } + + var crontabField = ToCrontabField(field, min, max, names, offset, spellOutStars); + if (crontabField is null) + { + return null; + } + + // Quartz reads a day field that lists '*' as any day, like a lone '*' + return field.Split(',').Contains("*") ? "*" : crontabField; + } + + private static string? ToCrontabField( + string field, + int min, + int max, + string[]? names = null, + int offset = 0, + bool spellOutStar = false) + { + var terms = field.Split(','); + for (var i = 0; i < terms.Length; i++) + { + if (ToCrontabTerm(terms[i], min, max, names, offset, spellOutStar) is not { } term) + { + return null; + } + + terms[i] = term; + } + + return string.Join(",", terms); + } + + private static string? ToCrontabTerm( + string term, + int min, + int max, + string[]? names, + int offset, + bool spellOutStar) + { + string? step = null; + var slash = term.IndexOf('/'); + if (slash >= 0) + { + if (!TryParseNumber(term.Substring(slash + 1), 1, int.MaxValue, out var stepValue)) + { + return null; + } + + step = "/" + stepValue.ToString(CultureInfo.InvariantCulture); + term = term.Substring(0, slash); + } + + if (term == "*") + { + return spellOutStar && step is not null + ? Format(min + offset) + "-" + Format(max + offset) + step + : "*" + step; + } + + var dash = term.IndexOf('-'); + var startText = dash < 0 ? term : term.Substring(0, dash); + var endText = dash < 0 ? null : term.Substring(dash + 1); + + // Quartz mishandles steps after names, so their meaning is uncertain + var usesNames = !char.IsDigit(startText.FirstOrDefault()) || (endText is not null && !char.IsDigit(endText.FirstOrDefault())); + if (step is not null && usesNames) + { + return null; + } + + if (!TryParseValue(startText, min, max, names, out var start)) + { + return null; + } + + int end; + if (endText is null) + { + if (step is null) + { + return Format(start + offset); + } + + // Quartz's "a/n" runs from a to the end of the range, which Sentry needs written as a range + end = max; + } + else if (!TryParseValue(endText, min, max, names, out end)) + { + return null; + } + + // Quartz wraps ranges whose end comes before their start, and mishandles ranges that start and end on the same value + if (end <= start) + { + return null; + } + + return Format(start + offset) + "-" + Format(end + offset) + step; + } + + private static bool TryParseValue( + string text, + int min, + int max, + string[]? names, + out int value) + { + var nameIndex = names is null ? -1 : Array.IndexOf(names, text); + if (nameIndex >= 0) + { + value = nameIndex + min; + return true; + } + + return TryParseNumber(text, min, max, out value); + } + + private static bool TryParseNumber( + string text, + int min, + int max, + out int value) => + int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value) && value >= min && value <= max; + + private static string Format(int value) => value.ToString(CultureInfo.InvariantCulture); + + internal static (int Interval, SentryMonitorInterval Unit)? ToInterval(ISimpleTrigger trigger) + { + var interval = trigger.RepeatInterval; + if (trigger.RepeatCount != SimpleTriggerImpl.RepeatIndefinitely + || interval <= TimeSpan.Zero + || interval.Ticks % TimeSpan.TicksPerMinute != 0) + { + return null; + } + + var (count, unit) = interval.Ticks switch + { + var ticks when ticks % TimeSpan.TicksPerDay == 0 => (ticks / TimeSpan.TicksPerDay, SentryMonitorInterval.Day), + var ticks when ticks % TimeSpan.TicksPerHour == 0 => (ticks / TimeSpan.TicksPerHour, SentryMonitorInterval.Hour), + var ticks => (ticks / TimeSpan.TicksPerMinute, SentryMonitorInterval.Minute) + }; + + return count <= int.MaxValue ? ((int)count, unit) : null; + } + + internal static string? ToIanaTimeZoneId(TimeZoneInfo? timeZone) + { + if (timeZone is null) + { + return null; + } + + if (timeZone.Id == TimeZoneInfo.Utc.Id) + { + return "UTC"; + } + +#if NET6_0_OR_GREATER + if (timeZone.HasIanaId) + { + return timeZone.Id; + } + + return TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZone.Id, out var ianaId) ? ianaId : null; +#else + // .NET Framework can't convert Windows IDs, so only pass on IDs that look like an IANA ID + return timeZone.Id.Contains("/") ? timeZone.Id : null; +#endif + } +} + +// The schedule sent as a trigger's monitor config +internal sealed record MonitorSchedule(string? Crontab, string? TimeZone, int Interval, SentryMonitorInterval Unit) +{ + public void Apply(SentryMonitorOptions options) + { + if (Crontab is null) + { + options.Interval(Interval, Unit); + return; + } + + options.Interval(Crontab); + options.TimeZone ??= TimeZone; + } +} diff --git a/src/Sentry.Quartz/QuartzApi.cs b/src/Sentry.Quartz/QuartzApi.cs new file mode 100644 index 0000000000..dd0a683a85 --- /dev/null +++ b/src/Sentry.Quartz/QuartzApi.cs @@ -0,0 +1,43 @@ +using Quartz; + +namespace Sentry.Quartz; + +// The Quartz 4 side of the APIs that differ between Quartz 3 and 4 +internal static class QuartzApi +{ + // Quartz 4 reads '*' and '?' alike in the day fields, and runs on either day when both restrict + public static bool UnionsDayFields => true; + + public static Type GetJobType(IJobDetail jobDetail) => jobDetail.JobType.Type; + + // Returns the triggers of every job that matches the predicate + public static async Task> GetTriggersAsync( + IScheduler scheduler, + Func predicate, + CancellationToken cancellationToken) + { + var headers = await scheduler.QueryTriggers(new TriggerQuery { Take = PagedQuery.All }, cancellationToken) + .ConfigureAwait(false); + var jobKeys = headers.Items.Select(header => header.JobKey).Distinct().ToList(); + var jobs = (await scheduler.GetJobDetails(jobKeys, cancellationToken).ConfigureAwait(false)) + .Where(predicate) + .ToDictionary(job => job.Key); + + var triggerKeys = headers.Items.Where(header => jobs.ContainsKey(header.JobKey)).Select(header => header.Key).ToList(); + if (triggerKeys.Count == 0) + { + return []; + } + + var result = new List<(IJobDetail Job, ITrigger Trigger)>(); + foreach (var trigger in await scheduler.GetTriggers(triggerKeys, cancellationToken).ConfigureAwait(false)) + { + if (jobs.TryGetValue(trigger.JobKey, out var job)) + { + result.Add((job, trigger)); + } + } + + return result; + } +} diff --git a/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs new file mode 100644 index 0000000000..119470359d --- /dev/null +++ b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Options; +using Quartz; +using Sentry.Extensions.Logging; + +namespace Sentry.Quartz; + +// Quartz's ErrorLogger logs each exception a job throws again, wrapped in a JobExecutionProcessException. +// SentryScopeMiddleware already captured it, and the default DeduplicateMode doesn't look at inner exceptions. +// Registered for every options type, and only acts on SentryLoggingOptions, such as SentryAspNetCoreOptions. +internal sealed class QuartzErrorLogFilterSetup : IConfigureOptions + where TOptions : class +{ + // SchedulerErrorForFire in https://github.com/quartznet/quartznet/blob/v4.0.0/src/Quartz/Core/CoreLog.cs + internal const string Category = "Quartz.Core.ErrorLogger"; + internal const int EventId = 1081; + + public void Configure(TOptions options) => + (options as SentryLoggingOptions)?.AddLogEntryFilter((category, _, eventId, exception) => + eventId.Id == EventId + && string.Equals(category, Category, StringComparison.Ordinal) + && exception is JobExecutionProcessException { InnerException: { } jobException } + && SentryScopeMiddleware.WasCaptured(jobException)); +} diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj new file mode 100644 index 0000000000..9280dcf62f --- /dev/null +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -0,0 +1,24 @@ + + + + Official Quartz.NET 4.x integration for Sentry - Open-source error tracking that helps developers monitor and fix crashes in real time. + $(PackageTags);Quartz;Crons + + net10.0 + + + + true + + + + + + + + + + + + + diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs new file mode 100644 index 0000000000..e0434ecd48 --- /dev/null +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -0,0 +1,30 @@ +using Quartz; + +namespace Sentry.Quartz; + +internal sealed class SentryCronJobMiddleware(JobMonitor monitor) : IJobExecutionMiddleware +{ + public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + { + var checkIn = await monitor.StartAsync(context, cancellationToken).ConfigureAwait(false); + if (checkIn is null) + { + await next(context, cancellationToken).ConfigureAwait(false); + return; + } + + var stopwatch = Stopwatch.StartNew(); + try + { + await next(context, cancellationToken).ConfigureAwait(false); + } + catch + { + monitor.Finish(checkIn, failed: true, stopwatch.Elapsed); + throw; + } + + // A job can stop early when cancelled without throwing + monitor.Finish(checkIn, failed: cancellationToken.IsCancellationRequested, stopwatch.Elapsed); + } +} diff --git a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs new file mode 100644 index 0000000000..f873087e0b --- /dev/null +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Quartz; +using Sentry.Extensibility; + +namespace Sentry.Quartz; + +/// +/// Sentry extensions for . +/// +public static class SentryQuartzBuilderExtensions +{ + extension(IQuartzBuilder builder) + { + /// + /// Adds Sentry to the scheduler's job execution pipeline. + /// + /// + /// + /// Each job runs in its own Sentry scope and trace, tagged with the job's key, unless global mode is enabled. + /// Exceptions that jobs throw are captured, and Sentry's logging integration skips Quartz's error log entry for + /// the same exception. + /// + /// + /// Jobs with also send a check-in each time they run, with the + /// schedule of the trigger that fired them as the monitor config. A job that throws or is cancelled sends an + /// error check-in. See . To monitor every scheduled job, + /// enable . + /// + /// + /// Configures the options. + /// The builder. + public IQuartzBuilder AddSentry(Action? configure = null) + { + var options = new SentryQuartzOptions(); + configure?.Invoke(options); + return builder.AddSentry(options, HubAdapter.Instance); + } + + internal IQuartzBuilder AddSentry(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logger = null) + { + // Middleware runs outermost first, so check-ins are captured in the job's scope and trace + builder.AddJobMiddleware(new SentryScopeMiddleware(hub)); + builder.AddJobMiddleware(new SentryCronJobMiddleware(new JobMonitor(options, hub, logger))); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton(typeof(IConfigureOptions<>), typeof(QuartzErrorLogFilterSetup<>))); + return builder; + } + } +} diff --git a/src/Sentry.Quartz/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs new file mode 100644 index 0000000000..1380d97043 --- /dev/null +++ b/src/Sentry.Quartz/SentryScopeMiddleware.cs @@ -0,0 +1,49 @@ +using Quartz; + +namespace Sentry.Quartz; + +// Runs each job in its own scope and trace, and captures what the job throws inside it +internal sealed class SentryScopeMiddleware(IHub hub, SentryOptions? options = null) : IJobExecutionMiddleware +{ + internal const string JobTag = "quartz.job"; + internal const string MechanismType = "Quartz"; + + // The exceptions captured here, so the log entry Quartz writes for them can be dropped + private static readonly ConditionalWeakTable CapturedExceptions = new(); + + internal static bool WasCaptured(Exception exception) => CapturedExceptions.TryGetValue(exception, out _); + + public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + { + // Jobs share the one scope in global mode, so leave it alone + if ((options ?? hub.GetSentryOptions())?.IsGlobalModeEnabled is true) + { + await InvokeAndCapture(context, next, cancellationToken).ConfigureAwait(false); + return; + } + + using var _ = hub.PushScope(); + hub.ConfigureScope(static (scope, context) => + { + scope.SetPropagationContext(new SentryPropagationContext()); + scope.SetTag(JobTag, context.JobDetail.Key.ToString()); + }, context); + + await InvokeAndCapture(context, next, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask InvokeAndCapture(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + { + try + { + await next(context, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + e.SetSentryMechanism(MechanismType, handled: false, terminal: false); + hub.CaptureException(e); + CapturedExceptions.AddOrUpdate(e, null); + throw; + } + } +} diff --git a/src/Sentry.Quartz3/QuartzApi.cs b/src/Sentry.Quartz3/QuartzApi.cs new file mode 100644 index 0000000000..50b175e41c --- /dev/null +++ b/src/Sentry.Quartz3/QuartzApi.cs @@ -0,0 +1,40 @@ +using Quartz; +using Quartz.Impl.Matchers; + +namespace Sentry.Quartz; + +// The Quartz 3 side of the APIs that differ between Quartz 3 and 4 +internal static class QuartzApi +{ + // Quartz 3 needs exactly one day field to be '?', and the other decides + public static bool UnionsDayFields => false; + + public static Type GetJobType(IJobDetail jobDetail) => jobDetail.JobType; + + // Returns the triggers of every job that matches the predicate. Quartz 3 has no bulk lookups, so this reads each + // job, and the triggers of each matching one. + public static async Task> GetTriggersAsync( + IScheduler scheduler, + Func predicate, + CancellationToken cancellationToken) + { + var result = new List<(IJobDetail Job, ITrigger Trigger)>(); + var jobKeys = await scheduler.GetJobKeys(GroupMatcher.AnyGroup(), cancellationToken).ConfigureAwait(false); + foreach (var jobKey in jobKeys) + { + // Null when the job was deleted after its key was read + var job = await scheduler.GetJobDetail(jobKey, cancellationToken).ConfigureAwait(false); + if (job is null || !predicate(job)) + { + continue; + } + + foreach (var trigger in await scheduler.GetTriggersOfJob(jobKey, cancellationToken).ConfigureAwait(false)) + { + result.Add((job, trigger)); + } + } + + return result; + } +} diff --git a/src/Sentry.Quartz3/Sentry.Quartz3.csproj b/src/Sentry.Quartz3/Sentry.Quartz3.csproj new file mode 100644 index 0000000000..1069525283 --- /dev/null +++ b/src/Sentry.Quartz3/Sentry.Quartz3.csproj @@ -0,0 +1,29 @@ + + + + Official Quartz.NET 3.x integration for Sentry - Open-source error tracking that helps developers monitor and fix crashes in real time. + $(PackageTags);Quartz;Crons + $(CurrentTfms);net462 + Sentry.Quartz + + + + true + + + + + + + + + + + + + + + + + + diff --git a/src/Sentry.Quartz3/SentryJobListener.cs b/src/Sentry.Quartz3/SentryJobListener.cs new file mode 100644 index 0000000000..3c3fcb6b77 --- /dev/null +++ b/src/Sentry.Quartz3/SentryJobListener.cs @@ -0,0 +1,79 @@ +using Quartz; +using Sentry.Extensibility; + +namespace Sentry.Quartz; + +/// +/// A Quartz.NET job listener that sends a Sentry check-in each time a job with +/// runs. +/// +public class SentryJobListener : IJobListener +{ + // Keyed on the context, which Quartz passes to both callbacks of a run + private readonly ConditionalWeakTable _checkIns = new(); + private readonly JobMonitor _monitor; + + /// + /// Creates a listener with the default options. + /// + public SentryJobListener() : this(new SentryQuartzOptions()) + { + } + + /// + /// Creates a listener with the given options. + /// + /// The options. + public SentryJobListener(SentryQuartzOptions options) : this(options, HubAdapter.Instance) + { + } + + internal SentryJobListener(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logger = null) + { + _monitor = new JobMonitor(options, hub, logger); + + // Quartz 4 default-implements IJobListener with ValueTask methods, so ours would never be called + var quartzVersion = typeof(IJobListener).Assembly.GetName().Version; + if (IsUnsupportedQuartzVersion(quartzVersion)) + { + _monitor.Logger?.LogError("Sentry.Quartz3 supports Quartz.NET 3.x, but Quartz.NET {0} is loaded, so no " + + "check-ins will be sent. Use the Sentry.Quartz package for Quartz.NET 4.x.", quartzVersion); + } + } + + internal static bool IsUnsupportedQuartzVersion(Version? version) => version is { Major: >= 4 }; + + /// + public string Name => "Sentry"; + + /// + public async Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) + { + if (await _monitor.StartAsync(context, cancellationToken).ConfigureAwait(false) is { } checkIn) + { + _checkIns.Remove(context); + _checkIns.Add(context, checkIn); + } + } + + /// + public Task JobWasExecuted( + IJobExecutionContext context, + JobExecutionException? jobException, + CancellationToken cancellationToken = default) + { + if (_checkIns.TryGetValue(context, out var checkIn)) + { + _checkIns.Remove(context); + // Quartz swallows a cancelled job's OperationCanceledException and passes no exception here + _monitor.Finish(checkIn, failed: jobException is not null || context.CancellationToken.IsCancellationRequested, + context.JobRunTime); + } + + return Task.CompletedTask; + } + + /// + public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) => + Task.CompletedTask; +} diff --git a/src/Sentry/Internal/ConcurrentQueueLite.cs b/src/Sentry/Internal/ConcurrentQueueLite.cs index a8653c52a4..3973fe098f 100644 --- a/src/Sentry/Internal/ConcurrentQueueLite.cs +++ b/src/Sentry/Internal/ConcurrentQueueLite.cs @@ -17,6 +17,27 @@ public void Enqueue(T item) _queue.Add(item); } } + + /// + /// Appends all the items in to this queue. + /// Intended for populating a queue that is not yet shared with other threads. + /// + public void EnqueueRange(ConcurrentQueueLite source) + { + lock (source._queue) + { + if (source._queue.Count == 0) + { + return; + } + + lock (_queue) + { + _queue.AddRange(source._queue); + } + } + } + public bool TryDequeue([NotNullWhen(true)] out T? item) { lock (_queue) diff --git a/src/Sentry/Internal/ExceptionHandlingSynchronizationContext.cs b/src/Sentry/Internal/ExceptionHandlingSynchronizationContext.cs deleted file mode 100644 index a39d809ece..0000000000 --- a/src/Sentry/Internal/ExceptionHandlingSynchronizationContext.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Sentry.Internal; - -/// -/// See https://www.jamescrosswell.dev/posts/catching-async-void-exceptions/ for a detailed explanation -/// -internal class ExceptionHandlingSynchronizationContext(Action exceptionHandler, SynchronizationContext? innerContext) - : SynchronizationContext -{ - public override void Post(SendOrPostCallback d, object? state) - { - if (state is ExceptionDispatchInfo exceptionInfo) - { - exceptionHandler(exceptionInfo.SourceException); - return; - } - if (innerContext != null) - { - innerContext.Post(d, state); - return; - } - base.Post(d, state); - } -} diff --git a/src/Sentry/Internal/MonitorSlug.cs b/src/Sentry/Internal/MonitorSlug.cs new file mode 100644 index 0000000000..080058b095 --- /dev/null +++ b/src/Sentry/Internal/MonitorSlug.cs @@ -0,0 +1,62 @@ +using System.Text; + +namespace Sentry.Internal; + +/// +/// Builds monitor slugs the way Sentry's slugify_monitor_slug does, so Sentry shows the same slug. +/// +/// +/// Also compiled into Sentry.Hangfire, which can't see Sentry's internals because it isn't strong-named. +/// +internal static class MonitorSlug +{ + private const int MaxLength = 50; + + /// + /// Returns the slug of , or null when nothing usable is left. + /// + public static string? From(string value) + { + string normalized; + try + { + normalized = value.Normalize(NormalizationForm.FormKD); + } + catch (PlatformNotSupportedException) + { + // Invariant globalization mode can't normalize non-ASCII text + normalized = value; + } + + var slug = new StringBuilder(normalized.Length); + var inSeparator = false; + foreach (var c in normalized) + { + if (c is '-' or ' ' or >= '\t' and <= '\r' or >= '\x1c' and <= '\x1f') + { + if (!inSeparator) + { + slug.Append('-'); + inSeparator = true; + } + } + else if (c is >= 'a' and <= 'z' or >= '0' and <= '9' or '_') + { + slug.Append(c); + inSeparator = false; + } + else if (c is >= 'A' and <= 'Z') + { + slug.Append(char.ToLowerInvariant(c)); + inSeparator = false; + } + } + + var result = slug.ToString().Trim('-', '_'); + if (result.Length > MaxLength) + { + result = result.Substring(0, MaxLength).TrimEnd('-'); + } + return result.Length > 0 ? result : null; + } +} diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 48fd4b1dbb..7c4c47be0a 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -155,8 +155,10 @@ internal async Task SerializeAsync( { try { - await item.SerializeAsync(stream, logger, cancellationToken).ConfigureAwait(false); - await stream.WriteNewlineAsync(cancellationToken).ConfigureAwait(false); + if (await item.SerializeAsync(stream, logger, dropEmptyAttachment: true, cancellationToken).ConfigureAwait(false)) + { + await stream.WriteNewlineAsync(cancellationToken).ConfigureAwait(false); + } } catch (Exception e) { @@ -180,8 +182,10 @@ internal void Serialize(Stream stream, IDiagnosticLogger? logger, ISystemClock c { try { - item.Serialize(stream, logger); - stream.WriteNewline(); + if (item.Serialize(stream, logger, dropEmptyAttachment: true)) + { + stream.WriteNewline(); + } } catch (Exception e) { diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 364d15087e..3b1e84f7a6 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -175,7 +175,11 @@ private static void SerializeHeader( } /// - public async Task SerializeAsync(Stream stream, IDiagnosticLogger? logger, + public Task SerializeAsync(Stream stream, IDiagnosticLogger? logger, + CancellationToken cancellationToken = default) => + SerializeAsync(stream, logger, dropEmptyAttachment: false, cancellationToken); + + internal async Task SerializeAsync(Stream stream, IDiagnosticLogger? logger, bool dropEmptyAttachment, CancellationToken cancellationToken = default) { // Always calculate the length of the payload, as Sentry will reject envelopes that have incorrect lengths @@ -189,6 +193,11 @@ public async Task SerializeAsync(Stream stream, IDiagnosticLogger? logger, await using (payloadBuffer.ConfigureAwait(false)) #endif { + if (dropEmptyAttachment && IsEmptyAttachmentOfUnknownLength(payloadBuffer, logger)) + { + return false; + } + // Write to the outbound stream asynchronously. It's likely either an HttpRequestStream or a FileStream. // Header @@ -199,11 +208,15 @@ public async Task SerializeAsync(Stream stream, IDiagnosticLogger? logger, // Payload await payloadBuffer.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); + return true; } } /// - public void Serialize(Stream stream, IDiagnosticLogger? logger) + public void Serialize(Stream stream, IDiagnosticLogger? logger) => + Serialize(stream, logger, dropEmptyAttachment: false); + + internal bool Serialize(Stream stream, IDiagnosticLogger? logger, bool dropEmptyAttachment) { // Always calculate the length of the payload, as Sentry will reject envelopes that have incorrect lengths // in item headers. Don't trust any previously calculated value to be correct. @@ -211,6 +224,11 @@ public void Serialize(Stream stream, IDiagnosticLogger? logger) using var payloadBuffer = BufferPayload(logger); + if (dropEmptyAttachment && IsEmptyAttachmentOfUnknownLength(payloadBuffer, logger)) + { + return false; + } + // Header var headerWithLength = Header.ToDict(); headerWithLength[LengthKey] = payloadBuffer.Length; @@ -219,6 +237,18 @@ public void Serialize(Stream stream, IDiagnosticLogger? logger) // Payload payloadBuffer.CopyTo(stream); + return true; + } + + private bool IsEmptyAttachmentOfUnknownLength(MemoryStream payloadBuffer, IDiagnosticLogger? logger) + { + if (payloadBuffer.Length != 0 || TryGetLength() is not null || Payload is not BufferedStreamSerializable) + { + return false; + } + + logger?.LogWarning("Did not add '{0}' to envelope because the attachment was empty.", TryGetFileName()); + return true; } /// diff --git a/src/Sentry/Scope.cs b/src/Sentry/Scope.cs index 55bfa26604..d4a3433129 100644 --- a/src/Sentry/Scope.cs +++ b/src/Sentry/Scope.cs @@ -100,13 +100,15 @@ public SentryRequest Request set => _request = value; } - private readonly SentryContexts _contexts = new(); + // The contexts, tags, extras, attachments and SDK version are created lazily as most scopes + // (e.g. those created by pushing a scope) never use them and they are comparatively expensive to allocate. + private SentryContexts? _contexts; /// public SentryContexts Contexts { - get => _contexts; - set => _contexts.ReplaceWith(value); + get => LazyInitializer.EnsureInitialized(ref _contexts, static () => new())!; + set => Contexts.ReplaceWith(value); } // Internal for testing. @@ -254,8 +256,10 @@ public ITransactionTracer? Transaction internal SessionUpdate? SessionUpdate { get; set; } + private SdkVersion? _sdk; + /// - public SdkVersion Sdk { get; } = new(); + public SdkVersion Sdk => LazyInitializer.EnsureInitialized(ref _sdk, static () => new())!; /// public IReadOnlyList Fingerprint { get; set; } = Array.Empty(); @@ -265,22 +269,28 @@ public ITransactionTracer? Transaction /// public IReadOnlyCollection Breadcrumbs => _breadcrumbs; - private readonly ConcurrentDictionary _extra = new(); + private ConcurrentDictionary? _extra; + + private ConcurrentDictionary ExtraStore => LazyInitializer.EnsureInitialized(ref _extra, static () => new())!; /// - public IReadOnlyDictionary Extra => _extra; + public IReadOnlyDictionary Extra => ExtraStore; + + private ConcurrentDictionary? _tags; - private readonly ConcurrentDictionary _tags = new(); + private ConcurrentDictionary TagStore => LazyInitializer.EnsureInitialized(ref _tags, static () => new())!; /// - public IReadOnlyDictionary Tags => _tags; + public IReadOnlyDictionary Tags => TagStore; - private readonly ConcurrentBagLite _attachments = new(); + private ConcurrentBagLite? _attachments; + + private ConcurrentBagLite AttachmentStore => LazyInitializer.EnsureInitialized(ref _attachments, static () => new())!; /// /// Attachments. /// - public IReadOnlyCollection Attachments => _attachments; + public IReadOnlyCollection Attachments => AttachmentStore; /// /// Creates a scope with the specified options. @@ -354,7 +364,7 @@ public void AddBreadcrumb(Breadcrumb breadcrumb, SentryHint hint) /// public void SetExtra(string key, object? value) { - _extra[key] = value; + ExtraStore[key] = value; ScopeObserver?.SetExtra(key, value); } @@ -366,14 +376,14 @@ public void SetTag(string key, string value) return; } - _tags[key] = value; + TagStore[key] = value; ScopeObserver?.SetTag(key, value); } /// public void UnsetTag(string key) { - _tags.TryRemove(key, out _); + _tags?.TryRemove(key, out _); ScopeObserver?.UnsetTag(key); } @@ -382,7 +392,7 @@ public void UnsetTag(string key) /// public void AddAttachment(SentryAttachment attachment) { - _attachments.Add(attachment); + AttachmentStore.Add(attachment); ScopeObserver?.AddAttachment(attachment); } @@ -402,15 +412,15 @@ public void Clear() { Level = default; Request = new(); - Contexts.Clear(); + _contexts?.Clear(); User = new(); Environment = default; TransactionName = default; Transaction = default; Fingerprint = Array.Empty(); ClearBreadcrumbs(); - _extra.Clear(); - _tags.Clear(); + _extra?.Clear(); + _tags?.Clear(); ClearAttachments(); PropagationContext = new(); } @@ -420,7 +430,7 @@ public void Clear() /// public void ClearAttachments() { - _attachments.Clear(); + _attachments?.Clear(); ScopeObserver?.ClearAttachments(); } @@ -463,23 +473,29 @@ public void Apply(IEventLike other) other.AddBreadcrumb(breadcrumb); } - foreach (var (key, value) in Extra) + if (_extra is { } extras) { - if (!other.Extra.ContainsKey(key)) + foreach (var (key, value) in extras) { - other.SetExtra(key, value); + if (!other.Extra.ContainsKey(key)) + { + other.SetExtra(key, value); + } } } - foreach (var (key, value) in Tags) + if (_tags is { } tags) { - if (!other.Tags.ContainsKey(key)) + foreach (var (key, value) in tags) { - other.SetTag(key, value); + if (!other.Tags.ContainsKey(key)) + { + other.SetTag(key, value); + } } } - Contexts.CopyTo(other.Contexts); + _contexts?.CopyTo(other.Contexts); Request.CopyTo(other.Request); User.CopyTo(other.User); @@ -487,15 +503,18 @@ public void Apply(IEventLike other) other.TransactionName ??= TransactionName; other.Level ??= Level; - if (other.Sdk.Name is null && other.Sdk.Version is null) + if (_sdk is { } sdk) { - other.Sdk.Name = Sdk.Name; - other.Sdk.Version = Sdk.Version; - } + if (other.Sdk.Name is null && other.Sdk.Version is null) + { + other.Sdk.Name = sdk.Name; + other.Sdk.Version = sdk.Version; + } - foreach (var package in Sdk.InternalPackages) - { - other.Sdk.AddPackage(package); + foreach (var package in sdk.InternalPackages) + { + other.Sdk.AddPackage(package); + } } } @@ -515,10 +534,13 @@ public void Apply(Scope other) other.Transaction ??= Transaction; other.SessionUpdate ??= SessionUpdate; - foreach (var attachment in Attachments) + if (_attachments is { } attachments) { - // Set the attachment directly to avoid triggering a scope sync - other._attachments.Add(attachment); + foreach (var attachment in attachments) + { + // Set the attachment directly to avoid triggering a scope sync + other.AttachmentStore.Add(attachment); + } } } @@ -550,29 +572,35 @@ public Scope Clone() clone.User = user.Clone(); } - Contexts.CopyTo(clone.Contexts); - Request.CopyTo(clone.Request); - Sdk.CopyTo(clone.Sdk); + _contexts?.CopyTo(clone.Contexts); + _request?.CopyTo(clone.Request); + _sdk?.CopyTo(clone.Sdk); // Copied directly so BeforeBreadcrumb and TagFilters don't run again - foreach (var breadcrumb in _breadcrumbs) - { - clone._breadcrumbs.Enqueue(breadcrumb); - } + clone._breadcrumbs.EnqueueRange(_breadcrumbs); - foreach (var (key, value) in _extra) + if (_extra is { IsEmpty: false } extras) { - clone._extra[key] = value; + foreach (var (key, value) in extras) + { + clone.ExtraStore[key] = value; + } } - foreach (var (key, value) in _tags) + if (_tags is { IsEmpty: false } tags) { - clone._tags[key] = value; + foreach (var (key, value) in tags) + { + clone.TagStore[key] = value; + } } - foreach (var attachment in _attachments) + if (_attachments is { IsEmpty: false } attachments) { - clone._attachments.Add(attachment); + foreach (var attachment in attachments) + { + clone.AttachmentStore.Add(attachment); + } } foreach (var processor in EventProcessors) diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index f5e305b6f8..09f37ed5ee 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -106,13 +106,13 @@ <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) - - - - - - - + + + + + + + @@ -176,6 +176,10 @@ + + + + diff --git a/src/Sentry/SentryMonitorOptions.cs b/src/Sentry/SentryMonitorOptions.cs index e814c89a0c..7b0c604af8 100644 --- a/src/Sentry/SentryMonitorOptions.cs +++ b/src/Sentry/SentryMonitorOptions.cs @@ -57,6 +57,8 @@ public class SentryMonitorOptions : ISentryJsonSerializable private int? _interval; private SentryMonitorInterval? _unit; + internal bool HasSchedule => _type is not SentryMonitorScheduleType.None; + /// /// Set Interval /// @@ -153,6 +155,21 @@ public int? RecoveryThreshold internal SentryMonitorOptions() { } + // For integrations that configure the options before capturing the check-in + internal void CopyTo(SentryMonitorOptions target) + { + target._type = _type; + target._crontab = _crontab; + target._interval = _interval; + target._unit = _unit; + target.CheckInMargin = CheckInMargin; + target.MaxRuntime = MaxRuntime; + target._failureIssueThreshold = _failureIssueThreshold; + target._recoveryThreshold = _recoveryThreshold; + target.TimeZone = TimeZone; + target.Owner = Owner; + } + /// public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { diff --git a/src/Sentry/StringOrRegex.cs b/src/Sentry/StringOrRegex.cs index c8e316f8e8..40cf266acc 100644 --- a/src/Sentry/StringOrRegex.cs +++ b/src/Sentry/StringOrRegex.cs @@ -16,6 +16,11 @@ public class StringOrRegex /// public bool IsRegex => _regex is Regex; + /// + /// The regular expression, or null when this instance contains a plain string. + /// + public Regex? Regex => _regex; + /// /// Constructs a instance. /// diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj b/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj index bbb08ba939..1842a0f831 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj +++ b/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj @@ -17,6 +17,8 @@ + + diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs index c57a810f97..443218e402 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs @@ -171,6 +171,88 @@ public void Log_WithEmptyGuidProperty_DoesntSetTagInEvent() e => e.Tags.Count == 0)); } + [Fact] + public void Log_MatchingFilter_DoesNotFormatMessage() + { + // Arrange + _fixture.Options.AddLogEntryFilter((_, _, _, _) => true); + var formatted = false; + + var sut = _fixture.GetSut(); + + // Act + sut.Log(LogLevel.Information, default, "state", null, (_, _) => + { + formatted = true; + return "message"; + }); + + // Assert + formatted.Should().BeFalse(); + _fixture.Scope.Breadcrumbs.Should().BeEmpty(); + } + + [Fact] + public void Log_FromSentry_DoesNotFormatMessage() + { + // Arrange + _fixture.CategoryName = "Sentry.ISentryClient"; + var formatted = false; + + var sut = _fixture.GetSut(); + + // Act + sut.Log(LogLevel.Information, default, "state", null, (_, _) => + { + formatted = true; + return "message"; + }); + + // Assert + formatted.Should().BeFalse(); + _fixture.Scope.Breadcrumbs.Should().BeEmpty(); + } + + [Fact] + public void Log_BelowMinimumLevels_DoesNotFormatMessage() + { + // Arrange + var formatted = false; + + var sut = _fixture.GetSut(); + + // Act + sut.Log(LogLevel.Debug, default, "state", null, (_, _) => + { + formatted = true; + return "message"; + }); + + // Assert + formatted.Should().BeFalse(); + } + + [Fact] + public void Log_EventAndBreadcrumbLevelsBothMet_FormatsMessageOnce() + { + // Arrange + var count = 0; + + var sut = _fixture.GetSut(); + + // Act + sut.Log(LogLevel.Critical, default, "state", null, (_, _) => + { + count++; + return "formatted message"; + }); + + // Assert + count.Should().Be(1); + _fixture.Hub.Received(1).CaptureEvent(Arg.Is(e => e.Message.Message == "formatted message")); + _fixture.Scope.Breadcrumbs.Should().ContainSingle().Which.Message.Should().Be("formatted message"); + } + [Fact] public void LogCritical_MatchingFilter_DoesNotCapturesEvent() { diff --git a/test/Sentry.Hangfire.Tests/HangfireFixture.cs b/test/Sentry.Hangfire.Tests/HangfireFixture.cs index 3b9f5cd0f9..8176bc7dc2 100644 --- a/test/Sentry.Hangfire.Tests/HangfireFixture.cs +++ b/test/Sentry.Hangfire.Tests/HangfireFixture.cs @@ -7,6 +7,8 @@ namespace Sentry.Hangfire.Tests; public class HangfireFixture : IDisposable { + public const string ExcludedRecurringJobId = "Excluded Recurring Job"; + public IHub Hub { get; set; } = Substitute.For(); public IDiagnosticLogger Logger { get; } private readonly BackgroundJobServer _server; @@ -21,7 +23,11 @@ public HangfireFixture() GlobalConfiguration.Configuration .UseMemoryStorage() - .UseSentry(Hub, Logger); + .UseSentry(Hub, Logger, new SentryHangfireOptions + { + MonitorAllRecurringJobs = true, + ExcludedRecurringJobs = { ExcludedRecurringJobId } + }); _server = new BackgroundJobServer(); _monitoringApi = JobStorage.Current.GetMonitoringApi(); } diff --git a/test/Sentry.Hangfire.Tests/HangfireTests.cs b/test/Sentry.Hangfire.Tests/HangfireTests.cs index 2efbc1a391..8f7ac918e4 100644 --- a/test/Sentry.Hangfire.Tests/HangfireTests.cs +++ b/test/Sentry.Hangfire.Tests/HangfireTests.cs @@ -58,14 +58,13 @@ public async Task ExecuteJobWithoutAttribute_DoesNotCapturesCheckInButLogs() await _fixture.Enqueue(job => job.ExecuteJobWithoutAttribute()); _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()); + _fixture.Logger.Received(1).Log(SentryLevel.Debug, Arg.Is(message => message.Contains("Skipping creating a check-in for")), null, Arg.Any(), + Arg.Is(method => method.Name == nameof(TestJob.ExecuteJobWithoutAttribute))); } - [SkippableFact] + [Fact] 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); @@ -78,6 +77,30 @@ public async Task ExecuteRecurringJob_SendMonitorConfigEnabled_CapturesCheckInWi monitorConfig.GetProperty("timezone").GetString().Should().Be("Europe/Berlin"); } + [Fact] + public async Task ExecuteRecurringJobWithoutAttribute_MonitorAllRecurringJobs_CapturesCheckInsWithJobIdSlug() + { + await _fixture.TriggerRecurringJob("Nightly Cleanup", job => job.ExecuteRecurringJobWithoutAttribute(), + "0 3 * * *", TimeZoneInfo.Utc); + + var checkInId = _fixture.Hub.ReceivedInProgressCheckInId("nightly-cleanup"); + checkInId.Should().NotBeNull().And.NotBe(SentryId.Empty); + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("nightly-cleanup")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 3 * * *"); + _fixture.Hub.Received(1).CaptureCheckIn("nightly-cleanup", CheckInStatus.Ok, checkInId, + Arg.Is(duration => duration != null), Arg.Any()); + } + + [Fact] + public async Task ExecuteRecurringJobWithoutAttribute_Excluded_DoesNotCaptureCheckIn() + { + await _fixture.TriggerRecurringJob(HangfireFixture.ExcludedRecurringJobId, + job => job.ExecuteRecurringJobWithoutAttribute(), "0 3 * * *", TimeZoneInfo.Utc); + + _fixture.Hub.DidNotReceive().CaptureCheckIn("excluded-recurring-job", Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>()); + } + // 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() @@ -127,4 +150,7 @@ public void ExecuteJobWithoutAttribute() [SentryMonitorSlug("test-recurring-job")] public void ExecuteRecurringJob() { } + + public void ExecuteRecurringJobWithoutAttribute() + { } } diff --git a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs index 172432aa64..47089dd6e1 100644 --- a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs +++ b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs @@ -68,15 +68,13 @@ public void OnPerformed_InProgressCheckInNotCaptured_FinishesWithSameNonEmptyId( fixture.Hub.Received(1).CaptureCheckIn(RecurringJobFixture.MonitorSlug, CheckInStatus.Ok, checkInId, Arg.Any()); } - [SkippableTheory] + [Theory] [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); @@ -241,6 +239,7 @@ public void OnPerforming_ClassSlugSharedByMethodsWithDifferentCron_SendsCheckInW var fixture = new RecurringJobFixture { Slug = SlugOnClassJob.Slug, + AttributeSlug = SlugOnClassJob.Slug, Cron = "0 2 * * *", Job = Job.FromExpression(job => job.First()) }; @@ -340,6 +339,259 @@ public void OnPerforming_ReadingAllRecurringJobsThrows_SendsCheckInWithoutMonito fixture.AssertCheckInSentWithoutMonitorConfig(); } + [Fact] + public void OnPerforming_MonitorAllRecurringJobs_UsesSlugifiedRecurringJobId() + { + // Arrange + var fixture = new RecurringJobFixture + { + RecurringJobId = "Reports: Nightly Run", + AttributeSlug = null, + Slug = "reports-nightly-run", + Options = { MonitorAllRecurringJobs = true } + }; + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + filter.OnPerformed(new PerformedContext(fixture.PerformingContext, null, false, null)); + + // Assert + fixture.GetSentMonitorConfig()!.Value.GetProperty("schedule").GetProperty("value").GetString().Should().Be(fixture.Cron); + var checkInId = fixture.Hub.ReceivedInProgressCheckInId("reports-nightly-run"); + fixture.Hub.Received(1).CaptureCheckIn("reports-nightly-run", CheckInStatus.Ok, checkInId, Arg.Any()); + } + + [Fact] + public void OnPerforming_MonitorAllRecurringJobsWithAttribute_KeepsAttributeSlug() + { + // Arrange + var fixture = new RecurringJobFixture { Options = { MonitorAllRecurringJobs = true } }; + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.GetSentMonitorConfig().Should().NotBeNull(); + fixture.Hub.DidNotReceive().CaptureCheckIn("test-recurring-job", Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>()); + } + + [Theory] + [InlineData(false, new string[0])] + [InlineData(true, new[] { "test-recurring-job" })] + public void OnPerforming_RecurringJobNotMonitored_DoesNotCaptureCheckIn(bool monitorAll, string[] excluded) + { + // Arrange + var fixture = new RecurringJobFixture { AttributeSlug = null }; + fixture.Options.MonitorAllRecurringJobs = monitorAll; + foreach (var id in excluded) + { + fixture.Options.ExcludedRecurringJobs.Add(id); + } + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + filter.OnPerformed(new PerformedContext(fixture.PerformingContext, null, false, null)); + + // Assert + fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + fixture.PerformingContext.Items.Should().NotContainKey(SentryServerFilter.SentryCheckInIdKey); + } + + [Fact] + public void OnPerforming_ExcludedRecurringJobWithAttribute_CapturesCheckIn() + { + // Arrange + var fixture = new RecurringJobFixture + { + Options = { MonitorAllRecurringJobs = true, ExcludedRecurringJobs = { "test-recurring-job" } } + }; + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.GetSentMonitorConfig().Should().NotBeNull(); + } + + [Fact] + public void OnPerforming_MonitorAllRecurringJobsIdsShareSlugWithDifferentCron_DoesNotCaptureCheckIn() + { + // Arrange + var fixture = new RecurringJobFixture + { + RecurringJobId = "Cleanup", + AttributeSlug = null, + Slug = "cleanup", + Cron = "0 2 * * *", + Job = Job.FromExpression(job => job.Run()), + Options = { MonitorAllRecurringJobs = true } + }; + fixture.OtherRecurringJobs.Add(("cleanup", "0 8 * * *", null, Job.FromExpression(job => job.Run()))); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + fixture.PerformingContext.Items.Should().NotContainKey(SentryServerFilter.SentryCheckInIdKey); + } + + [Theory] + [InlineData("0 0 L * *", "UTC", true)] + [InlineData("0 * * * *", "Not A Time Zone", true)] + [InlineData("0 0 L * *", "UTC", false)] + public void OnPerforming_MonitorAllRecurringJobsScheduleNotSupportedBySentry_DoesNotCaptureCheckIn( + string cron, string timeZoneId, bool sendMonitorConfig) + { + // Arrange + var fixture = new RecurringJobFixture + { + AttributeSlug = null, + Slug = "test-recurring-job", + Cron = cron, + TimeZoneId = timeZoneId, + Options = { MonitorAllRecurringJobs = true } + }; + var filter = fixture.GetSut(sendMonitorConfig); + + // Act + filter.OnPerforming(fixture.PerformingContext); + filter.OnPerformed(new PerformedContext(fixture.PerformingContext, null, false, null)); + + // Assert + fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + fixture.PerformingContext.Items.Should().NotContainKey(SentryServerFilter.SentryCheckInIdKey); + } + + [Fact] + public void OnPerforming_MonitorAllRecurringJobsRecurringJobDeleted_DoesNotCaptureCheckIn() + { + // Arrange + var fixture = new RecurringJobFixture + { + AttributeSlug = null, + Slug = "test-recurring-job", + RecurringJobExists = false, + Options = { MonitorAllRecurringJobs = true } + }; + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public void OnPerforming_MonitorAllRecurringJobsSendMonitorConfigDisabled_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture + { + AttributeSlug = null, + Slug = "test-recurring-job", + Options = { MonitorAllRecurringJobs = true } + }; + var filter = fixture.GetSut(sendMonitorConfig: false); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + [Fact] + public void OnPerforming_MonitorAllRecurringJobsAttributeWithScheduleNotSupportedBySentry_SendsCheckInWithoutMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture { Cron = "0 0 L * *", Options = { MonitorAllRecurringJobs = true } }; + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.AssertCheckInSentWithoutMonitorConfig(); + } + + [Fact] + public void OnPerforming_MonitorAllRecurringJobsExcludedIdWithSameSlug_SendsMonitorConfig() + { + // Arrange + var fixture = new RecurringJobFixture + { + RecurringJobId = "Cleanup", + AttributeSlug = null, + Slug = "cleanup", + Cron = "0 2 * * *", + Job = Job.FromExpression(job => job.Run()), + Options = { MonitorAllRecurringJobs = true, ExcludedRecurringJobs = { "cleanup" } } + }; + fixture.OtherRecurringJobs.Add(("cleanup", "0 8 * * *", null, Job.FromExpression(job => job.Run()))); + var filter = fixture.GetSut(); + + // Act + filter.OnPerforming(fixture.PerformingContext); + + // Assert + fixture.GetSentMonitorConfig().Should().NotBeNull(); + } + + [Theory] + [InlineData("nightly", "nightly")] + [InlineData("Reports: Nightly Run", "reports-nightly-run")] + [InlineData("!!!", null)] + public void GetRecurringJobSlug_RecurringJobId_ReturnsSlug(string recurringJobId, string? expected) + { + var options = new SentryHangfireOptions { MonitorAllRecurringJobs = true }; + + options.GetRecurringJobSlug(recurringJobId).Should().Be(expected); + } + + [Theory] + [InlineData("nightly", true)] + [InlineData("Nightly", false)] + [InlineData("nightly-2", false)] + public void GetRecurringJobSlug_ExcludedString_MatchesIdExactly(string recurringJobId, bool excluded) + { + var options = new SentryHangfireOptions + { + MonitorAllRecurringJobs = true, + ExcludedRecurringJobs = { "nightly" } + }; + + (options.GetRecurringJobSlug(recurringJobId) is null).Should().Be(excluded); + } + + [Theory] + [InlineData("cleanup-eu", true)] + [InlineData("Cleanup-US", true)] + [InlineData("nightly-cleanup", false)] + public void GetRecurringJobSlug_ExcludedRegex_MatchesId(string recurringJobId, bool excluded) + { + var options = new SentryHangfireOptions + { + MonitorAllRecurringJobs = true, + ExcludedRecurringJobs = { new Regex("^cleanup-", RegexOptions.IgnoreCase) } + }; + + (options.GetRecurringJobSlug(recurringJobId) is null).Should().Be(excluded); + } + + public class PlainJob + { + public void Run() + { } + } + private static Job ReportJob(string region) => Job.FromExpression(job => job.Run(region)); [Theory] @@ -370,7 +622,7 @@ public void ToCrontab_HangfireCron_ReturnsSentryCrontabOrNull(string cron, strin SentryServerFilter.ToCrontab(cron).Should().Be(expected); } - [SkippableTheory] + [Theory] [InlineData(null, "UTC")] [InlineData("", "UTC")] [InlineData("UTC", "UTC")] @@ -381,8 +633,6 @@ public void ToCrontab_HangfireCron_ReturnsSentryCrontabOrNull(string cron, strin [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); } @@ -425,12 +675,17 @@ private class RecurringJobFixture public SentryId CheckInId { get; set; } = SentryId.Create(); public PerformingContext PerformingContext { get; private set; } = null!; + public SentryHangfireOptions Options { get; set; } = new(); + 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)); + if (AttributeSlug is not null) + { + Connection.GetJobParameter(JobId, SentryServerFilter.SentryMonitorSlugKey) + .Returns(SerializationHelper.Serialize(AttributeSlug)); + } var recurringJobIds = new HashSet(OtherRecurringJobs.Select(job => job.Id)); if (RecurringJobId is not null) { @@ -458,20 +713,22 @@ public SentryServerFilter GetSut(bool? sendMonitorConfig = null) Arg.Any?>()) .Returns(CheckInId); - var backgroundJob = new BackgroundJob(JobId, null, DateTime.Now); + var backgroundJob = new BackgroundJob(JobId, Job ?? Job.FromExpression(job => job.Run()), 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; + Options.SendMonitorConfig = send; } - return new SentryServerFilter(Hub, Logger, options, Clock); + return new SentryServerFilter(Hub, Logger, Options, Clock); } public string Slug { get; set; } = MonitorSlug; + // The slug the job was created with; null for a job without SentryMonitorSlugAttribute + public string? AttributeSlug { get; set; } = MonitorSlug; + private static Dictionary RecurringJobHash(string cron, string? timeZoneId, Job? job) { var hash = new Dictionary { ["Cron"] = cron }; diff --git a/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt new file mode 100644 index 0000000000..b536bcc3af --- /dev/null +++ b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -0,0 +1,26 @@ +namespace Sentry.Quartz +{ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class SentryMonitorSlugAttribute : System.Attribute + { + public const string JobDataKey = "SentryMonitorSlug"; + public SentryMonitorSlugAttribute(string? monitorSlug = null) { } + public string? MonitorSlug { get; } + public bool SendMonitorConfig { get; set; } + } + public static class SentryQuartzBuilderExtensions + { + extension(Quartz.IQuartzBuilder builder) + { + public Quartz.IQuartzBuilder AddSentry(System.Action? configure = null) { } + } + } + public class SentryQuartzOptions + { + public SentryQuartzOptions() { } + public System.Action? ConfigureMonitorOptions { get; set; } + public System.Collections.Generic.IList ExcludedJobs { get; set; } + public bool MonitorAllJobs { get; set; } + public bool SendMonitorConfig { get; set; } + } +} \ No newline at end of file diff --git a/test/Sentry.Quartz.Tests/ApiApprovalTests.verify.cs b/test/Sentry.Quartz.Tests/ApiApprovalTests.verify.cs new file mode 100644 index 0000000000..dfdfb65341 --- /dev/null +++ b/test/Sentry.Quartz.Tests/ApiApprovalTests.verify.cs @@ -0,0 +1,10 @@ +namespace Sentry.Quartz.Tests; + +public class ApiApprovalTests +{ + [Fact] + public Task Run() + { + return typeof(SentryQuartzBuilderExtensions).Assembly.CheckApproval(); + } +} diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs new file mode 100644 index 0000000000..f585d495bf --- /dev/null +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -0,0 +1,995 @@ +namespace Sentry.Quartz.Tests; + +public class JobMonitorTests +{ + private class Fixture + { + public IHub Hub { get; } = Substitute.For(); + public IScheduler Scheduler { get; } = Substitute.For(); + public InMemoryDiagnosticLogger Logger { get; } = new(); + public SentryId CheckInId { get; set; } = SentryId.Create(); + public SentryQuartzOptions Options { get; } = new(); + public ISystemClock Clock { get; } = Substitute.For(); + public DateTimeOffset Now { get; } = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + public List<(IJobDetail Job, ITrigger Trigger)> OtherTriggers { get; } = []; + + public IJobDetail JobDetail { get; set; } = JobBuilder.Create() + .WithIdentity("DailyEmail", "reports") + .Build(); + + public ITrigger Trigger { get; set; } = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + public JobMonitor GetSut() + { + Hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(CheckInId); + Clock.GetUtcNow().Returns(Now); + return new JobMonitor(Options, Hub, Logger, Clock); + } + + public IJobExecutionContext GetContext() + { + if (!JobDetail.Key.Equals(Trigger.JobKey)) + { + Trigger = Trigger.GetTriggerBuilder().ForJob(JobDetail).Build(); + } + + var mergedJobDataMap = new JobDataMap(); + foreach (var entry in JobDetail.JobDataMap.Concat(Trigger.JobDataMap)) + { + mergedJobDataMap[entry.Key] = entry.Value; + } + + Scheduler.ReturnsTriggers([(JobDetail, Trigger), .. OtherTriggers]); + + var context = Substitute.For(); + context.JobDetail.Returns(JobDetail); + context.Trigger.Returns(Trigger); + context.Scheduler.Returns(Scheduler); + context.MergedJobDataMap.Returns(mergedJobDataMap); + return context; + } + + public Task StartAsync() => GetSut().StartAsync(GetContext(), CancellationToken.None); + } + + private readonly Fixture _fixture = new(); + + [Fact] + public async Task StartAsync_CronTrigger_CapturesInProgressCheckInWithMonitorConfig() + { + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + checkIn.Id.Should().NotBe(SentryId.Empty); + _fixture.Hub.ReceivedInProgressCheckInId("reports-dailyemail").Should().Be(checkIn.Id); + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 * * *"); + monitorConfig.GetProperty("timezone").GetString().Should().Be("UTC"); + } + + [Fact] + public async Task StartAsync_JobWithoutAttribute_CapturesNothing() + { + // Arrange + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_SendMonitorConfigDisabled_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Options.SendMonitorConfig = false; + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + _fixture.Scheduler.DidNotLookUpTriggers(); + } + + [Fact] + public async Task StartAsync_AttributeDisablesMonitorConfig_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_ScheduleSentryCantRepresent_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_JobHasTriggersWithDifferentSchedules_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + _fixture.Logger.Entries.Should().Contain(e => e.Message.Contains("with a different schedule")); + } + + [Fact] + public async Task StartAsync_JobHasTriggersWithSameSchedule_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 12 * * ?", _fixture.JobDetail))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_JobsShareSlugWithDifferentSchedules_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.JobDetail = JobBuilder.Create().WithIdentity("eu", "reports").Build(); + var other = JobBuilder.Create().WithIdentity("us", "reports").Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 18 * * ?", other))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().BeNull(); + } + +#if NET6_0_OR_GREATER + [Fact] + public async Task StartAsync_JobsShareSlugWithDifferentTimeZones_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.JobDetail = JobBuilder.Create().WithIdentity("eu", "reports").Build(); + var other = JobBuilder.Create().WithIdentity("us", "reports").Build(); + var timeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 12 * * ?", other, timeZone))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().BeNull(); + } +#endif + + [Fact] + public async Task StartAsync_JobsShareSlugWithSameConvertedSchedule_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.JobDetail = JobBuilder.Create().WithIdentity("eu", "reports").Build(); + var other = JobBuilder.Create().WithIdentity("us", "reports").Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 12 ? * *", other))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_IdentitylessJobsWithDifferentSchedules_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.JobDetail = JobBuilder.Create().Build(); + var other = JobBuilder.Create().Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 18 * * ?", other))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("monitoredjob").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_OtherTriggerSetsItsOwnSlug_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail, + monitorSlug: "daily-email-evening"))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_OtherJobWithoutAttributeSharesSlug_CapturesCheckInWithMonitorConfig() + { + // Arrange + var other = JobBuilder.Create().WithIdentity("DailyEmail", "Reports").Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 18 * * ?", other))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_OtherTriggerScheduleSentryCantRepresent_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 12 L * ?", _fixture.JobDetail))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_TriggerSetsMonitorSlug_CapturesCheckInWithThatTriggersMonitorConfig() + { + // Arrange + _fixture.Trigger = CronTrigger("0 0 12 * * ?", monitorSlug: "daily-email-noon"); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("daily-email-noon").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_WithinTtl_LooksUpTriggersOnce() + { + // Arrange + var sut = _fixture.GetSut(); + var context = _fixture.GetContext(); + + // Act + await sut.StartAsync(context, CancellationToken.None); + _fixture.Clock.GetUtcNow().Returns(_fixture.Now + JobMonitor.TriggerSchedulesTtl - TimeSpan.FromSeconds(1)); + await sut.StartAsync(context, CancellationToken.None); + + // Assert + _fixture.Scheduler.LookedUpTriggers(1); + } + + [Fact] + public async Task StartAsync_AfterTtl_LooksUpTriggersAgain() + { + // Arrange + var sut = _fixture.GetSut(); + var context = _fixture.GetContext(); + + // Act + await sut.StartAsync(context, CancellationToken.None); + _fixture.Clock.GetUtcNow().Returns(_fixture.Now + JobMonitor.TriggerSchedulesTtl); + await sut.StartAsync(context, CancellationToken.None); + + // Assert + _fixture.Scheduler.LookedUpTriggers(2); + } + + [Fact] + public async Task StartAsync_ConflictingTriggerAddedAfterTtl_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + var sut = _fixture.GetSut(); + await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + var context = _fixture.GetContext(); + _fixture.Hub.ClearReceivedCalls(); + + // Act + await sut.StartAsync(context, CancellationToken.None); + var withinTtl = _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail"); + _fixture.Hub.ClearReceivedCalls(); + _fixture.Clock.GetUtcNow().Returns(_fixture.Now + JobMonitor.TriggerSchedulesTtl); + await sut.StartAsync(context, CancellationToken.None); + + // Assert + withinTtl.Should().NotBeNull(); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_TriggerNotInLookup_LooksUpTriggersAgain() + { + // Arrange + var sut = _fixture.GetSut(); + await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); + _fixture.Trigger = CronTrigger("0 0 12 * * ?"); + + // Act + await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); + + // Assert + _fixture.Scheduler.LookedUpTriggers(2); + } + + [Fact] + public async Task StartAsync_SchedulerThrows_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + var sut = _fixture.GetSut(); + var context = _fixture.GetContext(); + _fixture.Scheduler.TriggerLookupThrows(new SchedulerException("unavailable")); + + // Act + await sut.StartAsync(context, CancellationToken.None); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_ConfigureMonitorOptions_RunsBeforeTriggerSchedule() + { + // Arrange + IJobExecutionContext? receivedContext = null; + _fixture.Options.ConfigureMonitorOptions = (context, options) => + { + receivedContext = context; + options.FailureIssueThreshold = 3; + options.TimeZone = "Europe/Vienna"; + }; + + // Act + await _fixture.StartAsync(); + + // Assert + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + receivedContext!.JobDetail.Should().BeSameAs(_fixture.JobDetail); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 * * *"); + monitorConfig.GetProperty("timezone").GetString().Should().Be("Europe/Vienna"); + monitorConfig.GetProperty("failure_issue_threshold").GetInt32().Should().Be(3); + } + + [Fact] + public async Task StartAsync_ConfigureMonitorOptionsSetsSchedule_UsesThatSchedule() + { + // Arrange + _fixture.Options.ConfigureMonitorOptions = (_, options) => options.Interval(1, SentryMonitorInterval.Day); + + // Act + await _fixture.StartAsync(); + + // Assert + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + monitorConfig.GetProperty("schedule").GetProperty("type").GetString().Should().Be("interval"); + monitorConfig.TryGetProperty("timezone", out _).Should().BeFalse(); + } + + [Fact] + public async Task StartAsync_ConfigureMonitorOptionsThrows_SendsTriggerSchedule() + { + // Arrange + _fixture.Options.ConfigureMonitorOptions = (_, _) => throw new InvalidOperationException("Callback failed"); + + // Act + await _fixture.StartAsync(); + + // Assert + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 * * *"); + } + + [Fact] + public async Task StartAsync_ConfigureMonitorOptionsThrowsAfterSettingSchedule_SendsTriggerSchedule() + { + // Arrange + _fixture.Options.ConfigureMonitorOptions = (_, options) => + { + options.Interval(1, SentryMonitorInterval.Day); + options.FailureIssueThreshold = 3; + throw new InvalidOperationException("Callback failed"); + }; + + // Act + await _fixture.StartAsync(); + + // Assert + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 * * *"); + monitorConfig.TryGetProperty("failure_issue_threshold", out _).Should().BeFalse(); + } + + [Fact] + public async Task StartAsync_InProgressCheckInNotCaptured_KeepsItsId() + { + // Arrange + _fixture.CheckInId = SentryId.Empty; + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.Id.Should().NotBe(SentryId.Empty); + _fixture.Hub.ReceivedInProgressCheckInId("reports-dailyemail").Should().Be(checkIn.Id); + } + + [Fact] + public async Task StartAsync_InProgressCheckInThrows_StillReturnsCheckIn() + { + // Arrange + var sut = _fixture.GetSut(); + _fixture.Hub.CaptureCheckIn(default!, default, default, default, default, default) + .ThrowsForAnyArgs(new InvalidOperationException("Hub failed")); + + // Act + var checkIn = await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); + + // Assert + checkIn!.Id.Should().NotBe(SentryId.Empty); + } + + [Fact] + public async Task StartAsync_ConfigureMonitorOptionsWithUnconvertibleTrigger_RunsCallback() + { + // Arrange + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + var called = false; + _fixture.Options.ConfigureMonitorOptions = (_, _) => called = true; + + // Act + await _fixture.StartAsync(); + + // Assert + called.Should().BeTrue(); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_ConfigureMonitorOptionsSetsScheduleForUnconvertibleTrigger_SendsThatSchedule() + { + // Arrange + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + _fixture.Options.ConfigureMonitorOptions = (_, options) => + { + options.Interval("0 12 28-31 * *"); + options.TimeZone = "Europe/Vienna"; + }; + + // Act + await _fixture.StartAsync(); + + // Assert + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 28-31 * *"); + monitorConfig.GetProperty("timezone").GetString().Should().Be("Europe/Vienna"); + } + + [Fact] + public async Task StartAsync_ConfigureMonitorOptionsWithoutAnySchedule_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + _fixture.Options.ConfigureMonitorOptions = (_, options) => + { + options.FailureIssueThreshold = 3; + options.RecoveryThreshold = 2; + }; + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_SendMonitorConfigDisabled_DoesNotRunCallback() + { + // Arrange + _fixture.Options.SendMonitorConfig = false; + var called = false; + _fixture.Options.ConfigureMonitorOptions = (_, _) => called = true; + + // Act + await _fixture.StartAsync(); + + // Assert + called.Should().BeFalse(); + } + + [Fact] + public async Task StartAsync_JobWithoutIdentity_UsesClassNameAndLogs() + { + // Arrange + var sut = _fixture.GetSut(); + _fixture.JobDetail = JobBuilder.Create().Build(); + var first = _fixture.GetContext(); + _fixture.JobDetail = JobBuilder.Create().Build(); + var second = _fixture.GetContext(); + + // Act + var firstCheckIn = await sut.StartAsync(first, CancellationToken.None); + var secondCheckIn = await sut.StartAsync(second, CancellationToken.None); + + // Assert + firstCheckIn!.MonitorSlug.Should().Be("monitoredjob"); + secondCheckIn!.MonitorSlug.Should().Be("monitoredjob"); + var entries = _fixture.Logger.Entries.Where(e => e.Message.Contains("has no identity")).ToList(); + entries.Should().HaveCount(2).And.OnlyContain(e => e.Level == SentryLevel.Debug); + string.Format(entries[0].Message, entries[0].Args).Should().Be( + $"Job `{typeof(MonitoredJob).FullName}` has no identity, so its monitor slug is `monitoredjob`. Give the job " + + "an identity (WithIdentity(...)) or set a slug ([SentryMonitorSlug(\"…\")]) to keep the monitor stable."); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsWithoutAttribute_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 * * *"); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsDefaultGroup_UsesJobName() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("Nightly Cleanup").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("nightly-cleanup"); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsRepeatingSimpleTrigger_CapturesCheckIn() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromMinutes(15)).RepeatForever()) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsOneOffTrigger_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create().StartNow().Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsScheduleSentryCantRepresent_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsSimpleTriggerWithRepeatCount_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromMinutes(15)).WithRepeatCount(3)) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsAttributeWithScheduleSentryCantRepresent_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsWithoutIdentity_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + } + + [Theory] + [InlineData("DailyEmail")] + [InlineData("reports.DailyEmail")] + public async Task StartAsync_MonitorAllJobsExcludedByString_CapturesNothing(string excluded) + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add(excluded); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Logger.Entries.Should().Contain(e => e.Message.Contains("ExcludedJobs")); + } + + [Theory] + [InlineData("Daily")] + [InlineData("reports")] + [InlineData("reports.DailyEmail.Other")] + public async Task StartAsync_MonitorAllJobsStringOnlyPartlyMatches_CapturesCheckIn(string excluded) + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add(excluded); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsExcludedByRegex_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add(new Regex("^reports\\.")); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + } + + [Fact] + public async Task StartAsync_ExcludedJobWithAttribute_CapturesCheckIn() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add("reports.DailyEmail"); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsDataMapSlug_UsesThatSlug() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports") + .UsingJobData(SentryMonitorSlugAttribute.JobDataKey, "daily-email").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("daily-email"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartAsync_MonitorAllJobsJobHasTriggersWithDifferentSchedules_CapturesNothing(bool sendMonitorConfig) + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.SendMonitorConfig = sendMonitorConfig; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + _fixture.Logger.Entries.Should().Contain(e => e.Message.Contains("MonitorAllJobs only monitors")); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsJobHasTriggersWithSameSchedule_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 12 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsSendMonitorConfigDisabled_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.SendMonitorConfig = false; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsAttributeJobHasTriggersWithDifferentSchedules_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsOtherJobWithoutAttributeSharesSlug_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + var other = JobBuilder.Create().WithIdentity("DailyEmail", "Reports").Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 18 * * ?", other))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsConfigureMonitorOptionsSetsSchedule_CapturesCheckInWithThatSchedule() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ConfigureMonitorOptions = (_, options) => options.Interval(5, SentryMonitorInterval.Minute); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsExcludedJobSharesSlug_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add("Reports.DailyEmail"); + var other = JobBuilder.Create().WithIdentity("DailyEmail", "Reports").Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 18 * * ?", other))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsWithinTtl_LooksUpTriggersOnce() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + var sut = _fixture.GetSut(); + var context = _fixture.GetContext(); + + // Act + await sut.StartAsync(context, CancellationToken.None); + await sut.StartAsync(context, CancellationToken.None); + + // Assert + _fixture.Scheduler.LookedUpTriggers(1); + } + + [Fact] + public void Finish_HubThrows_DoesNotThrow() + { + // Arrange + var sut = _fixture.GetSut(); + _fixture.Hub.CaptureCheckIn(default!, default, default, default, default, default) + .ThrowsForAnyArgs(new InvalidOperationException("Hub failed")); + + // Act + var act = () => sut.Finish(new JobCheckIn("reports-dailyemail", SentryId.Create()), failed: false, TimeSpan.Zero); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void Finish_Failed_CapturesErrorCheckInWithDuration() + { + // Arrange + var sut = _fixture.GetSut(); + var duration = TimeSpan.FromSeconds(3); + + // Act + sut.Finish(new JobCheckIn("reports-dailyemail", _fixture.CheckInId), failed: true, duration); + + // Assert + _fixture.Hub.Received(1).CaptureCheckIn("reports-dailyemail", CheckInStatus.Error, _fixture.CheckInId, duration); + } + + [Fact] + public void Finish_Succeeded_CapturesOkCheckInWithDuration() + { + // Arrange + var sut = _fixture.GetSut(); + var duration = TimeSpan.FromSeconds(3); + + // Act + sut.Finish(new JobCheckIn("reports-dailyemail", _fixture.CheckInId), failed: false, duration); + + // Assert + _fixture.Hub.Received(1).CaptureCheckIn("reports-dailyemail", CheckInStatus.Ok, _fixture.CheckInId, duration); + } + + [Theory] + [InlineData(typeof(MonitoredJob), "DailyEmail", "reports", null, "reports-dailyemail")] + [InlineData(typeof(MonitoredJob), "Cleanup", "DEFAULT", null, "cleanup")] + [InlineData(typeof(MonitoredJob), "Cleanup", "DEFAULT", "custom.Slug", "custom.Slug")] + [InlineData(typeof(MonitoredJob), "Cleanup", "DEFAULT", " ", "cleanup")] + [InlineData(typeof(CustomSlugJob), "Cleanup", "DEFAULT", null, "custom-slug")] + [InlineData(typeof(CustomSlugJob), "Cleanup", "DEFAULT", "data-map-slug", "data-map-slug")] + [InlineData(typeof(MonitoredJob), "3f2504e0-4f89-11d3-9a0c-0305e82c3301", "DEFAULT", null, "monitoredjob")] + [InlineData(typeof(MonitoredJob), "3f2504e0-4f89-11d3-9a0c-0305e82c3301", "reports", null, "monitoredjob")] + [InlineData(typeof(CustomSlugJob), "3f2504e0-4f89-11d3-9a0c-0305e82c3301", "DEFAULT", null, "custom-slug")] + public void GetMonitorSlug_Job_ReturnsSlug( + Type jobType, + string name, + string group, + string? dataMapSlug, + string expected) + { + var jobBuilder = JobBuilder.Create().OfType(jobType).WithIdentity(name, group); + if (dataMapSlug is not null) + { + jobBuilder.UsingJobData(SentryMonitorSlugAttribute.JobDataKey, dataMapSlug); + } + _fixture.JobDetail = jobBuilder.Build(); + var attribute = jobType.GetCustomAttribute()!; + + JobMonitor.GetMonitorSlug(_fixture.GetContext(), attribute).Should().Be(expected); + } + + private static ITrigger CronTrigger( + string cronExpression, + IJobDetail? job = null, + TimeZoneInfo? timeZone = null, + string? monitorSlug = null) + { + var builder = TriggerBuilder.Create() + .WithCronSchedule(cronExpression, schedule => schedule.InTimeZone(timeZone ?? TimeZoneInfo.Utc)); + if (job is not null) + { + builder.ForJob(job); + } + + if (monitorSlug is not null) + { + builder.UsingJobData(SentryMonitorSlugAttribute.JobDataKey, monitorSlug); + } + + return builder.Build(); + } +} diff --git a/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs b/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs new file mode 100644 index 0000000000..4baf43c582 --- /dev/null +++ b/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs @@ -0,0 +1,32 @@ +namespace Sentry.Quartz.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.Quartz.Tests/Core/TriggerScheduleTests.cs b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs new file mode 100644 index 0000000000..9ad00dc46a --- /dev/null +++ b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs @@ -0,0 +1,169 @@ +namespace Sentry.Quartz.Tests; + +public class TriggerScheduleTests +{ + [Theory] + [InlineData("0 0 12 * * ?", "0 12 * * *")] + [InlineData("0 15 10 ? * *", "15 10 * * *")] + [InlineData("30 0/5 * * * ?", "0-59/5 * * * *")] + [InlineData("0 */15 * * * ?", "*/15 * * * *")] + [InlineData("0 10-40/10 * * * ?", "10-40/10 * * * *")] + [InlineData("0 0 9-17 ? * MON-FRI", "0 9-17 * * 1-5")] + [InlineData("0 0 8 ? * 2,4,6", "0 8 * * 1,3,5")] + [InlineData("0 0 0 ? * sun", "0 0 * * 0")] + [InlineData("0 0 0 ? * 7", "0 0 * * 6")] + [InlineData("0 0 0 ? * 2/2", "0 0 * * 1-6/2")] + [InlineData("0 0 0 ? * */2", "0 0 * * */2")] + [InlineData("0 0 0 ? * 1-7/3", "0 0 * * 0-6/3")] + [InlineData("0 0 6 1,15 JAN-MAR ?", "0 6 1,15 1-3 *")] + [InlineData("0 0 0 1/10 * ?", "0 0 1-31/10 * *")] + [InlineData("0 0 0 * * ? *", "0 0 * * *")] + [InlineData("00 05\t3 ? * 01", "5 3 * * 0")] + public void ToCrontab_SupportedExpression_ReturnsEquivalentCrontab(string cronExpression, string expected) + { + var crontab = TriggerSchedule.ToCrontab(cronExpression); + + crontab.Should().Be(expected); + var interval = () => new SentryMonitorOptions().Interval(crontab!); + interval.Should().NotThrow(); + } + + [Theory] + [InlineData("0 0 0 * * ? 2030")] + [InlineData("0/30 * * * * ?")] + [InlineData("* * * * * ?")] + [InlineData("0 0 12 L * ?")] + [InlineData("0 0 12 LW * ?")] + [InlineData("0 0 12 15W * ?")] + [InlineData("0 0 12 ? * 6L")] + [InlineData("0 0 12 ? * 6#3")] + [InlineData("0 0 12 ? * MON/2")] + [InlineData("0 0 12 ? * MON-FRI/2")] + [InlineData("0 0 12 1 JAN/2 ?")] + [InlineData("0 0 22-2 * * ?")] + [InlineData("0 0 5-5 * * ?")] + [InlineData("0 0 12 ? * 7/2")] + [InlineData("0 0 12 ? * ?")] + [InlineData("0 60 12 * * ?")] + [InlineData("0 0 12 ? * 0")] + [InlineData("0 0 12 * *")] + [InlineData("")] + [InlineData(null)] + public void ToCrontab_ExpressionSentryCantRepresent_ReturnsNull(string? cronExpression) + { + TriggerSchedule.ToCrontab(cronExpression).Should().BeNull(); + } + + [Theory] + [InlineData(1, 1, SentryMonitorInterval.Minute)] + [InlineData(90, 90, SentryMonitorInterval.Minute)] + [InlineData(120, 2, SentryMonitorInterval.Hour)] + [InlineData(36 * 60, 36, SentryMonitorInterval.Hour)] + [InlineData(48 * 60, 2, SentryMonitorInterval.Day)] + public void ToInterval_RepeatsForeverInWholeMinutes_ReturnsInterval( + int minutes, + int expectedInterval, + SentryMonitorInterval expectedUnit) + { + var trigger = (ISimpleTrigger)TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromMinutes(minutes)).RepeatForever()) + .Build(); + + TriggerSchedule.ToInterval(trigger).Should().Be((expectedInterval, expectedUnit)); + } + + [Fact] + public void ToInterval_IntervalNotWholeMinutes_ReturnsNull() + { + var trigger = (ISimpleTrigger)TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromSeconds(90)).RepeatForever()) + .Build(); + + TriggerSchedule.ToInterval(trigger).Should().BeNull(); + } + + [Fact] + public void ToInterval_RepeatCountLimited_ReturnsNull() + { + var trigger = (ISimpleTrigger)TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromHours(1)).WithRepeatCount(5)) + .Build(); + + TriggerSchedule.ToInterval(trigger).Should().BeNull(); + } + + [Fact] + public void ToIanaTimeZoneId_Utc_ReturnsUtc() + { + TriggerSchedule.ToIanaTimeZoneId(TimeZoneInfo.Utc).Should().Be("UTC"); + } + + [Fact] + public void ToIanaTimeZoneId_CustomTimeZone_ReturnsNull() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("Custom", TimeSpan.FromHours(3), "Custom", "Custom"); + + TriggerSchedule.ToIanaTimeZoneId(timeZone).Should().BeNull(); + } + +#if NET6_0_OR_GREATER + [Theory] + [InlineData("Europe/Berlin", "Europe/Berlin")] + [InlineData("W. Europe Standard Time", "Europe/Berlin")] + public void ToIanaTimeZoneId_SystemTimeZone_ReturnsIanaId(string timeZoneId, string expected) + { + var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + + TriggerSchedule.ToIanaTimeZoneId(timeZone).Should().Be(expected); + } +#endif + + [Fact] + public void ToSchedule_CronTrigger_ReturnsCrontabAndTimeZone() + { + var trigger = TriggerBuilder.Create() + .WithCronSchedule("0 30 6 ? * MON-FRI", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + var monitorConfig = MonitorConfigJson.Render(TriggerSchedule.ToSchedule(trigger)!.Apply); + + monitorConfig.GetProperty("schedule").GetProperty("type").GetString().Should().Be("crontab"); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("30 6 * * 1-5"); + monitorConfig.GetProperty("timezone").GetString().Should().Be("UTC"); + } + + [Fact] + public void ToSchedule_SimpleTrigger_ReturnsInterval() + { + var trigger = TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromHours(2)).RepeatForever()) + .Build(); + + var monitorConfig = MonitorConfigJson.Render(TriggerSchedule.ToSchedule(trigger)!.Apply); + + monitorConfig.GetProperty("schedule").GetProperty("type").GetString().Should().Be("interval"); + monitorConfig.GetProperty("schedule").GetProperty("value").GetInt32().Should().Be(2); + monitorConfig.GetProperty("schedule").GetProperty("unit").GetString().Should().Be("hour"); + } + + [Fact] + public void ToSchedule_TriggerWithCalendar_ReturnsNull() + { + var trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .WithCalendar("holidays") + .Build(); + + TriggerSchedule.ToSchedule(trigger).Should().BeNull(); + } + + [Fact] + public void ToSchedule_CalendarIntervalTrigger_ReturnsNull() + { + var trigger = TriggerBuilder.Create() + .WithCalendarIntervalSchedule(schedule => schedule.WithInterval(1, IntervalUnit.Day)) + .Build(); + + TriggerSchedule.ToSchedule(trigger).Should().BeNull(); + } +} diff --git a/test/Sentry.Quartz.Tests/JobMonitorQuartz4Tests.cs b/test/Sentry.Quartz.Tests/JobMonitorQuartz4Tests.cs new file mode 100644 index 0000000000..d76dda49ef --- /dev/null +++ b/test/Sentry.Quartz.Tests/JobMonitorQuartz4Tests.cs @@ -0,0 +1,21 @@ +namespace Sentry.Quartz.Tests; + +public class JobMonitorQuartz4Tests +{ + [Fact] + public async Task StartAsync_OtherJobTypeCantBeResolved_CapturesCheckInWithMonitorConfig() + { + // Arrange + var hub = Substitute.For(); + var context = MiddlewareTestHelpers.CreateContext(); + var removedJob = JobBuilder.Create().OfType(new JobType("Removed.Job, Removed")).WithIdentity("Removed").Build(); + var removedJobTrigger = TriggerBuilder.Create().ForJob(removedJob).WithCronSchedule("0 0 6 * * ?").Build(); + context.Scheduler.ReturnsTriggers([(context.JobDetail, context.Trigger), (removedJob, removedJobTrigger)]); + + // Act + await new JobMonitor(new SentryQuartzOptions(), hub).StartAsync(context, CancellationToken.None); + + // Assert + hub.ReceivedConfigureMonitorOptions("cleanup").Should().NotBeNull(); + } +} diff --git a/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs new file mode 100644 index 0000000000..371fb3af90 --- /dev/null +++ b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs @@ -0,0 +1,27 @@ +namespace Sentry.Quartz.Tests; + +internal static class MiddlewareTestHelpers +{ + public static IJobExecutionContext CreateContext(CancellationToken cancellationToken = default) where TJob : IJob + { + var jobDetail = JobBuilder.Create().WithIdentity("Cleanup").Build(); + var trigger = TriggerBuilder.Create() + .ForJob(jobDetail) + .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + var scheduler = Substitute.For(); + scheduler.ReturnsTriggers([(jobDetail, trigger)]); + + var context = Substitute.For(); + context.JobDetail.Returns(jobDetail); + context.Trigger.Returns(trigger); + context.Scheduler.Returns(scheduler); + context.MergedJobDataMap.Returns(new JobDataMap()); + context.CancellationToken.Returns(cancellationToken); + return context; + } + + // The rest of the pipeline, which completes or throws the given exception + public static JobExecutionDelegate Next(Exception? throwException = null) => + (_, _) => throwException is null ? default : ValueTask.FromException(throwException); +} diff --git a/test/Sentry.Quartz.Tests/QuartzTestApi.cs b/test/Sentry.Quartz.Tests/QuartzTestApi.cs new file mode 100644 index 0000000000..c6d32991bd --- /dev/null +++ b/test/Sentry.Quartz.Tests/QuartzTestApi.cs @@ -0,0 +1,55 @@ +namespace Sentry.Quartz.Tests; + +// The Quartz 4 side of the test helpers whose APIs differ between Quartz 3 and 4 +internal static class QuartzTestApi +{ + public static TriggerBuilder WithCalendar(this TriggerBuilder builder, string calendarName) where TJob : IJob => + builder.WithCalendarName(calendarName); + + public static void ReturnsTriggers(this IScheduler scheduler, IReadOnlyList<(IJobDetail Job, ITrigger Trigger)> triggers) + { + var headers = triggers + .Select(t => new TriggerHeader(t.Trigger.Key, t.Job.Key, null, "CRON", TriggerState.Normal, + t.Trigger.StartTimeUtc, null, null, null, null, t.Trigger.Priority, null, null, 0)) + .ToList(); + scheduler.QueryTriggers(Arg.Any(), Arg.Any()) + .Returns(new PagedResult(headers, HasMore: false)); + scheduler.GetJobDetails(Arg.Any>(), Arg.Any()) + .Returns(call => triggers.Select(t => t.Job).DistinctBy(job => job.Key) + .Where(job => call.Arg>().Contains(job.Key)).ToList()); + scheduler.GetTriggers(Arg.Any>(), Arg.Any()) + .Returns(call => triggers.Select(t => t.Trigger) + .Where(trigger => call.Arg>().Contains(trigger.Key)).ToList()); + } + + public static void TriggerLookupThrows(this IScheduler scheduler, Exception exception) => + scheduler.QueryTriggers(Arg.Any(), Arg.Any()) + .Returns(_ => ValueTask.FromException>(exception)); + + public static void DidNotLookUpTriggers(this IScheduler scheduler) => + _ = scheduler.DidNotReceiveWithAnyArgs().QueryTriggers(default!, default); + + public static void LookedUpTriggers(this IScheduler scheduler, int count) => + _ = scheduler.ReceivedWithAnyArgs(count).QueryTriggers(default!, default); +} + +internal class NoOpJob : IJob +{ + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => default; +} + +[SentryMonitorSlug] +internal class MonitoredJob : NoOpJob; + +[SentryMonitorSlug("custom-slug")] +internal class CustomSlugJob : NoOpJob; + +[SentryMonitorSlug(SendMonitorConfig = false)] +internal class NoMonitorConfigJob : NoOpJob; + +[SentryMonitorSlug] +internal class FailingJob : IJob +{ + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Job failed"); +} diff --git a/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj b/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj new file mode 100644 index 0000000000..2c993200ae --- /dev/null +++ b/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj @@ -0,0 +1,22 @@ + + + + + net10.0 + enable + + + + + + + + + + + + + + + + diff --git a/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs new file mode 100644 index 0000000000..113d3332c1 --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs @@ -0,0 +1,106 @@ +namespace Sentry.Quartz.Tests; + +public class SentryCronJobMiddlewareTests +{ + private readonly IHub _hub = Substitute.For(); + private readonly SentryId _checkInId = SentryId.Create(); + + public SentryCronJobMiddlewareTests() + { + _hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(_checkInId); + } + + private SentryCronJobMiddleware GetSut() => new(new JobMonitor(new SentryQuartzOptions(), _hub)); + + [Fact] + public async Task Invoke_JobWithoutAttribute_RunsJobWithoutCheckIns() + { + // Arrange + var ran = false; + + // Act + await GetSut().Invoke(MiddlewareTestHelpers.CreateContext(), (_, _) => + { + ran = true; + return default; + }, CancellationToken.None); + + // Assert + ran.Should().BeTrue(); + _hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task Invoke_JobSucceeds_CapturesInProgressThenOkCheckIn() + { + // Act + await GetSut().Invoke(MiddlewareTestHelpers.CreateContext(), MiddlewareTestHelpers.Next(), CancellationToken.None); + + // Assert + _hub.ReceivedConfigureMonitorOptions("cleanup").Should().NotBeNull(); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Ok, _hub.ReceivedInProgressCheckInId("cleanup"), Arg.Any()); + } + + [Fact] + public async Task Invoke_JobThrows_CapturesErrorCheckInAndRethrows() + { + // Arrange + var exception = new InvalidOperationException("Job failed"); + + // Act + var act = () => GetSut().Invoke(MiddlewareTestHelpers.CreateContext(), MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); + + // Assert + (await act.Should().ThrowAsync()).Which.Should().BeSameAs(exception); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Error, _hub.ReceivedInProgressCheckInId("cleanup"), Arg.Any()); + } + + [Fact] + public async Task Invoke_JobCancelled_CapturesErrorCheckIn() + { + // Arrange + var cancelled = new CancellationToken(canceled: true); + + // Act + await GetSut().Invoke(MiddlewareTestHelpers.CreateContext(cancelled), MiddlewareTestHelpers.Next(), cancelled); + + // Assert + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Error, _hub.ReceivedInProgressCheckInId("cleanup"), Arg.Any()); + } + + [Fact] + public async Task Invoke_InProgressCheckInNotCaptured_FinishesWithSameNonEmptyId() + { + // Arrange + _hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(SentryId.Empty); + + // Act + await GetSut().Invoke(MiddlewareTestHelpers.CreateContext(), MiddlewareTestHelpers.Next(), CancellationToken.None); + + // Assert + var checkInId = _hub.ReceivedInProgressCheckInId("cleanup"); + checkInId.Should().NotBeNull().And.NotBe(SentryId.Empty); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Ok, checkInId, Arg.Any()); + } + + [Fact] + public async Task Invoke_StartingCheckInThrows_RunsJob() + { + // Arrange + var hub = Substitute.For(); + hub.CaptureCheckIn(default!, default, default, default, default, default) + .ThrowsForAnyArgs(new InvalidOperationException("Hub failed")); + var sut = new SentryCronJobMiddleware(new JobMonitor(new SentryQuartzOptions(), hub)); + var ran = false; + + // Act + await sut.Invoke(MiddlewareTestHelpers.CreateContext(), (_, _) => + { + ran = true; + return default; + }, CancellationToken.None); + + // Assert + ran.Should().BeTrue(); + } +} diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs new file mode 100644 index 0000000000..ec5847fb9a --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -0,0 +1,238 @@ +using System.Collections.Specialized; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.AspNetCore; +using Sentry.Extensions.Logging; + +namespace Sentry.Quartz.Tests; + +// Runs a real in-memory scheduler set up through AddSentry +public class SentryQuartzBuilderExtensionsTests +{ + private readonly IHub _hub = Substitute.For(); + private readonly SentryId _checkInId = SentryId.Create(); + private readonly TaskCompletionSource _jobCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public SentryQuartzBuilderExtensionsTests() + { + _hub.IsEnabled.Returns(true); + _hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(call => + { + var status = call.ArgAt(1); + if (status != CheckInStatus.InProgress) + { + _jobCompleted.TrySetResult(status); + } + + return _checkInId; + }); + } + + [Fact] + public async Task AddSentry_CronTriggerJobSucceeds_CapturesCheckInsWithCrontab() + { + // Arrange + var second = DateTimeOffset.UtcNow.AddSeconds(3).Second; + + // Act + var status = await RunUntilCheckInCompletes("Cleanup", null, trigger => trigger + .WithCronSchedule($"{second} * * * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc))); + + // Assert + status.Should().Be(CheckInStatus.Ok); + var monitorConfig = MonitorConfigJson.Render(_hub.ReceivedConfigureMonitorOptions("cleanup")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("* * * * *"); + monitorConfig.GetProperty("timezone").GetString().Should().Be("UTC"); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Ok, _hub.ReceivedInProgressCheckInId("cleanup"), Arg.Any()); + } + + [Fact] + public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalAndException() + { + // Act + var status = await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger + .StartNow() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromMinutes(15)).RepeatForever())); + + // Assert + status.Should().Be(CheckInStatus.Error); + var monitorConfig = MonitorConfigJson.Render(_hub.ReceivedConfigureMonitorOptions("billing-sync")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetInt32().Should().Be(15); + monitorConfig.GetProperty("schedule").GetProperty("unit").GetString().Should().Be("minute"); + _hub.Received(1).CaptureEvent(Arg.Is(e => e.Exception is InvalidOperationException)); + } + + [Fact] + public async Task AddSentry_MonitorAllJobs_CapturesCheckInsForJobWithoutAttribute() + { + // Arrange + var second = DateTimeOffset.UtcNow.AddSeconds(3).Second; + var options = new SentryQuartzOptions { MonitorAllJobs = true }; + + // Act + var status = await RunUntilCheckInCompletes("Nightly Report", null, trigger => trigger + .WithCronSchedule($"{second} * * * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)), options); + + // Assert + status.Should().Be(CheckInStatus.Ok); + var monitorConfig = MonitorConfigJson.Render(_hub.ReceivedConfigureMonitorOptions("nightly-report")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("* * * * *"); + } + + [Fact] + public async Task AddSentry_MonitoredJob_CapturesCheckInsInJobScope() + { + // Arrange + var jobScope = Substitute.For(); + _hub.PushScope().Returns(jobScope); + + // Act + await RunUntilCheckInCompletes("Cleanup", null, trigger => trigger + .StartNow() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromMinutes(15)).RepeatForever())); + + // Assert + Received.InOrder(() => + { + _hub.PushScope(); + _hub.CaptureCheckIn("cleanup", CheckInStatus.InProgress, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + _hub.CaptureCheckIn("cleanup", CheckInStatus.Ok, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + jobScope.Dispose(); + }); + } + + [Fact] + public async Task AddSentry_JobFailsWithLoggingIntegration_CapturesExceptionOnce() + { + // Act + await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger.StartNow(), configureServices: services => services + .AddSingleton(_hub) + .AddLogging(logging => logging.AddSentry())); + + // Assert + // Quartz also logs the job's own exception, which Sentry drops as the same exception instance + var exceptions = _hub.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IHub.CaptureEvent)) + .Select(call => ((SentryEvent)call.GetArguments()[0]!).Exception) + .ToList(); + exceptions.Should().NotBeEmpty(); + exceptions.Should().AllSatisfy(exception => exception.Should().BeOfType() + .And.BeSameAs(exceptions[0])); + } + + [Fact] + public void AddSentry_LoggingOptionsOfAnyType_GetLogEntryFilter() + { + // Arrange + var services = new ServiceCollection(); + services.AddQuartz(quartz => quartz.AddSentry(new SentryQuartzOptions(), _hub)); + + // Act + using var provider = services.BuildServiceProvider(); + + // Assert + provider.GetServices>().Should().ContainSingle(); + provider.GetServices>().Should().ContainSingle(); + } + + [Fact] + public void AddSentry_UnrelatedOptions_StillResolve() + { + // Arrange + var services = new ServiceCollection(); + services.Configure(options => options.Value = 1); + services.AddQuartz(quartz => quartz.AddSentry(new SentryQuartzOptions(), _hub)); + + // Act + using var provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService>().Value.Value.Should().Be(1); + } + + [Fact] + public async Task AddSentry_JobWithoutAttribute_CapturesNoCheckIns() + { + // Arrange + using var jobRan = new SemaphoreSlim(0, 1); + UnmonitoredSignallingJob.Signal = jobRan; + var scheduler = await BuildScheduler("Unmonitored", null, trigger => trigger.StartNow()); + + // Act + await scheduler.Start(); + try + { + var jobExecuted = await jobRan.WaitAsync(TimeSpan.FromSeconds(15)); + jobExecuted.Should().BeTrue("the job should run"); + } + finally + { + await scheduler.Shutdown(waitForJobsToComplete: true); + } + + // Assert + _hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + private async Task RunUntilCheckInCompletes( + string name, + string? group, + Action> configureTrigger, + SentryQuartzOptions? options = null, + Action? configureServices = null) where TJob : IJob + { + var scheduler = await BuildScheduler(name, group, configureTrigger, options, configureServices); + await scheduler.Start(); + try + { + var completed = await Task.WhenAny(_jobCompleted.Task, Task.Delay(TimeSpan.FromSeconds(70))); + completed.Should().BeSameAs(_jobCompleted.Task, "the job should run"); + return await _jobCompleted.Task; + } + finally + { + await scheduler.Shutdown(waitForJobsToComplete: true); + } + } + + private async Task BuildScheduler( + string name, + string? group, + Action> configureTrigger, + SentryQuartzOptions? options = null, + Action? configureServices = null) where TJob : IJob + { + var services = new ServiceCollection(); + configureServices?.Invoke(services); + var properties = new NameValueCollection { ["quartz.scheduler.instanceName"] = Guid.NewGuid().ToString() }; + services.AddQuartz(properties, quartz => + { + quartz.UseInMemoryStore(); + quartz.AddSentry(options ?? new SentryQuartzOptions(), _hub); + + var jobKey = new JobKey(name, group ?? JobKey.DefaultGroup); + quartz.AddJob(job => job.WithIdentity(jobKey)); + quartz.AddTrigger(trigger => configureTrigger(trigger.ForJob(jobKey))); + }); + + var provider = services.BuildServiceProvider(); + return await provider.GetRequiredService().GetScheduler(); + } + + private sealed class UnrelatedOptions + { + public int Value { get; set; } + } + + private sealed class UnmonitoredSignallingJob : IJob + { + public static SemaphoreSlim? Signal { get; set; } + + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) + { + Signal?.Release(); + return default; + } + } +} diff --git a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs new file mode 100644 index 0000000000..c299807f12 --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs @@ -0,0 +1,114 @@ +namespace Sentry.Quartz.Tests; + +public class SentryScopeMiddlewareTests +{ + private readonly IHub _hub = Substitute.For(); + private readonly Scope _scope = new(); + private readonly List _events = []; + + public SentryScopeMiddlewareTests() + { + _hub.IsEnabled.Returns(true); + var pushedScope = Substitute.For(); + pushedScope.When(s => s.Dispose()).Do(_ => _events.Add("dispose")); + _hub.PushScope().Returns(_ => + { + _events.Add("push"); + return pushedScope; + }); + _hub.When(h => h.ConfigureScope(Arg.Any>(), Arg.Any())) + .Do(call => call.Arg>()(_scope, call.Arg())); + _hub.When(h => h.CaptureEvent(Arg.Any())).Do(_ => _events.Add("capture")); + } + + [Fact] + public async Task Invoke_RunsJobInNewScopeAndTrace() + { + // Arrange + var traceId = _scope.PropagationContext.TraceId; + var sut = new SentryScopeMiddleware(_hub); + + // Act + await sut.Invoke(MiddlewareTestHelpers.CreateContext(), (_, _) => + { + _events.Add("next"); + return default; + }, CancellationToken.None); + + // Assert + _events.Should().Equal("push", "next", "dispose"); + _scope.Tags.Should().Contain(SentryScopeMiddleware.JobTag, "DEFAULT.Cleanup"); + _scope.PropagationContext.TraceId.Should().NotBe(traceId); + } + + [Fact] + public async Task Invoke_JobThrows_CapturesExceptionInJobScopeAndRethrows() + { + // Arrange + var exception = new InvalidOperationException("Job failed"); + var sut = new SentryScopeMiddleware(_hub); + + // Act + var act = () => sut.Invoke(MiddlewareTestHelpers.CreateContext(), MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); + + // Assert + (await act.Should().ThrowAsync()).Which.Should().BeSameAs(exception); + _events.Should().Equal("push", "capture", "dispose"); + _hub.Received(1).CaptureEvent(Arg.Is(e => e.Exception == exception)); + exception.Data[Mechanism.MechanismKey].Should().Be(SentryScopeMiddleware.MechanismType); + exception.Data[Mechanism.HandledKey].Should().Be(false); + exception.Data[Mechanism.TerminalKey].Should().Be(false); + } + + [Fact] + public async Task Invoke_JobCancelled_DoesNotCaptureException() + { + // Arrange + var cancelled = new CancellationToken(canceled: true); + var sut = new SentryScopeMiddleware(_hub); + + // Act + var act = () => sut.Invoke(MiddlewareTestHelpers.CreateContext(cancelled), MiddlewareTestHelpers.Next(new OperationCanceledException(cancelled)), cancelled).AsTask(); + + // Assert + await act.Should().ThrowAsync(); + _hub.DidNotReceive().CaptureEvent(Arg.Any()); + } + + [Fact] + public async Task Invoke_GlobalModeJobThrows_CapturesExceptionAndRethrows() + { + // Arrange + var exception = new InvalidOperationException("Job failed"); + var sut = new SentryScopeMiddleware(_hub, new SentryOptions { IsGlobalModeEnabled = true }); + + // Act + var act = () => sut.Invoke(MiddlewareTestHelpers.CreateContext(), MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); + + // Assert + (await act.Should().ThrowAsync()).Which.Should().BeSameAs(exception); + _events.Should().Equal("capture"); + _hub.Received(1).CaptureEvent(Arg.Is(e => e.Exception == exception)); + } + + [Fact] + public async Task Invoke_GlobalMode_RunsJobWithoutTouchingScope() + { + // Arrange + var traceId = _scope.PropagationContext.TraceId; + var sut = new SentryScopeMiddleware(_hub, new SentryOptions { IsGlobalModeEnabled = true }); + + // Act + await sut.Invoke(MiddlewareTestHelpers.CreateContext(), (_, _) => + { + _events.Add("next"); + return default; + }, CancellationToken.None); + + // Assert + _events.Should().Equal("next"); + _hub.DidNotReceiveWithAnyArgs().ConfigureScope(default(Action)!, default!); + _scope.Tags.Should().NotContainKey(SentryScopeMiddleware.JobTag); + _scope.PropagationContext.TraceId.Should().Be(traceId); + } +} diff --git a/test/Sentry.Quartz.Tests/TriggerScheduleQuartz4Tests.cs b/test/Sentry.Quartz.Tests/TriggerScheduleQuartz4Tests.cs new file mode 100644 index 0000000000..69aa885548 --- /dev/null +++ b/test/Sentry.Quartz.Tests/TriggerScheduleQuartz4Tests.cs @@ -0,0 +1,37 @@ +namespace Sentry.Quartz.Tests; + +// Quartz 4 reads '*' and '?' alike in the day fields, and runs on either day when both restrict +public class TriggerScheduleQuartz4Tests +{ + [Theory] + [InlineData("0 0 12 * * *", "0 12 * * *")] + [InlineData("0 0 12 * * MON", "0 12 * * 1")] + [InlineData("0 0 12 1 * *", "0 12 1 * *")] + [InlineData("0 0 12 1 * MON", "0 12 1 * 1")] + [InlineData("0 0 12 1,15 * MON-FRI", "0 12 1,15 * 1-5")] + [InlineData("0 0 12 */2 * MON", "0 12 1-31/2 * 1")] + [InlineData("0 0 12 1 * */2", "0 12 1 * 0-6/2")] + [InlineData("0 0 12 */10,5 * */3", "0 12 1-31/10,5 * 0-6/3")] + [InlineData("0 0 12 */2 * ?", "0 12 */2 * *")] + [InlineData("0 0 12 */2 * *", "0 12 */2 * *")] + [InlineData("0 0 12 5,* * MON", "0 12 * * 1")] + [InlineData("0 0 12 ? * MON", "0 12 * * 1")] + [InlineData("0 0 12 1 * ?", "0 12 1 * *")] + public void ToCrontab_Quartz4DayFields_ReturnsEquivalentCrontab(string cronExpression, string expected) + { + var crontab = TriggerSchedule.ToCrontab(cronExpression); + + crontab.Should().Be(expected); + var interval = () => new SentryMonitorOptions().Interval(crontab!); + interval.Should().NotThrow(); + } + + [Theory] + [InlineData("0 0 12 ? * ?")] + [InlineData("0 0 12 5,? * MON")] + [InlineData("0 0 12 *,L * MON")] + public void ToCrontab_DayFieldsSentryCantRepresent_ReturnsNull(string cronExpression) + { + TriggerSchedule.ToCrontab(cronExpression).Should().BeNull(); + } +} diff --git a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt new file mode 100644 index 0000000000..83666fd730 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -0,0 +1,28 @@ +namespace Sentry.Quartz +{ + public class SentryJobListener : Quartz.IJobListener + { + public SentryJobListener() { } + public SentryJobListener(Sentry.Quartz.SentryQuartzOptions options) { } + public string Name { get; } + public System.Threading.Tasks.Task JobExecutionVetoed(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobToBeExecuted(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobWasExecuted(Quartz.IJobExecutionContext context, Quartz.JobExecutionException? jobException, System.Threading.CancellationToken cancellationToken = default) { } + } + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class SentryMonitorSlugAttribute : System.Attribute + { + public const string JobDataKey = "SentryMonitorSlug"; + public SentryMonitorSlugAttribute(string? monitorSlug = null) { } + public string? MonitorSlug { get; } + public bool SendMonitorConfig { get; set; } + } + public class SentryQuartzOptions + { + public SentryQuartzOptions() { } + public System.Action? ConfigureMonitorOptions { get; set; } + public System.Collections.Generic.IList ExcludedJobs { get; set; } + public bool MonitorAllJobs { get; set; } + public bool SendMonitorConfig { get; set; } + } +} \ No newline at end of file diff --git a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt new file mode 100644 index 0000000000..83666fd730 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt @@ -0,0 +1,28 @@ +namespace Sentry.Quartz +{ + public class SentryJobListener : Quartz.IJobListener + { + public SentryJobListener() { } + public SentryJobListener(Sentry.Quartz.SentryQuartzOptions options) { } + public string Name { get; } + public System.Threading.Tasks.Task JobExecutionVetoed(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobToBeExecuted(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobWasExecuted(Quartz.IJobExecutionContext context, Quartz.JobExecutionException? jobException, System.Threading.CancellationToken cancellationToken = default) { } + } + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class SentryMonitorSlugAttribute : System.Attribute + { + public const string JobDataKey = "SentryMonitorSlug"; + public SentryMonitorSlugAttribute(string? monitorSlug = null) { } + public string? MonitorSlug { get; } + public bool SendMonitorConfig { get; set; } + } + public class SentryQuartzOptions + { + public SentryQuartzOptions() { } + public System.Action? ConfigureMonitorOptions { get; set; } + public System.Collections.Generic.IList ExcludedJobs { get; set; } + public bool MonitorAllJobs { get; set; } + public bool SendMonitorConfig { get; set; } + } +} \ No newline at end of file diff --git a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt new file mode 100644 index 0000000000..83666fd730 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -0,0 +1,28 @@ +namespace Sentry.Quartz +{ + public class SentryJobListener : Quartz.IJobListener + { + public SentryJobListener() { } + public SentryJobListener(Sentry.Quartz.SentryQuartzOptions options) { } + public string Name { get; } + public System.Threading.Tasks.Task JobExecutionVetoed(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobToBeExecuted(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobWasExecuted(Quartz.IJobExecutionContext context, Quartz.JobExecutionException? jobException, System.Threading.CancellationToken cancellationToken = default) { } + } + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class SentryMonitorSlugAttribute : System.Attribute + { + public const string JobDataKey = "SentryMonitorSlug"; + public SentryMonitorSlugAttribute(string? monitorSlug = null) { } + public string? MonitorSlug { get; } + public bool SendMonitorConfig { get; set; } + } + public class SentryQuartzOptions + { + public SentryQuartzOptions() { } + public System.Action? ConfigureMonitorOptions { get; set; } + public System.Collections.Generic.IList ExcludedJobs { get; set; } + public bool MonitorAllJobs { get; set; } + public bool SendMonitorConfig { get; set; } + } +} \ No newline at end of file diff --git a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt new file mode 100644 index 0000000000..83666fd730 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -0,0 +1,28 @@ +namespace Sentry.Quartz +{ + public class SentryJobListener : Quartz.IJobListener + { + public SentryJobListener() { } + public SentryJobListener(Sentry.Quartz.SentryQuartzOptions options) { } + public string Name { get; } + public System.Threading.Tasks.Task JobExecutionVetoed(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobToBeExecuted(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobWasExecuted(Quartz.IJobExecutionContext context, Quartz.JobExecutionException? jobException, System.Threading.CancellationToken cancellationToken = default) { } + } + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class SentryMonitorSlugAttribute : System.Attribute + { + public const string JobDataKey = "SentryMonitorSlug"; + public SentryMonitorSlugAttribute(string? monitorSlug = null) { } + public string? MonitorSlug { get; } + public bool SendMonitorConfig { get; set; } + } + public class SentryQuartzOptions + { + public SentryQuartzOptions() { } + public System.Action? ConfigureMonitorOptions { get; set; } + public System.Collections.Generic.IList ExcludedJobs { get; set; } + public bool MonitorAllJobs { get; set; } + public bool SendMonitorConfig { get; set; } + } +} \ No newline at end of file diff --git a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.Net4_8.verified.txt new file mode 100644 index 0000000000..83666fd730 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -0,0 +1,28 @@ +namespace Sentry.Quartz +{ + public class SentryJobListener : Quartz.IJobListener + { + public SentryJobListener() { } + public SentryJobListener(Sentry.Quartz.SentryQuartzOptions options) { } + public string Name { get; } + public System.Threading.Tasks.Task JobExecutionVetoed(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobToBeExecuted(Quartz.IJobExecutionContext context, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task JobWasExecuted(Quartz.IJobExecutionContext context, Quartz.JobExecutionException? jobException, System.Threading.CancellationToken cancellationToken = default) { } + } + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class SentryMonitorSlugAttribute : System.Attribute + { + public const string JobDataKey = "SentryMonitorSlug"; + public SentryMonitorSlugAttribute(string? monitorSlug = null) { } + public string? MonitorSlug { get; } + public bool SendMonitorConfig { get; set; } + } + public class SentryQuartzOptions + { + public SentryQuartzOptions() { } + public System.Action? ConfigureMonitorOptions { get; set; } + public System.Collections.Generic.IList ExcludedJobs { get; set; } + public bool MonitorAllJobs { get; set; } + public bool SendMonitorConfig { get; set; } + } +} \ No newline at end of file diff --git a/test/Sentry.Quartz3.Tests/ApiApprovalTests.verify.cs b/test/Sentry.Quartz3.Tests/ApiApprovalTests.verify.cs new file mode 100644 index 0000000000..50779564ce --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.verify.cs @@ -0,0 +1,10 @@ +namespace Sentry.Quartz.Tests; + +public class ApiApprovalTests +{ + [Fact] + public Task Run() + { + return typeof(SentryJobListener).Assembly.CheckApproval(); + } +} diff --git a/test/Sentry.Quartz3.Tests/QuartzTestApi.cs b/test/Sentry.Quartz3.Tests/QuartzTestApi.cs new file mode 100644 index 0000000000..fded7c5390 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/QuartzTestApi.cs @@ -0,0 +1,54 @@ +using Quartz.Impl.Matchers; + +namespace Sentry.Quartz.Tests; + +// The Quartz 3 side of the test helpers whose APIs differ between Quartz 3 and 4 +internal static class QuartzTestApi +{ + public static TriggerBuilder WithCalendar(this TriggerBuilder builder, string calendarName) => + builder.ModifiedByCalendar(calendarName); + + public static void ReturnsTriggers(this IScheduler scheduler, IReadOnlyList<(IJobDetail Job, ITrigger Trigger)> triggers) + { + var jobs = triggers.Select(t => t.Job).GroupBy(job => job.Key).Select(group => group.First()).ToList(); + scheduler.GetJobKeys(Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult>(jobs.Select(job => job.Key).ToList())); + // Quartz 3 annotates GetJobDetail as nullable on some target frameworks only +#nullable disable + scheduler.GetJobDetail(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult(jobs.First(job => job.Key.Equals(call.Arg())))); +#nullable restore + scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult>(triggers + .Where(t => t.Job.Key.Equals(call.Arg())).Select(t => t.Trigger).ToList())); + } + + public static void TriggerLookupThrows(this IScheduler scheduler, Exception exception) => + scheduler.GetJobKeys(Arg.Any>(), Arg.Any()).ThrowsAsync(exception); + + public static void DidNotLookUpTriggers(this IScheduler scheduler) => + _ = scheduler.DidNotReceiveWithAnyArgs().GetJobKeys(default!, default); + + public static void LookedUpTriggers(this IScheduler scheduler, int count) => + _ = scheduler.ReceivedWithAnyArgs(count).GetJobKeys(default!, default); +} + +internal class NoOpJob : IJob +{ + public Task Execute(IJobExecutionContext context) => Task.CompletedTask; +} + +[SentryMonitorSlug] +internal class MonitoredJob : NoOpJob; + +[SentryMonitorSlug("custom-slug")] +internal class CustomSlugJob : NoOpJob; + +[SentryMonitorSlug(SendMonitorConfig = false)] +internal class NoMonitorConfigJob : NoOpJob; + +[SentryMonitorSlug] +internal class FailingJob : IJob +{ + public Task Execute(IJobExecutionContext context) => throw new InvalidOperationException("Job failed"); +} diff --git a/test/Sentry.Quartz3.Tests/Sentry.Quartz3.Tests.csproj b/test/Sentry.Quartz3.Tests/Sentry.Quartz3.Tests.csproj new file mode 100644 index 0000000000..b7029b79ea --- /dev/null +++ b/test/Sentry.Quartz3.Tests/Sentry.Quartz3.Tests.csproj @@ -0,0 +1,24 @@ + + + + $(CurrentTfms) + $(TargetFrameworks);net48 + enable + Sentry.Quartz.Tests + + + + + + + + + + + + + + + + + diff --git a/test/Sentry.Quartz3.Tests/SentryJobListenerTests.cs b/test/Sentry.Quartz3.Tests/SentryJobListenerTests.cs new file mode 100644 index 0000000000..e1c82783ce --- /dev/null +++ b/test/Sentry.Quartz3.Tests/SentryJobListenerTests.cs @@ -0,0 +1,170 @@ +using System.Collections.Specialized; +using Quartz.Impl; + +namespace Sentry.Quartz.Tests; + +public class SentryJobListenerTests +{ + private readonly IHub _hub = Substitute.For(); + private readonly SentryId _checkInId = SentryId.Create(); + private readonly TimeSpan _jobRunTime = TimeSpan.FromSeconds(3); + + public SentryJobListenerTests() + { + _hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(_checkInId); + } + + private SentryJobListener GetSut() => new(new SentryQuartzOptions(), _hub); + + private IJobExecutionContext GetContext() where TJob : IJob + { + var jobDetail = JobBuilder.Create().WithIdentity("Cleanup").Build(); + var trigger = TriggerBuilder.Create().ForJob(jobDetail).WithSimpleSchedule(s => s.WithIntervalInHours(1).RepeatForever()).Build(); + var scheduler = Substitute.For(); + scheduler.ReturnsTriggers([(jobDetail, trigger)]); + + var context = Substitute.For(); + context.JobDetail.Returns(jobDetail); + context.Trigger.Returns(trigger); + context.Scheduler.Returns(scheduler); + context.MergedJobDataMap.Returns(new JobDataMap()); + context.JobRunTime.Returns(_jobRunTime); + return context; + } + + [Theory] + [InlineData(false, CheckInStatus.Ok)] + [InlineData(true, CheckInStatus.Error)] + public async Task JobWasExecuted_AfterJobToBeExecuted_CapturesCheckInWithDuration(bool failed, CheckInStatus expected) + { + // Arrange + var sut = GetSut(); + var context = GetContext(); + await sut.JobToBeExecuted(context); + + // Act + await sut.JobWasExecuted(context, failed ? new JobExecutionException("failed") : null); + await sut.JobWasExecuted(context, null); + + // Assert + _hub.ReceivedConfigureMonitorOptions("cleanup").Should().NotBeNull(); + _hub.Received(1).CaptureCheckIn("cleanup", expected, _hub.ReceivedInProgressCheckInId("cleanup"), _jobRunTime); + _hub.ReceivedWithAnyArgs(2).CaptureCheckIn(default!, default); + } + + [Fact] + public async Task JobWasExecuted_JobCancelled_CapturesErrorCheckIn() + { + // Arrange + var sut = GetSut(); + var context = GetContext(); + context.CancellationToken.Returns(new CancellationToken(canceled: true)); + await sut.JobToBeExecuted(context); + + // Act + await sut.JobWasExecuted(context, null); + + // Assert + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Error, _hub.ReceivedInProgressCheckInId("cleanup"), _jobRunTime); + } + + [Fact] + public async Task JobToBeExecuted_ConfigureMonitorOptions_ReceivesListenerContext() + { + // Arrange + IJobExecutionContext? receivedContext = null; + var sut = new SentryJobListener(new SentryQuartzOptions + { + ConfigureMonitorOptions = (context, _) => receivedContext = context + }, _hub); + var context = GetContext(); + + // Act + await sut.JobToBeExecuted(context); + + // Assert + receivedContext.Should().BeSameAs(context); + } + + [Fact] + public async Task JobWasExecuted_InProgressCheckInNotCaptured_FinishesWithSameNonEmptyId() + { + // Arrange + _hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(SentryId.Empty); + var sut = GetSut(); + var context = GetContext(); + await sut.JobToBeExecuted(context); + + // Act + await sut.JobWasExecuted(context, null); + + // Assert + var checkInId = _hub.ReceivedInProgressCheckInId("cleanup"); + checkInId.Should().NotBeNull().And.NotBe(SentryId.Empty); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Ok, checkInId, _jobRunTime); + } + + [Fact] + public async Task JobWasExecuted_JobWithoutAttribute_CapturesNothing() + { + // Arrange + var sut = GetSut(); + var context = GetContext(); + await sut.JobToBeExecuted(context); + + // Act + await sut.JobWasExecuted(context, null); + + // Assert + _hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Theory] + [InlineData("3.6.0.0", false)] + [InlineData("3.15.1.0", false)] + [InlineData("4.0.0.0", true)] + [InlineData(null, false)] + public void IsUnsupportedQuartzVersion_Version_ReturnsWhetherMajorIsAtLeastFour(string? version, bool expected) + { + SentryJobListener.IsUnsupportedQuartzVersion(version is null ? null : Version.Parse(version)).Should().Be(expected); + } + + [Fact] + public async Task Scheduler_CronTrigger_CapturesCheckInsWithCrontab() + { + // Arrange + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(call => + { + if (call.ArgAt(1) != CheckInStatus.InProgress) + { + completed.TrySetResult(call.ArgAt(1)); + } + + return _checkInId; + }); + var properties = new NameValueCollection { ["quartz.scheduler.instanceName"] = Guid.NewGuid().ToString() }; + var scheduler = await new StdSchedulerFactory(properties).GetScheduler(); + scheduler.ListenerManager.AddJobListener(GetSut()); + var second = DateTimeOffset.UtcNow.AddSeconds(3).Second; + await scheduler.ScheduleJob( + JobBuilder.Create().WithIdentity("Cleanup").Build(), + TriggerBuilder.Create().WithCronSchedule($"{second} * * * * ?", s => s.InTimeZone(TimeZoneInfo.Utc)).Build()); + + // Act + await scheduler.Start(); + try + { + (await Task.WhenAny(completed.Task, Task.Delay(TimeSpan.FromSeconds(70)))).Should().BeSameAs(completed.Task); + } + finally + { + await scheduler.Shutdown(waitForJobsToComplete: true); + } + + // Assert + (await completed.Task).Should().Be(CheckInStatus.Ok); + var monitorConfig = MonitorConfigJson.Render(_hub.ReceivedConfigureMonitorOptions("cleanup")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("* * * * *"); + } +} diff --git a/test/Sentry.Quartz3.Tests/TriggerScheduleQuartz3Tests.cs b/test/Sentry.Quartz3.Tests/TriggerScheduleQuartz3Tests.cs new file mode 100644 index 0000000000..10807f762e --- /dev/null +++ b/test/Sentry.Quartz3.Tests/TriggerScheduleQuartz3Tests.cs @@ -0,0 +1,28 @@ +namespace Sentry.Quartz.Tests; + +// Quartz 3 needs exactly one day field to be '?', so expressions only Quartz 4 accepts aren't converted +public class TriggerScheduleQuartz3Tests +{ + [Theory] + [InlineData("0 0 12 * * *")] + [InlineData("0 0 12 1 * MON")] + [InlineData("0 0 12 */2 * MON")] + [InlineData("0 0 12 * * MON")] + [InlineData("0 0 12 1 * *")] + [InlineData("0 0 12 ? * ?")] + public void ToCrontab_Quartz4DayFields_ReturnsNull(string cronExpression) + { + TriggerSchedule.ToCrontab(cronExpression).Should().BeNull(); + } + + [Theory] + [InlineData("0 0 12 ? * MON", "0 12 * * 1")] + [InlineData("0 0 12 1 * ?", "0 12 1 * *")] + [InlineData("0 0 12 * * ?", "0 12 * * *")] + [InlineData("0 0 12 ? * *", "0 12 * * *")] + [InlineData("0 0 12 */2 * ?", "0 12 */2 * *")] + public void ToCrontab_OneDayFieldIsQuestionMark_ReturnsEquivalentCrontab(string cronExpression, string expected) + { + TriggerSchedule.ToCrontab(cronExpression).Should().Be(expected); + } +} diff --git a/test/Sentry.Testing/TestEnvironment.cs b/test/Sentry.Testing/TestEnvironment.cs index 42e426863d..e70c571fd8 100644 --- a/test/Sentry.Testing/TestEnvironment.cs +++ b/test/Sentry.Testing/TestEnvironment.cs @@ -13,20 +13,4 @@ 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/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 83d266df17..01aca7e373 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1357,6 +1357,7 @@ namespace Sentry public StringOrRegex(System.Text.RegularExpressions.Regex regex) { } public StringOrRegex(string stringOrRegex) { } public bool IsRegex { get; } + public System.Text.RegularExpressions.Regex? Regex { get; } public override bool Equals(object? obj) { } public override int GetHashCode() { } public override string ToString() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt index 83d266df17..01aca7e373 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt @@ -1357,6 +1357,7 @@ namespace Sentry public StringOrRegex(System.Text.RegularExpressions.Regex regex) { } public StringOrRegex(string stringOrRegex) { } public bool IsRegex { get; } + public System.Text.RegularExpressions.Regex? Regex { get; } public override bool Equals(object? obj) { } public override int GetHashCode() { } public override string ToString() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 83d266df17..01aca7e373 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1357,6 +1357,7 @@ namespace Sentry public StringOrRegex(System.Text.RegularExpressions.Regex regex) { } public StringOrRegex(string stringOrRegex) { } public bool IsRegex { get; } + public System.Text.RegularExpressions.Regex? Regex { get; } public override bool Equals(object? obj) { } public override int GetHashCode() { } public override string ToString() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 83d266df17..01aca7e373 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1357,6 +1357,7 @@ namespace Sentry public StringOrRegex(System.Text.RegularExpressions.Regex regex) { } public StringOrRegex(string stringOrRegex) { } public bool IsRegex { get; } + public System.Text.RegularExpressions.Regex? Regex { get; } public override bool Equals(object? obj) { } public override int GetHashCode() { } public override string ToString() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index a6f1c7b7a0..6b0b0af988 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -1338,6 +1338,7 @@ namespace Sentry public StringOrRegex(System.Text.RegularExpressions.Regex regex) { } public StringOrRegex(string stringOrRegex) { } public bool IsRegex { get; } + public System.Text.RegularExpressions.Regex? Regex { get; } public override bool Equals(object? obj) { } public override int GetHashCode() { } public override string ToString() { } diff --git a/test/Sentry.Tests/Internals/ConcurrentQueueLiteTests.cs b/test/Sentry.Tests/Internals/ConcurrentQueueLiteTests.cs index e931799aab..6d5bbd006d 100644 --- a/test/Sentry.Tests/Internals/ConcurrentQueueLiteTests.cs +++ b/test/Sentry.Tests/Internals/ConcurrentQueueLiteTests.cs @@ -159,4 +159,24 @@ public async Task TestConcurrency() // Assert received.Should().Be(count * 2); } + + [Fact] + public void EnqueueRange_AppendsSnapshotInOrder() + { + // Arrange + var source = new ConcurrentQueueLite(); + source.Enqueue(1); + source.Enqueue(2); + source.Enqueue(3); + + var destination = new ConcurrentQueueLite(); + destination.Enqueue(0); + + // Act + destination.EnqueueRange(source); + + // Assert + destination.ToArray().Should().Equal(0, 1, 2, 3); + source.ToArray().Should().Equal(1, 2, 3); + } } diff --git a/test/Sentry.Tests/Internals/MonitorSlugTests.cs b/test/Sentry.Tests/Internals/MonitorSlugTests.cs new file mode 100644 index 0000000000..ef3645fb53 --- /dev/null +++ b/test/Sentry.Tests/Internals/MonitorSlugTests.cs @@ -0,0 +1,30 @@ +namespace Sentry.Tests.Internals; + +public class MonitorSlugTests +{ + // Expected values come from Relay's slugify_monitor_slug tests + [Theory] + [InlineData("my-monitor", "my-monitor")] + [InlineData("My Monitor", "my-monitor")] + [InlineData("my_job", "my_job")] + [InlineData(" leading and trailing ", "leading-and-trailing")] + [InlineData("a - ! - b", "a-b")] + [InlineData("--a--b--", "a-b")] + [InlineData("_-_a_-_", "a")] + [InlineData("a\tb\nc", "a-b-c")] + [InlineData("a\u001cb", "a-b")] + [InlineData("Café Crème", "cafe-creme")] + [InlineData("straße", "strae")] + [InlineData("file", "file")] + [InlineData("Hello, World!", "hello-world")] + [InlineData("a.b.c", "abc")] + [InlineData("ÀÉÎÕÜ", "aeiou")] + [InlineData("日本語", null)] + [InlineData("!!!", null)] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_")] + public void From_Value_MatchesRelay(string value, string expected) + { + MonitorSlug.From(value).Should().Be(expected); + } +} diff --git a/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs b/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs index 061fdaddca..bed1cff3f2 100644 --- a/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs +++ b/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs @@ -389,4 +389,70 @@ public void FromEvent_EmptyByteAttachment_DoesNotAddAttachment() .Should() .Be(0); } + + [Fact] + public async Task Serialization_EmptyNonSeekableStreamAttachment_DropsAttachment() + { + // Arrange + using var envelope = CreateEventEnvelopeWithEmptyNonSeekableAttachment(); + + // Act + var serialized = await envelope.SerializeToStringAsync(_testOutputLogger, _fakeClock); + + // Assert + serialized.Should().NotContain("empty.jpg").And.NotContain("\n\n"); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(serialized)); + using var deserialized = await Envelope.DeserializeAsync(stream); + deserialized.Items.Should().ContainSingle() + .Which.TryGetType().Should().Be(EnvelopeItem.TypeValueEvent); + } + + [Fact] + public async Task Serialization_EmptyNonSeekableStreamAttachmentSynchronously_DropsAttachment() + { + // Arrange + using var envelope = CreateEventEnvelopeWithEmptyNonSeekableAttachment(); + + // Act + var serialized = envelope.SerializeToString(_testOutputLogger, _fakeClock); + + // Assert + serialized.Should().NotContain("empty.jpg").And.NotContain("\n\n"); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(serialized)); + using var deserialized = await Envelope.DeserializeAsync(stream); + deserialized.Items.Should().ContainSingle() + .Which.TryGetType().Should().Be(EnvelopeItem.TypeValueEvent); + } + + [Fact] + public async Task Serialization_EmptyAttachmentOfKnownLengthAfterRoundTrip_KeepsAttachment() + { + // Arrange + var attachment = new SentryAttachment( + AttachmentType.Default, + new StreamAttachmentContent(new MemoryStream()), + "empty.log", + "text/plain"); + using var envelope = Envelope.FromAttachment(SentryId.Create(), attachment, _testOutputLogger); + using var cached = new MemoryStream(Encoding.UTF8.GetBytes( + await envelope.SerializeToStringAsync(_testOutputLogger, _fakeClock))); + using var deserialized = await Envelope.DeserializeAsync(cached); + + // Act + var serialized = await deserialized.SerializeToStringAsync(_testOutputLogger, _fakeClock); + + // Assert + serialized.Should().Contain("empty.log"); + } + + private Envelope CreateEventEnvelopeWithEmptyNonSeekableAttachment() + { + var attachment = new SentryAttachment( + AttachmentType.Default, + new StreamAttachmentContent(new NonSeekableReadStream(new MemoryStream())), + "empty.jpg", + "image/jpeg"); + + return Envelope.FromEvent(new SentryEvent(), [attachment], logger: _testOutputLogger); + } } diff --git a/test/Sentry.Tests/ScopeTests.cs b/test/Sentry.Tests/ScopeTests.cs index f99f3b55dc..c0b587694b 100644 --- a/test/Sentry.Tests/ScopeTests.cs +++ b/test/Sentry.Tests/ScopeTests.cs @@ -180,7 +180,8 @@ public void Clone_EveryFieldHandled() { string[] copied = [ - "Options", "OnEvaluating", "Level", "_request", "_contexts", "_user", "Environment", "_fallbackTransactionName", "_transaction", "PropagationContext", "SessionUpdate", "Sdk", + "Options", "OnEvaluating", "Level", "_request", "_contexts", "_user", + "Environment", "_fallbackTransactionName", "_transaction", "PropagationContext", "SessionUpdate", "_sdk", "Fingerprint", "_breadcrumbs", "_extra", "_tags", "_attachments", "_lazyEventProcessors", "_lazyTransactionProcessors", "_lazyExceptionProcessors", ]; @@ -197,6 +198,76 @@ public void Clone_EveryFieldHandled() fields.Should().BeEquivalentTo(copied.Concat(notCopied)); } + [Fact] + public void Clone_PopulatedScope_CollectionsAreCopiedAndIndependent() + { + // Arrange + var scope = new Scope(); + scope.SetTag("tag", "value"); + scope.SetExtra("extra", "value"); + scope.AddAttachment(new SentryAttachment(AttachmentType.Default, new ByteAttachmentContent([1]), "file.txt", null)); + scope.Contexts["context"] = "value"; + + // Act + var clone = scope.Clone(); + clone.SetTag("other-tag", "other-value"); + clone.SetExtra("other-extra", "other-value"); + clone.Contexts["other-context"] = "other-value"; + clone.AddAttachment(new SentryAttachment(AttachmentType.Default, new ByteAttachmentContent([2]), "other.txt", null)); + + // Assert + clone.Tags.Should().Contain("tag", "value"); + clone.Extra.Should().Contain("extra", "value"); + clone.Attachments.Should().HaveCount(2); + clone.Contexts.Should().ContainKey("context"); + + scope.Tags.Should().NotContainKey("other-tag"); + scope.Extra.Should().NotContainKey("other-extra"); + scope.Contexts.Should().NotContainKey("other-context"); + scope.Attachments.Should().ContainSingle(); + } + + [Fact] + public void LazyCollections_ConcurrentAccess_ReturnSameInstance() + { + // Arrange + var scope = new Scope(); + + // Act + var results = Enumerable.Range(0, 64) + .AsParallel() + .WithDegreeOfParallelism(16) + .Select(_ => (Tags: scope.Tags, Extra: scope.Extra, Attachments: scope.Attachments, Contexts: scope.Contexts, Sdk: scope.Sdk)) + .ToArray(); + + // Assert + results.Select(r => r.Tags).Distinct().Should().ContainSingle(); + results.Select(r => r.Extra).Distinct().Should().ContainSingle(); + results.Select(r => r.Attachments).Distinct().Should().ContainSingle(); + results.Select(r => r.Contexts).Distinct().Should().ContainSingle(); + results.Select(r => r.Sdk).Distinct().Should().ContainSingle(); + } + + [Fact] + public void Clone_Breadcrumbs_CopiedInOrderAndIndependent() + { + // Arrange + var scope = new Scope(); + for (var i = 0; i < 5; i++) + { + scope.AddBreadcrumb(new Breadcrumb($"crumb {i}", "default")); + } + + // Act + var clone = scope.Clone(); + clone.AddBreadcrumb(new Breadcrumb("clone only", "default")); + scope.AddBreadcrumb(new Breadcrumb("original only", "default")); + + // Assert + clone.Breadcrumbs.Select(b => b.Message).Should().Equal("crumb 0", "crumb 1", "crumb 2", "crumb 3", "crumb 4", "clone only"); + scope.Breadcrumbs.Select(b => b.Message).Should().Equal("crumb 0", "crumb 1", "crumb 2", "crumb 3", "crumb 4", "original only"); + } + [Fact] public void Clone_ScopeSyncEnabled_ObserverNotCalled() { diff --git a/test/Sentry.Tests/SentryMonitorOptionsTests.cs b/test/Sentry.Tests/SentryMonitorOptionsTests.cs index 184be0f9fe..c9d42a3816 100644 --- a/test/Sentry.Tests/SentryMonitorOptionsTests.cs +++ b/test/Sentry.Tests/SentryMonitorOptionsTests.cs @@ -90,6 +90,52 @@ public void Interval_ValidCrontab_DoesNotThrow(string crontab) options.Interval(crontab); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CopyTo_ConfiguredOptions_CopiesEverything(bool crontab) + { + // Arrange + var options = new SentryMonitorOptions + { + CheckInMargin = TimeSpan.FromMinutes(1), + MaxRuntime = TimeSpan.FromMinutes(2), + FailureIssueThreshold = 3, + RecoveryThreshold = 4, + TimeZone = "Europe/Vienna", + Owner = "team:crons" + }; + if (crontab) + { + options.Interval("0 12 * * *"); + } + else + { + options.Interval(5, SentryMonitorInterval.Hour); + } + + var target = new SentryMonitorOptions(); + + // Act + options.CopyTo(target); + + // Assert + ToJson(target).Should().Be(ToJson(options)); + } + + private static string ToJson(SentryMonitorOptions options) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + options.WriteTo(writer, null); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + [Fact] public void Interval_SetMoreThanOnce_Throws() {