javaloggingstructured-logging

JSON Logging with named fields in Java?


Here is the Golang version of what I want to do in Java:

package main

import (
    "log/slog"
    "os"
)

func main() {
    logLevel := new(slog.LevelVar) // Info by default
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))
    slog.SetDefault(logger)

    slog.Info("this is a test info message",
        slog.Bool("my-flag", true),
        slog.Int("my-int", 123),
        slog.Float64("my-float64", 3.14159),
        slog.String("my-name", "abcdefg"))
}

That generates:

{"time":"2024-04-05T16:54:14.028207-05:00","level":"INFO","msg":"this is a test info message","my-flag":true,"my-int":123,"my-float64":3.14159,"my-name":"abcdefg"}

In Java, I'm aware of three popular logging APIs:

AFAIK, none of these three support named fields like Golang slog does. Is that correct? Am I missing something? It seems like this should be easier.

I tried writing examples in all three logging apis.


Solution

  • If you use logback, for instance, you can use ch.qos.logback.classic.encoder.JsonEncoder to log JSON objects:

    You need this configuration in logback.xml:

    configuration>
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="ch.qos.logback.classic.encoder.JsonEncoder"/>
        </appender>
        <root level="debug">
            <appender-ref ref="STDOUT" />
        </root>
    </configuration>
    

    And then the code:

    package com.example.so;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public class Eg {
        public static void main(String[] args) {
            Logger l = LoggerFactory.getLogger("test");
    
            l.atInfo().addKeyValue("my-flag", true)
                    .addKeyValue("my-int", 123)
                    .addKeyValue("my-double", Math.PI)
                    .addKeyValue("my-name", "abcdefg")
                    .log();
        }
    }
    

    produces:

    {
      "sequenceNumber": 0,
      "timestamp": 1712366291199,
      "nanoseconds": 199243000,
      "level": "INFO",
      "threadName": "main",
      "loggerName": "test",
      "context": {
        "name": "default",
        "birthdate": 1712366291102,
        "properties": {}
      },
      "mdc": {},
      "kvpList": [
        {
          "my-flag": "true"
        },
        {
          "my-int": "123"
        },
        {
          "my-double": "3.141592653589793"
        },
        {
          "my-name": "abcdefg"
        }
      ],
      "message": "null",
      "throwable": null
    }
    

    I'm getting the required dependencies via Spring Boot:

    [INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.2.0-RC1:compile
    [INFO] |  +- org.springframework.boot:spring-boot-starter:jar:3.2.0-RC1:compile
    [INFO] |  |  +- org.springframework.boot:spring-boot-starter-logging:jar:3.2.0-RC1:compile
    [INFO] |  |  |  +- ch.qos.logback:logback-classic:jar:1.4.11:compile
    [INFO] |  |  |  |  \- ch.qos.logback:logback-core:jar:1.4.11:compile
    [INFO] |  |  |  +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.21.0:compile
    [INFO] |  |  |  |  \- org.apache.logging.log4j:log4j-api:jar:2.21.0:compile
    [INFO] |  |  |  \- org.slf4j:jul-to-slf4j:jar:2.0.9:compile
    

    This solution logs all values as JSON strings, even if there's a primitive representation available. The class ch.qos.logback.classic.encoder.JsonEncoder doesn't lend itself to extension, but you can copy it and modify it to log numbers and booleans naturally. (The code below works but has not been tested much)

    Add two methods:

        private void appenderMemberUnquotedValue(StringBuilder sb, String key, String value) {
            sb.append(QUOTE).append(key).append(QUOTE_COL).append(value);
        }
    
        String toJson(Object o) {
            return switch (o) {
                case String s -> "\"" + jsonEscape(s) + "\"";
                case Boolean b -> b.toString();
                case Number n -> n.toString();
                case null -> "null";
                default -> "\"" + jsonEscapedToString(o) + "\"";
            };
        }
    
    

    And modify one line in this method:

        private void appendKeyValuePairs(StringBuilder sb, ILoggingEvent event) {
            List<KeyValuePair> kvpList = event.getKeyValuePairs();
            if (kvpList == null || kvpList.isEmpty())
                return;
    
            sb.append(QUOTE).append(KEY_VALUE_PAIRS_ATTR_NAME).append(QUOTE_COL).append(SP).append(OPEN_ARRAY);
            final int len = kvpList.size();
            for (int i = 0; i < len; i++) {
                if (i != 0)
                    sb.append(VALUE_SEPARATOR);
                KeyValuePair kvp = kvpList.get(i);
                sb.append(OPEN_OBJ);
    //             appenderMember(sb, jsonEscapedToString(kvp.key), jsonEscapedToString(kvp.value)); // OLD
    
                appenderMemberUnquotedValue(sb, jsonEscapedToString(kvp.key), toJson(kvp.value)); // NEW
                sb.append(CLOSE_OBJ);
            }
            sb.append(CLOSE_ARRAY);
            sb.append(VALUE_SEPARATOR);
        }