From b88c2c8fce0d4e53642db543aa5b44b370de6846 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Fri, 5 Aug 2022 22:33:26 +0200 Subject: [PATCH] Add a Log4j2 build listener This PR adds a Log4j2 build listener as a modern alternative to the obsolete Log4j 1.x listener. --- build.xml | 17 +- fetch.xml | 2 + lib/libraries.properties | 3 + manual/listeners.html | 5 + src/etc/poms/ant-apache-log4j2/pom.xml | 93 ++++++ .../tools/ant/listener/Log4j2Listener.java | 296 ++++++++++++++++++ .../ant/listener/Log4j2ListenerParamTest.java | 102 ++++++ .../ant/listener/Log4j2ListenerTest.java | 103 ++++++ 8 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 src/etc/poms/ant-apache-log4j2/pom.xml create mode 100644 src/main/org/apache/tools/ant/listener/Log4j2Listener.java create mode 100644 src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerParamTest.java create mode 100644 src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerTest.java diff --git a/build.xml b/build.xml index 8d401aac7e..2299aaec63 100644 --- a/build.xml +++ b/build.xml @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - + @@ -260,6 +260,10 @@ + + + + @@ -357,6 +361,7 @@ + @@ -496,6 +501,9 @@ + @@ -730,6 +738,7 @@ + @@ -894,6 +903,7 @@ + @@ -990,6 +1000,7 @@ + @@ -2094,6 +2105,10 @@ ${antunit.reports} classloader --> + + + diff --git a/fetch.xml b/fetch.xml index ac231ea36d..dfc6c0e3ef 100644 --- a/fetch.xml +++ b/fetch.xml @@ -227,6 +227,8 @@ Set -Ddest=LOCATION on the command line description="load logging libraries (Commons and Log4j)" depends="init"> + + diff --git a/lib/libraries.properties b/lib/libraries.properties index f6c03562ba..5135886eab 100644 --- a/lib/libraries.properties +++ b/lib/libraries.properties @@ -74,6 +74,9 @@ jsch.version=0.1.55 jython.version=2.7.2 # log4j 1.2.15 requires JMS and a few other Sun jars that are not in the m2 repo log4j.version=1.2.14 +log4j-api.version=2.18.0 +# Used only in tests +log4j-core.version=2.18.0 oro.version=2.0.8 servlet-api.version=2.3 which.version=1.0 diff --git a/manual/listeners.html b/manual/listeners.html index 4c1b87801c..5bc5281bb1 100644 --- a/manual/listeners.html +++ b/manual/listeners.html @@ -88,6 +88,11 @@

Built-in Listeners/Loggers

