Spring Boot: 3.0.3

Using method with @Async annotation causes that Trace context is broken and spans within Async method are not child for outer spans.

Regarding this guide: https://github.com/micrometer-metrics/tracing/wiki/Spring-Cloud-Sleuth-3.1-Migration-Guide#async-instrumentation it should be working when declaring specific ThreadPoolTaskScheduler. But even with it, it is not working as expected. Sample test:

package com.example;

import io.micrometer.context.ContextExecutorService;
import io.micrometer.context.ContextSnapshot;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.concurrent.*;

import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

@AutoConfigureObservability
@SpringBootTest(classes = {AsyncTracingTest.AsyncTracingTestApplication.class}, webEnvironment = RANDOM_PORT, properties = {
        "spring.cloud.gcp.trace.enabled=true",
        "spring.cloud.gcp.core.enabled=true",
        "spring.cloud.gcp.trace.project-id=xyz-project-id"
})
class AsyncTracingTest {
    @Autowired
    Tracer tracer;

    @Autowired
    AsyncTracingTestApplication.AsyncLogic asyncLogic;

    @Test
    void asyncTracingTest() {
        Span firstSpan = tracer.nextSpan().name("First span").tag("test", "test");
        try (Tracer.SpanInScope scope = this.tracer.withSpan(firstSpan.start())) {
            asyncLogic.asyncCall();
        }
        finally {
            firstSpan.end();
        }
    }

    @EnableAsync
    @SpringBootApplication(scanBasePackages = {"scanThisClassOnly"})
    @Configuration(proxyBeanMethods = false)
    static class AsyncTracingTestApplication {

        @Configuration(proxyBeanMethods = false)
        static class AsyncConfig implements AsyncConfigurer, WebMvcConfigurer {
            @Override
            public Executor getAsyncExecutor() {
                return ContextExecutorService.wrap(Executors.newCachedThreadPool(), ContextSnapshot::captureAll);
            }

            @Override
            public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
                configurer.setTaskExecutor(new SimpleAsyncTaskExecutor(r -> new Thread(ContextSnapshot.captureAll().wrap(r))));
            }
        }

        @Bean(name = "taskExecutor", destroyMethod = "shutdown")
        ThreadPoolTaskScheduler threadPoolTaskScheduler() {
            ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler() {
                @Override
                protected ExecutorService initializeExecutor(ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) {
                    ExecutorService executorService = super.initializeExecutor(threadFactory, rejectedExecutionHandler);
                    return ContextExecutorService.wrap(executorService, ContextSnapshot::captureAll);
                }
            };
            threadPoolTaskScheduler.initialize();
            return threadPoolTaskScheduler;
        }

        @Component
        class AsyncLogic {
            Tracer tracer;

            public AsyncLogic(Tracer tracer) {
                this.tracer = tracer;
            }

            @Async("taskExecutor")
            public void asyncCall() {
                tracer.nextSpan().name("Second span").tag("test", "test").start().end();
            }
        }
    }
}

It results in 2 separate traces for First and Second spans: SpringBoot @Async annotation breaks Trace continuity

After removing @Async annotation it is working as expected for synchronous call: SpringBoot @Async annotation breaks Trace continuity

On Spring Boot 2.X with Sleuth it was working as continue trace (with additional span for async call). Sample test:

package com.example;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.sleuth.Span;
import org.springframework.cloud.sleuth.Tracer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.concurrent.*;

import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

@SpringBootTest(classes = {AsyncTracingTest.AsyncTracingTestApplication.class}, webEnvironment = RANDOM_PORT, properties = {
        "spring.cloud.gcp.trace.enabled=true",
        "spring.cloud.gcp.core.enabled=true",
        "spring.cloud.gcp.trace.project-id=xyz-project-id"
})
class AsyncTracingTest {
    @Autowired
    Tracer tracer;

    @Autowired
    AsyncTracingTestApplication.AsyncLogic asyncLogic;

    @Test
    void asyncTracingTest() {
        Span firstSpan = tracer.nextSpan().name("First span").tag("test", "test");
        try (Tracer.SpanInScope scope = this.tracer.withSpan(firstSpan.start())) {
            asyncLogic.asyncCall();
        }
        finally {
            firstSpan.end();
        }
    }

    @EnableAsync
    @SpringBootApplication(scanBasePackages = {"scanThisClassOnly"})
    @Configuration(proxyBeanMethods = false)
    static class AsyncTracingTestApplication {

        @Component
        class AsyncLogic {
            Tracer tracer;

