Возвращаем трассировку логов Quartz-задач после перехода на Spring Boot 3+

Привет! Меня зовут Александр Каненков, я Backend-разработчик в компании Домклик.
После перевода сервиса со второго Spring Boot на третий (а у некоторых уже и на четвёртый) мы с командой заметили следующую картину: HTTP-запрос журналируется с traceId, цепочка собирается в трейсе. Но как только срабатывала задача Quartz, в журналах Worker-потока появлялось[NoTrace, NoSpan]. Задача выполнялась «в никуда» — без единого идентификатора и вне какого-либо трейса, из-за чего связать её с запросом, который её запланировал, невозможно.
Давайте пошагово разберём, почему так происходит, и как сделать так, чтобы работало и с RAM (In-memory), и с JDBC Job Store.
Симптомы
Конфигурация журналирования стандартная:
logging:
pattern:
console: "%d{HH:mm:ss.SSS} [%X{traceId:-NoTrace}, %X{spanId:-NoSpan}] %5p --- [%t] %-40.40logger{39} : %m%n"На Boot 2.x + Sleuth журналы задачи выглядели следующим образом (traceId из HTTP-запроса «доезжал» до выполнения Job):
12:00:00.123 [a3f4b2c1d5e6f708192a3b4c5d6e7f80, 9c2f1b3a4d5e6f70] INFO --- [http-nio-8080-exec-1] c.e.q.t.demo.service.DemoJobScheduler : Scheduling DemoJob with id=123
12:01:00.456 [a3f4b2c1d5e6f708192a3b4c5d6e7f80, 77be2a4d1c5e8f30] INFO --- [quartzScheduler_Worker-1] c.e.quartz.tracing.demo.quartz.DemoJob : Executing DemoJob with message=helloПосле миграции на Micrometer-стек — так:
12:00:00.123 [a3f4b2c1d5e6f708192a3b4c5d6e7f80, 9c2f1b3a4d5e6f70] INFO --- [http-nio-8080-exec-1] c.e.q.t.demo.service.DemoJobScheduler : Scheduling DemoJob with id=123
12:01:00.456 [NoTrace, NoSpan] INFO --- [quartzScheduler_Worker-1] c.e.quartz.tracing.demo.quartz.DemoJob : Executing DemoJob with message=helloЗаметка про пример «до». Совпадающий
traceIdв журнале Worker-потока — это не «магия Sleuth из коробки», а следствие того, что Trace-контекст уже лежал вJobDataMapзадачи. Sleuth умел только продолжить его, но сам при планировании ничего туда не писал — контекст должен был положить код приложения в точке вызоваscheduleJob. Если контекста в Job Data нет (типичный случай), то на Boot 2 + Sleuth каждый запуск получал собственный корневой Span со своимtraceId: идентификаторы в журналах Worker-потока есть, но это отдельный трейс, а не трейс запроса-родителя (в Boot 3 не было бы и этого — отсюда[NoTrace, NoSpan]в примере «после»). Захват при планировании — ровно та часть схемы, которую мы в этой статье автоматизируем. Как это было устроено у Sleuth, разобрано в следующем разделе.
В чём дело?
Почему «пропал» Trace
В Boot 2.x Sleuth из коробки умел дружить с Quartz. Схема была аналогична той, которую мы дальше соберём руками, но Sleuth реализовывал её только наполовину. Отвечала за неё автоконфигурация TraceQuartzAutoConfiguration (пакет org.springframework.cloud.sleuth.autoconfig.instrument.quartz). Если в контексте приложения были и Tracer, и Scheduler, то она находила планировщик и вешала на него специальный слушатель TracingJobListener:
// Sleuth 3.1.x, TraceQuartzAutoConfiguration (сокращено)
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean({ Tracer.class, Scheduler.class })
public class TraceQuartzAutoConfiguration implements InitializingBean {
private final Scheduler scheduler;
@Override
public void afterPropertiesSet() throws Exception {
TracingJobListener listener = this.beanFactory.getBean(TracingJobListener.class);
this.scheduler.getListenerManager().addTriggerListener(listener);
this.scheduler.getListenerManager().addJobListener(listener);
}
}TracingJobListener (org.springframework.cloud.sleuth.instrument.quartz) обёртывал каждый запуск задачи в Span. Он регистрировался и как TriggerListener, и как JobListener. Основной метод — triggerFired, который Quartz вызывает в своём Worker-потоке непосредственно перед запуском Job. Слушатель достаёт Trace-контекст из Job Data задачи, запускает Span и помещает его в Scope. MDC наполняется, и журналы Worker-потока получают traceId/spanId. Завершается Span в triggerComplete:
// Sleuth 3.1.x, TracingJobListener (сокращено)
@Override
public void triggerFired(Trigger trigger, JobExecutionContext context) {
// извлечь trace-контекст из JobDataMap задачи и продолжить именно его
Span nextSpan = propagator.extract(context.getMergedJobDataMap(), GETTER).start();
AssertingSpan span = SleuthQuartzSpan.QUARTZ_TRIGGER_SPAN.wrap(nextSpan)
.tag(SleuthQuartzSpan.Tags.TRIGGER, context.getTrigger().getKey().toString())
.name(context.getTrigger().getJobKey().toString());
context.put(CONTEXT_SPAN_KEY, span);
context.put(CONTEXT_SPAN_IN_SCOPE_KEY, tracer.withSpan(span.start()));
}
@Override
public void triggerComplete(Trigger trigger, JobExecutionContext context,
CompletedExecutionInstruction triggerInstructionCode) {
closeTrace(context); // закрыть scope и span
}Важный момент, который пригодится дальше: эта половина — это восстановление на стороне выполнения. Span не создавался «из воздуха»: слушатель извлекал контекст из JobDataMap задачи (propagator.extract) и продолжал его. То есть Job «наследовал» трейс только в том случае, если при планировании в её Job Data уже лежал Trace-контекст. Захват же в момент планирования Sleuth на себя не брал вовсе: из коробки он ничего не пишет в JobDataMap. Кто именно кладёт туда контекст — подробно разобрано в заметке к примеру «до». Кратко: без контекста extract возвращает пустой результат, и запуск получает собственный корневой traceId. Наследование трейса запроса обеспечивает код приложения в точке вызова scheduleJob — это та самая «ручная работа», которую ниже автоматизирует обёртка.
Micrometer Tracing, пришедший на смену Sleuth в Boot 3+, не несёт даже эту часть. Это «низкоуровневый» каркас (трейсер, пропагаторы, декораторы Scope), а не автоконфигурация под каждую библиотеку. Он не регистрирует слушателей для Quartz, поэтому схему приходится собирать заново. Зато, как мы увидим дальше, собранная заново схема закрывает и ту дыру, которую оставлял Sleuth: захват при планировании теперь происходит автоматически.
Однако корень проблемы глубже, чем просто отсутствие готовой интеграции. Задача Quartz выполняется не в потоке HTTP-запроса, который её запланировал, а спустя некоторое время — в Worker-потоке планировщика. Trace-контекст (текущий Span и ключи MDC traceId/spanId) — это состояние текущего потока. Он не перетекает из потока планирования в поток выполнения: к моменту Fire HTTP-поток уже закончил работу, а Worker-поток Quartz «чист».
Очевидное решение — «протащить» в Job пару строк: traceId и spanId в JobDataMap. Но Trace-контекст — это не два числа, а набор атрибутов: помимо идентификаторов в нём хранится решение о сэмплинге (попадёт ли Span в трейс вообще) и связь родитель→ребёнок. Если передать только отдельные поля, то Job либо не попадёт в трейс, либо повиснет «сиротой» без родителя-запроса. Поэтому в Job нужно передавать именно полный Trace-контекст, а не его фрагменты.
Коротко о семантике Span
Прежде чем писать код, важно зафиксировать модель, иначе можно начнать «чинить» не то:
Span — единица работы в одном потоке с ограниченным временем жизни. Span HTTP-запроса к моменту срабатывания Job уже закрыт. «Восстановить» его в Worker-потоке нельзя и не нужно —
spanIdне переиспользуется.У повторяющейся задачи каждый запуск — отдельный Span. Нельзя, чтобы все Fire одной Job делили один SpanId.
Правильная модель — дочерний Span: новый Span с тем же
traceId, у которогоparentSpanId=spanIdмомента планирования. Тогда в трейсе видно: запрос «породил» задачу, а задача выполнилась через минуту.
Такой носитель уже стандартизирован и называется W3C Traceparent. traceparent — это HTTP-заголовок, которым сервисы обмениваются при вызовах друг друга, чтобы трейс собирался сквозь границы сервисов. Его значение — одна строка из четырёх полей, разделённых дефисами. Планируя Job внутри HTTP-запроса, мы берём ту же строку, что отправили бы в заголовке traceparent при HTTP-вызове из этого же запроса, и кладём её в JobDataMap — задача «наследует» трейс так же, как унаследовал бы его дочерний HTTP-вызов:
00-a3f4b2c1d5e6f708192a3b4c5d6e7f80-9c2f1b3a4d5e6f70-01Поле | Значение из примера | Длина | Что это |
Версия |
| 2 hex | Формат протокола (сейчас всегда |
TraceId |
| 32 hex | Идентификатор трейса: общий для HTTP-запроса и Job |
ParentId |
| 16 hex |
|
Trace-flags |
| 2 hex | Флаг сэмплинга: |
Наполнять MDC руками не требуется. Spring Boot с Micrometer-Tracing подключает к CurrentTraceContext специальный декоратор (MDCScopeDecorator у Brave), который сам наполняет MDC ключами traceId/spanId, когда Span входит в Scope. Достаточно поставить Span в Scope на Worker-потоке, и журналы задачи автоматически получат идентификаторы.
План
Задача распадается на три точки:
Захват — в момент планирования Job, пока активен Span запроса
Хранение — Traceparent в
JobDataMapJobВосстановление — перед выполнением, в Worker-потоке Quartz: извлечь контекст, создать новый Span, поставить в Scope (MDC наполнится сам)
И есть четвёртый пункт, ради которого всё затевается: сделать это автоматически, чтобы разработчик, создающий Job, физически не мог забыть про трассировку.
Стек: Spring Boot 3.x и 4.x, Micrometer Tracing (Brave), Quartz. Код — Kotlin; в конце — заметка про Java.
Шаг 1. Захват и восстановление: одна строка Traceparent
object QuartzTraceContextUtils {
/** Ключ в JobDataMap, под которым хранится trace-контекст (W3C traceparent). */
const val TRACE_CONTEXT_JOB_DATA = "TRACE_CONTEXT"
private const val TRACEPARENT_KEY = "traceparent"
/** Захватывает текущий trace-контекст для сохранения в Quartz job data. */
fun capture(): JobDataMap {
val jobDataMap = JobDataMap()
val tracing = Tracing.current() ?: return jobDataMap
val span = tracing.tracer().currentSpan() ?: return jobDataMap
val carrier = mutableMapOf<String, String>()
val setter = Propagation.Setter<MutableMap<String, String>, String> { map, key, value ->
map[key] = value
}
tracing.propagation().injector(setter).inject(span.context(), carrier)
carrier[TRACEPARENT_KEY]?.let { jobDataMap.put(TRACE_CONTEXT_JOB_DATA, it) }
return jobDataMap
}
/**
* Восстанавливает trace-контекст из traceparent на время выполнения job'а.
*
* Из traceparent извлекается контекст родительского span'а, и через nextSpan создаётся
* НОВЫЙ span: новый spanId, тот же traceId, parentSpanId = spanId момента планирования.
*
* Возвращает функцию очистки, которую нужно вызвать в finally.
*/
fun restore(traceContext: String?): () -> Unit {
val tracing = Tracing.current() ?: return {}
val tracer = tracing.tracer()
val carrier = traceContext?.let { mapOf(TRACEPARENT_KEY to it) } ?: emptyMap()
val getter = Propagation.Getter<Map<String, String>, String> { map, key -> map[key] }
val extracted = tracing.propagation()
.extractor(getter)
.extract(carrier)
val span = tracer.nextSpan(extracted)
val scope = tracer.withSpanInScope(span)
return scope::close
}
}Обратите внимание на restore: из traceparent извлекается родительский контекст, а nextSpan создаёт новый Span. Никакого ручного разбора Hex-строк и ручныхMDC.put — за это отвечает пропагация и декоратор.
Важная деталь реализации: Tracing.current() — это статический доступ к активному экземпляру Brave, который Spring Boot регистрирует сам. Поэтому утилита не зависит от внедрения бинов и одинаково работает как в потоке планирования, так и в Worker-потоке Quartz.
Шаг 2. Точка восстановления: JobListener
Восстановление контекста нужно делать именно там, где Quartz переключается на Worker-поток. Штатный механизм для этого — глобальный JobListener: Quartz вызывает его методы в том же потоке, что и Job.execute, поэтому хранить Cleanup-функцию в ThreadLocal безопасно — один Job соответствует одному потоку.
@Component
class TraceContextJobListener : JobListener {
private val executionCleanup = ThreadLocal<() -> Unit>()
override fun getName(): String = "traceContextJobListener"
override fun jobToBeExecuted(context: JobExecutionContext) {
val traceContext = context.jobDetail.jobDataMap
.getString(QuartzTraceContextUtils.TRACE_CONTEXT_JOB_DATA)
executionCleanup.set(QuartzTraceContextUtils.restore(traceContext))
}
override fun jobExecutionVetoed(context: JobExecutionContext) = Unit
override fun jobWasExecuted(context: JobExecutionContext, jobException: JobExecutionException?) {
executionCleanup.get()?.invoke()
executionCleanup.remove()
}
}Если в Job Data контекста нет (например, задача запланирована вне Trace или записана в базу данных ещё до внедрения фикса), то restore(null) создаст новый корневой Span. В этом случае задача всё равно будет журналироваться с идентификаторами. Это мягкий Fallback, а не ошибка.
Шаг 3. Точка захвата: почему НЕ SchedulerListener, а обёртка над Scheduler
Самое интересное: кажется естественным захватывать контекст в SchedulerListener.jobAdded(jobDetail). Этот метод получает готовый JobDetail, вызывается в потоке планирования, пока Span запроса ещё активен, и мы дописываем traceparent в Job Data. При RAM-хранилища этот вариант выглядит рабочим: контекст появляется в журналах. Именно поэтому баг так коварен — локально всё зелёное, а на проде с JDBC Job Store Trace снова пропадают.
Почему JobAdded ненадёжен
Дело в порядке вызовов внутри Quartz:
scheduleJob(job, trigger)
│
├─ jobStore.storeJob(job) ← ПЕРСИСТ: здесь JobDetail (+ JobDataMap) уходит в хранилище
│
└─ notifySchedulerListenersJobAdded(job) ← SchedulerListener.jobAdded вызывается ПОСЛЕ persistSchedulerListener.jobAdded нотифицируется после сохранения Job в Job Store. Что это значит для разных хранилищ:
Job Store | Мутация JobDataMap в |
RAM ( | Обычно работает, но только потому, что RAM-хранилище хранит в памяти тот же объект и правка доезжает до сохранённой копии по ссылке. Это поведение конкретной реализации, а не контракт Quartz |
JDBC ( | Не работает в принципе, к моменту |
То есть Listener-подход не просто «не работает для JDBC» — он ведёт себя по-разному в зависимости от Store, и эту разницу вы увидите только на проде. Полагаться на такой подход нельзя.
Универсальное решение: перехват до Persist
Поскольку корень проблемы в том, что при захвате правка в Listener происходит после записи в хранилище, точка захвата должна быть до Persist. Гарантированно это можно сделать только на границе планировщика — перехватить вызовы scheduleJob/addJob и вложить контекст в JobDetail до того, как Quartz решит, куда его записывать.
Для этого не нужен никакой Store-специфичный код: мы не трогаем хранилище вообще. Мы изменяем объект, который передаём в планировщик, а RAM или JDBC — Quartz разберётся сам. Контракт один: что передали в scheduleJob, то и будет персиститься — в память или в базу данных. Перехватив аргументы до передачи, мы гарантируем контекст в обоих случаях одним и тем же кодом.
В Quartz Scheduler — интерфейс, поэтому обёртка делается просто, через делегирование:
/**
* Обёртка над Scheduler: захватывает trace-контекст в момент планирования —
* ДО того, как job попадёт в job store.
*
* SchedulerListener.jobAdded вызывается ПОСЛЕ persist: для RAM-хранилища правка JobDataMap
* из listener'а может «доехать», а для JDBC — гарантированно теряется (JobDataMap уже
* сериализован в БД). Перехват scheduleJob/addJob на границе планировщика кладёт контекст
* в JobDataMap до persist — вариант одинаково работает для RAM и JDBC хранилищ,
* потому что не зависит от устройства job store вовсе.
*/
class TraceContextCapturingScheduler(
private val delegate: Scheduler,
) : Scheduler by delegate {
override fun addJob(jobDetail: JobDetail, replace: Boolean) {
delegate.addJob(jobDetail.attachTraceContext(), replace)
}
override fun addJob(jobDetail: JobDetail, replace: Boolean, storeNonDurableWhileAwaitingScheduling: Boolean) {
delegate.addJob(jobDetail.attachTraceContext(), replace, storeNonDurableWhileAwaitingScheduling)
}
override fun scheduleJob(jobDetail: JobDetail, trigger: Trigger): Date =
delegate.scheduleJob(jobDetail.attachTraceContext(), trigger)
override fun scheduleJob(jobDetail: JobDetail, triggers: Set<out Trigger>, replace: Boolean) {
delegate.scheduleJob(jobDetail.attachTraceContext(), triggers, replace)
}
override fun scheduleJobs(triggersAndJobs: Map<JobDetail, Set<out Trigger>>, replace: Boolean) {
triggersAndJobs.keys.forEach { it.attachTraceContext() }
delegate.scheduleJobs(triggersAndJobs, replace)
}
private fun JobDetail.attachTraceContext(): JobDetail {
jobDataMap.putAll(QuartzTraceContextUtils.capture())
return this
}
}Мы перехватываем все методы, которые кладут Job в Store: scheduleJob(job, trigger), scheduleJob(job, триггеры, replace), scheduleJobs(map, replace) и оба addJob. Все остальные методы (а их у Scheduler несколько десятков) уходит делегату. Если активного Span нет — capture() вернёт пустой Map, и Job Data не изменится: задача выполнится со своим корневым Trace. При повторном планировании того же Job контекст перезапишется свежим — что правильно, ведь у каждого Fire свой родитель.
Почему это универсально для обоих Store
Соберём аргументы вместе:
Единая точка кода. Веток «если RAM — то так, если JDBC — то эдак» нет и не может появиться: обёртка ничего не знает о хранилище.
Одна и та же семантика. Что запланировали через обёртку — то и персистится с контекстом: RAM-хранилище получает его в память, JDBC — в таблицы
QRTZ_JOB_DETAILS. Оба пути проходят через один перехват.Переключение Store — это конфиг.
spring.quartz.job-store-type: memory→jdbcменяется вapplication.yml, код остаётся без изменений. С Listener-подходом такое переключение было бы миной: на RAM работает, на JDBC — теряет Trace.Не зависит от версии Quartz. Мы опираемся на контракт интерфейса
Scheduler, а не на внутренности конкретной реализации Job Store.
Сравним подходы по этой же таблице:
Точка захвата | RAM | JDBC | Зависит от Store |
| Работает | Теряется | Да — фатально |
Обёртка | Работает | Работает | Нет |
Именно универсальность для RAM и JDBC стала решающим аргументом в пользу обёртки: это единственный вариант, который ведёт себя одинаково независимо от того, какое хранилище настроено.
Шаг 4. Регистрация
Осталось соединить компоненты. Ключевой момент: мы не заменяем SchedulerFactoryBean от Spring Boot, иначе перестанут работать свойства spring.quartz.* — выбор Job Store, DataSource и т.д. Мы лишь объявляем обёртку @Primary бином типа Scheduler, в результате всё, что внедряет планировщик, автоматически получает обёртку:
@Configuration(proxyBeanMethods = false)
class QuartzTraceSchedulerConfiguration {
@Bean
@Primary
fun traceContextAwareScheduler(schedulerFactoryBean: SchedulerFactoryBean): Scheduler =
TraceContextCapturingScheduler(schedulerFactoryBean.getObject()!!)
}Зависимость от SchedulerFactoryBean здесь не случайна: она гарантирует, что планировщик уже создан (afterPropertiesSet выполнен), когда мы забираем его через getObject. А вот регистрацию глобального JobListener делаем через штатный кастомизатор фабричного бина:
@Configuration
class QuartzTraceListenerConfiguration(
private val traceContextJobListener: TraceContextJobListener,
) : SchedulerFactoryBeanCustomizer {
override fun customize(schedulerFactoryBean: SchedulerFactoryBean) {
schedulerFactoryBean.setGlobalJobListeners(traceContextJobListener)
}
}Про импорт кастомизатора. В Boot 3.x он лежит в
org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer. В Boot 4.x кварцевая автоконфигурация вынесена в отдельный стартерspring-boot-quartz, и пакет изменился наorg.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer. Импорт в примере выше именно для Boot 4. В остальном код для Boot 3 и Boot 4 идентичен.
Итоговая архитектура
HTTP-запрос (поток N)
│ span активен
▼
DemoJobScheduler ── scheduler.scheduleJob(job, trigger)
│ │
│ ┌──────▼──────────────────────────┐
│ │ TraceContextCapturingScheduler │
│ │ кладёт traceparent в job data │
│ └──────┬──────────────────────────┘
│ ▼
│ SchedulerFactoryBean (Boot)
│ └─ storeJob → RAM или JDBC ← контекст уже в JobDataMap
│
▼
время прошло, срабатывает триггер
▼
worker-поток Quartz
│
├─ TraceContextJobListener.jobToBeExecuted
│ restore(traceparent) → новый child span в scope → MDC наполнен
▼
DemoJob.executeInternal → logger.info("Executing ...") ← логи уже с traceIdГлавный бонус всей конструкции: прикладной код больше ничего не знает о трассировке. Job — это обычный QuartzJobBean, планирование — обычный JobBuilder:
val job = JobBuilder.newJob(DemoJob::class.java)
.withIdentity("demo_job_$jobId")
.usingJobData(MESSAGE_PARAM_NAME, message)
.build()
scheduler.scheduleJob(job, setOf(trigger), true)Контекст подхватят обёртка и JobListener. Забыть про трассировку просто невозможно — её негде забыть.
Как проверить
В журналах должно получиться следующее (обратите внимание: traceId общий, spanId у выполнения — новый):
15:33:56.848 [6a9c0c3453ec48a34471d65a6a20b2ff, 4471d65a6a20b2ff] INFO --- [http-nio-8080-exec-1] c.e.q.t.demo.service.DemoJobScheduler : Scheduling DemoJob with id=100 and message=HI!
15:33:56.854 [6a9c0c3453ec48a34471d65a6a20b2ff, 4471d65a6a20b2ff] INFO --- [http-nio-8080-exec-1] c.e.q.t.demo.service.DemoJobScheduler : DemoJob with id=100 scheduled
15:34:56.865 [6a9c0c3453ec48a34471d65a6a20b2ff, 5359b6579cc264b4] INFO --- [quartzScheduler_Worker-1] c.e.quartz.tracing.demo.quartz.DemoJob : Executing DemoJob with message=HI!А в Trace-системе (Zipkin, Jaeger, Grafana Tempo) задача должна висеть ребёнком HTTP-запроса: parentSpanId выполнения = SpanId планирования.
Для воспроизводимости на демо не забудьте поднять вероятность сэмплинга:
management:
tracing:
sampling:
probability: 1.0Если логи пишутся в StdOut, попробуйте проверить то же самое с реальным JDBC Store (spring.quartz.job-store-type: jdbc + DataSource). Контекст должен доехать и туда. Для сравнения вернитесь к захвату в SchedulerListener.jobAdded из шага 3 — в JDBC его отличие от обёртки видно сразу: Trace пропадут снова.
Что покрыто тестами
Порядок захвата: мок
Schedulerполучает тот жеJobDetail, но уже сtraceparentв Job Data — захват происходит до вызова реального планировщика (до Persist)Реальный Quartz, RAM RoundTrip: Job после
scheduleJob,addJobиscheduleJobsчерез обёртку лежит в Store сtraceparent,traceIdсовпадает сtraceIdSpan планированияБез активного Span: Job Data не тронута — задача уходит в собственный корневой Trace
JobListener: вокруг выполнения MDC заполнен (
traceIdравен родительскому,spanId— новый), послеjobWasExecuted— очищен
Подводные камни и заметки
Уже сохранённые Job. Задачи, записанные в JDBC-базу до внедрения исправления, не имеют контекста и будут журналироваться с собственным корневым Trace. Это приемлемый Fallback. При необходимости можно «домигрировать» их отдельным скриптом, но обычно проще дать им доработать.
Обёртка перехватывает планирование через DI-бин
Scheduler. Единственный держатель «сырого» планировщика — самSchedulerFactoryBean(жизненный цикл и Job, сконфигурированные на старте). Это не мешает, так как стартовые Job планируются вне HTTP-запроса, и захватывать там нечего. Если в проекте кто-то достаёт планировщик в обход DI, стоит договориться, что все планируют через внедрениеScheduler.Чей трейс унаследует Job, решается в момент планирования, а не запуска.
capture()берёт Span, активный в потоке, который вызвалscheduleJob: если запланировали из HTTP-запроса — все запуски продолжат его трейс (новый Child Span на каждый Fire); если запланировали вне трейса (запуск приложения, Cron Job из конфигурации) — Job Data останется пустой, и каждый запуск по расписанию станет самостоятельным корневым трейсом. Это и есть желаемое поведение: Fire по крону происходит «сам по себе», и связать его с конкретным запросом не с чем, — но иNoTraceв журналах не будет: даже «бесхозный» запуск получает собственный Span. Следствие той же механики: Cron Job, запланированная внутри HTTP-запроса, будет наследовать его Trace на всех будущих запусках — захват выполняется один раз, при планировании.Метод
jobExecutionVetoedв JobListener намеренно пустой: если триггер наложит вето на запуск Job (запретит его), тоjobToBeExecutedне вызывается — чистить нечего.Захват идемпотентен для повторного планирования: каждый
scheduleJobперезаписываетtraceparentсвежим значением из текущего (нового) Span — у каждого Fire свой родитель, как и должно быть.
Заключение
Проблема «TraceId исчезает в Quartz после перехода на Spring Boot 3+» решается тремя небольшими компонентами:
QuartzTraceContextUtils— захват и восстановление W3C Traceparent (новый Child Span, MDC наполняется автоматически)TraceContextJobListener— восстановление контекста в Worker-потоке перед выполнениемTraceContextCapturingScheduler— захват контекста до Persist, из-за чего решение одинаково работает для RAM и JDBC Job Store, а переключение хранилища остаётся чисто конфигурационным.
Именно последний пункт — обёртка вокруг Scheduler вместо Listener на добавление Job — решает проблему с JDBC-хранилищем, где наивный вариант теряет контекст. Универсальность для обоих Store — не побочный эффект, а причина выбора: перехват на границе планировщика — единственная точка, не зависящая от устройства хранилища.
Полный рабочий пример (Kotlin, Spring Boot 4.1, RAM Store по умолчанию) лежит на GitHub. Для Java код переписывается механически: интерфейсы Scheduler и JobListener те же, делегирование можно реализовать вручную или через Dynamic Proxy.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.