diff --git a/src/Sentry.Quartz/Core/JobMonitor.cs b/src/Sentry.Quartz/Core/JobMonitor.cs index a73d79dfe0..d2c9698e4d 100644 --- a/src/Sentry.Quartz/Core/JobMonitor.cs +++ b/src/Sentry.Quartz/Core/JobMonitor.cs @@ -5,13 +5,14 @@ namespace Sentry.Quartz; -// Captures the check-ins of jobs with SentryMonitorSlugAttribute +// Captures the check-ins of jobs with SentryMonitorSlugAttribute, or of every scheduled job with MonitorAllJobs internal sealed class JobMonitor { // How long the schedules of all monitored triggers are reused before they're read from the scheduler again internal static readonly TimeSpan TriggerSchedulesTtl = TimeSpan.FromMinutes(1); private static readonly ConcurrentDictionary Attributes = new(); + private static readonly SentryMonitorSlugAttribute DefaultAttribute = new(); private readonly SentryQuartzOptions _options; private readonly IHub _hub; @@ -38,7 +39,8 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg try { var jobType = QuartzApi.GetJobType(context.JobDetail); - var attribute = GetAttribute(jobType); + var attribute = GetAttribute(jobType) + ?? (IsMonitoredByDefault(context) ? DefaultAttribute : null); if (attribute is null) { return null; @@ -52,9 +54,27 @@ public JobMonitor(SentryQuartzOptions options, IHub hub, IDiagnosticLogger? logg return null; } - configureMonitorOptions = _options.SendMonitorConfig && attribute.SendMonitorConfig - ? await GetMonitorConfigAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) - : null; + var monitoredByDefault = ReferenceEquals(attribute, DefaultAttribute); + bool hasNoSchedule; + if (_options.SendMonitorConfig && attribute.SendMonitorConfig) + { + configureMonitorOptions = await GetMonitorConfigAsync(context, monitorSlug, cancellationToken) + .ConfigureAwait(false); + hasNoSchedule = configureMonitorOptions is null; + } + else + { + configureMonitorOptions = null; + hasNoSchedule = monitoredByDefault + && await GetTriggerScheduleAsync(context, monitorSlug, cancellationToken).ConfigureAwait(false) is null; + } + + if (monitoredByDefault && hasNoSchedule) + { + Logger?.LogDebug("Skipping the check-in for job '{0}'. MonitorAllJobs only monitors jobs whose " + + "schedule Sentry supports.", context.JobDetail.Key); + return null; + } } catch (Exception e) { @@ -153,6 +173,52 @@ public void Finish(JobCheckIn checkIn, bool failed, TimeSpan duration) } } + private bool IsMonitoredByDefault(IJobExecutionContext context) + { + if (!_options.MonitorAllJobs) + { + return false; + } + + // Only jobs on a schedule Sentry can represent, and whose key stays the same across restarts + var key = context.JobDetail.Key; + if (!HasSentrySchedule(context.Trigger) || !HasIdentity(key)) + { + return false; + } + + if (IsExcluded(key)) + { + Logger?.LogDebug("Skipping the check-in for job '{0}'. It matches ExcludedJobs.", key); + return false; + } + + return true; + } + + // Whether MonitorAllJobs covers the job's scheduled runs + private bool IsMonitoredByDefault(IJobDetail job) => + _options.MonitorAllJobs && HasIdentity(job.Key) && !IsExcluded(job.Key); + + private static bool HasSentrySchedule(ITrigger trigger) => + TriggerSchedule.ToSchedule(trigger) is not null; + + private bool IsExcluded(JobKey key) + { + var fullKey = key.ToString(); + foreach (var pattern in _options.ExcludedJobs) + { + if (pattern._regex is { } regex + ? regex.IsMatch(fullKey) || regex.IsMatch(key.Name) + : pattern._string == fullKey || pattern._string == key.Name) + { + return true; + } + } + + return false; + } + // Reading every trigger takes several scheduler queries, so the result is reused for a minute. private async Task GetTriggerSchedulesAsync( IScheduler scheduler, @@ -166,7 +232,7 @@ private async Task GetTriggerSchedulesAsync( if (_triggerSchedules is not { } schedules || schedules.ReadAt + TriggerSchedulesTtl <= now || schedules.Scheduler != scheduler || !schedules.Contains(triggerKey)) { - schedules = await TriggerSchedules.ReadAsync(scheduler, now, cancellationToken).ConfigureAwait(false); + schedules = await TriggerSchedules.ReadAsync(this, scheduler, now, cancellationToken).ConfigureAwait(false); _triggerSchedules = schedules; } @@ -196,19 +262,20 @@ private TriggerSchedules(IScheduler scheduler, DateTimeOffset readAt, _keys = keys; } - public static async Task ReadAsync(IScheduler scheduler, DateTimeOffset readAt, - CancellationToken cancellationToken) + public static async Task ReadAsync(JobMonitor monitor, IScheduler scheduler, + DateTimeOffset readAt, CancellationToken cancellationToken) { var triggersBySlug = new Dictionary>(); var keys = new HashSet(); - var triggers = await QuartzApi.GetTriggersAsync(scheduler, IsMonitored, cancellationToken).ConfigureAwait(false); + var triggers = await QuartzApi.GetTriggersAsync(scheduler, monitor.IsMonitored, cancellationToken) + .ConfigureAwait(false); foreach (var (job, trigger) in triggers) { keys.Add(trigger.Key); - // Triggers whose schedule isn't sent don't change the monitor - var attribute = GetAttribute(QuartzApi.GetJobType(job))!; - if (!attribute.SendMonitorConfig + // Triggers whose runs aren't monitored, or whose schedule isn't sent, don't change the monitor + var attribute = GetAttribute(QuartzApi.GetJobType(job)) ?? DefaultAttribute; + if (attribute is not { SendMonitorConfig: true } || TriggerSchedule.ToSchedule(trigger) is not { } schedule || GetMonitorSlug(job, trigger, attribute) is not { } slug) { @@ -240,18 +307,18 @@ public List WithDifferentSchedule(string monitorSlug, TriggerKey tri .Select(trigger => trigger.Key) .ToList(); } + } - // Jobs whose type can't be resolved, such as a deleted job class still in a persistent store, are skipped - private static bool IsMonitored(IJobDetail job) + // Jobs whose type can't be resolved, such as a deleted job class still in a persistent store, are skipped + private bool IsMonitored(IJobDetail job) + { + try { - try - { - return GetAttribute(QuartzApi.GetJobType(job)) is not null; - } - catch (Exception) - { - return false; - } + return GetAttribute(QuartzApi.GetJobType(job)) is not null || IsMonitoredByDefault(job); + } + catch (Exception) + { + return false; } } diff --git a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs index 0aead11422..10a87a5788 100644 --- a/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs +++ b/src/Sentry.Quartz/Core/SentryMonitorSlugAttribute.cs @@ -5,7 +5,7 @@ namespace Sentry.Quartz; /// /// /// -/// Jobs without this attribute are not monitored. +/// Jobs without this attribute are not monitored, unless is enabled. /// /// /// When no monitor slug is given, the slug is the job's group and name joined by a hyphen, normalized the way Sentry diff --git a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs index 2708b6d419..5bb549f1c1 100644 --- a/src/Sentry.Quartz/Core/SentryQuartzOptions.cs +++ b/src/Sentry.Quartz/Core/SentryQuartzOptions.cs @@ -7,6 +7,34 @@ namespace Sentry.Quartz; /// public class SentryQuartzOptions { + /// + /// When enabled, every job fired by a trigger whose schedule Sentry can represent sends check-ins, as if it had + /// without a monitor slug. Defaults to false. + /// + /// + /// + /// The monitor slug is the slug of the job's key, as described on . Jobs + /// built without an identity are skipped, and so are runs fired by other triggers, such as a job triggered once by + /// hand or a trigger with a calendar. lists the schedules Sentry can represent. + /// Jobs with several triggers on different schedules are skipped, and so are jobs that share a monitor slug with + /// another job on a different schedule. Jobs with keep its settings and + /// still send check-ins. + /// + /// + /// To skip some jobs, add them to . + /// + /// + public bool MonitorAllJobs { get; set; } + + /// + /// Jobs that doesn't monitor. A string matches a job whose name or full key + /// (group.name) is equal to it, and a regular expression a job whose name or full key it matches. + /// + /// + /// Jobs with are monitored even when they match. + /// + public IList ExcludedJobs { get; set; } = new List(); + /// /// When enabled, the in-progress check-in of each job with includes /// the schedule of the trigger that fired the job as the monitor config, so Sentry creates the monitor or updates diff --git a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs index 2c15736eb2..f873087e0b 100644 --- a/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs +++ b/src/Sentry.Quartz/SentryQuartzBuilderExtensions.cs @@ -25,7 +25,8 @@ public static class SentryQuartzBuilderExtensions /// /// Jobs with also send a check-in each time they run, with the /// schedule of the trigger that fired them as the monitor config. A job that throws or is cancelled sends an - /// error check-in. See . + /// error check-in. See . To monitor every scheduled job, + /// enable . /// /// /// Configures the options. diff --git a/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 35f64f8b6b..b536bcc3af 100644 --- a/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Quartz.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -19,6 +19,8 @@ { public SentryQuartzOptions() { } public System.Action? ConfigureMonitorOptions { get; set; } + public System.Collections.Generic.IList ExcludedJobs { get; set; } + public bool MonitorAllJobs { get; set; } public bool SendMonitorConfig { get; set; } } } \ No newline at end of file diff --git a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs index c500d5d7f3..57d6e111a4 100644 --- a/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs +++ b/test/Sentry.Quartz.Tests/Core/JobMonitorTests.cs @@ -554,6 +554,351 @@ public async Task StartAsync_JobWithoutIdentity_UsesClassNameAndLogs() "an identity (WithIdentity(...)) or set a slug ([SentryMonitorSlug(\"…\")]) to keep the monitor stable."); } + [Fact] + public async Task StartAsync_MonitorAllJobsWithoutAttribute_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + var monitorConfig = MonitorConfigJson.Render(_fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("0 12 * * *"); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsDefaultGroup_UsesJobName() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("Nightly Cleanup").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("nightly-cleanup"); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsRepeatingSimpleTrigger_CapturesCheckIn() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromMinutes(15)).RepeatForever()) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsOneOffTrigger_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create().StartNow().Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsScheduleSentryCantRepresent_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsSimpleTriggerWithRepeatCount_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.Trigger = TriggerBuilder.Create() + .WithSimpleSchedule(schedule => schedule.WithInterval(TimeSpan.FromMinutes(15)).WithRepeatCount(3)) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsAttributeWithScheduleSentryCantRepresent_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Trigger = TriggerBuilder.Create() + .WithCronSchedule("0 0 12 L * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)) + .Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsWithoutIdentity_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + } + + [Theory] + [InlineData("DailyEmail")] + [InlineData("reports.DailyEmail")] + public async Task StartAsync_MonitorAllJobsExcludedByString_CapturesNothing(string excluded) + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add(excluded); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Logger.Entries.Should().Contain(e => e.Message.Contains("ExcludedJobs")); + } + + [Theory] + [InlineData("Daily")] + [InlineData("reports")] + [InlineData("reports.DailyEmail.Other")] + public async Task StartAsync_MonitorAllJobsStringOnlyPartlyMatches_CapturesCheckIn(string excluded) + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add(excluded); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsExcludedByRegex_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add(new Regex("^reports\\.")); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + } + + [Fact] + public async Task StartAsync_ExcludedJobWithAttribute_CapturesCheckIn() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add("reports.DailyEmail"); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsDataMapSlug_UsesThatSlug() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports") + .UsingJobData(SentryMonitorSlugAttribute.JobDataKey, "daily-email").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("daily-email"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartAsync_MonitorAllJobsJobHasTriggersWithDifferentSchedules_CapturesNothing(bool sendMonitorConfig) + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.SendMonitorConfig = sendMonitorConfig; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + _fixture.Logger.Entries.Should().Contain(e => e.Message.Contains("MonitorAllJobs only monitors")); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsJobHasTriggersWithSameSchedule_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 12 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsSendMonitorConfigDisabled_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.SendMonitorConfig = false; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsAttributeJobHasTriggersWithDifferentSchedules_CapturesCheckInWithoutMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().BeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsOtherJobWithoutAttributeSharesSlug_CapturesNothing() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + var other = JobBuilder.Create().WithIdentity("DailyEmail", "Reports").Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 18 * * ?", other))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn.Should().BeNull(); + _fixture.Hub.DidNotReceiveWithAnyArgs().CaptureCheckIn(default!, default); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsConfigureMonitorOptionsSetsSchedule_CapturesCheckInWithThatSchedule() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ConfigureMonitorOptions = (_, options) => options.Interval(5, SentryMonitorInterval.Minute); + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + _fixture.OtherTriggers.Add((_fixture.JobDetail, CronTrigger("0 0 18 * * ?", _fixture.JobDetail))); + + // Act + var checkIn = await _fixture.StartAsync(); + + // Assert + checkIn!.MonitorSlug.Should().Be("reports-dailyemail"); + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsExcludedJobSharesSlug_CapturesCheckInWithMonitorConfig() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.Options.ExcludedJobs.Add("Reports.DailyEmail"); + var other = JobBuilder.Create().WithIdentity("DailyEmail", "Reports").Build(); + _fixture.OtherTriggers.Add((other, CronTrigger("0 0 18 * * ?", other))); + + // Act + await _fixture.StartAsync(); + + // Assert + _fixture.Hub.ReceivedConfigureMonitorOptions("reports-dailyemail").Should().NotBeNull(); + } + + [Fact] + public async Task StartAsync_MonitorAllJobsWithinTtl_LooksUpTriggersOnce() + { + // Arrange + _fixture.Options.MonitorAllJobs = true; + _fixture.JobDetail = JobBuilder.Create().WithIdentity("DailyEmail", "reports").Build(); + var sut = _fixture.GetSut(); + var context = _fixture.GetContext(); + + // Act + await sut.StartAsync(context, CancellationToken.None); + await sut.StartAsync(context, CancellationToken.None); + + // Assert + _fixture.Scheduler.LookedUpTriggers(1); + } + [Fact] public void Finish_HubThrows_DoesNotThrow() { diff --git a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs index 928c041433..2ae218fcb2 100644 --- a/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs +++ b/test/Sentry.Quartz.Tests/SentryQuartzBuilderExtensionsTests.cs @@ -63,6 +63,23 @@ public async Task AddSentry_SimpleTriggerJobFails_CapturesCheckInsWithIntervalAn _hub.Received(1).CaptureEvent(Arg.Is(e => e.Exception is InvalidOperationException)); } + [Fact] + public async Task AddSentry_MonitorAllJobs_CapturesCheckInsForJobWithoutAttribute() + { + // Arrange + var second = DateTimeOffset.UtcNow.AddSeconds(3).Second; + var options = new SentryQuartzOptions { MonitorAllJobs = true }; + + // Act + var status = await RunUntilCheckInCompletes("Nightly Report", null, trigger => trigger + .WithCronSchedule($"{second} * * * * ?", schedule => schedule.InTimeZone(TimeZoneInfo.Utc)), options); + + // Assert + status.Should().Be(CheckInStatus.Ok); + var monitorConfig = MonitorConfigJson.Render(_hub.ReceivedConfigureMonitorOptions("nightly-report")!); + monitorConfig.GetProperty("schedule").GetProperty("value").GetString().Should().Be("* * * * *"); + } + [Fact] public async Task AddSentry_MonitoredJob_CapturesCheckInsInJobScope() { @@ -89,7 +106,7 @@ await RunUntilCheckInCompletes("Cleanup", null, trigger => trigger public async Task AddSentry_JobFailsWithLoggingIntegration_CapturesExceptionOnce() { // Act - await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger.StartNow(), services => services + await RunUntilCheckInCompletes("Sync", "billing", trigger => trigger.StartNow(), configureServices: services => services .AddSingleton(_hub) .AddLogging(logging => logging.AddSentry(options => options.InitializeSdk = false))); @@ -162,9 +179,10 @@ private async Task RunUntilCheckInCompletes( string name, string? group, Action> configureTrigger, + SentryQuartzOptions? options = null, Action? configureServices = null) where TJob : IJob { - var scheduler = await BuildScheduler(name, group, configureTrigger, configureServices); + var scheduler = await BuildScheduler(name, group, configureTrigger, options, configureServices); await scheduler.Start(); try { @@ -182,6 +200,7 @@ private async Task BuildScheduler( string name, string? group, Action> configureTrigger, + SentryQuartzOptions? options = null, Action? configureServices = null) where TJob : IJob { var services = new ServiceCollection(); @@ -190,7 +209,7 @@ private async Task BuildScheduler( services.AddQuartz(properties, quartz => { quartz.UseInMemoryStore(); - quartz.AddSentry(new SentryQuartzOptions(), _hub); + quartz.AddSentry(options ?? new SentryQuartzOptions(), _hub); var jobKey = new JobKey(name, group ?? JobKey.DefaultGroup); quartz.AddJob(job => job.WithIdentity(jobKey));