001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.microservice.logging; 018 019import static org.apache.juneau.commons.lang.StateEnum.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021import static org.apache.juneau.commons.utils.ThrowableUtils.*; 022 023import java.text.*; 024import java.util.*; 025import java.util.concurrent.*; 026import java.util.concurrent.atomic.*; 027import java.util.logging.*; 028import java.util.logging.Formatter; 029import java.util.regex.*; 030 031/** 032 * Log entry formatter. 033 * 034 * <p> 035 * Uses three simple parameter for configuring log entry formats: 036 * <ul class='spaced-list'> 037 * <li> 038 * <c>dateFormat</c> - A {@link SimpleDateFormat} string describing the format for dates. 039 * <li> 040 * <c>format</c> - A string with <c>{...}</c> replacement variables representing predefined fields. 041 * <li> 042 * <c>useStackTraceHashes</c> - A setting that causes duplicate stack traces to be replaced with 8-character 043 * hash strings. 044 * </ul> 045 * 046 * <p> 047 * This class converts the format strings into a regular expression that can be used to parse the resulting log file. 048 */ 049@SuppressWarnings({ 050 "java:S2143" // Public API built on SimpleDateFormat/Date for Formatter compatibility; getDateFormat() returns DateFormat as a contract. 051}) 052public class LogEntryFormatter extends Formatter { 053 054 private static String hashCode(Throwable t) { 055 int i = 0; 056 while (nn(t)) { 057 for (var e : t.getStackTrace()) 058 i ^= e.hashCode(); 059 t = t.getCause(); 060 } 061 return Integer.toHexString(i); 062 } 063 064 // Appends a single character to the log-parsing regex as a literal, escaping it when it is a regex 065 // metacharacter. The format and date-format strings are caller-supplied configuration, so their 066 // literal characters must be escaped to keep them matched verbatim and prevent regex-injection. 067 private static void appendLiteral(StringBuilder re, char c) { 068 if (! (Character.isLetterOrDigit(c) || Character.isWhitespace(c))) 069 re.append('\\'); 070 re.append(c); 071 } 072 073 // Appends each character of a string to the log-parsing regex as a literal (see {@link #appendLiteral(StringBuilder, char)}). 074 private static void appendLiteral(StringBuilder re, String s) { 075 for (var i = 0; i < s.length(); i++) 076 appendLiteral(re, s.charAt(i)); 077 } 078 079 // Converts a SimpleDateFormat pattern into a regex fragment that matches formatted dates: date/time pattern 080 // letters become "\d" matchers and all other characters are treated as regex literals. Escaping the 081 // non-letter characters (rather than only ".") prevents regex-injection from a caller-supplied date format. 082 private static String dateFormatToRegex(String dateFormat) { 083 var re = new StringBuilder(); 084 for (var i = 0; i < dateFormat.length(); i++) { 085 var c = dateFormat.charAt(i); 086 if ("mHhsSdMy".indexOf(c) >= 0) 087 re.append("\\d"); 088 else 089 appendLiteral(re, c); 090 } 091 return re.toString(); 092 } 093 094 private ConcurrentHashMap<String,AtomicInteger> hashes; 095 private final DateFormat df; 096 private final String format; 097 private final Pattern rePattern; 098 099 private Map<String,Integer> fieldIndexes; 100 101 /** 102 * Create a new formatter. 103 * 104 * @param format 105 * The log entry format. e.g. <js>"[{date} {level}] {msg}%n"</js> 106 * The string can contain any of the following variables: 107 * <ol> 108 * <li><js>"{date}"</js> - The date, formatted per <js>"Logging/dateFormat"</js>. 109 * <li><js>"{class}"</js> - The class name. 110 * <li><js>"{method}"</js> - The method name. 111 * <li><js>"{logger}"</js> - The logger name. 112 * <li><js>"{level}"</js> - The log level name. 113 * <li><js>"{msg}"</js> - The log message. 114 * <li><js>"{threadid}"</js> - The thread ID. 115 * <li><js>"{exception}"</js> - The localized exception message. 116 * <li><js>"{traceId}"</js> - The active OpenTelemetry trace ID (empty when OTel is absent or no span is active). 117 * <li><js>"{spanId}"</js> - The active OpenTelemetry span ID (empty when OTel is absent or no span is active). 118 * </ol> 119 * Must not be <jk>null</jk>. 120 * @param dateFormat 121 * The {@link SimpleDateFormat} format to use for dates. e.g. <js>"yyyy.MM.dd hh:mm:ss"</js>. 122 * <br>Must not be <jk>null</jk>. 123 * @param useStackTraceHashes 124 * If <jk>true</jk>, only print unique stack traces once and then refer to them by a simple 8 character hash 125 * identifier. 126 */ 127 @SuppressWarnings({ 128 "java:S3776" // Cognitive complexity acceptable for console command logic 129 }) 130 public LogEntryFormatter(String format, String dateFormat, boolean useStackTraceHashes) { 131 this.df = new SimpleDateFormat(dateFormat); 132 if (useStackTraceHashes) 133 hashes = new ConcurrentHashMap<>(); 134 135 fieldIndexes = m(); 136 137 // @formatter:off 138 format = format 139 .replace("{date}", "%1$s") 140 .replace("{class}", "%2$s") 141 .replace("{method}", "%3$s") 142 .replace("{logger}", "%4$s") 143 .replace("{level}", "%5$s") 144 .replace("{msg}", "%6$s") 145 .replace("{threadid}", "%7$s") 146 .replace("{exception}", "%8$s") 147 .replace("{traceId}", "%9$s") 148 .replace("{spanId}", "%10$s"); 149 // @formatter:on 150 151 this.format = format; 152 153 // Construct a regular expression to match this log entry. 154 int index = 1; 155 var re = new StringBuilder(); 156 157 // The parser state machine uses four states while scanning for format placeholders. 158 // In state S1 we are looking for a percent sign. 159 // In state S2 we have found a percent sign and are looking for a number. 160 // In state S3 we have found a number and are looking for a dollar sign. 161 // In state S4 we have found a dollar sign and are looking for an 's'. 162 var state = S1; 163 int i1 = 0; 164 for (var i = 0; i < format.length(); i++) { 165 var c = format.charAt(i); 166 if (state == S1) { 167 if (c == '%') 168 state = S2; 169 else 170 appendLiteral(re, c); 171 } else if (state == S2) { 172 if (Character.isDigit(c)) { 173 i1 = i; 174 state = S3; 175 } else { 176 re.append("\\%"); 177 appendLiteral(re, c); 178 state = S1; 179 } 180 } else if (state == S3) { // NOSONAR - State check necessary for state machine 181 if (c == '$') { 182 state = S4; 183 } else if (Character.isDigit(c)) { 184 // Stay in S3: group numbers may be multi-digit (e.g. %10$s for {spanId}). 185 state = S3; // NOSONAR - explicit self-transition documents the multi-digit case 186 } else { 187 re.append("\\%"); 188 appendLiteral(re, format.substring(i1, i)); // HTT - requires %digit followed by non-$ which can't come from standard format placeholders 189 state = S1; 190 } 191 } else if (state == S4) { 192 if (c == 's') { 193 int group = Integer.parseInt(format.substring(i1, i - 1)); 194 switch (group) { 195 case 1: 196 fieldIndexes.put("date", index++); 197 re.append("(").append(dateFormatToRegex(dateFormat)).append(")"); 198 break; 199 case 2: 200 fieldIndexes.put("class", index++); 201 re.append("([\\p{javaJavaIdentifierPart}\\.]+)"); 202 break; 203 case 3: 204 fieldIndexes.put("method", index++); 205 re.append("([\\p{javaJavaIdentifierPart}\\.]+)"); 206 break; 207 case 4: 208 fieldIndexes.put("logger", index++); 209 re.append("([\\w\\d\\.\\_]+)"); 210 break; 211 case 5: 212 fieldIndexes.put("level", index++); 213 re.append("(SEVERE|WARNING|INFO|CONFIG|FINE|FINER|FINEST)"); 214 break; 215 case 6: 216 fieldIndexes.put("msg", index++); 217 re.append("(.*)"); 218 break; 219 case 7: 220 fieldIndexes.put("threadid", index++); 221 re.append("(\\\\d+)"); 222 break; 223 case 8: 224 fieldIndexes.put("exception", index++); 225 re.append("(.*)"); 226 break; 227 case 9: 228 fieldIndexes.put("traceId", index++); 229 re.append("([0-9a-f]*)"); 230 break; 231 case 10: 232 fieldIndexes.put("spanId", index++); 233 re.append("([0-9a-f]*)"); 234 break; 235 default: // HTT - group numbers > 10 would require a format placeholder beyond {spanId} // NOSONAR 236 } 237 } else { 238 re.append("\\%"); 239 appendLiteral(re, format.substring(i1, i)); // HTT - requires %digit$ followed by non-s which can't come from standard format placeholders 240 } 241 state = S1; 242 } 243 } 244 245 // The log parser 246 var sre = re.toString(); 247 if (sre.endsWith("\\%n")) 248 sre = sre.substring(0, sre.length() - 3); 249 250 // Replace instances of %n. 251 sre = sre.replace("\\\\%n", "\\\\n"); 252 253 rePattern = Pattern.compile(sre); 254 fieldIndexes = cp(fieldIndexes); 255 } 256 257 @SuppressWarnings({ 258 "deprecation" // Uses deprecated API for compatibility 259 }) 260 @Override /* Overridden from Formatter */ 261 public String format(LogRecord r) { 262 String msg = formatMessage(r); 263 Throwable t = r.getThrown(); 264 String hash = null; 265 int c = 0; 266 if (nn(hashes) && nn(t)) { 267 hash = hashCode(t); 268 hashes.putIfAbsent(hash, new AtomicInteger(0)); 269 c = hashes.get(hash).incrementAndGet(); 270 if (c == 1) { 271 msg = '[' + hash + '.' + c + "] " + msg; 272 } else { 273 msg = '[' + hash + '.' + c + "] " + msg + ", " + localizedMessage(t); 274 t = null; 275 } 276 } 277 // SimpleDateFormat is not thread-safe; guard the shared instance so concurrent log records don't corrupt formatting. 278 String date; 279 synchronized (df) { 280 date = df.format(new Date(r.getMillis())); 281 } 282 var s = String.format(Locale.ROOT, format, date, r.getSourceClassName(), r.getSourceMethodName(), r.getLoggerName(), r.getLevel(), msg, r.getThreadID(), 283 r.getThrown() == null ? "" : r.getThrown().getMessage(), TraceContext.currentTraceId(), TraceContext.currentSpanId()); 284 if (nn(t)) 285 s += String.format(Locale.ROOT, "%n%s", getStackTrace(r.getThrown())); 286 return s; 287 } 288 289 /** 290 * Returns the {@link DateFormat} used for matching dates. 291 * 292 * <p> 293 * Returns a defensive {@link DateFormat#clone() clone} because {@link SimpleDateFormat} is mutable and not 294 * thread-safe; each caller gets its own instance and cannot corrupt the formatter's shared internal state. 295 * 296 * @return A clone of the {@link DateFormat} used for matching dates. 297 */ 298 public DateFormat getDateFormat() { 299 synchronized (df) { 300 return (DateFormat) df.clone(); 301 } 302 } 303 304 /** 305 * Given a matcher that has matched the pattern specified by {@link #getLogEntryPattern()}, returns the field value 306 * from the match. 307 * 308 * @param fieldName 309 * The field name. 310 * Possible values are: 311 * <ul> 312 * <li><js>"date"</js> 313 * <li><js>"class"</js> 314 * <li><js>"method"</js> 315 * <li><js>"logger"</js> 316 * <li><js>"level"</js> 317 * <li><js>"msg"</js> 318 * <li><js>"threadid"</js> 319 * <li><js>"exception"</js> 320 * <li><js>"traceId"</js> 321 * <li><js>"spanId"</js> 322 * </ul> 323 * @param m The matcher. Must not be <jk>null</jk>. 324 * @return The field value, or <jk>null</jk> if the specified field does not exist. 325 */ 326 public String getField(String fieldName, Matcher m) { 327 Integer i = fieldIndexes.get(fieldName); 328 return (i == null ? null : m.group(i)); 329 } 330 331 /** 332 * Returns the regular expression pattern used for matching log entries. 333 * 334 * @return The regular expression pattern used for matching log entries. 335 */ 336 public Pattern getLogEntryPattern() { return rePattern; } 337}