diff --git a/.generated.NoMobile.slnx b/.generated.NoMobile.slnx index babff9ea4d..1ca7ed8b1d 100644 --- a/.generated.NoMobile.slnx +++ b/.generated.NoMobile.slnx @@ -118,6 +118,7 @@ + @@ -168,6 +169,7 @@ + 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 c8f5aecc53..b0c237d9e2 100644 --- a/Sentry-CI-Build-Linux-NoMobile.slnf +++ b/Sentry-CI-Build-Linux-NoMobile.slnf @@ -32,6 +32,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.AspNetCore.Blazor.WebAssembly\\Sentry.AspNetCore.Blazor.WebAssembly.csproj", @@ -49,6 +50,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index 3025600da5..403d25eb11 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -34,6 +34,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -55,6 +56,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/Sentry-CI-Build-Windows-arm64.slnf b/Sentry-CI-Build-Windows-arm64.slnf index b2ecf8df01..6cda378799 100644 --- a/Sentry-CI-Build-Windows-arm64.slnf +++ b/Sentry-CI-Build-Windows-arm64.slnf @@ -35,6 +35,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -57,6 +58,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index 528d7817dd..46f97c15df 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -35,6 +35,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -57,6 +58,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index f8d2b3ca69..4e9e69d3b8 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -39,6 +39,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.Android.AssemblyReader\\Sentry.Android.AssemblyReader.csproj", @@ -62,6 +63,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/Sentry-CI-CodeQL.slnf b/Sentry-CI-CodeQL.slnf index 647fe0fa2c..b7d1fc7400 100644 --- a/Sentry-CI-CodeQL.slnf +++ b/Sentry-CI-CodeQL.slnf @@ -22,6 +22,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj" ] diff --git a/Sentry.slnx b/Sentry.slnx index babff9ea4d..1ca7ed8b1d 100644 --- a/Sentry.slnx +++ b/Sentry.slnx @@ -118,6 +118,7 @@ + @@ -168,6 +169,7 @@ + diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index ccd8904461..f1e754f58c 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -21,6 +21,7 @@ "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.Hangfire\\Sentry.Hangfire.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index a6ef473815..845198d90b 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -33,6 +33,7 @@ "samples\\Sentry.Samples.OpenTelemetry.AzureFunctions\\Sentry.Samples.OpenTelemetry.AzureFunctions.csproj", "samples\\Sentry.Samples.OpenTelemetry.Console\\Sentry.Samples.OpenTelemetry.Console.csproj", "samples\\Sentry.Samples.OpenTelemetry.MongoDB\\Sentry.Samples.OpenTelemetry.MongoDB.csproj", + "samples\\Sentry.Samples.Quartz\\Sentry.Samples.Quartz.csproj", "samples\\Sentry.Samples.Serilog\\Sentry.Samples.Serilog.csproj", "src\\Sentry.Analyzers\\Sentry.Analyzers.csproj", "src\\Sentry.AspNet\\Sentry.AspNet.csproj", @@ -51,6 +52,7 @@ "src\\Sentry.OpenTelemetry.Exporter\\Sentry.OpenTelemetry.Exporter.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", "src\\Sentry.Profiling\\Sentry.Profiling.csproj", + "src\\Sentry.Quartz\\Sentry.Quartz.csproj", "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.Analyzers.Tests\\Sentry.Analyzers.Tests.csproj", diff --git a/SentryNoSamples.slnf b/SentryNoSamples.slnf index 7050420d7f..e07c9a652a 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/samples/Sentry.Samples.Quartz/Program.cs b/samples/Sentry.Samples.Quartz/Program.cs new file mode 100644 index 0000000000..8716ad9bf4 --- /dev/null +++ b/samples/Sentry.Samples.Quartz/Program.cs @@ -0,0 +1,78 @@ +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 jobKey1 = new JobKey(nameof(FirstJob)); + quartz.AddJob(opts => opts.WithIdentity(jobKey1)); + quartz.AddTrigger(opts => opts.ForJob(jobKey1).WithIdentity($"{nameof(FirstJob)}-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(); + + 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..20c2fa35c0 --- /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/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index e3eaf125bc..a1a52653b4 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -167,6 +167,7 @@ filterConfigs: - "**/Sentry.csproj" - "**/Sentry.Analyzers.csproj" - "**/Sentry.Hangfire.csproj" + - "**/Sentry.Quartz.csproj" - "**/Sentry.Samples.Hangfire.csproj" - "**/Sentry.Testing.csproj" - "**/Sentry.Testing.CrashableApp.csproj" diff --git a/src/Sentry.Quartz/GlobalConfigurationExtensions.cs b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs new file mode 100644 index 0000000000..34ab03bb1f --- /dev/null +++ b/src/Sentry.Quartz/GlobalConfigurationExtensions.cs @@ -0,0 +1,58 @@ + 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 +{ + /// + /// Adds middleware to track CRON jobs to Sentry + /// + /// + /// Configures the options + /// + public static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, 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 that pushes a scope to sentry before job execution + /// + /// + /// + public static IQuartzBuilder AddSentryScope(this IQuartzBuilder configuration) + { + return configuration.AddJobMiddleware(); + } + + /// + /// For testing + /// + /// + /// + /// + /// + /// + internal static IQuartzBuilder AddSentryCronJobs(this IQuartzBuilder configuration, 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 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/Sentry.Quartz.csproj b/src/Sentry.Quartz/Sentry.Quartz.csproj new file mode 100644 index 0000000000..37254dcaab --- /dev/null +++ b/src/Sentry.Quartz/Sentry.Quartz.csproj @@ -0,0 +1,24 @@ + + + + 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 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/SentryCronJobMiddleware.cs b/src/Sentry.Quartz/SentryCronJobMiddleware.cs new file mode 100644 index 0000000000..d84c89fc78 --- /dev/null +++ b/src/Sentry.Quartz/SentryCronJobMiddleware.cs @@ -0,0 +1,121 @@ +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 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 (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); + } + }); + } + + 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 new file mode 100644 index 0000000000..d05a65c26c --- /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 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; +} diff --git a/src/Sentry.Quartz/SentryMetricsMiddleware.cs b/src/Sentry.Quartz/SentryMetricsMiddleware.cs new file mode 100644 index 0000000000..00a5855b98 --- /dev/null +++ b/src/Sentry.Quartz/SentryMetricsMiddleware.cs @@ -0,0 +1,63 @@ +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; + } +} + +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); + } +}