Daniel
Daniel

Reputation: 9464

Log4j2 write to Jenkins pipeline job console output?

How can I use log4j2 to log messages into a Jenkins pipeline job console output (while the job is running)?

By console output, I mean the log of text outputted from a job typically found:

For example, from a Jenkins pipeline job using shared libraries, a class calling log4j2's Logger.info():

package mypackage

import org.apache.logging.log4j.Logger
import org.apache.logging.log4j.LogManager

@Grab(group = "org.apache.logging.log4j", module = "log4j-api", version = "2.8.2")
public class MyJobClass {
    // Logger
    private static final Logger logger = LogManager.getLogger(MyJobClass.class)

    public void execute(def script) { // 'script' here is 'this' from within the pipeline script such as in the shared libraries example.

        // This will appear in the job console output.
        script.println("foo")

        // This will appear in files and stdout as defined in the log4j2 configuration file, but not the job console output.
        logger.info("bar")
    }
}

Ideally I'd like to be able to, during runtime, set additional log4j2 configuration to add an 'appender' targetting the current running job's console output stream.

One thing I plan on trying is to append to the C:\Program Files (x86)\Jenkins\jobs\<Job Name>\builds\<Job Run Number>\log file directly from log4j2, which is a configuration I would have to set during runtime. However, I don't know how compatible this will be with Jenkin's console output view, or if Jenkins locks the file during the job execution, or if unknown issues will arise from writing to the file at the same time Jenkins does.

Upvotes: 1

Views: 4496

Answers (1)

Daniel
Daniel

Reputation: 9464

To get log4j2 to log into the Jenkins job console output I created a Logger wrapper which calls script.echo().

Note: this code is in Groovy.

LogManager:

package myApplication.logging

import com.cloudbees.groovy.cps.NonCPS
import my.util.PathUtils
import my.util.StringUtils
import org.apache.logging.log4j.core.Appender
import org.apache.logging.log4j.core.LoggerContext
import org.apache.logging.log4j.core.appender.FileAppender
import org.apache.logging.log4j.core.config.Configuration
import org.apache.logging.log4j.core.impl.Log4jLogEvent
import org.apache.logging.log4j.core.layout.PatternLayout
import org.apache.logging.log4j.message.SimpleMessage

/**
 * Log manager.
 */
@Grapes([
    @Grab(group = "org.apache.logging.log4j", module = "log4j-api", version = "2.8.2", initClass = true),
    @Grab(group = "org.apache.logging.log4j", module = "log4j-core", version = "2.8.2", initClass = true),
    @Grab(group = "org.apache.logging.log4j", module = "log4j-web", version = "2.8.2", initClass = true)
])
public class LogManager {

    /** The script object. */
    private static def script

    /** Initialised flag. */
    private static boolean initialised = false

    /** Layout object containing the log format string. */
    private static PatternLayout layout

    /** Jenkins job console output log level. */
    private static Level logLevel = Level.ALL

    /**
     * Initialise the logger with the script object.
     * This allows loading of the log4j settings file and adds the Jenkins job console output appender.
     * Called in JeevesJobTemplate.vm and BuildMyJobsJeeves.
     *
     * @param script The script object.
     * @param logLevel Jenkins job console output log level.
     */
    @NonCPS
    public static void initialise(def script, Level logLevel) {
        if (!script) throw new IllegalArgumentException("script object cannot be null.")
        if (initialised) throw new IllegalStateException("LogManager.initialise() was called more than once.")

        this.script = script
        this.logLevel = logLevel

        // Deal with the 'WARN Unable to instantiate org.fusesource.jansi.WindowsAnsiOutputStream' message.
        System.setProperty("log4j.skipJansi", "true")

        final LoggerContext context = LoggerContext.getContext(false)

        // Set the configuration file.
        context.setConfigLocation(new File("${PathUtils.getResourcePath(script)}/log4j2.json").toURI())

        final Configuration configuration = context.configuration

        // Get 'logFormat' property from the log4j2.json configuration file.
        final String logFormat = configuration.getStrSubstitutor().getVariableResolver().lookup("logFormat")
        layout = PatternLayout.newBuilder().withPattern(logFormat).build()

        // Add job file appender.
        final Appender jobFileAppender = FileAppender.newBuilder()
            .withName("Job File")
            .withFileName("${PathUtils.getJobPath(script)}/Jeeves.log")
            .withLayout(layout)
            .build()
        addAppender(configuration, jobFileAppender)

        // Remove 'Console' appender because Logger will log to the Jenkins job console.
        configuration.rootLogger.removeAppender("Console")

        initialised = true
    }