Colorifies the build output. BuildLogger + + org.apache.tools.ant.listener.Log4j2Listener + Passes events to Apache Log4j2 for highly customizable logging. + BuildListener + org.apache.tools.ant.listener.Log4jListener Passes events to Apache Log4j for highly customizable diff --git a/src/etc/poms/ant-apache-log4j2/pom.xml b/src/etc/poms/ant-apache-log4j2/pom.xml new file mode 100644 index 0000000000..cf6236587d --- /dev/null +++ b/src/etc/poms/ant-apache-log4j2/pom.xml @@ -0,0 +1,93 @@ + + + + + + org.apache.ant + ant-parent + ../pom.xml + 1.10.13-SNAPSHOT + + 4.0.0 + https://ant.apache.org/ + org.apache.ant + ant-apache-log4j2 + 1.10.13-SNAPSHOT + Apache Ant + Log4J2 + + + org.apache.ant + ant + 1.10.13-SNAPSHOT + compile + + + org.apache.logging.log4j + log4j-api + 2.18.0 + compile + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org/apache/tools/ant/listener/Log4j2Listener* + + + + + org.apache.maven.plugins + maven-jar-plugin + + + true + + true + true + true + + + + + + + + ../../../.. + META-INF + + LICENSE + NOTICE + + + + ../../../../src/main + ../../../../src/testcases + ../../../../target/${project.artifactId}/classes + ../../../../target/${project.artifactId}/testcases + ../../../../target/${project.artifactId} + + diff --git a/src/main/org/apache/tools/ant/listener/Log4j2Listener.java b/src/main/org/apache/tools/ant/listener/Log4j2Listener.java new file mode 100644 index 0000000000..d703e1429d --- /dev/null +++ b/src/main/org/apache/tools/ant/listener/Log4j2Listener.java @@ -0,0 +1,296 @@ +/* + * 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 + * + * https://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.tools.ant.listener; + +import static org.apache.tools.ant.Project.MSG_DEBUG; +import static org.apache.tools.ant.Project.MSG_ERR; +import static org.apache.tools.ant.Project.MSG_INFO; +import static org.apache.tools.ant.Project.MSG_VERBOSE; +import static org.apache.tools.ant.Project.MSG_WARN; + +import java.io.PrintStream; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogBuilder; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.MarkerManager; +import org.apache.logging.log4j.spi.LoggerContext; +import org.apache.tools.ant.BuildEvent; +import org.apache.tools.ant.BuildLogger; +import org.apache.tools.ant.Location; +import org.apache.tools.ant.MagicNames; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.SubBuildListener; +import org.apache.tools.ant.Target; +import org.apache.tools.ant.Task; +import org.apache.tools.ant.UnknownElement; +import org.apache.tools.ant.util.StringUtils; + +/** + *

+ * Listener which sends events to Log4j2 logging system. + *

+ *

+ * The following names are used for the loggers: + *

+ *
    + *
  • PROJECT_NAME - for project events,
  • + *
  • PROJECT_NAME.TARGET_NAME - for target events,
  • + *
  • PROJECT_NAME.TARGET_NAME.TASK_NAME - for task events.
  • + *
+ * + *

+ * In all names we replace "." with "_" to allow an easy extraction of the event + * source name. Empty names are replaced with 'global'. + *

+ *

+ * The location information contains the Java class name of the event source, + * while the file name and line number refer to the Ant build script. + *

