How to create a circlular file logger with Timber

In some applications, I need to store my logs in a file aside of traditional logcat. For this, I am making use of Timber library. Because I don’t want to make my device full of logs, I wanted to use circular log files so that I can control the maximum amount of bytes taken by log data. To achieve this, I will use java Logger API to implement a new Timber.Tree. I also want some feature like log formatting and filtering.

All of this is implemented by Treessence library.

Log filtering

To implement filtering an interface is defined :

public interface Filter {

/**
* @param priority Log priority.
* @param tag Tag for log.
* @param message Formatted log message.
* @param t Accompanying exceptions.
* @return {@code true} if the log should be skipped, otherwise {@code false}.
* @see timber.log.Timber.Tree#log(int, String, String, Throwable)
*/
boolean skipLog(int priority, String tag, String message, Throwable t);

boolean isLoggable(int priority, String tag);
}

Priority filtering is provided by an implementation of this interface

public class PriorityFilter implements Filter {

private final int minPriority;

public PriorityFilter(int minPriority) {
this.minPriority = minPriority;
}

@Override
public boolean skipLog(int priority, String tag, String message, Throwable t) {
return priority < minPriority;
}

@Override
public boolean isLoggable(int priority, String tag) {
return priority >= minPriority;
}

public int getMinPriority() {
return minPriority;
}
}

We can now create our base class extending Timber.DebugTree

public class PriorityTree extends Timber.DebugTree {

private final PriorityFilter priorityFilter;
private Filter filter = NoFilter.INSTANCE;

/**
* @param priority priority from witch log will be logged
*/
public PriorityTree(int priority) {
this.priorityFilter = new PriorityFilter(priority);
}

/**
* Add additional {@link Filter}
*
* @param f Filter
* @return itself
*/
public PriorityTree withFilter(@NotNull Filter f) {
this.filter = f;
return this;
}

@Override
protected boolean isLoggable(int priority) {
return isLoggable("", priority);
}

@Override
public boolean isLoggable(String tag, int priority) {
return priorityFilter.isLoggable(priority, tag) && filter.isLoggable(priority, tag);
}

public PriorityFilter getPriorityFilter() {
return priorityFilter;
}

public Filter getFilter() {
return filter;
}

/**
* Use the additional filter to determine if this log needs to be skipped
*
* @param priority Log priority
* @param tag Log tag
* @param message Log message
* @param t Log throwable
* @return true if needed to be skipped or false
*/
protected boolean skipLog(int priority, String tag, @NotNull String message, Throwable t) {
return filter.skipLog(priority, tag, message, t);
}
}

This class can filter on two parameters :

  • First parameter is obviously log priority. This is done thanks to PriorityFilter instance.
  • Second parameter is an additional Filter instance that can be provided by caller.

Log formatting

Log formatting is obtained thanks to a Formatter class whose interface is defined as follow

public interface Formatter {

String format(int priority, String tag, String message);
}

Each formatter can display log to a defined format. For instance, logcat format is « MM-dd HH:mm:ss:SSS {priority}/{tag}({thread id}) : {message}\n ». Another format would be « {tag} : {message} »

Because we want to log in a file what we get in logcat, then we need to implement a logcat formatter

public class LogcatFormatter implements Formatter {

public static final LogcatFormatter INSTANCE = new LogcatFormatter();
private static final String SEP = " ";

private final HashMap<Integer, String> prioPrefixes = new HashMap<>();

private LogcatFormatter() {
prioPrefixes.put(Log.VERBOSE, "V/");
prioPrefixes.put(Log.DEBUG, "D/");
prioPrefixes.put(Log.INFO, "I/");
prioPrefixes.put(Log.WARN, "W/");
prioPrefixes.put(Log.ERROR, "E/");
prioPrefixes.put(Log.ASSERT, "WTF/");
}

@Override
public String format(int priority, String tag, @NotNull String message) {
String prio = prioPrefixes.get(priority);
if (prio == null) {
prio = "";
}
return TimeUtils.timestampToDate(System.currentTimeMillis(), "MM-dd HH:mm:ss:SSS")
+ SEP
+ prio
+ (tag == null ? "" : tag)
+ "(" + Thread.currentThread().getId() + ") :"
+ SEP
+ message
+ "\n";
}
}

Priority class can then be extended to add format functionality

