CentralLogFormatter.java

/*
** Module   : CentralLogFormatter.java
** Abstract : Log formatter for CentralLogger.
**
** Copyright (c) 2023-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ----------------------------Description-----------------------------
** 001 GBB 20230328 Initial setup
** 002 GBB 20230613 convertToLevel moved to LoggingUtils.
** 003 GBB 20230623 Performance improvements
** 004 GBB 20230825 SecurityManager context & session methods calls updated.
** 005 TJD 20240117 Do not modify needToInferCaller directly
** 006 GBB 20240729 Save log context in a new object and print it later with the message.
** 007 GBB 20250403 Checks for sessionId being null and fwdUser being an empty string.
*/
/*
 ** This program is free software: you can redistribute it and/or modify
 ** it under the terms of the GNU Affero General License as
 ** published by the Free Software Foundation, either version 3 of the
 ** License, or (at your option) any later version.
 **
 ** This program is distributed in the hope that it will be useful,
 ** but WITHOUT ANY WARRANTY; without even the implied warranty of
 ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 ** GNU Affero General License for more details.
 **
 ** You may find a copy of the GNU Affero GPL version 3 at the following
 ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
 **
 ** Additional terms under GNU Affero GPL version 3 section 7:
 **
 **   Under Section 7 of the GNU Affero GPL version 3, the following additional
 **   terms apply to the works covered under the License.  These additional terms
 **   are non-permissive additional terms allowed under Section 7 of the GNU
 **   Affero GPL version 3 and may not be removed by you.
 **
 **   0. Attribution Requirement.
 **
 **     You must preserve all legal notices or author attributions in the covered
 **     work or Appropriate Legal Notices displayed by works containing the covered
 **     work.  You may not remove from the covered work any author or developer
 **     credit already included within the covered work.
 **
 **   1. No License To Use Trademarks.
 **
 **     This license does not grant any license or rights to use the trademarks
 **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
 **     of Golden Code Development Corporation. You are not authorized to use the
 **     name Golden Code, FWD, or the names of any author or contributor, for
 **     publicity purposes without written authorization.
 **
 **   2. No Misrepresentation of Affiliation.
 **
 **     You may not represent yourself as Golden Code Development Corporation or FWD.
 **
 **     You may not represent yourself for publicity purposes as associated with
 **     Golden Code Development Corporation, FWD, or any author or contributor to
 **     the covered work, without written authorization.
 **
 **   3. No Misrepresentation of Source or Origin.
 **
 **     You may not represent the covered work as solely your work.  All modified
 **     versions of the covered work must be marked in a reasonable way to make it
 **     clear that the modified work is not originating from Golden Code Development
 **     Corporation or FWD.  All modified versions must contain the notices of
 **     attribution required in this license.
 */
package com.goldencode.p2j.util.logging;

import com.goldencode.p2j.main.*;
import com.goldencode.p2j.security.SecurityManager;

import java.io.*;
import java.text.*;
import java.util.*;
import java.util.logging.*;
import java.util.logging.Formatter;
import java.util.regex.*;

/**
 * Extends the java formatter base class used by {@link FileHandler}. Exposes portions of the formatting 
 * logic to be reused by other formatters or directly by the CentralLogger. Provides a parse method to 
 * convert back formatted text into CentralLogRecord. 
 */
