Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Dashboard Agent
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Observability

Quartz publishes traces and metrics through System.Diagnostics: an ActivitySource and a Meter, both named Quartz. No Quartz package is needed; install only the collector. Subscribe with the public constants:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddSource(QuartzInstrumentation.ActivitySourceName)
        .AddOtlpExporter())
    .WithMetrics(metrics => metrics
        .AddMeter(QuartzInstrumentation.MeterName)
        .AddOtlpExporter());

QuartzInstrumentation is in the Quartz.Diagnostics namespace. Both constants are "Quartz", so an existing AddSource("Quartz") keeps working.

Upgrading from 3.x

4.0 renamed every instrument and attribute, removed two of 3.x's four instruments, and added five for misfires, acquisition, cluster check-in and recovery, and store round trips. Dashboards and alerts on the old names break; see the complete old → new table.

Traces

SpanKindWhen
Quartz.Job.ExecuteInternalA job runs; covers the whole fire and carries its result
Quartz.Job.VetoInternalA trigger listener vetoed the fire; the job did not run
Quartz.JobStore.<operation>ClientOne per store operation; names are the members of Quartz.Diagnostics.OperationName.JobStore

The thirty-four store operations are those that change something or hand work to the scheduler:

AcquireNextTriggers, TriggersFired, TriggeredJobComplete, ReleaseAcquiredTrigger, ScheduleJob, ScheduleJobs, AddJob, AddTrigger, StoreTrigger, AddCalendar, DeleteJob, DeleteJobs, DeleteTrigger, DeleteTriggers, DeleteCalendar, ReplaceTrigger, UpdateTriggerDetails, PauseTrigger, PauseTriggers, PauseTriggerGroups, PauseJob, PauseJobs, PauseJobGroups, ResumeTrigger, ResumeTriggers, ResumeTriggerGroups, ResumeJob, ResumeJobs, ResumeJobGroups, PauseAll, ResumeAll, ResetTriggerFromErrorState, ResetTriggersFromErrorState, Clear.

  • Pausing by key and pausing by group matcher are separate spans. The first moves the given triggers; the second records that a group is paused and also catches triggers added to it later.
  • Reads (GetJob, Exists, the Query* members) are not spans, so a dashboard listing triggers does not produce a span per page.

How the spans are shaped into traces

  • A firing is its own trace. Quartz.Job.Execute and Quartz.Job.Veto are always trace roots and never take the ambient Activity as parent. What the job traces (an HttpClient call, an EF Core query, your own spans) are children of the firing.
  • A store span belongs to the caller. scheduler.ScheduleJob(…) inside an HTTP request puts Quartz.JobStore.ScheduleJob in that request's trace.
  • The scheduler loop's store calls are roots. AcquireNextTriggers, TriggersFired and TriggeredJobComplete belong to no request, and the loop runs for the life of the process.

Fixed in 4.2.0

