org.junit.jupiter
junit-jupiter
diff --git a/kit-java-web/src/main/java/dev/suprim/kit/web/context/ContextPropagation.java b/kit-java-web/src/main/java/dev/suprim/kit/web/context/ContextPropagation.java
new file mode 100644
index 0000000..1ba2dcb
--- /dev/null
+++ b/kit-java-web/src/main/java/dev/suprim/kit/web/context/ContextPropagation.java
@@ -0,0 +1,78 @@
+package dev.suprim.kit.web.context;
+
+import org.slf4j.MDC;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+
+/**
+ * Utilities for propagating {@link RequestContext} and MDC across async boundaries.
+ *
+ * When spawning async tasks (thread pools, CompletableFuture, etc.), the trace context
+ * is lost because it lives in ThreadLocal. These wrappers capture and restore it.
+ *
+ * Usage:
+ *
+ * executor.submit(ContextPropagation.wrap(() -> {
+ * // RequestContext and MDC are available here
+ * String traceId = RequestContext.getTraceId().orElse("unknown");
+ * }));
+ *
+ */
+public final class ContextPropagation {
+
+ private ContextPropagation() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+
+ /**
+ * Wraps a Runnable to propagate the current RequestContext and MDC to the executing thread.
+ *
+ * @param task the task to wrap, must not be null
+ * @return a context-aware Runnable
+ */
+ public static Runnable wrap(Runnable task) {
+ Objects.requireNonNull(task, "task");
+ Map contextSnapshot = RequestContext.snapshot();
+ Map mdcSnapshot = MDC.getCopyOfContextMap();
+ return () -> {
+ RequestContext.restore(contextSnapshot);
+ setMdcContext(mdcSnapshot);
+ try {
+ task.run();
+ } finally {
+ RequestContext.clear();
+ MDC.clear();
+ }
+ };
+ }
+
+ /**
+ * Wraps a Callable to propagate the current RequestContext and MDC to the executing thread.
+ *
+ * @param task the task to wrap, must not be null
+ * @return a context-aware Callable
+ */
+ public static Callable wrap(Callable task) {
+ Objects.requireNonNull(task, "task");
+ Map contextSnapshot = RequestContext.snapshot();
+ Map mdcSnapshot = MDC.getCopyOfContextMap();
+ return () -> {
+ RequestContext.restore(contextSnapshot);
+ setMdcContext(mdcSnapshot);
+ try {
+ return task.call();
+ } finally {
+ RequestContext.clear();
+ MDC.clear();
+ }
+ };
+ }
+
+ private static void setMdcContext(Map mdcSnapshot) {
+ Optional.ofNullable(mdcSnapshot)
+ .ifPresentOrElse(MDC::setContextMap, MDC::clear);
+ }
+}
diff --git a/kit-java-web/src/main/java/dev/suprim/kit/web/context/RequestContext.java b/kit-java-web/src/main/java/dev/suprim/kit/web/context/RequestContext.java
new file mode 100644
index 0000000..96ca697
--- /dev/null
+++ b/kit-java-web/src/main/java/dev/suprim/kit/web/context/RequestContext.java
@@ -0,0 +1,92 @@
+package dev.suprim.kit.web.context;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Holds request-scoped context (trace ID, request ID, etc.) via ThreadLocal.
+ *
+ * Lifecycle is managed by {@link RequestContextFilter} for HTTP requests.
+ * For gRPC, use the corresponding interceptor in kit-java-grpc.
+ *
+ * Usage:
+ *
+ * String traceId = RequestContext.getTraceId().orElse("unknown");
+ *
+ */
+public final class RequestContext {
+
+ public static final String KEY_TRACE_ID = "traceId";
+ public static final String KEY_REQUEST_ID = "requestId";
+
+ private static final ThreadLocal
+
+