This is an automated email from the ASF dual-hosted git repository. rgoers pushed a commit to branch ScopedContext in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git
commit 969f68ca03745849365811ffbdf7f1a2288e6995 Author: Ralph Goers <[email protected]> AuthorDate: Sat Mar 16 17:58:02 2024 -0700 2214 - Add support for ScopedContext --- .../apache/logging/log4j/ScopedContextTest.java | 44 ++++ .../org/apache/logging/log4j/ScopedContext.java | 224 +++++++++++++++++++++ .../org/apache/logging/log4j/package-info.java | 2 +- .../apache/logging/log4j/simple/SimpleLogger.java | 7 +- .../logging/log4j/core/ScopedContextTest.java | 66 ++++++ log4j-core-test/src/test/resources/log4j-list2.xml | 31 +++ .../log4j/core/impl/ScopedContextDataProvider.java | 50 +++++ .../logging/log4j/core/impl/package-info.java | 2 +- src/changelog/.2.x.x/add_scoped_context.xml | 9 + src/site/_release-notes/_2.x.x.adoc | 1 + src/site/asciidoc/manual/scoped-context.adoc | 82 ++++++++ src/site/site.xml | 1 + 12 files changed, 515 insertions(+), 4 deletions(-) diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/ScopedContextTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/ScopedContextTest.java new file mode 100644 index 0000000000..609ec0b5ad --- /dev/null +++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/ScopedContextTest.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.jupiter.api.Test; + +public class ScopedContextTest { + + @Test + public void testScope() { + ScopedContext.newInstance() + .where("key1", "Log4j2") + .run(() -> assertThat(ScopedContext.get("key1"), equalTo("Log4j2"))); + ScopedContext.newInstance().where("key1", "value1").run(() -> { + assertThat(ScopedContext.get("key1"), equalTo("value1")); + ScopedContext.newInstance(true).where("key2", "value2").run(() -> { + assertThat(ScopedContext.get("key1"), equalTo("value1")); + assertThat(ScopedContext.get("key2"), equalTo("value2")); + }); + ScopedContext.newInstance().where("key2", "value2").run(() -> { + assertThat(ScopedContext.get("key1"), nullValue()); + assertThat(ScopedContext.get("key2"), equalTo("value2")); + }); + }); + } +} diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/ScopedContext.java b/log4j-api/src/main/java/org/apache/logging/log4j/ScopedContext.java new file mode 100644 index 0000000000..d320b708d6 --- /dev/null +++ b/log4j-api/src/main/java/org/apache/logging/log4j/ScopedContext.java @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j; + +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +/** + * Context that can be used for data to be logged in a block of code. + * + * While this is influenced by ScopedValues from Java 21 it does not share the same API. While it can perform a + * similar function as a set of ScopedValues it is really meant to allow a block of code to include a set of keys and + * values in all the log events within that block. The underlying implementation must provide support for + * logging the ScopedContext for that to happen. + * + * The ScopedContext will not be bound to the current thread until either a run or call method is invoked. The + * contexts are nested so creating and running or calling via a second ScopedContext will result in the first + * ScopedContext being hidden until the call is returned. Thus the values from the first ScopedContext need to + * be added to the second to be included. + * + * @since 2.24.0 + */ +public class ScopedContext { + + private static final ThreadLocal<Deque<Map<String, Renderable>>> scopedContext = new ThreadLocal<>(); + + /** + * Returns an immutable Map containing all the key/value pairs as Renderable objects. + * @return An immutable copy of the Map at the current scope. + */ + public static Map<String, Renderable> getContext() { + Deque<Map<String, Renderable>> stack = scopedContext.get(); + if (stack != null && !stack.isEmpty()) { + return Collections.unmodifiableMap(stack.getFirst()); + } + return Collections.emptyMap(); + } + + private static void addScopedContext(Map<String, Renderable> contextMap) { + Deque<Map<String, Renderable>> stack = scopedContext.get(); + if (stack == null) { + stack = new ArrayDeque<>(); + scopedContext.set(stack); + } + stack.addFirst(contextMap); + } + + private static void removeScopedContext() { + Deque<Map<String, Renderable>> stack = scopedContext.get(); + if (stack != null) { + if (!stack.isEmpty()) { + stack.removeFirst(); + } + if (stack.isEmpty()) { + scopedContext.remove(); + } + } + } + + /** + * Return a new ScopedContext. + * @return the ScopedContext. + */ + public static ScopedContext newInstance() { + return newInstance(false); + } + + /** + * Return a new ScopedContext. + * @param inherit true if this context should inherit the values of its parent. + * @return the ScopedContext. + */ + public static ScopedContext newInstance(boolean inherit) { + return new ScopedContext(inherit); + } + + /** + * Return the key from the current ScopedContext, if there is one and the key exists. + * @param key The key. + * @return The value of the key in the current ScopedContext. + */ + @SuppressWarnings("unchecked") + public static <T> T get(String key) { + Renderable renderable = getContext().get(key); + if (renderable != null) { + return (T) renderable.getObject(); + } else { + return null; + } + } + + private final Map<String, Renderable> contextMap = new HashMap<>(); + + private ScopedContext(boolean inherit) { + Map<String, Renderable> parent = ScopedContext.getContext(); + if (inherit && !parent.isEmpty()) { + contextMap.putAll(parent); + } + } + + /** + * Add all the values in the specified Map to the ScopedContext being constructed. + * + * @param map The Map to add to the ScopedContext being constructed. + * @return the ScopedContext being constructed. + */ + public ScopedContext where(Map<String, Object> map) { + map.forEach(this::addObject); + return this; + } + + /** + * Adds a key/value pair to the ScopedContext being constructed. + * @param key the key to add. + * @param value the value associated with the key. + * @return the ScopedContext being constructed. + */ + public ScopedContext where(String key, Object value) { + addObject(key, value); + return this; + } + + /** + * Adds a key/value pair to the ScopedContext being constructed. + * @param key the key to add. + * @param supplier the function to generate the value. + * @return the ScopedContext being constructed. + */ + public ScopedContext where(String key, Supplier<Object> supplier) { + addObject(key, supplier.get()); + return this; + } + + private void addObject(String key, Object obj) { + if (obj != null) { + if (obj instanceof Renderable) { + contextMap.put(key, (Renderable) obj); + } else { + contextMap.put(key, new ObjectRenderable(obj)); + } + } + } + + /** + * Executes a code block that includes all the key/value pairs added to the ScopedContext. + * @param op the code block to execute. + */ + public void run(Runnable op) { + addScopedContext(contextMap); + try { + op.run(); + } finally { + removeScopedContext(); + } + } + + /** + * Executes a code block that includes all the key/value pairs added to the ScopedContext. + * @param op the code block to execute. + * @return the return value from the code block. + */ + public <R> R call(Callable<? extends R> op) throws Exception { + addScopedContext(contextMap); + try { + return op.call(); + } finally { + removeScopedContext(); + } + } + + /** + * Interface for converting Objects stored in the ContextScope to Strings for logging. + */ + public static interface Renderable { + /** + * Render the object as a String. + * @return the String representation of the Object. + */ + default String render() { + return this.toString(); + } + + default Object getObject() { + return this; + } + } + + private static class ObjectRenderable implements Renderable { + private final Object object; + + public ObjectRenderable(Object object) { + this.object = object; + } + + @Override + public String render() { + return object.toString(); + } + + @Override + public Object getObject() { + return object; + } + } +} diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/package-info.java b/log4j-api/src/main/java/org/apache/logging/log4j/package-info.java index 5407f05f61..f1c67c6c86 100644 --- a/log4j-api/src/main/java/org/apache/logging/log4j/package-info.java +++ b/log4j-api/src/main/java/org/apache/logging/log4j/package-info.java @@ -32,7 +32,7 @@ * @see <a href="http://logging.apache.org/log4j/2.x/manual/api.html">Log4j 2 API manual</a> */ @Export -@Version("2.20.2") +@Version("2.24.0") package org.apache.logging.log4j; import org.osgi.annotation.bundle.Export; diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/simple/SimpleLogger.java b/log4j-api/src/main/java/org/apache/logging/log4j/simple/SimpleLogger.java index 1690893187..2c46190517 100644 --- a/log4j-api/src/main/java/org/apache/logging/log4j/simple/SimpleLogger.java +++ b/log4j-api/src/main/java/org/apache/logging/log4j/simple/SimpleLogger.java @@ -21,9 +21,11 @@ import java.io.PrintStream; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.HashMap; import java.util.Map; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.ScopedContext; import org.apache.logging.log4j.ThreadContext; import org.apache.logging.log4j.message.Message; import org.apache.logging.log4j.message.MessageFactory; @@ -294,8 +296,9 @@ public class SimpleLogger extends AbstractLogger { } sb.append(msg.getFormattedMessage()); if (showContextMap) { - final Map<String, String> mdc = ThreadContext.getImmutableContext(); - if (mdc.size() > 0) { + final Map<String, String> mdc = new HashMap<>(ThreadContext.getImmutableContext()); + ScopedContext.getContext().forEach((key, value) -> mdc.put(key, value.render())); + if (!mdc.isEmpty()) { sb.append(SPACE); sb.append(mdc.toString()); sb.append(SPACE); diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/ScopedContextTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/ScopedContextTest.java new file mode 100644 index 0000000000..994097a566 --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/ScopedContextTest.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.core; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; + +import java.util.List; +import org.apache.logging.log4j.ScopedContext; +import org.apache.logging.log4j.core.test.appender.ListAppender; +import org.apache.logging.log4j.core.test.junit.LoggerContextSource; +import org.apache.logging.log4j.core.test.junit.Named; +import org.junit.jupiter.api.Test; + +@LoggerContextSource("log4j-list2.xml") +public class ScopedContextTest { + + private final ListAppender app; + + public ScopedContextTest(@Named("List") final ListAppender list) { + app = list.clear(); + } + + @Test + public void testScope(final LoggerContext context) { + final org.apache.logging.log4j.Logger logger = context.getLogger("org.apache.logging.log4j.scoped"); + ScopedContext.newInstance().where("key1", "Log4j2").run(() -> logger.debug("Hello, {}", "World")); + List<String> msgs = app.getMessages(); + assertThat(msgs, hasSize(1)); + String expected = "{key1=Log4j2}"; + assertThat(msgs.get(0), containsString(expected)); + app.clear(); + ScopedContext.newInstance().where("key1", "value1").run(() -> { + logger.debug("Log message 1 will include key1"); + ScopedContext.newInstance(true) + .where("key2", "value2") + .run(() -> logger.debug("Log message 2 will include key1 and key2")); + ScopedContext.newInstance() + .where("key2", "value2") + .run(() -> logger.debug("Log message 2 will include key2")); + }); + msgs = app.getMessages(); + assertThat(msgs, hasSize(3)); + expected = "{key1=value1}"; + assertThat(msgs.get(0), containsString(expected)); + expected = "{key1=value1, key2=value2}"; + assertThat(msgs.get(1), containsString(expected)); + expected = "{key2=value2}"; + assertThat(msgs.get(2), containsString(expected)); + } +} diff --git a/log4j-core-test/src/test/resources/log4j-list2.xml b/log4j-core-test/src/test/resources/log4j-list2.xml new file mode 100644 index 0000000000..c747458fbd --- /dev/null +++ b/log4j-core-test/src/test/resources/log4j-list2.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ~ Licensed to the Apache Software Foundation (ASF) under one or more + ~ contributor license agreements. See the NOTICE file distributed with + ~ this work for additional information regarding copyright ownership. + ~ The ASF licenses this file to you under the Apache License, Version 2.0 + ~ (the "License"); you may not use this file except in compliance with + ~ the License. You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<Configuration status="OFF" name="XMLConfigTest" monitorInterval="5" shutdownHook="disable"> + <Appenders> + <List name="List"> + <PatternLayout pattern="%d %p %C{1.} [%t] %X - %m%n"/> + </List> + </Appenders> + + <Loggers> + <Root level="trace"> + <AppenderRef ref="List"/> + </Root> + </Loggers> + +</Configuration> diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/ScopedContextDataProvider.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/ScopedContextDataProvider.java new file mode 100644 index 0000000000..7e638a57a8 --- /dev/null +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/ScopedContextDataProvider.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.logging.log4j.core.impl; + +import aQute.bnd.annotation.Resolution; +import aQute.bnd.annotation.spi.ServiceProvider; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.apache.logging.log4j.ScopedContext; +import org.apache.logging.log4j.core.util.ContextDataProvider; +import org.apache.logging.log4j.util.StringMap; + +/** + * ContextDataProvider for Map<String, String> data. + */ +@ServiceProvider(value = ContextDataProvider.class, resolution = Resolution.OPTIONAL) +public class ScopedContextDataProvider implements ContextDataProvider { + + @Override + public Map<String, String> supplyContextData() { + Map<String, ScopedContext.Renderable> contextMap = ScopedContext.getContext(); + if (!contextMap.isEmpty()) { + Map<String, String> map = new HashMap<>(); + contextMap.forEach((key, value) -> map.put(key, value.render())); + return map; + } else { + return Collections.emptyMap(); + } + } + + @Override + public StringMap supplyStringMap() { + return new JdkMapAdapterStringMap(supplyContextData()); + } +} diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java index c50504a872..0c3b08f43a 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java @@ -18,7 +18,7 @@ * Log4j 2 private implementation classes. */ @Export -@Version("2.23.0") +@Version("2.24.0") package org.apache.logging.log4j.core.impl; import org.osgi.annotation.bundle.Export; diff --git a/src/changelog/.2.x.x/add_scoped_context.xml b/src/changelog/.2.x.x/add_scoped_context.xml new file mode 100644 index 0000000000..06db3eb0d5 --- /dev/null +++ b/src/changelog/.2.x.x/add_scoped_context.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="http://logging.apache.org/log4j/changelog" + xsi:schemaLocation="http://logging.apache.org/log4j/changelog https://logging.apache.org/log4j/changelog-0.1.3.xsd" + type="updated"> + <issue id="kotlin-71" link="https://github.com/apache/logging-log4j-kotlin/issues/71"/> + <issue id="2214" link="https://github.com/apache/logging-log4j2/discussions/2214"/> + <description format="asciidoc">Add ScopedContext to log4j-api and ScopedContextDataProvider in log4j-core.</description> +</entry> diff --git a/src/site/_release-notes/_2.x.x.adoc b/src/site/_release-notes/_2.x.x.adoc index bbcc5b10ef..79d4278c24 100644 --- a/src/site/_release-notes/_2.x.x.adoc +++ b/src/site/_release-notes/_2.x.x.adoc @@ -31,6 +31,7 @@ This releases contains ... [#release-notes-2-x-x-updated] === Updated +* Add ScopedContext to log4j-api and ScopedContextDataProvider in log4j-core. (https://github.com/apache/logging-log4j-kotlin/issues/71[kotlin-71], https://github.com/apache/logging-log4j2/discussions/2214[2214]) * Update `actions/checkout` to version `4.1.2` (https://github.com/apache/logging-log4j2/pull/2370[2370]) * Update `com.fasterxml.jackson:jackson-bom` to version `2.17.0` (https://github.com/apache/logging-log4j2/pull/2372[2372]) * Update `com.google.guava:guava` to version `33.1.0-jre` (https://github.com/apache/logging-log4j2/pull/2377[2377]) diff --git a/src/site/asciidoc/manual/scoped-context.adoc b/src/site/asciidoc/manual/scoped-context.adoc new file mode 100644 index 0000000000..7f9afd2167 --- /dev/null +++ b/src/site/asciidoc/manual/scoped-context.adoc @@ -0,0 +1,82 @@ +//// + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +//// += Log4j 2 API +Ralph Goers <[email protected]>; + +== Scoped Context +The link:../log4j-api/apidocs/org/apache/logging/log4j/ScopedContext.html[`ScopedContext`] +is available in Log4j API releases 2.24.0 and greater. + +The `ScopedContext` is similar to the ThreadContextMap in that it allows key/value pairs to be included +in many log events. However, the pairs in a `ScopedContext` are only available to +application code and log events running within the scope of the `ScopeContext` object. + +The `ScopeContext` is essentially a builder that allows key/value pairs to be added to it +prior to invoking a method. The key/value pairs are available to any code running within +that method and will be included in all logging events as if they were part of the `ThreadContextMap`. + +[source,java] +---- +ScopedContext.newInstance() + .where("id", UUID.randomUUID()) + .where("ipAddress", request.getRemoteAddr()) + .where("loginId", session.getAttribute("loginId")) + .where("hostName", request.getServerName()) + .run(new Worker()); + +private class Worker implements Runnable { + private static final Logger LOGGER = LogManager.getLogger(Worker.class); + + public void run() { + LOGGER.debug("Performing work"); + String loginId = ScopedContext.get("loginId"); + } +} + +---- + +The values in the ScopedContext can be any Java object. However, objects stored in the +context Map will be converted to Strings when stored in a LogEvent. To aid in +this Objects may implement the Renderable interface which provides a `render` method +to format the object. By default, objects will have their toString() method called +if they do not implement the Renderable interface. + +Note that in the example above `UUID.randomUUID()` returns a UUID. By default, when it is +included in LogEvents its toString() method will be used. + +=== Nested ScopedContexts + +ScopedContexts may be nested. When creating a nested context the default behavior is to +hide the key/value pairs of the parent context. The may be included by passing `true` to +the newInstance method when creating the child context. + + +[source,java] +---- + ScopedContext.newInstance().where("key1", "value1").run(() -> { + assertThat(ScopedContext.get("key1"), equalTo("value1")); + ScopedContext.newInstance(true).where("key2", "value2").run(() -> { + assertThat(ScopedContext.get("key1"), equalTo("value1")); + assertThat(ScopedContext.get("key2"), equalTo("value2")); + }); + ScopedContext.newInstance().where("key2", "value2").run(() -> { + assertThat(ScopedContext.get("key1"), nullValue()); + assertThat(ScopedContext.get("key2"), equalTo("value2")); + }); + }); + +---- \ No newline at end of file diff --git a/src/site/site.xml b/src/site/site.xml index 75c89e075e..b044e24a25 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -100,6 +100,7 @@ <item name="Event Logging" href="/manual/eventlogging.html"/> <item name="Messages" href="/manual/messages.html"/> <item name="ThreadContext" href="/manual/thread-context.html"/> + <item name="ScopedContext" href="/manual/scoped-context.html"/> </item> <item name="Kotlin API" href="https://logging.apache.org/log4j/kotlin"/>
