Thursday, May 19, 2022

Java Tutorial: Create Loggers using java.util.logging Package

Chapters

Overview

Logging is an essential part of developing an application. It lets us diagnose applications with ease. There are thid-party loggers that we can export to java such as Log4j2. In this tutorial, we're gonna use java.util.logging package to create loggers.

Creating a Logger

Creating a logger is easy. We just need to invoke getLogger method from Logger class. Take a look at this example.
import java.util.logging.*;

public class SampleClass{

  //a Logger instance is preferrable to be referenced
  //to a global variable to prevent garbage collector
  //from collecting the logger's instance
  //in unexpected manner
  static Logger logger =
  Logger.getLogger(SampleClass.class.getName());
  
  public static void main(String[] args){
    
    try{
      int num = 1/0;
    }
    catch(ArithmeticException e){
      logger.log(Level.SEVERE, "Divisor is 0!", e);
    }
    
  }
}

Result
May 17, 2022 4:40:44 PM SampleClass main
SEVERE: Divisor is 0!
java.lang.ArithmeticException: / zero
          at SampleClass.main(SampleClass.java:10)
getLogger(String name) creates a logger with the specified name. If the logger with the specified name is already created, this method gets that logger instead of creating a new one.

Loggers are normally named, using a hierarchical dot-separated namespace. Logger names can be arbitrary strings, but they should normally be based on the package name or class name of the logged component, such as java.net or javax.swing.

log method logs a message. When the message is logged, it will be passed to logger's handler and the handler. What is a handler? handlers process the logged messages and output the messages to a console, file, etc. We will learn handlers later. In the example above, we didn't specify any handler to our logger. Thus, our logger uses the default handler which outputs messages to a console.

In the example above, log(Level level, String msg, Throwable thrown) method logged the date and time where the logging happened, the source class or the class where the log happened, the method name where the log happened, level which we will learn later, the message and the exception that we put in the log method.

log method has other variants: logp and logrb methods. logp or "log precise" method is just like log method but the source class and source method needs to be specified explicitly.

logrb method is just like logp method but this method accepts a ResourceBundle. Resource bundles contain locale-specific objects.

Some methods use MessageFormat style to format parameters in the params or param argument. For example, let's invoke this method: log(Level level, String msg, Object[] params)

logger.log(Level.SEVERE, "Causes: {0}, {1}, {2}", new String[]{"Divisor is 0!", "Illegal Division", "Undefined"});

If you put this code in the example above, the result would be:
May 17, 2022 4:40:44 PM SampleClass main
SEVERE: Causes: Divisor is 0!, Illegal Division, Undefined


If we just casually trying out some things and don't wanna create a logger, we can use the global logger. This logger is designed for testing purposes. To get the global logger, we can invoke Logger.getGlobal() or use GLOBAL_LOGGER_NAME field. For example:
//via method
Logger logger = Logger.getGlobal();

//via constant
Logger logger = 
logger.getLogger(Logger.GLOBAL_LOGGER_NAME);

//via String literal
Logger logger = 
logger.getLogger("global");
"global" is the name the refers to the global logger. In production, it's recommended to create logger per top-level class and add the class names to the loggers' name.

Levels

The Level class defines a set of standard logging levels that can be used to control logging output. These are the levels in descending order:
  • SEVERE (highest value)
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST (lowest value)
To get logger's level, invoke getLevel method from Logger class. To set logger's level, invoke setLevel(Level newLevel) from Logger class. Additionally, two other constants, apart from the constants above, can be used in setLevel method. These are:
  • OFF
  • ALL
Level.OFF means that all logging levels from FINEST to SEVERE are not loggable. Level.ALL is the opposite of Level.OFF. When we set a level to be loggable by invoking setLevel, all levels on top of the level that has been set will be loggable. For example, if I invoke setLevel like this: setLevel(Level.INFO). Then WARNING and SEVERE are also going to be loggable.

Note that loggers and handlers have their own levels. The level of a logger determines if a log can be sent to the handlers. The level of a handler determines if the log that has been received will be exported to a console, file, etc.

If the return value of setLevel method is null, it means that the handler or the logger will use the level of its parent logger. If a level is loggable, the handler or log accepts logs with the specified level. Otherwise, the log will be ignored.

