Java Log4j Logger - Programmatically Initialize JSON logger with customized keys in json logs

September 28, 2022

Introduction

Java log4j has many ways to initialize and append the desired layout. In this post, I will create a custom logger, with following functionalities:

  • JSON logs
  • Custom global key/values which will come in every log, without even passing in every statement.
  • Custom and Dynamic key-values to come in every log in json format
  • Programmatically initialized logger
  • Option to initialize with xml file as well

Log4j JSON Configuration

There are two ways to use JSON logging with log4j:

We will see both of these in this post.

Log4j Json Template Layout (Preferred)

This is the preferred way of using json logs. Lets look at the code:

Code With no Cutomization (Complete logger code)

Below code is complete java code for customized logger with all mentioned features.

package com.gyanblog.logger;

import java.util.HashMap;
import java.util.Map;

import org.apache.commons.lang.exception.ExceptionUtils;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.appender.ConsoleAppender;
import org.apache.logging.log4j.core.config.Configurator;
import org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder;
import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder;
import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory;
import org.apache.logging.log4j.core.config.builder.api.LayoutComponentBuilder;
import org.apache.logging.log4j.core.config.builder.api.RootLoggerComponentBuilder;
import org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration;
import org.apache.logging.log4j.message.ObjectMessage;

public class MyLogger {
	public enum LogLevel {
		DEBUG, INFO, WARN, ERROR, FATAL;
	}

	public final static String LOG_LEVEL = "log.level";
	public final static String LOG4J_XML_PATH = "log4j.xml.path";
	
	private static Logger rawLogger;
	
    // for setting global key-value pairs
	private static HashMap<String, String> keyValMap;
	
	public static void logInfo(String message) {
		logHelper(LogLevel.INFO, message, null, null);
	}

	public static void logInfo(String message, Map<String, String> additionKeyValuePairs) {
		logHelper(LogLevel.INFO, message, additionKeyValuePairs, null);
	}
	
	public static void logError(String message) {
		logHelper(LogLevel.ERROR, message, null, null);
	}

	public static void logError(String message, Map<String, String> additionKeyValuePairs) {
		logHelper(LogLevel.ERROR, message, additionKeyValuePairs, null);
	}
	
	public static void logError(String message, Map<String, String> additionKeyValuePairs, Exception exc) {
		logHelper(LogLevel.ERROR, message, additionKeyValuePairs, exc);
	}
	
	public static void init(String loggerName) {
		if (MyLogger.isLoggerInitialized()) {
			return;
		}

		String log4jxml = getPathLog4j();
		if (log4jxml == null) {
			MyLogger.rawLogger = LogManager.getLogger(loggerName);
			//set default appender
			setConsoleAppender();
		}
		else {
			//setting standard log4j property so that logger can read from this xml
			System.setProperty("log4j2.configurationFile", log4jxml);
			MyLogger.rawLogger = LogManager.getLogger(loggerName);
		}
	}
	
	public static void setGlobalKeyValueMap(Map<String, String> keyValueMap) {
		if (MyLogger.keyValMap == null) {
			MyLogger.keyValMap = new HashMap<>();
		}
		MyLogger.keyValMap.putAll(keyValueMap);
	}
	
	public static void addGlobalKeyValue(String key, String value) {
		if (MyLogger.keyValMap == null) {
			MyLogger.keyValMap = new HashMap<>();
		}
		MyLogger.keyValMap.put(key, value);
	}
	
	private static void logHelper(
			LogLevel logLevel, String message, 
			Map<String, String> additionKeyValuePairs,
			Exception exc) {
		if (!MyLogger.isLoggerInitialized()) {
			MyLogger.init("MyLogger");
		}
		
		Map<String, String> map = new HashMap<>();
		if (MyLogger.keyValMap != null) {
			map.putAll(MyLogger.keyValMap);
		}
		if (additionKeyValuePairs != null) {
			map.putAll(additionKeyValuePairs);
		}
		
		if (exc != null) {
			map.put("Stacktrace", ExceptionUtils.getFullStackTrace(exc));
		}

		// now put actual log message
		map.put("message", message);		

		switch (logLevel) {
			case DEBUG:
				MyLogger.rawLogger.debug(new ObjectMessage(map));
				break;
			case ERROR:
				MyLogger.rawLogger.error(new ObjectMessage(map));
				break;
			case FATAL:
				MyLogger.rawLogger.fatal(new ObjectMessage(map));
				break;
			case INFO:
				MyLogger.rawLogger.info(new ObjectMessage(map));
				break;
			case WARN:
				MyLogger.rawLogger.warn(new ObjectMessage(map));
				break;
			default:
				MyLogger.rawLogger.info(new ObjectMessage(map));
		}
	}
	
