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..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 @@ -81,4 +81,30 @@ 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 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 = { + val oldState = context.keys.map(key => key -> ThreadContext.get(key)).toMap + ThreadContext.putAll(context.asJava) + try { + body + } finally { + oldState.foreach { case (key, oldValue) => + if (oldValue == null) { + ThreadContext.remove(key) + } else { + ThreadContext.put(key, oldValue) + } + } + } + } + } 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..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,4 +113,58 @@ class LoggingContextTest extends AnyFunSuite with Matchers { result shouldBe Set("key1" -> "value1", "key2" -> "value2") } + test("withContext should add and remove values") { + LoggingContext.clear() + LoggingContext.withContext(Map("key" -> "value")) { + LoggingContext.get("key") shouldBe Some("value") + } + 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 + } + } 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..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 @@ -81,4 +81,30 @@ 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 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 = { + val oldState = context.keys.map(key => key -> ThreadContext.get(key)).toMap + ThreadContext.putAll(context.asJava) + try { + body + } finally { + 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 c21529c..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 @@ -72,4 +72,30 @@ 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 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 = { + val oldState = context.keys.map(key => key -> ThreadContext.get(key)).toMap + ThreadContext.putAll(context.asJava) + try { + body + } finally { + oldState.foreach { case (key, oldValue) => + if (oldValue == null) { + ThreadContext.remove(key) + } else { + ThreadContext.put(key, oldValue) + } + } + } + } + } 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 +----