/**
* Base class to format logs
*/
public class FormatterPriorityTree extends PriorityTree {
private Formatter formatter = getDefaultFormatter();

public FormatterPriorityTree(int priority) {
super(priority);
}

/**
* Set {
@link Formatter}
*
*
@param f formatter
*
@return itself
*/
public FormatterPriorityTree withFormatter(Formatter f) {
this.formatter = f;
return this;
}

/**
* Use its formatter to format log
*
*
@param priority Priority
*
@param tag Tag
*
@param message Message
*
@return Formatted log
*/
protected String format(int priority, String tag, @NotNull String message) {
return formatter.format(priority, tag, message);
}

/**
*
@return Default log {@link Formatter}
*/
protected Formatter getDefaultFormatter() {
return NoTagFormatter.INSTANCE;
}

@Override
protected void log(int priority, String tag, @NotNull String message, Throwable t) {
super.log(priority, tag, format(priority, tag, message), t);
}
}

File logging

We have seen how to filter and format logs. We can now start logging in file.

For this we need a java.util.logging.Logger instance. It will be used in conjunction with java.util.logging.FileHandler that do actual file logging. We will see how to create a Logger instance later.

public class FileLoggerTree extends FormatterPriorityTree {
private final Logger logger;

private FileLoggerTree(int priority,
Logger logger) {
super(priority);
this.logger = logger;
}
}

To activate logcat formatting by default, getDefaultFormatter() method is overridden


@Override
protected fr.bipi.tressence.common.formatter.Formatter getDefaultFormatter() {
return LogcatFormatter.INSTANCE;
}

We need to convert logcat level to java.util.logging.Level

private Level fromPriorityToLevel(int priority) {
switch (priority) {
case Log.VERBOSE:
return Level.FINER;
case Log.DEBUG:
return Level.FINE;
case Log.INFO:
return Level.INFO;
case Log.WARN:
return Level.WARNING;
case Log.ERROR:
return Level.SEVERE;
case Log.ASSERT:
return Level.SEVERE;
default:
return Level.FINEST;
}
}

Actual logging is done by this method

@Override
protected void log(int priority, String tag, @NotNull String message, Throwable t) {
if (skipLog(priority, tag, message, t)) {
return;
}

logger.log(fromPriorityToLevel(priority), format(priority, tag, message));
if (t != null) {
logger.log(fromPriorityToLevel(priority), "", t);
}
}

It is logging in using java.utils.logging API with log level conversion and logcat formatting

We haven’t seen how to provide the right logger. Let see how to configure it.

A builder class is defined to create a FileLoggerTree instance. This builder contains some default:

public static class Builder {
// 1 mb byte of data
private static final int SIZE_LIMIT = 1048576;
// Max 3 files for circular logging
private static final int NB_FILE_LIMIT = 3;

// Base filename.
// log index will be appended so actual file name will be
// "log.0" or "log.1"
// To parametrize where index is put, "%g" can be placed
// in file name. For instance "log%g.logcat" will give
// "log0.logcat", "log1.logcat" and so on
private String fileName = "log";
// Directory where files are stored
private String dir = "";
// Min priority to log from
private int priority = Log.INFO;
private int sizeLimit = SIZE_LIMIT;
private int fileLimit = NB_FILE_LIMIT;
// append log to already existing log file
private boolean appendToFile = true;

...

java.util.logging.Logger are created and managed by java.util.logging.LogManager. To bypass this a simple static class is used


/**
* Custom logger class that has no references to LogManager
*/
private static class MyLogger extends Logger {

/**
* Constructs a {@code Logger} object with the supplied name and resource
* bundle name; {@code notifyParentHandlers} is set to {@code true}.
* <p/>
* Notice : Loggers use a naming hierarchy. Thus "z.x.y" is a child of "z.x".
*
* @param name the name of this logger, may be {@code null} for anonymous
* loggers.
*/
MyLogger(String name) {
super(name, null);
}

public static Logger getLogger(String name) {
return new MyLogger(name);
}
}

Creation of java.util.logging.Logger can start

public FileLoggerTree build() throws IOException {
// Log file path
String path = FileUtils.combinePath(dir, fileName);
// File handler that is performing file logging
FileHandler fileHandler;
// Our custom logger
Logger logger = MyLogger.getLogger(TAG);
// We force level to ALL because priority filtering is
// done by our Tree implementation
logger.setLevel(Level.ALL);
// File handler can now be created
fileHandler = new FileHandler(path, sizeLimit, fileLimit, appendToFile);
// Formating is done by our Tree implementation
fileHandler.setFormatter(new NoFormatter());
// Configure java Logger
logger.addHandler(fileHandler);
// finally we got here !
return new FileLoggerTree(priority, logger);
}

Full code of FileLoggerTree is here : https://github.com/bastienpaulfr/Treessence/blob/master/treessence/src/main/java/fr/bipi/tressence/file/FileLoggerTree.java

This tree can then be planted like this

FileLoggerTree fileTree = FileLoggerTree.Builder()
.withFileName("log%g.logcat")
.withMinPriority(Log.VERBOSE)
.build()
Timber.plant(fileTree)

Thanks for reading this. Full source code is available here