According to the documentation, FINEST, FINER and FINE are used to log messages regarding tracing messages such as debug point or some kind of marker in message form. These levels are pretty optional and can be hidden.

CONFIG is used to log messages regarding configurations, INFO is used to log informational messages, WARNING is used to log potential problem and SEVERE is used to log serious failure such as exceptions. These levels are pretty important and should be watched closely especially the SEVERE level.

Logger Hierarchy

When we create a logger, that logger wil be added to the LogManager namespace. Java registers all created loggers to this namespace. However, there's a logger that can't be registered in the namespace and that logger is an anonymous logger.

One of the disadvantages of a logger that is not registered in the namespace is that that logger won't receive some checks like security checks which are done in the namespace. If you intend to bypass some checks then you may need to use anonymous logger to achieve your specific goal. To create an anonymous logger, invoke getAnonymousLogger method from Logger class.

Now, let's talk about logger hierarchy. When we create a logger, the logger will be automatically become a childen of root logger. Root logger is a logger that is closest to the LogManager. We will learn about LogManager later. The default level of root logger is INFO if the "Default global logging level" attribute in the configuration file of LogManager is not specified.

Root logger was the reason we could print logs on the console. By default, a newly created logger doesn't have a handler. If a logger doesn't have a specified handler, it will pass the log to the handler of its parent logger. Take note that if the logger and its parent have handlers, the log will be passed to the handlers of the logger and its parent.

If we want a log to only be passed to our logger, invoke setUseParentHandlers method and set its argument to false. In this way, the log won't be passed to the parent of our logger.

To gain access to the root logger, invoke Logger.getLogger() with "" argument. For example:

Logger logger = Logger.getLogger("");

Root logger doesn't have a display name. That's why when we invoke getName method in root logger the result is blank. If we want to get the parent logger of a logger, invoke getParent method for Logger class. If the return value of the method is null, the logger doesn't have a parent logger or the logger is the root logger because root logger doesn't have a parent logger.

Take note that even anonymous logger is not part of the logging namespace, its parent is still the root logger. If we want to set a parent logger to another logger, invoke setParent method from Logger class. This example demonstrates getParent and setParent methods.
import java.util.logging.*;

public class SampleClass{
  static Logger logger = 
  Logger.getLogger(SampleClass.class.getName());
  
  public static void main(String[] args){
    
    System.out.println("Name: " + logger.getName());
    System.out.println("Parent: " + 
    logger.getParent());
    System.out.println();
    
    new SampleClass();
  }
  
  SampleClass(){
    new Inner();
  }
  
  class Inner{
    Logger logger =
    Logger.getLogger(Inner.class.getName());
    
    Inner(){
       System.out.println("Name: " + logger.getName());
       System.out.println("Parent: " + logger.getParent());
       System.out.println();
       
       System.out.println("Change Parent...");
       logger.setParent(SampleClass.logger);
       System.out.println("Parent: " + 
       logger.getParent().getName());
    }
  }
}

Result(may vary)
Name: SampleClass
Parent: java.util.logging.LogManager$RootLogger@194...

Name: SampleClass$Inner
Parent: java.util.logging.LogManager$RootLogger@194...

Change Parent...
Parent: SampleClass

If our class is in a package, the return value of getName method would be: package-name.class-name. Take note that the LogManager namespace manages the hierarchy of registered loggers.

Thus, the namespace will be updated if we change the hierarchy of the registered loggers. In the namespace, Loggers are organized into a naming hierarchy based on their dot separated names. Thus "a.b.c" is a child of "a.b", but "a.b1" and a.b2" are peers.

Configuring LogManager

LogManager is used to maintain a set of shared state about Loggers and log services. In every JVM, there is a single global LogManager object that maintains every logger and log service in our application.

We can get that object by invoking LogManager.getLogManager method. The LogManager object is created during class initialization and cannot subsequently be changed. One of the important functionalities of LogManager is that it loads a configuration file. During LogManager initialization, a configuration file sets up our logger framework based on the content of the file.

A configuration file can be in a class file or properties file. To explicitly assign a configuration file to our logger framework, we can type these commands on our terminal/cmd:
load class file
java -Djava.util.logging.config.class="class path"

