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
| Span | Kind | When |
|---|---|---|
Quartz.Job.Execute | Internal | A job runs; covers the whole fire and carries its result |
Quartz.Job.Veto | Internal | A trigger listener vetoed the fire; the job did not run |
Quartz.JobStore.<operation> | Client | One 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, theQuery*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.ExecuteandQuartz.Job.Vetoare always trace roots and never take the ambientActivityas parent. What the job traces (anHttpClientcall, 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 putsQuartz.JobStore.ScheduleJobin that request's trace. - The scheduler loop's store calls are roots.
AcquireNextTriggers,TriggersFiredandTriggeredJobCompletebelong 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:
| Attribute | On |
|---|---|
quartz.scheduler.name, quartz.scheduler.id | every span |
quartz.job.name, quartz.job.group, quartz.job.type | job spans |
quartz.trigger.name, quartz.trigger.group | job spans; store spans about one trigger |
quartz.execution.group | job spans, when the trigger names an execution group |
quartz.fire.instance.id | job spans |
quartz.job.result | Quartz.Job.Execute: succeeded, failed, cancelled or skipped, from 4.4 |
quartz.job.name, quartz.job.group | store spans about one job |
quartz.jobstore.batch.size | Quartz.JobStore.AcquireNextTriggers: triggers requested |
quartz.jobstore.trigger.count | Quartz.JobStore.AcquireNextTriggers (returned) and .TriggersFired (fired) |
quartz.jobstore.trigger.fired_on_acquire | Quartz.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.reason | Quartz.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.type | any 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:
| Status | Description | error.type | exception event | |
|---|---|---|---|---|
| The job or a middleware threw | Error | the exception's message | the exception type | by default |
The job's JobRunReport says Failed | Unset | — | absent | never |
| The run was cancelled | Unset | — | absent | never |
| A store operation threw | Error | the exception's message | the exception type | by 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));
RecordExceptionSpanEvents | OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN | exception event |
|---|---|---|
null (default) | unset, logs/dup or another value | recorded |
null (default) | logs | not recorded |
true | anything | recorded |
false | anything | not recorded |
- The span keeps its status, description and
error.typeeither 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
ILoggerwhile 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 andGET /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.
| Instrument | Type | Unit | Extra attributes | Measures |
|---|---|---|---|---|
quartz.job.execution.duration | Histogram<double> | s | quartz.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.active | UpDownCounter<long> | {job} | the same identity attributes, quartz.execution.group¹ | Jobs running now |
quartz.trigger.misfire | Counter<long> | {trigger} | quartz.trigger.group, quartz.execution.group¹ | Firings not made on time |
quartz.trigger.retry | Counter<long> | {trigger} | quartz.trigger.group, quartz.execution.group¹ | Retries scheduled after a job failed |
quartz.trigger.retries_exhausted | Counter<long> | {trigger} | quartz.trigger.group, quartz.execution.group¹ | Failed occurrences whose retry policy ran out |
quartz.trigger.acquisition.duration | Histogram<double> | s | quartz.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.acquired | Counter<long> | {trigger} | quartz.jobstore.lock⁴, quartz.jobstore.lock.reason⁵ | Triggers returned by those rounds, fired or pending |
quartz.cluster.checkin.duration | Histogram<double> | s | error.type² | Cluster check-in duration, per attempt |
quartz.cluster.recovery.trigger | Counter<long> | {trigger} | quartz.cluster.recovered.instance.id | Fired-trigger rows recovered from a failed node |
quartz.jobstore.operation.duration | Histogram<double> | s | quartz.jobstore.operation, error.type² | Every store round trip, by operation |
quartz.jobstore.lock.wait.duration | Histogram<double> | s | quartz.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.retrycounts retries scheduled, not attempts configured: an unused policy adds nothing.quartz.trigger.retries_exhaustedcounts once per occurrence that gave up, never per attempt. It has the same attributes asquartz.trigger.retry, so the two divide.quartz.cluster.checkin.durationrecords each attempt: a retried check-in is two measurements.quartz.jobstore.lock.wait.durationdoes not record a re-entrant acquisition, which waited for nothing.quartz.jobstore.operationis one of the thirty-fourQuartz.JobStore.*names above, so one string finds a slow operation in traces and metrics. The histogram count is the number of each operation; theerror.type-tagged part is the failures.quartz.jobstore.lockis theLOCK_NAMEvalue:TRIGGER_ACCESS(taken by scheduling operations) orSTATE_ACCESS(taken by cluster check-in). On the two acquisition instruments it is alsonone, 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 byquartz.job.resultfor each result.error.typenames what a run threw, so it is on thrown failures only:quartz.job.resulterror.typesucceeded,skipped,cancelledabsent failed, the job threwthe exception type failed, fromJobRunReport.Failedabsent error.typeis the OpenTelemetry convention. Neither it norquartz.job.resultis onquartz.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.x | 4.x | |
|---|---|---|
| Publishes through | a DiagnosticListener named Quartz, new Activity(...) with no ActivitySource | an ActivitySource named Quartz |
| The package subscribes with | DiagnosticSourceSubscriber 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.TracedOperationsfilter. Subscribing directly records bothQuartz.Job.ExecuteandQuartz.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 scope | Carries | Opened |
|---|---|---|
| the scheduler's | quartz.scheduler.name, quartz.scheduler.id | once per scheduling loop; always |
| the firing's | quartz.job.name, quartz.job.group, quartz.trigger.name, quartz.trigger.group, quartz.fire.instance.id | once 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.
