Skip to main content

Logging in async threads

For direct REST Assured request/response reporting and its test context rules, see REST Assured clients.

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.