Quartz.NET Monitoring: The Complete Guide for .NET Developers
Updated
Contents
To monitor Quartz.NET, record every execution with a job listener, watch for misfires and triggers in the ERROR state, and add an external check that alerts when an expected run never completes. Quartz.NET logs an exception thrown by a job and hands it to your listeners, but it does not alert anyone, and nothing inside the process can report a run that never happened because the host was down.
This guide covers Quartz.NET 3.x, which the code below and the CronRadar.Quartz package target. Quartz.NET 4.x targets .NET 10 only and changes most async members from Task to ValueTask, so adapt the listener signatures if you have upgraded. See the Quartz.NET 4.x migration guide.
What Quartz.NET Does When a Job Fails#
| What happens | What Quartz.NET does | What you see by default |
|---|---|---|
Execute throws |
Logs the exception, passes it to job listeners as a JobExecutionException, and keeps the trigger on its schedule |
An error log entry, if Quartz.NET logging is wired up |
| The job cannot be created (its constructor or a dependency throws) | Reports a scheduler error and sets every trigger of that job to the ERROR state | A trigger that stops firing until you fix and reschedule it |
| A fire time passes while no worker thread is free or the scheduler is paused | Treats the firing as a misfire once it is later than the misfire threshold (60 seconds for a database job store, 5 seconds for an in-memory store set up with AddQuartz; see misfire instructions) and applies the trigger's misfire instruction |
Nothing, unless a trigger listener handles TriggerMisfired |
| The host process stops or crashes | Nothing runs | Nothing |
| A job runs far longer than usual | Lets it run; Quartz.NET does not stop a long-running job for you | Nothing |
The first two rows are easy to confuse. An exception from Execute does not stop the schedule; the next fire time runs normally. A failure to create the job does. Quartz.NET's job runner sets all of the job's triggers to the ERROR state when instantiation fails, so a dependency-injection mistake introduced in a deployment can silently stop a job. See JobRunShell in the Quartz.NET source.
Only the first row leaves a log entry by default. The others need a listener or an external check, and a stopped host can only be detected from outside the process.
Quartz.NET Listeners#
Quartz.NET 3.x has job, trigger, and scheduler listeners. Each has a support base class in Quartz.Listener with empty virtual methods, so you override only the events you need.
Job listener: record each result#
using Microsoft.Extensions.Logging;
using Quartz;
using Quartz.Listener;
public class JobExecutionListener : JobListenerSupport
{
private readonly ILogger<JobExecutionListener> _logger;
public JobExecutionListener(ILogger<JobExecutionListener> logger) => _logger = logger;
public override string Name => "JobExecutionListener";
public override Task JobToBeExecuted(IJobExecutionContext context,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Job {JobKey} starting. Scheduled: {ScheduledTime}, actual: {FireTime}",
context.JobDetail.Key,
context.ScheduledFireTimeUtc,
context.FireTimeUtc);
return Task.CompletedTask;
}
public override Task JobWasExecuted(IJobExecutionContext context,
JobExecutionException? jobException,
CancellationToken cancellationToken = default)
{
if (jobException is null)
{
_logger.LogInformation("Job {JobKey} completed in {Duration}",
context.JobDetail.Key, context.JobRunTime);
}
else
{
_logger.LogError(jobException, "Job {JobKey} failed after {Duration}",
context.JobDetail.Key, context.JobRunTime);
}
return Task.CompletedTask;
}
}Trigger listener: catch misfires#
using Microsoft.Extensions.Logging;
using Quartz;
using Quartz.Listener;
public class MisfireLoggingListener : TriggerListenerSupport
{
private readonly ILogger<MisfireLoggingListener> _logger;
public MisfireLoggingListener(ILogger<MisfireLoggingListener> logger) => _logger = logger;
public override string Name => "MisfireLoggingListener";
public override Task TriggerMisfired(ITrigger trigger,
CancellationToken cancellationToken = default)
{
_logger.LogWarning(
"Trigger {TriggerKey} for job {JobKey} misfired (misfire instruction {Instruction})",
trigger.Key, trigger.JobKey, trigger.MisfireInstruction);
return Task.CompletedTask;
}
}Misfire detection needs a running scheduler. If the process was down at the fire time, there may be nothing left to report a misfire when it starts again.
Scheduler listener: standby, shutdown, and errors#
using Microsoft.Extensions.Logging;
using Quartz;
using Quartz.Listener;
public class SchedulerHealthListener : SchedulerListenerSupport
{
private readonly ILogger<SchedulerHealthListener> _logger;
public SchedulerHealthListener(ILogger<SchedulerHealthListener> logger) => _logger = logger;
public override Task SchedulerInStandbyMode(CancellationToken cancellationToken = default)
{
_logger.LogWarning("Quartz scheduler entered standby mode; triggers will not fire");
return Task.CompletedTask;
}
public override Task SchedulerShuttingdown(CancellationToken cancellationToken = default)
{
_logger.LogWarning("Quartz scheduler is shutting down");
return Task.CompletedTask;
}
public override Task SchedulerError(string msg, SchedulerException cause,
CancellationToken cancellationToken = default)
{
_logger.LogError(cause, "Quartz scheduler error: {Message}", msg);
return Task.CompletedTask;
}
}Job-instantiation failures arrive here through SchedulerError, so this listener is where a broken constructor first shows up.
Register all three with the Microsoft DI integration. Listeners are resolved from the container, so constructor injection works:
using Quartz;
using Quartz.Impl.Matchers;
builder.Services.AddQuartz(q =>
{
q.AddJobListener<JobExecutionListener>(GroupMatcher<JobKey>.AnyGroup());
q.AddTriggerListener<MisfireLoggingListener>(GroupMatcher<TriggerKey>.AnyGroup());
q.AddSchedulerListener<SchedulerHealthListener>();
});Health Checks for Kubernetes and Load Balancers#
If you host Quartz.NET with Quartz.AspNetCore and AddQuartzServer, it registers an ASP.NET Core health check named quartz-scheduler that reports unhealthy when the scheduler is not running or cannot reach its store. See the ASP.NET Core integration docs.
With AddQuartzHostedService, a small check of your own covers the same ground. Check IsShutdown and InStandbyMode before IsStarted, because IsStarted only tells you that Start() was called at some point:
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Quartz;
public class QuartzHealthCheck : IHealthCheck
{
private readonly ISchedulerFactory _schedulerFactory;
public QuartzHealthCheck(ISchedulerFactory schedulerFactory) => _schedulerFactory = schedulerFactory;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
if (scheduler.IsShutdown || !scheduler.IsStarted)
return HealthCheckResult.Unhealthy("Quartz scheduler is not running");
if (scheduler.InStandbyMode)
return HealthCheckResult.Degraded("Quartz scheduler is in standby mode");
var metadata = await scheduler.GetMetaData(cancellationToken);
var data = new Dictionary<string, object>
{
["jobs_executed"] = metadata.NumberOfJobsExecuted,
["running_since"] = metadata.RunningSince?.ToString("O") ?? "unknown",
["thread_pool_size"] = metadata.ThreadPoolSize,
};
return HealthCheckResult.Healthy("Quartz scheduler is running", data);
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Could not read Quartz scheduler status", ex);
}
}
}using Microsoft.AspNetCore.Diagnostics.HealthChecks;
builder.Services.AddHealthChecks()
.AddCheck<QuartzHealthCheck>("quartz", tags: new[] { "ready" });
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
});A healthy scheduler does not prove that last night's job finished. Keep health checks for availability decisions and use the checks below for the work itself.
Common Failure Patterns and How to Catch Them#
Pattern 1: The scheduler stops firing#
Thread-pool exhaustion, a lost database connection in a clustered job store, or a host that was stopped and not restarted all look the same from the outside: no jobs run and nothing is logged.
A lightweight canary job makes this visible within minutes even when your real jobs run only once a day. It reports a heartbeat on every run, and the external monitor alerts when heartbeats stop. This one uses the CronRadar HTTP client registered in the listener section below, and its monitor key names the canary itself:
using Quartz;
[DisallowConcurrentExecution]
public class SchedulerCanaryJob : IJob
{
private readonly IHttpClientFactory _httpClientFactory;
public SchedulerCanaryJob(IHttpClientFactory httpClientFactory) =>
_httpClientFactory = httpClientFactory;
public async Task Execute(IJobExecutionContext context)
{
try
{
var client = _httpClientFactory.CreateClient("CronRadar");
using var response = await client.PostAsync(
"ping/quartz-scheduler-canary?schedule=*/5%20*%20*%20*%20*",
content: null,
context.CancellationToken);
}
catch (Exception)
{
// Monitoring must never fail the job. A missing heartbeat is the signal.
}
}
}Schedule it with the Quartz expression 0 0/5 * * * ?, the six-field equivalent of */5 * * * *. If you need a refresher on the two formats, see cron expression syntax.
Pattern 2: Triggers stuck in the ERROR state#
When a job cannot be instantiated, typically because its constructor throws or a registered dependency cannot be resolved, Quartz.NET moves the job's triggers to the ERROR state and they stop firing. Scan for it:
using Quartz;
using Quartz.Impl.Matchers;
public class TriggerErrorStateMonitor : BackgroundService
{
private readonly ISchedulerFactory _schedulerFactory;
private readonly ILogger<TriggerErrorStateMonitor> _logger;
public TriggerErrorStateMonitor(
ISchedulerFactory schedulerFactory,
ILogger<TriggerErrorStateMonitor> logger)
{
_schedulerFactory = schedulerFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
do
{
try
{
var scheduler = await _schedulerFactory.GetScheduler(stoppingToken);
var triggerKeys = await scheduler.GetTriggerKeys(
GroupMatcher<TriggerKey>.AnyGroup(), stoppingToken);
foreach (var key in triggerKeys)
{
if (await scheduler.GetTriggerState(key, stoppingToken) == TriggerState.Error)
{
_logger.LogError("Trigger {TriggerKey} is in the ERROR state and will not fire", key);
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A failed scan must not stop the host.
_logger.LogWarning(ex, "Could not read Quartz trigger states");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}Register it with builder.Services.AddHostedService<TriggerErrorStateMonitor>(). Fix the cause, then reschedule the trigger. A schedule-based monitor catches this case too: the job stops reporting, so its next expected run is reported missing.
Pattern 3: Long-running jobs#
Quartz.NET does not time out a job. If a job scheduled every 15 minutes takes 20, what happens next depends on the job class:
- Without
[DisallowConcurrentExecution], the next firing starts while the previous one is still running, and the two overlap. - With
[DisallowConcurrentExecution], Quartz.NET will not run two instances of the same job definition at once. The next firing waits for a free slot and can become a misfire. The attribute applies perJobDetail, so two job definitions that use the same class can still run together. See More about jobs.
Pass context.CancellationToken to async work so an interrupt or scheduler shutdown can stop the job, and put timeouts on the calls the job makes. To spot a run that started and never finished, report a start and a completion for each execution; the job listener in the next section does that.
Pattern 4: Exceptions nobody reads#
Quartz.NET catches an exception from Execute, logs it, and keeps the trigger on its schedule. The documentation recommends throwing only JobExecutionException from Execute; it can also tell the scheduler to refire the job. Refiring is immediate and unlimited, so bound it with context.RefireCount:
using Quartz;
public class InvoiceSyncJob : IJob
{
private readonly InvoiceSync _invoiceSync;
public InvoiceSyncJob(InvoiceSync invoiceSync) => _invoiceSync = invoiceSync;
public async Task Execute(IJobExecutionContext context)
{
try
{
await _invoiceSync.RunAsync(context.CancellationToken);
}
catch (HttpRequestException ex) when (context.RefireCount < 2)
{
// Transient network failure: run again now, at most twice.
throw new JobExecutionException(ex, refireImmediately: true);
}
catch (Exception ex) when (ex is not JobExecutionException)
{
throw new JobExecutionException(ex);
}
}
}InvoiceSync stands for your own service. Logging the exception is not the same as being told about it. The next two sections send each result somewhere that alerts.
Monitor Every Job with CronRadar.Quartz#
The CronRadar.Quartz package discovers your Quartz.NET jobs and reports their executions without per-job code. In the published 0.1.0, only jobs on repeating simple triggers get a monitor: a cron trigger's six-field expression is sent unchanged and rejected. It is published on NuGet, targets .NET 6 through 9, and supports Quartz.NET 3.x (3.4 and later).
Package status checked September 20, 2026: NuGet installs CronRadar.Quartz 0.1.0, and the MonitorAll setup API is available. The 0.1.1 release that the repository documentation describes is pending. Check the Quartz.NET integration guide for the current notice before relying on a specific behavior.
Install it in the project that hosts the scheduler:
dotnet add package CronRadar.QuartzCreate a CronRadar application for each environment and store its key as CRONRADAR_API_KEY in your deployment's secret configuration. Never commit it or reuse a production key in staging.
Call MonitorAll() after registering jobs and triggers:
using Quartz;
using CronRadar.Quartz;
var schedulerFactory = new StdSchedulerFactory();
IScheduler scheduler = await schedulerFactory.GetScheduler();
// register your jobs and triggers as usual
await scheduler.ScheduleJob(myJob, myTrigger);
// hook CronRadar in
await scheduler.MonitorAll();
await scheduler.Start();If you host Quartz.NET with AddQuartz() and AddQuartzHostedService(), call it from a hosted service after the scheduler starts:
public class CronRadarBootstrap : IHostedService
{
private readonly ISchedulerFactory _factory;
public CronRadarBootstrap(ISchedulerFactory factory) => _factory = factory;
public async Task StartAsync(CancellationToken ct)
{
var scheduler = await _factory.GetScheduler(ct);
await scheduler.MonitorAll();
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}When the scheduler starts, the extension walks every registered job and trigger, declares each job with its schedule, and installs a job listener that reports start, completion, and failure for every execution. 0.1.0 converts a repeating simple trigger to an approximate five-field schedule but sends a cron trigger's six-field expression unchanged, which CronRadar rejects. It never changes job execution: if Execute throws, Quartz.NET's normal handling runs unchanged, and CronRadar network errors are caught and written to the console only when CRONRADAR_DEBUG=true.
Two attributes adjust it per job class:
[SkipMonitor]excludes a job from monitoring.[CronRadar("critical-payment-processor")]overrides the monitor key, which is otherwise derived from the job'sJobKey.
In 0.1.0 every cron trigger, 0/30 * * * * ? included, is sent unchanged and rejected without a warning; the only console line is "Reconciled N monitor(s)", which counts the rejected jobs too. A repeating simple trigger shorter than a minute is monitored as * * * * *. Until the pending 0.1.1 ships, give a cron-triggered job [SkipMonitor] and report it with the base SDK's WrapAsync and a five-field UTC schedule. CronRadar evaluates schedules in UTC, and Quartz cron triggers use the host's local time zone unless you set one, so confirm that each discovered schedule matches when the job actually runs.
To verify the setup, start the scheduler, confirm each recurring trigger appears in the intended CronRadar application, let one job run, and confirm a successful or failed result. Then choose alert destinations: email, Slack, webhook, Discord, or PagerDuty. For one-off delivery checks, use a harmless job in a separate test application rather than failing a production job. The Quartz.NET monitoring overview summarizes coverage, and the .NET guide covers jobs outside Quartz.NET.
Alternative: Report from Your Own Listener#
If you cannot add a package, a job listener can call the HTTP API directly. Don't use it alongside CronRadar.Quartz for the same jobs, or each run is reported twice.
Register an HTTP client with a five-second timeout and Basic authentication. The API key is the username and the password is empty:
using System.Net.Http.Headers;
using System.Text;
var apiKey = builder.Configuration["CRONRADAR_API_KEY"]; // from the environment, never committed
builder.Services.AddHttpClient("CronRadar", client =>
{
client.BaseAddress = new Uri("https://cron.life/api/v1/");
client.Timeout = TimeSpan.FromSeconds(5);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{apiKey}:")));
});The listener reads each job's monitor key and five-field schedule from its job data. It sends a start before the job, then a completion or a failure, and shares Quartz's fire-instance ID as the run ID so overlapping runs stay distinct:
using Microsoft.Extensions.Logging;
using Quartz;
using Quartz.Listener;
public class CronRadarJobListener : JobListenerSupport
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CronRadarJobListener> _logger;
public CronRadarJobListener(
IHttpClientFactory httpClientFactory,
ILogger<CronRadarJobListener> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public override string Name => "CronRadarJobListener";
public override Task JobToBeExecuted(IJobExecutionContext context,
CancellationToken cancellationToken = default)
{
var schedule = context.MergedJobDataMap.GetString("CronRadarSchedule");
var extra = schedule is null ? null : $"schedule={Uri.EscapeDataString(schedule)}";
return Report(context, "start", extra, cancellationToken);
}
public override Task JobWasExecuted(IJobExecutionContext context,
JobExecutionException? jobException,
CancellationToken cancellationToken = default)
{
if (jobException is null)
return Report(context, "complete", null, cancellationToken);
// The job runs again immediately; that attempt reports its own result.
if (jobException.RefireImmediately)
return Task.CompletedTask;
// Send the exception type only: messages can contain data that must not leave the app.
var message = $"Job threw {jobException.GetBaseException().GetType().Name}";
return Report(context, "fail", $"message={Uri.EscapeDataString(message)}", cancellationToken);
}
private async Task Report(IJobExecutionContext context, string lifecycleEvent,
string? extraQuery, CancellationToken cancellationToken)
{
var monitorKey = context.MergedJobDataMap.GetString("CronRadarKey");
if (string.IsNullOrEmpty(monitorKey))
return;
var query = $"run_id={Uri.EscapeDataString(context.FireInstanceId)}&attempt={context.RefireCount}";
if (extraQuery is not null)
query += "&" + extraQuery;
try
{
var client = _httpClientFactory.CreateClient("CronRadar");
using var response = await client.PostAsync(
$"ping/{Uri.EscapeDataString(monitorKey)}/{lifecycleEvent}?{query}",
content: null,
cancellationToken);
if (!response.IsSuccessStatusCode)
_logger.LogWarning("CronRadar returned {Status} for {MonitorKey}",
(int)response.StatusCode, monitorKey);
}
catch (Exception ex)
{
// Never let monitoring change the job's outcome.
_logger.LogWarning(ex, "CronRadar {Event} report failed for {MonitorKey}",
lifecycleEvent, monitorKey);
}
}
}Give each job a stable key that names it, and set its trigger's time zone to UTC so the Quartz schedule and the CronRadar schedule describe the same times:
using Quartz;
using Quartz.Impl.Matchers;
builder.Services.AddQuartz(q =>
{
q.AddJobListener<CronRadarJobListener>(GroupMatcher<JobKey>.AnyGroup());
var invoiceSync = new JobKey("invoice-sync");
q.AddJob<InvoiceSyncJob>(opts => opts
.WithIdentity(invoiceSync)
.UsingJobData("CronRadarKey", "invoice-sync")
.UsingJobData("CronRadarSchedule", "*/15 * * * *"));
q.AddTrigger(opts => opts
.ForJob(invoiceSync)
.WithIdentity("invoice-sync-trigger")
.WithCronSchedule("0 0/15 * * * ?", cron => cron.InTimeZone(TimeZoneInfo.Utc)));
});
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);The first start request includes the schedule, so CronRadar creates the monitor if it does not exist. A failure report opens an incident immediately; a start without a completion and a run that never arrives are caught against the schedule. For the general pattern behind this, see dead man's switch monitoring.
OpenTelemetry#
For tracing on Quartz.NET 3.x, the prerelease OpenTelemetry.Instrumentation.Quartz package adds AddQuartzInstrumentation() to your tracer provider. It supports Quartz.NET 3.4 through 3.x and covers traces only. Quartz.NET 4.x publishes its own activity source and a Quartz meter instead. Traces show what a run did; they do not alert you when a run never starts.
Keep Quartz.NET Errors in Your Logs#
If Quartz.NET's scheduler logging is too noisy, raise the minimum level for its namespace and keep the errors that record failed jobs:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Quartz": "Warning"
}
}
}Don't raise the Quartz category above Error, for example to Critical or None: unhandled job exceptions and instantiation failures are logged as errors.
Production Checklist#
- A job listener records the start, duration, and result of every execution.
- A trigger listener logs misfires, and a scheduler listener logs standby, shutdown, and
SchedulerError. - Something scans for triggers in the ERROR state, or a schedule-based monitor reports the missing run.
- Jobs throw
JobExecutionException, and immediate refires are bounded withRefireCount. - Long jobs observe
context.CancellationTokenand their own timeouts;[DisallowConcurrentExecution]is set where overlap is unsafe. - A health check reports scheduler availability, separately from job results.
- An external monitor alerts on failed and missing runs, and someone has confirmed a test alert reached them.
Frequently Asked Questions#
How do I monitor Quartz.NET jobs?#
Use a job listener to record each execution's result, a trigger listener for misfires, and a scheduler listener for standby, shutdown, and errors. Add an external monitor that knows each job's schedule, because nothing inside the process can report a run that never happened while the host was down.
What puts a Quartz.NET trigger in the ERROR state?#
A failure to create the job. When the job's constructor throws or one of its dependencies cannot be resolved, Quartz.NET sets every trigger of that job to the ERROR state and they stop firing. An exception thrown from Execute does not do this; the trigger keeps its schedule.
Does DisallowConcurrentExecution prevent overlapping runs?#
Yes, for a single job definition. Quartz.NET will not run two instances of the same JobDetail at once; the next firing waits and can misfire. Two job definitions that use the same class can still run concurrently.
How do I detect Quartz.NET misfires?#
Implement TriggerMisfired in a trigger listener. A firing counts as misfired once it is later than the misfire threshold (in Quartz.NET 3.x, 60 seconds for a database job store and 5 seconds for an in-memory store configured through AddQuartz), and the trigger's misfire instruction then decides whether it runs now or skips. Misfire detection only works while the scheduler is running.
Does CronRadar.Quartz support Quartz.NET 4?#
The integration guide documents support for Quartz.NET 3.x, version 3.4 and later. It does not list 4.x, which targets .NET 10 and changes most listener signatures. On 4.x, report runs from your own listener over HTTP, adapting the listener above to the 4.x signatures.
Put this guide to work
Connect your Quartz.NET job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar.Quartz 0.1.0. MonitorAll discovers jobs, but this release sends a cron trigger's six-field expression unchanged and CronRadar accepts only five-field cron, so only jobs on repeating simple triggers are monitored; translating cron triggers requires the pending 0.1.1 release. Every reconciliation sets each discovered monitor's grace period to CronRadar.CronRadar.DefaultGracePeriodSeconds, 60 unless set in code, replacing dashboard edits.