/**
* Logback: the reliable, generic, fast and flexible logging framework.
* Copyright (C) 1999-2013, QOS.ch. All rights reserved.
*
* This program and the accompanying materials are dual-licensed under
* either the terms of the Eclipse Public License v1.0 as published by
* the Eclipse Foundation
*
* or (per the licensee's choosing)
*
* under the terms of the GNU Lesser General Public License version 2.1
* as published by the Free Software Foundation.
*/
package ch.qos.logback.classic.android;
import java.io.File;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteStatement;
import ch.qos.logback.classic.db.SQLBuilder;
import ch.qos.logback.classic.db.names.DBNameResolver;
import ch.qos.logback.classic.db.names.DefaultDBNameResolver;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.IThrowableProxy;
import ch.qos.logback.classic.spi.StackTraceElementProxy;
import ch.qos.logback.classic.spi.ThrowableProxyUtil;
import ch.qos.logback.core.CoreConstants;
import ch.qos.logback.core.UnsynchronizedAppenderBase;
import ch.qos.logback.core.android.CommonPathUtil;
import ch.qos.logback.core.util.Duration;
/**
* SQLiteAppender is a logback appender optimized for Android SQLite. It requires no JDBC
* as it uses the built-in Android SQLite API.
*
* @author Anthony Trinh
* @since 1.0.11
*/
public class SQLiteAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {
private SQLiteDatabase db;
private String insertPropertiesSQL;
private String insertExceptionSQL;
private String insertSQL;
private String filename;
private DBNameResolver dbNameResolver;
private Duration maxHistory;
private long lastCleanupTime = 0;
private SQLiteLogCleaner logCleaner;
/**
* Sets the database name resolver, used to customize the names of the table names
* and columns in the database.
*
* @param dbNameResolver the desired database name resolver
*/
public void setDbNameResolver(DBNameResolver dbNameResolver) {
this.dbNameResolver = dbNameResolver;
}
/**
* Get the maximum history in time duration of records to keep
*
* @return max history in time duration (e.g., "1 day")
*/
public String getMaxHistory() {
return maxHistory != null ? maxHistory.toString() : "";
}
/**
* Gets the maximum history in milliseconds
* @return the max history in milliseconds
*/
public long getMaxHistoryMs() {
return maxHistory != null ? maxHistory.getMilliseconds() : 0;
}
/**
* Set the maximum history in time duration of records to keep
*
* @param maxHistory
* max history in time duration (e.g., "2 days")
*/
public void setMaxHistory(String maxHistory) {
this.maxHistory = Duration.valueOf(maxHistory);
}
/**
* Gets the absolute path to the SQLite database
* @return
*/
public String getFilename() {
return this.filename;
}
/**
* Sets the path to the destination SQLite database
* @param filename absolute path to file
*/
public void setFilename(String filename) {
this.filename = filename;
}
/**
* Gets a file object from a file path to a SQLite database
* @param filename absolute path to database file
* @return the file object if a valid file found; otherwise, null
*/
public File getDatabaseFile(String filename) {
File dbFile = null;
if (filename != null && filename.trim().length() > 0) {
dbFile = new File(filename);
}
if (dbFile == null || dbFile.isDirectory()) {
if (getContext() != null) {
final String packageName = getContext().getProperty(CoreConstants.PACKAGE_NAME_KEY);
if (packageName != null && packageName.trim().length() > 0) {
dbFile = new File(CommonPathUtil.getDatabaseDirectoryPath(packageName), "logback.db");
}
} else {
dbFile = null;
}
}
return dbFile;
}
/*
* (non-Javadoc)
* @see ch.qos.logback.core.UnsynchronizedAppenderBase#start()
*/
@Override
public void start() {
this.started = false;
File dbfile = getDatabaseFile(this.filename);
if (dbfile == null) {
addError("Cannot determine database filename");
return;
}
boolean dbOpened = false;
try {
dbfile.getParentFile().mkdirs();
addInfo("db path: " + dbfile.getAbsolutePath());
this.db = SQLiteDatabase.openOrCreateDatabase(dbfile.getPath(), null);
dbOpened = true;
} catch (SQLiteException e) {
addError("Cannot open database", e);
}
if (dbOpened) {
if (dbNameResolver == null) {
dbNameResolver = new DefaultDBNameResolver();
}
insertExceptionSQL = SQLBuilder.buildInsertExceptionSQL(dbNameResolver);
insertPropertiesSQL = SQLBuilder.buildInsertPropertiesSQL(dbNameResolver);
insertSQL = SQLBuilder.buildInsertSQL(dbNameResolver);
try {
this.db.execSQL(SQLBuilder.buildCreateLoggingEventTableSQL(dbNameResolver));
this.db.execSQL(SQLBuilder.buildCreatePropertyTableSQL(dbNameResolver));
this.db.execSQL(SQLBuilder.buildCreateExceptionTableSQL(dbNameResolver));
clearExpiredLogs(this.db);
super.start();
this.started = true;
} catch (SQLiteException e) {
addError("Cannot create database tables", e);
}
}
}
/**
* Removes expired logs from the database
* @param db
*/
private void clearExpiredLogs(SQLiteDatabase db) {
if (lastCheckExpired(this.maxHistory, this.lastCleanupTime)) {
this.lastCleanupTime = System.currentTimeMillis();
this.getLogCleaner().performLogCleanup(db, this.maxHistory);
}
}
/**
* Determines whether it's time to clear expired logs
* @param expiry max time duration between checks
* @param lastCleanupTime timestamp (ms) of last cleanup
* @return true if last check has expired
*/
private boolean lastCheckExpired(Duration expiry, long lastCleanupTime) {
boolean isExpired = false;
if (expiry != null && expiry.getMilliseconds() > 0) {
final long now = System.currentTimeMillis();
final long timeDiff = now - lastCleanupTime;
isExpired = (lastCleanupTime <= 0) || (timeDiff >= expiry.getMilliseconds());
}
return isExpired;
}
/**
* Gets the {@code SQLiteLogCleaner} in use. Creates default if needed.
*/
public SQLiteLogCleaner getLogCleaner() {
if (this.logCleaner == null) {
this.logCleaner = new SQLiteLogCleaner() {
@Override
public void performLogCleanup(SQLiteDatabase db, Duration expiry) {
final long expiryMs = System.currentTimeMillis() - expiry.getMilliseconds();
final String deleteExpiredLogsSQL = SQLBuilder.buildDeleteExpiredLogsSQL(dbNameResolver, expiryMs);
db.execSQL(deleteExpiredLogsSQL);
}
};
}
return this.logCleaner;
}
/**
* Sets the {@code SQLiteLogCleaner}, invoked when {@code maxHistory} is exceeded
* at startup and in between logging events
* @param logCleaner
*/
public void setLogCleaner(SQLiteLogCleaner logCleaner) {
this.logCleaner = logCleaner;
}
/*
* (non-Javadoc)
* @see java.lang.Object#finalize()
*/
@Override
protected void finalize() throws Throwable {
this.db.close();
}
/*
* (non-Javadoc)
* @see ch.qos.logback.core.UnsynchronizedAppenderBase#stop()
*/
@Override
public void stop() {
this.db.close();
this.lastCleanupTime = 0;
}
/*
* (non-Javadoc)
* @see ch.qos.logback.core.UnsynchronizedAppenderBase#append(java.lang.Object)
*/
@Override
public void append(ILoggingEvent eventObject) {
if (isStarted()) {
try {
clearExpiredLogs(db);
SQLiteStatement stmt = db.compileStatement(insertSQL);
try {
db.beginTransaction();
long eventId = subAppend(eventObject, stmt);
if (eventId != -1) {
secondarySubAppend(eventObject, eventId);
db.setTransactionSuccessful();
}
} finally {
if (db.inTransaction()) {
db.endTransaction();
}
stmt.close();
}
} catch (Throwable e) {
addError("Cannot append event", e);
}
}
}
/**
* Inserts the main details of a log event into the database
*
* @param event the event to insert
* @param insertStatement the SQLite statement used to insert the event
* @return the row ID of the newly inserted event; -1 if the insertion failed
* @throws SQLException
*/
private long subAppend(ILoggingEvent event, SQLiteStatement insertStatement) throws SQLException {
bindLoggingEvent(insertStatement, event);
bindLoggingEventArguments(insertStatement, event.getArgumentArray());
// This is expensive... should we do it every time?
bindCallerData(insertStatement, event.getCallerData());
long insertId = -1;
try {
insertId = insertStatement.executeInsert();
} catch (SQLiteException e) {
addWarn("Failed to insert loggingEvent", e);
}
return insertId;
}
/**
* Updates an existing row of an event with the secondary details of the event.
* This includes MDC properties and any exception information.
*
* @param event the event containing the details to insert
* @param eventId the row ID of the event to modify
* @throws SQLException
*/
private void secondarySubAppend(ILoggingEvent event, long eventId) throws SQLException {
Map<String, String> mergedMap = mergePropertyMaps(event);
insertProperties(mergedMap, eventId);
if (event.getThrowableProxy() != null) {
insertThrowable(event.getThrowableProxy(), eventId);
}
}
private static final int TIMESTMP_INDEX = 1;
private static final int FORMATTED_MESSAGE_INDEX = 2;
private static final int LOGGER_NAME_INDEX = 3;
private static final int LEVEL_STRING_INDEX = 4;
private static final int THREAD_NAME_INDEX = 5;
private static final int REFERENCE_FLAG_INDEX = 6;
private static final int ARG0_INDEX = 7;
// private static final int ARG1_INDEX = 8;
// private static final int ARG2_INDEX = 9;
// private static final int ARG3_INDEX = 10;
private static final int CALLER_FILENAME_INDEX = 11;
private static final int CALLER_CLASS_INDEX = 12;
private static final int CALLER_METHOD_INDEX = 13;
private static final int CALLER_LINE_INDEX = 14;
// private static final int EVENT_ID_INDEX = 15;
/**
* Binds the main details of a log event to a SQLite statement's parameters
*
* @param stmt the SQLite statement to modify
* @param event the event containing the details to bind
* @throws SQLException
*/
private void bindLoggingEvent(SQLiteStatement stmt, ILoggingEvent event) throws SQLException {
stmt.bindLong(TIMESTMP_INDEX, event.getTimeStamp());
stmt.bindString(FORMATTED_MESSAGE_INDEX, event.getFormattedMessage());
stmt.bindString(LOGGER_NAME_INDEX, event.getLoggerName());
stmt.bindString(LEVEL_STRING_INDEX, event.getLevel().toString());
stmt.bindString(THREAD_NAME_INDEX, event.getThreadName());
stmt.bindLong(REFERENCE_FLAG_INDEX, computeReferenceMask(event));
}
/**
* Binds a logging event's arguments (e.g., <code>logger.debug("x={} y={}", arg1, arg2)</code>)
* to a SQLite statement's parameters
*
* @param stmt the SQLite statement to modify
* @param argArray the argument array to bind
* @throws SQLException
*/
private void bindLoggingEventArguments(SQLiteStatement stmt, Object[] argArray) throws SQLException {
int arrayLen = argArray != null ? argArray.length : 0;
for (int i = 0; i < arrayLen && i < 4; i++) {
stmt.bindString(ARG0_INDEX+i, asStringTruncatedTo254(argArray[i]));
}
//
// // set remaining columns to ""
// for (int i = arrayLen; i < 4; i++) {
// stmt.bindString(ARG0_INDEX+i, "");
// }
}
/**
* Gets the first 254 characters of an object's string representation. This is
* used to truncate a logging event's argument binding if necessary.
*
* @param o the object
* @return up to 254 characters of the object's string representation; or empty
* string if the object string is itself null
*/
private String asStringTruncatedTo254(Object o) {
String s = null;
if (o != null) {
s = o.toString();
}
if (s != null && s.length() > 254) {
s = s.substring(0, 254);
}
return s == null ? "" : s;
}
private static final short PROPERTIES_EXIST = 0x01;
private static final short EXCEPTION_EXISTS = 0x02;
/**
* Computes the reference mask for a logging event, including
* flags to indicate whether MDC properties or exception info
* is available for the event.
*
* @param event the logging event to evaluate
* @return the 16-bit reference mask
*/
private static short computeReferenceMask(ILoggingEvent event) {
short mask = 0;
int mdcPropSize = 0;
if (event.getMDCPropertyMap() != null) {
mdcPropSize = event.getMDCPropertyMap().keySet().size();
}
int contextPropSize = 0;
if (event.getLoggerContextVO().getPropertyMap() != null) {
contextPropSize = event.getLoggerContextVO().getPropertyMap().size();
}
if (mdcPropSize > 0 || contextPropSize > 0) {
mask = PROPERTIES_EXIST;
}
if (event.getThrowableProxy() != null) {
mask |= EXCEPTION_EXISTS;
}
return mask;
}
/**
* Merges a log event's properties with the properties of the logger context.
* The context properties are first in the map, and then the event's properties
* are appended.
*
* @param event the logging event to evaluate
* @return the merged properties map
*/
private Map<String, String> mergePropertyMaps(ILoggingEvent event) {
Map<String, String> mergedMap = new HashMap<String, String>();
// we add the context properties first, then the event properties, since
// we consider that event-specific properties should have priority over
// context-wide properties.
Map<String, String> loggerContextMap = event.getLoggerContextVO().getPropertyMap();
if (loggerContextMap != null) {
mergedMap.putAll(loggerContextMap);
}
Map<String, String> mdcMap = event.getMDCPropertyMap();
if (mdcMap != null) {
mergedMap.putAll(mdcMap);
}
return mergedMap;
}
/**
* Updates an existing row with property details (context properties and event's properties).
*
* @param mergedMap the properties of the context plus the event's properties
* @param eventId the row ID of the event
* @throws SQLException
*/
private void insertProperties(Map<String, String> mergedMap, long eventId) throws SQLException {
if (mergedMap.size() > 0) {
SQLiteStatement stmt = db.compileStatement(insertPropertiesSQL);
try {
for (Entry<String,String> entry : mergedMap.entrySet()) {
stmt.bindLong(1, eventId);
stmt.bindString(2, entry.getKey());
stmt.bindString(3, entry.getValue());
stmt.executeInsert();
}
} finally {
stmt.close();
}
}
}
/**
* Binds the calling function's details (filename, line, etc.) to a SQLite statement's arguments
*
* @param stmt the SQLite statement to modify
* @param callerDataArray the caller's stack trace
* @throws SQLException
*/
private void bindCallerData(SQLiteStatement stmt, StackTraceElement[] callerDataArray) throws SQLException {
if (callerDataArray != null && callerDataArray.length > 0) {
StackTraceElement callerData = callerDataArray[0];
if (callerData != null) {
stmt.bindString(CALLER_FILENAME_INDEX, callerData.getFileName());
stmt.bindString(CALLER_CLASS_INDEX, callerData.getClassName());
stmt.bindString(CALLER_METHOD_INDEX, callerData.getMethodName());
stmt.bindString(CALLER_LINE_INDEX, Integer.toString(callerData.getLineNumber()));
}
}
}
/**
* Inserts an exception into the logging_exceptions table
*
* @param stmt
* @param txt
* @param i
* @param eventId
*/
private void insertException(SQLiteStatement stmt, String txt, short i, long eventId) throws SQLException {
stmt.bindLong(1, eventId);
stmt.bindLong(2, i);
stmt.bindString(3, txt);
stmt.executeInsert();
}
private void insertThrowable(IThrowableProxy tp, long eventId) throws SQLException {
SQLiteStatement stmt = db.compileStatement(insertExceptionSQL);
try {
short baseIndex = 0;
while (tp != null) {
StringBuilder buf = new StringBuilder();
ThrowableProxyUtil.subjoinFirstLine(buf, tp);
insertException(stmt, buf.toString(), baseIndex++, eventId);
int commonFrames = tp.getCommonFrames();
StackTraceElementProxy[] stepArray = tp.getStackTraceElementProxyArray();
for (int i = 0; i < stepArray.length - commonFrames; i++) {
StringBuilder sb = new StringBuilder();
sb.append(CoreConstants.TAB);
ThrowableProxyUtil.subjoinSTEP(sb, stepArray[i]);
insertException(stmt, sb.toString(), baseIndex++, eventId);
}
if (commonFrames > 0) {
StringBuilder sb = new StringBuilder();
sb.append(CoreConstants.TAB)
.append("... ")
.append(commonFrames)
.append(" common frames omitted");
insertException(stmt, sb.toString(), baseIndex++, eventId);
}
tp = tp.getCause();
}
} finally {
stmt.close();
}
}
}