Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Sentry.Hangfire/GlobalConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ public static IGlobalConfiguration UseSentry(this IGlobalConfiguration configura
return configuration;
}

/// <summary>
/// Adds the Sentry filter that captures check-ins for jobs marked with <see cref="SentryMonitorSlugAttribute"/>.
/// </summary>
/// <param name="configuration">The Hangfire configuration.</param>
/// <param name="configureOptions">Configures the Sentry Hangfire integration.</param>
/// <returns>The Hangfire configuration.</returns>
public static IGlobalConfiguration UseSentry(this IGlobalConfiguration configuration, Action<SentryHangfireOptions> configureOptions)
{
if (configureOptions is null)
{
throw new ArgumentNullException(nameof(configureOptions));
}

var options = new SentryHangfireOptions();
configureOptions(options);
configuration.UseFilter(new SentryServerFilter(null, null, options));
return configuration;
}

/// <summary>
/// For testing
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Sentry.Hangfire/Sentry.Hangfire.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
<PackageReference Include="Hangfire.Core" Version="1.8.12" />
</ItemGroup>

<ItemGroup>
<!-- Sentry's internals aren't visible here because this assembly isn't strong-named -->
<Compile Include="..\Sentry\Internal\CrontabValidator.cs" Link="Internal\CrontabValidator.cs" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Sentry.Hangfire.Tests" PublicKey="$(SentryPublicKey)" />
</ItemGroup>
Expand Down
25 changes: 25 additions & 0 deletions src/Sentry.Hangfire/SentryHangfireOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Sentry.Hangfire;

/// <summary>
/// Options for the Sentry Hangfire integration.
/// </summary>
public class SentryHangfireOptions
{
/// <summary>
/// When enabled, the in-progress check-in of a recurring job includes the job's cron expression and time zone
/// as the monitor config, so Sentry creates the monitor or updates its schedule. Defaults to <c>true</c>.
/// Set to <c>false</c> to manage the monitor's schedule in Sentry instead.
/// </summary>
/// <remarks>
/// <para>
/// The monitor slug still comes from <see cref="SentryMonitorSlugAttribute"/>. If several recurring jobs run the
/// same method, they share one monitor, and each run overwrites the monitor's schedule with its own job's.
/// </para>
/// <para>
/// Schedules that Sentry can't represent are not sent. This includes crons that use seconds other than a fixed
/// value, and time zones that can't be converted to an IANA ID. On .NET Framework, Windows time zone IDs can't be
/// converted, so only <c>UTC</c> and IANA IDs are sent.
/// </para>
/// </remarks>
public bool SendMonitorConfig { get; set; } = true;
}
153 changes: 151 additions & 2 deletions src/Sentry.Hangfire/SentryServerFilter.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
using Hangfire.Server;
using Hangfire.Storage;
using Sentry.Extensibility;
using Sentry.Internal;

namespace Sentry.Hangfire;

internal class SentryServerFilter : IServerFilter
{
internal const string SentryMonitorSlugKey = "SentryMonitorSlug";
internal const string SentryCheckInIdKey = "SentryCheckInIdKey";
internal const string RecurringJobIdKey = "RecurringJobId";

private readonly IHub _hub;
private readonly IDiagnosticLogger? _logger;
private readonly SentryHangfireOptions _options;

internal SentryHangfireOptions Options => _options;

public SentryServerFilter() : this(null, null)
{ }

internal SentryServerFilter(IHub? hub, IDiagnosticLogger? logger)
internal SentryServerFilter(IHub? hub, IDiagnosticLogger? logger, SentryHangfireOptions? options = null)
{
_hub = hub ?? HubAdapter.Instance;
_options = options ?? new SentryHangfireOptions();
#pragma warning disable CS0618 // Type or member is obsolete
_logger = logger ?? _hub.GetInternalSentryOptions()?.DiagnosticLogger;
#pragma warning restore CS0618 // Type or member is obsolete
Expand All @@ -35,14 +42,156 @@ public void OnPerforming(PerformingContext context)
return;
}

var checkInId = _hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress);
var checkInId = CaptureInProgressCheckIn(context, monitorSlug);

// Note that we may be overwriting context.Items[SentryCheckInIdKey] here, which is intentional. If that happens
// then implicitly OnPerforming was called previously with the same context, but we never made it to OnPerformed
// This might happen if a Hangfire job failed at least once, with automatic retries configured.
context.Items[SentryCheckInIdKey] = checkInId;
}

private SentryId CaptureInProgressCheckIn(PerformingContext context, string monitorSlug)
{
Action<SentryMonitorOptions>? configureMonitorOptions = null;
if (_options.SendMonitorConfig && GetRecurringJobSchedule(context) is (var crontab, var timeZone))
{
configureMonitorOptions = options =>
{
options.Interval(crontab);
options.TimeZone = timeZone;
};
}

// Created here, so the final check-in has the same ID even when this one isn't sent
var checkInId = SentryId.Create();
_hub.CaptureCheckIn(monitorSlug, CheckInStatus.InProgress, checkInId, configureMonitorOptions: configureMonitorOptions);
return checkInId;
}