load properties file
java -Djava.util.logging.config.file="file path"
If a configuration file is not explicity set. Java will use the default logging.properties file. In java 8 and below, this file is located in $JAVA_HOME/jre/lib/ path. In java 9 and above, this file is located in $JAVA_HOME/conf/. $JAVA_HOME or %JAVA_HOME% (Windows) is a system variable that refers to the path where our JDK directory is located.

Now, let's create our own configuration file. First off, let's start with file configuration. Put this text in your custom properties file:

handlers = java.util.logging.ConsoleHandler

#ConsoleHandler initial configuration
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter

#initialize new logger
initLogger.level = CONFIG


I'll explain the content of the properties file later. For now, create a file and name it as "MyConfig.properties" and put the file in a directory that you like. Then compile this java code.
import java.util.logging.*;

public class LogTest{

  static Logger rootLogger =
  Logger.getLogger("");
  
  static Logger initLogger =
  Logger.getLogger("initLogger");
  
  static Logger globalLogger =
  Logger.getLogger(Logger.GLOBAL_LOGGER_NAME);
  
  public static void main(String[] args){
    System.out.println("Root: " + rootLogger.getLevel());
    System.out.println("User: " + initLogger.getLevel());
    System.out.println("Global: " + globalLogger.getLevel());
  }
}
After that, type this command:

java -Djava.util.logging.config.file=C:\test\logging-config-test\MyConfig.properties LogTest

The result would be:
Root: INFO
User: CONFIG
Global: null
Take note that this command is also valid:

java LogTest -Djava.util.logging.config.file=C:\test\logging-config-test\MyConfig.properties

However, our config file won't be loaded once our program runs. We need to load the config file first before running the program. Thus, order of commands matters.

Java will use the default logging.properties if our config file can't be found.java.util.logging.config.file system property doesn't look at classpath. That's why we use absolute path to refer to our custom config file.

Next, let's talk about the properties in a config file. First off, the "#" symbol means a comment. LogManager ignores characters that are after this symbol. "handlers" is a LogManager property that assigns a handler to the root logger. If you want to assign a handler to a specific logger apart from root logger, we use this property "<logger>.handlers".

For example, initLogger.handlers = java.util.logging.ConsoleHandler
Additionally, we can add multiple handlers in a logger. We separate the handlers using comma(,). For example:
handlers = java.util.logging.ConsoleHandler, java.util.logging.FileHandler
If you have a custom handler
handlers = MyPackage.MyHandler, java.util.logging.FileHandler

To initialize a new logger at startup, we just need to set one of the attributes of the new logger and java will create that logger for us. In the example above, I just write "initLogger.level = CONFIG" property and java creates a logger named "initLogger" and sets its level to CONFIG.

We can also set some properties of root and global logger in the config file. For example, if we want to change the level of the root logger, write this property: ".level = FINE". For global logger write this: "global.level = FINEST". Root and global loggers are automatically created by the LogManager.

In the discussion above, we now know that we can use "handlers" and "level" attributes to modify the handlers and levels of loggers. More information about properties of loggers can be found in the LogManager documentation.

"java.util.logging.ConsoleHandler" system property refers to the ConsoleHandler. We can initialize some attributes of ConsoleHandler in the config file such as level, filter, formatter and encoding. Take note that each instance of ConsoleHandler will copy the values of the properties assigned to it in the config file during their initialization.

If we have custom handler, we can initialize some of their attributes in the config file. For example:
//if your custom handler is in a package
MyPackage.MyHandler.level = FINE

//if your custom handler is not in a package
MyHandler.level = FINE
In this way, each instance of MyHandler will have a FINE level. If the specified attribute of your custom handler in the config file doesn't match any attribute in the implementation of your custom handler, the property that specified the attribute is invalid and will be ignored.

For example, your custom handler is a ConsoleHandler and you specify "MyHandler.maxLocks" property. In this case, "maxLocks" attribute is gonna be ignored. "maxLocks" is an attribute of FileHandler. For more information about properties of ConsoleHandler can be found in this documentation.

There are different types of handlers which we will learn in the next topic. We will also discuss formatters in another topic. Next, let's create a config file using class file. Write and compile this code:
import java.util.logging.*;

public class LoggerProperties{

