Logging in async threads
Overview
Pumpo#5 routes log entries to a per-test log file using a ThreadLocal test ID.
Threads spawned from a test thread — via new Thread(…), an ExecutorService,
CompletableFuture, or any other async mechanism — do not inherit that value.
Their log entries end up in a shared fallback appender and are therefore not
included in the test's log file or summary output.
LogContext solves this by letting you capture the calling thread's context and
propagate it to async threads.
Quick start
import dev.pumpo5.logging.LogContext;
@Test
void myTest() throws Exception {
LogContext ctx = LogContext.capture(); // capture on the test thread
Thread t = new Thread(ctx.wrap(() -> {
LOG.info("This appears in the same test's log file");
}));
t.start();
t.join(); // must complete before the test ends
}
Important constraint
All async work must complete before the test ends.
The Pumpo#5 afterEach lifecycle hook collects and writes the log file at that
point. Any thread that is still running — or has not yet started — will have its
entries silently omitted from the file. Always call Thread.join(),
Future.get(), or an equivalent await inside the test body.
Usage patterns
Plain thread
LogContext ctx = LogContext.capture();
Thread t = new Thread(ctx.wrap(() -> {
LOG.info("Logged to the same test file");
}));
t.start();
t.join();
ExecutorService
Wrap the entire ExecutorService once; every task submitted to it will
automatically inherit the context.
LogContext ctx = LogContext.capture();
ExecutorService exec = ctx.wrap(Executors.newFixedThreadPool(4));
Future<?> f1 = exec.submit(() -> LOG.info("step 1"));
List<Future<Result>> all = exec.invokeAll(List.of(() -> step2(), () -> step3()));
f1.get();
for (Future<Result> f : all) f.get();
Plain Executor / CompletableFuture
LogContext ctx = LogContext.capture();
CompletableFuture.runAsync(ctx.wrap(() -> {
LOG.info("Async completion logged to the same test");
})).join();
Manual apply / clear
Use this when you cannot pass a lambda — for example, when configuring a third-party callback framework.
LogContext ctx = LogContext.capture();
// Pass ctx to the other thread, then inside that thread:
ctx.apply();
try {
LOG.info("Logged to the originating test's file");
} finally {
ctx.clear(); // always in a finally block
}
Method reference
LogContext::capture
public static LogContext capture()
Captures the calling thread's current test log context. Call this on the test
thread before handing work off to async code. The returned LogContext is
immutable and safe to share across threads.
LogContext::wrap(Runnable)
public Runnable wrap(Runnable runnable)
Returns a Runnable that installs this context before delegating to runnable
and clears it afterwards.
LogContext::wrap(Callable)
public <T> Callable<T> wrap(Callable<T> callable)
Returns a Callable that installs this context before delegating to callable
and clears it afterwards.
LogContext::wrap(Executor)
public Executor wrap(Executor executor)
Returns an Executor that installs this context for every submitted task.
LogContext::wrap(ExecutorService)
public ExecutorService wrap(ExecutorService executor)
Returns an ExecutorService that installs this context for every submitted task.
Lifecycle methods (shutdown, awaitTermination, etc.) are delegated directly
to the underlying executor unchanged.
LogContext::apply
public void apply()
Installs this context on the current thread. Always pair with clear() in a
finally block. Prefer the wrap(…) helpers which do this automatically.
LogContext::clear
public void clear()
Removes this context from the current thread, reverting it to the shared fallback
state. Always call in a finally block after apply().
Fallback behaviour
If LogContext.capture() is called from a thread that is not running inside a
Pumpo#5 test (e.g. a static initialiser or a background daemon), the captured
context holds the shared fallback UUID. No exception is thrown, but log entries
written through that context are effectively lost for file and summary output
modes. When pn5.logging.mode.live=true the entries still reach stdout via
logback's normal pipeline.