            public AsyncLogic(Tracer tracer) {
                this.tracer = tracer;
            }
            @Async("taskExecutor")
            public void asyncCall() {
                tracer.nextSpan().name("Second span").tag("test", "test").start().end();
            }
        }
    }
}

SpringBoot @Async annotation breaks Trace continuity

For me it seems like a bug with @Async handling. E.g. for AsyncTaskExecutor

Callable<String> tracedCallable = tracer.currentTraceContext().wrap(callable);
threadPoolTaskExecutor.submit(tracedCallable);

it correctly passes Tracing context to asynchronous callable.

Comment From: ttddyy

At a quick glance, I think it is because a necessary context-propagation is not happening to the new task. The ThreadLocalAccessor from micrometer(ObservationThreadLocalAccessor), which is automatically registered, propagates Observation to the new task, but it does not propagate tracer/span.

So, if this sample uses Observation rather than Tracer/Span, it should automatically propagate the Observation to the task. When a task creates a new Observation, it will be a child of the caller's Observation. This will generate parent-child spans, at the end.

Alternatively, you may write and register a ThreadLocalAccessor that propagates necessary objects for Tracer to continue in the task, though I'm not sure what are those objects.

Comment From: marcingrzejszczak

Thank you @rafal-dudek for filing this issue and for a great reproducer.

Missing Wiki Entry

To begin with we're missing wrapping of the ScheduledExecutorService in the wiki migration guide snippet (I've already updated the wiki page).

/**
     * NAME OF THE BEAN IS IMPORTANT!
     * <p>
     * We need to wrap this for @Async related things to propagate the context.
     *
     * @see EnableAsync
     */
    // [Observability] instrumenting executors
    @Bean(name = "taskExecutor", destroyMethod = "shutdown")
    ThreadPoolTaskScheduler threadPoolTaskScheduler() {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler() {
            @Override
            protected ExecutorService initializeExecutor(ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) {
                ExecutorService executorService = super.initializeExecutor(threadFactory, rejectedExecutionHandler);
                return ContextExecutorService.wrap(executorService, ContextSnapshot::captureAll);
            }

                // THIS WAS MISSING
                @Override
                public ScheduledExecutorService getScheduledExecutor() throws IllegalStateException {
                    return ContextScheduledExecutorService.wrap(super.getScheduledExecutor());
                }
        };
        threadPoolTaskScheduler.initialize();
        return threadPoolTaskScheduler;
    }

Making This Work With Current Setup

Adding that snippet won't help you out fully just yet. In order for this to work out of the box, you would need to change your code to use observations as presented below:

@Test
    void asyncTracingTest() {
        Observation firstSpan = Observation.createNotStarted("First span", observationRegistry).highCardinalityKeyValue("test", "test");
        try (Observation.Scope scope = firstSpan.start().openScope()) {
            log.info("Async in test with observation - before call");          
            asyncLogic.asyncCall();
            log.info("Async in test with observation - after call");
        }
        finally {
            firstSpan.stop();
        }

        Future<Boolean> submit = threadPoolTaskScheduler.submit(() -> {
            log.info("There should be no span here");
            return tracer.currentSpan() == null;
        });
        boolean noCurrentSpan = submit.get(1, TimeUnit.SECONDS);

        Assertions.assertThat(noCurrentSpan).isTrue();
    }

If you do that you will observe that the ids got propagated.

Making This Work With Micrometer Tracing API

However there should be no problem with actually using just the Micrometer Tracing API and assuming that things just work. So let me suggest a temporary workaround for you until we figure out a better way of doing things.

What we will need is a Micrometer Tracing ThreadLocalAccessor that will be aware of Observations. You can use this code:

/*
 * Copyright 2002-2021 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.example.boot34622;

import io.micrometer.context.ThreadLocalAccessor;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.handler.TracingObservationHandler;

/**
 * A {@link ThreadLocalAccessor} to put and restore current {@link Span} depending on whether {@link ObservationThreadLocalAccessor} did some
 * work or not.
 *
 * @author Marcin Grzejszczak
 */
public class ObservationAwareSpanThreadLocalAccessor implements ThreadLocalAccessor<Span> {

    /**
     * Key under which this accessor is being registered.
     */
    public static final String KEY = "micrometer.tracing";

    private final Tracer tracer;

    private static final ObservationRegistry registry = ObservationRegistry.create();