  public LoggerProperties(){
    
    try{
      //use 
      //System.setProperty(String key, String value) method
      //to configure system property programatically
      //
      System.setProperty
      ("java.util.logging.ConsoleHandler.level","INFO");
    
      //initialize a logger
      Logger.getLogger("initLogger").
      setLevel(Level.CONFIG);
      
      //set global logger level
      Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).
      setLevel(Level.FINE);
    }
    catch(Exception e){
      e.printStackTrace();
    }
    
  }
}
After that, compile LogTest in the previous example and then type this command:

java -Djava.util.logging.config.class=LoggerProperties LogTest

And the result would be:
Root: INFO
User: CONFIG
Global: FINE
Use System.setProperty(String key, String value) to configure system properties. System properties are properties that are internally used by java. Unlike "java.util.logging.config.file" property, "java.util.logging.config.class" looks at classpath. That's why we can use a relative path relative to our program. Also, it's not required to include the .class file extension in the command.

There's another way to configure LogManager. We can use readConfiguration method in conjunction with "java.util.logging.config.file" property to directly load properties file in the classpath at startup. First off, compile this code:
import java.util.logging.*;
import java.io.InputStream;

public class LoggerProperties{

  public LoggerProperties(){
    
    //make variables final so they
    //can't be changed.
    //Thus, more security
    try{
      final LogManager lm = 
      LogManager.getLogManager();
      try(
      final InputStream is = 
      this.getClass().getResourceAsStream
      ("MyConfig.properties")){
        lm.readConfiguration(is);
      }
    }
    catch(Exception e){
      e.printStackTrace();
    }
    
  }
}
After that, compile LogTest in the previous example and then type this command:

java -Djava.util.logging.config.class=LoggerProperties LogTest

Assuming MyConfig.properties has this content:
global.level = FINEST

#initialize new logger
initLogger.level = CONFIG
The result would be:
Root: INFO
User: CONFIG
Global: FINEST
Custom Handler

There are different types of handlers. All handlers implement Handler class. Handler class has two direct subclasses: MemoryHandler and StreamHandler.

In this tutorial, we're gonna focus on StreamHandler. StreamHandler has three direct subclasses: ConsoleHandler, FileHandler and SocketHandler. In this tutorial, we're going to focus on ConsoleHandler and FileHandler

First off, let's create a custom ConsoleHandler. Write and compile this code:
import java.util.logging.*;
import java.io.IOException;

public class SampleClass{
  
  static Logger logger =
  Logger.getLogger("MyLogger");
  
  public static void main(String[] args){
    ConsoleHandler ch = 
    new CustomConsoleHandler();
    
    //add handler
    logger.addHandler(ch);
    
    //setLevel
    logger.setLevel(Level.FINE);
    ch.setLevel(Level.INFO);
    
    //this wil ensure that the log sent
    //to our logger won't be sent to 
    //its parent which is the root logger.
    //
    //If we don't do this, our console
    //will receive two identical messages.
    if(logger.getUseParentHandlers())
      logger.setUseParentHandlers(false);
    
    System.out.println("Logger Level: " + 
    logger.getLevel());
    System.out.println("Handler Level: " + 
    ch.getLevel());
    System.out.println();
    logger.log(Level.FINE, "This is fine!");
    
  }
}

class CustomConsoleHandler extends ConsoleHandler{

  @Override
  public void publish(LogRecord record){
    super.publish(logRecord(record));
  }
  
  private LogRecord logRecord(LogRecord log){
    if(log.getLevel() == Level.FINE){
      Level newLvl = Level.INFO;
      String message = 
      "\nOriginal Level: " + 
      log.getLevel() + "\n" +
      "New level: " + 
      newLvl + "\n" +
      "Message: " + "\n" +
      log.getMessage();
      
      
      LogRecord newLog = 
      new LogRecord(newLvl, message);
      return newLog;
    }
    else return log;
  }
}

Result
Logger Level: FINE
Handler Level: INFO

May 19, 2022 5:20:41 PM SampleClass main
INFO:
Original Level: FINE
New Level: INFO
Message:
This is fine!
We use addHandler method add handlers to our logger. Once our loggers creates a log, each handler in our logger will output the log. LogRecord contains information about a log that is logged by a logger. Once all information about the log is wrapped to a LogRecord, the LogRecord will be sent to the handlers.

Now, let's try a custom FileHandler. Write and compile this code:
import java.util.logging.*;
import java.io.IOException;

