Привет! Меня зовут Александр Каненков, я 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

Поле

Значение из примера

Длина

Что это

Версия

00

2 hex

Формат протокола (сейчас всегда 00)

TraceId

a3f4b2c1d5e6f708192a3b4c5d6e7f80

32 hex

Идентификатор трейса: общий для HTTP-запроса и Job

ParentId

9c2f1b3a4d5e6f70

16 hex

spanId момента планирования — родитель Span выполнения Job

Trace-flags

01

2 hex

Флаг сэмплинга: 01 — Span попадёт в трейс, 00 — нет

Наполнять MDC руками не требуется. Spring Boot с Micrometer-Tracing подключает к CurrentTraceContext специальный декоратор (MDCScopeDecorator у Brave), который сам наполняет MDC ключами traceId/spanId, когда Span входит в Scope. Достаточно поставить Span в Scope на Worker-потоке, и журналы задачи автоматически получат идентификаторы.

План

Задача распадается на три точки:

  1. Захват — в момент планирования Job, пока активен Span запроса

  2. Хранение — Traceparent в JobDataMap Job

  3. Восстановление — перед выполнением, в 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 вызывается ПОСЛЕ persist

SchedulerListener.jobAdded нотифицируется после сохранения Job в Job Store. Что это значит для разных хранилищ:

Job Store

Мутация JobDataMap в jobAdded

RAM (job-store-type: memory)

Обычно работает, но только потому, что RAM-хранилище хранит в памяти тот же объект и правка доезжает до сохранённой копии по ссылке. Это поведение конкретной реализации, а не контракт Quartz

JDBC (job-store-type: jdbc)

Не работает в принципе, к моменту jobAdded JobDataMap уже сериализован и записан в базу данных. Мутация из Listener молча теряется — Job «засыпает» в базе без Trace-контекста

То есть 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

SchedulerListener.jobAdded (после Persist)

Работает

Теряется

Да — фатально

Обёртка Scheduler (до Persist)

Работает

Работает

Нет

Именно универсальность для 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 совпадает с traceId Span планирования

  • Без активного 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+» решается тремя небольшими компонентами:

  1. QuartzTraceContextUtils — захват и восстановление W3C Traceparent (новый Child Span, MDC наполняется автоматически)

  2. TraceContextJobListener — восстановление контекста в Worker-потоке перед выполнением

  3. TraceContextCapturingScheduler — захват контекста до Persist, из-за чего решение одинаково работает для RAM и JDBC Job Store, а переключение хранилища остаётся чисто конфигурационным.

Именно последний пункт — обёртка вокруг Scheduler вместо Listener на добавление Job — решает проблему с JDBC-хранилищем, где наивный вариант теряет контекст. Универсальность для обоих Store — не побочный эффект, а причина выбора: перехват на границе планировщика — единственная точка, не зависящая от устройства хранилища.

Полный рабочий пример (Kotlin, Spring Boot 4.1, RAM Store по умолчанию) лежит на GitHub. Для Java код переписывается механически: интерфейсы Scheduler и JobListener те же, делегирование можно реализовать вручную или через Dynamic Proxy.

Александр Каненков

Backend-разработчик в Домклик

Комментарии (1)


  1. sherbinko
    29.09.2026 09:09

    Наверное не по теме статьи, но кварц это наверное 2ая по "худшедсти" библиотека из тех что я сталкивался. Всегда удавалось заменять её периодической задачей где одной строчкой проверяется крон выражение. Кварц - типичный маркер кровавого ентерпрайза с диким оверинжинирингом.