private (string Crontab, string TimeZone)? GetRecurringJobSchedule(PerformingContext context)
{
string? recurringJobId = null;
try
{
recurringJobId = context.GetJobParameter<string>(RecurringJobIdKey);
if (string.IsNullOrEmpty(recurringJobId))
{
return null;
}

var recurringJob = context.Connection.GetRecurringJobs([recurringJobId]).SingleOrDefault();
if (recurringJob is null || recurringJob.Removed)
{
_logger?.LogDebug("Not sending the schedule of recurring job '{0}'. The job no longer exists.", recurringJobId);
return null;
}

var crontab = ToCrontab(recurringJob.Cron);
var timeZone = ToIanaTimeZoneId(recurringJob.TimeZoneId);
if (crontab is null || timeZone is null)
{
_logger?.LogDebug("Not sending the schedule of recurring job '{0}'. Sentry doesn't support " +
"the cron expression '{1}' with time zone '{2}'.", recurringJobId, recurringJob.Cron, recurringJob.TimeZoneId);
return null;
}

return (crontab, timeZone);
}
catch (Exception e)
{
_logger?.LogError(e, "Failed to read the schedule of recurring job '{0}'.", recurringJobId);
return null;
}
}

// Hangfire also accepts a leading seconds field, which Sentry doesn't support.
internal static string? ToCrontab(string? cron)
{
if (string.IsNullOrWhiteSpace(cron))
{
return null;
}

var fields = cron!.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Length == 6
&& int.TryParse(fields[0], NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)
&& seconds < 60)
{
fields = fields.Skip(1).ToArray();
}

if (fields.Length != 5)
{
return null;
}

// Hangfire runs a job only on days that match both day fields, while Sentry runs it on days that match
// either one unless one of them starts with '*'.
if (!fields[2].StartsWith("*") && !fields[4].StartsWith("*"))
{
return null;
}

var crontab = string.Join(" ", fields);
return CrontabValidator.IsValid(crontab) && !HasReversedRange(fields) ? crontab : null;
}

private static readonly string[] DayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];

// Sentry rejects ranges whose start is after their end, such as 5-1 or SAT-SUN
private static bool HasReversedRange(string[] fields)
{
foreach (var field in fields)
{
foreach (var item in field.Split(','))
{
var range = item.Split('/')[0].Split('-');
if (range.Length == 2 && ToNumber(range[0]) > ToNumber(range[1]))
{
return true;
}
}
}

return false;

static int ToNumber(string value) =>
int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var number)
? number
: Array.FindIndex(DayNames, name => string.Equals(name, value, StringComparison.OrdinalIgnoreCase));
}

internal static string? ToIanaTimeZoneId(string? timeZoneId)
{
// Hangfire's default is TimeZoneInfo.Utc, whose ID is "UTC" on every platform
if (string.IsNullOrWhiteSpace(timeZoneId) || timeZoneId == "UTC")
{
return "UTC";
}

#if NET6_0_OR_GREATER
TimeZoneInfo timeZone;
try
{
timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId!);
}
catch (Exception)
{
return null;
}

if (timeZone.HasIanaId)
{
return timeZone.Id;
}

return TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZone.Id, out var ianaId) ? ianaId : null;
#else
// .NET Framework can neither look up nor convert IANA IDs, so only pass on IDs that look like one
return timeZoneId!.Contains("/") ? timeZoneId : null;
#endif
}

public void OnPerformed(PerformedContext context)
{
var monitorSlug = context.GetJobParameter<string>(SentryMonitorSlugKey);
Expand Down
43 changes: 43 additions & 0 deletions src/Sentry/Internal/CrontabValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Text.RegularExpressions;

namespace Sentry.Internal;

/// <summary>
/// Validates the crontab format Sentry accepts for monitor schedules.
/// </summary>
/// <remarks>
/// Also compiled into Sentry.Hangfire, which can't see Sentry's internals because it isn't strong-named.
/// </remarks>
internal static partial class CrontabValidator
{
// Breakdown of the validation regex pattern:
// For each time field (minute, hour, day, month, weekday):
// - Allows * for "any value"
// - Allows */n for step values where n must be any positive integer (except zero)
// - Allows single values within their valid ranges
// - Allows ranges (e.g., 8-10)
// - Allows step values with ranges (e.g., 8-18/4)
// - Allows lists of values and ranges (e.g., 6,8,9 or 8-10,12-14)
// - Allows weekday names (MON, TUE, WED, THU, FRI, SAT, SUN)
//
// Valid ranges for each field:
// - Minutes: 0-59
// - Hours: 0-23
// - Days: 1-31
// - Months: 1-12
// - Weekdays: 0-7 (0 and 7 both represent Sunday) or MON-SUN
private const string ValidCrontabPattern = @"^(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?))*$";

#if NET9_0_OR_GREATER
[GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)]
private static partial Regex ValidCrontab { get; }
#elif NET8_0
[GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)]
private static partial Regex ValidCrontabRegex();
private static readonly Regex ValidCrontab = ValidCrontabRegex();
#else
private static readonly Regex ValidCrontab = new(ValidCrontabPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture);
#endif