    /**
     * Helper method to get a Logger without having to import or grab grapes.
     *
     * @param clazz Class to log data from.
     * @return Log4j2 Logger object.
     */
    @NonCPS
    public static Logger getLogger(Class<?> clazz) {
        if (!clazz) throw new IllegalArgumentException("clazz cannot be null.")

        return new Logger(clazz)
    }

    /**
     * Log a copy of a log4j message to the Jenkins job console.
     *
     * @param loggerName Name of the logger, typically the class from which the logger was initialised.
     * @param level Log level.
     * @param message Message to log.
     */
    @NonCPS
    public static void log(String loggerName, Level level, String message) {
        if (!initialised) throw new IllegalStateException("LogManager is not initialised.")

        if (level <= logLevel) {
            final Log4jLogEvent event = Log4jLogEvent.newBuilder().setLoggerName(loggerName).setLevel(level.toLog4jLevel()).setMessage(new SimpleMessage(message)).build()
            final String logMessage = layout.toSerializable(event)
            script.echo(logMessage.substring(0, logMessage.length() - StringUtils.LINE_SEPARATOR.length()))
        }
    }

    /**
     * Add appender to log4j2 configuration.
     *
     * @param configuration Log4j2 configuration object.
     * @param appender Log4j2 appender to add to the configuration.
     */
    @NonCPS
    private static void addAppender(Configuration configuration, Appender appender) {
        if (!configuration) throw new IllegalArgumentException("configuration cannot be null.")
        if (!appender) throw new IllegalArgumentException("appender cannot be null.")

        appender.start()
        configuration.addAppender(appender)
        configuration.rootLogger.addAppender(appender, null, null)
    }
}

Logger:

package myApplication.logging

import com.cloudbees.groovy.cps.NonCPS

/**
 * Logger wrapper for log4j2's Logger class.
 * Needed to populate the Jenkins job console output.
 */
public class Logger implements Serializable {

    /** Log4j2 Logger object. */
    private org.apache.logging.log4j.Logger logger

    /** Logger constructor. */
    public Logger(Class<?> clazz) {
        logger = org.apache.logging.log4j.LogManager.getLogger(clazz)
    }

    /**
     * Log debug level message.
     * @param message Message to log.
     */
    @NonCPS
    public void debug(String message) {
        logger.debug(message)
        LogManager.log(logger.name, Level.DEBUG, message)
    }

    /**
     * Log error level message.
     * @param message Message to log.
     */
    @NonCPS
    public void error(String message) {
        logger.error(message)
        LogManager.log(logger.name, Level.ERROR, message)
    }

    /**
     * Log fatal level message.
     * @param message Message to log.
     */
    @NonCPS
    public void fatal(String message) {
        logger.fatal(message)
        LogManager.log(logger.name, Level.FATAL, message)
    }

    /**
     * Log info level message.
     * @param message Message to log.
     */
    @NonCPS
    public void info(String message) {
        logger.info(message)
        LogManager.log(logger.name, Level.INFO, message)
    }

    /**
     * Log a message at the supplied level.
     * @param level Level to log the message with.
     * @param message Message to log.
     */
    @NonCPS
    public void log(Level level, String message) {
        logger.log(level.toLog4jLevel(), message)
        LogManager.log(logger.name, level, message)
    }

    /**
     * Log trace level message.
     * @param message Message to log.
     */
    @NonCPS
    public void trace(String message) {
        logger.trace(message)
        LogManager.log(logger.name, Level.TRACE, message)
    }

    /**
     * Log warn level message.
     * @param message Message to log.
     */
    @NonCPS
    public void warn(String message) {
        logger.warn(message)
        LogManager.log(logger.name, Level.WARN, message)
    }
}

Level:

package my.logging

import com.cloudbees.groovy.cps.NonCPS
import org.apache.logging.log4j.Level as Log4jLevel

/**
 * Log levels.
 * Do not change the order of the enumeration elements.
 */
public enum Level implements Serializable {
    OFF(Log4jLevel.OFF),
    FATAL(Log4jLevel.FATAL),
    ERROR(Log4jLevel.ERROR),
    WARN(Log4jLevel.WARN),
    INFO(Log4jLevel.INFO),
    DEBUG(Log4jLevel.DEBUG),
    TRACE(Log4jLevel.TRACE),
    ALL(Log4jLevel.ALL)

    private final Log4jLevel level

    /**
     * Level constructor.
     * @param level Log4j level.
     */
    Level(Log4jLevel level) {
        this.level = level
    }

    /**
     * Get equivalent Log4j level.
     * @return Equivalent Log4j level.
     */
    @NonCPS
    public Log4jLevel toLog4jLevel() {
        return level
    }
}

Then, during initialisation, call LogManager.initialisation(script, Level.DEBUG) or whatever your Jenkins output log level should be.

Upvotes: 1

Related Questions