public class SampleClass{

  static Logger logger =
  Logger.getLogger("MyLogger");
  
  public static void main(String[] args)
                       throws IOException{
    FileHandler ch = 
    new CustomFileHandler();
    
    //add handler
    logger.addHandler(ch);
    
    //setLevel
    logger.setLevel(Level.FINE);
    ch.setLevel(Level.INFO);
    
    logger.log(Level.INFO, "Seems like trouble!");
    
  }
  
}

class CustomFileHandler extends FileHandler{

  CustomFileHandler() throws IOException{}
  
  @Override
  public void publish(LogRecord record){
    super.publish(logRecord(record));
  }
  
  private LogRecord logRecord(LogRecord log){
    if(log.getLevel() == Level.INFO){
      Level newLvl = Level.WARNING;
      String message =
      "\nOriginal Level: " +
      log.getLevel() + "\n" +
      "New level: " + 
      newLvl + "\n" +
      "Message: " + "\n" +
      log.getMessage();
      
      LogRecord newLog = 
      new LogRecord(newLvl, message);
      return newLog;
    }
    else return log;
  }
  
}

Result(On console)
May 19, 2022 7:37:57 PM SampleClass main
INFO: Seems like trouble!

Result(in file)
<?xml version="1.0" encoding="windows-1252" standalone="no"?>
<!DOCTYPE log SYSTEM "logger.dtd">
<log>
<record>
  <date>2022-05-19T11:37:57.256883500Z</date>
  <millis>1652960277256</millis>
  <nanos>883500</nanos>
  <sequence>1</sequence>
  <level>WARNING</level>
  <class>SampleClass</class>
  <method>main</method>
  <thread>1</thread>
  <message>
Original Level: INFO
New level: WARNING
Message: 
Seems like trouble!</message>
</record>
</log>
If you have been noticed, the date&time in the file and console are different. In my opinion, java.util.logging.SimpleTimeFormatter and java.util.logging.XMLFormatter use different timezones.

In the example above, I use the default logging.properties configuration file. Let's examine some properties of FileHandler used in the configuration file.

<handler-name>.pattern specifies a pattern for generating the output file name. The default value of this property is "%h/java%u.log". "%h" denotes "user.home" system property in linux. In windows, it denotes "C:\Users\%USERNAME%" system property.

"%u" denotes a unique number. This unique number is used to resolve conflicts between log files. This ensures that each generated log files during runtime is unique to one another.

<handler-name>.formatter specifies the name of a Formatter class to use. The default value of this property is "java.util.logging.XMLFormatter". That's why the result in the example above in file is in XML format. More information about FileHandler properties can be found in the documentation.

If you just want to instantiate a handler, just use their default constructors. For example:
//Instantiate default ConsoleHandler
ConsoleHandler ch = new ConsoleHandler();

//Instantiate default FileHandler
FileHandler fh = new FileHandler();

Custom Formatter

Java provided two formatters that are used by default. java.util.logging.SimpleFormatter system property is used by ConsoleHandler as its default formatter. java.util.logging.XMLFormatter system property is used by FileHandler as its default formatter. If those formatters are not enough, we can override java.util.logging.Formatter to create our custom formatter.

Write and compile this code:
import java.util.logging.*;
import java.util.Locale;
import java.time.format.*;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;

public class SampleClass{
  static Logger logger =
  Logger.getLogger("MyLogger");
  
  public static void main(String[] args){
    ConsoleHandler ch = new ConsoleHandler();
    
    //use set
    ch.setFormatter(new CustomFormatter());
    
    logger.addHandler(ch);
    logger.setUseParentHandlers(false);
    
    try{
      int num = 1/0;
    }
    catch(ArithmeticException e){
      logger.log(Level.SEVERE,
      "There's a problem here!", e);
    }
    
  }
}

class CustomFormatter extends Formatter{