    public ObservationAwareSpanThreadLocalAccessor(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    public Object key() {
        return KEY;
    }

    @Override
    public Span getValue() {
        Observation currentObservation = registry.getCurrentObservation();
        if (currentObservation != null) {
            // There's a current observation so OTLA hooked in
            // we will now check if the user created spans manually or not
            TracingObservationHandler.TracingContext tracingContext = currentObservation.getContextView().getOrDefault(TracingObservationHandler.TracingContext.class, new TracingObservationHandler.TracingContext());
            Span currentSpan = tracer.currentSpan();
            if (currentSpan != null && !currentSpan.equals(tracingContext.getSpan())) {
                // User created child spans manually and scoped them
                // the current span is not the same as the one from observation
                return currentSpan;
            }
            // Current span is same as the one from observation, we will skip this
            return null;
        }
        // No current observation so let's check the tracer
        return this.tracer.currentSpan();
    }

    @Override
    public void setValue(Span value) {
        this.tracer.withSpan(value);
    }

    @Override
    public void reset() {
        this.tracer.withSpan(null);
    }
}

Having that you need to register it in your code. You can do it by calling the Context Propagation API like this


@Configuration(proxyBeanMethods = false)
class Config {

        @Autowired
        Tracer tracer;

        @PostConstruct
        void setup() {
             ContextRegistry.getInstance().registerThreadLocalAccessor(new ObservationAwareSpanThreadLocalAccessor(tracer));
        }
}

Test Code

With that done tracing context will be propagated regardless of the fact whether you're using Observation API and / or Tracing API. Let me paste the whole testing code:

package com.example.boot34622;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import io.micrometer.context.ContextExecutorService;
import io.micrometer.context.ContextRegistry;
import io.micrometer.context.ContextScheduledExecutorService;
import io.micrometer.context.ContextSnapshot;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import jakarta.annotation.PostConstruct;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.BDDAssertions;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

@AutoConfigureObservability
@SpringBootTest(classes = {AsyncTracingTest.AsyncTracingTestApplication.class}, webEnvironment = RANDOM_PORT, properties = {
        "spring.cloud.gcp.trace.enabled=true",
        "spring.cloud.gcp.core.enabled=true",
        "spring.cloud.gcp.trace.project-id=xyz-project-id"
})
class AsyncTracingTest {

    static final Logger log = LoggerFactory.getLogger(AsyncTracingTest.class);

    @Autowired
    Tracer tracer;

    @Autowired
    ObservationRegistry observationRegistry;

    @Autowired
    AsyncTracingTestApplication.AsyncLogic asyncLogic;

    @Autowired
    ThreadPoolTaskScheduler threadPoolTaskScheduler;

    @Test
    void asyncTracingTestWithObservationAndManualSpans() throws ExecutionException, InterruptedException, TimeoutException {
        Observation firstSpan = Observation.createNotStarted("First span", observationRegistry).highCardinalityKeyValue("test", "test");
        try (Observation.Scope scope = firstSpan.start().openScope()) {
            log.info("Async in test with observation - before call");

            Span secondSpan = tracer.nextSpan().name("Second span").tag("test", "test");
            try (Tracer.SpanInScope scope2 = this.tracer.withSpan(secondSpan.start())) {
                log.info("Async in test with span - before call");
                Future<String> future = asyncLogic.asyncCall();
                String spanIdFromFuture = future.get(1, TimeUnit.SECONDS);
                log.info("Async in test with span - after call");
                BDDAssertions.then(spanIdFromFuture).isEqualTo(secondSpan.context().spanId());
            }
            finally {
                secondSpan.end();
            }

            log.info("Async in test with observation - after call");
        }
        finally {
            firstSpan.stop();
        }

        Future<Boolean> submit = threadPoolTaskScheduler.submit(() -> {
            log.info("There should be no span here");
            return tracer.currentSpan() == null;
        });
        boolean noCurrentSpan = submit.get(1, TimeUnit.SECONDS);

        Assertions.assertThat(noCurrentSpan).isTrue();

    }

    @Test
    void asyncTracingTestWithJustObservation() throws ExecutionException, InterruptedException, TimeoutException {
        Observation firstObservation = Observation.createNotStarted("First span", observationRegistry).highCardinalityKeyValue("test", "test");
        try (Observation.Scope scope = firstObservation.start().openScope()) {
            log.info("Async in test with span - before call");
            String currentSpanId = tracer.currentSpan().context().spanId();
            Future<String> future = asyncLogic.asyncCall();
            String spanIdFromFuture = future.get(1, TimeUnit.SECONDS);
            log.info("Async in test with span - after call");
            BDDAssertions.then(spanIdFromFuture).isEqualTo(currentSpanId);
        }
        finally {
            firstObservation.stop();
        }

        Future<Boolean> submit = threadPoolTaskScheduler.submit(() -> {
            log.info("There should be no span here");
            return tracer.currentSpan() == null;
        });
        boolean noCurrentSpan = submit.get(1, TimeUnit.SECONDS);

        Assertions.assertThat(noCurrentSpan).isTrue();

    }

