Skip to content

Commit

Permalink
Add JSON formatter to JULI
Browse files Browse the repository at this point in the history
Generates one line JSON, similar to access log.
  • Loading branch information
rmaucher committed Feb 8, 2025
1 parent 4a8603d commit 803ece9
Show file tree
Hide file tree
Showing 4 changed files with 279 additions and 1 deletion.
208 changes: 208 additions & 0 deletions java/org/apache/juli/JsonFormatter.java
Original file line number Diff line number Diff line change
@@ -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.getLongThreadID()));
} 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 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;
}
}

}
}
5 changes: 4 additions & 1 deletion java/org/apache/juli/OneLineFormatter.java
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,11 @@ protected void addTimestamp(StringBuilder buf, long timestamp) {

/**
* LogRecord has threadID but no thread name.
*
* @param logRecordThreadId the thread id
* @return the thread name
*/
private static String getThreadName(long logRecordThreadId) {
protected static String getThreadName(long logRecordThreadId) {
Map<Long, String> cache = threadNameCache.get();
String result = cache.get(Long.valueOf(logRecordThreadId));

Expand Down
59 changes: 59 additions & 0 deletions test/org/apache/juli/TestJsonFormatter.java
Original file line number Diff line number Diff line change
@@ -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");

}

}
8 changes: 8 additions & 0 deletions webapps/docs/changelog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@
issues do not "pop up" wrt. others).
-->
<section name="Tomcat 11.0.4 (markt)" 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 11.0.3 (markt)" rtext="release in progress">
<subsection name="Catalina">
Expand Down

0 comments on commit 803ece9

Please sign in to comment.