package org.lantern.exceptional4j; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.io.StringWriter; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Queue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.zip.GZIPOutputStream; import org.apache.commons.io.FileSystemUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.SystemUtils; import org.apache.commons.lang3.math.NumberUtils; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpHeaders; import org.apache.http.HttpResponse; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.util.EntityUtils; import org.apache.log4j.AppenderSkeleton; import org.apache.log4j.Level; import org.apache.log4j.Priority; import org.apache.log4j.spi.LocationInfo; import org.apache.log4j.spi.LoggingEvent; import org.apache.log4j.spi.ThrowableInformation; import org.json.simple.JSONArray; import org.json.simple.JSONObject; /** * Log4J appender that sends data to Exceptional. */ public class ExceptionalAppender extends AppenderSkeleton { private final Collection<Bug> recentBugs = Collections.synchronizedSet(new LinkedHashSet<Bug>()); private final ExecutorService pool = Executors.newSingleThreadExecutor(); private final ExceptionalAppenderCallback callback; private final boolean threaded; private final String apiKey; private final Priority reportingLevel; private final boolean active; private final HttpStrategy httpClient; private final Queue<Sanitizer> sanitizers = new LinkedBlockingQueue<Sanitizer>(); /** * Creates a new appender. * * @param apiKey Your API key. */ public ExceptionalAppender(final String apiKey) { this(apiKey, Level.WARN); } /** * Creates a new appender with the specified minimum log level to report. * * @param apiKey Your API key. * @param reportingLevel The log4j level to report errors at. Anything * logged at the specified level or above will be reported. If you set * the reportingLevel to Level.WARN, for example, all warn level logs will * be sent along with any more severe logs such as ERROR and FATAL. */ public ExceptionalAppender(final String apiKey, final Priority reportingLevel) { this(apiKey, new ExceptionalAppenderCallback() { public boolean addData(final JSONObject json, final LoggingEvent le) { return true; } }, reportingLevel); } /** * Creates a new appender with callback. * * @param apiKey Your API key. * @param callback The class to call for modifications prior to submitting * the bug. */ public ExceptionalAppender(final String apiKey, final ExceptionalAppenderCallback callback) { this(apiKey, callback, new DefaultHttpClient()); } /** * Creates a new appender with callback. * * @param apiKey Your API key. * @param callback The class to call for modifications prior to submitting * the bug. */ public ExceptionalAppender(final String apiKey, final ExceptionalAppenderCallback callback, final HttpStrategy httpClient) { this(apiKey, callback, true, Level.WARN, httpClient); } /** * Creates a new appender with callback. * * @param apiKey Your API key. * @param callback The class to call for modifications prior to submitting * the bug. */ public ExceptionalAppender(final String apiKey, final ExceptionalAppenderCallback callback, final HttpClient httpClient) { this(apiKey, callback, true, Level.WARN, wrap(httpClient)); } /** * Creates a new appender with callback. * * @param apiKey Your API key. * @param callback The class to call for modifications prior to submitting * the bug. * @param reportingLevel The log4j level to report errors at. Anything * logged at the specified level or above will be reported. If you set * the reportingLevel to Level.WARN, for example, all warn level logs will * be sent along with any more severe logs such as ERROR and FATAL. */ public ExceptionalAppender(final String apiKey, final ExceptionalAppenderCallback callback, final Priority reportingLevel) { this(apiKey, callback, true, reportingLevel, wrap(new DefaultHttpClient())); } /** * Creates a new appender with a flag for whether or not to thread * submissions. Not threading can be useful for testing in particular. * * @param apiKey Your API key. * @param threaded Whether or not to thread submissions to Exceptional. */ public ExceptionalAppender(final String apiKey, final boolean threaded) { this(apiKey, new ExceptionalAppenderCallback() { public boolean addData(final JSONObject json, final LoggingEvent le) { return true; } }, threaded, Level.WARN, wrap(new DefaultHttpClient())); } /** * Creates a new appender with callback. * * @param apiKey Your API key. * @param callback The class to call for modifications prior to submitting * the bug. * @param threaded Whether or not to thread submissions to Exceptional. * @param reportingLevel The log4j level to report errors at. Anything * logged at the specified level or above will be reported. If you set * the reportingLevel to Level.WARN, for example, all warn level logs will * be sent along with any more severe logs such as ERROR and FATAL. */ public ExceptionalAppender(final String apiKey, final ExceptionalAppenderCallback callback, final boolean threaded, final Priority reportingLevel, final HttpStrategy httpClient) { this.apiKey = apiKey; this.callback = callback; this.threaded = threaded; this.reportingLevel = reportingLevel; this.httpClient = httpClient; if (this.apiKey.equals(ExceptionalUtils.NO_OP_KEY)) { this.active = false; } else { this.active = true; } if (this.httpClient == null) { throw new NullPointerException("Null HTTP client?"); } } /** * Add a {@link Sanitizer} to the list of sanitizers used to clean strings * prior to sending them to Exceptional. */ public void addSanitizer(Sanitizer sanitizer) { sanitizers.add(sanitizer); } private static HttpStrategy wrap(final HttpClient hc) { return new HttpStrategy() { public HttpResponse execute(HttpGet request) throws ClientProtocolException, IOException { return hc.execute(request); } public HttpResponse execute(HttpPost request) throws ClientProtocolException, IOException { return hc.execute(request); } }; } @Override public void append(final LoggingEvent le) { // Only submit the bug under certain conditions. if (!active) { System.out.println("Exceptional reporting is not active"); return; } if (submitBug(le)) { // Just submit it to the thread pool to avoid holding up the calling // thread. if (threaded) { this.pool.submit(new BugRunner(le)); } else { new BugRunner(le).run(); } } } private boolean submitBug(final LoggingEvent le) { // Ignore plain old logs. if (!le.getLevel().isGreaterOrEqual(this.reportingLevel)) { return false; } final LocationInfo li = le.getLocationInformation(); final Bug lastBug = new Bug(li); if (recentBugs.contains(lastBug)) { // Don't send duplicates. This should be configurable, but we // want to avoid hammering the server. return false; } synchronized (this.recentBugs) { // Remove the oldest bug. if (this.recentBugs.size() >= 200) { final Bug lastIn = this.recentBugs.iterator().next(); this.recentBugs.remove(lastIn); } recentBugs.add(lastBug); return true; } } public void close() { } public boolean requiresLayout() { return false; } private final class BugRunner implements Runnable { private final LoggingEvent loggingEvent; private BugRunner(final LoggingEvent le) { this.loggingEvent = le; } public void run() { try { submitBug(this.loggingEvent); } catch (final Throwable t) { System.err.println("Error submitting bug: " + t); } } private void submitBug(final LoggingEvent le) { System.err.println("Starting to submit bug..."); final JSONObject json = new JSONObject(); json.put("request", requestData(le)); final JSONObject appData = new JSONObject(); appData.put("application_root_directory", "/"); final JSONObject env = getEnv(le); appData.put("env", env); json.put("application_environment", appData); json.put("exception", exceptionData(le)); json.put("client", clientData(le)); if (callback.addData(env, le)) { final String jsonStr = json.toJSONString(); System.out.println("JSON:\n"+jsonStr); submitData(jsonStr); } } } private void submitData(final String requestBody) { System.out.println("Submitting data..."); final String url = "https://www.exceptional.io/api/errors?" + "api_key="+this.apiKey+"&protocol_version=6"; final HttpPost post = new HttpPost(url); post.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); final ByteArrayOutputStream baos = new ByteArrayOutputStream(); GZIPOutputStream gos = null; InputStream is = null; try { gos = new GZIPOutputStream(baos); gos.write(requestBody.getBytes("UTF-8")); gos.close(); post.setEntity(new ByteArrayEntity(baos.toByteArray())); System.err.println("Sending data to server..."); final HttpResponse response = this.httpClient.execute(post); System.err.println("Sent data to server..."); final int statusCode = response.getStatusLine().getStatusCode(); final HttpEntity responseEntity = response.getEntity(); is = responseEntity.getContent(); if (statusCode < 200 || statusCode > 299) { final String body = IOUtils.toString(is); InputStream bais = null; OutputStream fos = null; try { bais = new ByteArrayInputStream(body.getBytes()); fos = new FileOutputStream(new File("bug_error.html")); IOUtils.copy(bais, fos); } finally { IOUtils.closeQuietly(bais); IOUtils.closeQuietly(fos); } //System.err.println("Could not send bug:\n" // + method.getStatusLine() + "\n" + body); final Header[] headers = response.getAllHeaders(); for (int i = 0; i < headers.length; i++) { System.err.println(headers[i]); } return; } // We always have to read the body. EntityUtils.consume(responseEntity); } catch (final IOException e) { System.err.println("\n\nERROR::IO error connecting to server" + e); System.out.println(dumpStack(e)); } catch (final Throwable e) { System.err.println("Got error\n" + e); System.out.println(dumpStack(e)); } finally { IOUtils.closeQuietly(is); IOUtils.closeQuietly(gos); post.reset(); } } private JSONObject requestData(final LoggingEvent le) { final JSONObject json = new JSONObject(); return json; } private JSONObject getEnv(final LoggingEvent le) { final JSONObject json = new JSONObject(); final LocationInfo li = le.getLocationInformation(); final int lineNumber; final String ln = li.getLineNumber(); if (NumberUtils.isNumber(ln)) { lineNumber = Integer.parseInt(ln); } else { lineNumber = -1; } json.put("message", le.getMessage().toString()); json.put("logLevel", le.getLevel().toString()); json.put("methodName", li.getMethodName()); json.put("lineNumber", lineNumber); json.put("threadName", le.getThreadName()); json.put("javaVersion", SystemUtils.JAVA_VERSION); json.put("osName", SystemUtils.OS_NAME); json.put("osArch", SystemUtils.OS_ARCH); json.put("osVersion", SystemUtils.OS_VERSION); json.put("language", SystemUtils.USER_LANGUAGE); json.put("country", SystemUtils.USER_COUNTRY); json.put("timeZone", SystemUtils.USER_TIMEZONE); final String osRoot = SystemUtils.IS_OS_WINDOWS ? "c:" : "/"; long free = Long.MAX_VALUE; try { free = FileSystemUtils.freeSpaceKb(osRoot); // Convert to megabytes for easy reading. free = free / 1024L; } catch (final IOException e) { } json.put("disk_space", String.valueOf(free)); return json; } JSONObject exceptionData(final LoggingEvent le) { final JSONObject json = new JSONObject(); json.put("message", sanitize(le.getMessage().toString())); json.put("backtrace", getThrowableArray(le)); final LocationInfo li = le.getLocationInformation(); final String exceptionClass; if (li == null) { exceptionClass = "unknown"; } else { exceptionClass = li.getClassName(); } json.put("exception_class", exceptionClass); json.put("occurred_at", ExceptionalUtils.iso8601()); return json; } private JSONObject clientData(final LoggingEvent le) { final JSONObject json = new JSONObject(); json.put("client", "exceptional-java-plugin"); json.put("version", "0.1"); json.put("protocol_version", "6"); return json; } private JSONArray getThrowableArray(final LoggingEvent le) { final JSONArray array = new JSONArray(); final ThrowableInformation ti = le.getThrowableInformation(); if (ti != null) { final String[] throwableStr = ti.getThrowableStrRep(); for (final String str : throwableStr) { array.add(str.trim()); } } return array; } /** * Returns the stack trace as a string. * * @param cause * The thread to dump. * @return The stack trace as a string. */ private static String dumpStack(final Throwable cause) { if (cause == null) { return "Throwable was null"; } final StringWriter sw = new StringWriter(); final PrintWriter s = new PrintWriter(sw); // This is very close to what Thread.dumpStack does. cause.printStackTrace(s); final String stack = sw.toString(); try { sw.close(); } catch (final IOException e) { System.err.println("Could not close "+e); } s.close(); return stack; } private static final class Bug { private final String className; private final String methodName; private final String lineNumber; private Bug(final LocationInfo li) { this.className = li.getClassName(); this.methodName = li.getMethodName(); this.lineNumber = li.getLineNumber(); } @Override public int hashCode() { final int PRIME = 31; int result = 1; result = PRIME * result + ((className == null) ? 0 : className.hashCode()); result = PRIME * result + ((lineNumber == null) ? 0 : lineNumber.hashCode()); result = PRIME * result + ((methodName == null) ? 0 : methodName.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; final Bug other = (Bug) obj; if (className == null) { if (other.className != null) return false; } else if (!className.equals(other.className)) return false; if (lineNumber == null) { if (other.lineNumber != null) return false; } else if (!lineNumber.equals(other.lineNumber)) return false; if (methodName == null) { if (other.methodName != null) return false; } else if (!methodName.equals(other.methodName)) return false; return true; } } /** * Applies all {@link Sanitizer}s to the original string. * * @param original * @return */ private String sanitize(String original) { if (original == null || original.length() == 0) { return original; } String result = original; for (Sanitizer filter : sanitizers) { result = filter.sanitize(result); } return result; } }