From 350f0399b65adb0e057e6728c0ddf136c64879f9 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 6 Oct 2025 11:02:11 +0200 Subject: [PATCH 01/53] feat: add Sentry.Quartz --- README.md | 1 + Sentry-CI-Build-Linux-NoMobile.slnf | 1 + Sentry-CI-Build-Linux.slnf | 1 + Sentry-CI-Build-Windows-arm64.slnf | 1 + Sentry-CI-Build-Windows.slnf | 1 + Sentry-CI-Build-macOS.slnf | 1 + Sentry-CI-CodeQL.slnf | 1 + SentryNoMobile.slnf | 1 + SentryNoSamples.slnf | 1 + .../GlobalConfigurationExtensions.cs | 52 ++++++++ src/Sentry.Quartz/Sentry.Quartz.csproj | 25 ++++ src/Sentry.Quartz/SentryCronInformation.cs | 21 ++++ src/Sentry.Quartz/SentryCronJobListener.cs | 111 ++++++++++++++++++ src/Sentry.Quartz/SentryCronJobOptions.cs | 16 +++ .../SentryCronMonitorSlugAttribute.cs | 17 +++ 15 files changed, 251 insertions(+) create mode 100644 src/Sentry.Quartz/GlobalConfigurationExtensions.cs create mode 100644 src/Sentry.Quartz/Sentry.Quartz.csproj create mode 100644 src/Sentry.Quartz/SentryCronInformation.cs create mode 100644 src/Sentry.Quartz/SentryCronJobListener.cs create mode 100644 src/Sentry.Quartz/SentryCronJobOptions.cs create mode 100644 src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs 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..745c3f904b 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -49,6 +49,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", diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index 2796e38594..dba46d493c 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -55,6 +55,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", diff --git a/Sentry-CI-Build-Windows-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index 19f3cc370c..b09c8be29e 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -57,6 +57,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", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index ea897b0b25..72c6bd7123 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -57,6 +57,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", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index f8d1358a11..a00afd8543 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -62,6 +62,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", 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/SentryNoMobile.slnf b/SentryNoMobile.slnf index 97defed5d1..f014db9871 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -51,6 +51,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", diff --git a/SentryNoSamples.slnf b/SentryNoSamples.slnf index 1d2813957f..eb6b93f9ea 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", diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs new file mode 100644 index 0000000000..b7716a2fa7 --- /dev/null +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Quartz; + +namespace Sentry.Quartz; + +/// +/// Quartz.NET Extensions for . +/// +public static class GlobalConfigurationExtensions +{ + /// + /// Uses Sentry + /// + /// + /// + /// + /// + public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IServiceCollection services, Action configure) + { + services.AddOptions().PostConfigure(configure); + + services.AddTransient(); + configuration.AddJobListener(sp => + { + var logger = sp.GetRequiredService>(); + var options = sp.GetRequiredService>(); + + return new SentryCronJobListener(logger, options); + }); + + return configuration; + } + + /// + /// For testing + /// + /// + /// + /// + /// + /// + /// + internal static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IServiceCollection services, IHub? hub, ILogger logger, IOptions options) + { + services.AddTransient(); + configuration.AddJobListener(new SentryCronJobListener(logger, options, hub)); + + return configuration; + } +} diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj new file mode 100644 index 0000000000..be6122e296 --- /dev/null +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -0,0 +1,25 @@ + + + + Official Quartz integration for Sentry - Open-source error tracking that helps developers monitor and fix crashes in real time. + $(PackageTags);Quartz + net9.0;net8.0 + enable + false + + + + true + + + + + + + + + + + + + diff --git a/src/Sentry.Quartz/SentryCronInformation.cs b/src/Sentry.Quartz/SentryCronInformation.cs new file mode 100644 index 0000000000..13a76f96ea --- /dev/null +++ b/src/Sentry.Quartz/SentryCronInformation.cs @@ -0,0 +1,21 @@ +using Quartz; + +namespace Sentry.Quartz; + +internal sealed class SentryCronInformation +{ + public SentryCronInformation(IJob job) + { + var jobType = job.GetType(); + var monitorAttribute = jobType.GetCustomAttribute(); + + ShouldWriteStatusToSentry = monitorAttribute is not null; + MonitorSlug = monitorAttribute?.MonitorSlug ?? jobType.Name; + } + + public string MonitorSlug { get; } + + public bool ShouldWriteStatusToSentry { get; } + + internal bool WarningShownForSecondsParameterIssue { get; set; } +} diff --git a/src/Sentry.Quartz/SentryCronJobListener.cs b/src/Sentry.Quartz/SentryCronJobListener.cs new file mode 100644 index 0000000000..9cdd6354a8 --- /dev/null +++ b/src/Sentry.Quartz/SentryCronJobListener.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Quartz; +using Quartz.Listener; +using Sentry.Extensibility; + +namespace Sentry.Quartz; + +internal sealed partial class SentryCronJobListener : JobListenerSupport +{ + private readonly ILogger _logger; + private readonly SentryCronJobOptions _options; + private readonly ConcurrentDictionary _fireInstanceId = new(); + private readonly ConcurrentDictionary _sentryCronInformation = []; + private readonly IHub _hub; + + public SentryCronJobListener(ILogger logger, IOptions options, IHub? hub = null) + { + _hub = hub ?? HubAdapter.Instance; + _logger = logger; + _options = options.Value; + } + + public override string Name { get; } = "Sentry Job Listener"; + + public override Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) + { + var jobType = context.JobInstance.GetType(); + _sentryCronInformation.TryAdd(jobType, new SentryCronInformation(context.JobInstance)); + + if (_sentryCronInformation.TryGetValue(jobType, out var info) && info.ShouldWriteStatusToSentry) + { + var sentryId = _hub.CaptureCheckIn(info.MonitorSlug, CheckInStatus.InProgress, configureMonitorOptions: options => + { + if (_options.EnableUpsertCronMonitor && context.Trigger is ICronTrigger cronTrigger) + { + UpsertCronMonitor(cronTrigger, info, options); + } + }); + + _fireInstanceId.TryAdd(context.FireInstanceId, sentryId); + } + + return Task.CompletedTask; + } + + public override Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default) + { + var jobType = context.JobInstance.GetType(); + + if (_sentryCronInformation.TryGetValue(jobType, out var info) && info.ShouldWriteStatusToSentry) + { + var status = jobException is not null ? CheckInStatus.Error : CheckInStatus.Ok; + _fireInstanceId.TryRemove(context.FireInstanceId, out var checkInId); + + _hub.CaptureCheckIn(info.MonitorSlug, status, checkInId); + } + + return Task.CompletedTask; + } + + private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation information, SentryMonitorOptions options) + { + if (string.IsNullOrWhiteSpace(cronTrigger.CronExpressionString)) + { + return; + } + + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(cronTrigger.TimeZone.Id, RegionInfo.CurrentRegion.TwoLetterISORegionName, out string? iana)) + { + options.TimeZone = iana; + } + else + { + options.TimeZone = cronTrigger.TimeZone.Id; + } + + string monitorSlug = information.MonitorSlug; + string cron = cronTrigger.CronExpressionString.Replace("?", "*", StringComparison.OrdinalIgnoreCase); + var cronParts = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries); + + if (cronParts.Length is 6 or 7) + { + if(!cronParts[0].Equals("0", StringComparison.OrdinalIgnoreCase)) + { + if (!information.WarningShownForSecondsParameterIssue) + { + information.WarningShownForSecondsParameterIssue = true; + LogUpdateCronMonitorGranularityWarning(monitorSlug, cron); + } + } + cronParts = cronParts[1..6]; + } + + try + { + string crontab = string.Join(" ", cronParts); + options.Interval(crontab); + } + catch (ArgumentException ex) + { + LogUpdateCronMonitorError(ex, monitorSlug, cron); + } + } + + [LoggerMessage(LogLevel.Warning, """Sentry Cron Monitor supports a minimum granularity of minutes. But for job {MonitorSlug} "{CronExpression}" was provided. The first field (seconds) will be ignored""")] + private partial void LogUpdateCronMonitorGranularityWarning(string monitorSlug, string cronExpression); + + [LoggerMessage(LogLevel.Error, """Sentry Cron Monitor update failed for job {MonitorSlug}. Cron expression "{CronExpression}" is invalid""")] + private partial void LogUpdateCronMonitorError(Exception ex, string monitorSlug, string cronExpression); +} diff --git a/src/Sentry.Quartz/SentryCronJobOptions.cs b/src/Sentry.Quartz/SentryCronJobOptions.cs new file mode 100644 index 0000000000..e4283c42a2 --- /dev/null +++ b/src/Sentry.Quartz/SentryCronJobOptions.cs @@ -0,0 +1,16 @@ +namespace Sentry.Quartz; + +/// +/// Represents configuration options for Sentry integration with Quartz Cron Jobs. +/// +public class SentryCronJobOptions +{ + /// + /// Gets or sets a value indicating whether to enable or disable the automatic upsert of a CronMonitor when a CronTrigger is linked with the job execution context. + /// + /// + /// If set to true, enables the creation or update of a CronMonitor with relevant details, such as Cron expression and time zone, + /// during a job execution that is configured with a CronTrigger. This ensures that the monitor reflects accurate scheduling metadata in Sentry. + /// + public bool EnableUpsertCronMonitor { get; set; } = true; +} diff --git a/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs b/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs new file mode 100644 index 0000000000..1b6581f541 --- /dev/null +++ b/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs @@ -0,0 +1,17 @@ +namespace Sentry.Quartz; + +/// +/// Sentry Monitor Slug Attribute +/// +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class SentryCronMonitorSlugAttribute(string? monitorSlug = null) : Attribute +{ + /// + /// Gets the slug associated with the Sentry monitor. The monitor slug is used to + /// identify a specific Sentry monitor associated with a job. If no slug is explicitly + /// provided through the , it defaults to + /// the name of the job type. + /// + public string? MonitorSlug { get; } = monitorSlug; +} From be40ccc25ef27bca55f26e9fcf48e02b6a72b6c2 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 7 Oct 2025 11:45:50 +0200 Subject: [PATCH 02/53] refactor: optimize cron information retrieval in SentryCronJobListener --- src/Sentry.Quartz/SentryCronJobListener.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Quartz/SentryCronJobListener.cs b/src/Sentry.Quartz/SentryCronJobListener.cs index 9cdd6354a8..86902d7f27 100644 --- a/src/Sentry.Quartz/SentryCronJobListener.cs +++ b/src/Sentry.Quartz/SentryCronJobListener.cs @@ -26,9 +26,9 @@ public SentryCronJobListener(ILogger logger, IOptions new SentryCronInformation(context.JobInstance)); - if (_sentryCronInformation.TryGetValue(jobType, out var info) && info.ShouldWriteStatusToSentry) + if (info.ShouldWriteStatusToSentry) { var sentryId = _hub.CaptureCheckIn(info.MonitorSlug, CheckInStatus.InProgress, configureMonitorOptions: options => { From ed085600a3b310f8cae27ac487c8778c05a2813f Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 7 Oct 2025 13:49:08 +0200 Subject: [PATCH 03/53] refactor: swap ILogger dependency from SentryCronJobListener with IDiagnosticLogger --- .../GlobalConfigurationExtensions.cs | 9 +++----- src/Sentry.Quartz/Sentry.Quartz.csproj | 1 - src/Sentry.Quartz/SentryCronJobListener.cs | 21 +++++++++---------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index b7716a2fa7..12756472ca 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -21,13 +21,11 @@ public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollec { services.AddOptions().PostConfigure(configure); - services.AddTransient(); configuration.AddJobListener(sp => { - var logger = sp.GetRequiredService>(); var options = sp.GetRequiredService>(); - return new SentryCronJobListener(logger, options); + return new SentryCronJobListener(options); }); return configuration; @@ -39,13 +37,12 @@ public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollec /// /// /// - /// /// /// - internal static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IServiceCollection services, IHub? hub, ILogger logger, IOptions options) + internal static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IServiceCollection services, IHub? hub, IOptions options) { services.AddTransient(); - configuration.AddJobListener(new SentryCronJobListener(logger, options, hub)); + configuration.AddJobListener(new SentryCronJobListener(options, hub)); return configuration; } diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index be6122e296..b31b466b9f 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -13,7 +13,6 @@ - diff --git a/src/Sentry.Quartz/SentryCronJobListener.cs b/src/Sentry.Quartz/SentryCronJobListener.cs index 86902d7f27..cb699e3807 100644 --- a/src/Sentry.Quartz/SentryCronJobListener.cs +++ b/src/Sentry.Quartz/SentryCronJobListener.cs @@ -6,18 +6,16 @@ namespace Sentry.Quartz; -internal sealed partial class SentryCronJobListener : JobListenerSupport +internal sealed class SentryCronJobListener : JobListenerSupport { - private readonly ILogger _logger; private readonly SentryCronJobOptions _options; private readonly ConcurrentDictionary _fireInstanceId = new(); private readonly ConcurrentDictionary _sentryCronInformation = []; private readonly IHub _hub; - public SentryCronJobListener(ILogger logger, IOptions options, IHub? hub = null) + public SentryCronJobListener(IOptions options, IHub? hub = null) { _hub = hub ?? HubAdapter.Instance; - _logger = logger; _options = options.Value; } @@ -86,7 +84,7 @@ private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation i if (!information.WarningShownForSecondsParameterIssue) { information.WarningShownForSecondsParameterIssue = true; - LogUpdateCronMonitorGranularityWarning(monitorSlug, cron); + GetLogger()?.LogWarning("Sentry Cron Monitor supports a minimum granularity of minutes. But for job {0} \"{1}\" was provided. The first field (seconds) will be ignored", monitorSlug, cron); } } cronParts = cronParts[1..6]; @@ -99,13 +97,14 @@ private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation i } catch (ArgumentException ex) { - LogUpdateCronMonitorError(ex, monitorSlug, cron); + GetLogger()?.LogError(ex, "Sentry Cron Monitor update failed for job {0}. Cron expression \"{1}\" is invalid", monitorSlug, cron); } } - [LoggerMessage(LogLevel.Warning, """Sentry Cron Monitor supports a minimum granularity of minutes. But for job {MonitorSlug} "{CronExpression}" was provided. The first field (seconds) will be ignored""")] - private partial void LogUpdateCronMonitorGranularityWarning(string monitorSlug, string cronExpression); - - [LoggerMessage(LogLevel.Error, """Sentry Cron Monitor update failed for job {MonitorSlug}. Cron expression "{CronExpression}" is invalid""")] - private partial void LogUpdateCronMonitorError(Exception ex, string monitorSlug, string cronExpression); + private IDiagnosticLogger? GetLogger() + { +#pragma warning disable CS0618 // Type or member is obsolete + return _hub.GetInternalSentryOptions()?.DiagnosticLogger; +#pragma warning restore CS0618 // Type or member is obsolete + } } From 5b0911c7e541f2eb6c6f7bc6c82dd65b8b81cf09 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 7 Oct 2025 14:04:36 +0200 Subject: [PATCH 04/53] refactor: simplify Quartz configuration by removing redundant service dependencies and streamlining SentryCronJobListener options handling --- .../GlobalConfigurationExtensions.cs | 17 ++++------------- src/Sentry.Quartz/SentryCronJobListener.cs | 4 ++-- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 12756472ca..b61b1af189 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -1,6 +1,3 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Quartz; namespace Sentry.Quartz; @@ -14,17 +11,14 @@ public static class GlobalConfigurationExtensions /// Uses Sentry /// /// - /// /// /// - public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IServiceCollection services, Action configure) + public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, Action? configure = null) { - services.AddOptions().PostConfigure(configure); - configuration.AddJobListener(sp => { - var options = sp.GetRequiredService>(); - + var options = new SentryCronJobOptions(); + configure?.Invoke(options); return new SentryCronJobListener(options); }); @@ -35,15 +29,12 @@ public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollec /// For testing /// /// - /// /// /// /// - internal static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IServiceCollection services, IHub? hub, IOptions options) + internal static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IHub? hub, SentryCronJobOptions options) { - services.AddTransient(); configuration.AddJobListener(new SentryCronJobListener(options, hub)); - return configuration; } } diff --git a/src/Sentry.Quartz/SentryCronJobListener.cs b/src/Sentry.Quartz/SentryCronJobListener.cs index cb699e3807..6dde56898f 100644 --- a/src/Sentry.Quartz/SentryCronJobListener.cs +++ b/src/Sentry.Quartz/SentryCronJobListener.cs @@ -13,10 +13,10 @@ internal sealed class SentryCronJobListener : JobListenerSupport private readonly ConcurrentDictionary _sentryCronInformation = []; private readonly IHub _hub; - public SentryCronJobListener(IOptions options, IHub? hub = null) + public SentryCronJobListener(SentryCronJobOptions options, IHub? hub = null) { _hub = hub ?? HubAdapter.Instance; - _options = options.Value; + _options = options; } public override string Name { get; } = "Sentry Job Listener"; From 7927c335af0be9b7177e8b02def18394c280368d Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 24 Aug 2026 15:56:01 +0200 Subject: [PATCH 05/53] refactor: migrate to Quartz v4.0.0 with .NET 10 --- .../GlobalConfigurationExtensions.cs | 25 +++++++--- src/Sentry.Quartz/Sentry.Quartz.csproj | 4 +- src/Sentry.Quartz/SentryCronJobListener.cs | 47 +++++++++---------- 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index b61b1af189..704e0776ff 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -1,3 +1,6 @@ + using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Quartz; namespace Sentry.Quartz; @@ -12,14 +15,23 @@ public static class GlobalConfigurationExtensions /// /// /// + /// /// - public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, Action? configure = null) + public static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IServiceCollection serviceCollection, Action? configure = null) { + serviceCollection.AddSingleton(); + + if (configure is not null) + { + serviceCollection.Configure(configure); + } + configuration.AddJobListener(sp => { - var options = new SentryCronJobOptions(); - configure?.Invoke(options); - return new SentryCronJobListener(options); + var serviceScope = sp.CreateScope(); + var serviceProvider = serviceScope.ServiceProvider; + + return serviceProvider.GetRequiredService(); }); return configuration; @@ -31,10 +43,11 @@ public static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollec /// /// /// + /// /// - internal static IServiceCollectionQuartzConfigurator UseSentry(this IServiceCollectionQuartzConfigurator configuration, IHub? hub, SentryCronJobOptions options) + internal static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) { - configuration.AddJobListener(new SentryCronJobListener(options, hub)); + configuration.AddJobListener(new SentryCronJobListener(options, hub, logger)); return configuration; } } diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index b31b466b9f..d7883444ca 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -3,7 +3,7 @@ Official Quartz integration for Sentry - Open-source error tracking that helps developers monitor and fix crashes in real time. $(PackageTags);Quartz - net9.0;net8.0 + net10.0; enable false @@ -13,7 +13,7 @@ - + diff --git a/src/Sentry.Quartz/SentryCronJobListener.cs b/src/Sentry.Quartz/SentryCronJobListener.cs index 6dde56898f..90bb6d9609 100644 --- a/src/Sentry.Quartz/SentryCronJobListener.cs +++ b/src/Sentry.Quartz/SentryCronJobListener.cs @@ -1,27 +1,27 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Quartz; -using Quartz.Listener; -using Sentry.Extensibility; namespace Sentry.Quartz; -internal sealed class SentryCronJobListener : JobListenerSupport +internal sealed partial class SentryCronJobListener : IJobListener { private readonly SentryCronJobOptions _options; private readonly ConcurrentDictionary _fireInstanceId = new(); private readonly ConcurrentDictionary _sentryCronInformation = []; private readonly IHub _hub; + private readonly ILogger _logger; - public SentryCronJobListener(SentryCronJobOptions options, IHub? hub = null) + public SentryCronJobListener(IOptions options, IHub hub, ILogger logger) { - _hub = hub ?? HubAdapter.Instance; - _options = options; + _hub = hub; + _logger = logger; + _options = options.Value; } - public override string Name { get; } = "Sentry Job Listener"; + public string Name { get; } = "Sentry Job Listener"; - public override Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) + public ValueTask JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) { var jobType = context.JobInstance.GetType(); var info = _sentryCronInformation.GetOrAdd(jobType, _ => new SentryCronInformation(context.JobInstance)); @@ -39,10 +39,10 @@ public override Task JobToBeExecuted(IJobExecutionContext context, CancellationT _fireInstanceId.TryAdd(context.FireInstanceId, sentryId); } - return Task.CompletedTask; + return ValueTask.CompletedTask; } - public override Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default) + public ValueTask JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default) { var jobType = context.JobInstance.GetType(); @@ -54,7 +54,7 @@ public override Task JobWasExecuted(IJobExecutionContext context, JobExecutionEx _hub.CaptureCheckIn(info.MonitorSlug, status, checkInId); } - return Task.CompletedTask; + return ValueTask.CompletedTask; } private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation information, SentryMonitorOptions options) @@ -75,36 +75,35 @@ private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation i string monitorSlug = information.MonitorSlug; string cron = cronTrigger.CronExpressionString.Replace("?", "*", StringComparison.OrdinalIgnoreCase); - var cronParts = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries); + var cronSpan = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries); - if (cronParts.Length is 6 or 7) + if (cronSpan.Length is 6 or 7) { - if(!cronParts[0].Equals("0", StringComparison.OrdinalIgnoreCase)) + if (!cronSpan[0].Equals("0", StringComparison.OrdinalIgnoreCase)) { if (!information.WarningShownForSecondsParameterIssue) { information.WarningShownForSecondsParameterIssue = true; - GetLogger()?.LogWarning("Sentry Cron Monitor supports a minimum granularity of minutes. But for job {0} \"{1}\" was provided. The first field (seconds) will be ignored", monitorSlug, cron); + LogGranularityWarning(monitorSlug, cron); } } - cronParts = cronParts[1..6]; + cronSpan = cronSpan[1..6]; } try { - string crontab = string.Join(" ", cronParts); + string crontab = string.Join(" ", cronSpan); options.Interval(crontab); } catch (ArgumentException ex) { - GetLogger()?.LogError(ex, "Sentry Cron Monitor update failed for job {0}. Cron expression \"{1}\" is invalid", monitorSlug, cron); + LogApiException(ex, monitorSlug, cron); } } - private IDiagnosticLogger? GetLogger() - { -#pragma warning disable CS0618 // Type or member is obsolete - return _hub.GetInternalSentryOptions()?.DiagnosticLogger; -#pragma warning restore CS0618 // Type or member is obsolete - } + [LoggerMessage(1, LogLevel.Warning, "Sentry Cron Monitor supports a minimum granularity of minutes. But for job {MonitorSlug} \"{Cron}\" was provided. The first field (seconds) will be ignored")] + private partial void LogGranularityWarning(string monitorSlug, string cron); + + [LoggerMessage(2, LogLevel.Error, "Sentry Cron Monitor update failed for job {MonitorSlug}. Cron expression \"{Cron}\" is invalid")] + private partial void LogApiException(Exception exception, string monitorSlug, string cron); } From 654515632eb862a596956a645b2de7095725167f Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 24 Aug 2026 15:56:09 +0200 Subject: [PATCH 06/53] feat: include Sentry.Quartz in solution filter and generation scripts --- SentryAspNetCore.slnf | 1 + scripts/generate-solution-filters-config.yaml | 1 + 2 files changed, 2 insertions(+) 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/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" From 864de52d84159f6d0175523a75a79f4e04d84e52 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 24 Aug 2026 16:37:26 +0200 Subject: [PATCH 07/53] feat: add Quartz sample showcasing Sentry integration --- .generated.NoMobile.slnx | 2 + Sentry-CI-Build-Linux-NoMobile.slnf | 1 + Sentry-CI-Build-Linux.slnf | 1 + Sentry-CI-Build-Windows-arm64.slnf | 1 + Sentry-CI-Build-Windows.slnf | 1 + Sentry-CI-Build-macOS.slnf | 1 + Sentry.slnx | 2 + SentryNoMobile.slnf | 1 + samples/Sentry.Samples.Quartz/Program.cs | 76 +++++++++++++++++++ .../Properties/launchSettings.json | 41 ++++++++++ samples/Sentry.Samples.Quartz/README.md | 8 ++ .../Sentry.Samples.Quartz.csproj | 24 ++++++ .../Sentry.Samples.Quartz/appsettings.json | 39 ++++++++++ src/Sentry.Quartz/Sentry.Quartz.csproj | 1 + 14 files changed, 199 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 diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index 288e399aef..7e82c66add 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -118,6 +118,7 @@ + @@ -168,6 +169,7 @@ + diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index 745c3f904b..07a1abe2d2 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", diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index dba46d493c..8682928355 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", diff --git a/Sentry-CI-Build-Windows-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index b09c8be29e..cc2e2188a7 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", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index 72c6bd7123..1fed0a6e49 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", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index a00afd8543..738ded856e 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", diff --git a/Sentry.slnx b/Sentry.slnx index 288e399aef..7e82c66add 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -118,6 +118,7 @@ + @@ -168,6 +169,7 @@ + diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index f014db9871..588e79e7da 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", diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs new file mode 100644 index 0000000000..7e8d7bf998 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -0,0 +1,76 @@ +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.AddQuartz(quartz => + { + quartz.UseSentry(builder.Services, options => + { + var jobKey = new JobKey(nameof(FirstJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey)); + quartz.AddTrigger(opts => opts.ForJob(jobKey).WithIdentity($"{nameof(FirstJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + + quartz.AddJob(opts => opts.WithIdentity(jobKey)); + quartz.AddTrigger(opts => opts.ForJob(jobKey).WithIdentity($"{nameof(SecondJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + + quartz.AddJob(opts => opts.WithIdentity(jobKey)); + quartz.AddTrigger(opts => opts.ForJob(jobKey).WithIdentity($"{nameof(ThirdJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + + if (!builder.Environment.IsProduction()) + { + options.EnableUpsertCronMonitor = false; + } + }); + }); + + var app = builder.Build(); + + app.Run(); + } +} + +[SentryCronMonitorSlug("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}"); + } +} + +[SentryCronMonitorSlug("job-that-throws")] +public class SecondJob : 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}"); + throw new Exception(); + } +} + +[SentryCronMonitorSlug("RecurringBackgroundJob")] +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..1195f60877 --- /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": "hangfire", + "applicationUrl": "http://localhost:5138", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "hangfire", + "applicationUrl": "http://localhost:5138", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "hangfire", + "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..c2f83967d1 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/README.md @@ -0,0 +1,8 @@ +# Sentry for Hangfire + +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..1ca2eca694 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -0,0 +1,24 @@ + + + + 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..d5e353d5b7 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/appsettings.json @@ -0,0 +1,39 @@ +{ + // 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" + } + }, + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index d7883444ca..bedb90736b 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -14,6 +14,7 @@ + From 77ad99afaa506fe625499c3ba688cab11a8a8210 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 24 Aug 2026 16:54:00 +0200 Subject: [PATCH 08/53] fix: cursor github comments --- samples/Sentry.Samples.Quartz/Program.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs index 7e8d7bf998..8716ad9bf4 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -18,22 +18,24 @@ public static void Main(string[] args) { quartz.UseSentry(builder.Services, options => { - var jobKey = new JobKey(nameof(FirstJob)); - quartz.AddJob(opts => opts.WithIdentity(jobKey)); - quartz.AddTrigger(opts => opts.ForJob(jobKey).WithIdentity($"{nameof(FirstJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + var jobKey1 = new JobKey(nameof(FirstJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey1)); + quartz.AddTrigger(opts => opts.ForJob(jobKey1).WithIdentity($"{nameof(FirstJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); - quartz.AddJob(opts => opts.WithIdentity(jobKey)); - quartz.AddTrigger(opts => opts.ForJob(jobKey).WithIdentity($"{nameof(SecondJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + var jobKey2 = new JobKey(nameof(SecondJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey2)); + quartz.AddTrigger(opts => opts.ForJob(jobKey2).WithIdentity($"{nameof(SecondJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); - quartz.AddJob(opts => opts.WithIdentity(jobKey)); - quartz.AddTrigger(opts => opts.ForJob(jobKey).WithIdentity($"{nameof(ThirdJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + var jobKey3 = new JobKey(nameof(ThirdJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey3)); + quartz.AddTrigger(opts => opts.ForJob(jobKey3).WithIdentity($"{nameof(ThirdJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); if (!builder.Environment.IsProduction()) { options.EnableUpsertCronMonitor = false; } }); - }); + }).AddQuartzHostedService(); var app = builder.Build(); From 489d073536290d0c451b5515737b7ec3a2776472 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 24 Aug 2026 16:57:38 +0200 Subject: [PATCH 09/53] refactor: simplify Quartz configuration and update project to use Web SDK --- .../Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj | 2 +- src/Sentry.Quartz/GlobalConfigurationExtensions.cs | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj index 1ca2eca694..8e2a054755 100644 --- a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 704e0776ff..2ad4fc8f81 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -26,13 +26,7 @@ public static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IServi serviceCollection.Configure(configure); } - configuration.AddJobListener(sp => - { - var serviceScope = sp.CreateScope(); - var serviceProvider = serviceScope.ServiceProvider; - - return serviceProvider.GetRequiredService(); - }); + configuration.AddJobListener(sp => sp.GetRequiredService()); return configuration; } From 5c2c060a07529dd271d809116e0ba908356aa3bf Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 31 Aug 2026 11:57:46 +0200 Subject: [PATCH 10/53] refactor: replace SentryCronJobListener with SentryCronJobMiddleware and update Quartz integration --- .../GlobalConfigurationExtensions.cs | 10 ++-- src/Sentry.Quartz/Sentry.Quartz.csproj | 2 +- ...Listener.cs => SentryCronJobMiddleware.cs} | 53 +++++++++++-------- 3 files changed, 34 insertions(+), 31 deletions(-) rename src/Sentry.Quartz/{SentryCronJobListener.cs => SentryCronJobMiddleware.cs} (67%) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 2ad4fc8f81..477813563c 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -19,16 +19,12 @@ public static class GlobalConfigurationExtensions /// public static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IServiceCollection serviceCollection, Action? configure = null) { - serviceCollection.AddSingleton(); - if (configure is not null) { serviceCollection.Configure(configure); } - configuration.AddJobListener(sp => sp.GetRequiredService()); - - return configuration; + return configuration.AddJobMiddleware(); } /// @@ -39,9 +35,9 @@ public static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IServi /// /// /// - internal static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) + internal static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) { - configuration.AddJobListener(new SentryCronJobListener(options, hub, logger)); + configuration.AddJobMiddleware(new SentryCronJobMiddleware(options, hub, logger)); return configuration; } } diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index bedb90736b..260a77fc67 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Sentry.Quartz/SentryCronJobListener.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs similarity index 67% rename from src/Sentry.Quartz/SentryCronJobListener.cs rename to src/Sentry.Quartz/SentryCronJobMiddleware.cs index 90bb6d9609..7322da9679 100644 --- a/src/Sentry.Quartz/SentryCronJobListener.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -1,60 +1,63 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Quartz; namespace Sentry.Quartz; -internal sealed partial class SentryCronJobListener : IJobListener +internal sealed partial class SentryCronJobMiddleware : IJobExecutionMiddleware { private readonly SentryCronJobOptions _options; - private readonly ConcurrentDictionary _fireInstanceId = new(); private readonly ConcurrentDictionary _sentryCronInformation = []; private readonly IHub _hub; - private readonly ILogger _logger; + private readonly ILogger _logger; - public SentryCronJobListener(IOptions options, IHub hub, ILogger logger) + public SentryCronJobMiddleware(IOptions options, IHub hub, ILogger logger) { _hub = hub; _logger = logger; _options = options.Value; } - public string Name { get; } = "Sentry Job Listener"; - - public ValueTask JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) + public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) { var jobType = context.JobInstance.GetType(); var info = _sentryCronInformation.GetOrAdd(jobType, _ => new SentryCronInformation(context.JobInstance)); + var sentryId = StartQuartz(context, info); + try + { + await next(context, cancellationToken).ConfigureAwait(false); + CompleteCheckIn(info, sentryId, CheckInStatus.Ok); + } + catch + { + CompleteCheckIn(info, sentryId, CheckInStatus.Error); + throw; + } + } + + private SentryId? StartQuartz(IJobExecutionContext context, SentryCronInformation info) + { if (info.ShouldWriteStatusToSentry) { - var sentryId = _hub.CaptureCheckIn(info.MonitorSlug, CheckInStatus.InProgress, configureMonitorOptions: options => + return _hub.CaptureCheckIn(info.MonitorSlug, CheckInStatus.InProgress, configureMonitorOptions: options => { if (_options.EnableUpsertCronMonitor && context.Trigger is ICronTrigger cronTrigger) { UpsertCronMonitor(cronTrigger, info, options); } }); - - _fireInstanceId.TryAdd(context.FireInstanceId, sentryId); } - return ValueTask.CompletedTask; + return null; } - public ValueTask JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default) + private void CompleteCheckIn(SentryCronInformation info, SentryId? sentryId, CheckInStatus status) { - var jobType = context.JobInstance.GetType(); - - if (_sentryCronInformation.TryGetValue(jobType, out var info) && info.ShouldWriteStatusToSentry) + if (sentryId is not null) { - var status = jobException is not null ? CheckInStatus.Error : CheckInStatus.Ok; - _fireInstanceId.TryRemove(context.FireInstanceId, out var checkInId); - - _hub.CaptureCheckIn(info.MonitorSlug, status, checkInId); + _hub.CaptureCheckIn(info.MonitorSlug, status, sentryId); } - - return ValueTask.CompletedTask; } private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation information, SentryMonitorOptions options) @@ -75,7 +78,11 @@ private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation i string monitorSlug = information.MonitorSlug; string cron = cronTrigger.CronExpressionString.Replace("?", "*", StringComparison.OrdinalIgnoreCase); - var cronSpan = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries); + var cronSpan = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries) +#if NET9_0_OR_GREATER + .AsSpan() +#endif + ; if (cronSpan.Length is 6 or 7) { From e1def1174508942eb35a0a26810981a8034b20fc Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 31 Aug 2026 13:52:55 +0200 Subject: [PATCH 11/53] fix: handling issue where current region might throw a CultureNotFoundException --- src/Sentry.Quartz/RegionInfoExtensions.cs | 19 +++++++++++++++++++ src/Sentry.Quartz/SentryCronJobMiddleware.cs | 8 ++------ 2 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 src/Sentry.Quartz/RegionInfoExtensions.cs diff --git a/src/Sentry.Quartz/RegionInfoExtensions.cs b/src/Sentry.Quartz/RegionInfoExtensions.cs new file mode 100644 index 0000000000..b5be84d8d0 --- /dev/null +++ b/src/Sentry.Quartz/RegionInfoExtensions.cs @@ -0,0 +1,19 @@ +namespace Sentry.Quartz; + +internal static class RegionInfoExtensions +{ + extension(RegionInfo) + { + public static RegionInfo? GetCurrentRegionOrNull() + { + try + { + return RegionInfo.CurrentRegion; + } + catch (CultureNotFoundException) + { + return null; + } + } + } +} \ No newline at end of file diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs index 7322da9679..cea64c0829 100644 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -67,7 +67,7 @@ private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation i return; } - if (TimeZoneInfo.TryConvertWindowsIdToIanaId(cronTrigger.TimeZone.Id, RegionInfo.CurrentRegion.TwoLetterISORegionName, out string? iana)) + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(cronTrigger.TimeZone.Id, RegionInfo.GetCurrentRegionOrNull()?.TwoLetterISORegionName, out string? iana)) { options.TimeZone = iana; } @@ -78,11 +78,7 @@ private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation i string monitorSlug = information.MonitorSlug; string cron = cronTrigger.CronExpressionString.Replace("?", "*", StringComparison.OrdinalIgnoreCase); - var cronSpan = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries) -#if NET9_0_OR_GREATER - .AsSpan() -#endif - ; + var cronSpan = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries).AsSpan(); if (cronSpan.Length is 6 or 7) { From e8669e826af1999a5712e27a30df258479a519ba Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 31 Aug 2026 13:53:39 +0200 Subject: [PATCH 12/53] fix: sample project has different nuget package version --- samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj index 8e2a054755..20c2fa35c0 100644 --- a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -7,7 +7,7 @@ - + From b30f97a7a1646e74febb1c8b6a428513a751e0c2 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 08:24:44 +0200 Subject: [PATCH 13/53] fix: skip middleware execution for non-cron triggers --- src/Sentry.Quartz/SentryCronJobMiddleware.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs index cea64c0829..29e440948d 100644 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -20,6 +20,11 @@ public SentryCronJobMiddleware(IOptions options, IHub hub, public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) { + if (context.Trigger is not ICronTrigger) + { + return; + } + var jobType = context.JobInstance.GetType(); var info = _sentryCronInformation.GetOrAdd(jobType, _ => new SentryCronInformation(context.JobInstance)); From 9c7f36e9a3f51c777e5c8115a9d0820d4e6f46c5 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 08:25:03 +0200 Subject: [PATCH 14/53] refactor: rename and update Quartz extension method to AddSentryCronJob --- src/Sentry.Quartz/GlobalConfigurationExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 477813563c..04897aceed 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -11,13 +11,13 @@ namespace Sentry.Quartz; public static class GlobalConfigurationExtensions { /// - /// Uses Sentry + /// Adds middleware to track CRON jobs to Sentry /// /// /// /// /// - public static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IServiceCollection serviceCollection, Action? configure = null) + public static IQuartzBuilder AddSentryCronJob(this IQuartzBuilder configuration, IServiceCollection serviceCollection, Action? configure = null) { if (configure is not null) { @@ -35,7 +35,7 @@ public static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IServi /// /// /// - internal static IQuartzBuilder UseSentry(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) + internal static IQuartzBuilder AddSentryCronJob(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) { configuration.AddJobMiddleware(new SentryCronJobMiddleware(options, hub, logger)); return configuration; From c882dc021d2f9e743ac9691dfc79ca9a0492288d Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 08:26:17 +0200 Subject: [PATCH 15/53] feat: add SentryMetricsMiddleware to track Quartz job execution duration metrics --- .../GlobalConfigurationExtensions.cs | 10 ++++++++ src/Sentry.Quartz/SentryMetricsMiddleware.cs | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/Sentry.Quartz/SentryMetricsMiddleware.cs diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 04897aceed..709b2f6512 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -27,6 +27,16 @@ public static IQuartzBuilder AddSentryCronJob(this IQuartzBuilder configuration, return configuration.AddJobMiddleware(); } + /// + /// Adds middleware to track job execution duration metrics + /// + /// + /// + public static IQuartzBuilder AddSentryMetrics(this IQuartzBuilder configuration) + { + return configuration.AddJobMiddleware(); + } + /// /// For testing /// diff --git a/src/Sentry.Quartz/SentryMetricsMiddleware.cs b/src/Sentry.Quartz/SentryMetricsMiddleware.cs new file mode 100644 index 0000000000..db4a50be51 --- /dev/null +++ b/src/Sentry.Quartz/SentryMetricsMiddleware.cs @@ -0,0 +1,24 @@ +using Quartz; + +namespace Sentry.Quartz; + +internal sealed class SentryMetricsMiddleware(IHub sentryHub) : IJobExecutionMiddleware +{ + private readonly IHub _sentryHub = sentryHub; + + public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + { + using var _ = _sentryHub.PushScope(); + + var stopWatch = Stopwatch.StartNew(); + await next(context, cancellationToken).ConfigureAwait(false); + stopWatch.Stop(); + + var attributes = new Dictionary + { + { "payload.job.name", context.JobInstance.GetType().Name }, + }; + + _sentryHub.Metrics.EmitDistribution("quartz.job.duration", stopWatch.ElapsedMilliseconds, MeasurementUnit.Duration.Millisecond, attributes); + } +} From 65fc9ab9978e14ebb02aaca54a99ee846745c02f Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 08:32:57 +0200 Subject: [PATCH 16/53] refactor: simplify AddSentryCronJob method by removing unused service collection parameter and streamlining option configuration --- src/Sentry.Quartz/GlobalConfigurationExtensions.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 709b2f6512..742b420120 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -14,16 +14,11 @@ public static class GlobalConfigurationExtensions /// Adds middleware to track CRON jobs to Sentry /// /// - /// - /// + /// Configures the options /// - public static IQuartzBuilder AddSentryCronJob(this IQuartzBuilder configuration, IServiceCollection serviceCollection, Action? configure = null) + public static IQuartzBuilder AddSentryCronJob(this IQuartzBuilder configuration, Action? configure = null) { - if (configure is not null) - { - serviceCollection.Configure(configure); - } - + configuration.ConfigureOptions(configure); return configuration.AddJobMiddleware(); } From d1082e7ac083520ede3ad78abd4b3e5c3e7a74d2 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 09:10:47 +0200 Subject: [PATCH 17/53] feat: add SentryScopeMiddleware and extend Quartz integration with AddSentryScope method, update SentryMetricsMiddleware to support configurable options --- .../GlobalConfigurationExtensions.cs | 16 ++++- src/Sentry.Quartz/SentryCronJobMiddleware.cs | 2 +- src/Sentry.Quartz/SentryCronJobOptions.cs | 2 +- src/Sentry.Quartz/SentryMetricsMiddleware.cs | 60 +++++++++++++++---- src/Sentry.Quartz/SentryScopeMiddleware.cs | 14 +++++ 5 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 src/Sentry.Quartz/SentryScopeMiddleware.cs diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 742b420120..e42f179975 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -16,7 +16,7 @@ public static class GlobalConfigurationExtensions /// /// Configures the options /// - public static IQuartzBuilder AddSentryCronJob(this IQuartzBuilder configuration, Action? configure = null) + public static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, Action? configure = null) { configuration.ConfigureOptions(configure); return configuration.AddJobMiddleware(); @@ -32,6 +32,16 @@ public static IQuartzBuilder AddSentryMetrics(this IQuartzBuilder configuration) return configuration.AddJobMiddleware(); } + /// + /// Adds middleware that pushes a scope to sentry before job execution + /// + /// + /// + public static IQuartzBuilder AddSentryScope(this IQuartzBuilder configuration) + { + return configuration.AddJobMiddleware(); + } + /// /// For testing /// @@ -40,9 +50,9 @@ public static IQuartzBuilder AddSentryMetrics(this IQuartzBuilder configuration) /// /// /// - internal static IQuartzBuilder AddSentryCronJob(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) + internal static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) { - configuration.AddJobMiddleware(new SentryCronJobMiddleware(options, hub, logger)); + configuration.AddJobMiddleware(new SentryCronJobMiddleware(hub, options, logger)); return configuration; } } diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs index 29e440948d..c489e2e624 100644 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -11,7 +11,7 @@ internal sealed partial class SentryCronJobMiddleware : IJobExecutionMiddleware private readonly IHub _hub; private readonly ILogger _logger; - public SentryCronJobMiddleware(IOptions options, IHub hub, ILogger logger) + public SentryCronJobMiddleware(IHub hub, IOptions options, ILogger logger) { _hub = hub; _logger = logger; diff --git a/src/Sentry.Quartz/SentryCronJobOptions.cs b/src/Sentry.Quartz/SentryCronJobOptions.cs index e4283c42a2..d05a65c26c 100644 --- a/src/Sentry.Quartz/SentryCronJobOptions.cs +++ b/src/Sentry.Quartz/SentryCronJobOptions.cs @@ -10,7 +10,7 @@ public class SentryCronJobOptions /// /// /// If set to true, enables the creation or update of a CronMonitor with relevant details, such as Cron expression and time zone, - /// during a job execution that is configured with a CronTrigger. This ensures that the monitor reflects accurate scheduling metadata in Sentry. + /// during a job execution configured with a CronTrigger. This ensures that the monitor reflects accurate scheduling metadata in Sentry. /// public bool EnableUpsertCronMonitor { get; set; } = true; } diff --git a/src/Sentry.Quartz/SentryMetricsMiddleware.cs b/src/Sentry.Quartz/SentryMetricsMiddleware.cs index db4a50be51..758f2450bb 100644 --- a/src/Sentry.Quartz/SentryMetricsMiddleware.cs +++ b/src/Sentry.Quartz/SentryMetricsMiddleware.cs @@ -2,23 +2,61 @@ namespace Sentry.Quartz; -internal sealed class SentryMetricsMiddleware(IHub sentryHub) : IJobExecutionMiddleware +internal sealed class SentryMetricsMiddleware : IJobExecutionMiddleware { - private readonly IHub _sentryHub = sentryHub; + private readonly IHub _sentryHub; + private readonly SentryMetricsOptions _options; - public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + public SentryMetricsMiddleware(IHub sentryHub, SentryMetricsOptions options) { - using var _ = _sentryHub.PushScope(); + _sentryHub = sentryHub; + _options = options; + } - var stopWatch = Stopwatch.StartNew(); - await next(context, cancellationToken).ConfigureAwait(false); - stopWatch.Stop(); + public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + { + using (GetLogger(context)) + { + await next(context, cancellationToken).ConfigureAwait(false); + } + } - var attributes = new Dictionary + private SentryMetricsLogger? GetLogger(IJobExecutionContext context) + { + if (!context.JobDetail.JobDataMap.TryGetBoolean("LogMetrics", out bool logMetrics) || logMetrics) { - { "payload.job.name", context.JobInstance.GetType().Name }, - }; + var metricsName = _options.ResolveMetricsName(context.JobDetail); + var additionalAttributes = _options.AdditionalAttributes?.Invoke(context.JobDetail); + return new SentryMetricsLogger(metricsName, additionalAttributes, _sentryHub); + } + + return null; + } +} - _sentryHub.Metrics.EmitDistribution("quartz.job.duration", stopWatch.ElapsedMilliseconds, MeasurementUnit.Duration.Millisecond, attributes); +public class SentryMetricsOptions +{ + public Func ResolveMetricsName { get; set; } = jobDetail => $"quartz.job.duration.{jobDetail.Key.Group}.{jobDetail.Key.Name}"; + + public Func>? AdditionalAttributes { get; set; } +} + +internal sealed class SentryMetricsLogger : IDisposable +{ + private readonly string _metricName; + private readonly IHub _sentryHub; + private readonly Stopwatch _stopWatch = Stopwatch.StartNew(); + private readonly IDictionary? _attributes; + + public SentryMetricsLogger(string metricName, IDictionary? attributes, IHub sentryHub) + { + _attributes = attributes; + _metricName = metricName; + _sentryHub = sentryHub; + } + + public void Dispose() + { + _sentryHub.Metrics.EmitDistribution(_metricName, _stopWatch.ElapsedMilliseconds, MeasurementUnit.Duration.Millisecond, _attributes); } } diff --git a/src/Sentry.Quartz/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs new file mode 100644 index 0000000000..e24224b168 --- /dev/null +++ b/src/Sentry.Quartz/SentryScopeMiddleware.cs @@ -0,0 +1,14 @@ +using Quartz; + +namespace Sentry.Quartz; + +internal sealed class SentryScopeMiddleware(IHub sentryHub) : IJobExecutionMiddleware +{ + private readonly IHub _sentryHub = sentryHub; + + public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + { + using var _ = _sentryHub.PushScope(); + await next(context, cancellationToken).ConfigureAwait(false); + } +} From e32a27e87e25dc6ddc3ec16688c16177ad3ec53f Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 09:17:48 +0200 Subject: [PATCH 18/53] refactor: move non-cron trigger check to StartQuartz method --- src/Sentry.Quartz/SentryCronJobMiddleware.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs index c489e2e624..07f897a10b 100644 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -20,11 +20,6 @@ public SentryCronJobMiddleware(IHub hub, IOptions options, public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) { - if (context.Trigger is not ICronTrigger) - { - return; - } - var jobType = context.JobInstance.GetType(); var info = _sentryCronInformation.GetOrAdd(jobType, _ => new SentryCronInformation(context.JobInstance)); @@ -43,6 +38,11 @@ public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate private SentryId? StartQuartz(IJobExecutionContext context, SentryCronInformation info) { + if (context.Trigger is not ICronTrigger) + { + return null; + } + if (info.ShouldWriteStatusToSentry) { return _hub.CaptureCheckIn(info.MonitorSlug, CheckInStatus.InProgress, configureMonitorOptions: options => From 25d22a7da3e390fed0dacd0674e50976d8ed9a44 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 09:21:15 +0200 Subject: [PATCH 19/53] feat: add missing options parameter --- src/Sentry.Quartz/GlobalConfigurationExtensions.cs | 10 +++++----- src/Sentry.Quartz/SentryMetricsMiddleware.cs | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index e42f179975..34ab03bb1f 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -18,18 +18,18 @@ public static class GlobalConfigurationExtensions /// public static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, Action? configure = null) { - configuration.ConfigureOptions(configure); - return configuration.AddJobMiddleware(); + return configuration.AddJobMiddleware().ConfigureOptions(configure); } /// /// Adds middleware to track job execution duration metrics /// /// + /// Configures the options /// - public static IQuartzBuilder AddSentryMetrics(this IQuartzBuilder configuration) + public static IQuartzBuilder AddSentryMetrics(this IQuartzBuilder configuration, Action? configure = null) { - return configuration.AddJobMiddleware(); + return configuration.AddJobMiddleware().ConfigureOptions(configure); } /// @@ -46,8 +46,8 @@ public static IQuartzBuilder AddSentryScope(this IQuartzBuilder configuration) /// For testing /// /// - /// /// + /// /// /// internal static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) diff --git a/src/Sentry.Quartz/SentryMetricsMiddleware.cs b/src/Sentry.Quartz/SentryMetricsMiddleware.cs index 758f2450bb..00a5855b98 100644 --- a/src/Sentry.Quartz/SentryMetricsMiddleware.cs +++ b/src/Sentry.Quartz/SentryMetricsMiddleware.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Options; using Quartz; namespace Sentry.Quartz; @@ -5,9 +6,9 @@ namespace Sentry.Quartz; internal sealed class SentryMetricsMiddleware : IJobExecutionMiddleware { private readonly IHub _sentryHub; - private readonly SentryMetricsOptions _options; + private readonly IOptions _options; - public SentryMetricsMiddleware(IHub sentryHub, SentryMetricsOptions options) + public SentryMetricsMiddleware(IHub sentryHub, IOptions options) { _sentryHub = sentryHub; _options = options; @@ -25,8 +26,8 @@ public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate { if (!context.JobDetail.JobDataMap.TryGetBoolean("LogMetrics", out bool logMetrics) || logMetrics) { - var metricsName = _options.ResolveMetricsName(context.JobDetail); - var additionalAttributes = _options.AdditionalAttributes?.Invoke(context.JobDetail); + var metricsName = _options.Value.ResolveMetricsName(context.JobDetail); + var additionalAttributes = _options.Value.AdditionalAttributes?.Invoke(context.JobDetail); return new SentryMetricsLogger(metricsName, additionalAttributes, _sentryHub); } From 2718a74b364dbbfb7897a1ab70116b05d73825d7 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 10:10:50 +0200 Subject: [PATCH 20/53] refactor: remove unused project reference to Sentry.AspNetCore in Sentry.Quartz --- src/Sentry.Quartz/Sentry.Quartz.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index 260a77fc67..9ccf1d2012 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -14,7 +14,6 @@ - From 1d13fd2642c6b10bc1a6efa18daeb92a267aa4e9 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 1 Sep 2026 12:59:04 +0200 Subject: [PATCH 21/53] fix: Quatz.Net and Sentry handling cron expression for days differently --- src/Sentry.Quartz/Sentry.Quartz.csproj | 2 +- src/Sentry.Quartz/SentryCronJobMiddleware.cs | 28 +++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index 9ccf1d2012..37254dcaab 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs index 07f897a10b..d84c89fc78 100644 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -82,26 +82,30 @@ private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation i } string monitorSlug = information.MonitorSlug; - string cron = cronTrigger.CronExpressionString.Replace("?", "*", StringComparison.OrdinalIgnoreCase); - var cronSpan = cron.Split(" ", StringSplitOptions.RemoveEmptyEntries).AsSpan(); + string cron = cronTrigger.CronExpressionString; + string[] fields = cron.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (cronSpan.Length is 6 or 7) + if (fields.Length is 6 or 7) { - if (!cronSpan[0].Equals("0", StringComparison.OrdinalIgnoreCase)) + if (fields[0] != "0" && !information.WarningShownForSecondsParameterIssue) { - if (!information.WarningShownForSecondsParameterIssue) - { - information.WarningShownForSecondsParameterIssue = true; - LogGranularityWarning(monitorSlug, cron); - } + information.WarningShownForSecondsParameterIssue = true; + LogGranularityWarning(monitorSlug, cron); } - cronSpan = cronSpan[1..6]; + + fields = fields[1..6]; } try { - string crontab = string.Join(" ", cronSpan); - options.Interval(crontab); + string crontab = string.Join(" ", fields); + var cronExpression = CronExpression.Parse(crontab, CronFormat.Unix); + + string[] normalized = cronExpression.CronExpressionString + .Replace("?", "*", StringComparison.OrdinalIgnoreCase) + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + + options.Interval(string.Join(" ", normalized[1..6])); } catch (ArgumentException ex) { From 7f0a48a2ea04d10cb9d7162c99542897e48b8a57 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 7 Sep 2026 10:01:34 +0200 Subject: [PATCH 22/53] feat: add XML documenation --- src/Sentry.Quartz/SentryMetricsMiddleware.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Sentry.Quartz/SentryMetricsMiddleware.cs b/src/Sentry.Quartz/SentryMetricsMiddleware.cs index 00a5855b98..c178c8e1b8 100644 --- a/src/Sentry.Quartz/SentryMetricsMiddleware.cs +++ b/src/Sentry.Quartz/SentryMetricsMiddleware.cs @@ -35,10 +35,27 @@ public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate } } +/// +/// Options used by to control how Quartz job execution metrics are +/// reported to Sentry. +/// public class SentryMetricsOptions { + /// + /// A function used to resolve the name of the metric emitted for a job's execution duration. + /// + /// + /// Defaults to quartz.job.duration.{group}.{name}, based on the job's . + /// public Func ResolveMetricsName { get; set; } = jobDetail => $"quartz.job.duration.{jobDetail.Key.Group}.{jobDetail.Key.Name}"; + /// + /// An optional function used to compute additional attributes to attach to the emitted duration metric, + /// based on the job being executed. + /// + /// Returns by default, meaning no additional + /// attributes are attached. + /// public Func>? AdditionalAttributes { get; set; } } From 6a91e7022411635fa8bbe271d18be18f5da70f8e Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 7 Sep 2026 10:33:57 +0200 Subject: [PATCH 23/53] feat: update Quartz to stable v4.0.0 --- samples/Sentry.Samples.Quartz/Program.cs | 30 +++++++++---------- .../Sentry.Samples.Quartz.csproj | 3 +- src/Sentry.Quartz/Sentry.Quartz.csproj | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs index 8716ad9bf4..053c881fc1 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -16,25 +16,25 @@ public static void Main(string[] args) builder.Services.AddQuartz(quartz => { - quartz.UseSentry(builder.Services, options => + quartz.AddSentryScope(); + quartz.AddSentryMetrics(); + quartz.AddSentryCronJobs(options => { - var jobKey1 = new JobKey(nameof(FirstJob)); - quartz.AddJob(opts => opts.WithIdentity(jobKey1)); - quartz.AddTrigger(opts => opts.ForJob(jobKey1).WithIdentity($"{nameof(FirstJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + options.EnableUpsertCronMonitor = builder.Environment.IsProduction(); + }); - var jobKey2 = new JobKey(nameof(SecondJob)); - quartz.AddJob(opts => opts.WithIdentity(jobKey2)); - quartz.AddTrigger(opts => opts.ForJob(jobKey2).WithIdentity($"{nameof(SecondJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + var jobKey1 = new JobKey(nameof(FirstJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey1)); + quartz.AddTrigger(opts => opts.ForJob(jobKey1).WithIdentity($"{nameof(FirstJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); - var jobKey3 = new JobKey(nameof(ThirdJob)); - quartz.AddJob(opts => opts.WithIdentity(jobKey3)); - quartz.AddTrigger(opts => opts.ForJob(jobKey3).WithIdentity($"{nameof(ThirdJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + var jobKey2 = new JobKey(nameof(SecondJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey2)); + quartz.AddTrigger(opts => opts.ForJob(jobKey2).WithIdentity($"{nameof(SecondJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); + + var jobKey3 = new JobKey(nameof(ThirdJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey3)); + quartz.AddTrigger(opts => opts.ForJob(jobKey3).WithIdentity($"{nameof(ThirdJob)}-trigger").WithCronSchedule("*/10 * * ? * *")); - if (!builder.Environment.IsProduction()) - { - options.EnableUpsertCronMonitor = false; - } - }); }).AddQuartzHostedService(); var app = builder.Build(); diff --git a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj index 20c2fa35c0..415cf496c1 100644 --- a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -7,7 +7,7 @@ - + @@ -18,6 +18,7 @@ + diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index 37254dcaab..75f85e6990 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -13,7 +13,7 @@ - + From 202c4759897140bf738b7c761a0990e2a0b217ea Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 7 Sep 2026 11:05:47 +0200 Subject: [PATCH 24/53] feat: add Quartz Dashboard and HTTP API integration, update launch URL --- samples/Sentry.Samples.Quartz/Program.cs | 13 +++++++++++++ .../Properties/launchSettings.json | 6 +++--- .../Sentry.Samples.Quartz.csproj | 1 + samples/Sentry.Samples.Quartz/appsettings.json | 5 ----- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs index 053c881fc1..b6cc5cdc86 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -14,6 +14,9 @@ public static void Main(string[] args) builder.WebHost.UseSentry(); + builder.Services.AddQuartzHttpApi(); + builder.Services.AddQuartzDashboard(); + builder.Services.AddQuartz(quartz => { quartz.AddSentryScope(); @@ -39,6 +42,16 @@ public static void Main(string[] args) 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(); } } diff --git a/samples/Sentry.Samples.Quartz/Properties/launchSettings.json b/samples/Sentry.Samples.Quartz/Properties/launchSettings.json index 1195f60877..e667e2ee58 100644 --- a/samples/Sentry.Samples.Quartz/Properties/launchSettings.json +++ b/samples/Sentry.Samples.Quartz/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "hangfire", + "launchUrl": "quartz", "applicationUrl": "http://localhost:5138", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -23,7 +23,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "hangfire", + "launchUrl": "quartz", "applicationUrl": "http://localhost:5138", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -32,7 +32,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "hangfire", + "launchUrl": "quartz", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj index 415cf496c1..973a202d29 100644 --- a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -8,6 +8,7 @@ + diff --git a/samples/Sentry.Samples.Quartz/appsettings.json b/samples/Sentry.Samples.Quartz/appsettings.json index d5e353d5b7..462bf7438f 100644 --- a/samples/Sentry.Samples.Quartz/appsettings.json +++ b/samples/Sentry.Samples.Quartz/appsettings.json @@ -30,10 +30,5 @@ "LogLevel": { "Default": "Trace" } - }, - "Kestrel": { - "EndpointDefaults": { - "Protocols": "Http2" - } } } From 827800917e1a17312f1fa686546c96ab132fe488 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 7 Sep 2026 11:38:45 +0200 Subject: [PATCH 25/53] feat: add customizable SentryMonitorOptions via callback --- samples/Sentry.Samples.Quartz/Program.cs | 15 ++++++++++++++- src/Sentry.Quartz/SentryCronJobMiddleware.cs | 1 + src/Sentry.Quartz/SentryCronJobOptions.cs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs index b6cc5cdc86..491b4b9da7 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -24,6 +24,14 @@ public static void Main(string[] args) quartz.AddSentryCronJobs(options => { options.EnableUpsertCronMonitor = builder.Environment.IsProduction(); + options.ConfigureSentryMonitorOptions = (jobDetail, options) => + { + if (jobDetail.Key.Name == nameof(SecondJob)) + { + options.FailureIssueThreshold = 10; + options.RecoveryThreshold = 10; + } + }; }); var jobKey1 = new JobKey(nameof(FirstJob)); @@ -70,12 +78,17 @@ public async ValueTask Execute(IJobExecutionContext context, CancellationToken c [SentryCronMonitorSlug("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}"); - throw new Exception(); + if (_random.Next(100) < 10) + { + throw new Exception(); + } } } diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs index d84c89fc78..f60aaea69a 100644 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -50,6 +50,7 @@ public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate if (_options.EnableUpsertCronMonitor && context.Trigger is ICronTrigger cronTrigger) { UpsertCronMonitor(cronTrigger, info, options); + _options.ConfigureSentryMonitorOptions?.Invoke(context.JobDetail, options); } }); } diff --git a/src/Sentry.Quartz/SentryCronJobOptions.cs b/src/Sentry.Quartz/SentryCronJobOptions.cs index d05a65c26c..c0493472e7 100644 --- a/src/Sentry.Quartz/SentryCronJobOptions.cs +++ b/src/Sentry.Quartz/SentryCronJobOptions.cs @@ -1,3 +1,5 @@ +using Quartz; + namespace Sentry.Quartz; /// @@ -13,4 +15,18 @@ public class SentryCronJobOptions /// during a job execution configured with a CronTrigger. This ensures that the monitor reflects accurate scheduling metadata in Sentry. /// public bool EnableUpsertCronMonitor { get; set; } = true; + + /// + /// Gets or sets a callback that is invoked to configure the used when + /// upserting a CronMonitor for a job execution. + /// + /// + /// This callback is only invoked when is true, and is invoked + /// after the automatic configuration derived from the job's (such as the schedule + /// and time zone) is applied, so any values set here take precedence over those automatically derived values. + /// Use this to customize properties of the monitor, such as or + /// , or to override the automatically derived schedule + /// and time zone, based on the being executed. + /// + public Action? ConfigureSentryMonitorOptions { get; set; } } From 83e92d2099c1ca6b493a01a3986f197921bf93b2 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 8 Sep 2026 12:40:54 +1200 Subject: [PATCH 26/53] 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 --- src/Sentry.Quartz/GlobalConfigurationExtensions.cs | 2 +- src/Sentry.Quartz/RegionInfoExtensions.cs | 2 +- src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs index 34ab03bb1f..241aaab163 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -1,4 +1,4 @@ - using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Quartz; diff --git a/src/Sentry.Quartz/RegionInfoExtensions.cs b/src/Sentry.Quartz/RegionInfoExtensions.cs index b5be84d8d0..1d87a19086 100644 --- a/src/Sentry.Quartz/RegionInfoExtensions.cs +++ b/src/Sentry.Quartz/RegionInfoExtensions.cs @@ -16,4 +16,4 @@ internal static class RegionInfoExtensions } } } -} \ No newline at end of file +} diff --git a/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs b/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs index 1b6581f541..8f930909d4 100644 --- a/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs +++ b/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs @@ -1,4 +1,4 @@ -namespace Sentry.Quartz; +namespace Sentry.Quartz; /// /// Sentry Monitor Slug Attribute From 045f8d9f76802a52f45cacb102c8032844af8e87 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 8 Sep 2026 12:40:55 +1200 Subject: [PATCH 27/53] 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 --- .craft.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.craft.yml b/.craft.yml index 269ddb1498..f945cabfc4 100644 --- a/.craft.yml +++ b/.craft.yml @@ -87,3 +87,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/ From be3fe217e6d753835fc21935c81a6f5092910718 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Tue, 8 Sep 2026 08:23:07 +0200 Subject: [PATCH 28/53] test: add Sentry.Quartz.Tests Co-Authored-By: Claude Sonnet 5 --- .generated.NoMobile.slnx | 1 + Sentry-CI-Build-Linux-NoMobile.slnf | 1 + Sentry-CI-Build-Linux.slnf | 1 + Sentry-CI-Build-Windows-arm64.slnf | 1 + Sentry-CI-Build-Windows.slnf | 1 + Sentry-CI-Build-macOS.slnf | 1 + Sentry.slnx | 1 + SentryNoMobile.slnf | 1 + SentryNoSamples.slnf | 1 + ...ensions.cs => IQuartzBuilderExtensions.cs} | 4 +- src/Sentry.Quartz/Sentry.Quartz.csproj | 2 +- src/Sentry/Sentry.csproj | 1 + .../MiddlewareTestHelpers.cs | 56 ++++ .../QuartzBuilderExtensionsTests.cs | 111 ++++++++ test/Sentry.Quartz.Tests/RecordingLogger.cs | 21 ++ .../RegionInfoExtensionsTests.cs | 13 + .../Sentry.Quartz.Tests.csproj | 18 ++ .../SentryCronInformationTests.cs | 39 +++ .../SentryCronJobMiddlewareTests.cs | 257 ++++++++++++++++++ .../SentryMetricsMiddlewareTests.cs | 103 +++++++ .../SentryMonitorOptionsExtensions.cs | 24 ++ .../SentryScopeMiddlewareTests.cs | 49 ++++ 22 files changed, 704 insertions(+), 3 deletions(-) rename src/Sentry.Quartz/{GlobalConfigurationExtensions.cs => IQuartzBuilderExtensions.cs} (94%) create mode 100644 test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs create mode 100644 test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs create mode 100644 test/Sentry.Quartz.Tests/RecordingLogger.cs create mode 100644 test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs create mode 100644 test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj create mode 100644 test/Sentry.Quartz.Tests/SentryCronInformationTests.cs create mode 100644 test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs create mode 100644 test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs create mode 100644 test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs create mode 100644 test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index 7e82c66add..fd72089ae0 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -204,6 +204,7 @@ + diff --git a/Sentry-CI-Build-Linux-NoMobile.slnf b/Sentry-CI-Build-Linux-NoMobile.slnf index 07a1abe2d2..96a8125b7d 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -72,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 8682928355..b7a4975621 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -81,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 cc2e2188a7..f9b1f924e6 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -80,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 1fed0a6e49..fccd8ebf8e 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -83,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 738ded856e..6827a106ce 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -89,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.slnx b/Sentry.slnx index 7e82c66add..fd72089ae0 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -204,6 +204,7 @@ + diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index 588e79e7da..76a74169fe 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -77,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 eb6b93f9ea..72d61e8bcf 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -51,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/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs similarity index 94% rename from src/Sentry.Quartz/GlobalConfigurationExtensions.cs rename to src/Sentry.Quartz/IQuartzBuilderExtensions.cs index 241aaab163..6717f70f39 100644 --- a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs +++ b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs @@ -6,9 +6,9 @@ namespace Sentry.Quartz; /// -/// Quartz.NET Extensions for . +/// Quartz.NET Extensions for . /// -public static class GlobalConfigurationExtensions +public static class IQuartzBuilderExtensions { /// /// Adds middleware to track CRON jobs to Sentry diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj index 75f85e6990..fb17e7bc82 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 6c6ffe39ee..e7272f2987 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -174,6 +174,7 @@ + diff --git a/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs new file mode 100644 index 0000000000..cfa15fd7f5 --- /dev/null +++ b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs @@ -0,0 +1,56 @@ +using Quartz; + +namespace Sentry.Quartz.Tests; + +/// +/// Shared helpers for building the Quartz middleware pipeline types (, +/// , ) that the Sentry.Quartz middlewares operate on, +/// without needing a real Quartz scheduler. +/// +internal static class MiddlewareTestHelpers +{ + public static IJobExecutionContext CreateContext(IJob job, ITrigger? trigger = null, IJobDetail? jobDetail = null) + { + trigger ??= Substitute.For(); + jobDetail ??= Substitute.For(); + jobDetail.JobDataMap.Returns(new JobDataMap()); + + var context = Substitute.For(); + context.JobInstance.Returns(job); + context.Trigger.Returns(trigger); + context.JobDetail.Returns(jobDetail); + return context; + } + + public static ICronTrigger CreateCronTrigger(string cronExpressionString = "0 0 12 * * ?", TimeZoneInfo? timeZone = null) + { + var trigger = Substitute.For(); + trigger.CronExpressionString.Returns(cronExpressionString); + trigger.TimeZone.Returns(timeZone ?? TimeZoneInfo.Utc); + return trigger; + } + + /// + /// A that either completes successfully, or throws/faults with the given + /// exception, mimicking the next delegate in the Quartz job execution middleware pipeline. + /// + public static JobExecutionDelegate Next(Exception? throwException = null) => + (_, _) => throwException is null ? default : ValueTask.FromException(throwException); +} + +internal sealed class PlainJob : IJob +{ + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) => default; +} + +[SentryCronMonitorSlug] +internal sealed class MonitoredJob : IJob +{ + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) => default; +} + +[SentryCronMonitorSlug("custom-slug")] +internal sealed class CustomSlugJob : IJob +{ + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) => default; +} diff --git a/test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs new file mode 100644 index 0000000000..9f9f7ad5d3 --- /dev/null +++ b/test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Quartz; + +namespace Sentry.Quartz.Tests; + +/// +/// End-to-end tests that run a real, in-memory Quartz scheduler wired up through , +/// to verify the middlewares are registered and invoked correctly - as opposed to the other test classes in this +/// project, which exercise each middleware directly. +/// +public class QuartzBuilderExtensionsTests +{ + [Fact] + public async Task AddSentryCronJobs_JobWithMonitorSlug_CapturesCheckInsAroundExecution() + { + var hub = Substitute.For(); + var startId = SentryId.Create(); + hub.CaptureCheckIn(nameof(TrackingJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()) + .Returns(startId); + + using var jobRan = new SemaphoreSlim(0, 1); + TrackingJob.Signal = jobRan; + + var services = new ServiceCollection(); + services.AddQuartz(quartz => + { + quartz.UseInMemoryStore(); + quartz.AddSentryCronJobs(Options.Create(new SentryCronJobOptions()), hub, NullLogger.Instance); + + var jobKey = new JobKey(nameof(TrackingJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey)); + quartz.AddTrigger(opts => opts.ForJob(jobKey).WithCronSchedule("* * * * * ?")); + }); + + await using var provider = services.BuildServiceProvider(); + var scheduler = await provider.GetRequiredService().GetScheduler(); + await scheduler.Start(); + try + { + var jobExecuted = await jobRan.WaitAsync(TimeSpan.FromSeconds(15)); + jobExecuted.Should().BeTrue("the job should have run within the timeout"); + } + finally + { + await scheduler.Shutdown(waitForJobsToComplete: true); + } + + hub.Received().CaptureCheckIn(nameof(TrackingJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); + hub.Received().CaptureCheckIn(nameof(TrackingJob), CheckInStatus.Ok, startId); + } + + [Fact] + public async Task AddSentryCronJobs_JobWithoutMonitorSlug_NeverCapturesCheckIns() + { + var hub = Substitute.For(); + + using var jobRan = new SemaphoreSlim(0, 1); + UnmonitoredTrackingJob.Signal = jobRan; + + var services = new ServiceCollection(); + services.AddQuartz(quartz => + { + quartz.UseInMemoryStore(); + quartz.AddSentryCronJobs(Options.Create(new SentryCronJobOptions()), hub, NullLogger.Instance); + + var jobKey = new JobKey(nameof(UnmonitoredTrackingJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey)); + quartz.AddTrigger(opts => opts.ForJob(jobKey).WithCronSchedule("* * * * * ?")); + }); + + await using var provider = services.BuildServiceProvider(); + var scheduler = await provider.GetRequiredService().GetScheduler(); + await scheduler.Start(); + try + { + var jobExecuted = await jobRan.WaitAsync(TimeSpan.FromSeconds(15)); + jobExecuted.Should().BeTrue("the job should have run within the timeout"); + } + finally + { + await scheduler.Shutdown(waitForJobsToComplete: true); + } + + hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [SentryCronMonitorSlug] + private sealed class TrackingJob : IJob + { + public static SemaphoreSlim? Signal { get; set; } + + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) + { + Signal?.Release(); + return default; + } + } + + private sealed class UnmonitoredTrackingJob : 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/RecordingLogger.cs b/test/Sentry.Quartz.Tests/RecordingLogger.cs new file mode 100644 index 0000000000..0e1ba47959 --- /dev/null +++ b/test/Sentry.Quartz.Tests/RecordingLogger.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; + +namespace Sentry.Quartz.Tests; + +/// +/// A minimal that records every log entry so tests can assert on the +/// messages produced by the source-generated LoggerMessage methods in . +/// +internal sealed class RecordingLogger : ILogger +{ + public List<(LogLevel Level, EventId EventId, string Message, Exception? Exception)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Entries.Add((logLevel, eventId, formatter(state, exception), exception)); + } +} diff --git a/test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs b/test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs new file mode 100644 index 0000000000..6ed38d0c4f --- /dev/null +++ b/test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs @@ -0,0 +1,13 @@ +namespace Sentry.Quartz.Tests; + +public class RegionInfoExtensionsTests +{ + [Fact] + public void GetCurrentRegionOrNull_DoesNotThrow_AndMatchesCurrentRegion() + { + var result = RegionInfo.GetCurrentRegionOrNull(); + + result.Should().NotBeNull(); + result!.TwoLetterISORegionName.Should().Be(RegionInfo.CurrentRegion.TwoLetterISORegionName); + } +} 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..4e63276fb2 --- /dev/null +++ b/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj @@ -0,0 +1,18 @@ + + + + + net10.0 + enable + + + + + + + + + + + + diff --git a/test/Sentry.Quartz.Tests/SentryCronInformationTests.cs b/test/Sentry.Quartz.Tests/SentryCronInformationTests.cs new file mode 100644 index 0000000000..3245811087 --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryCronInformationTests.cs @@ -0,0 +1,39 @@ +namespace Sentry.Quartz.Tests; + +public class SentryCronInformationTests +{ + [Fact] + public void Ctor_JobWithoutAttribute_DoesNotWriteStatus_AndUsesTypeNameAsSlug() + { + var info = new SentryCronInformation(new PlainJob()); + + info.ShouldWriteStatusToSentry.Should().BeFalse(); + info.MonitorSlug.Should().Be(nameof(PlainJob)); + } + + [Fact] + public void Ctor_JobWithAttributeWithoutSlug_WritesStatus_AndUsesTypeNameAsSlug() + { + var info = new SentryCronInformation(new MonitoredJob()); + + info.ShouldWriteStatusToSentry.Should().BeTrue(); + info.MonitorSlug.Should().Be(nameof(MonitoredJob)); + } + + [Fact] + public void Ctor_JobWithAttributeAndSlug_WritesStatus_AndUsesProvidedSlug() + { + var info = new SentryCronInformation(new CustomSlugJob()); + + info.ShouldWriteStatusToSentry.Should().BeTrue(); + info.MonitorSlug.Should().Be("custom-slug"); + } + + [Fact] + public void WarningShownForSecondsParameterIssue_DefaultsToFalse() + { + var info = new SentryCronInformation(new MonitoredJob()); + + info.WarningShownForSecondsParameterIssue.Should().BeFalse(); + } +} diff --git a/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs new file mode 100644 index 0000000000..fac7ba104f --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Quartz; + +namespace Sentry.Quartz.Tests; + +public class SentryCronJobMiddlewareTests +{ + private readonly IHub _hub = Substitute.For(); + private readonly RecordingLogger _logger = new(); + private readonly SentryCronJobOptions _options = new(); + + private SentryCronJobMiddleware CreateSut() => + new(_hub, Options.Create(_options), _logger); + + [Fact] + public async Task Invoke_TriggerIsNotCronTrigger_DoesNotCaptureCheckIn() + { + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), Substitute.For()); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task Invoke_CronTrigger_JobWithoutAttribute_DoesNotCaptureCheckIn() + { + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new PlainJob(), MiddlewareTestHelpers.CreateCronTrigger()); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task Invoke_CronTrigger_JobWithAttribute_JobSucceeds_CapturesInProgressThenOkWithSameId() + { + var startId = SentryId.Create(); + _hub.CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()) + .Returns(startId); + + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); + _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.Ok, startId); + } + + [Fact] + public async Task Invoke_CronTrigger_JobWithAttribute_JobThrows_CapturesInProgressThenErrorAndRethrows() + { + var startId = SentryId.Create(); + _hub.CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()) + .Returns(startId); + + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); + var exception = new InvalidOperationException("job failed"); + + var act = () => sut.Invoke(context, MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); + + (await act.Should().ThrowAsync()).Which.Should().BeSameAs(exception); + _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); + _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.Error, startId); + } + + [Fact] + public async Task Invoke_CronTrigger_CustomMonitorSlug_UsesSlugFromAttribute() + { + _hub.CaptureCheckIn(Arg.Any(), Arg.Any(), configureMonitorOptions: Arg.Any>()) + .Returns(SentryId.Create()); + + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new CustomSlugJob(), MiddlewareTestHelpers.CreateCronTrigger()); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _hub.Received(1).CaptureCheckIn("custom-slug", CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); + } + + [Fact] + public async Task Invoke_EnableUpsertCronMonitorFalse_DoesNotConfigureScheduleOrTimeZone_AndSkipsUserCallback() + { + _options.EnableUpsertCronMonitor = false; + var callbackInvoked = false; + _options.ConfigureSentryMonitorOptions = (_, _) => callbackInvoked = true; + + var configureCallback = CaptureConfigureCallback(); + + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + var monitorOptions = new SentryMonitorOptions(); + configureCallback().Invoke(monitorOptions); + + callbackInvoked.Should().BeFalse(); + monitorOptions.TimeZone.Should().BeNull(); + // Interval was never set, so setting it now should succeed rather than throw "set twice". + monitorOptions.Invoking(o => o.Interval("* * * * *")).Should().NotThrow(); + } + + [Fact] + public async Task Invoke_EnableUpsertCronMonitorTrue_SetsScheduleAndUtcTimeZone() + { + var configureCallback = CaptureConfigureCallback(); + + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext( + new MonitoredJob(), + MiddlewareTestHelpers.CreateCronTrigger("0 30 9 * * ?", TimeZoneInfo.Utc)); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + var monitorOptions = new SentryMonitorOptions(); + configureCallback().Invoke(monitorOptions); + + monitorOptions.TimeZone.Should().Be("Etc/UTC"); + using var json = monitorOptions.ToJsonDocument(); + var schedule = json.RootElement.GetProperty("monitor_config").GetProperty("schedule"); + schedule.GetProperty("type").GetString().Should().Be("crontab"); + schedule.GetProperty("value").GetString().Should().Be("30 9 * * *"); + } + + [Fact] + public async Task Invoke_TimeZoneIsAlreadyIana_UsesItAsIs() + { + var configureCallback = CaptureConfigureCallback(); + var ianaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext( + new MonitoredJob(), + MiddlewareTestHelpers.CreateCronTrigger(timeZone: ianaTimeZone)); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + var monitorOptions = new SentryMonitorOptions(); + configureCallback().Invoke(monitorOptions); + + monitorOptions.TimeZone.Should().Be("America/New_York"); + } + + [Fact] + public async Task Invoke_UserConfigureCallback_RunsAfterAutomaticConfiguration_AndCanOverrideIt() + { + _options.ConfigureSentryMonitorOptions = (_, options) => + { + options.TimeZone = "custom/zone"; + options.FailureIssueThreshold = 7; + }; + var configureCallback = CaptureConfigureCallback(); + + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + var monitorOptions = new SentryMonitorOptions(); + configureCallback().Invoke(monitorOptions); + + monitorOptions.TimeZone.Should().Be("custom/zone"); + monitorOptions.FailureIssueThreshold.Should().Be(7); + } + + [Fact] + public async Task Invoke_UserConfigureCallback_ReceivesTheExecutingJobDetail() + { + IJobDetail? receivedJobDetail = null; + _options.ConfigureSentryMonitorOptions = (jobDetail, _) => receivedJobDetail = jobDetail; + var configureCallback = CaptureConfigureCallback(); + + var jobDetail = Substitute.For(); + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger(), jobDetail); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + configureCallback().Invoke(new SentryMonitorOptions()); + + receivedJobDetail.Should().BeSameAs(jobDetail); + } + + [Fact] + public async Task Invoke_CronExpressionWithSecondsField_TrimsSecondsAndWarnsOnce() + { + var configureCallback = CaptureConfigureCallback(); + + var sut = CreateSut(); + + // "30" as the seconds field is not supported by Sentry Cron Monitors and should be trimmed off, + // triggering a granularity warning. + var firstContext = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("30 0 12 * * ?")); + await sut.Invoke(firstContext, MiddlewareTestHelpers.Next(), CancellationToken.None); + + var monitorOptions = new SentryMonitorOptions(); + configureCallback().Invoke(monitorOptions); + using var json = monitorOptions.ToJsonDocument(); + json.RootElement.GetProperty("monitor_config").GetProperty("schedule").GetProperty("value").GetString() + .Should().Be("0 12 * * *"); + + // Invoking the middleware again for the same job type should not warn a second time. + var secondContext = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("30 0 12 * * ?")); + await sut.Invoke(secondContext, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Warning); + } + + [Fact] + public async Task Invoke_CronExpressionWithZeroSecondsField_DoesNotWarn() + { + var sut = CreateSut(); + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("0 0 12 * * ?")); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _logger.Entries.Should().NotContain(e => e.Level == LogLevel.Warning); + } + + [Fact] + public async Task Invoke_CronExpressionValidForQuartzButNotForSentry_LogsErrorAndDoesNotThrow() + { + var configureCallback = CaptureConfigureCallback(); + + var sut = CreateSut(); + // "5L" (last Friday of the month) is valid Quartz syntax, but isn't supported by Sentry's crontab format. + var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("0 0 * * 5L")); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + var monitorOptions = new SentryMonitorOptions(); + var act = () => configureCallback().Invoke(monitorOptions); + + act.Should().NotThrow(); + _logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error); + // The schedule was never successfully applied, so setting it now should succeed rather than throw "set twice". + monitorOptions.Invoking(o => o.Interval("* * * * *")).Should().NotThrow(); + } + + /// + /// Captures the configureMonitorOptions callback that passes to + /// , so its effect on a real instance can be + /// asserted on directly. + /// + private Func> CaptureConfigureCallback() + { + Action? captured = null; + _hub.CaptureCheckIn(Arg.Any(), Arg.Any(), configureMonitorOptions: Arg.Do>(callback => captured = callback)) + .Returns(SentryId.Create()); + return () => captured ?? throw new InvalidOperationException("configureMonitorOptions was not captured; CaptureCheckIn was not called."); + } +} diff --git a/test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs new file mode 100644 index 0000000000..1c16dd9ffc --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.Options; +using Quartz; + +namespace Sentry.Quartz.Tests; + +public class SentryMetricsMiddlewareTests +{ + private readonly IHub _hub = Substitute.For(); + private readonly InMemorySentryMetricEmitter _metrics = new(); + private readonly SentryMetricsOptions _options = new(); + + public SentryMetricsMiddlewareTests() => _hub.Metrics.Returns(_metrics); + + private SentryMetricsMiddleware CreateSut() => new(_hub, Options.Create(_options)); + + private static IJobExecutionContext CreateContext(JobDataMap? jobDataMap = null) + { + var jobDetail = Substitute.For(); + jobDetail.Key.Returns(new JobKey("MyJob", "MyGroup")); + jobDetail.JobDataMap.Returns(jobDataMap ?? new JobDataMap()); + + var context = Substitute.For(); + context.JobDetail.Returns(jobDetail); + return context; + } + + [Fact] + public async Task Invoke_EmitsDistributionMetric_WithDefaultNameBasedOnJobKey() + { + var sut = CreateSut(); + var context = CreateContext(); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _metrics.Entries.Should().ContainSingle(); + var entry = _metrics.Entries[0]; + entry.Type.Should().Be(SentryMetricType.Distribution); + entry.Name.Should().Be("quartz.job.duration.MyGroup.MyJob"); + entry.Unit.Should().Be("millisecond"); + } + + [Fact] + public async Task Invoke_LogMetricsExplicitlyFalse_DoesNotEmitMetric() + { + var jobDataMap = new JobDataMap { { "LogMetrics", false } }; + var sut = CreateSut(); + var context = CreateContext(jobDataMap); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _metrics.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task Invoke_LogMetricsExplicitlyTrue_EmitsMetric() + { + var jobDataMap = new JobDataMap { { "LogMetrics", true } }; + var sut = CreateSut(); + var context = CreateContext(jobDataMap); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _metrics.Entries.Should().ContainSingle(); + } + + [Fact] + public async Task Invoke_CustomResolveMetricsName_UsesResolvedName() + { + _options.ResolveMetricsName = jobDetail => $"custom.{jobDetail.Key.Name}"; + var sut = CreateSut(); + var context = CreateContext(); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _metrics.Entries.Should().ContainSingle(e => e.Name == "custom.MyJob"); + } + + [Fact] + public async Task Invoke_AdditionalAttributes_AreAttachedToTheMetric() + { + _options.AdditionalAttributes = _ => new Dictionary { ["foo"] = "bar" }; + var sut = CreateSut(); + var context = CreateContext(); + + await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); + + _metrics.Entries.Should().ContainSingle(); + _metrics.Entries[0].Attributes.Should().ContainKey("foo").WhoseValue.Should().Be("bar"); + } + + [Fact] + public async Task Invoke_JobThrows_StillEmitsMetric_AndRethrows() + { + var sut = CreateSut(); + var context = CreateContext(); + var exception = new InvalidOperationException("boom"); + + var act = () => sut.Invoke(context, MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); + + await act.Should().ThrowAsync(); + _metrics.Entries.Should().ContainSingle(); + } +} diff --git a/test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs b/test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs new file mode 100644 index 0000000000..3ff9c6fe58 --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs @@ -0,0 +1,24 @@ +namespace Sentry.Quartz.Tests; + +/// +/// Serializes a to JSON so its private schedule/crontab state - only observable +/// through - can be asserted on in tests. +/// +internal static class SentryMonitorOptionsExtensions +{ + public static JsonDocument ToJsonDocument(this SentryMonitorOptions options) + { + // WriteTo starts by writing the "monitor_config" property, so - like when a check-in is serialized - it + // needs to be called from within an already-open JSON object. + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + options.WriteTo(writer, logger: null); + writer.WriteEndObject(); + } + + buffer.Seek(0, SeekOrigin.Begin); + return JsonDocument.Parse(buffer); + } +} diff --git a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs new file mode 100644 index 0000000000..0786a76600 --- /dev/null +++ b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs @@ -0,0 +1,49 @@ +using Quartz; + +namespace Sentry.Quartz.Tests; + +public class SentryScopeMiddlewareTests +{ + [Fact] + public async Task Invoke_PushesScopeBeforeNext_AndDisposesItAfter() + { + var events = new List(); + var hub = Substitute.For(); + var scope = Substitute.For(); + scope.When(s => s.Dispose()).Do(_ => events.Add("dispose")); + hub.PushScope().Returns(_ => + { + events.Add("push"); + return scope; + }); + + var sut = new SentryScopeMiddleware(hub); + var context = Substitute.For(); + JobExecutionDelegate next = (_, _) => + { + events.Add("next"); + return default; + }; + + await sut.Invoke(context, next, CancellationToken.None); + + events.Should().Equal("push", "next", "dispose"); + } + + [Fact] + public async Task Invoke_JobThrows_StillDisposesScope_AndRethrows() + { + var hub = Substitute.For(); + var scope = Substitute.For(); + hub.PushScope().Returns(scope); + + var sut = new SentryScopeMiddleware(hub); + var context = Substitute.For(); + var exception = new InvalidOperationException("boom"); + + var act = () => sut.Invoke(context, MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); + + await act.Should().ThrowAsync(); + scope.Received(1).Dispose(); + } +} From 16792a1bd6df9c1aa3c632e05bce34f348080a3f Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 14 Sep 2026 08:54:59 +0200 Subject: [PATCH 29/53] feat: add configurable SentryScopeMiddleware options --- src/Sentry.Quartz/IQuartzBuilderExtensions.cs | 5 +++-- src/Sentry.Quartz/SentryScopeMiddleware.cs | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs index 6717f70f39..79720f96b5 100644 --- a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs @@ -36,10 +36,11 @@ public static IQuartzBuilder AddSentryMetrics(this IQuartzBuilder configuration, /// Adds middleware that pushes a scope to sentry before job execution /// /// + /// Configures the options /// - public static IQuartzBuilder AddSentryScope(this IQuartzBuilder configuration) + public static IQuartzBuilder AddSentryScope(this IQuartzBuilder configuration, Action? configure = null) { - return configuration.AddJobMiddleware(); + return configuration.AddJobMiddleware().ConfigureOptions(configure); } /// diff --git a/src/Sentry.Quartz/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs index e24224b168..af39f86a76 100644 --- a/src/Sentry.Quartz/SentryScopeMiddleware.cs +++ b/src/Sentry.Quartz/SentryScopeMiddleware.cs @@ -1,14 +1,28 @@ +using Microsoft.Extensions.Options; using Quartz; namespace Sentry.Quartz; -internal sealed class SentryScopeMiddleware(IHub sentryHub) : IJobExecutionMiddleware +internal sealed class SentryScopeMiddleware : IJobExecutionMiddleware { - private readonly IHub _sentryHub = sentryHub; + private readonly IHub _sentryHub; + private readonly SentryScopeMiddlewareOptions _options; + + public SentryScopeMiddleware(IHub sentryHub, IOptions options) + { + _sentryHub = sentryHub; + _options = options.Value; + } public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) { using var _ = _sentryHub.PushScope(); + _sentryHub.ConfigureScope(scope => scope.SetTag(_options.ScopeTagName, context.JobDetail.Key.Name)); await next(context, cancellationToken).ConfigureAwait(false); } } + +public class SentryScopeMiddlewareOptions +{ + public string ScopeTagName { get; set; } = "quartz.job"; +} From 0f8e23714129b68b3d19ad4326694515e0abcced Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 14 Sep 2026 08:58:27 +0200 Subject: [PATCH 30/53] refactor: migrate extension methods to extension block --- src/Sentry.Quartz/IQuartzBuilderExtensions.cs | 81 +++++++++---------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs index 79720f96b5..a0d16e6a9b 100644 --- a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs @@ -10,50 +10,49 @@ namespace Sentry.Quartz; /// public static class IQuartzBuilderExtensions { - /// - /// Adds middleware to track CRON jobs to Sentry - /// - /// - /// Configures the options - /// - public static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, Action? configure = null) + extension(IQuartzBuilder configuration) { - return configuration.AddJobMiddleware().ConfigureOptions(configure); - } + /// + /// Adds middleware to track CRON jobs to Sentry + /// + /// Configures the options + /// + public IQuartzBuilder AddSentryCronJobs(Action? configure = null) + { + return configuration.AddJobMiddleware().ConfigureOptions(configure); + } - /// - /// Adds middleware to track job execution duration metrics - /// - /// - /// Configures the options - /// - public static IQuartzBuilder AddSentryMetrics(this IQuartzBuilder configuration, Action? configure = null) - { - return configuration.AddJobMiddleware().ConfigureOptions(configure); - } + /// + /// Adds middleware to track job execution duration metrics + /// + /// Configures the options + /// + public IQuartzBuilder AddSentryMetrics(Action? configure = null) + { + return configuration.AddJobMiddleware().ConfigureOptions(configure); + } - /// - /// Adds middleware that pushes a scope to sentry before job execution - /// - /// - /// Configures the options - /// - public static IQuartzBuilder AddSentryScope(this IQuartzBuilder configuration, Action? configure = null) - { - return configuration.AddJobMiddleware().ConfigureOptions(configure); - } + /// + /// Adds middleware that pushes a scope to sentry before job execution + /// + /// Configures the options + /// + public IQuartzBuilder AddSentryScope(Action? configure = null) + { + return configuration.AddJobMiddleware().ConfigureOptions(configure); + } - /// - /// For testing - /// - /// - /// - /// - /// - /// - internal static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, IOptions options, IHub hub, ILogger logger) - { - configuration.AddJobMiddleware(new SentryCronJobMiddleware(hub, options, logger)); - return configuration; + /// + /// For testing + /// + /// + /// + /// + /// + internal IQuartzBuilder AddSentryCronJobs(IOptions options, IHub hub, ILogger logger) + { + configuration.AddJobMiddleware(new SentryCronJobMiddleware(hub, options, logger)); + return configuration; + } } } From f0eebaf0d44c3f37ee9eaf5055bc209fea957b60 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 14 Sep 2026 08:58:54 +0200 Subject: [PATCH 31/53] refactor: remove SentryMetricsMiddleware --- src/Sentry.Quartz/IQuartzBuilderExtensions.cs | 10 --- src/Sentry.Quartz/SentryMetricsMiddleware.cs | 80 ------------------- 2 files changed, 90 deletions(-) delete mode 100644 src/Sentry.Quartz/SentryMetricsMiddleware.cs diff --git a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs index a0d16e6a9b..8c0524e320 100644 --- a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs @@ -22,16 +22,6 @@ public IQuartzBuilder AddSentryCronJobs(Action? configure return configuration.AddJobMiddleware().ConfigureOptions(configure); } - /// - /// Adds middleware to track job execution duration metrics - /// - /// Configures the options - /// - public IQuartzBuilder AddSentryMetrics(Action? configure = null) - { - return configuration.AddJobMiddleware().ConfigureOptions(configure); - } - /// /// Adds middleware that pushes a scope to sentry before job execution /// diff --git a/src/Sentry.Quartz/SentryMetricsMiddleware.cs b/src/Sentry.Quartz/SentryMetricsMiddleware.cs deleted file mode 100644 index c178c8e1b8..0000000000 --- a/src/Sentry.Quartz/SentryMetricsMiddleware.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Microsoft.Extensions.Options; -using Quartz; - -namespace Sentry.Quartz; - -internal sealed class SentryMetricsMiddleware : IJobExecutionMiddleware -{ - private readonly IHub _sentryHub; - private readonly IOptions _options; - - public SentryMetricsMiddleware(IHub sentryHub, IOptions options) - { - _sentryHub = sentryHub; - _options = options; - } - - public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) - { - using (GetLogger(context)) - { - await next(context, cancellationToken).ConfigureAwait(false); - } - } - - private SentryMetricsLogger? GetLogger(IJobExecutionContext context) - { - if (!context.JobDetail.JobDataMap.TryGetBoolean("LogMetrics", out bool logMetrics) || logMetrics) - { - var metricsName = _options.Value.ResolveMetricsName(context.JobDetail); - var additionalAttributes = _options.Value.AdditionalAttributes?.Invoke(context.JobDetail); - return new SentryMetricsLogger(metricsName, additionalAttributes, _sentryHub); - } - - return null; - } -} - -/// -/// Options used by to control how Quartz job execution metrics are -/// reported to Sentry. -/// -public class SentryMetricsOptions -{ - /// - /// A function used to resolve the name of the metric emitted for a job's execution duration. - /// - /// - /// Defaults to quartz.job.duration.{group}.{name}, based on the job's . - /// - public Func ResolveMetricsName { get; set; } = jobDetail => $"quartz.job.duration.{jobDetail.Key.Group}.{jobDetail.Key.Name}"; - - /// - /// An optional function used to compute additional attributes to attach to the emitted duration metric, - /// based on the job being executed. - /// - /// Returns by default, meaning no additional - /// attributes are attached. - /// - public Func>? AdditionalAttributes { get; set; } -} - -internal sealed class SentryMetricsLogger : IDisposable -{ - private readonly string _metricName; - private readonly IHub _sentryHub; - private readonly Stopwatch _stopWatch = Stopwatch.StartNew(); - private readonly IDictionary? _attributes; - - public SentryMetricsLogger(string metricName, IDictionary? attributes, IHub sentryHub) - { - _attributes = attributes; - _metricName = metricName; - _sentryHub = sentryHub; - } - - public void Dispose() - { - _sentryHub.Metrics.EmitDistribution(_metricName, _stopWatch.ElapsedMilliseconds, MeasurementUnit.Duration.Millisecond, _attributes); - } -} From 1b46d8bcfd124c45cd692c508924b62523461770 Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 14 Sep 2026 09:07:55 +0200 Subject: [PATCH 32/53] fix: use JobDetail.Key.ToString() --- src/Sentry.Quartz/SentryScopeMiddleware.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Sentry.Quartz/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs index af39f86a76..f8b0eba812 100644 --- a/src/Sentry.Quartz/SentryScopeMiddleware.cs +++ b/src/Sentry.Quartz/SentryScopeMiddleware.cs @@ -17,12 +17,23 @@ public SentryScopeMiddleware(IHub sentryHub, IOptions scope.SetTag(_options.ScopeTagName, context.JobDetail.Key.Name)); + _sentryHub.ConfigureScope(scope => scope.SetTag(_options.ScopeTagName, context.JobDetail.Key.ToString())); await next(context, cancellationToken).ConfigureAwait(false); } } +/// +/// Options for configuring the behavior of the SentryScopeMiddleware. +/// public class SentryScopeMiddlewareOptions { + /// + /// Gets or sets the name of the tag that will be added to the Sentry scope. + /// + /// + /// The value of this property is used as the key for a tag in the Sentry scope + /// to allow associating metadata about the current Quartz job execution context. + /// By default, it is set to "quartz.job". + /// public string ScopeTagName { get; set; } = "quartz.job"; } From 15b62bb827f225a105a9b58eba6ae3014b185d8b Mon Sep 17 00:00:00 2001 From: Michael Mairegger Date: Mon, 14 Sep 2026 09:11:22 +0200 Subject: [PATCH 33/53] fix: replace job type with job ID in SentryCronJobMiddleware dictionary key --- src/Sentry.Quartz/SentryCronJobMiddleware.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs index f60aaea69a..cb9c2feb98 100644 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -7,7 +7,7 @@ namespace Sentry.Quartz; internal sealed partial class SentryCronJobMiddleware : IJobExecutionMiddleware { private readonly SentryCronJobOptions _options; - private readonly ConcurrentDictionary _sentryCronInformation = []; + private readonly ConcurrentDictionary _sentryCronInformation = []; private readonly IHub _hub; private readonly ILogger _logger; @@ -20,8 +20,8 @@ public SentryCronJobMiddleware(IHub hub, IOptions options, public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) { - var jobType = context.JobInstance.GetType(); - var info = _sentryCronInformation.GetOrAdd(jobType, _ => new SentryCronInformation(context.JobInstance)); + var jobId = context.JobDetail.Key.ToString(); + var info = _sentryCronInformation.GetOrAdd(jobId, _ => new SentryCronInformation(context.JobInstance)); var sentryId = StartQuartz(context, info); try From 8a765b271cbabdfff7e546008b59cce53814d355 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 18:58:14 -0700 Subject: [PATCH 34/53] 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 --- .craft.yml | 8 +- .generated.NoMobile.slnx | 4 +- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + README.md | 2 +- Sentry-CI-Build-Linux-NoMobile.slnf | 4 +- Sentry-CI-Build-Linux.slnf | 4 +- Sentry-CI-Build-Windows-arm64.slnf | 4 +- Sentry-CI-Build-Windows.slnf | 4 +- Sentry-CI-Build-macOS.slnf | 4 +- Sentry-CI-CodeQL.slnf | 2 +- Sentry.slnx | 4 +- SentryAspNetCore.slnf | 2 +- SentryNoMobile.slnf | 4 +- SentryNoSamples.slnf | 4 +- samples/Sentry.Samples.Quartz/Program.cs | 22 +- samples/Sentry.Samples.Quartz/README.md | 2 +- .../Sentry.Samples.Quartz.csproj | 2 +- scripts/generate-solution-filters-config.yaml | 2 +- src/Sentry.Quartz/IQuartzBuilderExtensions.cs | 48 --- src/Sentry.Quartz/RegionInfoExtensions.cs | 19 -- src/Sentry.Quartz/Sentry.Quartz.csproj | 24 -- src/Sentry.Quartz/SentryCronInformation.cs | 21 -- src/Sentry.Quartz/SentryCronJobMiddleware.cs | 122 -------- src/Sentry.Quartz/SentryCronJobOptions.cs | 32 -- .../SentryCronMonitorSlugAttribute.cs | 17 -- src/Sentry.Quartz/SentryScopeMiddleware.cs | 39 --- src/Sentry.Quartz4/Core/JobMonitor.cs | 166 ++++++++++ .../Core/SentryCronMonitorSlugAttribute.cs | 37 +++ .../Core/SentryQuartzOptions.cs | 40 +++ src/Sentry.Quartz4/Core/TriggerSchedule.cs | 227 ++++++++++++++ src/Sentry.Quartz4/QuartzApi.cs | 16 + src/Sentry.Quartz4/Sentry.Quartz4.csproj | 24 ++ src/Sentry.Quartz4/SentryCronJobMiddleware.cs | 30 ++ .../SentryQuartzBuilderExtensions.cs | 43 +++ src/Sentry.Quartz4/SentryScopeMiddleware.cs | 32 ++ src/Sentry/Sentry.csproj | 3 +- src/Sentry/SentryMonitorOptions.cs | 2 + .../MiddlewareTestHelpers.cs | 56 ---- .../QuartzBuilderExtensionsTests.cs | 111 ------- test/Sentry.Quartz.Tests/RecordingLogger.cs | 21 -- .../RegionInfoExtensionsTests.cs | 13 - .../SentryCronInformationTests.cs | 39 --- .../SentryCronJobMiddlewareTests.cs | 257 ---------------- .../SentryMetricsMiddlewareTests.cs | 103 ------- .../SentryMonitorOptionsExtensions.cs | 24 -- .../SentryScopeMiddlewareTests.cs | 49 --- ...iApprovalTests.Run.DotNet10_0.verified.txt | 24 ++ .../ApiApprovalTests.verify.cs | 10 + .../Core/JobMonitorTests.cs | 283 ++++++++++++++++++ .../Core/MonitorConfigJson.cs | 28 ++ .../Core/TriggerScheduleTests.cs | 172 +++++++++++ .../MiddlewareTestHelpers.cs | 27 ++ test/Sentry.Quartz4.Tests/QuartzTestApi.cs | 46 +++ .../Sentry.Quartz4.Tests.csproj} | 10 +- .../SentryCronJobMiddlewareTests.cs | 91 ++++++ .../SentryQuartzBuilderExtensionsTests.cs | 136 +++++++++ .../SentryScopeMiddlewareTests.cs | 77 +++++ test/Sentry.Testing/TestEnvironment.cs | 16 + 58 files changed, 1576 insertions(+), 1038 deletions(-) delete mode 100644 src/Sentry.Quartz/IQuartzBuilderExtensions.cs delete mode 100644 src/Sentry.Quartz/RegionInfoExtensions.cs delete mode 100644 src/Sentry.Quartz/Sentry.Quartz.csproj delete mode 100644 src/Sentry.Quartz/SentryCronInformation.cs delete mode 100644 src/Sentry.Quartz/SentryCronJobMiddleware.cs delete mode 100644 src/Sentry.Quartz/SentryCronJobOptions.cs delete mode 100644 src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs delete mode 100644 src/Sentry.Quartz/SentryScopeMiddleware.cs create mode 100644 src/Sentry.Quartz4/Core/JobMonitor.cs create mode 100644 src/Sentry.Quartz4/Core/SentryCronMonitorSlugAttribute.cs create mode 100644 src/Sentry.Quartz4/Core/SentryQuartzOptions.cs create mode 100644 src/Sentry.Quartz4/Core/TriggerSchedule.cs create mode 100644 src/Sentry.Quartz4/QuartzApi.cs create mode 100644 src/Sentry.Quartz4/Sentry.Quartz4.csproj create mode 100644 src/Sentry.Quartz4/SentryCronJobMiddleware.cs create mode 100644 src/Sentry.Quartz4/SentryQuartzBuilderExtensions.cs create mode 100644 src/Sentry.Quartz4/SentryScopeMiddleware.cs delete mode 100644 test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs delete mode 100644 test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs delete mode 100644 test/Sentry.Quartz.Tests/RecordingLogger.cs delete mode 100644 test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs delete mode 100644 test/Sentry.Quartz.Tests/SentryCronInformationTests.cs delete mode 100644 test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs delete mode 100644 test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs delete mode 100644 test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs delete mode 100644 test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs create mode 100644 test/Sentry.Quartz4.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt create mode 100644 test/Sentry.Quartz4.Tests/ApiApprovalTests.verify.cs create mode 100644 test/Sentry.Quartz4.Tests/Core/JobMonitorTests.cs create mode 100644 test/Sentry.Quartz4.Tests/Core/MonitorConfigJson.cs create mode 100644 test/Sentry.Quartz4.Tests/Core/TriggerScheduleTests.cs create mode 100644 test/Sentry.Quartz4.Tests/MiddlewareTestHelpers.cs create mode 100644 test/Sentry.Quartz4.Tests/QuartzTestApi.cs rename test/{Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj => Sentry.Quartz4.Tests/Sentry.Quartz4.Tests.csproj} (52%) create mode 100644 test/Sentry.Quartz4.Tests/SentryCronJobMiddlewareTests.cs create mode 100644 test/Sentry.Quartz4.Tests/SentryQuartzBuilderExtensionsTests.cs create mode 100644 test/Sentry.Quartz4.Tests/SentryScopeMiddlewareTests.cs diff --git a/.craft.yml b/.craft.yml index f945cabfc4..6e4a234570 100644 --- a/.craft.yml +++ b/.craft.yml @@ -87,7 +87,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/ + nuget:Sentry.Quartz4: + name: Sentry.Quartz4 + packageUrl: https://www.nuget.org/packages/Sentry.Quartz4 + mainDocsUrl: https://docs.sentry.io/platforms/dotnet/crons/quartz/ diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index fd72089ae0..15cea968b5 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -169,7 +169,7 @@ - + @@ -204,7 +204,7 @@ - + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d8dc8f9623..c6b0d096ff 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.Quartz4 - Sentry.Serilog - Other validations: diff --git a/README.md b/README.md index 273606efb2..23643fbad1 100644 --- a/README.md +++ b/README.md @@ -35,7 +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.Quartz4** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Quartz4.svg)](https://www.nuget.org/packages/Sentry.Quartz4) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Quartz4.svg)](https://www.nuget.org/packages/Sentry.Quartz4) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Quartz4.svg)](https://www.nuget.org/packages/Sentry.Quartz4) | [![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 96a8125b7d..56f27d8bd6 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -50,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -72,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 b7a4975621..76da64e4fa 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -56,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -81,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 f9b1f924e6..814d91f739 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -58,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -80,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 fccd8ebf8e..031004682e 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -58,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -83,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 6827a106ce..3cf41a285c 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -63,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -89,7 +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.Quartz4.Tests\\Sentry.Quartz4.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..3b38e1bf9b 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -22,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj" ] diff --git a/Sentry.slnx b/Sentry.slnx index fd72089ae0..15cea968b5 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -169,7 +169,7 @@ - + @@ -204,7 +204,7 @@ - + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index f1e754f58c..fdc4e33d3d 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -21,7 +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.Quartz4\\Sentry.Quartz4.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..8436d5864b 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -52,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -77,7 +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.Quartz4.Tests\\Sentry.Quartz4.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..2ae61aedad 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -24,7 +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.Quartz4\\Sentry.Quartz4.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", @@ -51,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 index 491b4b9da7..64d9637646 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -19,32 +19,31 @@ public static void Main(string[] args) builder.Services.AddQuartz(quartz => { - quartz.AddSentryScope(); - quartz.AddSentryMetrics(); - quartz.AddSentryCronJobs(options => + // Runs each job in its own scope and captures its exceptions. Jobs with [SentryCronMonitorSlug] also send + // check-ins, with the trigger's schedule as the monitor config. + quartz.AddSentry(options => { - options.EnableUpsertCronMonitor = builder.Environment.IsProduction(); - options.ConfigureSentryMonitorOptions = (jobDetail, options) => + options.ConfigureMonitorOptions = (jobDetail, monitorOptions) => { if (jobDetail.Key.Name == nameof(SecondJob)) { - options.FailureIssueThreshold = 10; - options.RecoveryThreshold = 10; + 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("*/10 * * ? * *")); + 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("*/10 * * ? * *")); + 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("*/10 * * ? * *")); + quartz.AddTrigger(opts => opts.ForJob(jobKey3).WithIdentity($"{nameof(ThirdJob)}-trigger").WithCronSchedule("0 * * ? * *")); }).AddQuartzHostedService(); @@ -92,7 +91,8 @@ public async ValueTask Execute(IJobExecutionContext context, CancellationToken c } } -[SentryCronMonitorSlug("RecurringBackgroundJob")] +// Uses the slug of the job key: "thirdjob" +[SentryCronMonitorSlug] public class ThirdJob : IJob { public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) diff --git a/samples/Sentry.Samples.Quartz/README.md b/samples/Sentry.Samples.Quartz/README.md index c2f83967d1..66811ac5ae 100644 --- a/samples/Sentry.Samples.Quartz/README.md +++ b/samples/Sentry.Samples.Quartz/README.md @@ -1,4 +1,4 @@ -# Sentry for Hangfire +# Sentry for Quartz.NET This is a standard ASP.NET Core app with nothing in it but Quartz. To run this sample: diff --git a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj index 973a202d29..24dca70bfb 100644 --- a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -20,7 +20,7 @@ - + diff --git a/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index a1a52653b4..e4765811a3 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -167,7 +167,7 @@ filterConfigs: - "**/Sentry.csproj" - "**/Sentry.Analyzers.csproj" - "**/Sentry.Hangfire.csproj" - - "**/Sentry.Quartz.csproj" + - "**/Sentry.Quartz4.csproj" - "**/Sentry.Samples.Hangfire.csproj" - "**/Sentry.Testing.csproj" - "**/Sentry.Testing.CrashableApp.csproj" diff --git a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs b/src/Sentry.Quartz/IQuartzBuilderExtensions.cs deleted file mode 100644 index 8c0524e320..0000000000 --- a/src/Sentry.Quartz/IQuartzBuilderExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Quartz; - -namespace Sentry.Quartz; - -/// -/// Quartz.NET Extensions for . -/// -public static class IQuartzBuilderExtensions -{ - extension(IQuartzBuilder configuration) - { - /// - /// Adds middleware to track CRON jobs to Sentry - /// - /// Configures the options - /// - public IQuartzBuilder AddSentryCronJobs(Action? configure = null) - { - return configuration.AddJobMiddleware().ConfigureOptions(configure); - } - - /// - /// Adds middleware that pushes a scope to sentry before job execution - /// - /// Configures the options - /// - public IQuartzBuilder AddSentryScope(Action? configure = null) - { - return configuration.AddJobMiddleware().ConfigureOptions(configure); - } - - /// - /// For testing - /// - /// - /// - /// - /// - internal IQuartzBuilder AddSentryCronJobs(IOptions options, IHub hub, ILogger logger) - { - configuration.AddJobMiddleware(new SentryCronJobMiddleware(hub, options, logger)); - return configuration; - } - } -} diff --git a/src/Sentry.Quartz/RegionInfoExtensions.cs b/src/Sentry.Quartz/RegionInfoExtensions.cs deleted file mode 100644 index 1d87a19086..0000000000 --- a/src/Sentry.Quartz/RegionInfoExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Sentry.Quartz; - -internal static class RegionInfoExtensions -{ - extension(RegionInfo) - { - public static RegionInfo? GetCurrentRegionOrNull() - { - try - { - return RegionInfo.CurrentRegion; - } - catch (CultureNotFoundException) - { - return null; - } - } - } -} diff --git a/src/Sentry.Quartz/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj deleted file mode 100644 index fb17e7bc82..0000000000 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Official Quartz integration for Sentry - Open-source error tracking that helps developers monitor and fix crashes in real time. - $(PackageTags);Quartz - net10.0; - enable - false - - - - true - - - - - - - - - - - - diff --git a/src/Sentry.Quartz/SentryCronInformation.cs b/src/Sentry.Quartz/SentryCronInformation.cs deleted file mode 100644 index 13a76f96ea..0000000000 --- a/src/Sentry.Quartz/SentryCronInformation.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Quartz; - -namespace Sentry.Quartz; - -internal sealed class SentryCronInformation -{ - public SentryCronInformation(IJob job) - { - var jobType = job.GetType(); - var monitorAttribute = jobType.GetCustomAttribute(); - - ShouldWriteStatusToSentry = monitorAttribute is not null; - MonitorSlug = monitorAttribute?.MonitorSlug ?? jobType.Name; - } - - public string MonitorSlug { get; } - - public bool ShouldWriteStatusToSentry { get; } - - internal bool WarningShownForSecondsParameterIssue { get; set; } -} diff --git a/src/Sentry.Quartz/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs deleted file mode 100644 index cb9c2feb98..0000000000 --- a/src/Sentry.Quartz/SentryCronJobMiddleware.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Quartz; - -namespace Sentry.Quartz; - -internal sealed partial class SentryCronJobMiddleware : IJobExecutionMiddleware -{ - private readonly SentryCronJobOptions _options; - private readonly ConcurrentDictionary _sentryCronInformation = []; - private readonly IHub _hub; - private readonly ILogger _logger; - - public SentryCronJobMiddleware(IHub hub, IOptions options, ILogger logger) - { - _hub = hub; - _logger = logger; - _options = options.Value; - } - - public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) - { - var jobId = context.JobDetail.Key.ToString(); - var info = _sentryCronInformation.GetOrAdd(jobId, _ => new SentryCronInformation(context.JobInstance)); - - var sentryId = StartQuartz(context, info); - try - { - await next(context, cancellationToken).ConfigureAwait(false); - CompleteCheckIn(info, sentryId, CheckInStatus.Ok); - } - catch - { - CompleteCheckIn(info, sentryId, CheckInStatus.Error); - throw; - } - } - - private SentryId? StartQuartz(IJobExecutionContext context, SentryCronInformation info) - { - if (context.Trigger is not ICronTrigger) - { - return null; - } - - if (info.ShouldWriteStatusToSentry) - { - return _hub.CaptureCheckIn(info.MonitorSlug, CheckInStatus.InProgress, configureMonitorOptions: options => - { - if (_options.EnableUpsertCronMonitor && context.Trigger is ICronTrigger cronTrigger) - { - UpsertCronMonitor(cronTrigger, info, options); - _options.ConfigureSentryMonitorOptions?.Invoke(context.JobDetail, options); - } - }); - } - - return null; - } - - private void CompleteCheckIn(SentryCronInformation info, SentryId? sentryId, CheckInStatus status) - { - if (sentryId is not null) - { - _hub.CaptureCheckIn(info.MonitorSlug, status, sentryId); - } - } - - private void UpsertCronMonitor(ICronTrigger cronTrigger, SentryCronInformation information, SentryMonitorOptions options) - { - if (string.IsNullOrWhiteSpace(cronTrigger.CronExpressionString)) - { - return; - } - - if (TimeZoneInfo.TryConvertWindowsIdToIanaId(cronTrigger.TimeZone.Id, RegionInfo.GetCurrentRegionOrNull()?.TwoLetterISORegionName, out string? iana)) - { - options.TimeZone = iana; - } - else - { - options.TimeZone = cronTrigger.TimeZone.Id; - } - - string monitorSlug = information.MonitorSlug; - string cron = cronTrigger.CronExpressionString; - string[] fields = cron.Split(' ', StringSplitOptions.RemoveEmptyEntries); - - if (fields.Length is 6 or 7) - { - if (fields[0] != "0" && !information.WarningShownForSecondsParameterIssue) - { - information.WarningShownForSecondsParameterIssue = true; - LogGranularityWarning(monitorSlug, cron); - } - - fields = fields[1..6]; - } - - try - { - string crontab = string.Join(" ", fields); - var cronExpression = CronExpression.Parse(crontab, CronFormat.Unix); - - string[] normalized = cronExpression.CronExpressionString - .Replace("?", "*", StringComparison.OrdinalIgnoreCase) - .Split(' ', StringSplitOptions.RemoveEmptyEntries); - - options.Interval(string.Join(" ", normalized[1..6])); - } - catch (ArgumentException ex) - { - LogApiException(ex, monitorSlug, cron); - } - } - - [LoggerMessage(1, LogLevel.Warning, "Sentry Cron Monitor supports a minimum granularity of minutes. But for job {MonitorSlug} \"{Cron}\" was provided. The first field (seconds) will be ignored")] - private partial void LogGranularityWarning(string monitorSlug, string cron); - - [LoggerMessage(2, LogLevel.Error, "Sentry Cron Monitor update failed for job {MonitorSlug}. Cron expression \"{Cron}\" is invalid")] - private partial void LogApiException(Exception exception, string monitorSlug, string cron); -} diff --git a/src/Sentry.Quartz/SentryCronJobOptions.cs b/src/Sentry.Quartz/SentryCronJobOptions.cs deleted file mode 100644 index c0493472e7..0000000000 --- a/src/Sentry.Quartz/SentryCronJobOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Quartz; - -namespace Sentry.Quartz; - -/// -/// Represents configuration options for Sentry integration with Quartz Cron Jobs. -/// -public class SentryCronJobOptions -{ - /// - /// Gets or sets a value indicating whether to enable or disable the automatic upsert of a CronMonitor when a CronTrigger is linked with the job execution context. - /// - /// - /// If set to true, enables the creation or update of a CronMonitor with relevant details, such as Cron expression and time zone, - /// during a job execution configured with a CronTrigger. This ensures that the monitor reflects accurate scheduling metadata in Sentry. - /// - public bool EnableUpsertCronMonitor { get; set; } = true; - - /// - /// Gets or sets a callback that is invoked to configure the used when - /// upserting a CronMonitor for a job execution. - /// - /// - /// This callback is only invoked when is true, and is invoked - /// after the automatic configuration derived from the job's (such as the schedule - /// and time zone) is applied, so any values set here take precedence over those automatically derived values. - /// Use this to customize properties of the monitor, such as or - /// , or to override the automatically derived schedule - /// and time zone, based on the being executed. - /// - public Action? ConfigureSentryMonitorOptions { get; set; } -} diff --git a/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs b/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs deleted file mode 100644 index 8f930909d4..0000000000 --- a/src/Sentry.Quartz/SentryCronMonitorSlugAttribute.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Sentry.Quartz; - -/// -/// Sentry Monitor Slug Attribute -/// -/// -[AttributeUsage(AttributeTargets.Class)] -public sealed class SentryCronMonitorSlugAttribute(string? monitorSlug = null) : Attribute -{ - /// - /// Gets the slug associated with the Sentry monitor. The monitor slug is used to - /// identify a specific Sentry monitor associated with a job. If no slug is explicitly - /// provided through the , it defaults to - /// the name of the job type. - /// - public string? MonitorSlug { get; } = monitorSlug; -} diff --git a/src/Sentry.Quartz/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs deleted file mode 100644 index f8b0eba812..0000000000 --- a/src/Sentry.Quartz/SentryScopeMiddleware.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.Extensions.Options; -using Quartz; - -namespace Sentry.Quartz; - -internal sealed class SentryScopeMiddleware : IJobExecutionMiddleware -{ - private readonly IHub _sentryHub; - private readonly SentryScopeMiddlewareOptions _options; - - public SentryScopeMiddleware(IHub sentryHub, IOptions options) - { - _sentryHub = sentryHub; - _options = options.Value; - } - - public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) - { - using var _ = _sentryHub.PushScope(); - _sentryHub.ConfigureScope(scope => scope.SetTag(_options.ScopeTagName, context.JobDetail.Key.ToString())); - await next(context, cancellationToken).ConfigureAwait(false); - } -} - -/// -/// Options for configuring the behavior of the SentryScopeMiddleware. -/// -public class SentryScopeMiddlewareOptions -{ - /// - /// Gets or sets the name of the tag that will be added to the Sentry scope. - /// - /// - /// The value of this property is used as the key for a tag in the Sentry scope - /// to allow associating metadata about the current Quartz job execution context. - /// By default, it is set to "quartz.job". - /// - public string ScopeTagName { get; set; } = "quartz.job"; -} diff --git a/src/Sentry.Quartz4/Core/JobMonitor.cs b/src/Sentry.Quartz4/Core/JobMonitor.cs new file mode 100644 index 0000000000..3f13d6a016 --- /dev/null +++ b/src/Sentry.Quartz4/Core/JobMonitor.cs @@ -0,0 +1,166 @@ +using Quartz; +using Sentry.Extensibility; + +namespace Sentry.Quartz; + +// Captures the check-ins of jobs with SentryCronMonitorSlugAttribute +internal sealed class JobMonitor +{ + private const int MaxSlugLength = 50; + + private static readonly ConcurrentDictionary Attributes = new(); + + private readonly SentryQuartzOptions _options; + private readonly IHub _hub; + private readonly IDiagnosticLogger? _logger; + + public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logger = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _hub = hub; + _logger = logger; + } + + internal IDiagnosticLogger? Logger => _logger ?? _hub.GetSentryOptions()?.DiagnosticLogger; + + // Returns null when the job isn't monitored + public async Task StartAsync(IJobExecutionContext context, CancellationToken cancellationToken) + { + try + { + var jobType = QuartzApi.GetJobType(context.JobDetail); + var attribute = Attributes.GetOrAdd(jobType, type => type.GetCustomAttribute()); + if (attribute is null) + { + return null; + } + + var monitorSlug = GetMonitorSlug(context, attribute); + if (monitorSlug is null) + { + Logger?.LogDebug("Skipping the check-in for job '{0}'. Its key has no characters a monitor slug can use. " + + "Set a monitor slug in its SentryCronMonitorSlug attribute.", context.JobDetail.Key); + return null; + } + + var configureMonitorOptions = _options.SendMonitorConfig && attribute.SendMonitorConfig + ? await GetMonitorConfigAsync(context, cancellationToken).ConfigureAwait(false) + : null; + + var checkInId = _hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress, configureMonitorOptions: configureMonitorOptions); + return new JobCheckIn(monitorSlug, checkInId); + } + catch (Exception e) + { + Logger?.LogError(e, "Failed to capture the check-in for job '{0}'.", context.JobDetail.Key); + return null; + } + } + + public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) => + _hub.CaptureCheckIn(checkIn.MonitorSlug, failed ? CheckInStatus.Error : CheckInStatus.Ok, checkIn.Id, duration); + + private async Task?> GetMonitorConfigAsync( + IJobExecutionContext context, + CancellationToken cancellationToken) + { + var trigger = context.Trigger; + try + { + var schedule = TriggerSchedule.ToMonitorConfig(trigger); + if (schedule is null) + { + Logger?.LogDebug("Not sending the schedule of trigger '{0}'. Sentry can't represent it.", trigger.Key); + return null; + } + + if (!trigger.JobDataMap.ContainsKey(SentryCronMonitorSlugAttribute.JobDataKey) + && await QuartzApi.HasSeveralTriggersAsync(context, cancellationToken).ConfigureAwait(false)) + { + Logger?.LogDebug("Not sending the schedule of trigger '{0}'. Job '{1}' has more than one trigger. " + + "Set '{2}' in each trigger's data map to give each its own monitor.", + trigger.Key, context.JobDetail.Key, SentryCronMonitorSlugAttribute.JobDataKey); + return null; + } + + var configure = _options.ConfigureMonitorOptions; + if (configure is null) + { + return schedule; + } + + var jobDetail = context.JobDetail; + return options => + { + try + { + configure(jobDetail, options); + } + catch (Exception e) + { + Logger?.LogError(e, "ConfigureMonitorOptions threw for job '{0}'.", jobDetail.Key); + } + + if (!options.HasSchedule) + { + schedule(options); + } + }; + } + catch (Exception e) + { + Logger?.LogError(e, "Failed to read the schedule of trigger '{0}'.", trigger.Key); + return null; + } + } + + internal static string? GetMonitorSlug(IJobExecutionContext context, SentryCronMonitorSlugAttribute attribute) + { + if (context.MergedJobDataMap.TryGetValue(SentryCronMonitorSlugAttribute.JobDataKey, out var value) + && value is string dataMapSlug + && !string.IsNullOrWhiteSpace(dataMapSlug)) + { + return dataMapSlug; + } + + if (!string.IsNullOrWhiteSpace(attribute.MonitorSlug)) + { + return attribute.MonitorSlug; + } + + var key = context.JobDetail.Key; + return ToSlug(key.Group == JobKey.DefaultGroup ? key.Name : key.Group + "." + key.Name); + } + + // 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.Quartz4/Core/SentryCronMonitorSlugAttribute.cs b/src/Sentry.Quartz4/Core/SentryCronMonitorSlugAttribute.cs new file mode 100644 index 0000000000..cd2509a031 --- /dev/null +++ b/src/Sentry.Quartz4/Core/SentryCronMonitorSlugAttribute.cs @@ -0,0 +1,37 @@ +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. +/// +/// +/// The monitor slug. Defaults to the slug of the job's key. +[AttributeUsage(AttributeTargets.Class)] +public sealed class SentryCronMonitorSlugAttribute(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.Quartz4/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz4/Core/SentryQuartzOptions.cs new file mode 100644 index 0000000000..b7125b5ce5 --- /dev/null +++ b/src/Sentry.Quartz4/Core/SentryQuartzOptions.cs @@ -0,0 +1,40 @@ +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, and jobs with more than one trigger that share a + /// monitor, are not sent. + /// + /// + /// 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 whenever a monitor config is sent, before the trigger's schedule is applied. A schedule or time zone set + /// here is used instead of the trigger's. + /// + public Action? ConfigureMonitorOptions { get; set; } +} diff --git a/src/Sentry.Quartz4/Core/TriggerSchedule.cs b/src/Sentry.Quartz4/Core/TriggerSchedule.cs new file mode 100644 index 0000000000..4ae66683f0 --- /dev/null +++ b/src/Sentry.Quartz4/Core/TriggerSchedule.cs @@ -0,0 +1,227 @@ +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"]; + + public static Action? ToMonitorConfig(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); + if (crontab is null || timeZone is null) + { + return null; + } + + return options => + { + options.Interval(crontab); + options.TimeZone ??= timeZone; + }; + case ISimpleTrigger simpleTrigger when ToInterval(simpleTrigger) is (var interval, var unit): + return options => options.Interval(interval, unit); + default: + return null; + } + } + + // Quartz has a seconds field, an optional year field, '?' for the 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] != "*") + || (fields[3] == "?") == (fields[5] == "?")) + { + return null; + } + + string?[] crontab = + [ + ToCrontabField(fields[1], 0, 59), + ToCrontabField(fields[2], 0, 23), + fields[3] == "?" ? "*" : ToCrontabField(fields[3], 1, 31), + ToCrontabField(fields[4], 1, 12, MonthNames), + fields[5] == "?" ? "*" : ToCrontabField(fields[5], 1, 7, DayNames, offset: -1) + ]; + + return crontab.Any(field => field is null) ? null : string.Join(" ", crontab); + } + + private static string? ToCrontabField( + string field, + int min, + int max, + string[]? names = null, + int offset = 0) + { + var terms = field.Split(','); + for (var i = 0; i < terms.Length; i++) + { + if (ToCrontabTerm(terms[i], min, max, names, offset) 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) + { + 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 "*" + 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 + } +} diff --git a/src/Sentry.Quartz4/QuartzApi.cs b/src/Sentry.Quartz4/QuartzApi.cs new file mode 100644 index 0000000000..15861f4bc4 --- /dev/null +++ b/src/Sentry.Quartz4/QuartzApi.cs @@ -0,0 +1,16 @@ +using Quartz; + +namespace Sentry.Quartz; + +// The Quartz 4 side of the APIs that differ between Quartz 3 and 4 +internal static class QuartzApi +{ + public static Type GetJobType(IJobDetail jobDetail) => jobDetail.JobType.Type; + + public static async Task HasSeveralTriggersAsync(IJobExecutionContext context, CancellationToken cancellationToken) + { + var query = new TriggerQuery { Job = context.JobDetail.Key, Take = 2 }; + var triggers = await context.Scheduler.QueryTriggers(query, cancellationToken).ConfigureAwait(false); + return triggers.Items.Count > 1; + } +} diff --git a/src/Sentry.Quartz4/Sentry.Quartz4.csproj b/src/Sentry.Quartz4/Sentry.Quartz4.csproj new file mode 100644 index 0000000000..38c730ad3c --- /dev/null +++ b/src/Sentry.Quartz4/Sentry.Quartz4.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 + Sentry.Quartz + + + + true + + + + + + + + + + + + diff --git a/src/Sentry.Quartz4/SentryCronJobMiddleware.cs b/src/Sentry.Quartz4/SentryCronJobMiddleware.cs new file mode 100644 index 0000000000..e0434ecd48 --- /dev/null +++ b/src/Sentry.Quartz4/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.Quartz4/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz4/SentryQuartzBuilderExtensions.cs new file mode 100644 index 0000000000..02f1735b46 --- /dev/null +++ b/src/Sentry.Quartz4/SentryQuartzBuilderExtensions.cs @@ -0,0 +1,43 @@ +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, and exceptions it throws are + /// captured in that scope. + /// + /// + /// Jobs with also send a check-in each time they run, with the + /// schedule of the trigger that fired them as the monitor config. 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))); + return builder; + } + } +} diff --git a/src/Sentry.Quartz4/SentryScopeMiddleware.cs b/src/Sentry.Quartz4/SentryScopeMiddleware.cs new file mode 100644 index 0000000000..d4439c19e5 --- /dev/null +++ b/src/Sentry.Quartz4/SentryScopeMiddleware.cs @@ -0,0 +1,32 @@ +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) : IJobExecutionMiddleware +{ + internal const string JobTag = "quartz.job"; + internal const string MechanismType = "Quartz"; + + public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken) + { + using var _ = hub.PushScope(); + hub.ConfigureScope(static (scope, context) => + { + scope.SetPropagationContext(new SentryPropagationContext()); + scope.SetTag(JobTag, context.JobDetail.Key.ToString()); + }, context); + + try + { + await next(context, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + // Quartz catches it, so it doesn't end the process + e.SetSentryMechanism(MechanismType, handled: false, terminal: false); + hub.CaptureException(e); + throw; + } + } +} diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index e7272f2987..0dcdddbbd6 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -174,7 +174,8 @@ - + + diff --git a/src/Sentry/SentryMonitorOptions.cs b/src/Sentry/SentryMonitorOptions.cs index 8d566beedc..08bd942934 100644 --- a/src/Sentry/SentryMonitorOptions.cs +++ b/src/Sentry/SentryMonitorOptions.cs @@ -74,6 +74,8 @@ public partial class SentryMonitorOptions : ISentryJsonSerializable private int? _interval; private SentryMonitorInterval? _unit; + internal bool HasSchedule => _type is not SentryMonitorScheduleType.None; + #if NET9_0_OR_GREATER [GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)] private static partial Regex ValidCrontab { get; } diff --git a/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs deleted file mode 100644 index cfa15fd7f5..0000000000 --- a/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Quartz; - -namespace Sentry.Quartz.Tests; - -/// -/// Shared helpers for building the Quartz middleware pipeline types (, -/// , ) that the Sentry.Quartz middlewares operate on, -/// without needing a real Quartz scheduler. -/// -internal static class MiddlewareTestHelpers -{ - public static IJobExecutionContext CreateContext(IJob job, ITrigger? trigger = null, IJobDetail? jobDetail = null) - { - trigger ??= Substitute.For(); - jobDetail ??= Substitute.For(); - jobDetail.JobDataMap.Returns(new JobDataMap()); - - var context = Substitute.For(); - context.JobInstance.Returns(job); - context.Trigger.Returns(trigger); - context.JobDetail.Returns(jobDetail); - return context; - } - - public static ICronTrigger CreateCronTrigger(string cronExpressionString = "0 0 12 * * ?", TimeZoneInfo? timeZone = null) - { - var trigger = Substitute.For(); - trigger.CronExpressionString.Returns(cronExpressionString); - trigger.TimeZone.Returns(timeZone ?? TimeZoneInfo.Utc); - return trigger; - } - - /// - /// A that either completes successfully, or throws/faults with the given - /// exception, mimicking the next delegate in the Quartz job execution middleware pipeline. - /// - public static JobExecutionDelegate Next(Exception? throwException = null) => - (_, _) => throwException is null ? default : ValueTask.FromException(throwException); -} - -internal sealed class PlainJob : IJob -{ - public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) => default; -} - -[SentryCronMonitorSlug] -internal sealed class MonitoredJob : IJob -{ - public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) => default; -} - -[SentryCronMonitorSlug("custom-slug")] -internal sealed class CustomSlugJob : IJob -{ - public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) => default; -} diff --git a/test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs deleted file mode 100644 index 9f9f7ad5d3..0000000000 --- a/test/Sentry.Quartz.Tests/QuartzBuilderExtensionsTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Quartz; - -namespace Sentry.Quartz.Tests; - -/// -/// End-to-end tests that run a real, in-memory Quartz scheduler wired up through , -/// to verify the middlewares are registered and invoked correctly - as opposed to the other test classes in this -/// project, which exercise each middleware directly. -/// -public class QuartzBuilderExtensionsTests -{ - [Fact] - public async Task AddSentryCronJobs_JobWithMonitorSlug_CapturesCheckInsAroundExecution() - { - var hub = Substitute.For(); - var startId = SentryId.Create(); - hub.CaptureCheckIn(nameof(TrackingJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()) - .Returns(startId); - - using var jobRan = new SemaphoreSlim(0, 1); - TrackingJob.Signal = jobRan; - - var services = new ServiceCollection(); - services.AddQuartz(quartz => - { - quartz.UseInMemoryStore(); - quartz.AddSentryCronJobs(Options.Create(new SentryCronJobOptions()), hub, NullLogger.Instance); - - var jobKey = new JobKey(nameof(TrackingJob)); - quartz.AddJob(opts => opts.WithIdentity(jobKey)); - quartz.AddTrigger(opts => opts.ForJob(jobKey).WithCronSchedule("* * * * * ?")); - }); - - await using var provider = services.BuildServiceProvider(); - var scheduler = await provider.GetRequiredService().GetScheduler(); - await scheduler.Start(); - try - { - var jobExecuted = await jobRan.WaitAsync(TimeSpan.FromSeconds(15)); - jobExecuted.Should().BeTrue("the job should have run within the timeout"); - } - finally - { - await scheduler.Shutdown(waitForJobsToComplete: true); - } - - hub.Received().CaptureCheckIn(nameof(TrackingJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); - hub.Received().CaptureCheckIn(nameof(TrackingJob), CheckInStatus.Ok, startId); - } - - [Fact] - public async Task AddSentryCronJobs_JobWithoutMonitorSlug_NeverCapturesCheckIns() - { - var hub = Substitute.For(); - - using var jobRan = new SemaphoreSlim(0, 1); - UnmonitoredTrackingJob.Signal = jobRan; - - var services = new ServiceCollection(); - services.AddQuartz(quartz => - { - quartz.UseInMemoryStore(); - quartz.AddSentryCronJobs(Options.Create(new SentryCronJobOptions()), hub, NullLogger.Instance); - - var jobKey = new JobKey(nameof(UnmonitoredTrackingJob)); - quartz.AddJob(opts => opts.WithIdentity(jobKey)); - quartz.AddTrigger(opts => opts.ForJob(jobKey).WithCronSchedule("* * * * * ?")); - }); - - await using var provider = services.BuildServiceProvider(); - var scheduler = await provider.GetRequiredService().GetScheduler(); - await scheduler.Start(); - try - { - var jobExecuted = await jobRan.WaitAsync(TimeSpan.FromSeconds(15)); - jobExecuted.Should().BeTrue("the job should have run within the timeout"); - } - finally - { - await scheduler.Shutdown(waitForJobsToComplete: true); - } - - hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); - } - - [SentryCronMonitorSlug] - private sealed class TrackingJob : IJob - { - public static SemaphoreSlim? Signal { get; set; } - - public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) - { - Signal?.Release(); - return default; - } - } - - private sealed class UnmonitoredTrackingJob : 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/RecordingLogger.cs b/test/Sentry.Quartz.Tests/RecordingLogger.cs deleted file mode 100644 index 0e1ba47959..0000000000 --- a/test/Sentry.Quartz.Tests/RecordingLogger.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Sentry.Quartz.Tests; - -/// -/// A minimal that records every log entry so tests can assert on the -/// messages produced by the source-generated LoggerMessage methods in . -/// -internal sealed class RecordingLogger : ILogger -{ - public List<(LogLevel Level, EventId EventId, string Message, Exception? Exception)> Entries { get; } = []; - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - Entries.Add((logLevel, eventId, formatter(state, exception), exception)); - } -} diff --git a/test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs b/test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs deleted file mode 100644 index 6ed38d0c4f..0000000000 --- a/test/Sentry.Quartz.Tests/RegionInfoExtensionsTests.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Sentry.Quartz.Tests; - -public class RegionInfoExtensionsTests -{ - [Fact] - public void GetCurrentRegionOrNull_DoesNotThrow_AndMatchesCurrentRegion() - { - var result = RegionInfo.GetCurrentRegionOrNull(); - - result.Should().NotBeNull(); - result!.TwoLetterISORegionName.Should().Be(RegionInfo.CurrentRegion.TwoLetterISORegionName); - } -} diff --git a/test/Sentry.Quartz.Tests/SentryCronInformationTests.cs b/test/Sentry.Quartz.Tests/SentryCronInformationTests.cs deleted file mode 100644 index 3245811087..0000000000 --- a/test/Sentry.Quartz.Tests/SentryCronInformationTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Sentry.Quartz.Tests; - -public class SentryCronInformationTests -{ - [Fact] - public void Ctor_JobWithoutAttribute_DoesNotWriteStatus_AndUsesTypeNameAsSlug() - { - var info = new SentryCronInformation(new PlainJob()); - - info.ShouldWriteStatusToSentry.Should().BeFalse(); - info.MonitorSlug.Should().Be(nameof(PlainJob)); - } - - [Fact] - public void Ctor_JobWithAttributeWithoutSlug_WritesStatus_AndUsesTypeNameAsSlug() - { - var info = new SentryCronInformation(new MonitoredJob()); - - info.ShouldWriteStatusToSentry.Should().BeTrue(); - info.MonitorSlug.Should().Be(nameof(MonitoredJob)); - } - - [Fact] - public void Ctor_JobWithAttributeAndSlug_WritesStatus_AndUsesProvidedSlug() - { - var info = new SentryCronInformation(new CustomSlugJob()); - - info.ShouldWriteStatusToSentry.Should().BeTrue(); - info.MonitorSlug.Should().Be("custom-slug"); - } - - [Fact] - public void WarningShownForSecondsParameterIssue_DefaultsToFalse() - { - var info = new SentryCronInformation(new MonitoredJob()); - - info.WarningShownForSecondsParameterIssue.Should().BeFalse(); - } -} diff --git a/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs deleted file mode 100644 index fac7ba104f..0000000000 --- a/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs +++ /dev/null @@ -1,257 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Quartz; - -namespace Sentry.Quartz.Tests; - -public class SentryCronJobMiddlewareTests -{ - private readonly IHub _hub = Substitute.For(); - private readonly RecordingLogger _logger = new(); - private readonly SentryCronJobOptions _options = new(); - - private SentryCronJobMiddleware CreateSut() => - new(_hub, Options.Create(_options), _logger); - - [Fact] - public async Task Invoke_TriggerIsNotCronTrigger_DoesNotCaptureCheckIn() - { - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), Substitute.For()); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); - } - - [Fact] - public async Task Invoke_CronTrigger_JobWithoutAttribute_DoesNotCaptureCheckIn() - { - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new PlainJob(), MiddlewareTestHelpers.CreateCronTrigger()); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); - } - - [Fact] - public async Task Invoke_CronTrigger_JobWithAttribute_JobSucceeds_CapturesInProgressThenOkWithSameId() - { - var startId = SentryId.Create(); - _hub.CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()) - .Returns(startId); - - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); - _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.Ok, startId); - } - - [Fact] - public async Task Invoke_CronTrigger_JobWithAttribute_JobThrows_CapturesInProgressThenErrorAndRethrows() - { - var startId = SentryId.Create(); - _hub.CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()) - .Returns(startId); - - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); - var exception = new InvalidOperationException("job failed"); - - var act = () => sut.Invoke(context, MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); - - (await act.Should().ThrowAsync()).Which.Should().BeSameAs(exception); - _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); - _hub.Received(1).CaptureCheckIn(nameof(MonitoredJob), CheckInStatus.Error, startId); - } - - [Fact] - public async Task Invoke_CronTrigger_CustomMonitorSlug_UsesSlugFromAttribute() - { - _hub.CaptureCheckIn(Arg.Any(), Arg.Any(), configureMonitorOptions: Arg.Any>()) - .Returns(SentryId.Create()); - - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new CustomSlugJob(), MiddlewareTestHelpers.CreateCronTrigger()); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _hub.Received(1).CaptureCheckIn("custom-slug", CheckInStatus.InProgress, configureMonitorOptions: Arg.Any>()); - } - - [Fact] - public async Task Invoke_EnableUpsertCronMonitorFalse_DoesNotConfigureScheduleOrTimeZone_AndSkipsUserCallback() - { - _options.EnableUpsertCronMonitor = false; - var callbackInvoked = false; - _options.ConfigureSentryMonitorOptions = (_, _) => callbackInvoked = true; - - var configureCallback = CaptureConfigureCallback(); - - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - var monitorOptions = new SentryMonitorOptions(); - configureCallback().Invoke(monitorOptions); - - callbackInvoked.Should().BeFalse(); - monitorOptions.TimeZone.Should().BeNull(); - // Interval was never set, so setting it now should succeed rather than throw "set twice". - monitorOptions.Invoking(o => o.Interval("* * * * *")).Should().NotThrow(); - } - - [Fact] - public async Task Invoke_EnableUpsertCronMonitorTrue_SetsScheduleAndUtcTimeZone() - { - var configureCallback = CaptureConfigureCallback(); - - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext( - new MonitoredJob(), - MiddlewareTestHelpers.CreateCronTrigger("0 30 9 * * ?", TimeZoneInfo.Utc)); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - var monitorOptions = new SentryMonitorOptions(); - configureCallback().Invoke(monitorOptions); - - monitorOptions.TimeZone.Should().Be("Etc/UTC"); - using var json = monitorOptions.ToJsonDocument(); - var schedule = json.RootElement.GetProperty("monitor_config").GetProperty("schedule"); - schedule.GetProperty("type").GetString().Should().Be("crontab"); - schedule.GetProperty("value").GetString().Should().Be("30 9 * * *"); - } - - [Fact] - public async Task Invoke_TimeZoneIsAlreadyIana_UsesItAsIs() - { - var configureCallback = CaptureConfigureCallback(); - var ianaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); - - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext( - new MonitoredJob(), - MiddlewareTestHelpers.CreateCronTrigger(timeZone: ianaTimeZone)); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - var monitorOptions = new SentryMonitorOptions(); - configureCallback().Invoke(monitorOptions); - - monitorOptions.TimeZone.Should().Be("America/New_York"); - } - - [Fact] - public async Task Invoke_UserConfigureCallback_RunsAfterAutomaticConfiguration_AndCanOverrideIt() - { - _options.ConfigureSentryMonitorOptions = (_, options) => - { - options.TimeZone = "custom/zone"; - options.FailureIssueThreshold = 7; - }; - var configureCallback = CaptureConfigureCallback(); - - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger()); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - var monitorOptions = new SentryMonitorOptions(); - configureCallback().Invoke(monitorOptions); - - monitorOptions.TimeZone.Should().Be("custom/zone"); - monitorOptions.FailureIssueThreshold.Should().Be(7); - } - - [Fact] - public async Task Invoke_UserConfigureCallback_ReceivesTheExecutingJobDetail() - { - IJobDetail? receivedJobDetail = null; - _options.ConfigureSentryMonitorOptions = (jobDetail, _) => receivedJobDetail = jobDetail; - var configureCallback = CaptureConfigureCallback(); - - var jobDetail = Substitute.For(); - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger(), jobDetail); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - configureCallback().Invoke(new SentryMonitorOptions()); - - receivedJobDetail.Should().BeSameAs(jobDetail); - } - - [Fact] - public async Task Invoke_CronExpressionWithSecondsField_TrimsSecondsAndWarnsOnce() - { - var configureCallback = CaptureConfigureCallback(); - - var sut = CreateSut(); - - // "30" as the seconds field is not supported by Sentry Cron Monitors and should be trimmed off, - // triggering a granularity warning. - var firstContext = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("30 0 12 * * ?")); - await sut.Invoke(firstContext, MiddlewareTestHelpers.Next(), CancellationToken.None); - - var monitorOptions = new SentryMonitorOptions(); - configureCallback().Invoke(monitorOptions); - using var json = monitorOptions.ToJsonDocument(); - json.RootElement.GetProperty("monitor_config").GetProperty("schedule").GetProperty("value").GetString() - .Should().Be("0 12 * * *"); - - // Invoking the middleware again for the same job type should not warn a second time. - var secondContext = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("30 0 12 * * ?")); - await sut.Invoke(secondContext, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Warning); - } - - [Fact] - public async Task Invoke_CronExpressionWithZeroSecondsField_DoesNotWarn() - { - var sut = CreateSut(); - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("0 0 12 * * ?")); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _logger.Entries.Should().NotContain(e => e.Level == LogLevel.Warning); - } - - [Fact] - public async Task Invoke_CronExpressionValidForQuartzButNotForSentry_LogsErrorAndDoesNotThrow() - { - var configureCallback = CaptureConfigureCallback(); - - var sut = CreateSut(); - // "5L" (last Friday of the month) is valid Quartz syntax, but isn't supported by Sentry's crontab format. - var context = MiddlewareTestHelpers.CreateContext(new MonitoredJob(), MiddlewareTestHelpers.CreateCronTrigger("0 0 * * 5L")); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - var monitorOptions = new SentryMonitorOptions(); - var act = () => configureCallback().Invoke(monitorOptions); - - act.Should().NotThrow(); - _logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error); - // The schedule was never successfully applied, so setting it now should succeed rather than throw "set twice". - monitorOptions.Invoking(o => o.Interval("* * * * *")).Should().NotThrow(); - } - - /// - /// Captures the configureMonitorOptions callback that passes to - /// , so its effect on a real instance can be - /// asserted on directly. - /// - private Func> CaptureConfigureCallback() - { - Action? captured = null; - _hub.CaptureCheckIn(Arg.Any(), Arg.Any(), configureMonitorOptions: Arg.Do>(callback => captured = callback)) - .Returns(SentryId.Create()); - return () => captured ?? throw new InvalidOperationException("configureMonitorOptions was not captured; CaptureCheckIn was not called."); - } -} diff --git a/test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs deleted file mode 100644 index 1c16dd9ffc..0000000000 --- a/test/Sentry.Quartz.Tests/SentryMetricsMiddlewareTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Microsoft.Extensions.Options; -using Quartz; - -namespace Sentry.Quartz.Tests; - -public class SentryMetricsMiddlewareTests -{ - private readonly IHub _hub = Substitute.For(); - private readonly InMemorySentryMetricEmitter _metrics = new(); - private readonly SentryMetricsOptions _options = new(); - - public SentryMetricsMiddlewareTests() => _hub.Metrics.Returns(_metrics); - - private SentryMetricsMiddleware CreateSut() => new(_hub, Options.Create(_options)); - - private static IJobExecutionContext CreateContext(JobDataMap? jobDataMap = null) - { - var jobDetail = Substitute.For(); - jobDetail.Key.Returns(new JobKey("MyJob", "MyGroup")); - jobDetail.JobDataMap.Returns(jobDataMap ?? new JobDataMap()); - - var context = Substitute.For(); - context.JobDetail.Returns(jobDetail); - return context; - } - - [Fact] - public async Task Invoke_EmitsDistributionMetric_WithDefaultNameBasedOnJobKey() - { - var sut = CreateSut(); - var context = CreateContext(); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _metrics.Entries.Should().ContainSingle(); - var entry = _metrics.Entries[0]; - entry.Type.Should().Be(SentryMetricType.Distribution); - entry.Name.Should().Be("quartz.job.duration.MyGroup.MyJob"); - entry.Unit.Should().Be("millisecond"); - } - - [Fact] - public async Task Invoke_LogMetricsExplicitlyFalse_DoesNotEmitMetric() - { - var jobDataMap = new JobDataMap { { "LogMetrics", false } }; - var sut = CreateSut(); - var context = CreateContext(jobDataMap); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _metrics.Entries.Should().BeEmpty(); - } - - [Fact] - public async Task Invoke_LogMetricsExplicitlyTrue_EmitsMetric() - { - var jobDataMap = new JobDataMap { { "LogMetrics", true } }; - var sut = CreateSut(); - var context = CreateContext(jobDataMap); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _metrics.Entries.Should().ContainSingle(); - } - - [Fact] - public async Task Invoke_CustomResolveMetricsName_UsesResolvedName() - { - _options.ResolveMetricsName = jobDetail => $"custom.{jobDetail.Key.Name}"; - var sut = CreateSut(); - var context = CreateContext(); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _metrics.Entries.Should().ContainSingle(e => e.Name == "custom.MyJob"); - } - - [Fact] - public async Task Invoke_AdditionalAttributes_AreAttachedToTheMetric() - { - _options.AdditionalAttributes = _ => new Dictionary { ["foo"] = "bar" }; - var sut = CreateSut(); - var context = CreateContext(); - - await sut.Invoke(context, MiddlewareTestHelpers.Next(), CancellationToken.None); - - _metrics.Entries.Should().ContainSingle(); - _metrics.Entries[0].Attributes.Should().ContainKey("foo").WhoseValue.Should().Be("bar"); - } - - [Fact] - public async Task Invoke_JobThrows_StillEmitsMetric_AndRethrows() - { - var sut = CreateSut(); - var context = CreateContext(); - var exception = new InvalidOperationException("boom"); - - var act = () => sut.Invoke(context, MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); - - await act.Should().ThrowAsync(); - _metrics.Entries.Should().ContainSingle(); - } -} diff --git a/test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs b/test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs deleted file mode 100644 index 3ff9c6fe58..0000000000 --- a/test/Sentry.Quartz.Tests/SentryMonitorOptionsExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Sentry.Quartz.Tests; - -/// -/// Serializes a to JSON so its private schedule/crontab state - only observable -/// through - can be asserted on in tests. -/// -internal static class SentryMonitorOptionsExtensions -{ - public static JsonDocument ToJsonDocument(this SentryMonitorOptions options) - { - // WriteTo starts by writing the "monitor_config" property, so - like when a check-in is serialized - it - // needs to be called from within an already-open JSON object. - using var buffer = new MemoryStream(); - using (var writer = new Utf8JsonWriter(buffer)) - { - writer.WriteStartObject(); - options.WriteTo(writer, logger: null); - writer.WriteEndObject(); - } - - buffer.Seek(0, SeekOrigin.Begin); - return JsonDocument.Parse(buffer); - } -} diff --git a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs deleted file mode 100644 index 0786a76600..0000000000 --- a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Quartz; - -namespace Sentry.Quartz.Tests; - -public class SentryScopeMiddlewareTests -{ - [Fact] - public async Task Invoke_PushesScopeBeforeNext_AndDisposesItAfter() - { - var events = new List(); - var hub = Substitute.For(); - var scope = Substitute.For(); - scope.When(s => s.Dispose()).Do(_ => events.Add("dispose")); - hub.PushScope().Returns(_ => - { - events.Add("push"); - return scope; - }); - - var sut = new SentryScopeMiddleware(hub); - var context = Substitute.For(); - JobExecutionDelegate next = (_, _) => - { - events.Add("next"); - return default; - }; - - await sut.Invoke(context, next, CancellationToken.None); - - events.Should().Equal("push", "next", "dispose"); - } - - [Fact] - public async Task Invoke_JobThrows_StillDisposesScope_AndRethrows() - { - var hub = Substitute.For(); - var scope = Substitute.For(); - hub.PushScope().Returns(scope); - - var sut = new SentryScopeMiddleware(hub); - var context = Substitute.For(); - var exception = new InvalidOperationException("boom"); - - var act = () => sut.Invoke(context, MiddlewareTestHelpers.Next(exception), CancellationToken.None).AsTask(); - - await act.Should().ThrowAsync(); - scope.Received(1).Dispose(); - } -} diff --git a/test/Sentry.Quartz4.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Quartz4.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt new file mode 100644 index 0000000000..753ee53396 --- /dev/null +++ b/test/Sentry.Quartz4.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -0,0 +1,24 @@ +namespace Sentry.Quartz +{ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class SentryCronMonitorSlugAttribute : System.Attribute + { + public const string JobDataKey = "SentryMonitorSlug"; + public SentryCronMonitorSlugAttribute(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.Quartz4.Tests/ApiApprovalTests.verify.cs b/test/Sentry.Quartz4.Tests/ApiApprovalTests.verify.cs new file mode 100644 index 0000000000..dfdfb65341 --- /dev/null +++ b/test/Sentry.Quartz4.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.Quartz4.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz4.Tests/Core/JobMonitorTests.cs new file mode 100644 index 0000000000..260df07d9b --- /dev/null +++ b/test/Sentry.Quartz4.Tests/Core/JobMonitorTests.cs @@ -0,0 +1,283 @@ +namespace Sentry.Quartz.Tests; + +public class JobMonitorTests +{ + private class Fixture + { + public IHub Hub { get; } = Substitute.For(); + public IScheduler Scheduler { get; } = Substitute.For(); + public SentryId CheckInId { get; } = SentryId.Create(); + public SentryQuartzOptions Options { get; } = new(); + public int TriggerCount { get; set; } = 1; + + 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); + return new JobMonitor(Options, Hub); + } + + public IJobExecutionContext GetContext() + { + var mergedJobDataMap = new JobDataMap(); + foreach (var entry in JobDetail.JobDataMap.Concat(Trigger.JobDataMap)) + { + mergedJobDataMap[entry.Key] = entry.Value; + } + + Scheduler.ReturnsTriggers(JobDetail.Key, Enumerable.Repeat(Trigger, TriggerCount).ToList()); + + 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().Be(_fixture.CheckInId); + 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_JobHasSeveralTriggers_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.TriggerCount = 2; + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_TriggerSetsMonitorSlug_CapturesCheckInWithThatTriggersMonitorConfig() + { + // Arrange + _fixture.TriggerCount = 2; + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .UsingJobData(SentryCronMonitorSlugAttribute.JobDataKey, "daily-email-noon") + .Build(); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("daily-email-noon").Should().NotBeNull(); + _fixture.Scheduler.DidNotLookUpTriggers(); + } + + [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 + IJobDetail? receivedJobDetail = null; + _fixture.Options.ConfigureMonitorOptions = (jobDetail, options) => + { + receivedJobDetail = jobDetail; + options.FailureIssueThreshold = 3; + options.TimeZone = "Europe/Vienna"; + }; + + // Act + await _fixture.StartAsync(); + + // Assert + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + receivedJobDetail.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 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")] + 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(SentryCronMonitorSlugAttribute.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); + } +} diff --git a/test/Sentry.Quartz4.Tests/Core/MonitorConfigJson.cs b/test/Sentry.Quartz4.Tests/Core/MonitorConfigJson.cs new file mode 100644 index 0000000000..03ad8fd613 --- /dev/null +++ b/test/Sentry.Quartz4.Tests/Core/MonitorConfigJson.cs @@ -0,0 +1,28 @@ +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.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IHub.CaptureCheckIn)) + .Select(call => call.GetArguments()) + .Where(args => (string?)args[0] == monitorSlug && (CheckInStatus?)args[1] == CheckInStatus.InProgress) + .Select(args => args[5] as Action) + .Single(); +} diff --git a/test/Sentry.Quartz4.Tests/Core/TriggerScheduleTests.cs b/test/Sentry.Quartz4.Tests/Core/TriggerScheduleTests.cs new file mode 100644 index 0000000000..d22b9524fa --- /dev/null +++ b/test/Sentry.Quartz4.Tests/Core/TriggerScheduleTests.cs @@ -0,0 +1,172 @@ +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 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 ToMonitorConfig_CronTrigger_ReturnsCrontabAndTimeZone() + { + var trigger = TriggerBuilder.Create() + .WithCronSchedule("0 30 6 ? * MON-FRI", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + var monitorConfig = MonitorConfigJson.Render(TriggerSchedule.ToMonitorConfig(trigger)!); + + 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 ToMonitorConfig_SimpleTrigger_ReturnsInterval() + { + var trigger = TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromHours(2)).RepeatForever()) + .Build(); + + var monitorConfig = MonitorConfigJson.Render(TriggerSchedule.ToMonitorConfig(trigger)!); + + 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 ToMonitorConfig_TriggerWithCalendar_ReturnsNull() + { + var trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .WithCalendar("holidays") + .Build(); + + TriggerSchedule.ToMonitorConfig(trigger).Should().BeNull(); + } + + [Fact] + public void ToMonitorConfig_CalendarIntervalTrigger_ReturnsNull() + { + var trigger = TriggerBuilder.Create() + .WithCalendarIntervalSchedule(schedule => schedule.WithInterval(1, IntervalUnit.Day)) + .Build(); + + TriggerSchedule.ToMonitorConfig(trigger).Should().BeNull(); + } +} diff --git a/test/Sentry.Quartz4.Tests/MiddlewareTestHelpers.cs b/test/Sentry.Quartz4.Tests/MiddlewareTestHelpers.cs new file mode 100644 index 0000000000..c930b0d0d5 --- /dev/null +++ b/test/Sentry.Quartz4.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.Key, [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.Quartz4.Tests/QuartzTestApi.cs b/test/Sentry.Quartz4.Tests/QuartzTestApi.cs new file mode 100644 index 0000000000..971afc6021 --- /dev/null +++ b/test/Sentry.Quartz4.Tests/QuartzTestApi.cs @@ -0,0 +1,46 @@ +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, JobKey jobKey, IReadOnlyList triggers) + { + var headers = triggers + .Select(t => new TriggerHeader(t.Key, jobKey, null, "CRON", TriggerState.Normal, t.StartTimeUtc, + null, null, null, null, t.Priority, null, null, 0)) + .ToList(); + scheduler.QueryTriggers(Arg.Is(q => q.Job == jobKey), Arg.Any()) + .Returns(new PagedResult(headers, HasMore: false)); + } + + 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); +} + +internal class NoOpJob : IJob +{ + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => default; +} + +[SentryCronMonitorSlug] +internal class MonitoredJob : NoOpJob; + +[SentryCronMonitorSlug("custom-slug")] +internal class CustomSlugJob : NoOpJob; + +[SentryCronMonitorSlug(SendMonitorConfig = false)] +internal class NoMonitorConfigJob : NoOpJob; + +[SentryCronMonitorSlug] +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.Quartz4.Tests/Sentry.Quartz4.Tests.csproj similarity index 52% rename from test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj rename to test/Sentry.Quartz4.Tests/Sentry.Quartz4.Tests.csproj index 4e63276fb2..8bca2a1bd8 100644 --- a/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj +++ b/test/Sentry.Quartz4.Tests/Sentry.Quartz4.Tests.csproj @@ -1,18 +1,22 @@ - + net10.0 enable + Sentry.Quartz.Tests - + + + + - + diff --git a/test/Sentry.Quartz4.Tests/SentryCronJobMiddlewareTests.cs b/test/Sentry.Quartz4.Tests/SentryCronJobMiddlewareTests.cs new file mode 100644 index 0000000000..5608d83741 --- /dev/null +++ b/test/Sentry.Quartz4.Tests/SentryCronJobMiddlewareTests.cs @@ -0,0 +1,91 @@ +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, _checkInId, 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, _checkInId, 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, _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.Quartz4.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz4.Tests/SentryQuartzBuilderExtensionsTests.cs new file mode 100644 index 0000000000..cc24c216aa --- /dev/null +++ b/test/Sentry.Quartz4.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -0,0 +1,136 @@ +using System.Collections.Specialized; +using Microsoft.Extensions.DependencyInjection; + +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, _checkInId, 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_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) where TJob : IJob + { + var scheduler = await BuildScheduler(name, group, configureTrigger); + 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) where TJob : IJob + { + var services = new ServiceCollection(); + 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 UnmonitoredSignallingJob : IJob + { + public static SemaphoreSlim? Signal { get; set; } + + public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) + { + Signal?.Release(); + return default; + } + } +} diff --git a/test/Sentry.Quartz4.Tests/SentryScopeMiddlewareTests.cs b/test/Sentry.Quartz4.Tests/SentryScopeMiddlewareTests.cs new file mode 100644 index 0000000000..55f3b353eb --- /dev/null +++ b/test/Sentry.Quartz4.Tests/SentryScopeMiddlewareTests.cs @@ -0,0 +1,77 @@ +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()); + } +} diff --git a/test/Sentry.Testing/TestEnvironment.cs b/test/Sentry.Testing/TestEnvironment.cs index e70c571fd8..42e426863d 100644 --- a/test/Sentry.Testing/TestEnvironment.cs +++ b/test/Sentry.Testing/TestEnvironment.cs @@ -13,4 +13,20 @@ public static bool IsGitHubActions return isGitHubActions?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; } } + + /// + /// Some containers (e.g. Alpine) ship without a time zone database. + /// + public static bool HasTimeZone(string id) + { + try + { + _ = TimeZoneInfo.FindSystemTimeZoneById(id); + return true; + } + catch (Exception e) when (e is TimeZoneNotFoundException or InvalidTimeZoneException) + { + return false; + } + } } From fd4beb41a77adace43beb84267d3ce86fa60db02 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 6 Oct 2026 09:47:07 -0700 Subject: [PATCH 35/53] ref(quartz): Rename Sentry.Quartz4 to Sentry.Quartz Sentry.Quartz3 keeps its name for Quartz.NET 3.x. Co-Authored-By: Claude --- .craft.yml | 6 +++--- .generated.NoMobile.slnx | 4 ++-- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- README.md | 2 +- Sentry-CI-Build-Linux-NoMobile.slnf | 4 ++-- Sentry-CI-Build-Linux.slnf | 4 ++-- Sentry-CI-Build-Windows-arm64.slnf | 4 ++-- Sentry-CI-Build-Windows.slnf | 4 ++-- Sentry-CI-Build-macOS.slnf | 4 ++-- Sentry-CI-CodeQL.slnf | 2 +- Sentry.slnx | 4 ++-- SentryAspNetCore.slnf | 2 +- SentryNoMobile.slnf | 4 ++-- SentryNoSamples.slnf | 4 ++-- samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj | 2 +- scripts/generate-solution-filters-config.yaml | 2 +- src/{Sentry.Quartz4 => Sentry.Quartz}/Core/JobMonitor.cs | 0 .../Core/SentryCronMonitorSlugAttribute.cs | 0 .../Core/SentryQuartzOptions.cs | 0 .../Core/TriggerSchedule.cs | 0 src/{Sentry.Quartz4 => Sentry.Quartz}/QuartzApi.cs | 0 .../Sentry.Quartz.csproj} | 3 +-- .../SentryCronJobMiddleware.cs | 0 .../SentryQuartzBuilderExtensions.cs | 0 .../SentryScopeMiddleware.cs | 0 src/Sentry/Sentry.csproj | 4 ++-- .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 0 .../ApiApprovalTests.verify.cs | 0 .../Core/JobMonitorTests.cs | 0 .../Core/MonitorConfigJson.cs | 0 .../Core/TriggerScheduleTests.cs | 0 .../MiddlewareTestHelpers.cs | 0 .../QuartzTestApi.cs | 0 .../Sentry.Quartz.Tests.csproj} | 5 ++--- .../SentryCronJobMiddlewareTests.cs | 0 .../SentryQuartzBuilderExtensionsTests.cs | 0 .../SentryScopeMiddlewareTests.cs | 0 37 files changed, 32 insertions(+), 34 deletions(-) rename src/{Sentry.Quartz4 => Sentry.Quartz}/Core/JobMonitor.cs (100%) rename src/{Sentry.Quartz4 => Sentry.Quartz}/Core/SentryCronMonitorSlugAttribute.cs (100%) rename src/{Sentry.Quartz4 => Sentry.Quartz}/Core/SentryQuartzOptions.cs (100%) rename src/{Sentry.Quartz4 => Sentry.Quartz}/Core/TriggerSchedule.cs (100%) rename src/{Sentry.Quartz4 => Sentry.Quartz}/QuartzApi.cs (100%) rename src/{Sentry.Quartz4/Sentry.Quartz4.csproj => Sentry.Quartz/Sentry.Quartz.csproj} (83%) rename src/{Sentry.Quartz4 => Sentry.Quartz}/SentryCronJobMiddleware.cs (100%) rename src/{Sentry.Quartz4 => Sentry.Quartz}/SentryQuartzBuilderExtensions.cs (100%) rename src/{Sentry.Quartz4 => Sentry.Quartz}/SentryScopeMiddleware.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/ApiApprovalTests.Run.DotNet10_0.verified.txt (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/ApiApprovalTests.verify.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/Core/JobMonitorTests.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/Core/MonitorConfigJson.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/Core/TriggerScheduleTests.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/MiddlewareTestHelpers.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/QuartzTestApi.cs (100%) rename test/{Sentry.Quartz4.Tests/Sentry.Quartz4.Tests.csproj => Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj} (70%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/SentryCronJobMiddlewareTests.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/SentryQuartzBuilderExtensionsTests.cs (100%) rename test/{Sentry.Quartz4.Tests => Sentry.Quartz.Tests}/SentryScopeMiddlewareTests.cs (100%) diff --git a/.craft.yml b/.craft.yml index 6e4a234570..78c132919c 100644 --- a/.craft.yml +++ b/.craft.yml @@ -87,7 +87,7 @@ targets: name: Sentry.Profiling packageUrl: https://www.nuget.org/packages/Sentry.Profiling mainDocsUrl: https://docs.sentry.io/platforms/dotnet/profiling/ - nuget:Sentry.Quartz4: - name: Sentry.Quartz4 - packageUrl: https://www.nuget.org/packages/Sentry.Quartz4 + 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 15cea968b5..fd72089ae0 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -169,7 +169,7 @@ - + @@ -204,7 +204,7 @@ - + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c6b0d096ff..3e80e2c1f9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,7 +29,7 @@ body: - Sentry.NLog - Sentry.OpenTelemetry - Sentry.Profiling - - Sentry.Quartz4 + - Sentry.Quartz - Sentry.Serilog - Other validations: diff --git a/README.md b/README.md index 23643fbad1..273606efb2 100644 --- a/README.md +++ b/README.md @@ -35,7 +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.Quartz4** | [![Downloads](https://img.shields.io/nuget/dt/Sentry.Quartz4.svg)](https://www.nuget.org/packages/Sentry.Quartz4) | [![NuGet](https://img.shields.io/nuget/v/Sentry.Quartz4.svg)](https://www.nuget.org/packages/Sentry.Quartz4) | [![NuGet](https://img.shields.io/nuget/vpre/Sentry.Quartz4.svg)](https://www.nuget.org/packages/Sentry.Quartz4) | [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dotnet/crons/quartz/) | +| **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 56f27d8bd6..96a8125b7d 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -50,7 +50,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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", @@ -72,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 76da64e4fa..b7a4975621 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -56,7 +56,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 814d91f739..f9b1f924e6 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -58,7 +58,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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", @@ -80,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 031004682e..fccd8ebf8e 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -58,7 +58,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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", @@ -83,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 3cf41a285c..6827a106ce 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -63,7 +63,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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", @@ -89,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 3b38e1bf9b..b7d1fc7400 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -22,7 +22,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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 15cea968b5..fd72089ae0 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -169,7 +169,7 @@ - + @@ -204,7 +204,7 @@ - + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index fdc4e33d3d..f1e754f58c 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -21,7 +21,7 @@ "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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 8436d5864b..76a74169fe 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -52,7 +52,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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", @@ -77,7 +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.Quartz4.Tests\\Sentry.Quartz4.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 2ae61aedad..72d61e8bcf 100644 --- a/SentryNoSamples.slnf +++ b/SentryNoSamples.slnf @@ -24,7 +24,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", - "src\\Sentry.Quartz4\\Sentry.Quartz4.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", @@ -51,7 +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.Quartz4.Tests\\Sentry.Quartz4.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/Sentry.Samples.Quartz.csproj b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj index 24dca70bfb..973a202d29 100644 --- a/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj +++ b/samples/Sentry.Samples.Quartz/Sentry.Samples.Quartz.csproj @@ -20,7 +20,7 @@ - + diff --git a/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index e4765811a3..a1a52653b4 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -167,7 +167,7 @@ filterConfigs: - "**/Sentry.csproj" - "**/Sentry.Analyzers.csproj" - "**/Sentry.Hangfire.csproj" - - "**/Sentry.Quartz4.csproj" + - "**/Sentry.Quartz.csproj" - "**/Sentry.Samples.Hangfire.csproj" - "**/Sentry.Testing.csproj" - "**/Sentry.Testing.CrashableApp.csproj" diff --git a/src/Sentry.Quartz4/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs similarity index 100% rename from src/Sentry.Quartz4/Core/JobMonitor.cs rename to src/Sentry.Quartz/Core/JobMonitor.cs diff --git a/src/Sentry.Quartz4/Core/SentryCronMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs similarity index 100% rename from src/Sentry.Quartz4/Core/SentryCronMonitorSlugAttribute.cs rename to src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs diff --git a/src/Sentry.Quartz4/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs similarity index 100% rename from src/Sentry.Quartz4/Core/SentryQuartzOptions.cs rename to src/Sentry.Quartz/Core/SentryQuartzOptions.cs diff --git a/src/Sentry.Quartz4/Core/TriggerSchedule.cs b/src/Sentry.Quartz/Core/TriggerSchedule.cs similarity index 100% rename from src/Sentry.Quartz4/Core/TriggerSchedule.cs rename to src/Sentry.Quartz/Core/TriggerSchedule.cs diff --git a/src/Sentry.Quartz4/QuartzApi.cs b/src/Sentry.Quartz/QuartzApi.cs similarity index 100% rename from src/Sentry.Quartz4/QuartzApi.cs rename to src/Sentry.Quartz/QuartzApi.cs diff --git a/src/Sentry.Quartz4/Sentry.Quartz4.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj similarity index 83% rename from src/Sentry.Quartz4/Sentry.Quartz4.csproj rename to src/Sentry.Quartz/Sentry.Quartz.csproj index 38c730ad3c..f1ca7aa4ec 100644 --- a/src/Sentry.Quartz4/Sentry.Quartz4.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -5,7 +5,6 @@ $(PackageTags);Quartz;Crons net10.0 - Sentry.Quartz @@ -18,7 +17,7 @@ - + diff --git a/src/Sentry.Quartz4/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs similarity index 100% rename from src/Sentry.Quartz4/SentryCronJobMiddleware.cs rename to src/Sentry.Quartz/SentryCronJobMiddleware.cs diff --git a/src/Sentry.Quartz4/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs similarity index 100% rename from src/Sentry.Quartz4/SentryQuartzBuilderExtensions.cs rename to src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs diff --git a/src/Sentry.Quartz4/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs similarity index 100% rename from src/Sentry.Quartz4/SentryScopeMiddleware.cs rename to src/Sentry.Quartz/SentryScopeMiddleware.cs diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 0dcdddbbd6..3399691c42 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -174,8 +174,8 @@ - - + + diff --git a/test/Sentry.Quartz4.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt similarity index 100% rename from test/Sentry.Quartz4.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt rename to test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt diff --git a/test/Sentry.Quartz4.Tests/ApiApprovalTests.verify.cs b/test/Sentry.Quartz.Tests/ApiApprovalTests.verify.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/ApiApprovalTests.verify.cs rename to test/Sentry.Quartz.Tests/ApiApprovalTests.verify.cs diff --git a/test/Sentry.Quartz4.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/Core/JobMonitorTests.cs rename to test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs diff --git a/test/Sentry.Quartz4.Tests/Core/MonitorConfigJson.cs b/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/Core/MonitorConfigJson.cs rename to test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs diff --git a/test/Sentry.Quartz4.Tests/Core/TriggerScheduleTests.cs b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/Core/TriggerScheduleTests.cs rename to test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs diff --git a/test/Sentry.Quartz4.Tests/MiddlewareTestHelpers.cs b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/MiddlewareTestHelpers.cs rename to test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs diff --git a/test/Sentry.Quartz4.Tests/QuartzTestApi.cs b/test/Sentry.Quartz.Tests/QuartzTestApi.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/QuartzTestApi.cs rename to test/Sentry.Quartz.Tests/QuartzTestApi.cs diff --git a/test/Sentry.Quartz4.Tests/Sentry.Quartz4.Tests.csproj b/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj similarity index 70% rename from test/Sentry.Quartz4.Tests/Sentry.Quartz4.Tests.csproj rename to test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj index 8bca2a1bd8..cbcdb9b33d 100644 --- a/test/Sentry.Quartz4.Tests/Sentry.Quartz4.Tests.csproj +++ b/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj @@ -4,14 +4,13 @@ net10.0 enable - Sentry.Quartz.Tests - + - + diff --git a/test/Sentry.Quartz4.Tests/SentryCronJobMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/SentryCronJobMiddlewareTests.cs rename to test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs diff --git a/test/Sentry.Quartz4.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/SentryQuartzBuilderExtensionsTests.cs rename to test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs diff --git a/test/Sentry.Quartz4.Tests/SentryScopeMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs similarity index 100% rename from test/Sentry.Quartz4.Tests/SentryScopeMiddlewareTests.cs rename to test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs From 739ce979da2f181e14b13b7cebb5f584a96bf2ca Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 8 Oct 2026 13:46:48 -0700 Subject: [PATCH 36/53] 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 --- samples/Sentry.Samples.Quartz/Program.cs | 8 +- src/Sentry.Quartz/Core/JobMonitor.cs | 140 ++++++++++--- .../Core/SentryCronMonitorSlugAttribute.cs | 4 + src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 12 +- src/Sentry.Quartz/Core/TriggerSchedule.cs | 58 ++++- src/Sentry.Quartz/QuartzApi.cs | 3 + .../SentryQuartzBuilderExtensions.cs | 8 +- src/Sentry.Quartz/SentryScopeMiddleware.cs | 24 +-- src/Sentry/SentryMonitorOptions.cs | 15 ++ ...iApprovalTests.Run.DotNet10_0.verified.txt | 2 +- .../Core/JobMonitorTests.cs | 198 +++++++++++++++++- .../Core/MonitorConfigJson.cs | 10 +- .../Core/TriggerScheduleTests.cs | 1 - .../SentryCronJobMiddlewareTests.cs | 21 +- .../SentryQuartzBuilderExtensionsTests.cs | 6 +- .../SentryScopeMiddlewareTests.cs | 27 +-- .../TriggerScheduleQuartz4Tests.cs | 37 ++++ .../Sentry.Tests/SentryMonitorOptionsTests.cs | 46 ++++ 18 files changed, 524 insertions(+), 96 deletions(-) create mode 100644 test/Sentry.Quartz.Tests/TriggerScheduleQuartz4Tests.cs diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs index 64d9637646..d5bd049deb 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -19,13 +19,13 @@ public static void Main(string[] args) builder.Services.AddQuartz(quartz => { - // Runs each job in its own scope and captures its exceptions. Jobs with [SentryCronMonitorSlug] also send - // check-ins, with the trigger's schedule as the monitor config. + // Runs each job in its own scope. Jobs with [SentryCronMonitorSlug] also send check-ins, with the + // trigger's schedule as the monitor config. Exceptions reach Sentry through Quartz's error log. quartz.AddSentry(options => { - options.ConfigureMonitorOptions = (jobDetail, monitorOptions) => + options.ConfigureMonitorOptions = (context, monitorOptions) => { - if (jobDetail.Key.Name == nameof(SecondJob)) + if (context.JobDetail.Key.Name == nameof(SecondJob)) { monitorOptions.FailureIssueThreshold = 10; monitorOptions.RecoveryThreshold = 10; diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index 3f13d6a016..210a0e810f 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -14,6 +14,11 @@ internal sealed class JobMonitor private readonly IHub _hub; private readonly IDiagnosticLogger? _logger; + // The job each slug was first used by: its key, or its type when it has no identity + private readonly ConcurrentDictionary _slugOwners = new(); + private readonly ConcurrentDictionary _sharedSlugs = new(); + private readonly ConcurrentDictionary _jobTypesWithoutIdentity = new(); + public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logger = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -26,6 +31,8 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg // 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); @@ -35,34 +42,80 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg return null; } - var monitorSlug = GetMonitorSlug(context, attribute); + monitorSlug = ResolveMonitorSlug(context, jobType, attribute); if (monitorSlug is null) { - Logger?.LogDebug("Skipping the check-in for job '{0}'. Its key has no characters a monitor slug can use. " + + 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 SentryCronMonitorSlug attribute.", context.JobDetail.Key); return null; } - var configureMonitorOptions = _options.SendMonitorConfig && attribute.SendMonitorConfig + configureMonitorOptions = _options.SendMonitorConfig && attribute.SendMonitorConfig ? await GetMonitorConfigAsync(context, cancellationToken).ConfigureAwait(false) : null; - - var checkInId = _hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress, configureMonitorOptions: configureMonitorOptions); - return new JobCheckIn(monitorSlug, checkInId); } 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) => - _hub.CaptureCheckIn(checkIn.MonitorSlug, failed ? CheckInStatus.Error : CheckInStatus.Ok, checkIn.Id, duration); + 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, CancellationToken cancellationToken) + { + var monitorOptions = new SentryMonitorOptions(); + if (_options.ConfigureMonitorOptions is { } configure) + { + try + { + configure(context, monitorOptions); + } + catch (Exception e) + { + Logger?.LogError(e, "ConfigureMonitorOptions threw for job '{0}'.", context.JobDetail.Key); + } + } + + if (!monitorOptions.HasSchedule + && await GetTriggerScheduleAsync(context, cancellationToken).ConfigureAwait(false) is { } schedule) + { + schedule(monitorOptions); + } + + return monitorOptions.HasSchedule ? monitorOptions.CopyTo : null; + } + + private async Task?> GetTriggerScheduleAsync( + IJobExecutionContext context, + CancellationToken cancellationToken) { var trigger = context.Trigger; try @@ -83,29 +136,7 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) => return null; } - var configure = _options.ConfigureMonitorOptions; - if (configure is null) - { - return schedule; - } - - var jobDetail = context.JobDetail; - return options => - { - try - { - configure(jobDetail, options); - } - catch (Exception e) - { - Logger?.LogError(e, "ConfigureMonitorOptions threw for job '{0}'.", jobDetail.Key); - } - - if (!options.HasSchedule) - { - schedule(options); - } - }; + return schedule; } catch (Exception e) { @@ -114,8 +145,42 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) => } } - internal static string? GetMonitorSlug(IJobExecutionContext context, SentryCronMonitorSlugAttribute attribute) + private string? ResolveMonitorSlug(IJobExecutionContext context, Type jobType, SentryCronMonitorSlugAttribute attribute) + { + var monitorSlug = GetMonitorSlug(context, attribute, out var fromJobType); + if (monitorSlug is null) + { + return null; + } + + if (fromJobType && _jobTypesWithoutIdentity.TryAdd(jobType, 0)) + { + Logger?.LogWarning("Job `{0}` has no identity, so its monitor slug is `{1}`. Give the job an identity " + + "(WithIdentity(...)) or set a slug ([SentryCronMonitorSlug(\"…\")]) to keep the monitor stable.", + jobType.FullName, monitorSlug); + } + + var key = context.JobDetail.Key; + var owner = HasIdentity(key) ? key.ToString() : jobType.FullName ?? jobType.Name; + var firstOwner = _slugOwners.GetOrAdd(monitorSlug, owner); + if (firstOwner != owner && _sharedSlugs.TryAdd(monitorSlug, 0)) + { + Logger?.LogWarning("Jobs '{0}' and '{1}' both use the monitor slug '{2}', so their check-ins go to the same " + + "monitor. Give each job its own identity or slug.", firstOwner, owner, monitorSlug); + } + + return monitorSlug; + } + + internal static string? GetMonitorSlug(IJobExecutionContext context, SentryCronMonitorSlugAttribute attribute) => + GetMonitorSlug(context, attribute, out _); + + private static string? GetMonitorSlug( + IJobExecutionContext context, + SentryCronMonitorSlugAttribute attribute, + out bool fromJobType) { + fromJobType = false; if (context.MergedJobDataMap.TryGetValue(SentryCronMonitorSlugAttribute.JobDataKey, out var value) && value is string dataMapSlug && !string.IsNullOrWhiteSpace(dataMapSlug)) @@ -129,9 +194,18 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) => } var key = context.JobDetail.Key; - return ToSlug(key.Group == JobKey.DefaultGroup ? key.Name : key.Group + "." + key.Name); + if (HasIdentity(key)) + { + return ToSlug(key.Group == JobKey.DefaultGroup ? key.Name : key.Group + "." + key.Name); + } + + fromJobType = true; + return ToSlug(QuartzApi.GetJobType(context.JobDetail).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) { diff --git a/src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs index cd2509a031..5e1e65c553 100644 --- a/src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs +++ b/src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs @@ -13,6 +13,10 @@ namespace Sentry.Quartz; /// 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, and a warning is logged. 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)] diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index b7125b5ce5..740de27d95 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -33,8 +33,14 @@ public class SentryQuartzOptions /// Configures the monitor config of a job, for example its thresholds or . /// /// - /// Called whenever a monitor config is sent, before the trigger's schedule is applied. A schedule or time zone set - /// here is used instead of the trigger's. + /// + /// 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; } + public Action? ConfigureMonitorOptions { get; set; } } diff --git a/src/Sentry.Quartz/Core/TriggerSchedule.cs b/src/Sentry.Quartz/Core/TriggerSchedule.cs index 4ae66683f0..8a7e141489 100644 --- a/src/Sentry.Quartz/Core/TriggerSchedule.cs +++ b/src/Sentry.Quartz/Core/TriggerSchedule.cs @@ -37,7 +37,7 @@ internal static class TriggerSchedule } } - // Quartz has a seconds field, an optional year field, '?' for the unused day field, and numbers days of the week + // 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) { @@ -49,35 +49,72 @@ internal static class TriggerSchedule 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] != "*") - || (fields[3] == "?") == (fields[5] == "?")) + || (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), - fields[3] == "?" ? "*" : ToCrontabField(fields[3], 1, 31), + ToDayField(dayOfMonth, 1, 31, null, 0, bothDaysRestrict), ToCrontabField(fields[4], 1, 12, MonthNames), - fields[5] == "?" ? "*" : ToCrontabField(fields[5], 1, 7, DayNames, offset: -1) + 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) + 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) is not { } term) + if (ToCrontabTerm(terms[i], min, max, names, offset, spellOutStar) is not { } term) { return null; } @@ -93,7 +130,8 @@ internal static class TriggerSchedule int min, int max, string[]? names, - int offset) + int offset, + bool spellOutStar) { string? step = null; var slash = term.IndexOf('/'); @@ -110,7 +148,9 @@ internal static class TriggerSchedule if (term == "*") { - return "*" + step; + return spellOutStar && step is not null + ? Format(min + offset) + "-" + Format(max + offset) + step + : "*" + step; } var dash = term.IndexOf('-'); diff --git a/src/Sentry.Quartz/QuartzApi.cs b/src/Sentry.Quartz/QuartzApi.cs index 15861f4bc4..3f8c37d895 100644 --- a/src/Sentry.Quartz/QuartzApi.cs +++ b/src/Sentry.Quartz/QuartzApi.cs @@ -5,6 +5,9 @@ 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; public static async Task HasSeveralTriggersAsync(IJobExecutionContext context, CancellationToken cancellationToken) diff --git a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs index 02f1735b46..9de5a3ee41 100644 --- a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -15,12 +15,14 @@ public static class SentryQuartzBuilderExtensions /// /// /// - /// Each job runs in its own Sentry scope and trace, tagged with the job's key, and exceptions it throws are - /// captured in that scope. + /// 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 not captured here: Quartz logs them, and Sentry's logging integration + /// captures that log entry. /// /// /// Jobs with also send a check-in each time they run, with the - /// schedule of the trigger that fired them as the monitor config. See . + /// 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. diff --git a/src/Sentry.Quartz/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs index d4439c19e5..2cb2a2e32e 100644 --- a/src/Sentry.Quartz/SentryScopeMiddleware.cs +++ b/src/Sentry.Quartz/SentryScopeMiddleware.cs @@ -2,14 +2,20 @@ 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) : IJobExecutionMiddleware +// Runs each job in its own scope and trace +internal sealed class SentryScopeMiddleware(IHub hub, SentryOptions? options = null) : IJobExecutionMiddleware { internal const string JobTag = "quartz.job"; - internal const string MechanismType = "Quartz"; 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 next(context, cancellationToken).ConfigureAwait(false); + return; + } + using var _ = hub.PushScope(); hub.ConfigureScope(static (scope, context) => { @@ -17,16 +23,6 @@ public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate scope.SetTag(JobTag, context.JobDetail.Key.ToString()); }, context); - try - { - await next(context, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) when (e is not OperationCanceledException || !cancellationToken.IsCancellationRequested) - { - // Quartz catches it, so it doesn't end the process - e.SetSentryMechanism(MechanismType, handled: false, terminal: false); - hub.CaptureException(e); - throw; - } + await next(context, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Sentry/SentryMonitorOptions.cs b/src/Sentry/SentryMonitorOptions.cs index 08bd942934..87e6ea3235 100644 --- a/src/Sentry/SentryMonitorOptions.cs +++ b/src/Sentry/SentryMonitorOptions.cs @@ -183,6 +183,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 index 753ee53396..4619df979a 100644 --- a/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -18,7 +18,7 @@ public class SentryQuartzOptions { public SentryQuartzOptions() { } - public System.Action? ConfigureMonitorOptions { get; set; } + public System.Action? ConfigureMonitorOptions { 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 260df07d9b..5411750802 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -6,7 +6,8 @@ private class Fixture { public IHub Hub { get; } = Substitute.For(); public IScheduler Scheduler { get; } = Substitute.For(); - public SentryId CheckInId { get; } = SentryId.Create(); + public InMemoryDiagnosticLogger Logger { get; } = new(); + public SentryId CheckInId { get; set; } = SentryId.Create(); public SentryQuartzOptions Options { get; } = new(); public int TriggerCount { get; set; } = 1; @@ -21,7 +22,7 @@ private class Fixture public JobMonitor GetSut() { Hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(CheckInId); - return new JobMonitor(Options, Hub); + return new JobMonitor(Options, Hub, Logger); } public IJobExecutionContext GetContext() @@ -55,7 +56,8 @@ public async Task StartAsync_CronTrigger_CapturesInProgressCheckInWithMonitorCon // Assert checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); - checkIn.Id.Should().Be(_fixture.CheckInId); + 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"); @@ -167,10 +169,10 @@ public async Task StartAsync_SchedulerThrows_CapturesCheckInWithoutMonitorConfig public async Task StartAsync_ConfigureMonitorOptions_RunsBeforeTriggerSchedule() { // Arrange - IJobDetail? receivedJobDetail = null; - _fixture.Options.ConfigureMonitorOptions = (jobDetail, options) => + IJobExecutionContext? receivedContext = null; + _fixture.Options.ConfigureMonitorOptions = (context, options) => { - receivedJobDetail = jobDetail; + receivedContext = context; options.FailureIssueThreshold = 3; options.TimeZone = "Europe/Vienna"; }; @@ -180,7 +182,7 @@ public async Task StartAsync_ConfigureMonitorOptions_RunsBeforeTriggerSchedule() // Assert var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); - receivedJobDetail.Should().BeSameAs(_fixture.JobDetail); + 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); @@ -215,6 +217,185 @@ public async Task StartAsync_ConfigureMonitorOptionsThrows_SendsTriggerSchedule( monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 * * *"); } + [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_UsesClassNameAndWarnsOnce() + { + // 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 warning = _fixture.Logger.Entries.Should().ContainSingle(e => e.Level == SentryLevel.Warning).Subject; + string.Format(warning.Message, warning.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 ([SentryCronMonitorSlug(\"…\")]) to keep the monitor stable."); + } + + [Fact] + public async Task StartAsync_TwoJobsWithSameSlug_WarnsOnce() + { + // Arrange + var sut = _fixture.GetSut(); + var contexts = new List(); + foreach (var group in new[] { "a", "b", "b" }) + { + _fixture.JobDetail = JobBuilder.Create().WithIdentity("Job", group).Build(); + contexts.Add(_fixture.GetContext()); + } + + // Act + foreach (var context in contexts) + { + await sut.StartAsync(context, CancellationToken.None); + } + + // Assert + var warning = _fixture.Logger.Entries.Should().ContainSingle(e => e.Level == SentryLevel.Warning).Subject; + warning.Args.Should().Equal("a.Job", "b.Job", "custom-slug"); + } + + [Fact] + public async Task StartAsync_SameJobAgain_DoesNotWarn() + { + // Arrange + var sut = _fixture.GetSut(); + + // Act + await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); + await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); + + // Assert + _fixture.Logger.Entries.Should().NotContain(e => e.Level == SentryLevel.Warning); + } + + [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() { @@ -250,6 +431,9 @@ public void Finish_Succeeded_CapturesOkCheckInWithDuration() [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, diff --git a/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs b/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs index 03ad8fd613..4baf43c582 100644 --- a/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs +++ b/test/Sentry.Quartz.Tests/Core/MonitorConfigJson.cs @@ -19,10 +19,14 @@ public static JsonElement Render(Action configureMonitorOp } 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()) - .Where(args => (string?)args[0] == monitorSlug && (CheckInStatus?)args[1] == CheckInStatus.InProgress) - .Select(args => args[5] as Action) - .Single(); + .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 index d22b9524fa..6ef8153d3b 100644 --- a/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs +++ b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs @@ -43,7 +43,6 @@ public void ToCrontab_SupportedExpression_ReturnsEquivalentCrontab(string cronEx [InlineData("0 0 22-2 * * ?")] [InlineData("0 0 5-5 * * ?")] [InlineData("0 0 12 ? * 7/2")] - [InlineData("0 0 12 * * *")] [InlineData("0 0 12 ? * ?")] [InlineData("0 60 12 * * ?")] [InlineData("0 0 12 ? * 0")] diff --git a/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs index 5608d83741..113d3332c1 100644 --- a/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs +++ b/test/Sentry.Quartz.Tests/SentryCronJobMiddlewareTests.cs @@ -38,7 +38,7 @@ public async Task Invoke_JobSucceeds_CapturesInProgressThenOkCheckIn() // Assert _hub.ReceivedConfigureMonitorOptions("cleanup").Should().NotBeNull(); - _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Ok, _checkInId, Arg.Any()); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Ok, _hub.ReceivedInProgressCheckInId("cleanup"), Arg.Any()); } [Fact] @@ -52,7 +52,7 @@ public async Task Invoke_JobThrows_CapturesErrorCheckInAndRethrows() // Assert (await act.Should().ThrowAsync()).Which.Should().BeSameAs(exception); - _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Error, _checkInId, Arg.Any()); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Error, _hub.ReceivedInProgressCheckInId("cleanup"), Arg.Any()); } [Fact] @@ -65,7 +65,22 @@ public async Task Invoke_JobCancelled_CapturesErrorCheckIn() await GetSut().Invoke(MiddlewareTestHelpers.CreateContext(cancelled), MiddlewareTestHelpers.Next(), cancelled); // Assert - _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Error, _checkInId, Arg.Any()); + _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] diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index cc24c216aa..ee4bbd4d61 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -40,11 +40,11 @@ public async Task AddSentry_CronTriggerJobSucceeds_CapturesCheckInsWithCrontab() 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, _checkInId, Arg.Any()); + _hub.Received(1).CaptureCheckIn("cleanup", CheckInStatus.Ok, _hub.ReceivedInProgressCheckInId("cleanup"), Arg.Any()); } [Fact] - public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalAndException() + public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalButNotException() { // Act var status = await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger @@ -56,7 +56,7 @@ public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalAn 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)); + _hub.DidNotReceive().CaptureEvent(Arg.Any()); } [Fact] diff --git a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs index 55f3b353eb..838d72cbf9 100644 --- a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs +++ b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs @@ -42,7 +42,7 @@ await sut.Invoke(MiddlewareTestHelpers.CreateContext(), (_, _) => } [Fact] - public async Task Invoke_JobThrows_CapturesExceptionInJobScopeAndRethrows() + public async Task Invoke_JobThrows_RethrowsWithoutCapturing() { // Arrange var exception = new InvalidOperationException("Job failed"); @@ -53,25 +53,28 @@ public async Task Invoke_JobThrows_CapturesExceptionInJobScopeAndRethrows() // 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); + _events.Should().Equal("push", "dispose"); + _hub.DidNotReceive().CaptureEvent(Arg.Any()); } [Fact] - public async Task Invoke_JobCancelled_DoesNotCaptureException() + public async Task Invoke_GlobalMode_RunsJobWithoutTouchingScope() { // Arrange - var cancelled = new CancellationToken(canceled: true); - var sut = new SentryScopeMiddleware(_hub); + var traceId = _scope.PropagationContext.TraceId; + var sut = new SentryScopeMiddleware(_hub, new SentryOptions { IsGlobalModeEnabled = true }); // Act - var act = () => sut.Invoke(MiddlewareTestHelpers.CreateContext(cancelled), MiddlewareTestHelpers.Next(new OperationCanceledException(cancelled)), cancelled).AsTask(); + await sut.Invoke(MiddlewareTestHelpers.CreateContext(), (_, _) => + { + _events.Add("next"); + return default; + }, CancellationToken.None); // Assert - await act.Should().ThrowAsync(); - _hub.DidNotReceive().CaptureEvent(Arg.Any()); + _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 fe97276b87ee8773ee40f036f51d7dac5696d1e4 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 8 Oct 2026 15:02:57 -0700 Subject: [PATCH 37/53] 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 --- src/Sentry.Quartz/Core/JobMonitor.cs | 20 ++---- .../Core/JobMonitorTests.cs | 64 +++++++------------ 2 files changed, 29 insertions(+), 55 deletions(-) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index 210a0e810f..b6041b2a57 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -15,9 +15,6 @@ internal sealed class JobMonitor private readonly IDiagnosticLogger? _logger; // The job each slug was first used by: its key, or its type when it has no identity - private readonly ConcurrentDictionary _slugOwners = new(); - private readonly ConcurrentDictionary _sharedSlugs = new(); - private readonly ConcurrentDictionary _jobTypesWithoutIdentity = new(); public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logger = null) { @@ -100,6 +97,8 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) } 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); } } @@ -153,22 +152,13 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) return null; } - if (fromJobType && _jobTypesWithoutIdentity.TryAdd(jobType, 0)) + if (fromJobType) { - Logger?.LogWarning("Job `{0}` has no identity, so its monitor slug is `{1}`. Give the job an identity " + - "(WithIdentity(...)) or set a slug ([SentryCronMonitorSlug(\"…\")]) to keep the monitor stable.", + Logger?.LogDebug("Job `{0}` has no identity, so its monitor slug is `{1}`. Give the job an identity " + + "(WithIdentity(...)) or set a slug ([SentryCronMonitorSlug(\"…\")]) to keep the monitor stable.", jobType.FullName, monitorSlug); } - var key = context.JobDetail.Key; - var owner = HasIdentity(key) ? key.ToString() : jobType.FullName ?? jobType.Name; - var firstOwner = _slugOwners.GetOrAdd(monitorSlug, owner); - if (firstOwner != owner && _sharedSlugs.TryAdd(monitorSlug, 0)) - { - Logger?.LogWarning("Jobs '{0}' and '{1}' both use the monitor slug '{2}', so their check-ins go to the same " + - "monitor. Give each job its own identity or slug.", firstOwner, owner, monitorSlug); - } - return monitorSlug; } diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 5411750802..5415035320 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -217,6 +217,26 @@ public async Task StartAsync_ConfigureMonitorOptionsThrows_SendsTriggerSchedule( 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() { @@ -322,7 +342,7 @@ public async Task StartAsync_SendMonitorConfigDisabled_DoesNotRunCallback() } [Fact] - public async Task StartAsync_JobWithoutIdentity_UsesClassNameAndWarnsOnce() + public async Task StartAsync_JobWithoutIdentity_UsesClassNameAndLogs() { // Arrange var sut = _fixture.GetSut(); @@ -338,49 +358,13 @@ public async Task StartAsync_JobWithoutIdentity_UsesClassNameAndWarnsOnce() // Assert firstCheckIn!.MonitorSlug.Should().Be("monitoredjob"); secondCheckIn!.MonitorSlug.Should().Be("monitoredjob"); - var warning = _fixture.Logger.Entries.Should().ContainSingle(e => e.Level == SentryLevel.Warning).Subject; - string.Format(warning.Message, warning.Args).Should().Be( + 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 ([SentryCronMonitorSlug(\"…\")]) to keep the monitor stable."); } - [Fact] - public async Task StartAsync_TwoJobsWithSameSlug_WarnsOnce() - { - // Arrange - var sut = _fixture.GetSut(); - var contexts = new List(); - foreach (var group in new[] { "a", "b", "b" }) - { - _fixture.JobDetail = JobBuilder.Create().WithIdentity("Job", group).Build(); - contexts.Add(_fixture.GetContext()); - } - - // Act - foreach (var context in contexts) - { - await sut.StartAsync(context, CancellationToken.None); - } - - // Assert - var warning = _fixture.Logger.Entries.Should().ContainSingle(e => e.Level == SentryLevel.Warning).Subject; - warning.Args.Should().Equal("a.Job", "b.Job", "custom-slug"); - } - - [Fact] - public async Task StartAsync_SameJobAgain_DoesNotWarn() - { - // Arrange - var sut = _fixture.GetSut(); - - // Act - await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); - await sut.StartAsync(_fixture.GetContext(), CancellationToken.None); - - // Assert - _fixture.Logger.Entries.Should().NotContain(e => e.Level == SentryLevel.Warning); - } - [Fact] public void Finish_HubThrows_DoesNotThrow() { From 73e5551ac38cc1131ae882d3f5bd6cfc0e3233e9 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 13 Oct 2026 14:52:19 -0700 Subject: [PATCH 38/53] 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 --- samples/Sentry.Samples.Quartz/Program.cs | 8 ++-- src/Sentry.Quartz/Core/JobMonitor.cs | 22 +++++------ ...ibute.cs => SentryMonitorSlugAttribute.cs} | 2 +- src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 4 +- .../SentryQuartzBuilderExtensions.cs | 2 +- src/Sentry.Quartz/SentryScopeMiddleware.cs | 21 ++++++++-- ...iApprovalTests.Run.DotNet10_0.verified.txt | 4 +- .../Core/JobMonitorTests.cs | 8 ++-- test/Sentry.Quartz.Tests/QuartzTestApi.cs | 8 ++-- .../SentryQuartzBuilderExtensionsTests.cs | 4 +- .../SentryScopeMiddlewareTests.cs | 38 ++++++++++++++++++- 11 files changed, 85 insertions(+), 36 deletions(-) rename src/Sentry.Quartz/Core/{SentryCronMonitorSlugAttribute.cs => SentryMonitorSlugAttribute.cs} (95%) diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs index d5bd049deb..116226beb5 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -19,7 +19,7 @@ public static void Main(string[] args) builder.Services.AddQuartz(quartz => { - // Runs each job in its own scope. Jobs with [SentryCronMonitorSlug] also send check-ins, with the + // Runs each job in its own scope. Jobs with [SentryMonitorSlug] also send check-ins, with the // trigger's schedule as the monitor config. Exceptions reach Sentry through Quartz's error log. quartz.AddSentry(options => { @@ -63,7 +63,7 @@ public static void Main(string[] args) } } -[SentryCronMonitorSlug("first-job")] +[SentryMonitorSlug("first-job")] public class FirstJob : IJob { public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) @@ -74,7 +74,7 @@ public async ValueTask Execute(IJobExecutionContext context, CancellationToken c } } -[SentryCronMonitorSlug("job-that-throws")] +[SentryMonitorSlug("job-that-throws")] public class SecondJob : IJob { private static readonly Random _random = new Random(); @@ -92,7 +92,7 @@ public async ValueTask Execute(IJobExecutionContext context, CancellationToken c } // Uses the slug of the job key: "thirdjob" -[SentryCronMonitorSlug] +[SentryMonitorSlug] public class ThirdJob : IJob { public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index b6041b2a57..8aff45f05d 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -3,12 +3,12 @@ namespace Sentry.Quartz; -// Captures the check-ins of jobs with SentryCronMonitorSlugAttribute +// Captures the check-ins of jobs with SentryMonitorSlugAttribute internal sealed class JobMonitor { private const int MaxSlugLength = 50; - private static readonly ConcurrentDictionary Attributes = new(); + private static readonly ConcurrentDictionary Attributes = new(); private readonly SentryQuartzOptions _options; private readonly IHub _hub; @@ -33,7 +33,7 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg try { var jobType = QuartzApi.GetJobType(context.JobDetail); - var attribute = Attributes.GetOrAdd(jobType, type => type.GetCustomAttribute()); + var attribute = Attributes.GetOrAdd(jobType, type => type.GetCustomAttribute()); if (attribute is null) { return null; @@ -43,7 +43,7 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg 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 SentryCronMonitorSlug attribute.", context.JobDetail.Key); + "Set a monitor slug in its SentryMonitorSlug attribute.", context.JobDetail.Key); return null; } @@ -126,12 +126,12 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) return null; } - if (!trigger.JobDataMap.ContainsKey(SentryCronMonitorSlugAttribute.JobDataKey) + if (!trigger.JobDataMap.ContainsKey(SentryMonitorSlugAttribute.JobDataKey) && await QuartzApi.HasSeveralTriggersAsync(context, cancellationToken).ConfigureAwait(false)) { Logger?.LogDebug("Not sending the schedule of trigger '{0}'. Job '{1}' has more than one trigger. " + "Set '{2}' in each trigger's data map to give each its own monitor.", - trigger.Key, context.JobDetail.Key, SentryCronMonitorSlugAttribute.JobDataKey); + trigger.Key, context.JobDetail.Key, SentryMonitorSlugAttribute.JobDataKey); return null; } @@ -144,7 +144,7 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) } } - private string? ResolveMonitorSlug(IJobExecutionContext context, Type jobType, SentryCronMonitorSlugAttribute attribute) + private string? ResolveMonitorSlug(IJobExecutionContext context, Type jobType, SentryMonitorSlugAttribute attribute) { var monitorSlug = GetMonitorSlug(context, attribute, out var fromJobType); if (monitorSlug is null) @@ -155,23 +155,23 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) 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 ([SentryCronMonitorSlug(\"…\")]) to keep the monitor stable.", + "(WithIdentity(...)) or set a slug ([SentryMonitorSlug(\"…\")]) to keep the monitor stable.", jobType.FullName, monitorSlug); } return monitorSlug; } - internal static string? GetMonitorSlug(IJobExecutionContext context, SentryCronMonitorSlugAttribute attribute) => + internal static string? GetMonitorSlug(IJobExecutionContext context, SentryMonitorSlugAttribute attribute) => GetMonitorSlug(context, attribute, out _); private static string? GetMonitorSlug( IJobExecutionContext context, - SentryCronMonitorSlugAttribute attribute, + SentryMonitorSlugAttribute attribute, out bool fromJobType) { fromJobType = false; - if (context.MergedJobDataMap.TryGetValue(SentryCronMonitorSlugAttribute.JobDataKey, out var value) + if (context.MergedJobDataMap.TryGetValue(SentryMonitorSlugAttribute.JobDataKey, out var value) && value is string dataMapSlug && !string.IsNullOrWhiteSpace(dataMapSlug)) { diff --git a/src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs similarity index 95% rename from src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs rename to src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs index 5e1e65c553..5cd182e792 100644 --- a/src/Sentry.Quartz/Core/SentryCronMonitorSlugAttribute.cs +++ b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs @@ -20,7 +20,7 @@ namespace Sentry.Quartz; /// /// The monitor slug. Defaults to the slug of the job's key. [AttributeUsage(AttributeTargets.Class)] -public sealed class SentryCronMonitorSlugAttribute(string? monitorSlug = null) : Attribute +public sealed class SentryMonitorSlugAttribute(string? monitorSlug = null) : Attribute { /// /// The JobDataMap key whose value overrides the monitor slug. diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index 740de27d95..ac6ffcab76 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -8,7 +8,7 @@ namespace Sentry.Quartz; public class SentryQuartzOptions { /// - /// When enabled, the in-progress check-in of each job with includes + /// 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. /// @@ -24,7 +24,7 @@ public class SentryQuartzOptions /// converted, so only UTC and IANA IDs are sent. /// /// - /// To turn this off for one job, set . + /// To turn this off for one job, set . /// /// public bool SendMonitorConfig { get; set; } = true; diff --git a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs index 9de5a3ee41..0d5e92ae97 100644 --- a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -20,7 +20,7 @@ public static class SentryQuartzBuilderExtensions /// captures that log entry. /// /// - /// Jobs with also send a check-in each time they run, with the + /// 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 . /// diff --git a/src/Sentry.Quartz/SentryScopeMiddleware.cs b/src/Sentry.Quartz/SentryScopeMiddleware.cs index 2cb2a2e32e..d6058ba064 100644 --- a/src/Sentry.Quartz/SentryScopeMiddleware.cs +++ b/src/Sentry.Quartz/SentryScopeMiddleware.cs @@ -2,17 +2,18 @@ namespace Sentry.Quartz; -// Runs each job in its own scope and trace +// 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"; 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 next(context, cancellationToken).ConfigureAwait(false); + await InvokeAndCapture(context, next, cancellationToken).ConfigureAwait(false); return; } @@ -23,6 +24,20 @@ public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate scope.SetTag(JobTag, context.JobDetail.Key.ToString()); }, context); - await next(context, cancellationToken).ConfigureAwait(false); + 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); + throw; + } } } 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 4619df979a..35f64f8b6b 100644 --- a/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1,10 +1,10 @@ namespace Sentry.Quartz { [System.AttributeUsage(System.AttributeTargets.Class)] - public sealed class SentryCronMonitorSlugAttribute : System.Attribute + public sealed class SentryMonitorSlugAttribute : System.Attribute { public const string JobDataKey = "SentryMonitorSlug"; - public SentryCronMonitorSlugAttribute(string? monitorSlug = null) { } + public SentryMonitorSlugAttribute(string? monitorSlug = null) { } public string? MonitorSlug { get; } public bool SendMonitorConfig { get; set; } } diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 5415035320..54020b74f0 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -139,7 +139,7 @@ public async Task StartAsync_TriggerSetsMonitorSlug_CapturesCheckInWithThatTrigg _fixture.TriggerCount = 2; _fixture.Trigger = TriggerBuilder.Create() .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) - .UsingJobData(SentryCronMonitorSlugAttribute.JobDataKey, "daily-email-noon") + .UsingJobData(SentryMonitorSlugAttribute.JobDataKey, "daily-email-noon") .Build(); // Act @@ -362,7 +362,7 @@ public async Task StartAsync_JobWithoutIdentity_UsesClassNameAndLogs() 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 ([SentryCronMonitorSlug(\"…\")]) to keep the monitor stable."); + "an identity (WithIdentity(...)) or set a slug ([SentryMonitorSlug(\"…\")]) to keep the monitor stable."); } [Fact] @@ -428,10 +428,10 @@ public void GetMonitorSlug_Job_ReturnsSlug( var jobBuilder = JobBuilder.Create().OfType(jobType).WithIdentity(name, group); if (dataMapSlug is not null) { - jobBuilder.UsingJobData(SentryCronMonitorSlugAttribute.JobDataKey, dataMapSlug); + jobBuilder.UsingJobData(SentryMonitorSlugAttribute.JobDataKey, dataMapSlug); } _fixture.JobDetail = jobBuilder.Build(); - var attribute = jobType.GetCustomAttribute()!; + var attribute = jobType.GetCustomAttribute()!; JobMonitor.GetMonitorSlug(_fixture.GetContext(), attribute).Should().Be(expected); } diff --git a/test/Sentry.Quartz.Tests/QuartzTestApi.cs b/test/Sentry.Quartz.Tests/QuartzTestApi.cs index 971afc6021..c0b195696f 100644 --- a/test/Sentry.Quartz.Tests/QuartzTestApi.cs +++ b/test/Sentry.Quartz.Tests/QuartzTestApi.cs @@ -29,16 +29,16 @@ internal class NoOpJob : IJob public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => default; } -[SentryCronMonitorSlug] +[SentryMonitorSlug] internal class MonitoredJob : NoOpJob; -[SentryCronMonitorSlug("custom-slug")] +[SentryMonitorSlug("custom-slug")] internal class CustomSlugJob : NoOpJob; -[SentryCronMonitorSlug(SendMonitorConfig = false)] +[SentryMonitorSlug(SendMonitorConfig = false)] internal class NoMonitorConfigJob : NoOpJob; -[SentryCronMonitorSlug] +[SentryMonitorSlug] internal class FailingJob : IJob { public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index ee4bbd4d61..29659b27b4 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -44,7 +44,7 @@ public async Task AddSentry_CronTriggerJobSucceeds_CapturesCheckInsWithCrontab() } [Fact] - public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalButNotException() + public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalAndException() { // Act var status = await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger @@ -56,7 +56,7 @@ public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalBu 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.DidNotReceive().CaptureEvent(Arg.Any()); + _hub.Received(1).CaptureEvent(Arg.Is(e => e.Exception is InvalidOperationException)); } [Fact] diff --git a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs index 838d72cbf9..c299807f12 100644 --- a/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs +++ b/test/Sentry.Quartz.Tests/SentryScopeMiddlewareTests.cs @@ -42,7 +42,7 @@ await sut.Invoke(MiddlewareTestHelpers.CreateContext(), (_, _) => } [Fact] - public async Task Invoke_JobThrows_RethrowsWithoutCapturing() + public async Task Invoke_JobThrows_CapturesExceptionInJobScopeAndRethrows() { // Arrange var exception = new InvalidOperationException("Job failed"); @@ -53,10 +53,44 @@ public async Task Invoke_JobThrows_RethrowsWithoutCapturing() // Assert (await act.Should().ThrowAsync()).Which.Should().BeSameAs(exception); - _events.Should().Equal("push", "dispose"); + _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() { From ccf6da4fd743b2fcb7e34d08e42c66a868026cc2 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 16 Oct 2026 11:24:16 +1300 Subject: [PATCH 39/53] 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 --- src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs index 0d5e92ae97..0b34a9ab48 100644 --- a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -16,8 +16,7 @@ public static class SentryQuartzBuilderExtensions /// /// /// 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 not captured here: Quartz logs them, and Sentry's logging integration - /// captures that log entry. + /// Exceptions that jobs throw are captured. /// /// /// Jobs with also send a check-in each time they run, with the From c7680965c923e04fa30e783a60d5233e0c454ffc Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:37:58 -0700 Subject: [PATCH 40/53] 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 --- src/Sentry.Quartz/Core/JobMonitor.cs | 45 ++++- .../Core/SentryMonitorSlugAttribute.cs | 2 +- src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 25 +++ .../SentryQuartzBuilderExtensions.cs | 3 +- ...iApprovalTests.Run.DotNet10_0.verified.txt | 2 + .../Core/JobMonitorTests.cs | 158 ++++++++++++++++++ .../SentryQuartzBuilderExtensionsTests.cs | 27 ++- 7 files changed, 254 insertions(+), 8 deletions(-) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index 8aff45f05d..1ba743a1ef 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -3,12 +3,13 @@ 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 { private const int MaxSlugLength = 50; private static readonly ConcurrentDictionary Attributes = new(); + private static readonly SentryMonitorSlugAttribute DefaultAttribute = new(); private readonly SentryQuartzOptions _options; private readonly IHub _hub; @@ -33,7 +34,8 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg try { var jobType = QuartzApi.GetJobType(context.JobDetail); - var attribute = Attributes.GetOrAdd(jobType, type => type.GetCustomAttribute()); + var attribute = Attributes.GetOrAdd(jobType, type => type.GetCustomAttribute()) + ?? (IsMonitoredByDefault(context) ? DefaultAttribute : null); if (attribute is null) { return null; @@ -144,6 +146,45 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) } } + private bool IsMonitoredByDefault(IJobExecutionContext context) + { + if (!_options.MonitorAllJobs) + { + return false; + } + + // Only jobs that run on a schedule, and whose key stays the same across restarts + var key = context.JobDetail.Key; + if (context.Trigger is not (ICronTrigger or ISimpleTrigger { RepeatCount: not 0 }) || !HasIdentity(key)) + { + return false; + } + + if (IsExcluded(key)) + { + Logger?.LogDebug("Skipping the check-in for job '{0}'. It matches ExcludedJobs.", key); + return false; + } + + return true; + } + + 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; + } + private string? ResolveMonitorSlug(IJobExecutionContext context, Type jobType, SentryMonitorSlugAttribute attribute) { var monitorSlug = GetMonitorSlug(context, attribute, out var fromJobType); diff --git a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs index 5cd182e792..da3f1e7715 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 key, lowercased with every run of other characters replaced diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index ac6ffcab76..f4690568e0 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -7,6 +7,31 @@ namespace Sentry.Quartz; /// public class SentryQuartzOptions { + /// + /// When enabled, every job fired by a cron trigger or a repeating simple trigger 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 and runs fired by other triggers, such as a job triggered once by hand, are skipped. + /// Jobs with keep its settings. + /// + /// + /// 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 0d5e92ae97..9261537717 100644 --- a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -22,7 +22,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 54020b74f0..8b4bf9aa97 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -365,6 +365,164 @@ 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_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"); + } + [Fact] public void Finish_HubThrows_DoesNotThrow() { diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index 29659b27b4..2a7fbfae70 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -59,6 +59,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_JobWithoutAttribute_CapturesNoCheckIns() { @@ -86,9 +103,10 @@ public async Task AddSentry_JobWithoutAttribute_CapturesNoCheckIns() private async Task RunUntilCheckInCompletes( string name, string? group, - Action> configureTrigger) where TJob : IJob + Action> configureTrigger, + SentryQuartzOptions? options = null) where TJob : IJob { - var scheduler = await BuildScheduler(name, group, configureTrigger); + var scheduler = await BuildScheduler(name, group, configureTrigger, options); await scheduler.Start(); try { @@ -105,14 +123,15 @@ private async Task RunUntilCheckInCompletes( private async Task BuildScheduler( string name, string? group, - Action> configureTrigger) where TJob : IJob + Action> configureTrigger, + SentryQuartzOptions? options = null) where TJob : IJob { var services = new ServiceCollection(); var properties = new NameValueCollection { ["quartz.scheduler.instanceName"] = Guid.NewGuid().ToString() }; 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 e957e7bd18e0023e77421d42a77eb8cbb97af36d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 09:35:02 -0700 Subject: [PATCH 41/53] ref(quartz): Remove a leftover comment from JobMonitor --- src/Sentry.Quartz/Core/JobMonitor.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index 8aff45f05d..32d8fbcc46 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -14,8 +14,6 @@ internal sealed class JobMonitor private readonly IHub _hub; private readonly IDiagnosticLogger? _logger; - // The job each slug was first used by: its key, or its type when it has no identity - public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logger = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); From 787ab1e17baf2d69bc39e3532692afcbe30f55ff Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 09:59:02 -0700 Subject: [PATCH 42/53] 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. --- src/Sentry.Quartz/Core/JobMonitor.cs | 159 ++++++++++-- src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 9 +- src/Sentry.Quartz/Core/TriggerSchedule.cs | 32 ++- src/Sentry.Quartz/QuartzApi.cs | 32 ++- .../Core/JobMonitorTests.cs | 232 +++++++++++++++++- .../Core/TriggerScheduleTests.cs | 16 +- .../MiddlewareTestHelpers.cs | 2 +- test/Sentry.Quartz.Tests/QuartzTestApi.cs | 17 +- 8 files changed, 440 insertions(+), 59 deletions(-) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index 32d8fbcc46..b10df78897 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -1,5 +1,6 @@ using Quartz; using Sentry.Extensibility; +using Sentry.Infrastructure; namespace Sentry.Quartz; @@ -8,17 +9,24 @@ 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) + 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; @@ -31,7 +39,7 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg try { var jobType = QuartzApi.GetJobType(context.JobDetail); - var attribute = Attributes.GetOrAdd(jobType, type => type.GetCustomAttribute()); + var attribute = GetAttribute(jobType); if (attribute is null) { return null; @@ -46,7 +54,7 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg } configureMonitorOptions = _options.SendMonitorConfig && attribute.SendMonitorConfig - ? await GetMonitorConfigAsync(context, cancellationToken).ConfigureAwait(false) + ? await GetMonitorConfigAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) : null; } catch (Exception e) @@ -84,6 +92,7 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) // Returns null when there's no schedule to send private async Task?> GetMonitorConfigAsync( IJobExecutionContext context, + string monitorSlug, CancellationToken cancellationToken) { var monitorOptions = new SentryMonitorOptions(); @@ -102,34 +111,37 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) } if (!monitorOptions.HasSchedule - && await GetTriggerScheduleAsync(context, cancellationToken).ConfigureAwait(false) is { } schedule) + && await GetTriggerScheduleAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) is { } schedule) { - schedule(monitorOptions); + schedule.Apply(monitorOptions); } return monitorOptions.HasSchedule ? monitorOptions.CopyTo : null; } - private async Task?> GetTriggerScheduleAsync( + private async Task GetTriggerScheduleAsync( IJobExecutionContext context, + string monitorSlug, CancellationToken cancellationToken) { var trigger = context.Trigger; try { - var schedule = TriggerSchedule.ToMonitorConfig(trigger); + 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; } - if (!trigger.JobDataMap.ContainsKey(SentryMonitorSlugAttribute.JobDataKey) - && await QuartzApi.HasSeveralTriggersAsync(context, cancellationToken).ConfigureAwait(false)) + 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}'. Job '{1}' has more than one trigger. " + - "Set '{2}' in each trigger's data map to give each its own monitor.", - trigger.Key, context.JobDetail.Key, SentryMonitorSlugAttribute.JobDataKey); + 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; } @@ -142,6 +154,100 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) } } + // 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(); + } + + private static bool IsMonitored(IJobDetail job) => GetAttribute(QuartzApi.GetJobType(job)) is not null; + } + + 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); @@ -167,11 +273,30 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) 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 (context.MergedJobDataMap.TryGetValue(SentryMonitorSlugAttribute.JobDataKey, out var value) - && value is string dataMapSlug - && !string.IsNullOrWhiteSpace(dataMapSlug)) + if (dataMapValue is string dataMapSlug && !string.IsNullOrWhiteSpace(dataMapSlug)) { return dataMapSlug; } @@ -181,14 +306,14 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) return attribute.MonitorSlug; } - var key = context.JobDetail.Key; + 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(context.JobDetail).Name); + return ToSlug(QuartzApi.GetJobType(job).Name); } // Quartz names a job built without an identity with a new GUID diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index ac6ffcab76..2708b6d419 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -16,8 +16,13 @@ public class SentryQuartzOptions /// /// 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, and jobs with more than one trigger that share a - /// monitor, are not sent. + /// 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 diff --git a/src/Sentry.Quartz/Core/TriggerSchedule.cs b/src/Sentry.Quartz/Core/TriggerSchedule.cs index 8a7e141489..1676dd22eb 100644 --- a/src/Sentry.Quartz/Core/TriggerSchedule.cs +++ b/src/Sentry.Quartz/Core/TriggerSchedule.cs @@ -8,7 +8,8 @@ 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"]; - public static Action? ToMonitorConfig(ITrigger trigger) + // Returns null when Sentry can't represent the trigger's schedule + public static MonitorSchedule? ToSchedule(ITrigger trigger) { if (trigger.CalendarName is not null) { @@ -20,18 +21,9 @@ internal static class TriggerSchedule case ICronTrigger cronTrigger: var crontab = ToCrontab(cronTrigger.CronExpressionString); var timeZone = ToIanaTimeZoneId(cronTrigger.TimeZone); - if (crontab is null || timeZone is null) - { - return null; - } - - return options => - { - options.Interval(crontab); - options.TimeZone ??= 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 options => options.Interval(interval, unit); + return new MonitorSchedule(null, null, interval, unit); default: return null; } @@ -265,3 +257,19 @@ internal static (int Interval, SentryMonitorInterval Unit)? ToInterval(ISimpleTr #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 index 3f8c37d895..dd0a683a85 100644 --- a/src/Sentry.Quartz/QuartzApi.cs +++ b/src/Sentry.Quartz/QuartzApi.cs @@ -10,10 +10,34 @@ internal static class QuartzApi public static Type GetJobType(IJobDetail jobDetail) => jobDetail.JobType.Type; - public static async Task HasSeveralTriggersAsync(IJobExecutionContext context, CancellationToken cancellationToken) + // Returns the triggers of every job that matches the predicate + public static async Task> GetTriggersAsync( + IScheduler scheduler, + Func predicate, + CancellationToken cancellationToken) { - var query = new TriggerQuery { Job = context.JobDetail.Key, Take = 2 }; - var triggers = await context.Scheduler.QueryTriggers(query, cancellationToken).ConfigureAwait(false); - return triggers.Items.Count > 1; + 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/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 54020b74f0..2b19ca66c0 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -9,7 +9,9 @@ private class Fixture public InMemoryDiagnosticLogger Logger { get; } = new(); public SentryId CheckInId { get; set; } = SentryId.Create(); public SentryQuartzOptions Options { get; } = new(); - public int TriggerCount { get; set; } = 1; + 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") @@ -22,18 +24,24 @@ private class Fixture public JobMonitor GetSut() { Hub.CaptureCheckIn(default!, default, default, default, default, default).ReturnsForAnyArgs(CheckInId); - return new JobMonitor(Options, Hub, Logger); + 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.Key, Enumerable.Repeat(Trigger, TriggerCount).ToList()); + Scheduler.ReturnsTriggers([(JobDetail, Trigger), .. OtherTriggers]); var context = Substitute.For(); context.JobDetail.Returns(JobDetail); @@ -120,34 +128,215 @@ public async Task StartAsync_ScheduleSentryCantRepresent_CapturesCheckInWithoutM } [Fact] - public async Task StartAsync_JobHasSeveralTriggers_CapturesCheckInWithoutMonitorConfig() + public async Task StartAsync_JobHasTriggersWithDifferentSchedules_CapturesCheckInWithoutMonitorConfig() { // Arrange - _fixture.TriggerCount = 2; + _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(); + } + + [Fact] + public async Task StartAsync_JobsShareSlugWithDifferentTimeZones_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.JobDetail = JobBuilder.Create().WithIdentity("eu", "reports").Build(); + var other = JobBuilder.Create().WithIdentity("us", "reports").Build(); + var timeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 12 * * ?", other, timeZone))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().BeNull(); + } + + [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.TriggerCount = 2; - _fixture.Trigger = TriggerBuilder.Create() - .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) - .UsingJobData(SentryMonitorSlugAttribute.JobDataKey, "daily-email-noon") - .Build(); + _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(); - _fixture.Scheduler.DidNotLookUpTriggers(); + } + + [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] @@ -448,4 +637,25 @@ public void ToSlug_Value_ReturnsSlugSentryLeavesUnchanged(string value, string? { 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/TriggerScheduleTests.cs b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs index 6ef8153d3b..7fd748b4c4 100644 --- a/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs +++ b/test/Sentry.Quartz.Tests/Core/TriggerScheduleTests.cs @@ -121,13 +121,13 @@ public void ToIanaTimeZoneId_SystemTimeZone_ReturnsIanaId(string timeZoneId, str #endif [Fact] - public void ToMonitorConfig_CronTrigger_ReturnsCrontabAndTimeZone() + 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.ToMonitorConfig(trigger)!); + 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"); @@ -135,13 +135,13 @@ public void ToMonitorConfig_CronTrigger_ReturnsCrontabAndTimeZone() } [Fact] - public void ToMonitorConfig_SimpleTrigger_ReturnsInterval() + public void ToSchedule_SimpleTrigger_ReturnsInterval() { var trigger = TriggerBuilder.Create() .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromHours(2)).RepeatForever()) .Build(); - var monitorConfig = MonitorConfigJson.Render(TriggerSchedule.ToMonitorConfig(trigger)!); + 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); @@ -149,23 +149,23 @@ public void ToMonitorConfig_SimpleTrigger_ReturnsInterval() } [Fact] - public void ToMonitorConfig_TriggerWithCalendar_ReturnsNull() + public void ToSchedule_TriggerWithCalendar_ReturnsNull() { var trigger = TriggerBuilder.Create() .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) .WithCalendar("holidays") .Build(); - TriggerSchedule.ToMonitorConfig(trigger).Should().BeNull(); + TriggerSchedule.ToSchedule(trigger).Should().BeNull(); } [Fact] - public void ToMonitorConfig_CalendarIntervalTrigger_ReturnsNull() + public void ToSchedule_CalendarIntervalTrigger_ReturnsNull() { var trigger = TriggerBuilder.Create() .WithCalendarIntervalSchedule(schedule => schedule.WithInterval(1, IntervalUnit.Day)) .Build(); - TriggerSchedule.ToMonitorConfig(trigger).Should().BeNull(); + TriggerSchedule.ToSchedule(trigger).Should().BeNull(); } } diff --git a/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs index c930b0d0d5..371fb3af90 100644 --- a/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs +++ b/test/Sentry.Quartz.Tests/MiddlewareTestHelpers.cs @@ -10,7 +10,7 @@ public static IJobExecutionContext CreateContext(CancellationToken cancell .WithCronSchedule("0 0 12 * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) .Build(); var scheduler = Substitute.For(); - scheduler.ReturnsTriggers(jobDetail.Key, [trigger]); + scheduler.ReturnsTriggers([(jobDetail, trigger)]); var context = Substitute.For(); context.JobDetail.Returns(jobDetail); diff --git a/test/Sentry.Quartz.Tests/QuartzTestApi.cs b/test/Sentry.Quartz.Tests/QuartzTestApi.cs index c0b195696f..c6d32991bd 100644 --- a/test/Sentry.Quartz.Tests/QuartzTestApi.cs +++ b/test/Sentry.Quartz.Tests/QuartzTestApi.cs @@ -6,14 +6,20 @@ 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, JobKey jobKey, IReadOnlyList triggers) + public static void ReturnsTriggers(this IScheduler scheduler, IReadOnlyList<(IJobDetail Job, ITrigger Trigger)> triggers) { var headers = triggers - .Select(t => new TriggerHeader(t.Key, jobKey, null, "CRON", TriggerState.Normal, t.StartTimeUtc, - null, null, null, null, t.Priority, null, null, 0)) + .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.Is(q => q.Job == jobKey), Arg.Any()) + 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) => @@ -22,6 +28,9 @@ public static void TriggerLookupThrows(this IScheduler scheduler, Exception exce 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 From ef8b0e61563b79299cb8414cdb781af83ada964f Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 11:12:22 -0700 Subject: [PATCH 43/53] test(quartz): Skip the time zone test when there's no time zone database --- test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 2b19ca66c0..4151331442 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -169,9 +169,11 @@ public async Task StartAsync_JobsShareSlugWithDifferentSchedules_CapturesCheckIn _fixture.Hub.ReceivedConfigureMonitorOptions("custom-slug").Should().BeNull(); } - [Fact] + [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(); From b59c8d95aaf6e750d745ed3b46f957c5c5b18192 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 21 Oct 2026 12:40:43 +1300 Subject: [PATCH 44/53] 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 --- src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs index 5cd182e792..c21f4eb13a 100644 --- a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs +++ b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs @@ -15,7 +15,7 @@ namespace Sentry.Quartz; /// /// /// 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, and a warning is logged. Give the job an identity or a monitor slug to keep its monitor stable. +/// 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. From edc97575e3dfc4209b4abda993b17178a8fa88f1 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 09:22:38 -0700 Subject: [PATCH 45/53] 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 --- src/Sentry.Quartz/Core/JobMonitor.cs | 13 +++++++++++- .../JobMonitorQuartz4Tests.cs | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 test/Sentry.Quartz.Tests/JobMonitorQuartz4Tests.cs diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index b10df78897..430c243af0 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -242,7 +242,18 @@ public List WithDifferentSchedule(string monitorSlug, TriggerKey tri .ToList(); } - private static bool IsMonitored(IJobDetail job) => GetAttribute(QuartzApi.GetJobType(job)) is not null; + // 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) => 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(); + } +} From e892565bcaa10f8d99213d9478be34c475983c76 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 09:23:37 -0700 Subject: [PATCH 46/53] test(quartz): Pin the order of the Sentry job middleware Co-Authored-By: Claude --- .../SentryQuartzBuilderExtensionsTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index 29659b27b4..cda8478777 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -59,6 +59,28 @@ public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalAn _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_JobWithoutAttribute_CapturesNoCheckIns() { From 0b3b7555a30c0445f5e5a2215738fb4e0793954a Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 09:23:41 -0700 Subject: [PATCH 47/53] docs(quartz): Update the sample's comment on how job exceptions are captured Co-Authored-By: Claude --- samples/Sentry.Samples.Quartz/Program.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs index 116226beb5..e7a55220d7 100644 --- a/samples/Sentry.Samples.Quartz/Program.cs +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -19,8 +19,8 @@ public static void Main(string[] args) builder.Services.AddQuartz(quartz => { - // Runs each job in its own scope. Jobs with [SentryMonitorSlug] also send check-ins, with the - // trigger's schedule as the monitor config. Exceptions reach Sentry through Quartz's error log. + // 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) => From 1383e8a0ebfbc005949611f83888cfdd28ff6f0d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 09:35:31 -0700 Subject: [PATCH 48/53] 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 --- .../QuartzErrorLogFilterSetup.cs | 24 ++++++++++ src/Sentry.Quartz/Sentry.Quartz.csproj | 1 + .../SentryQuartzBuilderExtensions.cs | 8 +++- src/Sentry.Quartz/SentryScopeMiddleware.cs | 6 +++ .../Sentry.Quartz.Tests.csproj | 1 + .../SentryQuartzBuilderExtensionsTests.cs | 48 +++++++++++++++++-- 6 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs diff --git a/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs new file mode 100644 index 0000000000..c6ff17d6ba --- /dev/null +++ b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs @@ -0,0 +1,24 @@ +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. +// Open generic, so it applies to whichever SentryLoggingOptions the logging integration uses, such as +// SentryAspNetCoreOptions. +internal sealed class QuartzErrorLogFilterSetup : IConfigureOptions + where TOptions : SentryLoggingOptions +{ + // 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.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 index f1ca7aa4ec..9280dcf62f 100644 --- a/src/Sentry.Quartz/Sentry.Quartz.csproj +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs index 0b34a9ab48..2c15736eb2 100644 --- a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Quartz; using Sentry.Extensibility; @@ -16,7 +19,8 @@ public static class SentryQuartzBuilderExtensions /// /// /// 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. + /// 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 @@ -38,6 +42,8 @@ internal IQuartzBuilder AddSentry(SentryQuartzOptions options, IHub hub, IDiagno // 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 index d6058ba064..1380d97043 100644 --- a/src/Sentry.Quartz/SentryScopeMiddleware.cs +++ b/src/Sentry.Quartz/SentryScopeMiddleware.cs @@ -8,6 +8,11 @@ internal sealed class SentryScopeMiddleware(IHub hub, SentryOptions? options = n 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 @@ -37,6 +42,7 @@ private async ValueTask InvokeAndCapture(IJobExecutionContext context, JobExecut { e.SetSentryMechanism(MechanismType, handled: false, terminal: false); hub.CaptureException(e); + CapturedExceptions.AddOrUpdate(e, null); throw; } } diff --git a/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj b/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj index cbcdb9b33d..2c993200ae 100644 --- a/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj +++ b/test/Sentry.Quartz.Tests/Sentry.Quartz.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index cda8478777..30b53cab1c 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -1,5 +1,9 @@ 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; @@ -81,6 +85,41 @@ await RunUntilCheckInCompletes("Cleanup", null, trigger => trigger }); } + [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(); + provider.GetServices>().Should().BeEmpty(); + } + [Fact] public async Task AddSentry_JobWithoutAttribute_CapturesNoCheckIns() { @@ -108,9 +147,10 @@ public async Task AddSentry_JobWithoutAttribute_CapturesNoCheckIns() private async Task RunUntilCheckInCompletes( string name, string? group, - Action> configureTrigger) where TJob : IJob + Action> configureTrigger, + Action? configureServices = null) where TJob : IJob { - var scheduler = await BuildScheduler(name, group, configureTrigger); + var scheduler = await BuildScheduler(name, group, configureTrigger, configureServices); await scheduler.Start(); try { @@ -127,9 +167,11 @@ private async Task RunUntilCheckInCompletes( private async Task BuildScheduler( string name, string? group, - Action> configureTrigger) where TJob : IJob + 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 => { From 3a840275e1ce897c5ad764f79bb2cf362bb7afb4 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 09:43:23 -0700 Subject: [PATCH 49/53] test(quartz): Check that AddSentry leaves unrelated options resolvable Co-Authored-By: Claude --- .../SentryQuartzBuilderExtensionsTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index 30b53cab1c..7be5f26e97 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -120,6 +120,21 @@ public void AddSentry_LoggingOptionsOfAnyType_GetLogEntryFilter() provider.GetServices>().Should().BeEmpty(); } + [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() { @@ -187,6 +202,11 @@ private async Task BuildScheduler( 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; } From 81e188867a5d19244ec8d013d7916ce023b917c0 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 11:00:13 -0700 Subject: [PATCH 50/53] Update src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs Co-authored-by: Ricardo Colombo Oliveira --- src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs index c6ff17d6ba..c7158786da 100644 --- a/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs +++ b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs @@ -9,14 +9,14 @@ namespace Sentry.Quartz; // Open generic, so it applies to whichever SentryLoggingOptions the logging integration uses, such as // SentryAspNetCoreOptions. internal sealed class QuartzErrorLogFilterSetup : IConfigureOptions - where TOptions : SentryLoggingOptions + 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.AddLogEntryFilter((category, _, eventId, exception) => + (options as SentryLoggingOptions)?.AddLogEntryFilter((category, _, eventId, exception) => eventId.Id == EventId && string.Equals(category, Category, StringComparison.Ordinal) && exception is JobExecutionProcessException { InnerException: { } jobException } From f3d7c360d722c69f4677ba3bb95f52c894310c17 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 11:01:19 -0700 Subject: [PATCH 51/53] ref(quartz): Drop the stale constraint wording and assertion for the log filter setup Co-Authored-By: Claude --- src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs | 3 +-- test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs index c7158786da..119470359d 100644 --- a/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs +++ b/src/Sentry.Quartz/QuartzErrorLogFilterSetup.cs @@ -6,8 +6,7 @@ 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. -// Open generic, so it applies to whichever SentryLoggingOptions the logging integration uses, such as -// SentryAspNetCoreOptions. +// Registered for every options type, and only acts on SentryLoggingOptions, such as SentryAspNetCoreOptions. internal sealed class QuartzErrorLogFilterSetup : IConfigureOptions where TOptions : class { diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index 7be5f26e97..928c041433 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -117,7 +117,6 @@ public void AddSentry_LoggingOptionsOfAnyType_GetLogEntryFilter() // Assert provider.GetServices>().Should().ContainSingle(); provider.GetServices>().Should().ContainSingle(); - provider.GetServices>().Should().BeEmpty(); } [Fact] From 7d4293ece3dad480a15b61354758be59a3c469f0 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 08:51:12 -0700 Subject: [PATCH 52/53] 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. --- src/Sentry.Quartz/Core/JobMonitor.cs | 10 ++-- src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 5 +- .../Core/JobMonitorTests.cs | 53 +++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index 0655e34cc2..355f746ce2 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -162,9 +162,9 @@ private bool IsMonitoredByDefault(IJobExecutionContext context) return false; } - // Only jobs that run on a schedule, and whose key stays the same across restarts + // Only jobs on a schedule Sentry can represent, and whose key stays the same across restarts var key = context.JobDetail.Key; - if (!RunsOnSchedule(context.Trigger) || !HasIdentity(key)) + if (!HasSentrySchedule(context.Trigger) || !HasIdentity(key)) { return false; } @@ -182,8 +182,8 @@ private bool IsMonitoredByDefault(IJobExecutionContext context) private bool IsMonitoredByDefault(IJobDetail job) => _options.MonitorAllJobs && HasIdentity(job.Key) && !IsExcluded(job.Key); - private static bool RunsOnSchedule(ITrigger trigger) => - trigger is ICronTrigger or ISimpleTrigger { RepeatCount: not 0 }; + private static bool HasSentrySchedule(ITrigger trigger) => + TriggerSchedule.ToSchedule(trigger) is not null; private bool IsExcluded(JobKey key) { @@ -256,7 +256,7 @@ public static async Task ReadAsync(JobMonitor monitor, ISchedu keys.Add(trigger.Key); // Triggers whose runs aren't monitored, or whose schedule isn't sent, don't change the monitor - var attribute = GetAttribute(QuartzApi.GetJobType(job)) ?? (RunsOnSchedule(trigger) ? DefaultAttribute : null); + 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) diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index 052d77c069..9cfad7e351 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -8,13 +8,14 @@ namespace Sentry.Quartz; public class SentryQuartzOptions { /// - /// When enabled, every job fired by a cron trigger or a repeating simple trigger sends check-ins, as if it had + /// 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 and runs fired by other triggers, such as a job triggered once by hand, are skipped. + /// 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 keep its settings. /// /// diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 9c71b15882..20e5d16e77 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -618,6 +618,59 @@ public async Task StartAsync_MonitorAllJobsOneOffTrigger_CapturesNothing() _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() { From 70816f88a2173054abc4198c0ec63bd76604ff44 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 13:51:08 -0700 Subject: [PATCH 53/53] fix(quartz): Skip default-monitored jobs whose triggers have different schedules --- src/Sentry.Quartz/Core/JobMonitor.cs | 24 +++++- src/Sentry.Quartz/Core/SentryQuartzOptions.cs | 4 +- .../Core/JobMonitorTests.cs | 81 +++++++++++++++++-- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index 355f746ce2..d2c9698e4d 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -54,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) { diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index 9cfad7e351..5bb549f1c1 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -16,7 +16,9 @@ public class SentryQuartzOptions /// 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 keep its settings. + /// 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 . diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index 20e5d16e77..57d6e111a4 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -765,34 +765,105 @@ public async Task StartAsync_MonitorAllJobsDataMapSlug_UsesThatSlug() 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_MonitorAllJobsJobHasTriggersWithDifferentSchedules_CapturesCheckInWithoutMonitorConfig() + 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 - await _fixture.StartAsync(); + 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_CapturesCheckInWithoutMonitorConfig() + 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 - await _fixture.StartAsync(); + var checkIn = await _fixture.StartAsync(); // Assert - _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + 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]