From 43a0e29781d1ca0be9306124d360ab451e531ddd Mon Sep 17 00:00:00 2001 From: Gary Gregory Date: Sat, 7 Dec 2024 16:02:44 -0500 Subject: [PATCH] Add a Jakarta JMS Appender #2295 (#3247) --- .github/dependabot.yaml | 1 + .../log4j/core/appender/mom/JmsAppender.java | 11 +- .../log4j/core/appender/mom/JmsManager.java | 6 +- .../log4j/core/appender/mom/package-info.java | 2 +- .../.log4j-plugin-processing-activator | 1 + log4j-jakarta-jms/pom.xml | 71 +++ .../appender/mom/jakarta/JmsAppender.java | 242 +++++++++ .../core/appender/mom/jakarta/JmsManager.java | 508 ++++++++++++++++++ .../appender/mom/jakarta/package-info.java | 26 + .../appender/mom/jakarta/JmsAppenderTest.java | 196 +++++++ .../test/resources/JmsJakartaAppenderTest.xml | 46 ++ pom.xml | 7 + .../.2.x.x/2295_add_JMS_Jakarta_Appender.xml | 8 + .../pages/manual/appenders/message-queue.adoc | 12 +- .../modules/ROOT/pages/manual/messages.adoc | 2 +- 15 files changed, 1127 insertions(+), 12 deletions(-) create mode 100644 log4j-jakarta-jms/.log4j-plugin-processing-activator create mode 100644 log4j-jakarta-jms/pom.xml create mode 100644 log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppender.java create mode 100644 log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsManager.java create mode 100644 log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/package-info.java create mode 100644 log4j-jakarta-jms/src/test/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppenderTest.java create mode 100644 log4j-jakarta-jms/src/test/resources/JmsJakartaAppenderTest.xml create mode 100644 src/changelog/.2.x.x/2295_add_JMS_Jakarta_Appender.xml diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ee3d0963323..64712968763 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -59,6 +59,7 @@ updates: - "/log4j-docker" - "/log4j-fuzz-test" - "/log4j-iostreams" + - "/log4j-jakarta-jms" - "/log4j-jakarta-smtp" - "/log4j-jakarta-web" - "/log4j-jcl" diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsAppender.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsAppender.java index 8d2c87c4b8d..b6df7dd08dd 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsAppender.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsAppender.java @@ -37,11 +37,14 @@ import org.apache.logging.log4j.core.net.JndiManager; /** - * Generic JMS Appender plugin for both queues and topics. This Appender replaces the previous split ones. However, - * configurations set up for the 2.0 version of the JMS appenders will still work. + * Javax JMS Appender plugin. This Appender replaces the previous split classes. + * Configurations set up for the 2.0 version of the JMS appenders will still work. + * + * @deprecated Use {@code org.apache.logging.log4j.core.appender.mom.jakarta.JmsAppender}. */ -@Plugin(name = "JMS", category = Node.CATEGORY, elementType = Appender.ELEMENT_TYPE, printObject = true) -@PluginAliases({"JMSQueue", "JMSTopic"}) +@Deprecated +@Plugin(name = "JMS-Javax", category = Node.CATEGORY, elementType = Appender.ELEMENT_TYPE, printObject = true) +@PluginAliases({"JMS", "JMSQueue", "JMSTopic"}) public class JmsAppender extends AbstractAppender { public static class Builder> extends AbstractAppender.Builder diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsManager.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsManager.java index 3d2872a9fd8..178d1f428af 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsManager.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/JmsManager.java @@ -42,10 +42,12 @@ * Consider this class private; it is only public for access by integration tests. * *

- * JMS connection and session manager. Can be used to access MessageProducer, MessageConsumer, and Message objects - * involving a configured ConnectionFactory and Destination. + * JMS connection and destination manager. Uses a MessageProducer to send log events to a JMS Destination. *