	private static void setConsoleAppender(){
		if (MyLogger.rawLogger == null) { 
			return;
		}

		ConfigurationBuilder<BuiltConfiguration> builder = ConfigurationBuilderFactory.newConfigurationBuilder();

		builder.setStatusLevel(getEnvLogLevel());
		// naming the logger configuration
		builder.setConfigurationName("DefaultLogger");

		// create a console appender
		AppenderComponentBuilder appenderBuilder = builder.newAppender("Console", "CONSOLE")
		                .addAttribute("target", ConsoleAppender.Target.SYSTEM_OUT);
		
		LayoutComponentBuilder jsonLayout = builder.newLayout("JsonTemplateLayout");

		appenderBuilder.add(jsonLayout);
		
		RootLoggerComponentBuilder rootLogger = builder.newRootLogger(getEnvLogLevel());
		rootLogger.add(builder.newAppenderRef("Console"));

		builder.add(appenderBuilder);
		builder.add(rootLogger);
		Configurator.reconfigure(builder.build());
	}
	
	private static Level getEnvLogLevel() {
		String logLevel = getPropertyValue(LOG_LEVEL);
		
		if (logLevel != null) {
			if (logLevel.toLowerCase().contains("info")) {
				return Level.INFO;
			}
			else if (logLevel.toLowerCase().contains("debug")) {
				return Level.DEBUG;
			}
			else if (logLevel.toLowerCase().contains("warn")) {
				return Level.WARN;
			}
			else if (logLevel.toLowerCase().contains("error")) {
				return Level.ERROR;
			}
			else if (logLevel.toLowerCase().contains("fatal")) {
				return Level.FATAL;
			}
		}
		
		return Level.INFO;
	}
	
	private static String getPropertyValue(String key) {
		String value = System.getProperty(key);
		if (value != null) {
			return value;
		}
		
		return System.getenv(key);
	}
	
	private static boolean isLoggerInitialized() {
		return MyLogger.rawLogger != null;
	}

	private static String getPathLog4j() {
		String path = getPropertyValue(LOG4J_XML_PATH);
				
		if (path != null && !"".equals(path.trim())) {
			return path.trim();
		}
		return null;
	}
}

The code is very simple and self-explanatory to use. I can include usage for putting custom json keys:

MyLogger.rawLogger.info(
    new ObjectMessage(map)
);

The idea is to send a map to logger, not string.

Sample Log
MyLogger.init("mylogger");
MyLogger.logInfo("This is a test log");
MyLogger.addGlobalKeyValue("instanceId", "myContainer-123");
MyLogger.logInfo("This is another test log");
MyLogger.logInfo("This is also another test log", getKeyValueMap("jiraId", "ABC-123", "objId", "xyz-124"));

// code to prepare map from key-value params
public static Map<String, String> getKeyValueMap(String ... keyValuePairs){
    Map<String, String> map = new JCLoggerConcurrentHashMap<String, String>();

    if (keyValuePairs != null){
        int l = keyValuePairs.length;
        for(int i=0; i<l; i++){
            String key = keyValuePairs[i];
            String value = "";
            i++;
            if (i < l){
                value = keyValuePairs[i];

                //Will not allow a null key
                map.put(key, value);
            }
        }
    }

    return map;
}

This will produce following logs:

{"@timestamp":"2022-09-28T13:25:40.358Z","ecs.version":"1.2.0","log.level":"INFO","message":"{message=This is a test log}","process.thread.name":"main","log.logger":"mylogger"}
{"@timestamp":"2022-09-28T13:25:40.360Z","ecs.version":"1.2.0","log.level":"INFO","message":"{message=This is another test log, instanceId=myContainer-123}","process.thread.name":"main","log.logger":"mylogger"}
{"@timestamp":"2022-09-28T13:25:40.361Z","ecs.version":"1.2.0","log.level":"INFO","message":"{objId=xyz-124, instanceId=myContainer-123, message=This is also another test log, jiraId=ABC-123}","process.thread.name":"main","log.logger":"mylogger"}

This is default log format, if you need customization. See next block.

Code With Cutomization

You need to put a json file in classpath, and configure logger to load it from that file. You can write various layout options.

See various options at https://logging.apache.org/log4j/2.x/manual/json-template-layout.html

In setConsoleAppender() method above, you need to modify it like:

private static void setConsoleAppender(){
    if (MyLogger.rawLogger == null) { 
        return;
    }

    ConfigurationBuilder<BuiltConfiguration> builder = ConfigurationBuilderFactory.newConfigurationBuilder();

    builder.setStatusLevel(getEnvLogLevel());
    // naming the logger configuration
    builder.setConfigurationName("DefaultLogger");

    // create a console appender
    AppenderComponentBuilder appenderBuilder = builder.newAppender("Console", "CONSOLE")
                    .addAttribute("target", ConsoleAppender.Target.SYSTEM_OUT);
    
    LayoutComponentBuilder jsonLayout = builder.newLayout("JsonTemplateLayout")
            .addAttribute("eventTemplateUri", "classpath:layout.json");        

    appenderBuilder.add(jsonLayout);
    
    RootLoggerComponentBuilder rootLogger = builder.newRootLogger(getEnvLogLevel());
    rootLogger.add(builder.newAppenderRef("Console"));

    builder.add(appenderBuilder);
    builder.add(rootLogger);
    Configurator.reconfigure(builder.build());
}

