From 41e9329afef453d47772d1075f654c04acd74a78 Mon Sep 17 00:00:00 2001 From: SpaceLeam Date: Fri, 26 Dec 2025 12:32:02 -0500 Subject: [PATCH 1/3] Address PR #97 review comments: add withContext API, update docs and changelog --- .../logging/log4j/scala/LoggingContext.scala | 19 +++++++++++++++++++ .../log4j/scala/LoggingContextTest.scala | 10 ++++++++++ .../logging/log4j/scala/LoggingContext.scala | 19 +++++++++++++++++++ .../logging/log4j/scala/LoggingContext.scala | 19 +++++++++++++++++++ .../add-logging-context-with-context.xml | 10 ++++++++++ src/site/antora/modules/ROOT/pages/index.adoc | 17 +++++++++++++++++ 6 files changed, 94 insertions(+) create mode 100644 src/changelog/.13.x.x/add-logging-context-with-context.xml diff --git a/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala b/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala index 246ab02..90ab907 100644 --- a/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala +++ b/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala @@ -81,4 +81,23 @@ object LoggingContext extends mutable.Map[String, String] { override def isEmpty: Boolean = ThreadContext.isEmpty + /** + * Runs the given block with the provided context data effectively added to the + * {@link org.apache.logging.log4j.ThreadContext ThreadContext} and removed after the block completes. + * + * @param context the map of key-value pairs to add to the context + * @param body the block of code to execute + * @tparam R the return type of the block + * @return the result of the block + * @since 13.2.0 + */ + def withContext[R](context: Map[String, String])(body: => R): R = { + ThreadContext.putAll(context.asJava) + try { + body + } finally { + ThreadContext.removeAll(context.keys.asJavaCollection) + } + } + } diff --git a/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala b/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala index b37124c..88e5a45 100644 --- a/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala +++ b/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala @@ -113,4 +113,14 @@ class LoggingContextTest extends AnyFunSuite with Matchers { result shouldBe Set("key1" -> "value1", "key2" -> "value2") } + test("withContext") { + LoggingContext.clear() + LoggingContext.withContext(Map("key1" -> "value1", "key2" -> "value2")) { + LoggingContext.get("key1") shouldBe Some("value1") + LoggingContext.get("key2") shouldBe Some("value2") + } + LoggingContext.get("key1") shouldBe None + LoggingContext.get("key2") shouldBe None + } + } diff --git a/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala b/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala index 246ab02..90ab907 100644 --- a/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala +++ b/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala @@ -81,4 +81,23 @@ object LoggingContext extends mutable.Map[String, String] { override def isEmpty: Boolean = ThreadContext.isEmpty + /** + * Runs the given block with the provided context data effectively added to the + * {@link org.apache.logging.log4j.ThreadContext ThreadContext} and removed after the block completes. + * + * @param context the map of key-value pairs to add to the context + * @param body the block of code to execute + * @tparam R the return type of the block + * @return the result of the block + * @since 13.2.0 + */ + def withContext[R](context: Map[String, String])(body: => R): R = { + ThreadContext.putAll(context.asJava) + try { + body + } finally { + ThreadContext.removeAll(context.keys.asJavaCollection) + } + } + } diff --git a/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala b/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala index c21529c..66c07fc 100644 --- a/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala +++ b/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala @@ -72,4 +72,23 @@ object LoggingContext extends mutable.Map[String, String] { override def isEmpty: Boolean = ThreadContext.isEmpty + /** + * Runs the given block with the provided context data effectively added to the + * {@link org.apache.logging.log4j.ThreadContext ThreadContext} and removed after the block completes. + * + * @param context the map of key-value pairs to add to the context + * @param body the block of code to execute + * @tparam R the return type of the block + * @return the result of the block + * @since 13.2.0 + */ + def withContext[R](context: Map[String, String])(body: => R): R = { + ThreadContext.putAll(context.asJava) + try { + body + } finally { + ThreadContext.removeAll(context.keys.asJava) + } + } + } diff --git a/src/changelog/.13.x.x/add-logging-context-with-context.xml b/src/changelog/.13.x.x/add-logging-context-with-context.xml new file mode 100644 index 0000000..296fc1f --- /dev/null +++ b/src/changelog/.13.x.x/add-logging-context-with-context.xml @@ -0,0 +1,10 @@ + + + + + Added `LoggingContext.withContext` to allow running a block of code with a populated `ThreadContext` that is automatically cleared upon completion. + + diff --git a/src/site/antora/modules/ROOT/pages/index.adoc b/src/site/antora/modules/ROOT/pages/index.adoc index 1280168..9c4c368 100644 --- a/src/site/antora/modules/ROOT/pages/index.adoc +++ b/src/site/antora/modules/ROOT/pages/index.adoc @@ -88,3 +88,20 @@ logger.debug(s"Logging in user ${user.getName} with birthday ${user.calcBirthday Most logging implementations use a hierarchical scheme for matching logger names with logging configuration. In this scheme the logger name hierarchy is represented by `.` characters in the logger name, in a fashion very similar to the hierarchy used for Java/Scala package names. The `Logger` property added by the `Logging` trait follows this convention: the trait ensures the `Logger` is automatically named according to the class it is being used in. + +[#scoped-context] +== Scoped Context (Loan Pattern) + +To ensure that the `ThreadContext` is properly cleaned up after execution—especially in asynchronous environments where thread reuse can lead to context leaks—you can use the `LoggingContext.withContext` method. + +[source,scala] +---- +import org.apache.logging.log4j.scala.LoggingContext + +// Context is automatically cleared after the block finishes +LoggingContext.withContext(Map("userId" -> "user-123", "role" -> "admin")) { + logger.info("This log has context data") + // Do some work... +} +// Context is clean here +---- From addeb37dcbbd3c169119fbb7032899cac568de9d Mon Sep 17 00:00:00 2001 From: SpaceLeam Date: Tue, 30 Dec 2025 08:29:00 -0500 Subject: [PATCH 2/3] Fix PR #97: implement safe ThreadContext save/restore logic and fix Javadoc --- .../apache/logging/log4j/scala/LoggingContext.scala | 11 +++++++++-- .../apache/logging/log4j/scala/LoggingContext.scala | 11 +++++++++-- .../apache/logging/log4j/scala/LoggingContext.scala | 11 +++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala b/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala index 90ab907..61ffd1f 100644 --- a/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala +++ b/log4j-api-scala_2.10/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala @@ -83,7 +83,7 @@ object LoggingContext extends mutable.Map[String, String] { /** * Runs the given block with the provided context data effectively added to the - * {@link org.apache.logging.log4j.ThreadContext ThreadContext} and removed after the block completes. + * {@link ThreadContext} and removed after the block completes. * * @param context the map of key-value pairs to add to the context * @param body the block of code to execute @@ -92,11 +92,18 @@ object LoggingContext extends mutable.Map[String, String] { * @since 13.2.0 */ def withContext[R](context: Map[String, String])(body: => R): R = { + val oldState = context.keys.map(key => key -> ThreadContext.get(key)).toMap ThreadContext.putAll(context.asJava) try { body } finally { - ThreadContext.removeAll(context.keys.asJavaCollection) + oldState.foreach { case (key, oldValue) => + if (oldValue == null) { + ThreadContext.remove(key) + } else { + ThreadContext.put(key, oldValue) + } + } } } diff --git a/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala b/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala index 90ab907..61ffd1f 100644 --- a/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala +++ b/log4j-api-scala_2.12/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala @@ -83,7 +83,7 @@ object LoggingContext extends mutable.Map[String, String] { /** * Runs the given block with the provided context data effectively added to the - * {@link org.apache.logging.log4j.ThreadContext ThreadContext} and removed after the block completes. + * {@link ThreadContext} and removed after the block completes. * * @param context the map of key-value pairs to add to the context * @param body the block of code to execute @@ -92,11 +92,18 @@ object LoggingContext extends mutable.Map[String, String] { * @since 13.2.0 */ def withContext[R](context: Map[String, String])(body: => R): R = { + val oldState = context.keys.map(key => key -> ThreadContext.get(key)).toMap ThreadContext.putAll(context.asJava) try { body } finally { - ThreadContext.removeAll(context.keys.asJavaCollection) + oldState.foreach { case (key, oldValue) => + if (oldValue == null) { + ThreadContext.remove(key) + } else { + ThreadContext.put(key, oldValue) + } + } } } diff --git a/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala b/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala index 66c07fc..b8afb1c 100644 --- a/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala +++ b/log4j-api-scala_2.13/src/main/scala/org/apache/logging/log4j/scala/LoggingContext.scala @@ -74,7 +74,7 @@ object LoggingContext extends mutable.Map[String, String] { /** * Runs the given block with the provided context data effectively added to the - * {@link org.apache.logging.log4j.ThreadContext ThreadContext} and removed after the block completes. + * {@link ThreadContext} and removed after the block completes. * * @param context the map of key-value pairs to add to the context * @param body the block of code to execute @@ -83,11 +83,18 @@ object LoggingContext extends mutable.Map[String, String] { * @since 13.2.0 */ def withContext[R](context: Map[String, String])(body: => R): R = { + val oldState = context.keys.map(key => key -> ThreadContext.get(key)).toMap ThreadContext.putAll(context.asJava) try { body } finally { - ThreadContext.removeAll(context.keys.asJava) + oldState.foreach { case (key, oldValue) => + if (oldValue == null) { + ThreadContext.remove(key) + } else { + ThreadContext.put(key, oldValue) + } + } } } From a99121c8a0166a099631061b5b468750f1031911 Mon Sep 17 00:00:00 2001 From: SpaceLeam Date: Tue, 30 Dec 2025 10:45:03 -0500 Subject: [PATCH 3/3] Add comprehensive tests for withContext covering nesting, restoration, and exceptions --- .../log4j/scala/LoggingContextTest.scala | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala b/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala index 88e5a45..2c55fe5 100644 --- a/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala +++ b/log4j-api-scala_2.10/src/test/scala/org/apache/logging/log4j/scala/LoggingContextTest.scala @@ -113,14 +113,58 @@ class LoggingContextTest extends AnyFunSuite with Matchers { result shouldBe Set("key1" -> "value1", "key2" -> "value2") } - test("withContext") { + test("withContext should add and remove values") { LoggingContext.clear() - LoggingContext.withContext(Map("key1" -> "value1", "key2" -> "value2")) { - LoggingContext.get("key1") shouldBe Some("value1") - LoggingContext.get("key2") shouldBe Some("value2") + LoggingContext.withContext(Map("key" -> "value")) { + LoggingContext.get("key") shouldBe Some("value") } - LoggingContext.get("key1") shouldBe None - LoggingContext.get("key2") shouldBe None + LoggingContext.contains("key") shouldBe false + } + + test("withContext should save and restore existing values") { + LoggingContext.clear() + LoggingContext += "key" -> "oldValue" + + LoggingContext.withContext(Map("key" -> "newValue")) { + LoggingContext.get("key") shouldBe Some("newValue") + } + + // Harus balik ke oldValue, bukan dihapus + LoggingContext.get("key") shouldBe Some("oldValue") + } + + test("withContext should handle nested contexts") { + LoggingContext.clear() + + LoggingContext.withContext(Map("outer" -> "outerValue", "common" -> "outerCommon")) { + LoggingContext.get("outer") shouldBe Some("outerValue") + LoggingContext.get("common") shouldBe Some("outerCommon") + + LoggingContext.withContext(Map("inner" -> "innerValue", "common" -> "innerCommon")) { + LoggingContext.get("outer") shouldBe Some("outerValue") + LoggingContext.get("inner") shouldBe Some("innerValue") + // Inner harus override outer + LoggingContext.get("common") shouldBe Some("innerCommon") + } + + // Pas keluar inner, common harus balik ke outerCommon + LoggingContext.get("common") shouldBe Some("outerCommon") + LoggingContext.contains("inner") shouldBe false + } + + LoggingContext.isEmpty shouldBe true + } + + test("withContext should cleanup on exception") { + LoggingContext.clear() + intercept[RuntimeException] { + LoggingContext.withContext(Map("key" -> "value")) { + throw new RuntimeException("Test exception") + } + } + + // Pastikan tetap bersih meski ada error + LoggingContext.contains("key") shouldBe false } }