+ * + * @deprecated Use {@code org.apache.logging.log4j.core.appender.mom.jakarta.JmsManager}. */ +@Deprecated public class JmsManager extends AbstractManager { public static class JmsManagerConfiguration { diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/package-info.java index 6ed35287fdc..c12d8343757 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/package-info.java @@ -21,7 +21,7 @@ * @since 2.1 */ @Export -@Version("2.20.1") +@Version("2.25.0") package org.apache.logging.log4j.core.appender.mom; import org.osgi.annotation.bundle.Export; diff --git a/log4j-jakarta-jms/.log4j-plugin-processing-activator b/log4j-jakarta-jms/.log4j-plugin-processing-activator new file mode 100644 index 00000000000..ba133f36961 --- /dev/null +++ b/log4j-jakarta-jms/.log4j-plugin-processing-activator @@ -0,0 +1 @@ +This file is here to activate the `plugin-processing` Maven profile. diff --git a/log4j-jakarta-jms/pom.xml b/log4j-jakarta-jms/pom.xml new file mode 100644 index 00000000000..57b7b2c14b8 --- /dev/null +++ b/log4j-jakarta-jms/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + org.apache.logging.log4j + log4j + ${revision} + ../log4j-parent + + log4j-jakarta-jms + Apache Log4j Jakarta JMS + Apache Log4j Java Message Service (JMS), version for Jakarta. + + + true + + org.apache.logging.log4j.jakarta.jms + org.apache.logging.log4j.core + + + + jakarta.jms + jakarta.jms-api + provided + + + org.apache.logging.log4j + log4j-api + + + org.apache.logging.log4j + log4j-core + + + org.junit.vintage + junit-vintage-engine + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.apache.logging.log4j + log4j-core-test + test + + + commons-logging + commons-logging + test + + + diff --git a/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppender.java b/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppender.java new file mode 100644 index 00000000000..661cfd03295 --- /dev/null +++ b/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppender.java @@ -0,0 +1,242 @@ +/* + * 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.appender.mom.jakarta; + +import jakarta.jms.JMSException; +import java.io.Serializable; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.Layout; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.appender.AbstractManager; +import org.apache.logging.log4j.core.appender.mom.jakarta.JmsManager.JmsManagerConfiguration; +import org.apache.logging.log4j.core.config.Node; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginAliases; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory; +import org.apache.logging.log4j.core.config.plugins.validation.constraints.Required; +import org.apache.logging.log4j.core.net.JndiManager; + +/** + * Jakarta JMS Appender plugin for both queues and topics. + */ +@Plugin(name = "JMS-Jakarta", category = Node.CATEGORY, elementType = Appender.ELEMENT_TYPE, printObject = true) +public final class JmsAppender extends AbstractAppender { + + public static final class Builder extends AbstractAppender.Builder + implements org.apache.logging.log4j.core.util.Builder { + + public static final int DEFAULT_RECONNECT_INTERVAL_MILLIS = 5000; + + @PluginBuilderAttribute + private String factoryName; + + @PluginBuilderAttribute + private String providerUrl; + + @PluginBuilderAttribute + private String urlPkgPrefixes; + + @PluginBuilderAttribute + private String securityPrincipalName; + + @PluginBuilderAttribute(sensitive = true) + private String securityCredentials; + + @PluginBuilderAttribute + @Required(message = "A jakarta.jms.ConnectionFactory JNDI name must be specified") + private String factoryBindingName; + + @PluginBuilderAttribute + @PluginAliases({"queueBindingName", "topicBindingName"}) + @Required(message = "A jakarta.jms.Destination JNDI name must be specified") + private String destinationBindingName; + + @PluginBuilderAttribute + private String userName; + + @PluginBuilderAttribute(sensitive = true) + private char[] password; + + @PluginBuilderAttribute + private long reconnectIntervalMillis = DEFAULT_RECONNECT_INTERVAL_MILLIS; + + @PluginBuilderAttribute + private boolean immediateFail; + + // Programmatic access only for now. + private JmsManager jmsManager; + + private Builder() {} + + @SuppressWarnings("resource") // actualJmsManager and jndiManager are managed by the JmsAppender + @Override + public JmsAppender build() { + JmsManager actualJmsManager = jmsManager; + JmsManagerConfiguration configuration = null; + if (actualJmsManager == null) { + final Properties jndiProperties = JndiManager.createProperties( + factoryName, providerUrl, urlPkgPrefixes, securityPrincipalName, securityCredentials, null); + configuration = new JmsManagerConfiguration( + jndiProperties, + factoryBindingName, + destinationBindingName, + userName, + password, + immediateFail, + reconnectIntervalMillis); + actualJmsManager = AbstractManager.getManager(getName(), JmsManager.FACTORY, configuration); + } + if (actualJmsManager == null) { + // JmsManagerFactory has already logged an ERROR. + return null; + } + final Layout layout = getLayout(); + if (layout == null) { + LOGGER.error("No layout provided for JmsAppender"); + return null; + } + try { + return new JmsAppender( + getName(), getFilter(), layout, isIgnoreExceptions(), getPropertyArray(), actualJmsManager); + } catch (final JMSException e) { + // Never happens since the ctor no longer actually throws a JMSException. + throw new IllegalStateException(e); + } + } + + public Builder setDestinationBindingName(final String destinationBindingName) { + this.destinationBindingName = destinationBindingName; + return this; + } + + public Builder setFactoryBindingName(final String factoryBindingName) { + this.factoryBindingName = factoryBindingName; + return this; + } + + public Builder setFactoryName(final String factoryName) { + this.factoryName = factoryName; + return this; + } + + public Builder setImmediateFail(final boolean immediateFail) { + this.immediateFail = immediateFail; + return this; + } + + public Builder setJmsManager(final JmsManager jmsManager) { + this.jmsManager = jmsManager; + return this; + } + + public Builder setPassword(final char[] password) { + this.password = password; + return this; + } + + public Builder setProviderUrl(final String providerUrl) { + this.providerUrl = providerUrl; + return this; + } + + public Builder setReconnectIntervalMillis(final long reconnectIntervalMillis) { + this.reconnectIntervalMillis = reconnectIntervalMillis; + return this; + } + + public Builder setSecurityCredentials(final String securityCredentials) { + this.securityCredentials = securityCredentials; + return this; + } + + public Builder setSecurityPrincipalName(final String securityPrincipalName) { + this.securityPrincipalName = securityPrincipalName; + return this; + } + + public Builder setUrlPkgPrefixes(final String urlPkgPrefixes) { + this.urlPkgPrefixes = urlPkgPrefixes; + return this; + } + + public Builder setUserName(final String userName) { + this.userName = userName; + return this; + } + + /** + * Does not include the password. + */ + @Override + public String toString() { + return "Builder [name=" + getName() + ", factoryName=" + factoryName + ", providerUrl=" + providerUrl + + ", urlPkgPrefixes=" + urlPkgPrefixes + ", securityPrincipalName=" + securityPrincipalName + + ", securityCredentials=" + securityCredentials + ", factoryBindingName=" + factoryBindingName + + ", destinationBindingName=" + destinationBindingName + ", username=" + userName + ", layout=" + + getLayout() + ", filter=" + getFilter() + ", ignoreExceptions=" + isIgnoreExceptions() + + ", jmsManager=" + jmsManager + "]"; + } + } + + @PluginBuilderFactory + public static Builder newBuilder() { + return new Builder(); + } + + private volatile JmsManager manager; + + /** + * Constructs a new instance. + * + * @throws JMSException not thrown as of 2.9 but retained in the signature for compatibility, will be removed in 3.0 + */ + private JmsAppender( + final String name, + final Filter filter, + final Layout layout, + final boolean ignoreExceptions, + final Property[] properties, + final JmsManager manager) + throws JMSException { + super(name, filter, layout, ignoreExceptions, properties); + this.manager = manager; + } + + @Override + public void append(final LogEvent event) { + this.manager.send(event, toSerializable(event)); + } + + public JmsManager getManager() { + return manager; + } + + @Override + public boolean stop(final long timeout, final TimeUnit timeUnit) { + setStopping(); + boolean stopped = super.stop(timeout, timeUnit, false); + stopped &= this.manager.stop(timeout, timeUnit); + setStopped(); + return stopped; + } +} diff --git a/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsManager.java b/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsManager.java new file mode 100644 index 00000000000..81b915dafeb --- /dev/null +++ b/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsManager.java @@ -0,0 +1,508 @@ +/* + * 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.appender.mom.jakarta; + +import jakarta.jms.Connection; +import jakarta.jms.ConnectionFactory; +import jakarta.jms.Destination; +import jakarta.jms.JMSException; +import jakarta.jms.MapMessage; +import jakarta.jms.Message; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; +import java.io.Serializable; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import javax.naming.NamingException; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractManager; +import org.apache.logging.log4j.core.appender.AppenderLoggingException; +import org.apache.logging.log4j.core.appender.ManagerFactory; +import org.apache.logging.log4j.core.net.JndiManager; +import org.apache.logging.log4j.core.util.Log4jThread; +import org.apache.logging.log4j.status.StatusLogger; + +/** + * Consider this class private; it is only public for access by integration tests. + * + *

+ * JMS connection and session manager. Can be used to access MessageProducer, MessageConsumer, and Message objects + * involving a configured ConnectionFactory and Destination. + *

+ */ +public final class JmsManager extends AbstractManager { + + public static final class JmsManagerConfiguration { + private final Properties jndiProperties; + private final String connectionFactoryName; + private final String destinationName; + private final String userName; + private final char[] password; + private final boolean immediateFail; + private final boolean retry; + private final long reconnectIntervalMillis; + + JmsManagerConfiguration( + final Properties jndiProperties, + final String connectionFactoryName, + final String destinationName, + final String userName, + final char[] password, + final boolean immediateFail, + final long reconnectIntervalMillis) { + this.jndiProperties = jndiProperties; + this.connectionFactoryName = connectionFactoryName; + this.destinationName = destinationName; + this.userName = userName; + this.password = password; + this.immediateFail = immediateFail; + this.reconnectIntervalMillis = reconnectIntervalMillis; + this.retry = reconnectIntervalMillis > 0; + } + + public String getConnectionFactoryName() { + return connectionFactoryName; + } + + public String getDestinationName() { + return destinationName; + } + + public JndiManager getJndiManager() { + return JndiManager.getJndiManager(getJndiProperties()); + } + + public Properties getJndiProperties() { + return jndiProperties; + } + + public char[] getPassword() { + return password; + } + + public long getReconnectIntervalMillis() { + return reconnectIntervalMillis; + } + + public String getUserName() { + return userName; + } + + public boolean isImmediateFail() { + return immediateFail; + } + + public boolean isRetry() { + return retry; + } + + @Override + public String toString() { + return "JmsManagerConfiguration [jndiProperties=" + jndiProperties + ", connectionFactoryName=" + + connectionFactoryName + ", destinationName=" + destinationName + ", userName=" + userName + + ", immediateFail=" + immediateFail + ", retry=" + retry + ", reconnectIntervalMillis=" + + reconnectIntervalMillis + "]"; + } + } + + private static final class JmsManagerFactory implements ManagerFactory { + + @Override + public JmsManager createManager(final String name, final JmsManagerConfiguration data) { + if (JndiManager.isJndiJmsEnabled()) { + try { + return new JmsManager(name, data); + } catch (final Exception e) { + logger().error("Error creating JmsManager using JmsManagerConfiguration [{}]", data, e); + return null; + } + } + logger().error("JNDI must be enabled by setting log4j2.enableJndiJms=true"); + return null; + } + } + + /** + * Handles reconnecting to JMS on a Thread. + */ + private final class Reconnector extends Log4jThread { + + private final CountDownLatch latch = new CountDownLatch(1); + + private volatile boolean shutdown; + + private final Object owner; + + private Reconnector(final Object owner) { + super("JmsManager-Reconnector"); + this.owner = owner; + } + + public void latch() { + try { + latch.await(); + } catch (final InterruptedException ex) { + // Ignore the exception. + } + } + + void reconnect() throws NamingException, JMSException { + final JndiManager jndiManager2 = getJndiManager(); + final Connection connection2 = createConnection(jndiManager2); + final Session session2 = createSession(connection2); + final Destination destination2 = createDestination(jndiManager2); + final MessageProducer messageProducer2 = createMessageProducer(session2, destination2); + connection2.start(); + synchronized (owner) { + jndiManager = jndiManager2; + connection = connection2; + session = session2; + destination = destination2; + messageProducer = messageProducer2; + reconnector = null; + shutdown = true; + } + logger().debug("Connection reestablished to {}", configuration); + } + + @Override + public void run() { + while (!shutdown) { + try { + sleep(configuration.getReconnectIntervalMillis()); + reconnect(); + } catch (final InterruptedException | JMSException | NamingException e) { + logger().debug( + "Cannot reestablish JMS connection to {}: {}", + configuration, + e.getLocalizedMessage(), + e); + } finally { + latch.countDown(); + } + } + } + + public void shutdown() { + shutdown = true; + } + } + + static final JmsManagerFactory FACTORY = new JmsManagerFactory(); + + /** + * Gets a JmsManager using the specified configuration parameters. + * + * @param name + * The name to use for this JmsManager. + * @param connectionFactoryName + * The binding name for the {@link javax.jms.ConnectionFactory}. + * @param destinationName + * The binding name for the {@link javax.jms.Destination}. + * @param userName + * The userName to connect with or {@code null} for no authentication. + * @param password + * The password to use with the given userName or {@code null} for no authentication. + * @param immediateFail + * Whether or not to fail immediately with a {@link AppenderLoggingException} when connecting to JMS + * fails. + * @param reconnectIntervalMillis + * How to log sleep in milliseconds before trying to reconnect to JMS. + * @param jndiProperties + * JNDI properties. + * @return The JmsManager as configured. + */ + public static JmsManager getJmsManager( + final String name, + final Properties jndiProperties, + final String connectionFactoryName, + final String destinationName, + final String userName, + final char[] password, + final boolean immediateFail, + final long reconnectIntervalMillis) { + final JmsManagerConfiguration configuration = new JmsManagerConfiguration( + jndiProperties, + connectionFactoryName, + destinationName, + userName, + password, + immediateFail, + reconnectIntervalMillis); + return getManager(name, FACTORY, configuration); + } + + private final JmsManagerConfiguration configuration; + + private volatile Reconnector reconnector; + private volatile JndiManager jndiManager; + private volatile Connection connection; + private volatile Session session; + private volatile Destination destination; + private volatile MessageProducer messageProducer; + + private JmsManager(final String name, final JmsManagerConfiguration configuration) { + super(null, name); + this.configuration = configuration; + this.jndiManager = configuration.getJndiManager(); + try { + this.connection = createConnection(this.jndiManager); + this.session = createSession(this.connection); + this.destination = createDestination(this.jndiManager); + this.messageProducer = createMessageProducer(this.session, this.destination); + this.connection.start(); + } catch (NamingException | JMSException e) { + this.reconnector = createReconnector(); + this.reconnector.start(); + } + } + + private boolean closeConnection() { + if (connection == null) { + return true; + } + final Connection temp = connection; + connection = null; + try { + temp.close(); + return true; + } catch (final JMSException e) { + StatusLogger.getLogger() + .debug( + "Caught exception closing JMS Connection: {} ({}); continuing JMS manager shutdown", + e.getLocalizedMessage(), + temp, + e); + return false; + } + } + + private boolean closeJndiManager() { + if (jndiManager == null) { + return true; + } + final JndiManager tmp = jndiManager; + jndiManager = null; + tmp.close(); + return true; + } + + private boolean closeMessageProducer() { + if (messageProducer == null) { + return true; + } + final MessageProducer temp = messageProducer; + messageProducer = null; + try { + temp.close(); + return true; + } catch (final JMSException e) { + StatusLogger.getLogger() + .debug( + "Caught exception closing JMS MessageProducer: {} ({}); continuing JMS manager shutdown", + e.getLocalizedMessage(), + temp, + e); + return false; + } + } + + private boolean closeSession() { + if (session == null) { + return true; + } + final Session temp = session; + session = null; + try { + temp.close(); + return true; + } catch (final JMSException e) { + StatusLogger.getLogger() + .debug( + "Caught exception closing JMS Session: {} ({}); continuing JMS manager shutdown", + e.getLocalizedMessage(), + temp, + e); + return false; + } + } + + private Connection createConnection(final JndiManager jndiManager) throws NamingException, JMSException { + final ConnectionFactory connectionFactory = jndiManager.lookup(configuration.getConnectionFactoryName()); + if (configuration.getUserName() != null && configuration.getPassword() != null) { + return connectionFactory.createConnection( + configuration.getUserName(), + configuration.getPassword() == null ? null : String.valueOf(configuration.getPassword())); + } + return connectionFactory.createConnection(); + } + + private Destination createDestination(final JndiManager jndiManager) throws NamingException { + return jndiManager.lookup(configuration.getDestinationName()); + } + + /** + * Creates a TextMessage, MapMessage, or ObjectMessage from a Serializable object. + *

+ * For instance, when using a text-based {@link org.apache.logging.log4j.core.Layout} such as + * {@link org.apache.logging.log4j.core.layout.PatternLayout}, the {@link org.apache.logging.log4j.core.LogEvent} + * message will be serialized to a String. + *

+ *

+ * When using a layout such as {@link org.apache.logging.log4j.core.layout.SerializedLayout}, the LogEvent message + * will be serialized as a Java object. + *

+ *

+ * When using a layout such as {@link org.apache.logging.log4j.core.layout.MessageLayout} and the LogEvent message + * is a Log4j MapMessage, the message will be serialized as a JMS MapMessage. + *

+ * + * @param object + * The LogEvent or String message to wrap. + * @return A new JMS message containing the provided object. + * @throws JMSException if the JMS provider fails to create this message due to some internal error. + */ + private Message createMessage(final Serializable object) throws JMSException { + if (object instanceof String) { + return session.createTextMessage((String) object); + } else if (object instanceof org.apache.logging.log4j.message.MapMessage) { + return map((org.apache.logging.log4j.message.MapMessage) object, session.createMapMessage()); + } + return session.createObjectMessage(object); + } + + private void createMessageAndSend(final LogEvent event, final Serializable serializable) throws JMSException { + final Message message = createMessage(serializable); + message.setJMSTimestamp(event.getTimeMillis()); + messageProducer.send(message); + } + + /** + * Creates a MessageProducer on this Destination using the current Session. + * + * @param session + * The JMS Session to use to create the MessageProducer + * @param destination + * The JMS Destination for the MessageProducer + * @return A MessageProducer on this Destination. + * @throws JMSException if the session fails to create a MessageProducer due to some internal error. + */ + private MessageProducer createMessageProducer(final Session session, final Destination destination) + throws JMSException { + return session.createProducer(destination); + } + + private Reconnector createReconnector() { + final Reconnector recon = new Reconnector(this); + recon.setDaemon(true); + recon.setPriority(Thread.MIN_PRIORITY); + return recon; + } + + private Session createSession(final Connection connection) throws JMSException { + return connection.createSession(false, Session.AUTO_ACKNOWLEDGE); + } + + public JmsManagerConfiguration getJmsManagerConfiguration() { + return configuration; + } + + JndiManager getJndiManager() { + return configuration.getJndiManager(); + } + + T lookup(final String destinationName) throws NamingException { + return jndiManager.lookup(destinationName); + } + + private MapMessage map( + final org.apache.logging.log4j.message.MapMessage log4jMapMessage, final MapMessage jmsMapMessage) { + // Map without calling org.apache.logging.log4j.message.MapMessage#getData() which makes a copy of the map. + log4jMapMessage.forEach((key, value) -> { + try { + jmsMapMessage.setObject(key, value); + } catch (final JMSException e) { + throw new IllegalArgumentException( + String.format( + "%s mapping key '%s' to value '%s': %s", + e.getClass(), key, value, e.getLocalizedMessage()), + e); + } + }); + return jmsMapMessage; + } + + @Override + protected boolean releaseSub(final long timeout, final TimeUnit timeUnit) { + if (reconnector != null) { + reconnector.shutdown(); + reconnector.interrupt(); + reconnector = null; + } + boolean closed = false; + closed &= closeJndiManager(); + closed &= closeMessageProducer(); + closed &= closeSession(); + closed &= closeConnection(); + return closed && jndiManager.stop(timeout, timeUnit); + } + + void send(final LogEvent event, final Serializable serializable) { + if (messageProducer == null) { + if (reconnector != null && !configuration.isImmediateFail()) { + reconnector.latch(); + if (messageProducer == null) { + throw new AppenderLoggingException( + "Error sending to JMS Manager '" + getName() + "': JMS message producer not available"); + } + } + } + synchronized (this) { + try { + createMessageAndSend(event, serializable); + } catch (final JMSException causeEx) { + if (configuration.isRetry() && reconnector == null) { + reconnector = createReconnector(); + try { + closeJndiManager(); + reconnector.reconnect(); + } catch (NamingException | JMSException reconnEx) { + logger().debug( + "Cannot reestablish JMS connection to {}: {}; starting reconnector thread {}", + configuration, + reconnEx.getLocalizedMessage(), + reconnector.getName(), + reconnEx); + reconnector.start(); + throw new AppenderLoggingException( + String.format("JMS exception sending to %s for %s", getName(), configuration), causeEx); + } + try { + createMessageAndSend(event, serializable); + } catch (final JMSException e) { + throw new AppenderLoggingException( + String.format( + "Error sending to %s after reestablishing JMS connection for %s", + getName(), configuration), + causeEx); + } + } + } + } + } +} diff --git a/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/package-info.java b/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/package-info.java new file mode 100644 index 00000000000..a5651b62751 --- /dev/null +++ b/log4j-jakarta-jms/src/main/java/org/apache/logging/log4j/core/appender/mom/jakarta/package-info.java @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** + * Jakarta-based JMS Appender. + */ +@Export +@Version("2.25.0") +package org.apache.logging.log4j.core.appender.mom.jakarta; + +import org.osgi.annotation.bundle.Export; +import org.osgi.annotation.versioning.Version; diff --git a/log4j-jakarta-jms/src/test/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppenderTest.java b/log4j-jakarta-jms/src/test/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppenderTest.java new file mode 100644 index 00000000000..5d730fc47b4 --- /dev/null +++ b/log4j-jakarta-jms/src/test/java/org/apache/logging/log4j/core/appender/mom/jakarta/JmsAppenderTest.java @@ -0,0 +1,196 @@ +/* + * 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.appender.mom.jakarta; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +import jakarta.jms.Connection; +import jakarta.jms.ConnectionFactory; +import jakarta.jms.Destination; +import jakarta.jms.MapMessage; +import jakarta.jms.MessageProducer; +import jakarta.jms.ObjectMessage; +import jakarta.jms.Session; +import jakarta.jms.TextMessage; +import java.io.Serializable; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.Log4jLogEvent; +import org.apache.logging.log4j.core.test.categories.Appenders; +import org.apache.logging.log4j.core.test.junit.JndiRule; +import org.apache.logging.log4j.core.test.junit.LoggerContextRule; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.message.SimpleMessage; +import org.apache.logging.log4j.message.StringMapMessage; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.rules.RuleChain; + +@Category(Appenders.Jms.class) +public class JmsAppenderTest { + + private static final String CONNECTION_FACTORY_NAME = "jms/connectionFactory"; + private static final String QUEUE_FACTORY_NAME = "jms/queues"; + private static final String TOPIC_FACTORY_NAME = "jms/topics"; + private static final String DESTINATION_NAME = "jms/destination"; + private static final String DESTINATION_NAME_ML = "jms/destination-ml"; + private static final String QUEUE_NAME = "jms/queue"; + private static final String TOPIC_NAME = "jms/topic"; + private static final String LOG_MESSAGE = "Hello, world!"; + + private final ConnectionFactory connectionFactory = mock(ConnectionFactory.class); + private final Connection connection = mock(Connection.class); + private final Session session = mock(Session.class); + private final Destination destination = mock(Destination.class); + private final Destination destinationMl = mock(Destination.class); + private final MessageProducer messageProducer = mock(MessageProducer.class); + private final MessageProducer messageProducerMl = mock(MessageProducer.class); + private final TextMessage textMessage = mock(TextMessage.class); + private final ObjectMessage objectMessage = mock(ObjectMessage.class); + private final MapMessage mapMessage = mock(MapMessage.class); + + private final JndiRule jndiRule = new JndiRule(createBindings()); + private final LoggerContextRule ctx = new LoggerContextRule("JmsJakartaAppenderTest.xml"); + + @Rule + public RuleChain rules = RuleChain.outerRule(jndiRule).around(ctx); + + @AfterClass + public static void afterClass() throws Exception { + System.clearProperty("log4j2.enableJndiJms"); + } + + @BeforeClass + public static void beforeClass() throws Exception { + System.setProperty("log4j2.enableJndiJms", "true"); + } + + public JmsAppenderTest() throws Exception { + // this needs to set up before LoggerContextRule + given(connectionFactory.createConnection()).willReturn(connection); + given(connectionFactory.createConnection(anyString(), anyString())).willThrow(IllegalArgumentException.class); + given(connection.createSession(eq(false), eq(Session.AUTO_ACKNOWLEDGE))).willReturn(session); + given(session.createProducer(eq(destination))).willReturn(messageProducer); + given(session.createProducer(eq(destinationMl))).willReturn(messageProducerMl); + given(session.createTextMessage(anyString())).willReturn(textMessage); + given(session.createObjectMessage(isA(Serializable.class))).willReturn(objectMessage); + given(session.createMapMessage()).willReturn(mapMessage); + } + + private Map createBindings() { + final ConcurrentHashMap map = new ConcurrentHashMap<>(); + map.put(CONNECTION_FACTORY_NAME, connectionFactory); + map.put(DESTINATION_NAME, destination); + map.put(DESTINATION_NAME_ML, destinationMl); + map.put(QUEUE_FACTORY_NAME, connectionFactory); + map.put(QUEUE_NAME, destination); + map.put(TOPIC_FACTORY_NAME, connectionFactory); + map.put(TOPIC_NAME, destination); + return map; + } + + private Log4jLogEvent createLogEvent() { + return createLogEvent(new SimpleMessage(LOG_MESSAGE)); + } + + private Log4jLogEvent createLogEvent(final Message message) { + // @formatter:off + return Log4jLogEvent.newBuilder() + .setLoggerName(JmsAppenderTest.class.getName()) + .setLoggerFqcn(JmsAppenderTest.class.getName()) + .setLevel(Level.INFO) + .setMessage(message) + .build(); + // @formatter:on + } + + private Log4jLogEvent createMapMessageLogEvent() { + final StringMapMessage mapMessage = new StringMapMessage(); + return createLogEvent(mapMessage.with("testMesage", LOG_MESSAGE)); + } + + @Before + public void setUp() throws Exception { + // we have 4 appenders all connecting to the same ConnectionFactory + then(connection).should(times(4)).start(); + } + + @Test + public void testAppendToQueue() throws Exception { + final JmsAppender appender = (JmsAppender) ctx.getRequiredAppender("JmsAppender"); + final LogEvent event = createLogEvent(); + appender.append(event); + then(session).should().createTextMessage(eq(LOG_MESSAGE)); + then(textMessage).should().setJMSTimestamp(anyLong()); + then(messageProducer).should().send(textMessage); + appender.stop(); + then(session).should().close(); + then(connection).should().close(); + } + + @Test + public void testAppendToQueueWithMessageLayout() throws Exception { + final JmsAppender appender = (JmsAppender) ctx.getRequiredAppender("JmsAppender-MessageLayout"); + final LogEvent event = createMapMessageLogEvent(); + appender.append(event); + then(session).should().createMapMessage(); + then(mapMessage).should().setJMSTimestamp(anyLong()); + then(messageProducerMl).should().send(mapMessage); + appender.stop(); + then(session).should().close(); + then(connection).should().close(); + } + + @Test + public void testJmsQueueAppenderCompatibility() throws Exception { + final JmsAppender appender = (JmsAppender) ctx.getRequiredAppender("JmsQueueAppender"); + final LogEvent expected = createLogEvent(); + appender.append(expected); + then(session).should().createObjectMessage(eq(expected)); + then(objectMessage).should().setJMSTimestamp(anyLong()); + then(messageProducer).should().send(objectMessage); + appender.stop(); + then(session).should().close(); + then(connection).should().close(); + } + + @Test + public void testJmsTopicAppenderCompatibility() throws Exception { + final JmsAppender appender = (JmsAppender) ctx.getRequiredAppender("JmsTopicAppender"); + final LogEvent expected = createLogEvent(); + appender.append(expected); + then(session).should().createObjectMessage(eq(expected)); + then(objectMessage).should().setJMSTimestamp(anyLong()); + then(messageProducer).should().send(objectMessage); + appender.stop(); + then(session).should().close(); + then(connection).should().close(); + } +} diff --git a/log4j-jakarta-jms/src/test/resources/JmsJakartaAppenderTest.xml b/log4j-jakarta-jms/src/test/resources/JmsJakartaAppenderTest.xml new file mode 100644 index 00000000000..e1d660d1c17 --- /dev/null +++ b/log4j-jakarta-jms/src/test/resources/JmsJakartaAppenderTest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index cc0fed3561b..74394de40c6 100644 --- a/pom.xml +++ b/pom.xml @@ -249,6 +249,7 @@ log4j-docker log4j-fuzz-test log4j-iostreams + log4j-jakarta-jms log4j-jakarta-smtp log4j-jakarta-web log4j-jcl @@ -446,6 +447,12 @@ ${project.version} + + org.apache.logging.log4j + log4j-jakarta-jms + ${project.version} + + org.apache.logging.log4j log4j-jakarta-smtp diff --git a/src/changelog/.2.x.x/2295_add_JMS_Jakarta_Appender.xml b/src/changelog/.2.x.x/2295_add_JMS_Jakarta_Appender.xml new file mode 100644 index 00000000000..fd40164e1b8 --- /dev/null +++ b/src/changelog/.2.x.x/2295_add_JMS_Jakarta_Appender.xml @@ -0,0 +1,8 @@ + + + + Add a Jakarta-based JMS Appender module log4j-jakarta-jms and deprecate the Javax version. + diff --git a/src/site/antora/modules/ROOT/pages/manual/appenders/message-queue.adoc b/src/site/antora/modules/ROOT/pages/manual/appenders/message-queue.adoc index 3adb9f8b90d..d032c53de30 100644 --- a/src/site/antora/modules/ROOT/pages/manual/appenders/message-queue.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/appenders/message-queue.adoc @@ -423,8 +423,8 @@ This example cannot be configured using Java properties. [#JmsAppender] == JMS Appender -The JMS Appender sends the formatted log event to a -https://jakarta.ee/specifications/messaging/2.0/[Jakarta Messaging API] +The JMS Appender sends a formatted log event to a +https://jakarta.ee/specifications/messaging/3.0/[Jakarta] or https://jakarta.ee/specifications/messaging/2.0/[Javax] Messaging API destination. [IMPORTANT] @@ -432,10 +432,12 @@ destination. As of Log4j `2.17.0` you need to enable the JMS Appender **explicitly** by setting the xref:manual/systemproperties.adoc#log4j2.enableJndiJms[`log4j2.enableJndiJms`] configuration property to `true`. - -Due to breaking changes in the underlying API, the JMS Appender cannot be used with Jakarta Messaging API 3.0 or later. ==== +For Jakarta, use the `JMS-Jakarta` element name in the `log4j-jakarta-jms` Maven module. + +For Javax, use the `JMS-Javax` element name; the names `JMS`, `JMSQueue`, and `JMSTopic` are provided for backward compatibility. + [#JmsAppender-attributes] .JMS Appender configuration attributes [cols="1m,1,1,5"] @@ -601,6 +603,8 @@ https://jakarta.ee/specifications/platform/8/apidocs/javax/jms/objectmessage[`Ob [#JmsAppender-examples] === Configuration examples +In the examples below, to use Jakarta, replace `JMS` with `JMS-Jakarta`. + Here is a sample JMS Appender configuration: [tabs] diff --git a/src/site/antora/modules/ROOT/pages/manual/messages.adoc b/src/site/antora/modules/ROOT/pages/manual/messages.adoc index 2e123a8022d..d678fe8e11a 100644 --- a/src/site/antora/modules/ROOT/pages/manual/messages.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/messages.adoc @@ -233,7 +233,7 @@ It supports following formats: Some appenders handle ``MapMessage``s differently when there is no layout: -* JMS Appender converts to a JMS `javax.jms.MapMessage` +* JMS Appender converts to a JMS `javax.jms.MapMessage` or `jakarta.jms.MapMessage` * xref:manual/appenders/database.adoc#JdbcAppender[JDBC Appender] converts to values in an `SQL INSERT` statement * xref:manual/appenders/database.adoc#MongoDbProvider[MongoDB NoSQL provider] converts to fields in a MongoDB object