    @Test
    void asyncTracingTestWithJustSpans() throws ExecutionException, InterruptedException, TimeoutException {
        Span secondSpan = tracer.nextSpan().name("Second span").tag("test", "test");
        try (Tracer.SpanInScope scope2 = this.tracer.withSpan(secondSpan.start())) {
            log.info("Async in test with span - before call");
            Future<String> future = asyncLogic.asyncCall();
            String spanIdFromFuture = future.get(1, TimeUnit.SECONDS);
            log.info("Async in test with span - after call");
            BDDAssertions.then(spanIdFromFuture).isEqualTo(secondSpan.context().spanId());
        }
        finally {
            secondSpan.end();
        }

        Future<Boolean> submit = threadPoolTaskScheduler.submit(() -> {
            log.info("There should be no span here");
            return tracer.currentSpan() == null;
        });
        boolean noCurrentSpan = submit.get(1, TimeUnit.SECONDS);

        Assertions.assertThat(noCurrentSpan).isTrue();

    }

    @EnableAsync
    @SpringBootApplication(scanBasePackages = {"scanThisClassOnly"})
    @Configuration(proxyBeanMethods = false)
    static class AsyncTracingTestApplication {

        @Configuration(proxyBeanMethods = false)
        static class AsyncConfig implements AsyncConfigurer, WebMvcConfigurer {
            @Override
            public Executor getAsyncExecutor() {
                return ContextExecutorService.wrap(Executors.newCachedThreadPool(), ContextSnapshot::captureAll);
            }

            @Override
            public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
                configurer.setTaskExecutor(new SimpleAsyncTaskExecutor(r -> new Thread(ContextSnapshot.captureAll().wrap(r))));
            }
        }

        @Bean(name = "taskExecutor", destroyMethod = "shutdown")
        ThreadPoolTaskScheduler threadPoolTaskScheduler() {
            ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler() {
                @Override
                protected ExecutorService initializeExecutor(ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) {
                    ExecutorService executorService = super.initializeExecutor(threadFactory, rejectedExecutionHandler);
                    return ContextExecutorService.wrap(executorService, ContextSnapshot::captureAll);
                }

                @Override
                public ScheduledExecutorService getScheduledExecutor() throws IllegalStateException {
                    return ContextScheduledExecutorService.wrap(super.getScheduledExecutor());
                }
            };
            threadPoolTaskScheduler.initialize();
            return threadPoolTaskScheduler;
        }

        @Autowired
        Tracer tracer;

        @PostConstruct
        void setup() {
            ContextRegistry.getInstance().registerThreadLocalAccessor(new ObservationAwareSpanThreadLocalAccessor(tracer));
        }

        @Component
        class AsyncLogic {
            Tracer tracer;

            public AsyncLogic(Tracer tracer) {
                this.tracer = tracer;
            }

            @Async("taskExecutor")
            public Future<String> asyncCall() {
                log.info("TASK EXECUTOR");
                tracer.nextSpan().name("Second span").tag("test", "test").start().end();
                String spanId = tracer.currentSpan().context().spanId();
                return CompletableFuture.supplyAsync(() -> spanId);
            }
        }
    }
}

Summary

WDYT about this? I've checked this code and it works fine, if that's possible, could you please check it also on your side @rafal-dudek ?

I've created an issue in Micrometer Tracing to track this https://github.com/micrometer-metrics/tracing/issues/206.

Comment From: rafal-dudek

Thanks, with ScheduledExecutorService override and using Observation instead of Span tracing for @Async is working properly.

I also checked your ObservationAwareSpanThreadLocalAccessor and it is working for Spans without Observation.

I'm looking forward to more automatic way to do that in some future Spring Boot update, as it might be convenient for our teams.

Comment From: wilkinsona

Thanks, @marcingrzejszczak and @rafal-dudek. It's good to hear this is working now. I'm going to close this in favor of the Micrometer Tracing wiki updates and issue to which I've subscribed so that we can make any Boot changes that may be needed in the future.

Comment From: rafal-dudek

I also noticed similar situation when using WebClient with Scheduler.

When using Observation, the trace context is propagated when I use wrapped Executor:

    @Test
    void schedulerObservation() {
        Scheduler scheduler = Schedulers.fromExecutor(ContextExecutorService.wrap(Executors.newCachedThreadPool(), ContextSnapshot::captureAll));
        scheduler.init();

        WebClient client = builder.baseUrl("http://localhost:" + port).build();

        Observation firstSpan = Observation.createNotStarted("test-span", observationRegistry).highCardinalityKeyValue("test","test");
        try (Observation.Scope scope = firstSpan.start().openScope()) {
            client.get().uri("/").retrieve().bodyToMono(String.class).subscribeOn(scheduler).block();
        } finally {
            firstSpan.stop();
        }
    }

