This is an automated email from the ASF dual-hosted git repository.

remm pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/9.0.x by this push:
     new ce4c29bc36 Add JSON formatter to JULI
ce4c29bc36 is described below

commit ce4c29bc3694e04fc50835fdb16216eb3331584a
Author: remm <r...@apache.org>
AuthorDate: Sat Feb 8 14:30:00 2025 +0100

    Add JSON formatter to JULI
    
    Generates one line JSON, similar to access log.
---
 java/org/apache/juli/JsonFormatter.java     | 208 ++++++++++++++++++++++++++++
 java/org/apache/juli/OneLineFormatter.java  |   5 +-
 test/org/apache/juli/TestJsonFormatter.java |  59 ++++++++
 webapps/docs/changelog.xml                  |   8 ++
 4 files changed, 279 insertions(+), 1 deletion(-)

diff --git a/java/org/apache/juli/JsonFormatter.java 
b/java/org/apache/juli/JsonFormatter.java
new file mode 100644
index 0000000000..b0ae7e9576
--- /dev/null
+++ b/java/org/apache/juli/JsonFormatter.java
@@ -0,0 +1,208 @@
+/*
+ * 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.juli;
+
+import java.util.logging.LogRecord;
+
+/**
+ * Provides the same information as the one line format but using JSON 
formatting.
+ * All the information of the LogRecord is included as a one line JSON 
document,
+ * including the full stack trace of the associated exception if any.
+ * <p>
+ * The LogRecord is mapped as attributes:
+ * <ul>
+ * <li>time: the log record timestamp</li>
+ * <li>level: the log level</li>
+ * <li>thread: the current on which the log occurred</li>
+ * <li>class: the class from which the log originated</li>
+ * <li>method: the method from which the log originated</li>
+ * <li>message: the log message</li>
+ * <li>error: the message from an exception, if present</li>
+ * <li>trace: the full stack trace from an exception, if present, represented 
as an array of string
+ *  (the message first, then one string per stack trace element prefixed by a 
string,
+ *  then moving on to the cause exception if any)</li>
+ * </ul>
+ */
+public class JsonFormatter extends OneLineFormatter {
+
+    @Override
+    public String format(LogRecord record) {
+        StringBuilder sb = new StringBuilder();
+        sb.append('{');
+
+        // Timestamp
+        sb.append("\"time\": \"");
+        addTimestamp(sb, record.getMillis());
+        sb.append("\", ");
+
+        // Severity
+        sb.append("\"level\": \"");
+        sb.append(record.getLevel().getLocalizedName());
+        sb.append("\", ");
+
+        // Thread
+        sb.append("\"thread\": \"");
+        final String threadName = Thread.currentThread().getName();
+        if (threadName != null && 
threadName.startsWith(AsyncFileHandler.THREAD_PREFIX)) {
+            // If using the async handler can't get the thread name from the
+            // current thread.
+            sb.append(getThreadName(record.getThreadID()));
+        } else {
+            sb.append(threadName);
+        }
+        sb.append("\", ");
+
+        // Source
+        sb.append("\"class\": \"");
+        sb.append(record.getSourceClassName());
+        sb.append("\", ");
+        sb.append("\"method\": \"");
+        sb.append(record.getSourceMethodName());
+        sb.append("\", ");
+
+        // Message
+        sb.append("\"message\": \"");
+        sb.append(JSONFilter.escape(formatMessage(record)));
+
+        Throwable t = record.getThrown();
+        if (t != null) {
+            sb.append("\", ");
+
+            // Error
+            sb.append("\"error\": \"");
+            sb.append(JSONFilter.escape(t.toString()));
+            sb.append("\", ");
+
+            // Stack trace
+            sb.append("\"trace\": [");
+            boolean first = true;
+            do {
+                if (!first) {
+                    sb.append(',');
+                } else {
+                    first = false;
+                }
+                
sb.append('\"').append(JSONFilter.escape(t.toString())).append('\"');
+                for (StackTraceElement element : t.getStackTrace()) {
+                    sb.append(',').append('\"').append(' 
').append(JSONFilter.escape(element.toString())).append('\"');
+                }
+                t = t.getCause();
+            } while (t != null);
+            sb.append("]");
+        } else {
+            sb.append("\"");
+        }
+
+        sb.append('}');
+        // New line for next record
+        sb.append(System.lineSeparator());
+
+        return sb.toString();
+    }
+
+
+    /**
+     * Provides escaping of values so they can be included in a JSON document.
+     * Escaping is based on the definition of JSON found in
+     * <a href="https://www.rfc-editor.org/rfc/rfc8259.html";>RFC 8259</a>.
+     */
+    public static class JSONFilter {
+
+        /**
+         * Escape the given string.
+         * @param input the string
+         * @return the escaped string
+         */
+        public static String escape(String input) {
+            return escape(input, 0, input.length()).toString();
+        }
+
+        /**
+         * Escape the given char sequence.
+         * @param input the char sequence
+         * @param off the offset on which escaping will start
+         * @param length the length which should be escaped
+         * @return the escaped char sequence corresponding to the specified 
range
+         */
+        public static CharSequence escape(CharSequence input, int off, int 
length) {
+            /*
+             * While any character MAY be escaped, only U+0000 to U+001F 
(control
+             * characters), U+0022 (quotation mark) and U+005C (reverse 
solidus)
+             * MUST be escaped.
+             */
+            StringBuilder escaped = null;
+            int lastUnescapedStart = off;
+            for (int i = off; i < length; i++) {
+                char c = input.charAt(i);
+                if (c < 0x20 || c == 0x22 || c == 0x5c || 
Character.isHighSurrogate(c) || Character.isLowSurrogate(c)) {
+                    if (escaped == null) {
+                        escaped = new StringBuilder(length + 20);
+                    }
+                    if (lastUnescapedStart < i) {
+                        escaped.append(input.subSequence(lastUnescapedStart, 
i));
+                    }
+                    lastUnescapedStart = i + 1;
+                    char popular = getPopularChar(c);
+                    if (popular > 0) {
+                        escaped.append('\\').append(popular);
+                    } else {
+                        escaped.append("\\u");
+                        escaped.append(String.format("%04X", 
Integer.valueOf(c)));
+                    }
+                }
+            }
+            if (escaped == null) {
+                if (off == 0 && length == input.length()) {
+                    return input;
+                } else {
+                    return input.subSequence(off, length - off);
+                }
+            } else {
+                if (lastUnescapedStart < length) {
+                    escaped.append(input.subSequence(lastUnescapedStart, 
length));
+                }
+                return escaped.toString();
+            }
+        }
+
+        private JSONFilter() {
+            // Utility class. Hide the default constructor.
+        }
+
+        private static char getPopularChar(char c) {
+            switch (c) {
+                case '"':
+                case '\\':
+                case '/':
+                    return c;
+                case 0x8:
+                    return 'b';
+                case 0xc:
+                    return 'f';
+                case 0xa:
+                    return 'n';
+                case 0xd:
+                    return 'r';
+                case 0x9:
+                    return 't';
+                default:
+                    return 0;
+            }
+        }
+
+    }
+}
diff --git a/java/org/apache/juli/OneLineFormatter.java 
b/java/org/apache/juli/OneLineFormatter.java
index 6f9fd62a21..c48811af7d 100644
--- a/java/org/apache/juli/OneLineFormatter.java
+++ b/java/org/apache/juli/OneLineFormatter.java
@@ -213,8 +213,11 @@ public class OneLineFormatter extends Formatter {
      * <p>
      * Words fail me to describe what I think of the design decision to use an 
int in LogRecord for a long value and the
      * resulting mess that follows.
+     *
+     * @param logRecordThreadId the thread id
+     * @return the thread name
      */
-    private static String getThreadName(int logRecordThreadId) {
+    protected static String getThreadName(int logRecordThreadId) {
         Map<Integer, String> cache = threadNameCache.get();
         String result = cache.get(Integer.valueOf(logRecordThreadId));
 
diff --git a/test/org/apache/juli/TestJsonFormatter.java 
b/test/org/apache/juli/TestJsonFormatter.java
new file mode 100644
index 0000000000..9114556ad9
--- /dev/null
+++ b/test/org/apache/juli/TestJsonFormatter.java
@@ -0,0 +1,59 @@
+/*
+ * 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.juli;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.logging.Formatter;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.tomcat.util.json.JSONParser;
+
+public class TestJsonFormatter {
+
+    @Test
+    public void testFormat() throws Exception {
+
+        Formatter formatter = new JsonFormatter();
+        LogRecord logRecord = new LogRecord(Level.FINE, "Test log message");
+        logRecord.setSourceClassName("org.apache.juli.TestJsonFormatter");
+        logRecord.setSourceMethodName("testFormat");
+        try {
+            throw new IllegalStateException("Bad state");
+        } catch (IllegalStateException e) {
+            logRecord.setThrown(e);
+        }
+
+        String result = formatter.format(logRecord);
+
+        // Verify JSON content
+        Assert.assertTrue(result.startsWith("{"));
+        JSONParser parser = new JSONParser(new StringReader(result));
+        LinkedHashMap<String,Object> json = parser.object();
+        Assert.assertEquals(json.get("method"), "testFormat");
+        @SuppressWarnings("unchecked")
+        ArrayList<Object> trace = (ArrayList<Object>) json.get("trace");
+        Assert.assertEquals(trace.get(0), "java.lang.IllegalStateException: 
Bad state");
+
+    }
+
+}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 0b2757e9cf..8672051464 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -105,6 +105,14 @@
   issues do not "pop up" wrt. others).
 -->
 <section name="Tomcat 9.0.100 (remm)" rtext="in development">
+  <subsection name="Other">
+    <changelog>
+      <add>
+        Add <code>org.apache.juli.JsonFormatter</code> to format log as one
+        line JSON documents. (remm)
+      </add>
+    </changelog>
+  </subsection>
 </section>
 <section name="Tomcat 9.0.99 (remm)" rtext="release in progress">
   <subsection name="Catalina">


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org
For additional commands, e-mail: dev-h...@tomcat.apache.org

Reply via email to