When I look up a request ID in the logs, I want to follow the request through the application. Take a shipping quote arriving with X-Request-ID: quote-4711. I’d expect that value in the log entry from the pricing callback, too. If it disappears at the first asynchronous call, I have to reconstruct the rest from whatever the callback happened to log.
A question in r/quarkus describes this situation. The application uses Mutiny and CompletableFuture, puts the incoming request ID into MDC, and loses it at an asynchronous boundary. We’d need a reproducer to establish the cause in that particular application. For now, let’s build a small example where we can see what happens at each boundary.
MDC, the mapped diagnostic context, associates values such as a request ID with log messages. You may know it as a map attached to the current thread. Quarkus also has a reactive MDC integration that can store those values in a Vert.x duplicated context, giving each request isolated context data. If that context follows a task onto another thread, the task can still read the ID. The Quarkus logging guide describes this integration.
I’ve kept the application small: each REST endpoint reports the thread name and the MDC value from inside its callback. Some paths deliberately lose the ID, others preserve it, and one returns a failure response. You can then follow the same request through different execution paths and check which context reaches the callback.
Create the application
You’ll need Java 21 to build and run the application and the Quarkus CLI to create the project. We’ll send requests with curl, inspect the JSON with jq, and use Bash for the concurrency check. The generated project includes a Maven wrapper. Everything runs locally, without a database, container, or external account.
Start with the Quarkus application or the fully build out example on my Github repository.
quarkus create app com.themainthread:mdc-context-demo \
--java=21 \
--extension=rest-jackson,smallrye-context-propagation \
--no-code --no-dockerfiles \
--package-name=com.themainthread.mdc
cd mdc-context-demo
mkdir -p src/main/java/com/themainthread/mdcrest-jackson gives us the REST endpoints and JSON serialization. I included smallrye-context-propagation explicitly because we’ll inject both ManagedExecutor and ThreadContext. That extension supplies the MicroProfile context APIs and their Quarkus integration. You can find the details for Mutiny and CompletionStage in the context propagation guide.
Add this line to src/main/resources/application.properties:
quarkus.log.console.format=%d{HH:mm:ss.SSS} %-5p request.id=%X{request.id} [%c{2.}] (%t) %s%e%n%X{request.id} prints the MDC entry. I also keep the thread name in the format so we can see where each callback executes. The checks below assert the ID, since worker numbers can change between runs.
Establish the ID at the HTTP boundary
Create src/main/java/com/themainthread/mdc/RequestIdFilter.java:
package com.themainthread.mdc;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import org.jboss.logmanager.MDC;
import org.jboss.resteasy.reactive.server.ServerRequestFilter;
import org.jboss.resteasy.reactive.server.ServerResponseFilter;
public class RequestIdFilter {
static final String HEADER = "X-Request-ID";
static final String MDC_KEY = "request.id";
private static final String PROPERTY = RequestIdFilter.class.getName() + ".id";
private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
@ServerRequestFilter
public void request(ContainerRequestContext request) {
List<String> values = request.getHeaders().get(HEADER);
String incoming = values != null && values.size() == 1 ? values.getFirst() : null;
String id = incoming != null && SAFE_ID.matcher(incoming).matches()
? incoming
: UUID.randomUUID().toString();
request.setProperty(PROPERTY, id);
MDC.put(MDC_KEY, id);
}
@ServerResponseFilter
public void response(ContainerRequestContext request, ContainerResponseContext response) {
try {
Object id = request.getProperty(PROPERTY);
if (id != null) {
response.getHeaders().putSingle(HEADER, id);
}
} finally {
MDC.remove(MDC_KEY);
}
}
}For this example, I accept one visible header value containing 1–128 characters from a small alphabet. Missing or invalid input gets a UUID, as do multiple values visible to the filter. This keeps the selected value short and predictable in the logs. Of course, HTTP processing can normalize headers before they reach the filter, and the caller can reuse an accepted ID across requests. Keep authorization tied to an authenticated identity; this ID is only a correlation label.
I keep the selected ID in a request property as well as MDC. The response filter reads the property, so the caller receives the same ID even when a callback loses its logging context. Log statements read MDC. With those two paths in place, you can see a correct response header alongside an empty MDC observation. We’ll check the callback’s value explicitly.
The response filter removes the key we added. I use MDC.remove() here because MDC.clear() would also erase entries installed by other components. Cleanup covers our REST response lifecycle, including the handled failure below. Background tasks and log statements after this filter need their own lifecycle handling. Quarkus’s duplicated contexts provide the request isolation this example also relies on.
Observe the callback itself
Create src/main/java/com/themainthread/mdc/ContextObservation.java:
package com.themainthread.mdc;
import org.jboss.logging.Logger;
import org.jboss.logmanager.MDC;
public record ContextObservation(String stage, String thread, String requestId) {
private static final Logger LOG = Logger.getLogger(ContextObservation.class);
static ContextObservation observe(String stage) {
String id = MDC.get(RequestIdFilter.MDC_KEY);
LOG.infov("stage={0}; observed request.id={1}", stage, id);
return new ContextObservation(stage, Thread.currentThread().getName(), id);
}
}This method reads MDC inside the executing callback. Passing the expected ID as an argument would let us produce the right JSON even with an empty logging context, so I keep that lookup inside observe(). The response then tells us what the callback could actually read.
We also need two small files for the library example. Create src/main/java/com/themainthread/mdc/CaptureResult.java:
package com.themainthread.mdc;
public record CaptureResult(ContextObservation library, ContextObservation continuation) {
}Then create src/main/java/com/themainthread/mdc/LegacyAsyncClient.java:
package com.themainthread.mdc;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class LegacyAsyncClient {
public CompletionStage<ContextObservation> call() {
return CompletableFuture.supplyAsync(() -> ContextObservation.observe("legacy-library"));
}
}This class represents a library that schedules its own task and returns a stage. I’ve reduced it to a common-pool submission, which reproduces the context loss without a network call. The two observations let us inspect the library’s task and the continuation we attach to its result separately.
Add the endpoints
Create src/main/java/com/themainthread/mdc/MdcResource.java:
package com.themainthread.mdc;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.context.ManagedExecutor;
import org.eclipse.microprofile.context.ThreadContext;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import io.vertx.core.Context;
import io.vertx.core.Vertx;
@Path("/mdc")
@Produces(MediaType.APPLICATION_JSON)
public class MdcResource {
private final ManagedExecutor managedExecutor;
private final ThreadContext threadContext;
private final LegacyAsyncClient legacyAsyncClient;
public MdcResource(ManagedExecutor managedExecutor, ThreadContext threadContext,
LegacyAsyncClient legacyAsyncClient) {
this.managedExecutor = managedExecutor;
this.threadContext = threadContext;
this.legacyAsyncClient = legacyAsyncClient;
}
@GET
@Path("/common-pool")
public CompletionStage<ContextObservation> commonPool() {
ContextObservation.observe("before-common-pool");
return CompletableFuture.supplyAsync(() -> ContextObservation.observe("common-pool"));
}
@GET
@Path("/managed")
public CompletionStage<ContextObservation> managed() {
ContextObservation.observe("before-managed");
return managedExecutor.supplyAsync(() -> ContextObservation.observe("managed"));
}
@GET
@Path("/mutiny")
public Uni<ContextObservation> mutiny() {
ContextObservation.observe("before-mutiny");
return Uni.createFrom().item("continue")
.emitOn(Infrastructure.getDefaultExecutor())
.map(ignored -> ContextObservation.observe("mutiny"));
}
@GET
@Path("/uncaptured")
public CompletionStage<CaptureResult> uncaptured() {
ContextObservation.observe("before-uncaptured");
return legacyAsyncClient.call()
.thenApplyAsync(library -> new CaptureResult(library,
ContextObservation.observe("uncaptured-continuation")), ForkJoinPool.commonPool());
}
@GET
@Path("/captured")
public CompletionStage<CaptureResult> captured() {
ContextObservation.observe("before-captured");
CompletionStage<ContextObservation> stage = legacyAsyncClient.call();
return threadContext.withContextCapture(stage)
.thenApplyAsync(library -> new CaptureResult(library,
ContextObservation.observe("captured-continuation")), ForkJoinPool.commonPool());
}
@GET
@Path("/bridged")
public CompletionStage<CaptureResult> bridged() {
ContextObservation.observe("before-bridged");
Context requestContext = Vertx.currentContext();
Executor returnToRequest = task -> requestContext.runOnContext(ignored -> task.run());
CompletionStage<ContextObservation> stage = legacyAsyncClient.call();
return threadContext.withContextCapture(stage)
.thenApplyAsync(library -> new CaptureResult(library,
ContextObservation.observe("bridged-continuation")), returnToRequest);
}
@GET
@Path("/managed-failure")
public CompletionStage<ContextObservation> managedFailure() {
return managedExecutor.supplyAsync(() -> {
ContextObservation failure = ContextObservation.observe("managed-failure");
throw new WebApplicationException(Response.status(Response.Status.SERVICE_UNAVAILABLE)
.entity(failure).type(MediaType.APPLICATION_JSON).build());
});
}
}Quarkus supplies the beans through the constructor and manages the executor’s lifetime. Each endpoint returns its asynchronous result to Quarkus REST, which waits for that result before sending the response. I’ve kept all the demonstrated tasks within that request lifetime.
Start dev mode:
quarkus devLeave it running and use a second terminal for the requests. The default address is http://localhost:8080.
Lose the ID on purpose
Start with /common-pool:
curl --fail --silent --show-error \
-H 'X-Request-ID: quote-4711' \
http://localhost:8080/mdc/common-pool | jqThe response has this shape; the worker number can differ:
{
"stage": "common-pool",
"thread": "ForkJoinPool.commonPool-worker-7",
"requestId": null
}Have a look at the two log entries. The entry before submission contains quote-4711. Inside the supplier, request.id is empty and the message reports observed request.id=null. We received a successful HTTP response from a task that had already lost its correlation information.
Calling CompletableFuture.supplyAsync() without an executor normally sends the task to ForkJoinPool.commonPool(). The JDK falls back to a new thread per task when the common pool cannot support parallelism of at least two. Both paths schedule execution outside this request’s Quarkus context. The Java 21 CompletableFuture API documents the scheduling rule.
Submit a task while its request context is available
For /managed, I submit the task directly to the injected executor while the REST method is still executing in the request:
return managedExecutor.supplyAsync(() -> ContextObservation.observe("managed"));Verify the result:
curl --fail --silent --show-error \
-H 'X-Request-ID: managed-123' \
http://localhost:8080/mdc/managed |
jq -e '.stage == "managed" and .requestId == "managed-123"'jq prints true and exits successfully. The log moves from an event-loop thread to an executor thread while retaining managed-123.
I’d start here when the application controls task submission. Quarkus’s executor integration preserves the relevant Vert.x execution context, and SmallRye handles registered MicroProfile contexts. In this method, submission happens while the request context is available. A callback scheduled later by an unmanaged thread can reach the executor with a different context, as we’ll see in the library example.
The Mutiny endpoint uses the same managed execution environment:
curl --fail --silent --show-error \
-H 'X-Request-ID: mutiny-123' \
http://localhost:8080/mdc/mutiny |
jq -e '.stage == "mutiny" and .requestId == "mutiny-123"'You should see true here as well. The item source emits synchronously while the request context is present. Then emitOn(Infrastructure.getDefaultExecutor()) moves downstream processing to Quarkus’s executor, with SmallRye’s Mutiny integration participating in context propagation.
So far, we’ve checked a specific pipeline and executor. If you wrap a library’s returned stage in a Uni, the library still controls how its internal tasks execute. You’ll need to check those submission boundaries as well. And if your callback changes shared mutable state, it still needs the same concurrency protection when running on a worker.
A returned CompletionStage needs a closer look
First call the library without capture:
curl --fail --silent --show-error \
-H 'X-Request-ID: library-123' \
http://localhost:8080/mdc/uncaptured |
jq '{library: .library.requestId, continuation: .continuation.requestId}'Both observations are empty:
{
"library": null,
"continuation": null
}I chose thenApplyAsync with an explicit common-pool executor for this control. An ordinary thenApply can execute immediately in the registering thread if the stage is already complete, or in a completing thread otherwise. You could then see the ID come and go as the library’s response time changes. Here, the continuation runs off the request thread regardless of when the library completes.
Now try /captured, which wraps the stage with ThreadContext.withContextCapture() before attaching the callback:
curl --fail --silent --show-error \
-H 'X-Request-ID: capture-123' \
http://localhost:8080/mdc/captured |
jq '{library: .library.requestId, continuation: .continuation.requestId}'With the pinned application, I got the same empty values:
{
"library": null,
"continuation": null
}ThreadContext uses registered MicroProfile context providers to contextualize dependent actions. Those providers define which context it can propagate. In this application, the wrapper leaves the continuation’s Vert.x-backed MDC empty. The ThreadContext API specifies that wrapping creates a new stage and leaves the original stage’s existing tasks unchanged. Attach your continuation to that returned wrapper while the required context is available.
I also checked the combination of withContextCapture() and managedExecutor for the continuation. That call lost MDC too. If the stage is already complete, submission can happen from the request thread and preserve MDC. If the library completes it later on an unmanaged thread, the executor can receive the task from there without the original Vert.x context. As it turns out, this callback needs an explicit route back to the original Vert.x context.
Return to the saved request context
The /bridged endpoint adds an explicit route back:
Context requestContext = Vertx.currentContext();
Executor returnToRequest = task -> requestContext.runOnContext(ignored -> task.run());I save the current context inside the REST method, while the request is still executing there. The executor adapter can then schedule the continuation on that same context when the library completes its stage elsewhere. Vert.x provides this behavior through Context.runOnContext().
We now have both mechanisms in the chain. withContextCapture() handles registered MicroProfile contexts for the dependent action, and returnToRequest re-enters the Vert.x context holding this request’s MDC. In our example, that return to the Vert.x context gives the callback access to the ID again. Let’s call the endpoint:
curl --fail --silent --show-error \
-H 'X-Request-ID: bridge-123' \
http://localhost:8080/mdc/bridged |
jq '{library: .library.requestId, continuation: .continuation.requestId}'Now we get:
{
"library": null,
"continuation": "bridge-123"
}The continuation now has bridge-123 and runs on a vert.x-eventloop-thread-* thread. The library’s own observation remains empty, because the bridge controls the callback we attach to its result. If you also need the ID in the library’s internal logs, look for an executor or context parameter supported by its API.
I’ve kept this continuation short: it constructs a record and writes a log line, which lets it run on the event loop. Keep blocking I/O and long CPU calculations off this callback. If your next operation needs a worker, submit it from the restored context and compose the returned stage into the response. The saved context must stay local to this request. Storing it in an application-scoped field would mix request lifetimes.
The endpoint’s asynchronous return type gives us Quarkus REST’s nonblocking execution model, with the request’s Vert.x context available inside the method. You can read about the defaults and annotation overrides in the REST execution model. On an arbitrary background thread, Vertx.currentContext() may return null, so check that condition before adapting this code to another entry point.
Check generated IDs, failures, and overlapping requests
Before moving on, I also want to check what happens when the caller leaves out the ID or sends an invalid value. Inspect the header and body together:
curl --silent --show-error --include \
http://localhost:8080/mdc/managed
curl --silent --show-error --include \
-H 'X-Request-ID: contains spaces' \
http://localhost:8080/mdc/managedEach response should contain a generated UUID in X-Request-ID and the same UUID in the body’s requestId. For the second request, the filter should replace contains spaces with that UUID.
For the failure path, /managed-failure throws a WebApplicationException inside the managed task. You can check that the handled failure keeps its correlation value with this call:
curl --silent --show-error --include \
-H 'X-Request-ID: failed-123' \
http://localhost:8080/mdc/managed-failureExpect HTTP 503, X-Request-ID: failed-123, and a JSON observation with "requestId":"failed-123". I’ve left out --fail for this call because we’re expecting the error status. Then send another request without the header and check that it gets a fresh UUID.
Now let’s overlap a few requests. I want each callback to retain its own ID while the same pools serve other requests. Create the scripts directory, then add scripts/check-concurrent.sh with the following content:
#!/usr/bin/env bash
set -euo pipefail
base_url=${1:-http://localhost:8080}
export base_url
check_one() {
local id="request-$1"
curl --fail --silent --show-error --max-time 10 \
-H "X-Request-ID: $id" "$base_url/mdc/bridged" |
jq -e --arg id "$id" \
'.library.requestId == null and .continuation.requestId == $id' > /dev/null
}
export -f check_one
seq 1 100 | xargs -n 1 -P 20 bash -euo pipefail -c 'check_one "$1"' _
printf 'All 100 bridged requests kept their own IDs.\n'Run it from the project directory:
scripts/check-concurrent.shIt sends 100 requests with up to 20 concurrent curl processes. Each process checks its own expected ID in the continuation and an empty library observation. HTTP errors, timeouts, invalid JSON, and mismatches cause a nonzero exit. Success prints:
All 100 bridged requests kept their own IDs.For a different port, pass the base URL: bash scripts/check-concurrent.sh http://localhost:8086.
The companion project’s src/test/java/com/themainthread/mdc/MdcResourceTest.java adds automated HTTP checks for all seven paths, missing and invalid IDs, the 128-character boundary, repeated HTTP/1.1 header values, and the failure response. It also issues 140 requests in batches of 20 across the seven endpoints, checking both the response headers and the MDC observations. MdcResourceIT.java repeats those checks against the packaged JVM application.
From the companion project, stop dev mode with Ctrl+C and run:
./mvnw verify -DskipITs=falseThe run passed all 11 application tests and all 11 packaged-JVM tests. That gives us regression coverage for the boundaries demonstrated here. Your own libraries and custom executors need equivalent checks in their actual configuration. I kept this validation on the JVM; native execution remains untested.
Apply the boundary deliberately
Every task in this example contributes to the HTTP response. For a scheduled job or a durable background task, I’d give the job explicit correlation metadata and manage its lifetime separately. A propagated request-scoped bean still reaches the end of its lifetime with the request, as the CDI context propagation guidance explains. If a job only needs the ID string, pass that value as part of the job’s data.
We’ve stayed inside one JVM throughout this example. Sending X-Request-ID to another service requires an explicit client header policy. Quarkus REST Client supports selected incoming-header propagation with @RegisterClientHeaders and org.eclipse.microprofile.rest.client.propagateHeaders, described in its custom headers documentation. Our filter stores generated IDs in a request property. A client forwarding that selected value needs to read the property explicitly, because the original incoming header won’t contain a newly generated ID. When you need span relationships across services as well, add distributed tracing.
For quote-4711, we can now follow the ID into the callback and see where it disappears in the unmanaged paths. I’d use the same approach in a larger application: find the task submission, check the context available there, and read MDC again inside the callback. For the external stage here, saving and returning to the Vert.x request context restored the correlation value. With those checks in place, I can follow the request through its asynchronous calls when it fails.