  @Override
  public String format(LogRecord record){
    LocalDateTime ldt = 
    LocalDateTime.ofInstant(Instant.ofEpochMilli(
    record.getMillis()), ZoneId.of("Asia/Taipei"));
    
    String output = 
    "Date: " + 
    ldt.format(DateTimeFormatter.
    ofPattern("MM/dd/uu hh:mm:ss", 
               Locale.US))+ "\n" +
    "Thread ID: " + 
    record.getLongThreadID() + "\n" +
    "Source Class: " + 
    record.getSourceClassName() + "\n" +
    "Source Method: " + 
    record.getSourceMethodName() + "\n" +
    "Logger Name: " + 
    record.getLoggerName() + "\n" +
    "Log Level: " +
    record.getLevel() + "\n" +
    "Log Message: " +
    record.getMessage() + "\n";
    
    Throwable throwable = record.getThrown();
    if(throwable != null)
      output = output.concat
      ("Exception\n" + throwable);
    
    return output;
  }
}

Result
Date: 05/19/22 10:17:03
Thread ID: 1
Source Class: SampleClass
Source Method: main
Logger Name: MyLogger
Log Level: SEVERE
Log Message: There's a problem here!
Exception
java.lang.ArithmeticException: / by zero
Take note that if you change your formatter, you need to recreate some of the formatting of the default formatter if you need them. For example, log, logp and logrb methods with param1 or params parameters won't follow MessageFormat formatting if we use our custom formatter. To enable that feature to our formatter, we need to recreate it.

Custom Filter

By default, loggers and handlers don't have filters. However, we can create our custom filter that filters loggers and handlers. This example demonstrates adding filter to a logger.
import java.util.logging.*;

public class SampleClass{

  static Logger logger =
  Logger.getLogger("MyLogger");
  
  public static void main(String[] args){
    logger.setFilter(new MyFilter());
    
    logger.log(Level.INFO, "Good Line1");
    logger.log(Level.INFO, "Bad Line2");
    logger.log(Level.INFO, "Bad Line3");
    logger.log(Level.INFO, "Good Line4");
    logger.log(Level.INFO, "Bad Line5");
  }
}

class MyFilter implements Filter{
  
  @Override
  public boolean isLoggable(LogRecord record){
    return record.getMessage().startsWith("Good");
  }
}

Result
May 20, 2022 12:14:05 AM SampleClass main
INFO: Good Line1
May 20, 2022 12:14:05 AM SampleClass main
INFO: Good Line4
Take note that a filter can be applied to a logger, handler or both. If a filter is applied to a logger, logs are gonna be filtered before sending them to a handler. If a filter is applied to a handler, received logs are gonna be filtered before sending them to a console, file, etc.

Log Entry and Return of Methods

There are two convenient methods that we can use to log method entry and return. These methods are entering and exiting methods from Logger class. These methods have two overloaded forms. This example demonstrates their overloaded forms with two parameters.
import java.util.logging.*;

public class SampleClass{
  private static final String className = 
  SampleClass.class.getName();

  static Logger logger = 
  Logger.getLogger
  (className);
  
  public static void main(String[] args){
    ConsoleHandler ch = new ConsoleHandler();
    logger.setLevel(Level.FINER);
    ch.setLevel(Level.FINER);
    logger.addHandler(ch);
    
    ClassA ca = new ClassA();
    logger.entering(className, "main");
    ca.classAMeth();
    logger.exiting(className, "main");
  }
}

class ClassA{
  private final String className = 
  ClassA.class.getName();
  
  Logger logger = 
  Logger.getLogger(className);
  
  ClassA(){
    ConsoleHandler ch = new ConsoleHandler();
    logger.setLevel(Level.FINER);
    ch.setLevel(Level.FINER);
    logger.addHandler(ch);
  }
  
  void classAMeth(){
    logger.entering(className, "classAMeth");
    System.out.println("Class A Method!");
    logger.exiting(className, "classAMeth");
  }
}

Result
May 20, 2022 1:22:42 AM SampleClass main
FINER: ENTRY
May 20, 2022 1:22:42 AM SampleClass classAMeth
FINER: ENTRY
Class A Method!
May 20, 2022 1:22:42 AM SampleClass classAMeth
FINER: RETURN
May 20, 2022 1:22:42 AM SampleClass main
FINER: RETURN
In the example above, I logged the pre-entry and entry of execution in classAMeth; pre-return and return of execution from classAMeth and main. Take note that the logs of these two methods are loggable in FINER level. Thus, your logger and handler must be in FINER level or level lower than FINER.

Other forms of these two methods can be found in the documentation.

No comments:

Post a Comment