Punch2027: Obidients reject INEC’s plan to use AIThe Jerusalem PostSome 18 injured in eight-car pileup on Highway 6 in central Israel, halting trafficDaily MaverickWORLD HEART DAY: Silent killer, simple fix? How to save SA from hypertensionRTP DesportoJaime Faria falha acesso ao quadro principal do torneio de TóquioInquirerTulfo flags ‘palakasan system’ in NHA housing beneficiary selectionVanguardOtti to Ndigbo: Don’t fight Chinese traders; beat them with technologyColliderArthur Morgan Actor Officially Addresses One of 'Red Dead Redemption 2's Biggest Fan Theories [Exclusive]20 Minuten«Überall liegt Staub»: Das Schächental nach dem FelssturzThe South AfricanDay 15 of 24: Festive Quiz – Test your Local Government Elections 2026 knowledge and win R250NMEVictoria Beckham “back in the studio” as she records vocals with son CruzIl Fatto QuotidianoNations League, la situazione dell’Italia: la nuova classifica, la sfida alla Francia, Montella a rischio | Cosa c’è in balloSportstarIndia in Athletics LIVE Updates, Asian Games 2026: Dev Meena, Kuldeep Kumar clear 5.35m in men's pole vault final; Pooja, Supriya in action in women's high jump
The Daily Newsstand · Free, Always
Tuesday, September 29, 2026

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

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.