SpringBoot @Async annotation breaks Trace continuity

But when using Span, the context is not propagated (client with a server is a separate Trace than "test-span")

    @Test
    void schedulerSpan() {
        Scheduler scheduler = Schedulers.fromExecutor(ContextExecutorService.wrap(Executors.newCachedThreadPool(), ContextSnapshot::captureAll));
        scheduler.init();

        WebClient client = builder.baseUrl("http://localhost:" + port).build();

        Span firstSpan = tracer.nextSpan().name("test-span").tag("test", "test");
        try (Tracer.SpanInScope scope = this.tracer.withSpan(firstSpan.start())) {
            client.get().uri("/").retrieve().bodyToMono(String.class).subscribeOn(scheduler).block();

        }
        finally {
            firstSpan.end();
        }
    }

SpringBoot @Async annotation breaks Trace continuity

And without Scheduler it is working for both cases:

    @Test
    void noSchedulerSpan() {
        WebClient client = builder.baseUrl("http://localhost:" + port).build();

        Span firstSpan = tracer.nextSpan().name("test-span").tag("test", "test");
        try (Tracer.SpanInScope scope = this.tracer.withSpan(firstSpan.start())) {
            client.get().uri("/").retrieve().bodyToMono(String.class).block();
        }
        finally {
            firstSpan.end();
        }
    }

SpringBoot @Async annotation breaks Trace continuity

@marcingrzejszczak Is this problem related to the same ThreadLocalAccessors issue https://github.com/micrometer-metrics/tracing/issues/206 or should I create new Issue for that?

Comment From: marcingrzejszczak

I think it's the same issue. Do you observe that the problem is no longer present when you use the ThreadLocalAccessor workaround I've presented in this issue?

Comment From: rafal-dudek

Yes it is working, didn't thought to check that. So I am waiting for https://github.com/micrometer-metrics/tracing/issues/206. Thanks

Comment From: Toxa45

@marcingrzejszczak Will this work in Spring Boot 3.2.2 with virtual threads?

Comment From: thammaratNak1

Will this work in Spring Boot 3.2.2 with virtual threads?

Comment From: bclozel

Yes, see ˋApplication Events and @EventListener` in https://docs.spring.io/spring-framework/reference/integration/observability.html

Comment From: bylidev

I'll leave a hotfix here. I don't know why you guys don't like to propagate traces on async tasks.

  @Bean("asyncTaskExecutor")
  public Executor asyncTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(20);
    executor.setMaxPoolSize(20);
    executor.setQueueCapacity(10);
    executor.setKeepAliveSeconds(1);
    executor.setThreadNamePrefix("test");
    executor.initialize();

    return ContextExecutorService.wrap(
        executor.getThreadPoolExecutor(), ContextSnapshot::captureAll); // Aslo this is deprecated D:
  }

Comment From: bclozel

@bylidev what gives you the impression that we don't like this use case?

The workaround you have shared is not advised as it wraps the original type as ExecutorService and erases the concrete type ThreadPoolTaskExecutor. Exposing the most concrete type in the bean method signature is the preferred approach in Spring applications.

Setting up context propagation by default for all executors would create significant overhead, even if the use case doesn't require it. You can check out Spring Framework's reference documentation on observability for application events and executors for a simpler solution.

Comment From: bylidev

Solved adding task decorator as well, thanks !

executor.setTaskDecorator(new ContextPropagatingTaskDecorator());

Comment From: investr777

When TaskExecutor execute task without span and observation ObservationAwareSpanThreadLocalAccessor.getValue() returns null. Is there a way in order to create new a Span?

@Bean
    fun testExec(): TaskExecutor {

        val threadPoolTaskExecutor = ThreadPoolTaskExecutorBuilder()
            .corePoolSize(2)
            .maxPoolSize(5)
            .queueCapacity(3)
            .threadNamePrefix("testEXEC-")
            .taskDecorator(ContextPropagatingTaskDecorator())
            .build()

        return threadPoolTaskExecutor
    }

@Configuration(proxyBeanMethods = false)
internal class FConfig {

    @Autowired
   private lateinit var tracer: Tracer

    @PostConstruct
    fun setup() {
        ContextRegistry.getInstance().registerThreadLocalAccessor(ObservationAwareSpanThreadLocalAccessor(tracer))
    }
}

Comment From: bclozel

@investr777 please ask questions on StackOverflow.