Skip to main content

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.