+ */ +public class Log4j2Listener implements SubBuildListener, BuildLogger { + + private static final Level VERBOSE = Level.forName("VERBOSE", 450); + private static final Marker PROJECT = MarkerManager.getMarker("PROJECT"); + private static final Marker TARGET = MarkerManager.getMarker("TARGET").addParents(PROJECT); + private static final Marker TASK = MarkerManager.getMarker("TASK").addParents(TARGET); + private static final String LOGGER_SEP = "."; + private static final String UNDERSCORE = "_"; + + final LoggerContext context; + private final Map loggers = new ConcurrentHashMap<>(); + + public Log4j2Listener() { + // Initializes the logger context before Ant performs System.out and System.err + // redirections, but after the `-logfile` redirection. + this.context = LogManager.getContext(false); + } + + // Used in tests + Log4j2Listener(final LoggerContext context) { + this.context = context; + } + + @Override + public void setMessageOutputLevel(int priority) { + final Level level = toLog4jLevel(priority); + try { + final Object config = context.getClass().getMethod("getConfiguration").invoke(context); + final Object loggerConfig = config.getClass().getMethod("getRootLogger").invoke(config); + final Level oldLevel = (Level) loggerConfig.getClass().getMethod("getLevel").invoke(loggerConfig); + if (!oldLevel.equals(level)) { + loggerConfig.getClass().getMethod("setLevel", Level.class).invoke(loggerConfig, level); + context.getClass().getMethod("updateLoggers").invoke(context); + } + } catch (ReflectiveOperationException e) { + context.getLogger("") + .warn("Log level selection requires the Log4j2 Core backend."); + } + } + + @Override + public void setOutputPrintStream(PrintStream output) { + // Not possible, but also not necessary, since System.out has the correct value, + // when this class is instantiated. + } + + @Override + public void setEmacsMode(boolean emacsMode) { + // Not supported + } + + @Override + public void setErrorPrintStream(PrintStream err) { + // Not possible, but also not necessary, since System.err has the correct value, + // when this class is instantiated. + } + + /** + * {@inheritDoc} + */ + @Override + public void buildStarted(final BuildEvent event) { + this.getLogBuilder(event, MSG_INFO) + .log("Build started."); + } + + /** + * {@inheritDoc}. + */ + @Override + public void buildFinished(final BuildEvent event) { + final boolean success = event.getException() == null; + this.getLogBuilder(event, success ? MSG_INFO : MSG_ERR) + .log("Build finished{}.", success ? "" : "with error"); + } + + /** + * {@inheritDoc} + */ + @Override + public void subBuildStarted(BuildEvent event) { + this.getLogBuilder(event, MSG_INFO) + .log("Project \"{}\" started.", defaultString(extractName(event.getProject()))); + } + + /** + * {@inheritDoc} + */ + @Override + public void subBuildFinished(BuildEvent event) { + final boolean success = event.getException() == null; + final String projectName = defaultString(extractName(event.getProject())); + this.getLogBuilder(event, success ? MSG_INFO : MSG_ERR) + .log("Project \"{}\" finished{}.", projectName, success ? "" : "with error"); + } + + /** + * {@inheritDoc}. + */ + @Override + public void targetStarted(final BuildEvent event) { + this.getLogBuilder(event, MSG_INFO) + .log("Target \"{}\" started.", defaultString(extractName(event.getTarget()))); + } + + /** + * {@inheritDoc}. + */ + @Override + public void targetFinished(final BuildEvent event) { + final boolean success = event.getException() == null; + final String targetName = defaultString(extractName(event.getTarget())); + this.getLogBuilder(event, success ? MSG_INFO : MSG_ERR) + .log("Target \"{}\" finished{}.", targetName, success ? "" : "with error"); + } + + /** + * {@inheritDoc}. + */ + @Override + public void taskStarted(final BuildEvent event) { + this.getLogBuilder(event, MSG_INFO) + .log("Task \"{}\" started.", extractName(event.getTask())); + } + + /** + * {@inheritDoc}. + */ + @Override + public void taskFinished(final BuildEvent event) { + final boolean success = event.getException() == null; + this.getLogBuilder(event, success ? MSG_INFO : MSG_ERR) + .log("Task \"{}\" finished{}.", extractName(event.getTask()), success ? "" : "with error"); + } + + /** + * {@inheritDoc}. + */ + @Override + public void messageLogged(final BuildEvent event) { + this.getLogBuilder(event, event.getPriority()) + .log(event.getMessage()); + } + + private static String extractName(final Project project) { + return project != null ? StringUtils.trimToNull(project.getName()) : null; + } + + private static String extractName(final Target target) { + return target != null ? StringUtils.trimToNull(target.getName()) : null; + } + + private static String extractName(final Task task) { + return task != null ? task.getTaskName() : null; + } + + private static String defaultString(final String str) { + return str != null ? str : ""; + } + + static String getLoggerName(final BuildEvent event) { + return Stream.of(extractName(event.getProject()), extractName(event.getTarget()), extractName(event.getTask())) + .filter(Objects::nonNull) + .map(name -> name.replace(LOGGER_SEP, UNDERSCORE)) + .collect(Collectors.joining(LOGGER_SEP)); + } + + private Logger getLogger(final BuildEvent event) { + Logger logger = loggers.get(event.getSource()); + if (logger == null) { + final String loggerName = getLoggerName(event); + logger = context.getLogger(loggerName != null ? loggerName : ""); + loggers.put(event.getSource(), logger); + } + return logger; + } + + static Marker getMarker(final BuildEvent event) { + final Object source = event.getSource(); + return source instanceof Task ? TASK : source instanceof Target ? TARGET : PROJECT; + } + + private static Level toLog4jLevel(int priority) { + switch (priority) { + case MSG_DEBUG: + return Level.DEBUG; + case MSG_VERBOSE: + return VERBOSE; + case MSG_INFO: + return Level.INFO; + case MSG_WARN: + return Level.WARN; + case MSG_ERR: + default: + return Level.ERROR; + } + } + + static StackTraceElement extractLocation(final BuildEvent event) { + Object source = event.getSource(); + if (source instanceof UnknownElement) { + final Task task = ((UnknownElement) source).getTask(); + if (task != null) { + source = task; + } + } + // We use the source's class name for the StackTraceElement + final String className = source.getClass().getName(); + if (source instanceof Project) { + final Project project = event.getProject(); + return new StackTraceElement(className, "", project.getUserProperty(MagicNames.ANT_FILE), -1); + } + final Location location = source instanceof Target ? event.getTarget().getLocation() : event.getTask().getLocation(); + return new StackTraceElement(className, "", location.getFileName(), location.getLineNumber()); + } + + private LogBuilder getLogBuilder(final BuildEvent event, int priority) { + return this.getLogger(event) + .atLevel(toLog4jLevel(priority)) + .withMarker(getMarker(event)) + .withLocation(extractLocation(event)) + .withThrowable(event.getException()); + } +} diff --git a/src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerParamTest.java b/src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerParamTest.java new file mode 100644 index 0000000000..a51249bb1d --- /dev/null +++ b/src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerParamTest.java @@ -0,0 +1,102 @@ +/* + * 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 + * + * https://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.tools.ant.listener; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.Collection; + +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.MarkerManager; +import org.apache.tools.ant.BuildEvent; +import org.apache.tools.ant.Location; +import org.apache.tools.ant.MagicNames; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.Target; +import org.apache.tools.ant.Task; +import org.apache.tools.ant.TaskAdapter; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public class Log4j2ListenerParamTest { + + private final static Marker PROJECT = MarkerManager.getMarker("PROJECT"); + private final static Marker TARGET = MarkerManager.getMarker("TARGET"); + private final static Marker TASK = MarkerManager.getMarker("TASK"); + + @Parameters + public static Collection data() { + final Project namelessProject = new Project(); + final Project namedProject = new Project(); + namedProject.setName("My.Project"); + namedProject.setUserProperty(MagicNames.ANT_FILE, "My.File"); + final Target namelessTarget = new Target(); + namelessTarget.setLocation(new Location("My.File0", 1, 0)); + namelessTarget.setProject(namedProject); + final Target namedTarget = new Target(); + namedTarget.setName("My.Target"); + namedTarget.setLocation(new Location("My.File1", 3, 0)); + namedTarget.setProject(namedProject); + final Task globalTask = new TaskAdapter(); + globalTask.setTaskName("My.Task1"); + globalTask.setLocation(new Location("My.File2", 5, 0)); + globalTask.setProject(namedProject); + final Task localTask = new TaskAdapter(); + localTask.setTaskName("My.Task2"); + localTask.setLocation(new Location("My.File3", 7, 0)); + localTask.setProject(namedProject); + localTask.setOwningTarget(namedTarget); + MarkerManager.getMarker("PROJECT"); + MarkerManager.getMarker("TARGET"); + MarkerManager.getMarker("TASK"); + return Arrays.asList(new Object[][] { + { new BuildEvent(namelessProject), "", null, -1, PROJECT }, + { new BuildEvent(namedProject), "My_Project", "My.File", -1, PROJECT }, + { new BuildEvent(namelessTarget), "My_Project", "My.File0", 1, TARGET }, + { new BuildEvent(namedTarget), "My_Project.My_Target", "My.File1", 3, TARGET }, + { new BuildEvent(globalTask), "My_Project.My_Task1", "My.File2", 5, TASK }, + { new BuildEvent(localTask), "My_Project.My_Target.My_Task2", "My.File3", 7, TASK } }); + } + + private final BuildEvent event; + private final String loggerName; + private final String fileName; + private final int lineNumber; + private final Marker marker; + + public Log4j2ListenerParamTest(BuildEvent event, String loggerName, String fileName, int lineNumber, Marker marker) { + this.event = event; + this.loggerName = loggerName; + this.fileName = fileName; + this.lineNumber = lineNumber; + this.marker = marker; + } + + @Test + public void testNames() { + assertEquals(loggerName, Log4j2Listener.getLoggerName(event)); + final StackTraceElement location = Log4j2Listener.extractLocation(event); + assertEquals(fileName, location.getFileName()); + assertEquals(lineNumber, location.getLineNumber()); + assertEquals(marker, Log4j2Listener.getMarker(event)); + } +} diff --git a/src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerTest.java b/src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerTest.java new file mode 100644 index 0000000000..84c580be65 --- /dev/null +++ b/src/tests/junit/org/apache/tools/ant/listener/Log4j2ListenerTest.java @@ -0,0 +1,103 @@ +/* + * 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 + * + * https://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.tools.ant.listener; + +import static org.junit.Assert.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Proxy; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.spi.ExtendedLogger; +import org.apache.logging.log4j.spi.LoggerContext; +import org.apache.tools.ant.BuildEvent; +import org.apache.tools.ant.Location; +import org.apache.tools.ant.MagicNames; +import org.apache.tools.ant.Project; +import org.apache.tools.ant.Target; +import org.apache.tools.ant.Task; +import org.apache.tools.ant.TaskAdapter; +import org.junit.Test; + +public class Log4j2ListenerTest { + + private static LoggerContext createMockContext() { + final ClassLoader cl = Log4j2Listener.class.getClassLoader(); + return (LoggerContext) Proxy.newProxyInstance(cl, new Class[] { LoggerContext.class }, + (proxy, method, args) -> { + if ("getLogger".equals(method.getName())) { + return Proxy.newProxyInstance(cl, new Class[] { ExtendedLogger.class }, (p, m, a) -> null); + } + return null; + }); + } + + @Test + public void testMessageOutputLevel() { + final Log4j2Listener listener = new Log4j2Listener(); + // Sets level using Log4j2 Core + listener.setMessageOutputLevel(Project.MSG_DEBUG); + assertEquals(Level.DEBUG, listener.context.getLogger("").getLevel()); + // Does not fail if Log4j2 Core is not available + final Log4j2Listener mockListener = new Log4j2Listener(createMockContext()); + mockListener.setMessageOutputLevel(Project.MSG_DEBUG); + } + + @Test + public void smokeTest() { + final Project project = new Project(); + project.setName("My.Project"); + project.setUserProperty(MagicNames.ANT_FILE, "My.File"); + final Target target = new Target(); + target.setName("My.Target"); + target.setLocation(new Location("My.File", 3, 0)); + target.setProject(project); + final Task task = new TaskAdapter(); + task.setTaskName("My.Task"); + task.setLocation(new Location("My.File", 7, 0)); + task.setProject(project); + task.setOwningTarget(target); + final Log4j2Listener listener = new Log4j2Listener(); + + listener.setEmacsMode(false); + final PrintStream stream = new PrintStream(new ByteArrayOutputStream()); + listener.setOutputPrintStream(stream); + listener.setErrorPrintStream(stream); + listener.setMessageOutputLevel(Project.MSG_VERBOSE); + + final BuildEvent projectEvent = new BuildEvent(project); + listener.buildStarted(projectEvent); + listener.buildFinished(projectEvent); + listener.subBuildStarted(projectEvent); + listener.subBuildFinished(projectEvent); + + final BuildEvent targetEvent = new BuildEvent(target); + listener.targetStarted(targetEvent); + listener.targetFinished(targetEvent); + + final BuildEvent taskEvent = new BuildEvent(task); + listener.taskStarted(taskEvent); + listener.taskFinished(taskEvent); + + final BuildEvent logEvent = new BuildEvent(task); + logEvent.setException(new RuntimeException()); + logEvent.setMessage("Hello world!", Project.MSG_ERR); + listener.messageLogged(logEvent); + } +}