From 29f984ff0bef7861097c8aec3e45fe2755ef557f Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Wed, 21 Oct 2026 23:10:54 +0200 Subject: [PATCH 01/35] ci(craft): publish each NuGet package as its own craft target (#5753) Craft's nuget target is dropping --skip-duplicate (getsentry/craft#686). With our single nuget target, a retry after a partly failed publish would stop at the first package that's already on nuget.org. With workspaces enabled, craft splits the target into one target per package, in dependency order, and a retry skips the packages that already went out. Craft releases without workspace support pass the new keys through and keep publishing everything as one target, so this is safe to merge before the craft release. Co-authored-by: Claude Opus 5.5 --- .craft.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.craft.yml b/.craft.yml index 269ddb1498..4cfeef220f 100644 --- a/.craft.yml +++ b/.craft.yml @@ -3,6 +3,15 @@ changelogPolicy: auto preReleaseCommand: bash scripts/bump-version.sh 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 From 04cac0d9681ea2214608d0b02e1bb8e0d1e47cd7 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Wed, 21 Oct 2026 23:49:50 +0200 Subject: [PATCH 02/35] feat: add Sentry.Quartz for cron monitoring of Quartz.NET 4.x jobs (#5505) --------- Co-authored-by: James Crosswell Co-authored-by: Claude Opus 5 Co-authored-by: Dan Fuller Co-authored-by: Ricardo Colombo Oliveira --- .craft.yml | 4 + .generated.NoMobile.slnx | 3 + .github/ISSUE_TEMPLATE/bug_report.yml | 1 + README.md | 1 + Sentry-CI-Build-Linux-NoMobile.slnf | 3 + Sentry-CI-Build-Linux.slnf | 3 + Sentry-CI-Build-Windows-arm64.slnf | 3 + Sentry-CI-Build-Windows.slnf | 3 + Sentry-CI-Build-macOS.slnf | 3 + Sentry-CI-CodeQL.slnf | 1 + Sentry.slnx | 3 + SentryAspNetCore.slnf | 1 + SentryNoMobile.slnf | 3 + SentryNoSamples.slnf | 2 + samples/Sentry.Samples.Quartz/Program.cs | 104 +++ .../Properties/launchSettings.json | 41 ++ samples/Sentry.Samples.Quartz/README.md | 8 + .../Sentry.Samples.Quartz.csproj | 26 + .../Sentry.Samples.Quartz/appsettings.json | 34 + scripts/generate-solution-filters-config.yaml | 1 + src/Sentry.Quartz/Core/JobMonitor.cs | 364 ++++++++++ .../Core/SentryMonitorSlugAttribute.cs | 41 ++ src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 51 ++ src/Sentry.Quartz/Core/TriggerSchedule.cs | 275 ++++++++ src/Sentry.Quartz/QuartzApi.cs | 43 ++ .../QuartzErrorLogFilterSetup.cs | 23 + src/Sentry.Quartz/Sentry.Quartz.csproj | 24 + src/Sentry.Quartz/SentryCronJobMiddleware.cs | 30 + .../SentryQuartzBuilderExtensions.cs | 50 ++ src/Sentry.Quartz/SentryScopeMiddleware.cs | 49 ++ src/Sentry/Sentry.csproj | 2 + src/Sentry/SentryMonitorOptions.cs | 17 + ...iApprovalTests.Run.DotNet10_0.verified.txt | 24 + .../ApiApprovalTests.verify.cs | 10 + .../Core/JobMonitorTests.cs | 663 ++++++++++++++++++ .../Core/MonitorConfigJson.cs | 32 + .../Core/TriggerScheduleTests.cs | 171 +++++ .../JobMonitorQuartz4Tests.cs | 21 + .../MiddlewareTestHelpers.cs | 27 + test/Sentry.Quartz.Tests/QuartzTestApi.cs | 55 ++ .../Sentry.Quartz.Tests.csproj | 22 + .../SentryCronJobMiddlewareTests.cs | 106 +++ .../SentryQuartzBuilderExtensionsTests.cs | 219 ++++++ .../SentryScopeMiddlewareTests.cs | 114 +++ .../TriggerScheduleQuartz4Tests.cs | 37 + .../Sentry.Tests/SentryMonitorOptionsTests.cs | 46 ++ 46 files changed, 2764 insertions(+) create mode 100644 samples/Sentry.Samples.Quartz/Program.cs create mode 100644 samples/Sentry.Samples.Quartz/Properties/launchSettings.json create mode 100644 samples/Sentry.Samples.Quartz/README.md create mode 100644 samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj create mode 100644 samples/Sentry.Samples.Quartz/appsettings.json create mode 100644 src/Sentry.Quartz/Core/JobMonitor.cs create mode 100644 src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs create mode 100644 src/Sentry.Quartz/Core/SentryQuartzOptions.cs create mode 100644 src/Sentry.Quartz/Core/TriggerSchedule.cs create mode 100644 src/Sentry.Quartz/QuartzApi.cs create mode 100644 src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs create mode 100644 src/Sentry.Quartz/Sentry.Quartz.csproj create mode 100644 src/Sentry.Quartz/SentryCronJobMiddleware.cs create mode 100644 src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs create mode 100644 src/Sentry.Quartz/SentryScopeMiddleware.cs create mode 100644 test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt create mode 100644 test/Sentry.Quartz.Tests/ApiApprovalTests.verify.cs create mode 100644 test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs create mode 100644 test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs create mode 100644 test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs create mode 100644 test/Sentry.Quartz.Tests/JobMonitorQuartz4Tests.cs create mode 100644 test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs create mode 100644 test/Sentry.Quartz.Tests/QuartzTestApi.cs create mode 100644 test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj create mode 100644 test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs create mode 100644 test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs create mode 100644 test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs create mode 100644 test/Sentry.Quartz.Tests/TriggerScheduleQuartz4Tests.cs diff --git a/.craft.yml b/.craft.yml index 4cfeef220f..36992b45e9 100644 --- a/.craft.yml +++ b/.craft.yml @@ -96,3 +96,7 @@ 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/ diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index 288e399aef..fd72089ae0 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -118,6 +118,7 @@ + @@ -168,6 +169,7 @@ + @@ -202,6 +204,7 @@ + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d8dc8f9623..3e80e2c1f9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,6 +29,7 @@ body: - Sentry.NLog - Sentry.OpenTelemetry - Sentry.Profiling + - Sentry.Quartz - Sentry.Serilog - Other validations: diff --git a/README.md b/README.md index 117721f92b..273606efb2 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Sentry SDK for .NET | **Sentry.OpenTelemetry** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![NuGet](https://img.shields.io/nuget/v/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.OpenTelemetry.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry/) | | **Sentry.OpenTelemetry.Exporter** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/v/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry-otlp/) | | **Sentry.Profiling** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/profiling/) | +| **Sentry.Quartz** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/crons/quartz/) | | **Sentry.Serilog** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Serilog.svg)](https://www.nuget.org/packages/Serilog) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/guides/serilog) | ## More Sentry .NET Integrations diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index 6afb1c705a..96a8125b7d 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -32,6 +32,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", @@ -49,6 +50,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -70,6 +72,7 @@ "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.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 2796e38594..b7a4975621 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.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.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -55,6 +56,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -79,6 +81,7 @@ "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.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-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index 19f3cc370c..f9b1f924e6 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -35,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", @@ -57,6 +58,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -78,6 +80,7 @@ "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.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 ea897b0b25..fccd8ebf8e 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -35,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", @@ -57,6 +58,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -81,6 +83,7 @@ "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.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 f8d1358a11..6827a106ce 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -39,6 +39,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", @@ -62,6 +63,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -87,6 +89,7 @@ "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.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 647fe0fa2c..b7d1fc7400 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -22,6 +22,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj" ] diff --git a/Sentry.slnx b/Sentry.slnx index 288e399aef..fd72089ae0 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -118,6 +118,7 @@ + @@ -168,6 +169,7 @@ + @@ -202,6 +204,7 @@ + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index ccd8904461..f1e754f58c 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -21,6 +21,7 @@ "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.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 97defed5d1..76a74169fe 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.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.AspNet\\Sentry.AspNet.csproj", @@ -51,6 +52,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -75,6 +77,7 @@ "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.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 1d2813957f..72d61e8bcf 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -24,6 +24,7 @@ "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.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -50,6 +51,7 @@ "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.Serilog.Tests\\Sentry.Serilog.Tests.csproj", "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", 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/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index e3eaf125bc..a1a52653b4 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -167,6 +167,7 @@ filterConfigs: - "**/Sentry.csproj" - "**/Sentry.Analyzers.csproj" - "**/Sentry.Hangfire.csproj" + - "**/Sentry.Quartz.csproj" - "**/Sentry.Samples.Hangfire.csproj" - "**/Sentry.Testing.csproj" - "**/Sentry.Testing.CrashableApp.csproj" diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs new file mode 100644 index 0000000000..430c243af0 --- /dev/null +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -0,0 +1,364 @@ +using Quartz; +using Sentry.Extensibility; +using Sentry.Infrastructure; + +namespace Sentry.Quartz; + +// Captures the check-ins of jobs with SentryMonitorSlugAttribute +internal sealed class JobMonitor +{ + private const int MaxSlugLength = 50; + + // 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 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); + 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; + } + + configureMonitorOptions = _options.SendMonitorConfig && attribute.SendMonitorConfig + ? await GetMonitorConfigAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) + : 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; + } + } + + // 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(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(IScheduler scheduler, DateTimeOffset readAt, + CancellationToken cancellationToken) + { + var triggersBySlug = new Dictionary>(); + var keys = new HashSet(); + var triggers = await QuartzApi.GetTriggersAsync(scheduler, IsMonitored, cancellationToken).ConfigureAwait(false); + foreach (var (job, trigger) in triggers) + { + keys.Add(trigger.Key); + + // Triggers whose schedule isn't sent don't change the monitor + var attribute = GetAttribute(QuartzApi.GetJobType(job))!; + if (!attribute.SendMonitorConfig + || 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 static bool IsMonitored(IJobDetail job) + { + try + { + return GetAttribute(QuartzApi.GetJobType(job)) is not null; + } + 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 ToSlug(key.Group == JobKey.DefaultGroup ? key.Name : key.Group + "." + key.Name); + } + + fromJobType = true; + return ToSlug(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 _); + + // Produces a slug that Sentry's own slugify leaves unchanged + internal static string? ToSlug(string value) + { + var slug = new StringBuilder(value.Length); + foreach (var c in value.ToLowerInvariant()) + { + if (c is >= 'a' and <= 'z' or >= '0' and <= '9' or '_') + { + slug.Append(c); + } + else if (slug.Length > 0 && slug[slug.Length - 1] != '-') + { + slug.Append('-'); + } + } + + var result = slug.ToString().Trim('-', '_'); + if (result.Length > MaxSlugLength) + { + result = result.Substring(0, MaxSlugLength).Trim('-', '_'); + } + + return result.Length > 0 ? result : null; + } +} + +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..c21f4eb13a --- /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. +/// +/// +/// When no monitor slug is given, the slug is the job's key, lowercased with every run of other characters replaced +/// by a hyphen. 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..2708b6d419 --- /dev/null +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -0,0 +1,51 @@ +using Quartz; + +namespace Sentry.Quartz; + +/// +/// Options for the Sentry Quartz.NET integration. +/// +public class SentryQuartzOptions +{ + /// + /// 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..2c15736eb2 --- /dev/null +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -0,0 +1,50 @@ +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 . + /// + /// + /// 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/Sentry.csproj b/src/Sentry/Sentry.csproj index 6c6ffe39ee..3399691c42 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -174,6 +174,8 @@ + + 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/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..35f64f8b6b --- /dev/null +++ b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -0,0 +1,24 @@ +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 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..4151331442 --- /dev/null +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -0,0 +1,663 @@ +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(); + } + + [SkippableFact] + public async Task StartAsync_JobsShareSlugWithDifferentTimeZones_CapturesCheckInWithoutMonitorConfig() + { + Skip.If(!TestEnvironment.HasTimeZone("America/New_York"), "No time zone database"); + + // 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(); + } + + [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 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); + } + + [Theory] + [InlineData("Nightly Report", "nightly-report")] + [InlineData("Billing.Invoices/Send!", "billing-invoices-send")] + [InlineData("_queue__drain_", "queue__drain")] + [InlineData("-a.-b-", "a-b")] + [InlineData("日本語", null)] + [InlineData("a234567890b234567890c234567890d234567890e234567890f234567890", "a234567890b234567890c234567890d234567890e234567890")] + [InlineData("a234567890b234567890c234567890d234567890e23456789-f234567890", "a234567890b234567890c234567890d234567890e23456789")] + public void ToSlug_Value_ReturnsSlugSentryLeavesUnchanged(string value, string? expected) + { + JobMonitor.ToSlug(value).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..7fd748b4c4 --- /dev/null +++ b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs @@ -0,0 +1,171 @@ +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 + [SkippableTheory] + [InlineData("Europe/Berlin", "Europe/Berlin")] + [InlineData("W. Europe Standard Time", "Europe/Berlin")] + public void ToIanaTimeZoneId_SystemTimeZone_ReturnsIanaId(string timeZoneId, string expected) + { + Skip.If(!TestEnvironment.HasTimeZone(timeZoneId), "No time zone database"); + + 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..928c041433 --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -0,0 +1,219 @@ +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_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(), services => services + .AddSingleton(_hub) + .AddLogging(logging => logging.AddSentry(options => options.InitializeSdk = false))); + + // 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, + Action? configureServices = null) where TJob : IJob + { + var scheduler = await BuildScheduler(name, group, configureTrigger, 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, + 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(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.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() { From 8333e3ba373fa4157ec5177c33f56f87003947cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:03:09 +1300 Subject: [PATCH 03/35] chore: update scripts/update-cli.ps1 to 3.8.1 (#5768) Co-authored-by: GitHub --- Directory.Build.props | 2 +- src/Sentry/Sentry.csproj | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index c6ae6155b2..522879783f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -110,7 +110,7 @@ - 3.8.0 + 3.8.1 $(MSBuildThisFileDirectory)tools\sentry-cli\$(SentryCLIVersion)\ diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 3399691c42..619ad426a5 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -106,13 +106,13 @@ <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) - - - - - - - + + + + + + + From bd407cf1014b365d3558df502421a77b444b6aaa Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Thu, 22 Oct 2026 15:05:39 +0200 Subject: [PATCH 04/35] ci: skip generated code when collecting coverage (#5773) coverlet instruments the SDK assemblies again in every test host, and for each branch it rescans the whole method for sequence points. [GeneratedRegex] turns the crontab pattern into one TryMatchAtCurrentPosition method with 8,070 IL instructions, 682 branches and 1,968 sequence points, about 90% of the instruction x branch cost across Sentry.dll. That costs about 35 s of CPU per test host before any test runs. Excluding [GeneratedCode] skips the generated regex matchers and the System.Text.Json source-generated context. Hand-written code keeps the same coverage, and CodeCoverage.runsettings already excludes the same attribute for the Microsoft collector. Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0092e2cb96..153ca7c52c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -246,7 +246,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 From 4ca260901a35d29c106d6229923d563c54c103b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:03:10 +1300 Subject: [PATCH 05/35] chore(deps): update Cocoa SDK to v9.31.0 (#5769) * chore: update modules/sentry-cocoa to 9.31.0 * chore: regenerate Cocoa bindings for 9.31.0 Cocoa 9.31.0 adds SentryObjCExperimentalOptions.metrickit, which returns SentryObjCMetricKitOptions. That type isn't in the patch script's KeepInterfaces list, so the regenerated bindings failed to compile with CS0246. .NET doesn't expose MetricKit, so drop the property alongside the other option properties that reference types we don't bind. The rest is Objective Sharpie output: serializeScope: and serializeDebugMeta: on the internal serializer API, and the SentryObjCMetricKitHangReportingMode enum. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: GitHub Co-authored-by: Ricardo Colombo Oliveira Co-authored-by: Claude Opus 5.5 --- modules/sentry-cocoa | 2 +- scripts/patch-cocoa-bindings.cs | 2 ++ src/Sentry.Bindings.Cocoa/ApiDefinition.cs | 9 +++++++++ src/Sentry.Bindings.Cocoa/StructsAndEnums.cs | 7 +++++++ 4 files changed, 19 insertions(+), 1 deletion(-) 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/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.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 { From 418367a1b7a0a405990b3c4cf0feba76c75270e6 Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Thu, 22 Oct 2026 23:17:20 +0200 Subject: [PATCH 06/35] ci: skip the device test app in the macOS build and stop serializing verify-api and format-code (#5758) * ci: stop building the device test app in the macOS build job The macOS leg of build.yml built Sentry.Maui.Device.TestApp for net9.0-android, net10.0-android and net10.0-ios, and nothing in build.yml runs it. In the run for #5752 on version7 that was the last 6 minutes of the 24.7-minute Build step, on the critical path. The Linux and Windows filters already exclude the app, and both device test workflows build it themselves. The macOS leg of verify-api builds the same filter, so it no longer builds the app either. The device test path filters now also cover what the app compiles and they didn't list: Sentry.Analyzers, Sentry.Testing.CrashableApp, the nested Directory.Build files and .editorconfig, which can fail the build because warnings are errors. Co-Authored-By: Claude Opus 5.5 * ci: serialize only the jobs that push in verify-api and format-code Both workflows put the whole run in the pr-auto-commit concurrency group, so each waited for the other to finish. On one PR, format-code started 35 minutes after its run was created, and on another verify-api waited 13 minutes. The group exists to keep their git pushes apart, so it now covers only accept-api-changes and the format-code job, and the API test matrix starts right away. Both still push from the head they checked out and fail as non-fast-forward if the branch moved in the meantime. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/device-tests-android.yml | 5 +++++ .github/workflows/device-tests-ios.yml | 5 +++++ .github/workflows/format-code.yml | 11 +++++------ .github/workflows/verify-api.yml | 11 +++++------ Sentry-CI-Build-macOS.slnf | 1 - scripts/generate-solution-filters-config.yaml | 1 + 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index f8b3bd1fc6..d59fa75a9f 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' diff --git a/.github/workflows/device-tests-ios.yml b/.github/workflows/device-tests-ios.yml index d477710cb8..bbc751bcbe 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' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index f2cd3089db..7113382488 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 diff --git a/.github/workflows/verify-api.yml b/.github/workflows/verify-api.yml index fbd9eaba61..5e4ccf5115 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 }}) @@ -120,6 +114,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/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index 6827a106ce..ce671ebede 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -83,7 +83,6 @@ "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", diff --git a/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index a1a52653b4..ea4233d3dc 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -91,6 +91,7 @@ filterConfigs: patterns: - "**/*AndroidTestApp.csproj" - "**/*DeviceTests*.csproj" + - "**/*Sentry.Maui.Device.TestApp.csproj" - "**/*.AspNet.Tests.csproj" - outputPath: Sentry-CI-Build-Windows.slnf From 7ececf8abc68a32f3fb233ee4cb94df956d7303e Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Thu, 22 Oct 2026 23:22:57 +0200 Subject: [PATCH 07/35] ci: install only the .NET SDK in the Unsupported AOT jobs (#5774) The Unsupported AOT jobs run after the whole build-sentry matrix, so their time adds to every build. They only publish a console app against the packages from the macOS job, but they ran the full Setup Environment with Java, the Android SDK, Mono and workloads. version7 dropped that in #5746, and this brings the same job to main. Co-authored-by: Claude Opus 5.5 --- .github/actions/environment/action.yml | 8 -------- .github/workflows/build.yml | 23 ++++++++++++++++------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index c071f2ec3f..3754453a45 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -30,14 +30,6 @@ runs: shell: bash run: sudo ./scripts/install-libssl1.sh - - name: Install Linux ARM 32-bit dependencies - if: ${{ matrix.rid == 'linux-arm' }} - shell: bash - run: | - sudo dpkg --add-architecture armhf - sudo apt-get update - sudo apt-get install -y gcc-arm-linux-gnueabihf libc6:armhf - # See https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md#xcode # Also https://github.com/dotnet/macios/issues/21762#issuecomment-3424033859 (don't reference symlinks) - name: Pin the Xcode Version diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 153ca7c52c..52976f9593 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -365,6 +365,9 @@ jobs: 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 @@ -375,18 +378,24 @@ jobs: - os: windows-latest 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 - with: - submodules: recursive - - name: Setup Environment - uses: ./.github/actions/environment + - 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: Build Native Dependencies - if: ${{ !env.NO_MOBILE }} - uses: ./.github/actions/buildnative + - 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 From 916d3d48fb5e55fdc7d20243b67e6d7e7e772565 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:08 +1300 Subject: [PATCH 08/35] ci: Use craft's built-in version bumping (#5775) * ci: Use craft's built-in version bumping Craft 3.0.0 bumps in Directory.Build.props itself and fails prepare if no version gets bumped (getsentry/craft#923), so the custom bump-version script is no longer needed. Co-Authored-By: Burak Yigit Kaya * ci: Move to craft's changelog and versioning policy keys Co-Authored-By: Burak Yigit Kaya * ci: Use the craft v3 floating tag Co-Authored-By: Burak Yigit Kaya --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Burak Yigit Kaya --- .craft.yml | 8 +++++--- .generated.NoMobile.slnx | 2 -- .github/workflows/release.yml | 2 +- Sentry.slnx | 2 -- scripts/bump-version.ps1 | 13 ------------- scripts/bump-version.sh | 7 ------- 6 files changed, 6 insertions(+), 28 deletions(-) delete mode 100644 scripts/bump-version.ps1 delete mode 100644 scripts/bump-version.sh diff --git a/.craft.yml b/.craft.yml index 36992b45e9..d02883d4ab 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,6 +1,8 @@ -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 diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index fd72089ae0..853302c314 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -125,8 +125,6 @@ - - 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/Sentry.slnx b/Sentry.slnx index fd72089ae0..853302c314 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -125,8 +125,6 @@ - - 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 From aa4bd9aaa7d887a53bf10ad0c1844c94601073f9 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 23 Oct 2026 10:33:47 +1300 Subject: [PATCH 09/35] test: pin System.Security.Cryptography.Pkcs in DiagnosticSource integration tests (#5771) Microsoft.Data.SqlClient 6.1.x pulls in Pkcs 9.0.4 on net10.0, which is affected by GHSA-846f-f764-76mx and GHSA-2fmv-rv2c-59x7. No SqlClient release floors Pkcs high enough to clear them yet. Co-authored-by: Claude Opus 5.5 --- .../Sentry.DiagnosticSource.IntegrationTests.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj b/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj index c7ffb40e0b..b17e6be1eb 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj +++ b/test/Sentry.DiagnosticSource.IntegrationTests/Sentry.DiagnosticSource.IntegrationTests.csproj @@ -11,6 +11,8 @@ + + From 1d52ea0c960b242a349610c4a80f5ef0b1cb2e72 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:03:13 +1300 Subject: [PATCH 10/35] chore: update scripts/update-cli.ps1 to 3.8.2 (#5778) Co-authored-by: GitHub --- Directory.Build.props | 2 +- src/Sentry/Sentry.csproj | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 522879783f..c3a2944a18 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -110,7 +110,7 @@ - 3.8.1 + 3.8.2 $(MSBuildThisFileDirectory)tools\sentry-cli\$(SentryCLIVersion)\ diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 619ad426a5..72683cf27a 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -106,13 +106,13 @@ <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) - - - - - - - + + + + + + + From f080ba40fd39e0b1e189b77011b13f99221f2990 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:03:20 +1300 Subject: [PATCH 11/35] chore: update scripts/update-java.ps1 to 8.61.1 (#5777) Co-authored-by: GitHub --- src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj b/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj index 32a5b12481..bdfb377931 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.61.1 $(BaseIntermediateOutputPath)sdks\$(TargetFramework)\Sentry\Android\$(SentryAndroidSdkVersion)\ From ff0355e4ea2078b24c58cd9bdf9b21bd9c54d7eb Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Fri, 23 Oct 2026 15:54:44 +0200 Subject: [PATCH 12/35] ci: cache the Cocoa SDK build (#5781) The macOS build job and the iOS device tests build the Cocoa SDK from the sentry-cocoa submodule on every run. In the macOS Build step on main, that takes 12.2 of 23.7 minutes on one MSBuild node, and the other two nodes go idle waiting for the Cocoa bindings. version7 caches it since #5746, and this brings the same steps to main. The key covers the selected Xcode, build-sentry-cocoa.sh, sentry-cocoa.xcconfig and the submodule commit. Pushes to main save the cache, so pull requests restore it from there. Release branches still build it from source, because it ships in the Sentry.Bindings.Cocoa package. Co-authored-by: Claude Opus 5.5 --- .github/actions/cocoa-cache-key/action.yml | 13 ++++++++++++ .github/workflows/build.yml | 24 ++++++++++++++++++++++ .github/workflows/device-tests-ios.yml | 19 +++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 .github/actions/cocoa-cache-key/action.yml diff --git a/.github/actions/cocoa-cache-key/action.yml b/.github/actions/cocoa-cache-key/action.yml new file mode 100644 index 0000000000..1862eaa9e2 --- /dev/null +++ b/.github/actions/cocoa-cache-key/action.yml @@ -0,0 +1,13 @@ +name: Cocoa SDK cache key +description: Computes the cache key for the Cocoa SDK that Sentry.Bindings.Cocoa builds from the sentry-cocoa submodule +outputs: + key: + description: Covers the selected Xcode, build-sentry-cocoa.sh, sentry-cocoa.xcconfig and the submodule commit + value: ${{ steps.key.outputs.key }} +runs: + using: composite + + steps: + - id: key + shell: bash + run: echo "key=sentry-cocoa-$(xcodebuild -version | tr -cs '[:alnum:].' '-')-${{ hashFiles('scripts/build-sentry-cocoa.sh', 'scripts/sentry-cocoa.xcconfig') }}-${{ hashFiles('.git/modules/modules/sentry-cocoa/HEAD') }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52976f9593..b3b3ad321b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -209,6 +209,22 @@ jobs: fail-on-cache-miss: true enableCrossOsArchive: true + # The Cocoa SDK is built from the submodule by the Sentry.Bindings.Cocoa project, which takes ~15 minutes + # on a hosted runner. build-sentry-cocoa.sh skips the build when the restored stamp matches. + # Release builds always build it from source, because it ships in the Sentry.Bindings.Cocoa package. + - 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 if: ${{ !matrix.container && !env.NO_MOBILE }} uses: ./.github/actions/buildnative @@ -220,6 +236,14 @@ jobs: id: build run: dotnet build ${{ matrix.slnf }} -c Release --no-restore --nologo -v:minimal -flp:logfile=build.log -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 }} + - name: Upload build logs if: ${{ always() }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/device-tests-ios.yml b/.github/workflows/device-tests-ios.yml index bbc751bcbe..222f969c52 100644 --- a/.github/workflows/device-tests-ios.yml +++ b/.github/workflows/device-tests-ios.yml @@ -75,9 +75,28 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + # 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 }} + - name: Build iOS Test App run: pwsh ./scripts/device-test.ps1 ios -Build + - 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: + path: modules/sentry-cocoa/Carthage + key: ${{ steps.cache-cocoa.outputs.cache-primary-key }} + - name: Run Tests id: first-test-run continue-on-error: true From b41b96c2402f985bffd4c484b8fd0cdfa43449ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:03:23 +1300 Subject: [PATCH 13/35] Bump sharpie.bind.tool from 27.0.0.722 to 27.1.0.18 (#5779) --- updated-dependencies: - dependency-name: sharpie.bind.tool dependency-version: 27.1.0.18 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" ], From b21628fa74f2f09db4683a8ad35e498785a7b099 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:55:35 +1300 Subject: [PATCH 14/35] build(deps): bump the github-actions group across 2 directories with 4 updates (#5780) Bumps the github-actions group with 4 updates in the / directory: [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft), [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [actions/setup-java](https://github.com/actions/setup-java). Bumps the github-actions group with 1 update in the /.github/actions/environment directory: [actions/setup-java](https://github.com/actions/setup-java). Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.34.1 to 2.35.1 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/3f2abdc5703191d12ef2cc013f792f1bd220f44d...0ff75896d43285ca376c7254e8143bac23acd429) Updates `github/codeql-action/init` from 4.38.3 to 4.39.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee) Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee) Updates `actions/setup-java` from 6.0.1 to 6.1.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/de7274f081f381c8f8158605e0321c36c376e2e6...b843ebf9b913146b38da2eaa7aeddf76074ae1ad) Updates `actions/setup-java` from 6.0.1 to 6.1.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/de7274f081f381c8f8158605e0321c36c376e2e6...b843ebf9b913146b38da2eaa7aeddf76074ae1ad) --- updated-dependencies: - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.35.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/init dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/analyze dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/setup-java dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/setup-java dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/environment/action.yml | 2 +- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/device-tests-android.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index 3754453a45..841088e3f1 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -40,7 +40,7 @@ runs: # Java 21 is needed by .NET Android - name: Install Java 21 if: ${{ !matrix.container }} - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: '21' 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..c0fda62203 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/actions/environment - name: Initialize CodeQL - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: csharp @@ -49,6 +49,6 @@ jobs: run: dotnet build Sentry-CI-CodeQL.slnf --no-restore --nologo - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@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 d59fa75a9f..1bcbb3841b 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -166,7 +166,7 @@ jobs: uses: ./.github/actions/environment - name: Select Java 21 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: '21' From d147028c387a8e7c30532771d00f6ec77554cbba Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Tue, 27 Oct 2026 01:41:21 +0100 Subject: [PATCH 15/35] ci: stop installing and logging what the jobs don't use (#5807) * ci: stop installing Mono Every Linux and macOS job installed Mono, but nothing runs it. #4545 added it for the net4-console integration test, which ran the .NET Framework app through Mono there. Two weeks later #4461 made that test Windows-only, and the logs show it skipped on Linux and macOS. The .NET Framework unit tests only build on Windows too. On Ubuntu the install downloads 53 MB from the package mirror. On October 23 that download took 15 to 21 minutes in 4 linux-x64 legs and 1 Android device test job. The chmod after it only repaired the /usr/share/dotnet permissions that installing Mono had changed. Co-Authored-By: Claude Opus 5.5 * ci: stop writing detailed text build logs The Build step wrote a detailed text log next to the binlog, 1.9 GB on macOS, and uploaded both. The binlog holds the same events and warnings, and nothing reads the text log. The MSBuild job and the integration test builds wrote one too. Co-Authored-By: Claude Opus 5.5 * ci: install only the workloads each job needs Setup Environment ran dotnet workload restore without a project, so it evaluated every project in Sentry.slnx in every job. The Linux legs without mobile targets installed android, maui-android, maui-tizen, macos, wasm-tools and wasm-tools-net9, and Windows installed ios, maccatalyst and macos. The environment action now requires the solution filters or project files the job builds, and restores only their workloads. dotnet workload restore doesn't accept a solution filter, so the step passes the projects in it, and names the filter if it finds none. Jobs that build everything pass Sentry.slnx. The Android test app is restored only when the job builds the tests that build it, and the legs without mobile targets skip Java and the Android SDK. CodeQL, format-code and verify-api no longer run a second, unfiltered workload restore after the action. Jobs that build a MAUI project still get maui-tizen, because dotnet workload restore adds it for every MAUI project. Measured on top of the other CI changes, Setup Environment went from about 190 to 89 seconds in the trim job and from 170-180 to 71-78 in the Android device test build. Co-Authored-By: Claude Opus 5.5 * Apply suggestion from @jamescrosswell --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: James Crosswell --- .github/actions/environment/action.yml | 64 +++++++++++--------- .github/workflows/build.yml | 20 +++--- .github/workflows/codeql-analysis.yml | 7 +-- .github/workflows/device-tests-android.yml | 5 ++ .github/workflows/device-tests-ios.yml | 2 + .github/workflows/format-code.yml | 7 +-- .github/workflows/playwright-blazor-wasm.yml | 2 +- .github/workflows/verify-api.yml | 6 +- .github/workflows/vulnerabilities.yml | 2 + integration-test/common.ps1 | 2 +- 10 files changed, 68 insertions(+), 49 deletions(-) diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index 841088e3f1..e5cba99e50 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -1,5 +1,9 @@ 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 @@ -39,37 +43,20 @@ runs: # Java 21 is needed by .NET Android - name: Install Java 21 - if: ${{ !matrix.container }} + 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 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: @@ -87,14 +74,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/workflows/build.yml b/.github/workflows/build.yml index b3b3ad321b..492828b395 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,6 +145,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 @@ -234,7 +236,7 @@ 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 @@ -249,9 +251,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 @@ -357,6 +357,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: Sentry-CI-Build-Windows.slnf - name: Build Native Dependencies uses: ./.github/actions/buildnative @@ -366,7 +368,7 @@ 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 -t:Restore,Build,Pack -p:Configuration=Release --nologo -v:minimal -p:CopyLocalLockFileAssemblies=true -bl:msbuild.binlog - name: Test MSBuild uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 @@ -378,9 +380,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ runner.os }}-msbuild-logs - path: | - msbuild.log - msbuild.binlog + path: msbuild.binlog if-no-files-found: ignore # Unsupported Native AOT runtimes should have SentryNative auto-disabled @@ -404,7 +404,7 @@ jobs: # 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). + # Setup Environment (Java, Android SDK, workloads). steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -459,6 +459,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 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c0fda62203..f5cf5afdc5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -33,6 +33,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: Sentry-CI-CodeQL.slnf - name: Initialize CodeQL uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 @@ -40,10 +42,7 @@ jobs: 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 diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index 1bcbb3841b..3303724bea 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -75,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 @@ -164,6 +167,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: integration-test/net9-maui/Sentry.Maui.Device.IntegrationTestApp.csproj - name: Select Java 21 uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/device-tests-ios.yml b/.github/workflows/device-tests-ios.yml index 222f969c52..f1ac40fa39 100644 --- a/.github/workflows/device-tests-ios.yml +++ b/.github/workflows/device-tests-ios.yml @@ -74,6 +74,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj integration-test/net9-maui/Sentry.Maui.Device.IntegrationTestApp.csproj # See build.yml - name: Get Cocoa SDK cache key diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 7113382488..e8099fec36 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -28,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/verify-api.yml b/.github/workflows/verify-api.yml index 5e4ccf5115..3a8c6bc212 100644 --- a/.github/workflows/verify-api.yml +++ b/.github/workflows/verify-api.yml @@ -44,6 +44,8 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment + with: + projects: ${{ matrix.slnf }} - name: Restore sentry-native cache id: cache-native @@ -62,9 +64,7 @@ jobs: uses: ./.github/actions/buildnative - name: Restore .NET Dependencies - run: | - dotnet workload restore - dotnet restore ${{ matrix.slnf }} --nologo + run: dotnet restore ${{ matrix.slnf }} --nologo - name: Build run: dotnet build ${{ matrix.slnf }} -c Release --no-restore --nologo -v:minimal 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/integration-test/common.ps1 b/integration-test/common.ps1 index d09721ce15..908a1bda4c 100644 --- a/integration-test/common.ps1 +++ b/integration-test/common.ps1 @@ -124,7 +124,7 @@ BeforeAll { Write-Host "::group::${action}ing $project" try { - dotnet $action $project -flp:logfile=build.log ` + dotnet $action $project ` -c Release ` --nologo ` --framework $TargetFramework ` From 14c086d39667ddab6334b18ca831e288796b2e77 Mon Sep 17 00:00:00 2001 From: Schalk Pienaar <59658723+schalkpienaar@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:16:57 +1300 Subject: [PATCH 16/35] ci: install tzdata in the Alpine CI image (#5818) * ci: install tzdata in the Alpine CI image The linux-musl CI image has no time zone database, so only UTC resolves and tests that need a real IANA zone are skipped on musl. Refs #5756 Co-Authored-By: Claude Opus 5.5 * Apply suggestion from @jamescrosswell --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: James Crosswell --- .github/alpine/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 \ From c1680a3eb6991699878f9a4487cb89f8ad2b2a1b Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Tue, 27 Oct 2026 11:50:29 +0100 Subject: [PATCH 17/35] ci: speed up the Windows jobs (#5808) * ci: build the MSBuild job on all cores msbuild.exe builds on one node unless it gets -m, while the dotnet CLI passes -maxcpucount on its own. The MSBuild job's binlog shows 21.4 minutes on one node, 12.4 of them in Build. The win-x64 leg builds the same solution filter with dotnet build in 3 to 4.5 minutes. -nr:false keeps the worker nodes from staying around after the build, since nothing later in the job uses them. Co-Authored-By: Claude Opus 5.5 * ci: install .NET and NuGet packages on a faster drive on Windows The Windows .NET legs spent about 12 minutes in Setup Environment and Restore, against about 4.5 on Linux. setup-dotnet installed into C:\Program Files\dotnet, where the SDK installs workloads as MSI packages, and NuGet extracted packages into the user profile on C:. On Windows, Setup Environment now starts by installing .NET and the NuGet cache next to the workspace, where workloads install as plain files. On the x64 runners that's on D:. The arm64 runners have no D:, so there the NuGet packages and TEMP go on a Dev Drive, and .NET stays next to the workspace on C:. Trusting the new drive is retried. If setting it up fails anyway, the step warns and keeps the NuGet packages next to the workspace, because the drive is a speedup. Every Windows job that uses Setup Environment gets it. There ~/.nuget/packages isn't the NuGet cache anymore, so the integration tests now clear the Sentry packages they push from NUGET_PACKAGES when it's set. In two probes of the win-arm64 leg, the job took 33.8 and 32.4 minutes with the Dev Drive, against 37.2 and 35.8 without it, and 36.9 with .NET on the drive too. On its own, the step took the MSBuild job's Run MSBuild from 23-24 to 14.7 minutes. Co-Authored-By: Claude Opus 5.5 * ci: restore in its own pass in the MSBuild job The MSBuild job ran msbuild -t:Restore,Build,Pack. NuGet's docs say restoring and building in one invocation is unpredictable and often wrong: restore can bring in props and targets from packages, so the build needs its own evaluation after it. -restore runs the restore as a separate pass first, which is what the dotnet CLI does. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/environment/action.yml | 46 ++++++++++++++++++++++++++ .github/workflows/build.yml | 2 +- integration-test/common.ps1 | 3 +- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index e5cba99e50..26e40ff605 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -9,6 +9,52 @@ runs: 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: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 492828b395..6b51c3588b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -368,7 +368,7 @@ jobs: - name: Run MSBuild id: msbuild - run: msbuild Sentry-CI-Build-Windows.slnf -t:Restore,Build,Pack -p:Configuration=Release --nologo -v:minimal -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 - name: Test MSBuild uses: getsentry/github-workflows/sentry-cli/integration-test@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 diff --git a/integration-test/common.ps1 b/integration-test/common.ps1 index 908a1bda4c..df880b08e9 100644 --- a/integration-test/common.ps1 +++ b/integration-test/common.ps1 @@ -105,7 +105,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() From d3053c1463297522b18aa11fa45d9876047af958 Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Tue, 27 Oct 2026 13:20:31 +0100 Subject: [PATCH 18/35] ci: run the samples and integration tests in their own jobs (#5809) * ci: build the app samples in their own job The app samples only check that they compile, but their Release builds trim, AOT-compile and package each app. In the macOS leg that's about 13.7 of the Build step's 28.3 task-minutes, and it's the whole tail of the step once the Cocoa SDK comes from the cache. On Windows, the MAUI sample's Android build is the last thing the Build step finishes. The Android, iOS, Mac Catalyst, macOS and MAUI samples now build in a separate job that starts with the .NET legs: all of them on macOS, the Android and MAUI samples on Linux, and the MAUI sample on Windows, which is the only build of its Windows target. The legs' solution filters no longer include them, and generated filters list them. The samples job's Cocoa SDK steps match the macOS leg's. So that msbuild.exe still builds an Android and a Windows app, the MSBuild job builds the MAUI sample after the solution filter. That took 180 to 211 seconds on three runs, and the job still ended about 10 minutes before the slowest check. Co-Authored-By: Claude Opus 5.5 * ci: run the integration tests in their own jobs The build legs built, ran the unit tests, and then ran the integration tests, one step after another. On macOS the MAUI cases in cli.Tests.ps1 took 8 to 10 of the leg's 13 to 15 integration test minutes, building three Release apps for Android, iOS and Mac Catalyst, and the Windows legs spent 12 to 16 minutes in the integration tests. The MAUI cases move to cli-maui.Tests.ps1, unchanged except that SKIP_MAUI_TESTS also skips them. A new integration-test job runs next to the legs: on macOS it runs the MAUI cases, which the macOS leg now skips, and on linux-x64, win-x64 and win-arm64 it runs every integration test file, which those legs now skip. Each entry builds and packs only the packages its tests use, from two generated solution filters: Sentry-CI-IntegrationTest-macOS.slnf, which adds the Cocoa bindings, and Sentry-CI-IntegrationTest.slnf. It packs with --no-build like the legs, because packing Sentry.Maui while building it on macOS fails with NU5026. These packages aren't the ones the macOS leg publishes, but CI_PUBLISHING_BUILD only adds the other desktop platforms' native libraries and sentry-cli binaries, which these tests don't use. On release branches the macOS leg runs the MAUI cases too, against the packages it uploads for craft, so the packages we publish are still the ones tested. The integration-test job runs them there as well, which keeps the jobs the same on every branch. On three runs with the other CI changes, the win-x64, linux-x64 and win-arm64 legs took 15.0, 11.2 and 17.9 minutes, and their integration jobs 18.5, 12.7 and 22.1. Before, the win-arm64 leg took 31 and was the slowest job. The new jobs passed the same 69 tests and skipped the same 35 as the legs did before. Co-Authored-By: Claude Opus 5.5 * ci: run the integration tests on linux-musl-x64 again #4789 turned them off on linux-musl-x64 while they hung there without an error, tracked in #4788. #4743 fixed the disk space problem behind it, and #4788 was closed in December 2025, but the guard stayed. The leg finishes in about 9 minutes without them, and the same tests take about 6 on linux-musl-arm64, so the leg still ends well before the slowest check. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 189 +++++++++++++++++- AGENTS.md | 10 +- Sentry-CI-Build-Linux.slnf | 2 - Sentry-CI-Build-Samples-Linux.slnf | 9 + Sentry-CI-Build-Samples-macOS.slnf | 12 ++ Sentry-CI-Build-Windows-arm64.slnf | 1 - Sentry-CI-Build-Windows.slnf | 1 - Sentry-CI-Build-macOS.slnf | 5 - Sentry-CI-IntegrationTest-macOS.slnf | 13 ++ Sentry-CI-IntegrationTest.slnf | 12 ++ integration-test/cli-maui.Tests.ps1 | 151 ++++++++++++++ integration-test/cli.Tests.ps1 | 147 -------------- scripts/generate-solution-filters-config.yaml | 44 ++++ 13 files changed, 433 insertions(+), 163 deletions(-) create mode 100644 Sentry-CI-Build-Samples-Linux.slnf create mode 100644 Sentry-CI-Build-Samples-macOS.slnf create mode 100644 Sentry-CI-IntegrationTest-macOS.slnf create mode 100644 Sentry-CI-IntegrationTest.slnf create mode 100644 integration-test/cli-maui.Tests.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b51c3588b..de830c7944 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -319,9 +319,13 @@ jobs: - name: Integration test # # Note that when upgrading .NET versions, linux-musl may need special handling... see comments below # if: ${{ (matrix.rid != 'linux-musl-x64') && (matrix.rid != 'linux-musl-arm64') }} - # TODO: Re-enable once we have resolved https://github.com/getsentry/sentry-dotnet/issues/4788 - if: ${{ (matrix.rid != 'linux-musl-x64') }} + # The other legs' integration tests run in the integration-test job + if: matrix.rid == 'linux-arm64' || matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-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/') }} with: path: integration-test @@ -336,6 +340,96 @@ jobs: # with: # path: integration-test + # 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-latest + rid: win-x64 + slnf: Sentry-CI-IntegrationTest.slnf + tests: integration-test + - os: windows-11-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 + with: + path: ${{ matrix.tests }} + ms-build: needs: build-sentry-native name: MSBuild @@ -358,7 +452,7 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment with: - projects: Sentry-CI-Build-Windows.slnf + projects: Sentry-CI-Build-Windows.slnf samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj - name: Build Native Dependencies uses: ./.github/actions/buildnative @@ -370,6 +464,11 @@ jobs: id: msbuild 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 with: @@ -380,7 +479,9 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ runner.os }}-msbuild-logs - path: msbuild.binlog + path: | + msbuild.binlog + msbuild-maui.binlog if-no-files-found: ignore # Unsupported Native AOT runtimes should have SentryNative auto-disabled @@ -477,6 +578,86 @@ jobs: # - name: Publish Test app (iOS) # run: dotnet publish test/Sentry.MauiTrimTest/Sentry.MauiTrimTest.csproj -c Release -f net9.0-ios18.0 -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-latest + 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/AGENTS.md b/AGENTS.md index c2a8f45d76..02f408c8c6 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/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index b7a4975621..e502cf89f1 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", @@ -25,7 +24,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", 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 f9b1f924e6..63b09f4ada 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -26,7 +26,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", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index fccd8ebf8e..33b56f3d83 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -26,7 +26,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", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index ce671ebede..4b1980cdaa 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", @@ -26,11 +25,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", 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/integration-test/cli-maui.Tests.ps1 b/integration-test/cli-maui.Tests.ps1 new file mode 100644 index 0000000000..cc10d089df --- /dev/null +++ b/integration-test/cli-maui.Tests.ps1 @@ -0,0 +1,151 @@ +# This file contains test cases for https://pester.dev/ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. $PSScriptRoot/common.ps1 + +Describe 'MAUI ()' -ForEach @( + @{ framework = $previousFramework } +) -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', + 'libxamarin-app.so', + 'maui-app.pdb' + ) + $result.ScriptOutput | Should -AnyElementMatch "Found 23 debug information files \(1 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 @( + 'libmono-component-debugger.dylib', + 'libmono-component-diagnostics_tracing.dylib', + 'libmono-component-hot_reload.dylib', + 'libmono-component-marshal-ilgen.dylib', + 'libmonosgen-2.0.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-debug.dylib', + 'libxamarin-dotnet.dylib', + 'maui-app', + 'maui-app.pdb', + '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 @( + 'libmonosgen-2.0.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-debug.dylib', + 'libxamarin-dotnet.dylib', + 'maui-app', + 'maui-app.pdb', + '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 c19d6e79ae..cc7c523410 100644 --- a/integration-test/cli.Tests.ps1 +++ b/integration-test/cli.Tests.ps1 @@ -254,150 +254,3 @@ Describe 'Console apps () - native AOT publish' -ForEach @( $result.UploadedDebugFiles() | Should -BeNullOrEmpty } } - -Describe 'MAUI ()' -ForEach @( - @{ framework = $previousFramework } -) -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', - 'libxamarin-app.so', - 'maui-app.pdb' - ) - $result.ScriptOutput | Should -AnyElementMatch "Found 23 debug information files \(1 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 @( - 'libmono-component-debugger.dylib', - 'libmono-component-diagnostics_tracing.dylib', - 'libmono-component-hot_reload.dylib', - 'libmono-component-marshal-ilgen.dylib', - 'libmonosgen-2.0.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-debug.dylib', - 'libxamarin-dotnet.dylib', - 'maui-app', - 'maui-app.pdb', - '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 @( - 'libmonosgen-2.0.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-debug.dylib', - 'libxamarin-dotnet.dylib', - 'maui-app', - 'maui-app.pdb', - '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/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index ea4233d3dc..5cb493c15d 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,6 +101,7 @@ filterConfigs: - "modules/perfview/**/FastSerialization.csproj" exclude: groups: + - "appSamples" - "artefacts" - "trimTests" - "playwrightTests" @@ -103,6 +120,7 @@ filterConfigs: - "modules/perfview/**/FastSerialization.csproj" exclude: groups: + - "appSamples" - "macOnly" - "artefacts" - "trimTests" @@ -125,6 +143,7 @@ filterConfigs: - "modules/perfview/**/FastSerialization.csproj" exclude: groups: + - "appSamples" - "macOnly" - "artefacts" - "trimTests" @@ -142,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: From d4dc935c806854b2594d7d9117d46b02bc1c720b Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Tue, 27 Oct 2026 13:22:56 +0100 Subject: [PATCH 19/35] ci: run the iOS and Android integration tests in their own jobs (#5810) * ci: run the iOS integration tests in their own job The iOS job ran the device tests and then the integration tests on one Mac, about 42 minutes in all. The two build and run different apps and share only the simulator, so the integration tests now run in a second job at the same time. ios.Tests.ps1 skips every case unless a simulator is already booted, and xharness only boots one in the ios-tests job, so the new job boots its own, with a 10-minute timeout. The boot takes about 2 minutes. The job uploads no results, because ios.Tests.ps1 writes nothing to the integration-test/mobile-app/test_output folder the old job uploaded. On its first run, the iOS workflow took 18 minutes, against 38.7 for the single job the same night, and all 8 integration cases passed. Co-Authored-By: Claude Opus 5.5 * ci: run the Android integration tests in their own job Each Android emulator job ran the device tests, then Setup Environment, then the integration tests, about 22 minutes after waiting 7.5 for the device test app builds. The integration tests build their own app from source and share only the emulator, so they now run in a second job that starts with the workflow, on the same runners. The new job drops "Select Java 21". #4559 added it as "Select Java 17", when Setup Environment ended on Java 11, and since #5060 Setup Environment installs only Java 21. The new job uploads integration-test/mobile-app/test_output, where android.Tests.ps1 points xharness, without a warning when it's empty. It was empty on every run so far. On its first run, the Android workflow took 19.8 minutes instead of 30.1. The device test jobs took 3.6 to 3.8 minutes and the integration jobs 12.0 to 19.7. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/device-tests-android.yml | 46 +++++++++++----- .github/workflows/device-tests-ios.yml | 62 +++++++++++++++++++--- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index 3303724bea..e657a88ead 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -101,14 +101,14 @@ jobs: # Requires a "larger runner", for nested virtualization support runs-on: ubuntu-latest-4-cores - strategy: + strategy: &android-emulator-matrix fail-fast: false matrix: tfm: [net9.0, net10.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+ @@ -165,17 +165,40 @@ 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 }}) + + # Requires a "larger runner", for nested virtualization support + runs-on: ubuntu-latest-4-cores + + 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 with: projects: integration-test/net9-maui/Sentry.Maui.Device.IntegrationTestApp.csproj - - name: Select Java 21 - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 - with: - distribution: temurin - java-version: '21' - - name: Checkout github-workflows uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -209,7 +232,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 f1ac40fa39..00c8820065 100644 --- a/.github/workflows/device-tests-ios.yml +++ b/.github/workflows/device-tests-ios.yml @@ -75,7 +75,7 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment with: - projects: test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj integration-test/net9-maui/Sentry.Maui.Device.IntegrationTestApp.csproj + projects: test/Sentry.Maui.Device.TestApp/Sentry.Maui.Device.TestApp.csproj # See build.yml - name: Get Cocoa SDK cache key @@ -110,6 +110,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/net9-maui/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 @@ -125,11 +173,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 }} From 7c4dc431db9c6dab7374ee37fc88802f461436a9 Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Tue, 27 Oct 2026 13:23:47 +0100 Subject: [PATCH 20/35] ci: test unsupported AOT runtimes in the build legs (#5811) The Unsupported AOT jobs waited for the whole build matrix to use the macOS packages, but any leg's packages work. Every leg already packs and runs aot.Tests.ps1 against its own. linux-arm now runs in the linux-arm64 leg and win-x86 in the win-x64 leg, which removes two jobs and the tail after the matrix. Some arm64 hosts can't run 32-bit ARM binaries and failed with "Exec format error", so QEMU runs them on every host. Its binfmt_misc handler comes before the kernel's own loader, so it runs them even on hosts that could. On the first runs a small uname program confirmed that: it reported armv7l, which only QEMU does, on a host that runs 32-bit ARM natively. The leg now only checks that the handler is enabled. The action runs the tonistiigi/binfmt image privileged, so that image is pinned by digest here and in alpine.yml, like the action itself. On its first run, the AOT and container cases passed under QEMU in 38 seconds. Co-authored-by: Claude Opus 5.5 --- .github/workflows/alpine.yml | 3 ++ .github/workflows/build.yml | 82 ++++++++++++++---------------------- 2 files changed, 34 insertions(+), 51 deletions(-) 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 de830c7944..71b9758d0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -329,6 +329,37 @@ jobs: 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' }} + with: + path: integration-test/aot.Tests.ps1 + # # For the linux-musl runtimes we have to pin the ContainerBaseImage for preview or RC builds, since # # these don't conform to the normal naming conventions and don't get resolved automatically. We do # # by passing it as parameter to aot.Tests.ps1 via an environment variable @@ -484,57 +515,6 @@ jobs: 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-latest - 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, 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 }} - with: - path: integration-test/aot.Tests.ps1 - trim-analysis: needs: build-sentry-native name: Trim analysis From f90333bc13b2c65837be3087fc6c2d04f23dcc4f Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Tue, 27 Oct 2026 13:24:31 +0100 Subject: [PATCH 21/35] ci: run the Android emulator jobs on standard runners (#5812) The emulator jobs ran on the ubuntu-latest-4-cores larger runner. #2139 moved them there in 2023 because the 2-core standard runners couldn't run a hardware-accelerated emulator. Since April 2024 they can, with the same KVM udev rule this workflow already sets, and on public repositories ubuntu-latest now has 4 CPUs and 16 GB of RAM. Both the device test and the integration test jobs move. On the first runs the device tests took 2.5 to 4.5 minutes, against 2.3 to 4.4 on the larger runner, with the same 2,921 tests. The standard runner listed less disk, but had 88 GB free at the start and 67 at the end. Co-authored-by: Claude Opus 5.5 --- .github/workflows/device-tests-android.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index e657a88ead..0df33a739d 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -98,8 +98,7 @@ 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: &android-emulator-matrix fail-fast: false @@ -176,8 +175,7 @@ jobs: android-integration-tests: name: Run Android API-${{ matrix.api-level }} Integration Test (${{ matrix.tfm }}) - # Requires a "larger runner", for nested virtualization support - runs-on: ubuntu-latest-4-cores + runs-on: ubuntu-latest strategy: *android-emulator-matrix env: *android-emulator-env From 7b78cf0377a96d5b4683220519a1d5be2ff1c213 Mon Sep 17 00:00:00 2001 From: Schalk Pienaar <59658723+schalkpienaar@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:59:25 +1300 Subject: [PATCH 22/35] ci: run the time zone tests on musl instead of skipping them (#5820) * Remove skip logic from tests and HasTimeZone helper method * Use Fact and Theory for tests that no longer skip Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- test/Sentry.Hangfire.Tests/HangfireTests.cs | 4 +--- test/Sentry.Hangfire.Tests/ServerFilterTests.cs | 8 ++------ test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs | 4 +--- .../Core/TriggerScheduleTests.cs | 4 +--- test/Sentry.Testing/TestEnvironment.cs | 16 ---------------- 5 files changed, 5 insertions(+), 31 deletions(-) diff --git a/test/Sentry.Hangfire.Tests/HangfireTests.cs b/test/Sentry.Hangfire.Tests/HangfireTests.cs index 2efbc1a391..2195c3ddbd 100644 --- a/test/Sentry.Hangfire.Tests/HangfireTests.cs +++ b/test/Sentry.Hangfire.Tests/HangfireTests.cs @@ -61,11 +61,9 @@ public async Task ExecuteJobWithoutAttribute_DoesNotCapturesCheckInButLogs() _fixture.Logger.Received(1).Log(SentryLevel.Debug, Arg.Is(message => message.Contains("Skipping creating a check-in for")), null, Arg.Any(), Arg.Any()); } - [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); diff --git a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs index 172432aa64..1b7776243c 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); @@ -370,7 +368,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 +379,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); } diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 4151331442..2b19ca66c0 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -169,11 +169,9 @@ public async Task StartAsync_JobsShareSlugWithDifferentSchedules_CapturesCheckIn _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().BeNull(); } - [SkippableFact] + [Fact] public async Task StartAsync_JobsShareSlugWithDifferentTimeZones_CapturesCheckInWithoutMonitorConfig() { - Skip.If(!TestEnvironment.HasTimeZone("America/New_York"), "No time zone database"); - // Arrange _fixture.JobDetail = JobBuilder.Create().WithIdentity("eu", "reports").Build(); var other = JobBuilder.Create().WithIdentity("us", "reports").Build(); diff --git a/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs index 7fd748b4c4..9ad00dc46a 100644 --- a/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs +++ b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs @@ -107,13 +107,11 @@ public void ToIanaTimeZoneId_CustomTimeZone_ReturnsNull() } #if NET6_0_OR_GREATER - [SkippableTheory] + [Theory] [InlineData("Europe/Berlin", "Europe/Berlin")] [InlineData("W. Europe Standard Time", "Europe/Berlin")] public void ToIanaTimeZoneId_SystemTimeZone_ReturnsIanaId(string timeZoneId, string expected) { - Skip.If(!TestEnvironment.HasTimeZone(timeZoneId), "No time zone database"); - var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); TriggerSchedule.ToIanaTimeZoneId(timeZone).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; - } - } } From 29bf3a411141916e9c6a91a9009e384629590dce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:03:22 +1300 Subject: [PATCH 23/35] chore: update scripts/update-java.ps1 to 8.62.0 (#5824) Co-authored-by: GitHub --- src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj b/src/Sentry.Bindings.Android/Sentry.Bindings.Android.csproj index bdfb377931..58cedaf358 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.1 + 8.62.0 $(BaseIntermediateOutputPath)sdks\$(TargetFramework)\Sentry\Android\$(SentryAndroidSdkVersion)\ From 3bba861560b987f9fb67eada6040c0f2a479b9d7 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Wed, 28 Oct 2026 15:07:48 +0100 Subject: [PATCH 24/35] fix: drop attachments whose stream turns out empty when the envelope is sent (#5787) --- src/Sentry/Protocol/Envelopes/Envelope.cs | 12 ++-- src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 34 +++++++++- .../Envelopes/AttachmentSerializationTests.cs | 66 +++++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 0174dbd32a..edb432dc5b 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/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs b/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs index 061fdaddca..69b2d7229d 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(), _testOutputLogger, [attachment]); + } } From 4b89b2f968b7503d77ade30d8efbed14f5d1958c Mon Sep 17 00:00:00 2001 From: Ricardo Colombo Oliveira Date: Thu, 29 Oct 2026 06:55:20 +0100 Subject: [PATCH 25/35] ci: retry the powershell-yaml install when it fails (#5819) generate-solution-filters.ps1 tries to install powershell-yaml up to 5 times, 10 seconds apart, but it sets $ErrorActionPreference to Stop, so the first failed download ended the script. The PowerShell Gallery failed that download twice in two days, failing test-solution-filters on #5810 and #5808. A failed attempt now prints a warning and the loop tries again. If every attempt fails, Import-Module still stops the script. Co-authored-by: Claude Opus 5.5 --- scripts/generate-solution-filters.ps1 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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-- } From 46524ca196dc3d5486f45c19c3dbd6bda42c68dd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 28 Oct 2026 23:01:32 -0700 Subject: [PATCH 26/35] feat(hangfire): Add an option to monitor all recurring jobs (#5742) * feat(hangfire): Send recurring job schedule as monitor config Opt-in via UseSentry(o => o.SendRecurringJobSchedule = true). The in-progress check-in of a recurring job carries the job's cron and time zone so Sentry can create the monitor. Unsupported schedules fall back to a check-in without config. Co-Authored-By: Claude * ref(hangfire): Validate the recurring job schedule before capturing Check the crontab up front so the check-in goes out in a single call instead of relying on the hub to swallow an exception from Interval. The validator is shared with Sentry.Hangfire as a linked source file, since the unsigned assembly can't see Sentry's internals. Read the job through GetRecurringJobs, look up time zones with FindSystemTimeZoneById on .NET 6+, and add tests for UTC, unknown zones, deleted jobs, the UseSentry overload, and a real recurring job. Co-Authored-By: Claude * test(hangfire): Skip time zone tests when the zone is missing The linux-musl CI containers have no time zone database, so lookups of Europe/Berlin and Etc/UTC fail there. Co-Authored-By: Claude * fix(hangfire): Skip schedules Sentry would read differently Hangfire runs a job only on days that match both the day-of-month and day-of-week fields, while Sentry runs it on days that match either unless one of them starts with '*'. Don't send those schedules, nor ones with a reversed range such as 5-1 or SAT-SUN, which Sentry rejects. Co-Authored-By: Claude * feat(hangfire): Send recurring job schedules by default Jobs are only monitored when they have [SentryMonitorSlug], so sending the recurring job's schedule lets Sentry create or update the monitor, like the Spring and Quartz integrations. Set SendRecurringJobSchedule to false to keep managing the schedule in Sentry. Co-Authored-By: Claude * fix(hangfire): Create the check-in ID before the in-progress check-in The final check-in reused the ID returned for the in-progress one, which is empty when that one isn't captured. Create the ID up front, send it with the in-progress check-in, and store it in the job's items as before. Co-Authored-By: Claude * ref(hangfire): Rename SendRecurringJobSchedule to SendMonitorConfig Matches SentryQuartzOptions.SendMonitorConfig. Co-Authored-By: Claude * feat(hangfire): Don't send a monitor config when recurring jobs share a slug with different schedules * fix(hangfire): Don't treat a recurring job as conflicting with its own cached schedule Co-Authored-By: Claude * feat(hangfire): Add an option to monitor every recurring job MonitorAllRecurringJobs sends check-ins for recurring jobs without SentryMonitorSlugAttribute, using the slugified recurring job ID as the monitor slug. ExcludedRecurringJobs leaves out recurring jobs by ID. Co-Authored-By: Claude * fix(hangfire): Build recurring job slugs the way Sentry normalizes them Collapse runs of hyphens and trim underscores, so IDs that Sentry would store under the same slug also share one here and the conflicting schedule check sees them. Co-Authored-By: Claude * feat(hangfire): Match Relay's slugify and accept regexes in ExcludedRecurringJobs Adds a public StringOrRegex.Regex, since Sentry.Hangfire isn't strong-named and can't see Sentry's internals. Co-Authored-By: Claude * ref(crons): Share one Relay-compatible slug helper between Hangfire and Quartz Quartz now joins a job's group and name with a hyphen, since Relay drops dots. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../GlobalConfigurationExtensions.cs | 9 +- src/Sentry.Hangfire/Sentry.Hangfire.csproj | 1 + src/Sentry.Hangfire/SentryHangfireOptions.cs | 46 +++++ src/Sentry.Hangfire/SentryServerFilter.cs | 33 ++- src/Sentry.Quartz/Core/JobMonitor.cs | 32 +-- .../Core/SentryMonitorSlugAttribute.cs | 4 +- src/Sentry/Internal/MonitorSlug.cs | 62 ++++++ src/Sentry/StringOrRegex.cs | 5 + test/Sentry.Hangfire.Tests/HangfireFixture.cs | 8 +- test/Sentry.Hangfire.Tests/HangfireTests.cs | 30 ++- .../ServerFilterTests.cs | 192 +++++++++++++++++- .../Core/JobMonitorTests.cs | 13 -- ...iApprovalTests.Run.DotNet10_0.verified.txt | 1 + ...piApprovalTests.Run.DotNet8_0.verified.txt | 1 + ...piApprovalTests.Run.DotNet9_0.verified.txt | 1 + .../ApiApprovalTests.Run.Net4_8.verified.txt | 1 + .../Internals/MonitorSlugTests.cs | 30 +++ 17 files changed, 408 insertions(+), 61 deletions(-) create mode 100644 src/Sentry/Internal/MonitorSlug.cs create mode 100644 test/Sentry.Tests/Internals/MonitorSlugTests.cs 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..d8d72018d2 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,48 @@ 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. + /// + /// + /// Jobs marked with keep their monitor slug. + /// 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..2ca302bf73 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 = GetMonitorSlug(context); if (monitorSlug is null) { var jobType = context.BackgroundJob.Job.Type; @@ -58,6 +58,27 @@ public void OnPerforming(PerformingContext context) // 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; + } + + private string? GetMonitorSlug(PerformContext context) + { + var monitorSlug = context.GetJobParameter(SentryMonitorSlugKey); + if (monitorSlug is not null || !_options.MonitorAllRecurringJobs) + { + return monitorSlug; + } + + try + { + var recurringJobId = context.GetJobParameter(RecurringJobIdKey); + return string.IsNullOrEmpty(recurringJobId) ? null : _options.GetRecurringJobSlug(recurringJobId!); + } + catch (Exception e) + { + _logger?.LogError(e, "Failed to read the recurring job ID of job '{0}'.", context.BackgroundJob.Id); + return null; + } } private SentryId CaptureInProgressCheckIn(PerformingContext context, string monitorSlug) @@ -133,7 +154,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 +177,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 +317,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 index 430c243af0..a73d79dfe0 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -1,14 +1,13 @@ using Quartz; using Sentry.Extensibility; using Sentry.Infrastructure; +using Sentry.Internal; namespace Sentry.Quartz; // Captures the check-ins of jobs with SentryMonitorSlugAttribute internal sealed class JobMonitor { - private const int MaxSlugLength = 50; - // 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); @@ -320,40 +319,15 @@ private static bool IsMonitored(IJobDetail job) var key = job.Key; if (HasIdentity(key)) { - return ToSlug(key.Group == JobKey.DefaultGroup ? key.Name : key.Group + "." + key.Name); + return MonitorSlug.From(key.Group == JobKey.DefaultGroup ? key.Name : key.Group + "-" + key.Name); } fromJobType = true; - return ToSlug(QuartzApi.GetJobType(job).Name); + 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 _); - - // Produces a slug that Sentry's own slugify leaves unchanged - internal static string? ToSlug(string value) - { - var slug = new StringBuilder(value.Length); - foreach (var c in value.ToLowerInvariant()) - { - if (c is >= 'a' and <= 'z' or >= '0' and <= '9' or '_') - { - slug.Append(c); - } - else if (slug.Length > 0 && slug[slug.Length - 1] != '-') - { - slug.Append('-'); - } - } - - var result = slug.ToString().Trim('-', '_'); - if (result.Length > MaxSlugLength) - { - result = result.Substring(0, MaxSlugLength).Trim('-', '_'); - } - - return result.Length > 0 ? result : null; - } } internal sealed class JobCheckIn(string monitorSlug, SentryId id) diff --git a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs index c21f4eb13a..0aead11422 100644 --- a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs +++ b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs @@ -8,8 +8,8 @@ namespace Sentry.Quartz; /// Jobs without this attribute are not monitored. /// /// -/// When no monitor slug is given, the slug is the job's key, lowercased with every run of other characters replaced -/// by a hyphen. Jobs in the default group use only the job name: reports.DailyEmail becomes +/// 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. /// 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/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.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 2195c3ddbd..8f7ac918e4 100644 --- a/test/Sentry.Hangfire.Tests/HangfireTests.cs +++ b/test/Sentry.Hangfire.Tests/HangfireTests.cs @@ -58,7 +58,8 @@ 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))); } [Fact] @@ -76,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() @@ -125,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 1b7776243c..7d86ea582c 100644 --- a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs +++ b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs @@ -239,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()) }; @@ -338,6 +339,178 @@ 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_SendsCheckInWithoutMonitorConfig() + { + // 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.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] @@ -421,12 +594,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) { @@ -454,20 +632,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/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 2b19ca66c0..c500d5d7f3 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -625,19 +625,6 @@ public void GetMonitorSlug_Job_ReturnsSlug( JobMonitor.GetMonitorSlug(_fixture.GetContext(), attribute).Should().Be(expected); } - [Theory] - [InlineData("Nightly Report", "nightly-report")] - [InlineData("Billing.Invoices/Send!", "billing-invoices-send")] - [InlineData("_queue__drain_", "queue__drain")] - [InlineData("-a.-b-", "a-b")] - [InlineData("日本語", null)] - [InlineData("a234567890b234567890c234567890d234567890e234567890f234567890", "a234567890b234567890c234567890d234567890e234567890")] - [InlineData("a234567890b234567890c234567890d234567890e23456789-f234567890", "a234567890b234567890c234567890d234567890e23456789")] - public void ToSlug_Value_ReturnsSlugSentryLeavesUnchanged(string value, string? expected) - { - JobMonitor.ToSlug(value).Should().Be(expected); - } - private static ITrigger CronTrigger( string cronExpression, IJobDetail? job = null, diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 2a3833e84f..dcb5151b83 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1367,6 +1367,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 2a3833e84f..dcb5151b83 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1367,6 +1367,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 2a3833e84f..dcb5151b83 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1367,6 +1367,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 28b4f8cea8..832e96b3db 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -1348,6 +1348,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/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); + } +} From f39654d0a758a08de30917514731f2bb350d56b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:03:27 +1300 Subject: [PATCH 27/35] chore: update modules/sentry-native to 0.17.5 (#5826) Co-authored-by: GitHub --- modules/sentry-native | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 85c8a62278d6ff3a5e49a3962b1b3bf1d08b13d2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:28:19 +0100 Subject: [PATCH 28/35] chore: Remove unused ExceptionHandlingSynchronizationContext (#5827) It was only used by SentrySdk.RunAsyncVoid, which was removed in #3466. Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- ...ExceptionHandlingSynchronizationContext.cs | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 src/Sentry/Internal/ExceptionHandlingSynchronizationContext.cs 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); - } -} From 5eeb2165005d63bdcbae647fa0ae03f2385f3f71 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 14:46:02 -0700 Subject: [PATCH 29/35] fix(hangfire): Skip jobs with unsupported schedules when monitoring all jobs (#5831) MonitorAllRecurringJobs only covers recurring jobs whose schedule Sentry can represent, matching Quartz and sentry-python. Jobs with SentryMonitorSlug keep checking in without a config. --- src/Sentry.Hangfire/SentryHangfireOptions.cs | 6 +- src/Sentry.Hangfire/SentryServerFilter.cs | 30 +++++-- .../ServerFilterTests.cs | 83 ++++++++++++++++++- 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/src/Sentry.Hangfire/SentryHangfireOptions.cs b/src/Sentry.Hangfire/SentryHangfireOptions.cs index d8d72018d2..3d493d6187 100644 --- a/src/Sentry.Hangfire/SentryHangfireOptions.cs +++ b/src/Sentry.Hangfire/SentryHangfireOptions.cs @@ -33,8 +33,10 @@ public class SentryHangfireOptions /// dropped, and cut to 50 characters. Defaults to false. /// /// - /// Jobs marked with keep their monitor slug. - /// Use to leave out some recurring jobs. + /// 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; } diff --git a/src/Sentry.Hangfire/SentryServerFilter.cs b/src/Sentry.Hangfire/SentryServerFilter.cs index 2ca302bf73..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 = GetMonitorSlug(context); + var (monitorSlug, recurringJobId) = GetMonitorSlug(context); if (monitorSlug is null) { var jobType = context.BackgroundJob.Job.Type; @@ -52,7 +52,18 @@ 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 @@ -61,30 +72,33 @@ public void OnPerforming(PerformingContext context) context.Items[SentryMonitorSlugKey] = monitorSlug; } - private string? GetMonitorSlug(PerformContext context) + // 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; + return (monitorSlug, null); } try { var recurringJobId = context.GetJobParameter(RecurringJobIdKey); - return string.IsNullOrEmpty(recurringJobId) ? null : _options.GetRecurringJobSlug(recurringJobId!); + 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; + 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 => { diff --git a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs index 7d86ea582c..47089dd6e1 100644 --- a/test/Sentry.Hangfire.Tests/ServerFilterTests.cs +++ b/test/Sentry.Hangfire.Tests/ServerFilterTests.cs @@ -419,7 +419,7 @@ public void OnPerforming_ExcludedRecurringJobWithAttribute_CapturesCheckIn() } [Fact] - public void OnPerforming_MonitorAllRecurringJobsIdsShareSlugWithDifferentCron_SendsCheckInWithoutMonitorConfig() + public void OnPerforming_MonitorAllRecurringJobsIdsShareSlugWithDifferentCron_DoesNotCaptureCheckIn() { // Arrange var fixture = new RecurringJobFixture @@ -437,6 +437,87 @@ public void OnPerforming_MonitorAllRecurringJobsIdsShareSlugWithDifferentCron_Se // 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(); } From fe691cc8a1ea083addd656aa55018e967ac7a2d8 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 14:49:24 -0700 Subject: [PATCH 30/35] feat(quartz): Add an option to monitor all scheduled jobs (#5741) * feat: add Sentry.Quartz * refactor: optimize cron information retrieval in SentryCronJobListener * refactor: swap ILogger dependency from SentryCronJobListener with IDiagnosticLogger * refactor: simplify Quartz configuration by removing redundant service dependencies and streamlining SentryCronJobListener options handling * refactor: migrate to Quartz v4.0.0 with .NET 10 * feat: include Sentry.Quartz in solution filter and generation scripts * feat: add Quartz sample showcasing Sentry integration * fix: cursor github comments * refactor: simplify Quartz configuration and update project to use Web SDK * refactor: replace SentryCronJobListener with SentryCronJobMiddleware and update Quartz integration * fix: handling issue where current region might throw a CultureNotFoundException * fix: sample project has different nuget package version * fix: skip middleware execution for non-cron triggers * refactor: rename and update Quartz extension method to AddSentryCronJob * feat: add SentryMetricsMiddleware to track Quartz job execution duration metrics * refactor: simplify AddSentryCronJob method by removing unused service collection parameter and streamlining option configuration * feat: add SentryScopeMiddleware and extend Quartz integration with AddSentryScope method, update SentryMetricsMiddleware to support configurable options * refactor: move non-cron trigger check to StartQuartz method * feat: add missing options parameter * refactor: remove unused project reference to Sentry.AspNetCore in Sentry.Quartz * fix: Quatz.Net and Sentry handling cron expression for days differently * feat: add XML documenation * feat: update Quartz to stable v4.0.0 * feat: add Quartz Dashboard and HTTP API integration, update launch URL * feat: add customizable SentryMonitorOptions via callback * style: apply dotnet format to Sentry.Quartz The format-code workflow can't push its auto-fix to a fork branch, so it fails the check instead. Applying the fix here. Co-Authored-By: Claude Opus 5 * ci(craft): add Sentry.Quartz to the release registry Every package needs a name/packageUrl/mainDocsUrl entry so craft can create its registry entry on first publish; a missing `name` fails the whole registry target rather than just that package. Co-Authored-By: Claude Opus 5 * test: add Sentry.Quartz.Tests Co-Authored-By: Claude Sonnet 5 * feat: add configurable SentryScopeMiddleware options * refactor: migrate extension methods to extension block * refactor: remove SentryMetricsMiddleware * fix: use JobDetail.Key.ToString() * fix: replace job type with job ID in SentryCronJobMiddleware dictionary key * feat(quartz): Rework Sentry.Quartz into Sentry.Quartz4 Keeps the middleware design from #5505 and adds what the cron monitors need on top: - One AddSentry() entry point on IQuartzBuilder that registers the scope and check-in middleware with HubAdapter, so no IHub is needed in DI. - The scope middleware starts a new trace per run and captures what the job throws inside the run's scope. - Check-ins only for jobs with [SentryCronMonitorSlug]. The slug comes from the trigger or job data map, the attribute, or the slugified job key, so job keys sharing a class don't share a monitor. - The monitor config is converted from cron and simple triggers, with the Quartz day-of-week shift, and left out when Sentry can't represent the schedule. SendMonitorConfig turns it off globally or per job. - ConfigureMonitorOptions runs before the trigger's schedule is applied, so it can override it, and never breaks the check-in. - The package is signed and restricted to Quartz [4.0.0,5.0.0). The schedule conversion, slug and check-in logic live in Core/ so the Quartz 3 package can compile the same files. Co-Authored-By: Claude * ref(quartz): Rename Sentry.Quartz4 to Sentry.Quartz Sentry.Quartz3 keeps its name for Quartz.NET 3.x. Co-Authored-By: Claude * fix(quartz): Address review feedback on Sentry.Quartz - Jobs built without an identity get a new GUID name on each start, so their monitor slug falls back to the job class name, with a warning. Two jobs that resolve to the same slug also log a warning. - The scope middleware no longer captures exceptions. Quartz logs them, and the logging integration reports that. The cron middleware still sends an error check-in. In global mode the scope is left alone. - Quartz 4 accepts '*' in both day fields and runs on either day when both restrict. Convert those expressions, spelling out a '*' step as a range so Sentry unions the fields too. - ConfigureMonitorOptions takes the IJobExecutionContext and always runs while SendMonitorConfig is on. The trigger's schedule fills in when it sets none, and the config is only sent when it has a schedule. - The check-in ID is created up front, so the final check-in matches the in-progress one even when that wasn't sent. Co-Authored-By: Claude * ref(quartz): Drop warn-once state and discard options from a failed callback Log the unnamed-job fallback at debug level on each run instead of tracking which jobs were warned about, and remove the shared-slug detection. If ConfigureMonitorOptions throws, ignore what it set and use the trigger's schedule. Co-Authored-By: Claude * fix(quartz): Capture job exceptions in the job scope and rename the slug attribute Quartz logs a job's exception only after the middleware chain has returned, so the logged error lost the job's scope and trace. Capture it inside the scope again; the SDK drops the duplicate from the log. Rename SentryCronMonitorSlugAttribute to SentryMonitorSlugAttribute to match Sentry.Hangfire. Co-Authored-By: Claude * docs(quartz): Say that AddSentry captures job exceptions The AddSentry() remarks still said job exceptions are left to the logging integration. The scope middleware has captured them again since 73e5551. Co-Authored-By: Claude Opus 5.5 * feat(quartz): Add an option to monitor every scheduled job MonitorAllJobs sends check-ins for every job fired by a cron trigger or a repeating simple trigger, with the slug of the job's key. ExcludedJobs skips jobs by name, full key or regex. Jobs with SentryMonitorSlug keep their settings. Co-Authored-By: Claude * ref(quartz): Remove a leftover comment from JobMonitor * fix(quartz): Drop the schedule when jobs share a slug with different schedules Reads every monitored trigger, cached for a minute, like Sentry.Hangfire. Replaces the check for jobs with several triggers. * test(quartz): Skip the time zone test when there's no time zone database * docs(quartz): Drop the stale warning from the slug attribute docs A job without an identity has fallen back to its class name with a debug message, not a warning, since fe97276. Co-Authored-By: Claude Opus 5.5 * fix(quartz): Skip jobs whose type can't be resolved when reading triggers A job left in a persistent store after its class was renamed or deleted made the whole trigger read throw, so no monitored job sent its schedule. Co-Authored-By: Claude * test(quartz): Pin the order of the Sentry job middleware Co-Authored-By: Claude * docs(quartz): Update the sample's comment on how job exceptions are captured Co-Authored-By: Claude * fix(quartz): Capture each job exception once with the logging integration on Quartz's ErrorLogger logs the exception again, wrapped in a JobExecutionProcessException that the default DeduplicateMode doesn't unwrap. AddSentry now adds a log entry filter for that entry, to whichever SentryLoggingOptions the app uses (SentryAspNetCoreOptions included), when the middleware captured the inner exception. Co-Authored-By: Claude * test(quartz): Check that AddSentry leaves unrelated options resolvable Co-Authored-By: Claude * Update src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs Co-authored-by: Ricardo Colombo Oliveira * ref(quartz): Drop the stale constraint wording and assertion for the log filter setup Co-Authored-By: Claude * fix(quartz): Skip jobs with unsupported schedules when monitoring all jobs MonitorAllJobs only covers runs whose trigger schedule Sentry can represent, matching sentry-python. Jobs with SentryMonitorSlug keep checking in without a config. * fix(quartz): Skip default-monitored jobs whose triggers have different schedules --------- Co-authored-by: Michael Mairegger Co-authored-by: James Crosswell Co-authored-by: Claude Opus 5 Co-authored-by: Ricardo Colombo Oliveira --- src/Sentry.Quartz/Core/JobMonitor.cs | 111 ++++-- .../Core/SentryMonitorSlugAttribute.cs | 2 +- src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 28 ++ .../SentryQuartzBuilderExtensions.cs | 3 +- ...iApprovalTests.Run.DotNet10_0.verified.txt | 2 + .../Core/JobMonitorTests.cs | 345 ++++++++++++++++++ .../SentryQuartzBuilderExtensionsTests.cs | 25 +- 7 files changed, 489 insertions(+), 27 deletions(-) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index a73d79dfe0..d2c9698e4d 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -5,13 +5,14 @@ namespace Sentry.Quartz; -// Captures the check-ins of jobs with SentryMonitorSlugAttribute +// 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; @@ -38,7 +39,8 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg try { var jobType = QuartzApi.GetJobType(context.JobDetail); - var attribute = GetAttribute(jobType); + var attribute = GetAttribute(jobType) + ?? (IsMonitoredByDefault(context) ? DefaultAttribute : null); if (attribute is null) { return null; @@ -52,9 +54,27 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg return null; } - configureMonitorOptions = _options.SendMonitorConfig && attribute.SendMonitorConfig - ? await GetMonitorConfigAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) - : 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) { @@ -153,6 +173,52 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) } } + 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, @@ -166,7 +232,7 @@ private async Task GetTriggerSchedulesAsync( if (_triggerSchedules is not { } schedules || schedules.ReadAt + TriggerSchedulesTtl <= now || schedules.Scheduler != scheduler || !schedules.Contains(triggerKey)) { - schedules = await TriggerSchedules.ReadAsync(scheduler, now, cancellationToken).ConfigureAwait(false); + schedules = await TriggerSchedules.ReadAsync(this, scheduler, now, cancellationToken).ConfigureAwait(false); _triggerSchedules = schedules; } @@ -196,19 +262,20 @@ private TriggerSchedules(IScheduler scheduler, DateTimeOffset readAt, _keys = keys; } - public static async Task ReadAsync(IScheduler scheduler, DateTimeOffset readAt, - CancellationToken cancellationToken) + 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, IsMonitored, cancellationToken).ConfigureAwait(false); + var triggers = await QuartzApi.GetTriggersAsync(scheduler, monitor.IsMonitored, cancellationToken) + .ConfigureAwait(false); foreach (var (job, trigger) in triggers) { keys.Add(trigger.Key); - // Triggers whose schedule isn't sent don't change the monitor - var attribute = GetAttribute(QuartzApi.GetJobType(job))!; - if (!attribute.SendMonitorConfig + // 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) { @@ -240,18 +307,18 @@ public List WithDifferentSchedule(string monitorSlug, TriggerKey tri .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 static bool IsMonitored(IJobDetail job) + // 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 { - try - { - return GetAttribute(QuartzApi.GetJobType(job)) is not null; - } - catch (Exception) - { - return false; - } + return GetAttribute(QuartzApi.GetJobType(job)) is not null || IsMonitoredByDefault(job); + } + catch (Exception) + { + return false; } } diff --git a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs index 0aead11422..10a87a5788 100644 --- a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs +++ b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs @@ -5,7 +5,7 @@ namespace Sentry.Quartz; /// /// /// -/// Jobs without this attribute are not monitored. +/// 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 diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index 2708b6d419..5bb549f1c1 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -7,6 +7,34 @@ namespace Sentry.Quartz; /// 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 diff --git a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs index 2c15736eb2..f873087e0b 100644 --- a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -25,7 +25,8 @@ public static class SentryQuartzBuilderExtensions /// /// 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 . + /// error check-in. See . To monitor every scheduled job, + /// enable . /// /// /// Configures the options. diff --git a/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 35f64f8b6b..b536bcc3af 100644 --- a/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -19,6 +19,8 @@ { 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/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index c500d5d7f3..57d6e111a4 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -554,6 +554,351 @@ public async Task StartAsync_JobWithoutIdentity_UsesClassNameAndLogs() "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() { diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index 928c041433..2ae218fcb2 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -63,6 +63,23 @@ public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalAn _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() { @@ -89,7 +106,7 @@ await RunUntilCheckInCompletes("Cleanup", null, trigger => trigger public async Task AddSentry_JobFailsWithLoggingIntegration_CapturesExceptionOnce() { // Act - await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger.StartNow(), services => services + await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger.StartNow(), configureServices: services => services .AddSingleton(_hub) .AddLogging(logging => logging.AddSentry(options => options.InitializeSdk = false))); @@ -162,9 +179,10 @@ 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, configureServices); + var scheduler = await BuildScheduler(name, group, configureTrigger, options, configureServices); await scheduler.Start(); try { @@ -182,6 +200,7 @@ private async Task BuildScheduler( string name, string? group, Action> configureTrigger, + SentryQuartzOptions? options = null, Action? configureServices = null) where TJob : IJob { var services = new ServiceCollection(); @@ -190,7 +209,7 @@ private async Task BuildScheduler( services.AddQuartz(properties, quartz => { quartz.UseInMemoryStore(); - quartz.AddSentry(new SentryQuartzOptions(), _hub); + quartz.AddSentry(options ?? new SentryQuartzOptions(), _hub); var jobKey = new JobKey(name, group ?? JobKey.DefaultGroup); quartz.AddJob(job => job.WithIdentity(jobKey)); From 6381270a020e03e066d2bfc7474ebf1dbd6081a0 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 15:14:05 -0700 Subject: [PATCH 31/35] feat: add Sentry.Quartz3 for cron monitoring of Quartz.NET 3.x jobs (#5668) * feat: add Sentry.Quartz * refactor: optimize cron information retrieval in SentryCronJobListener * refactor: swap ILogger dependency from SentryCronJobListener with IDiagnosticLogger * refactor: simplify Quartz configuration by removing redundant service dependencies and streamlining SentryCronJobListener options handling * refactor: migrate to Quartz v4.0.0 with .NET 10 * feat: include Sentry.Quartz in solution filter and generation scripts * feat: add Quartz sample showcasing Sentry integration * fix: cursor github comments * refactor: simplify Quartz configuration and update project to use Web SDK * refactor: replace SentryCronJobListener with SentryCronJobMiddleware and update Quartz integration * fix: handling issue where current region might throw a CultureNotFoundException * fix: sample project has different nuget package version * fix: skip middleware execution for non-cron triggers * refactor: rename and update Quartz extension method to AddSentryCronJob * feat: add SentryMetricsMiddleware to track Quartz job execution duration metrics * refactor: simplify AddSentryCronJob method by removing unused service collection parameter and streamlining option configuration * feat: add SentryScopeMiddleware and extend Quartz integration with AddSentryScope method, update SentryMetricsMiddleware to support configurable options * refactor: move non-cron trigger check to StartQuartz method * feat: add missing options parameter * refactor: remove unused project reference to Sentry.AspNetCore in Sentry.Quartz * fix: Quatz.Net and Sentry handling cron expression for days differently * feat: add XML documenation * feat: update Quartz to stable v4.0.0 * feat: add Quartz Dashboard and HTTP API integration, update launch URL * feat: add customizable SentryMonitorOptions via callback * style: apply dotnet format to Sentry.Quartz The format-code workflow can't push its auto-fix to a fork branch, so it fails the check instead. Applying the fix here. Co-Authored-By: Claude Opus 5 * ci(craft): add Sentry.Quartz to the release registry Every package needs a name/packageUrl/mainDocsUrl entry so craft can create its registry entry on first publish; a missing `name` fails the whole registry target rather than just that package. Co-Authored-By: Claude Opus 5 * test: add Sentry.Quartz.Tests Co-Authored-By: Claude Sonnet 5 * feat: add configurable SentryScopeMiddleware options * refactor: migrate extension methods to extension block * refactor: remove SentryMetricsMiddleware * fix: use JobDetail.Key.ToString() * fix: replace job type with job ID in SentryCronJobMiddleware dictionary key * feat(quartz): Rework Sentry.Quartz into Sentry.Quartz4 Keeps the middleware design from #5505 and adds what the cron monitors need on top: - One AddSentry() entry point on IQuartzBuilder that registers the scope and check-in middleware with HubAdapter, so no IHub is needed in DI. - The scope middleware starts a new trace per run and captures what the job throws inside the run's scope. - Check-ins only for jobs with [SentryCronMonitorSlug]. The slug comes from the trigger or job data map, the attribute, or the slugified job key, so job keys sharing a class don't share a monitor. - The monitor config is converted from cron and simple triggers, with the Quartz day-of-week shift, and left out when Sentry can't represent the schedule. SendMonitorConfig turns it off globally or per job. - ConfigureMonitorOptions runs before the trigger's schedule is applied, so it can override it, and never breaks the check-in. - The package is signed and restricted to Quartz [4.0.0,5.0.0). The schedule conversion, slug and check-in logic live in Core/ so the Quartz 3 package can compile the same files. Co-Authored-By: Claude * ref(quartz): Rename Sentry.Quartz4 to Sentry.Quartz Sentry.Quartz3 keeps its name for Quartz.NET 3.x. Co-Authored-By: Claude * fix(quartz): Address review feedback on Sentry.Quartz - Jobs built without an identity get a new GUID name on each start, so their monitor slug falls back to the job class name, with a warning. Two jobs that resolve to the same slug also log a warning. - The scope middleware no longer captures exceptions. Quartz logs them, and the logging integration reports that. The cron middleware still sends an error check-in. In global mode the scope is left alone. - Quartz 4 accepts '*' in both day fields and runs on either day when both restrict. Convert those expressions, spelling out a '*' step as a range so Sentry unions the fields too. - ConfigureMonitorOptions takes the IJobExecutionContext and always runs while SendMonitorConfig is on. The trigger's schedule fills in when it sets none, and the config is only sent when it has a schedule. - The check-in ID is created up front, so the final check-in matches the in-progress one even when that wasn't sent. Co-Authored-By: Claude * feat(quartz): Add Sentry.Quartz3 for Quartz.NET 3.x Compiles the Sentry.Quartz core (schedule conversion, slugs, options and check-ins) against Quartz 3.6+ and drives it from an IJobListener, since Quartz 3 has no job middleware. Logs an error at startup when Quartz 4 is loaded, where the listener's Task methods would never be called. Co-Authored-By: Claude * fix(quartz): Apply the Sentry.Quartz review fixes to Sentry.Quartz3 Quartz 3 needs exactly one '?' day field, so only that form is converted. ConfigureMonitorOptions gets the listener's IJobExecutionContext, and the final check-in reuses the ID created for the in-progress one. Co-Authored-By: Claude * ref(quartz): Drop warn-once state and discard options from a failed callback Log the unnamed-job fallback at debug level on each run instead of tracking which jobs were warned about, and remove the shared-slug detection. If ConfigureMonitorOptions throws, ignore what it set and use the trigger's schedule. Co-Authored-By: Claude * fix(quartz): Capture job exceptions in the job scope and rename the slug attribute Quartz logs a job's exception only after the middleware chain has returned, so the logged error lost the job's scope and trace. Capture it inside the scope again; the SDK drops the duplicate from the log. Rename SentryCronMonitorSlugAttribute to SentryMonitorSlugAttribute to match Sentry.Hangfire. Co-Authored-By: Claude * ref(quartz): Rename SentryCronMonitorSlug to SentryMonitorSlug in Sentry.Quartz3 Co-Authored-By: Claude * test(quartz): Reorder the Sentry.Quartz3 API snapshots after the attribute rename Co-Authored-By: Claude * docs(quartz): Say that AddSentry captures job exceptions The AddSentry() remarks still said job exceptions are left to the logging integration. The scope middleware has captured them again since 73e5551. Co-Authored-By: Claude Opus 5.5 * ref(quartz): Remove a leftover comment from JobMonitor * fix(quartz): Drop the schedule when jobs share a slug with different schedules Reads every monitored trigger, cached for a minute, like Sentry.Hangfire. Replaces the check for jobs with several triggers. * test(quartz): Skip the time zone test when there's no time zone database * docs(quartz): Drop the stale warning from the slug attribute docs A job without an identity has fallen back to its class name with a debug message, not a warning, since fe97276. Co-Authored-By: Claude Opus 5.5 * Merge branch 'danf/quartz-5505-rebased' into danf/quartz-crons Ports the shared slug check to Quartz 3: QuartzApi.GetTriggersAsync reads each job and the triggers of each monitored one, since Quartz 3 has no bulk lookups. The test shim gets the new ReturnsTriggers and LookedUpTriggers, and HasSeveralTriggersAsync is gone. Co-Authored-By: Claude * fix(quartz): Skip jobs whose type can't be resolved when reading triggers A job left in a persistent store after its class was renamed or deleted made the whole trigger read throw, so no monitored job sent its schedule. Co-Authored-By: Claude * test(quartz): Pin the order of the Sentry job middleware Co-Authored-By: Claude * docs(quartz): Update the sample's comment on how job exceptions are captured Co-Authored-By: Claude * test(quartz): Fix the nullability of the Quartz 3 job detail stub Co-Authored-By: Claude * test(quartz): Ignore the nullability of the Quartz 3 job detail stub Quartz 3 annotates GetJobDetail differently across target frameworks. Co-Authored-By: Claude * fix(quartz): Capture each job exception once with the logging integration on Quartz's ErrorLogger logs the exception again, wrapped in a JobExecutionProcessException that the default DeduplicateMode doesn't unwrap. AddSentry now adds a log entry filter for that entry, to whichever SentryLoggingOptions the app uses (SentryAspNetCoreOptions included), when the middleware captured the inner exception. Co-Authored-By: Claude * test(quartz): Check that AddSentry leaves unrelated options resolvable Co-Authored-By: Claude * Update src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs Co-authored-by: Ricardo Colombo Oliveira * ref(quartz): Drop the stale constraint wording and assertion for the log filter setup Co-Authored-By: Claude * fix(quartz3): Send an error check-in when a job is cancelled Quartz 3 swallows a cancelled job's OperationCanceledException and calls JobWasExecuted with no exception, so check the context's cancellation token too. This matches SentryCronJobMiddleware in Sentry.Quartz. * test(quartz): Only run the IANA time zone test on .NET 6+ .NET Framework on Windows can't resolve IANA time zone IDs, and the shared JobMonitor tests run on net48 in Sentry.Quartz3.Tests. --------- Co-authored-by: Michael Mairegger Co-authored-by: James Crosswell Co-authored-by: Claude Opus 5 Co-authored-by: Ricardo Colombo Oliveira --- .craft.yml | 4 + .generated.NoMobile.slnx | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 1 + README.md | 3 + Sentry-CI-Build-Linux-NoMobile.slnf | 2 + Sentry-CI-Build-Linux.slnf | 2 + Sentry-CI-Build-Windows-arm64.slnf | 2 + Sentry-CI-Build-Windows.slnf | 2 + Sentry-CI-Build-macOS.slnf | 2 + Sentry-CI-CodeQL.slnf | 1 + Sentry.slnx | 2 + SentryAspNetCore.slnf | 1 + SentryNoMobile.slnf | 2 + SentryNoSamples.slnf | 2 + scripts/generate-solution-filters-config.yaml | 1 + src/Sentry.Quartz3/QuartzApi.cs | 40 +++++ src/Sentry.Quartz3/Sentry.Quartz3.csproj | 29 +++ src/Sentry.Quartz3/SentryJobListener.cs | 79 ++++++++ src/Sentry/Sentry.csproj | 2 + .../Core/JobMonitorTests.cs | 2 + ...iApprovalTests.Run.DotNet10_0.verified.txt | 26 +++ ...piApprovalTests.Run.DotNet8_0.verified.txt | 26 +++ ...piApprovalTests.Run.DotNet9_0.verified.txt | 26 +++ .../ApiApprovalTests.Run.Net4_8.verified.txt | 26 +++ .../ApiApprovalTests.verify.cs | 10 ++ test/Sentry.Quartz3.Tests/QuartzTestApi.cs | 54 ++++++ .../Sentry.Quartz3.Tests.csproj | 24 +++ .../SentryJobListenerTests.cs | 170 ++++++++++++++++++ .../TriggerScheduleQuartz3Tests.cs | 28 +++ 29 files changed, 571 insertions(+) create mode 100644 src/Sentry.Quartz3/QuartzApi.cs create mode 100644 src/Sentry.Quartz3/Sentry.Quartz3.csproj create mode 100644 src/Sentry.Quartz3/SentryJobListener.cs create mode 100644 test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt create mode 100644 test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt create mode 100644 test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt create mode 100644 test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.Net4_8.verified.txt create mode 100644 test/Sentry.Quartz3.Tests/ApiApprovalTests.verify.cs create mode 100644 test/Sentry.Quartz3.Tests/QuartzTestApi.cs create mode 100644 test/Sentry.Quartz3.Tests/Sentry.Quartz3.Tests.csproj create mode 100644 test/Sentry.Quartz3.Tests/SentryJobListenerTests.cs create mode 100644 test/Sentry.Quartz3.Tests/TriggerScheduleQuartz3Tests.cs diff --git a/.craft.yml b/.craft.yml index d02883d4ab..1f3177969b 100644 --- a/.craft.yml +++ b/.craft.yml @@ -102,3 +102,7 @@ targets: 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 853302c314..11265dd308 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -168,6 +168,7 @@ + @@ -203,6 +204,7 @@ + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3e80e2c1f9..780b3b9a65 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -30,6 +30,7 @@ body: - Sentry.OpenTelemetry - Sentry.Profiling - Sentry.Quartz + - Sentry.Quartz3 - Sentry.Serilog - Other validations: diff --git a/README.md b/README.md index 273606efb2..5e4a912246 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,11 @@ Sentry SDK for .NET | **Sentry.OpenTelemetry.Exporter** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/v/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.OpenTelemetry.Exporter.svg)](https://www.nuget.org/packages/Sentry.OpenTelemetry.Exporter) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/tracing/instrumentation/opentelemetry-otlp/) | | **Sentry.Profiling** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Profiling.svg)](https://www.nuget.org/packages/Sentry.Profiling) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/profiling/) | | **Sentry.Quartz** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Quartz.svg)](https://www.nuget.org/packages/Sentry.Quartz) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/crons/quartz/) | +| **Sentry.Quartz3** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Quartz3.svg)](https://www.nuget.org/packages/Sentry.Quartz3) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Quartz3.svg)](https://www.nuget.org/packages/Sentry.Quartz3) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Quartz3.svg)](https://www.nuget.org/packages/Sentry.Quartz3) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/crons/quartz/) | | **Sentry.Serilog** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Serilog.svg)](https://www.nuget.org/packages/Serilog) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Serilog.svg)](https://www.nuget.org/packages/Sentry.Serilog) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/guides/serilog) | +Use Sentry.Quartz3 with Quartz.NET 3.x (3.6.0 or later) and Sentry.Quartz with Quartz.NET 4.x. + ## More Sentry .NET Integrations Sentry offers other integrations that are not part of this repository: diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index 96a8125b7d..d2f5f342c9 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -51,6 +51,7 @@ "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 +74,7 @@ "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 e502cf89f1..e3003a227a 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -55,6 +55,7 @@ "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", @@ -80,6 +81,7 @@ "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-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index 63b09f4ada..1dc63a23dd 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -58,6 +58,7 @@ "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", @@ -80,6 +81,7 @@ "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 33b56f3d83..5afc4b30e2 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -58,6 +58,7 @@ "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", @@ -83,6 +84,7 @@ "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 4b1980cdaa..7b8922608a 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -59,6 +59,7 @@ "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 +85,7 @@ "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 b7d1fc7400..dd9a6bfa81 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -23,6 +23,7 @@ "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.slnx b/Sentry.slnx index 853302c314..11265dd308 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -168,6 +168,7 @@ + @@ -203,6 +204,7 @@ + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index f1e754f58c..77e1f7d656 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -22,6 +22,7 @@ "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 76a74169fe..6994207630 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -53,6 +53,7 @@ "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 +79,7 @@ "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 72d61e8bcf..48eaa8545f 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -25,6 +25,7 @@ "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 +53,7 @@ "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/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index 5cb493c15d..15f8e80279 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -213,6 +213,7 @@ filterConfigs: - "**/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/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/Sentry.csproj b/src/Sentry/Sentry.csproj index 72683cf27a..9c4cf0530d 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -176,6 +176,8 @@ + + diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 57d6e111a4..f585d495bf 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -169,6 +169,7 @@ public async Task StartAsync_JobsShareSlugWithDifferentSchedules_CapturesCheckIn _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().BeNull(); } +#if NET6_0_OR_GREATER [Fact] public async Task StartAsync_JobsShareSlugWithDifferentTimeZones_CapturesCheckInWithoutMonitorConfig() { @@ -184,6 +185,7 @@ public async Task StartAsync_JobsShareSlugWithDifferentTimeZones_CapturesCheckIn // Assert _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().BeNull(); } +#endif [Fact] public async Task StartAsync_JobsShareSlugWithSameConvertedSchedule_CapturesCheckInWithMonitorConfig() 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..da16f2e475 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -0,0 +1,26 @@ +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 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..da16f2e475 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -0,0 +1,26 @@ +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 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..da16f2e475 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -0,0 +1,26 @@ +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 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..da16f2e475 --- /dev/null +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -0,0 +1,26 @@ +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 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); + } +} From 5d3318944bc8bb65836d234a02f9c630708fb290 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Thu, 29 Oct 2026 22:27:58 +0000 Subject: [PATCH 32/35] perf: reduce allocations when pushing scopes via ILogger.BeginScope (#5828) * perf: pushing a scope no longer eagerly allocates tags, extras, contexts and attachments Scope.Clone runs on every ILogger.BeginScope call made while Sentry's logging integration is registered. Every Scope eagerly allocated three ConcurrentDictionary instances (tags, extras and the one inside SentryContexts), which made up around 80% of the 4.4 KB each scope cost on a 20 core machine (about 60% of 2.5 KB with 2 cores). Create the tags, extras, contexts, attachments and SDK version lazily, and skip copying them in Clone and Apply when the source has none. The public properties still return the same live instance once created. Assisted-by: Claude Code Co-Authored-By: Claude Code * perf: cloning a scope copies its breadcrumbs without intermediate allocations Scope.Clone enumerated the breadcrumbs of the source scope (which takes a snapshot array) and enqueued them one by one into the clone, repeatedly growing the clone's backing list. With the default of 100 breadcrumbs that was around 3 KB per clone. Copy the items of the source queue directly into the clone with a single sized allocation instead. Assisted-by: Claude Code Co-Authored-By: Claude Code * perf: SentryLogger only formats a log message once it knows it will be used SentryLogger.Log invoked the formatter before checking whether the entry came from the Sentry SDK itself, was an EF exception message or was excluded by a log entry filter, so the message was formatted (and allocated) for every entry that was then discarded. The filter check also allocated a closure for every entry through LINQ's Any(). Move the formatting after those checks and replace the LINQ call with a loop. Assisted-by: Claude Code Co-Authored-By: Claude Code * refactor: address review feedback on lazy scope collections Use LazyInitializer.EnsureInitialized for the lazily created collections of Scope instead of hand-written compare-and-swap loops. Assert in the Clone test that the contexts and attachments of a clone are independent of the scope they were cloned from, and remove new tests that only duplicate the coverage of existing tests. Assisted-by: Claude Code Co-Authored-By: Claude Code --------- Co-authored-by: Claude Code --- src/Sentry.Extensions.Logging/SentryLogger.cs | 16 ++- src/Sentry/Internal/ConcurrentQueueLite.cs | 21 ++++ src/Sentry/Scope.cs | 109 +++++++++++------- .../SentryLoggerTests.cs | 82 +++++++++++++ .../Internals/ConcurrentQueueLiteTests.cs | 20 ++++ test/Sentry.Tests/ScopeTests.cs | 72 +++++++++++- 6 files changed, 274 insertions(+), 46 deletions(-) diff --git a/src/Sentry.Extensions.Logging/SentryLogger.cs b/src/Sentry.Extensions.Logging/SentryLogger.cs index 9a25cfc0b5..2b31ef868d 100644 --- a/src/Sentry.Extensions.Logging/SentryLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryLogger.cs @@ -50,13 +50,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); @@ -171,7 +171,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/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/Scope.cs b/src/Sentry/Scope.cs index 197a4f762d..154486ff5a 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. @@ -268,8 +270,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(); @@ -279,22 +283,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. @@ -368,7 +378,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); } @@ -380,14 +390,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); } @@ -396,7 +406,7 @@ public void UnsetTag(string key) /// public void AddAttachment(SentryAttachment attachment) { - _attachments.Add(attachment); + AttachmentStore.Add(attachment); ScopeObserver?.AddAttachment(attachment); } @@ -416,7 +426,7 @@ public void Clear() { Level = default; Request = new(); - Contexts.Clear(); + _contexts?.Clear(); User = new(); Release = default; Distribution = default; @@ -425,8 +435,8 @@ public void Clear() Transaction = default; Fingerprint = Array.Empty(); ClearBreadcrumbs(); - _extra.Clear(); - _tags.Clear(); + _extra?.Clear(); + _tags?.Clear(); ClearAttachments(); PropagationContext = new(); } @@ -436,7 +446,7 @@ public void Clear() /// public void ClearAttachments() { - _attachments.Clear(); + _attachments?.Clear(); ScopeObserver?.ClearAttachments(); } @@ -479,23 +489,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); @@ -505,7 +521,7 @@ public void Apply(IEventLike other) other.TransactionName ??= TransactionName; other.Level ??= Level; - Sdk.CopyTo(other.Sdk); + _sdk?.CopyTo(other.Sdk); } /// @@ -524,10 +540,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); + } } } @@ -561,29 +580,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/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs index 58fd48ab14..66d849382c 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs @@ -168,6 +168,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.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/ScopeTests.cs b/test/Sentry.Tests/ScopeTests.cs index f5503ce074..d667845d9d 100644 --- a/test/Sentry.Tests/ScopeTests.cs +++ b/test/Sentry.Tests/ScopeTests.cs @@ -181,7 +181,7 @@ public void Clone_EveryFieldHandled() string[] copied = [ "Options", "OnEvaluating", "Level", "_request", "_contexts", "_user", "Release", "Distribution", - "Environment", "_fallbackTransactionName", "_transaction", "PropagationContext", "SessionUpdate", "Sdk", + "Environment", "_fallbackTransactionName", "_transaction", "PropagationContext", "SessionUpdate", "_sdk", "Fingerprint", "_breadcrumbs", "_extra", "_tags", "_attachments", "_lazyEventProcessors", "_lazyTransactionProcessors", "_lazyExceptionProcessors", ]; @@ -198,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() { From c9470fa72866ad000fdd657d3780e1c517f42e00 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 30 Oct 2026 12:02:18 +1300 Subject: [PATCH 33/35] test(quartz3): Add MonitorAllJobs and ExcludedJobs to API snapshots #5741 added these options to SentryQuartzOptions, and #5668 added Sentry.Quartz3 (which compiles the same source) with snapshots that predate them. Each PR was green on its own; together they broke main. Co-Authored-By: Claude Opus 5.5 --- .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 2 ++ .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 2 ++ .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 2 ++ .../ApiApprovalTests.Run.Net4_8.verified.txt | 2 ++ 4 files changed, 8 insertions(+) diff --git a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index da16f2e475..83666fd730 100644 --- a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -21,6 +21,8 @@ { 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 index da16f2e475..83666fd730 100644 --- a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -21,6 +21,8 @@ { 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 index da16f2e475..83666fd730 100644 --- a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -21,6 +21,8 @@ { 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 index da16f2e475..83666fd730 100644 --- a/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -21,6 +21,8 @@ { 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 From d26b28a255bb55746e4f0e2e5c93560788ac3db1 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 30 Oct 2026 13:02:27 +1300 Subject: [PATCH 34/35] test: fix main's new tests and snapshots for version7 - Quartz: the logging integration no longer takes InitializeSdk. - AttachmentSerializationTests: Envelope.FromEvent's logger is now the last, optional parameter. - Add the net11.0 API snapshots for StringOrRegex.Regex and Sentry.Quartz3. Co-Authored-By: Claude Opus 5.5 --- .../SentryQuartzBuilderExtensionsTests.cs | 2 +- ...iApprovalTests.Run.DotNet11_0.verified.txt | 28 +++++++++++++++++++ ...iApprovalTests.Run.DotNet11_0.verified.txt | 1 + .../Envelopes/AttachmentSerializationTests.cs | 2 +- 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 test/Sentry.Quartz3.Tests/ApiApprovalTests.Run.DotNet11_0.verified.txt diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index 2ae218fcb2..ec5847fb9a 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -108,7 +108,7 @@ public async Task AddSentry_JobFailsWithLoggingIntegration_CapturesExceptionOnce // Act await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger.StartNow(), configureServices: services => services .AddSingleton(_hub) - .AddLogging(logging => logging.AddSentry(options => options.InitializeSdk = false))); + .AddLogging(logging => logging.AddSentry())); // Assert // Quartz also logs the job's own exception, which Sentry drops as the same exception instance 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.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/Protocol/Envelopes/AttachmentSerializationTests.cs b/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs index 69b2d7229d..bed1cff3f2 100644 --- a/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs +++ b/test/Sentry.Tests/Protocol/Envelopes/AttachmentSerializationTests.cs @@ -453,6 +453,6 @@ private Envelope CreateEventEnvelopeWithEmptyNonSeekableAttachment() "empty.jpg", "image/jpeg"); - return Envelope.FromEvent(new SentryEvent(), _testOutputLogger, [attachment]); + return Envelope.FromEvent(new SentryEvent(), [attachment], logger: _testOutputLogger); } } From d28aa8bc5ae3a40dbd8f00fb0b1807feb5cf8f71 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 30 Oct 2026 13:16:03 +1300 Subject: [PATCH 35/35] ci: point the device tests' workload restore at maui-device version7 renamed integration-test/net9-maui to maui-device. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/device-tests-android.yml | 2 +- .github/workflows/device-tests-ios.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index 613e92b8eb..8972524831 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -195,7 +195,7 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment with: - projects: integration-test/net9-maui/Sentry.Maui.Device.IntegrationTestApp.csproj + projects: integration-test/maui-device/Sentry.Maui.Device.IntegrationTestApp.csproj - name: Checkout github-workflows uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/device-tests-ios.yml b/.github/workflows/device-tests-ios.yml index afada43ceb..8b3d652aa8 100644 --- a/.github/workflows/device-tests-ios.yml +++ b/.github/workflows/device-tests-ios.yml @@ -146,7 +146,7 @@ jobs: - name: Setup Environment uses: ./.github/actions/environment with: - projects: integration-test/net9-maui/Sentry.Maui.Device.IntegrationTestApp.csproj + projects: integration-test/maui-device/Sentry.Maui.Device.IntegrationTestApp.csproj - name: Get Cocoa SDK cache key id: cocoa-key