public static bool IsValid(string crontab) => ValidCrontab.IsMatch(crontab);
}
34 changes: 3 additions & 31 deletions src/Sentry/SentryMonitorOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sentry.Extensibility;
using Sentry.Internal;
using Sentry.Internal.Extensions;

namespace Sentry;
Expand Down Expand Up @@ -49,42 +50,13 @@ public enum SentryMonitorInterval
/// <summary>
/// Sentry's options for monitors
/// </summary>
public partial class SentryMonitorOptions : ISentryJsonSerializable
public class SentryMonitorOptions : ISentryJsonSerializable
{
// Breakdown of the validation regex pattern:
// For each time field (minute, hour, day, month, weekday):
// - Allows * for "any value"
// - Allows */n for step values where n must be any positive integer (except zero)
// - Allows single values within their valid ranges
// - Allows ranges (e.g., 8-10)
// - Allows step values with ranges (e.g., 8-18/4)
// - Allows lists of values and ranges (e.g., 6,8,9 or 8-10,12-14)
// - Allows weekday names (MON, TUE, WED, THU, FRI, SAT, SUN)
//
// Valid ranges for each field:
// - Minutes: 0-59
// - Hours: 0-23
// - Days: 1-31
// - Months: 1-12
// - Weekdays: 0-7 (0 and 7 both represent Sunday) or MON-SUN
private const string ValidCrontabPattern = @"^(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([0-5]?\d)|([0-5]?\d)-([0-5]?\d)(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([01]?\d|2[0-3])|([01]?\d|2[0-3])-([01]?\d|2[0-3])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|[12]\d|3[01])|([1-9]|[12]\d|3[01])-([1-9]|[12]\d|3[01])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|([1-9]|1[0-2])|([1-9]|1[0-2])-([1-9]|1[0-2])(\/([1-9][0-9]*))?))*(\s+)(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?)(,(\*(\/([1-9][0-9]*))?|[0-7]|(MON|TUE|WED|THU|FRI|SAT|SUN)|[0-7]-[0-7](\/([1-9][0-9]*))?|(MON|TUE|WED|THU|FRI|SAT|SUN)-(MON|TUE|WED|THU|FRI|SAT|SUN)(\/([1-9][0-9]*))?))*$";

private SentryMonitorScheduleType _type = SentryMonitorScheduleType.None;
private string? _crontab;
private int? _interval;
private SentryMonitorInterval? _unit;

#if NET9_0_OR_GREATER
[GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)]
private static partial Regex ValidCrontab { get; }
#elif NET8_0
[GeneratedRegex(ValidCrontabPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)]
private static partial Regex ValidCrontabRegex();
private static readonly Regex ValidCrontab = ValidCrontabRegex();
#else
private static readonly Regex ValidCrontab = new(ValidCrontabPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture);
#endif

/// <summary>
/// Set Interval
/// </summary>
Expand All @@ -96,7 +68,7 @@ public void Interval(string crontab)
throw new ArgumentException("You tried to set the interval twice. The Check-Ins interval is supposed to be set only once.");
}

if (!ValidCrontab.IsMatch(crontab))
if (!CrontabValidator.IsValid(crontab))
{
throw new ArgumentException("The provided crontab does not match the expected format of '* * * * *' " +
"translating to 'minute', 'hour', 'day of the month', 'month', and 'day of the week'.");
Expand Down
11 changes: 9 additions & 2 deletions test/Sentry.Hangfire.Tests/HangfireFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ public HangfireFixture()
_monitoringApi = JobStorage.Current.GetMonitoringApi();
}

public Task Enqueue<T>(Expression<Action<T>> methodCall)
public Task Enqueue<T>(Expression<Action<T>> methodCall) => WaitForJob(BackgroundJob.Enqueue(methodCall));

public Task TriggerRecurringJob<T>(string recurringJobId, Expression<Action<T>> methodCall, string cron, TimeZoneInfo timeZone)
{
RecurringJob.AddOrUpdate(recurringJobId, methodCall, cron, new RecurringJobOptions { TimeZone = timeZone });
return WaitForJob(RecurringJob.TriggerJob(recurringJobId));
}

private Task WaitForJob(string jobId)
{
var jobId = BackgroundJob.Enqueue(methodCall);
var checkJobState = Task.Run(() =>
{
while (true)
Expand Down
Loading
Loading