Note the .addAttribute("eventTemplateUri", "classpath:layout.json");. And, you need to put this layout.json in classpath. I have put in src/main/resources and added this path in classpath.

layout.json

{
  "timestamp": {
    "$resolver": "timestamp",
    "pattern": {
      "format": "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
      "timeZone": "UTC"
    }
  },
  "level": {
    "$resolver": "level",
    "field": "name"
  },
  "message": {
    "$resolver": "message",
    "stringified": false
  },
  "labels": {
    "$resolver": "mdc",
    "flatten": true,
    "stringified": true
  },
  "tags": {
    "$resolver": "ndc"
  },
  "error.type": {
    "$resolver": "exception",
    "field": "className"
  },
  "error.message": {
    "$resolver": "exception",
    "field": "message"
  },
  "error.stack_trace": {
    "$resolver": "exception",
    "field": "stackTrace",
    "stackTrace": {
      "stringified": true
    }
  }
}

Now, if I run above sample log code. I will get following logs:

Sample Log
{"timestamp":"2022-09-28T13:33:32.927Z","level":"INFO","message":{"message":"This is a test log"}}
{"timestamp":"2022-09-28T13:33:32.930Z","level":"INFO","message":{"message":"This is another test log","instanceId":"myContainer-123"}}
{"timestamp":"2022-09-28T13:33:32.932Z","level":"INFO","message":{"objId":"xyz-124","instanceId":"myContainer-123","message":"This is also another test log","jiraId":"ABC-123"}}

Log4j Json Layout (Deprecated)

I will include the initialization code from above method, you just need to change this.

private static void setConsoleAppender(){
    if (MyLogger.rawLogger == null) { 
        return;
    }

    ConfigurationBuilder<BuiltConfiguration> builder = ConfigurationBuilderFactory.newConfigurationBuilder();

    builder.setStatusLevel(getEnvLogLevel());
    // naming the logger configuration
    builder.setConfigurationName("DefaultLogger");

    // create a console appender
    AppenderComponentBuilder appenderBuilder = builder.newAppender("Console", "CONSOLE")
                    .addAttribute("target", ConsoleAppender.Target.SYSTEM_OUT);
    
    LayoutComponentBuilder jsonLayout = builder.newLayout("JsonLayout")
            .addAttribute("complete", false)
            .addAttribute("compact", false)
            .addAttribute("includeTimeMillis", true)
            .addAttribute("objectMessageAsJsonObject", true)
            .addComponent(appenderBuilder);        

    appenderBuilder.add(jsonLayout);
    
    RootLoggerComponentBuilder rootLogger = builder.newRootLogger(getEnvLogLevel());
    rootLogger.add(builder.newAppenderRef("Console"));

    builder.add(appenderBuilder);
    builder.add(rootLogger);
    Configurator.reconfigure(builder.build());
}

For more options, see https://logging.apache.org/log4j/2.x/manual/layouts.html

This logger is less customizable, and it adds annoying unnecessary fields to logs. Its very important to add objectMessageAsJsonObject property. Otherwise, the log message will come as string, not json.

Now, if I run above sample log code. I will get following logs:

Sample Log

2022-09-28 19:05:09,790 main ERROR layout JsonLayout has no parameter that matches element CONSOLE
{
  "timeMillis" : 1664372109886,
  "thread" : "main",
  "level" : "INFO",
  "loggerName" : "mylogger",
  "message" : {
    "message" : "This is a test log"
  },
  "endOfBatch" : false,
  "loggerFqcn" : "org.apache.logging.log4j.spi.AbstractLogger",
  "threadId" : 1,
  "threadPriority" : 5
}
{
  "timeMillis" : 1664372109931,
  "thread" : "main",
  "level" : "INFO",
  "loggerName" : "mylogger",
  "message" : {
    "message" : "This is another test log",
    "instanceId" : "myContainer-123"
  },
  "endOfBatch" : false,
  "loggerFqcn" : "org.apache.logging.log4j.spi.AbstractLogger",
  "threadId" : 1,
  "threadPriority" : 5
}
{
  "timeMillis" : 1664372109932,
  "thread" : "main",
  "level" : "INFO",
  "loggerName" : "mylogger",
  "message" : {
    "objId" : "xyz-124",
    "instanceId" : "myContainer-123",
    "message" : "This is also another test log",
    "jiraId" : "ABC-123"
  },
  "endOfBatch" : false,
  "loggerFqcn" : "org.apache.logging.log4j.spi.AbstractLogger",
  "threadId" : 1,
  "threadPriority" : 5
}

Let me know if you face any issue with this.


Similar Posts

Latest Posts