# Quartz.NET Monitoring: The Complete Guide for .NET Developers

By Dan Ochoa · Published 2025-12-03 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/quartz-net-monitoring

> Monitor Quartz.NET jobs with listeners, misfire and ERROR trigger checks, and health checks, plus alerts when a scheduled job fails or stops running.

**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](https://www.quartz-scheduler.net/documentation/quartz-4.x/migration-guide.html).

## 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](https://cronradar.com/blog/quartz-net-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`](https://github.com/quartznet/quartznet/blob/3.x/src/Quartz/Core/JobRunShell.cs) 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

```csharp
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

```csharp
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

```csharp
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:

```csharp
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](https://www.quartz-scheduler.net/documentation/quartz-3.x/packages/aspnet-core-integration.html).

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:

```csharp
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);
        }
    }
}
```

```csharp
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:

```csharp
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](https://cronradar.com/blog/cron-expression).

### 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:

```csharp
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 per `JobDetail`, so two job definitions that use the same class can still run together. See [More about jobs](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/more-about-jobs.html).

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`:

```csharp
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](https://docs.cronradar.com/integrations/quartz) for the current notice before relying on a specific behavior.

Install it in the project that hosts the scheduler:

```bash
dotnet add package CronRadar.Quartz
```

Create 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:

```csharp
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:

```csharp
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's `JobKey`.

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](https://cronradar.com/quartz) summarizes coverage, and the [.NET guide](https://cronradar.com/dotnet) 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](https://docs.cronradar.com/api-reference) 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:

```csharp
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:

```csharp
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:

```csharp
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](https://cronradar.com/blog/cron-dead-mans-switch).

## 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:

```json
{
  "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 with `RefireCount`.
- Long jobs observe `context.CancellationToken` and 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](https://docs.cronradar.com/integrations/quartz) 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.