Before 4.2.0, each span the scheduler's loop opened became the parent of the next, and each firing hung off whichever span was current at dispatch. A scheduler produced one trace that grew for the life of the process: a day of a quiet staging pod was one tree of several thousand spans, with the jobs' HttpClient and EF Core spans buried in it (#3797). Upgrading fixes it; no configuration change is needed.

Every store, not just the database one

Store tracing is a decorator over IJobStore, applied to any store: in-memory, a community package's, or your own. Before 4.0.0 only the ADO.NET store emitted store spans.

Span names are constants on Quartz.Diagnostics.OperationName. Attributes are namespaced quartz.* and are constants on Quartz.Diagnostics.ActivityTags:

AttributeOn
quartz.scheduler.name, quartz.scheduler.idevery span
quartz.job.name, quartz.job.group, quartz.job.typejob spans
quartz.trigger.name, quartz.trigger.groupjob spans; store spans about one trigger
quartz.execution.groupjob spans, when the trigger names an execution group
quartz.fire.instance.idjob spans
quartz.job.resultQuartz.Job.Execute: succeeded, failed, cancelled or skipped, from 4.4
quartz.job.name, quartz.job.groupstore spans about one job
quartz.jobstore.batch.sizeQuartz.JobStore.AcquireNextTriggers: triggers requested
quartz.jobstore.trigger.countQuartz.JobStore.AcquireNextTriggers (returned) and .TriggersFired (fired)
quartz.jobstore.trigger.fired_on_acquireQuartz.JobStore.AcquireNextTriggers: of those returned, how many the store fired as it acquired them. They get no .TriggersFired span
quartz.jobstore.lock, quartz.jobstore.lock.reasonQuartz.JobStore.AcquireNextTriggers on the ADO.NET store, from 4.6: the lock the round ran under, TRIGGER_ACCESS or none, and why it took it. See Lock-free firing rounds
error.typeany span that ended in a failure; store spans from 4.4

quartz.fire.instance.id identifies one firing; it is the id IScheduler.InterruptFireInstance takes. quartz.job.result is the value the duration histogram and the execution history record; see Reading the numbers.

Failed spans

A span ends in Error only when something threw:

StatusDescriptionerror.typeexception event
The job or a middleware threwErrorthe exception's messagethe exception typeby default
The job's JobRunReport says FailedUnset—absentnever
The run was cancelledUnset—absentnever
A store operation threwErrorthe exception's messagethe exception typeby default

quartz.job.result is failed for both kinds of failed run.

Exceptions as span events

OpenTelemetry deprecated span events, exception events included, in favour of exceptions as logs. Quartz records them by default. To stop:

services.AddQuartz(q => q.ConfigureScheduler(options => options.RecordExceptionSpanEvents = false));
RecordExceptionSpanEventsOTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_INexception event
null (default)unset, logs/dup or another valuerecorded
null (default)logsnot recorded
trueanythingrecorded
falseanythingnot recorded
  • The span keeps its status, description and error.type either way.
  • The option wins over the variable. The variable is read when the scheduler is built; its value is case-insensitive. The variable is OpenTelemetry's semantic-convention opt-in.
  • A job's exception is logged through ILogger while its span is current, so an OpenTelemetry log exporter ties it to the trace. A store operation's exception goes to its caller.

Linking a firing to what scheduled it

When the scheduling call runs inside an Activity, the scheduler stores its W3C trace context on the trigger, under the reserved keys SchedulerConstants.TraceParent and SchedulerConstants.TraceState. The firing's Quartz.Job.Execute span, and a Quartz.Job.Veto span, carry an ActivityLink back to it.

  • The firing may run much later and on another node. It is linked, not parented, so no trace stays open across the wait. This is OpenTelemetry's shape for an asynchronous producer and consumer.
  • Nothing needs configuring. An HTTP API request gets it automatically, because the endpoint runs inside ASP.NET Core's server span.
  • Cost: two string entries on each trigger's data map, visible in MergedJobDataMap, the dashboard and GET /triggers.

Turn it off with:

q.ConfigureScheduler(options => options.PropagateTraceContext = false);

The trigger's map, never the job's

[PersistJobDataAfterExecution] writes back only the job's map, so a persisted job cannot carry a traceparent into its next firing.

Metrics

Eleven instruments, all on the Quartz meter. Every measurement carries quartz.scheduler.name and quartz.scheduler.id: the name identifies the scheduler, the id its node in a cluster.

Each instrument name is a const string on QuartzInstrumentation.Instruments; for example QuartzInstrumentation.Instruments.JobExecutionDuration is quartz.job.execution.duration. Tests hold the constants equal to what the meter emits and snapshot the name, kind, unit and description of every instrument.

InstrumentTypeUnitExtra attributesMeasures
quartz.job.execution.durationHistogram<double>squartz.trigger.group, quartz.trigger.name, quartz.job.group, quartz.job.name, quartz.execution.group¹, quartz.job.result³, error.type²Job duration; count = executions
quartz.job.execution.activeUpDownCounter<long>{job}the same identity attributes, quartz.execution.group¹Jobs running now
quartz.trigger.misfireCounter<long>{trigger}quartz.trigger.group, quartz.execution.group¹Firings not made on time
quartz.trigger.retryCounter<long>{trigger}quartz.trigger.group, quartz.execution.group¹Retries scheduled after a job failed
quartz.trigger.retries_exhaustedCounter<long>{trigger}quartz.trigger.group, quartz.execution.group¹Failed occurrences whose retry policy ran out
quartz.trigger.acquisition.durationHistogram<double>squartz.jobstore.lock⁴, quartz.jobstore.lock.reason⁵Loop's wait on the store for the next batch, the fire of the triggers already due included
quartz.trigger.acquiredCounter<long>{trigger}quartz.jobstore.lock⁴, quartz.jobstore.lock.reason⁵Triggers returned by those rounds, fired or pending
quartz.cluster.checkin.durationHistogram<double>serror.type²Cluster check-in duration, per attempt
quartz.cluster.recovery.triggerCounter<long>{trigger}quartz.cluster.recovered.instance.idFired-trigger rows recovered from a failed node
quartz.jobstore.operation.durationHistogram<double>squartz.jobstore.operation, error.type²Every store round trip, by operation
quartz.jobstore.lock.wait.durationHistogram<double>squartz.jobstore.lock, error.type²One attempt to take a job store lock

¹ Only when the trigger names an execution group. A trigger in no group has no such attribute (not an empty one), so the two are separate series. ² Only when the operation failed. The value is the exception type's fully-qualified name. ³ Always. succeeded, failed, cancelled or skipped: the run's JobRunResult, lower-cased, as the execution history records it. Constant: ActivityTags.JobResult. ⁴ On the ADO.NET store, from 4.6: TRIGGER_ACCESS or none, the lock the round ran under. The in-memory store has no such attribute, so its series keep their shape. ⁵ Only on a round that took the lock: why. The values are the table in Lock-free firing rounds. Constant: ActivityTags.JobStoreLockReason.

  • quartz.trigger.retry counts retries scheduled, not attempts configured: an unused policy adds nothing.
  • quartz.trigger.retries_exhausted counts once per occurrence that gave up, never per attempt. It has the same attributes as quartz.trigger.retry, so the two divide.
  • quartz.cluster.checkin.duration records each attempt: a retried check-in is two measurements.
  • quartz.jobstore.lock.wait.duration does not record a re-entrant acquisition, which waited for nothing.
  • quartz.jobstore.operation is one of the thirty-four Quartz.JobStore.* names above, so one string finds a slow operation in traces and metrics. The histogram count is the number of each operation; the error.type-tagged part is the failures.
  • quartz.jobstore.lock is the LOCK_NAME value: TRIGGER_ACCESS (taken by scheduling operations) or STATE_ACCESS (taken by cluster check-in). On the two acquisition instruments it is also none, for a firing round that took no lock.

The lock-wait histogram is the one instrument that reports during a stall. A lock statement blocked behind another session returns and throws nothing, so no operation or failure is recorded. The matching warning is event 3716 in Log Events; the case is A Lock Held by a Connection That Is Gone.

The two cluster instruments and the lock-wait histogram come from the ADO.NET store, the only clustered one. The other eight work with any store.

Reading the numbers

  • There is no execution counter and no error counter. Executions are the count of quartz.job.execution.duration; group it by quartz.job.result for each result.

  • error.type names what a run threw, so it is on thrown failures only:

    quartz.job.resulterror.type
    succeeded, skipped, cancelledabsent
    failed, the job threwthe exception type
    failed, from JobRunReport.Failedabsent
  • error.type is the OpenTelemetry convention. Neither it nor quartz.job.result is on quartz.job.execution.active: an up-down counter's increment and decrement need identical attributes, and the result is not known at start.

Cardinality

quartz.job.name and quartz.trigger.name are per job and per trigger, and quartz.scheduler.id is per node, so a backend can get a series per node per trigger. Unless you need them, drop the name attributes in a view; the group attributes are usually the ones to keep. quartz.job.result has four values.

The meter is created from the container's IMeterFactory when there is one. AddMetrics(), and so every generic-host application, registers it. This keeps measurements of two schedulers, or two hosts in one test process, apart.

OpenTelemetry.Instrumentation.Quartz

OpenTelemetry.Instrumentation.Quartz is the OpenTelemetry community's instrumentation library, written for 3.x.

It produces nothing against 4.0, and does not say so

AddQuartzInstrumentation() yields zero spans on Quartz 4.x. Nothing throws or warns and the call still compiles; an upgraded application silently loses its job spans.

3.x4.x
Publishes througha DiagnosticListener named Quartz, new Activity(...) with no ActivitySourcean ActivitySource named Quartz
The package subscribes withDiagnosticSourceSubscriber for that listener, plus AddLegacySource("Quartz.Job.Execute") and AddLegacySource("Quartz.Job.Veto")matches nothing

In the OpenTelemetry SDK a "legacy source" is an activity with no ActivitySource. 4.x activities have one, and 4.x writes nothing to a DiagnosticListener, so neither half of the subscription matches.

Replace it with the two calls at the top of this page, AddSource(QuartzInstrumentation.ActivitySourceName) and AddMeter(QuartzInstrumentation.MeterName), and remove the package:

- builder.Services.AddOpenTelemetry()
-     .WithTracing(tracing => tracing.AddQuartzInstrumentation());
+ builder.Services.AddOpenTelemetry()
+     .WithTracing(tracing => tracing.AddSource(QuartzInstrumentation.ActivitySourceName))
+     .WithMetrics(metrics => metrics.AddMeter(QuartzInstrumentation.MeterName));
- <PackageReference Include="OpenTelemetry.Instrumentation.Quartz" Version="1.*" />
  • Lost: the package's QuartzInstrumentationOptions.TracedOperations filter. Subscribing directly records both Quartz.Job.Execute and Quartz.Job.Veto; drop one with an OpenTelemetry processor or sampler.
  • Gained: everything 4.0 added, including the store spans and all eleven instruments.

Older packages

Quartz.OpenTelemetry.Instrumentation is obsolete and not part of 4.x. Subscribe to the activity source directly, as at the top of this page.

Coming from Quartz.OpenTracing

Quartz.OpenTracing is not part of 4.x and has no 4.x release; the OpenTracing project is archived. It used the DiagnosticSource events that 4.x replaced with System.Diagnostics.Activity. Replace an AddQuartzOpenTracing call with the OpenTelemetry setup at the top of this page.

Logging

Quartz logs through Microsoft.Extensions.Logging, using the application's configuration; nothing to wire up. Types no container builds (a listener or trigger you constructed, the static helpers, the jobs in Quartz.Jobs) use the factory set with Quartz.Diagnostics.LogProvider.SetLogProvider(loggerFactory).

Log scopeCarriesOpened
the scheduler'squartz.scheduler.name, quartz.scheduler.idonce per scheduling loop; always
the firing'squartz.job.name, quartz.job.group, quartz.trigger.name, quartz.trigger.group, quartz.fire.instance.idonce per firing, from 4.3, with q.AddJobLogScope() — see A log scope per firing

The names are the span attributes above. Set IncludeScopes = true on the OpenTelemetry logger to export them.

For job and trigger history as log entries rather than traces, use the history plugins.

Help us by improving this page!
Last Updated: 10/19/26, 11:45 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Multiple Schedulers with Microsoft DI
Next
Redis Lock Handler