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** | [](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry/) |
| **Sentry.OpenTelemetry.Exporter** | [](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry-otlp/) |
| **Sentry.Profiling** | [](https://www.nuget.org/packages/Sentry.Profiling) | [](https://www.nuget.org/packages/Sentry.Profiling) | [](https://www.nuget.org/packages/Sentry.Profiling) | [](https://docs.sentry.io/platforms/dotnet/profiling/) |
+| **Sentry.Quartz** | [](https://www.nuget.org/packages/Sentry.Quartz) | [](https://www.nuget.org/packages/Sentry.Quartz) | [](https://www.nuget.org/packages/Sentry.Quartz) | [](https://docs.sentry.io/platforms/dotnet/crons/quartz/) |
+| **Sentry.Quartz3** | [](https://www.nuget.org/packages/Sentry.Quartz3) | [](https://www.nuget.org/packages/Sentry.Quartz3) | [](https://www.nuget.org/packages/Sentry.Quartz3) | [](https://docs.sentry.io/platforms/dotnet/crons/quartz/) |
| **Sentry.Serilog** | [](https://www.nuget.org/packages/Serilog) | [](https://www.nuget.org/packages/Sentry.Serilog) | [](https://www.nuget.org/packages/Sentry.Serilog) | [](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()
{