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}