public class CentralLogFormatter
extends Formatter
{
   /** The divider between different parts of the log message. */
   static final String LOG_PARTS_DIVIDER = " | ";

   /** The datetime formatter used for each log record. Similar to the one used in 4GL LOG-MANAGER. */
   private static final SimpleDateFormat DATETIME_FORMATTER = 
      new SimpleDateFormat("YY/MM/dd HH:mm:ss.SSSXX");

   /** The line separator for the operating system. */
   private static final String LINE_SEPARATOR = System.lineSeparator();
   
   /**
    * Get a string which uniquely describes the current context. Consists of multiple parts:
    * - the name of the logger mode,
    * - (for clients with initialized native lib) process id,
    * - thread name,
    * - (when security manager has context initialized) session, thread and user id.
    *
    * @param    sessionId
    *           The remote server session ID.
    * @param    userId
    *           The remote server user ID.
    * @param    excludeSMContext
    *           Flag to prevent deadlock on SecurityManager, ref #5703#note-77.
    *           
    * @return   Description of the context.
    */
   protected static ContextLogRecord.LogContext describeContext(Integer sessionId, String userId, boolean excludeSMContext)
   {
      ContextLogRecord.LogContext logContext = new ContextLogRecord.LogContext();
      
      if (ClientCore.isNativeLayerInitialized())
      {
         logContext.setPid(ClientCore.getPid());
      }

      logContext.setThreadName(Thread.currentThread().getName());
      
      if (sessionId != null || userId != null)
      {
         logContext.setSessionId(sessionId == null || sessionId == 0 ? 
                                    null : 
                                    LoggingUtil.addLeadingZeros(sessionId, 8));
         logContext.setFwdUser(userId);
      }
      else if (!excludeSMContext)
      {
         SecurityManager sm = SecurityManager.getInstance();
         if (sm != null && SecurityManager.initialized() && sm.contextSm.hasContext())
         {
            sessionId = sm.sessionSm.getSessionId();
            logContext.setSessionId(sessionId == null || sessionId == 0 ?
                                       null :
                                       LoggingUtil.addLeadingZeros(sessionId, 8));
            logContext.setThreadId(LoggingUtil.addLeadingZeros(sm.getThreadId(), 8));
            logContext.setFwdUser(sm.getUserId());
         }
      }
      
      return logContext;
   }

   /**
    * Convert back formatted text into CentralLogRecord. 
    * 
    * @param    logLine
    *           The formatted log.
    * 
    * @return   CentralLogRecord parsed from the provided text.
    */
   static CentralLogRecord parse(String logLine)
   {
      String[] logRecordParts = logLine.split(Pattern.quote(LOG_PARTS_DIVIDER));
      if (logRecordParts.length < 5)
      {
         return null;
      }
      
      CentralLogRecord clr = new CentralLogRecord();
      try
      {
         clr.setMillis(DATETIME_FORMATTER.parse(logRecordParts[0].trim()).getTime());
      }
      catch (Throwable ignored)
      {
         // ignore
      }
      
      clr.setLevel(LoggingUtil.convertToLevel(logRecordParts[1].trim()).orElse(null));
      // concatenates original logger name and source class, method
      clr.setLoggerName(logRecordParts[2].trim());
      // concatenates original context and the actual message
      String msg = "[" + logRecordParts[3].trim() + "] " + logRecordParts[4];
      if (logRecordParts.length > 5)
      {
         String[] msgSegments = Arrays.copyOfRange(logRecordParts, 5, logRecordParts.length - 1);
         msg += String.join("", msgSegments);
      }
      clr.setMessage(msg);
      return clr;
   }

   /**
    * Dump all details of the throwable into the given string buffer.
    * Includes exception type, message, complete stack trace, and chained
    * throwables, if any.
    *
    * @param    sb
    *           The string builder the throwable to be appended to.
    * @param    original
    *           The throwable to dump.
    */
   static void dumpThrowable(StringBuilder sb, Throwable original)
   {
      StringWriter writer = new StringWriter();
      PrintWriter printWriter = new PrintWriter(writer);
      original.printStackTrace(printWriter);
      printWriter.flush();
      sb.append(writer.toString());
   }

   /**
    * Appends the log message to the string builder.
    * 
    * @param    sb
    *           The string builder the message to be appended to.
    * @param    record
    *           The log record with the message or the message format and parameters.
    */
   static void dumpMessage(StringBuilder sb, LogRecord record)
   {
      if (!CentralLogger.WRITE_TO_SLF4J_API && record instanceof ContextLogRecord)
      {
         ContextLogRecord.LogContext context = ((ContextLogRecord) record).getLogContext();
         
         if (context != null)
         {
            boolean isContextAdded = false;
            
            if (context.getPid() != null)
            {
               sb.append("PID:").append(context.getPid());
               isContextAdded = true;
            }

            if (context.getThreadName() != null)
            {
               sb.append(isContextAdded ? ", " : "").append("ThreadName:").append(context.getThreadName());
               isContextAdded = true;
            }

            if (context.getSessionId() != null)
            {
               sb.append(isContextAdded ? ", " : "").append("Session:").append(context.getSessionId());
               isContextAdded = true;
            }

            if (context.getThreadId() != null)
            {
               sb.append(isContextAdded ? ", " : "").append("ThreadId:").append(context.getThreadId());
               isContextAdded = true;
            }

            if (context.getFwdUser() != null && !context.getFwdUser().equals(""))
            {
               sb.append(isContextAdded ? ", " : "").append("User:").append(context.getFwdUser());
            }

            sb.append(LOG_PARTS_DIVIDER);
         }
      }
      
      Object[] params = record.getParameters();
      if (params == null || params.length == 0)
      {
         sb.append(record.getMessage());
         return;
      }
      sb.append(String.format(record.getMessage(), params));
   }

   /**
    * Transforms the log record to string. A divider separates the different parts:
    * - datetime,
    * - log level,
    * - logger name,
    * - (optional) source class name, source method name,
    * - message (no divider after it),
    * - (optional) throwable.
    *
    * @param    record
    *           The log record.
    *           
    * @return   The string representation of the log record.
    */
   @Override
   public String format(LogRecord record)
   {
      StringBuilder sb = new StringBuilder(DATETIME_FORMATTER.format(new Date(record.getMillis())));
      sb.append(LOG_PARTS_DIVIDER);
      sb.append(LoggingUtil.addLeadingSymbols(record.getLevel().toString(), 7, ' '));
      sb.append(LOG_PARTS_DIVIDER);
      
      sb.append(record.getLoggerName());

      String sourceClassName = record.getSourceClassName();
      if (sourceClassName != null)
      {
         sb.append(" [").append(sourceClassName);
         String sourceMethodName = record.getSourceMethodName();
         if (sourceMethodName != null && !sourceMethodName.trim().isEmpty())
         {
            sb.append(".").append(sourceMethodName).append("()");
         }
         sb.append("]");
      }
      sb.append(LOG_PARTS_DIVIDER);

      dumpMessage(sb, record);
      sb.append(LINE_SEPARATOR);
      
      if (record.getThrown() != null)
      {
         dumpThrowable(sb, record.getThrown());
      }

      return sb.toString();
   }
}