package com.joshdholtz.sentry; import android.Manifest.permission; import android.content.Context; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.Uri; import android.os.Build; import android.util.DisplayMetrics; import android.util.Log; import android.view.WindowManager; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; import java.io.Serializable; import java.lang.Thread.UncaughtExceptionHandler; import java.net.HttpURLConnection; import java.net.URL; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.TimeZone; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Executor; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSession; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import static java.util.concurrent.TimeUnit.SECONDS; public class Sentry { private static final String TAG = "Sentry"; private final static String sentryVersion = "7"; private static final int MAX_QUEUE_LENGTH = 50; public static boolean debug = false; private Context context; private String baseUrl; private Uri dsn; private AppInfo appInfo = AppInfo.Empty; private boolean verifySsl; private SentryEventCaptureListener captureListener; private JSONObject contexts = new JSONObject(); private Executor executor; final Breadcrumbs breadcrumbs = new Breadcrumbs(); public enum SentryEventLevel { FATAL("fatal"), ERROR("error"), WARNING("warning"), INFO("info"), DEBUG("debug"); private final String value; SentryEventLevel(String value) { this.value = value; } } private Sentry() { } private static void log(String text) { if (debug) { Log.d(TAG, text); } } private static Sentry getInstance() { return LazyHolder.instance; } static class LazyHolder { static final Sentry instance = new Sentry(); } public static void init(Context context, String dsn) { init(context, dsn, true); } public static void init(Context context, String dsn, boolean setupUncaughtExceptionHandler) { final Sentry sentry = Sentry.getInstance(); sentry.context = context.getApplicationContext(); Uri uri = Uri.parse(dsn); String port = ""; if (uri.getPort() >= 0) { port = ":" + uri.getPort(); } sentry.baseUrl = uri.getScheme() + "://" + uri.getHost() + port; sentry.dsn = uri; sentry.appInfo = AppInfo.Read(sentry.context); sentry.verifySsl = getVerifySsl(dsn); sentry.contexts = readContexts(sentry.context, sentry.appInfo); sentry.executor = fixedQueueDiscardingExecutor(MAX_QUEUE_LENGTH); if (setupUncaughtExceptionHandler) { sentry.setupUncaughtExceptionHandler(); } } private static Executor fixedQueueDiscardingExecutor(int queueSize) { // Name our threads so that it is easy for app developers to see who is creating threads. final ThreadFactory threadFactory = new ThreadFactory() { private final AtomicLong count = new AtomicLong(); @Override public Thread newThread(Runnable runnable) { final Thread thread = new Thread(runnable); thread.setName(String.format(Locale.US, "Sentry HTTP Thread %d", count.incrementAndGet())); return thread; } }; return new ThreadPoolExecutor( 0, 1, // Keep 0 threads alive. Max pool size is 1. 60, SECONDS, // Kill unused threads after this length. new ArrayBlockingQueue<Runnable>(queueSize), threadFactory, new ThreadPoolExecutor.DiscardPolicy()); // Discard exceptions } private static boolean getVerifySsl(String dsn) { try { final Uri uri = Uri.parse(dsn); final String value = uri.getQueryParameter("verify_ssl"); return value == null || Integer.parseInt(value) != 0; } catch (Exception e) { Log.w(TAG, "Could not parse verify_ssl correctly", e); return true; } } private void setupUncaughtExceptionHandler() { UncaughtExceptionHandler currentHandler = Thread.getDefaultUncaughtExceptionHandler(); if (currentHandler != null) { log("current handler class=" + currentHandler.getClass().getName()); } // don't register again if already registered if (!(currentHandler instanceof SentryUncaughtExceptionHandler)) { // Register default exceptions handler Thread.setDefaultUncaughtExceptionHandler( new SentryUncaughtExceptionHandler(currentHandler, InternalStorage.getInstance())); } sendAllCachedCapturedEvents(); } private static String createXSentryAuthHeader(Uri dsn) { final StringBuilder header = new StringBuilder(); final String authority = dsn.getAuthority().replace("@" + dsn.getHost(), ""); final String[] authorityParts = authority.split(":"); final String publicKey = authorityParts[0]; final String secretKey = authorityParts[1]; header.append("Sentry ") .append(String.format("sentry_version=%s,", sentryVersion)) .append(String.format("sentry_client=sentry-android/%s,", BuildConfig.SENTRY_ANDROID_VERSION)) .append(String.format("sentry_key=%s,", publicKey)) .append(String.format("sentry_secret=%s", secretKey)); return header.toString(); } private static String getProjectId(Uri dsn) { String path = dsn.getPath(); return path.substring(path.lastIndexOf("/") + 1); } public static void sendAllCachedCapturedEvents() { List<SentryEventRequest> unsentRequests = InternalStorage.getInstance().getUnsentRequests(); log("Sending up " + unsentRequests.size() + " cached response(s)"); for (SentryEventRequest request : unsentRequests) { Sentry.doCaptureEventPost(request); } } /** * @param captureListener the captureListener to set */ public static void setCaptureListener(SentryEventCaptureListener captureListener) { Sentry.getInstance().captureListener = captureListener; } /** * Set a limit on the number of breadcrumbs that will be stored by the client, and sent with * exceptions. * * @param maxBreadcrumbs the maximum number of breadcrumbs to store and send. */ public static void setMaxBreadcrumbs(int maxBreadcrumbs) { getInstance().breadcrumbs.setMaxBreadcrumbs(maxBreadcrumbs); } public static void captureMessage(String message) { Sentry.captureMessage(message, SentryEventLevel.INFO); } public static void captureMessage(String message, SentryEventLevel level) { Sentry.captureEvent(new SentryEventBuilder() .setMessage(message) .setLevel(level) ); } public static void captureException(Throwable t) { Sentry.captureException(t, t.getMessage(), SentryEventLevel.ERROR); } public static void captureException(Throwable t, String message) { Sentry.captureException(t, message, SentryEventLevel.ERROR); } public static void captureException(Throwable t, SentryEventLevel level) { captureException(t, t.getMessage(), level); } public static void captureException(Throwable t, String message, SentryEventLevel level) { String culprit = getCause(t, t.getMessage()); Sentry.captureEvent(new SentryEventBuilder() .setMessage(message) .setCulprit(culprit) .setLevel(level) .setException(t) ); } private static String getCause(Throwable t, String culprit) { final String packageName = Sentry.getInstance().appInfo.name; for (StackTraceElement stackTrace : t.getStackTrace()) { if (stackTrace.toString().contains(packageName)) { return stackTrace.toString(); } } return culprit; } public static void captureEvent(SentryEventBuilder builder) { final Sentry sentry = Sentry.getInstance(); final SentryEventRequest request; builder.event.put("contexts", sentry.contexts); addDefaultRelease(builder, sentry.appInfo); builder.event.put("breadcrumbs", Sentry.getInstance().breadcrumbs.current()); if (sentry.captureListener != null) { builder = sentry.captureListener.beforeCapture(builder); if (builder == null) { Log.e(Sentry.TAG, "SentryEventBuilder in captureEvent is null"); return; } } request = new SentryEventRequest(builder); log("Request - " + request.requestData); doCaptureEventPost(request); } private boolean shouldAttemptPost() { PackageManager pm = context.getPackageManager(); int hasPerm = pm.checkPermission(permission.ACCESS_NETWORK_STATE, context.getPackageName()); if (hasPerm != PackageManager.PERMISSION_GRANTED) { return false; } ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); return activeNetworkInfo != null && activeNetworkInfo.isConnected(); } private static void ignoreSslErrors(HttpURLConnection connection) { try { if (!(connection instanceof HttpsURLConnection)) { return; } final HttpsURLConnection https = (HttpsURLConnection) connection; final X509TrustManager x509TrustManager = new X509TrustManager() { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { } @Override public X509Certificate[] getAcceptedIssuers() { return null; } }; SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, new TrustManager[]{x509TrustManager}, null); https.setSSLSocketFactory(sslContext.getSocketFactory()); https.setHostnameVerifier(new HostnameVerifier() { public boolean verify(String host, SSLSession sess) { return true; } }); } catch (Exception ex) { Log.w(TAG, "Error bypassing SSL validation", ex); } } private Runnable makePoster(final SentryEventRequest request) { return new Runnable() { @Override public void run() { try { int projectId = Integer.parseInt(getProjectId(dsn)); URL url = new URL(baseUrl + "/api/" + projectId + "/store/"); final HttpURLConnection conn = (HttpURLConnection) url.openConnection(); if (!verifySsl) { ignoreSslErrors(conn); } final int timeoutMillis = (int)SECONDS.toMillis(10); conn.setConnectTimeout(timeoutMillis); conn.setReadTimeout(timeoutMillis); conn.setDoOutput(true); conn.setDoInput(false); conn.setRequestMethod("POST"); conn.setRequestProperty("X-Sentry-Auth", createXSentryAuthHeader(dsn)); conn.setRequestProperty("User-Agent", "sentry-android/" + BuildConfig.SENTRY_ANDROID_VERSION); conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); OutputStream os = conn.getOutputStream(); os.write(request.requestData.getBytes("UTF-8")); os.close(); final int status = conn.getResponseCode(); final boolean success = status == 200; conn.disconnect(); log("SendEvent status=" + status); if (success) { InternalStorage.getInstance().removeBuilder(request); } else { InternalStorage.getInstance().addRequest(request); } } catch (Exception e) { Log.e(TAG, "Error sending event", e); } } }; } private static void doCaptureEventPost(final SentryEventRequest request) { final Sentry sentry = Sentry.getInstance(); if (!sentry.shouldAttemptPost()) { InternalStorage.getInstance().addRequest(request); return; } sentry.executor.execute(sentry.makePoster(request)); } private static class SentryUncaughtExceptionHandler implements UncaughtExceptionHandler { private final InternalStorage storage; private final UncaughtExceptionHandler defaultExceptionHandler; // constructor public SentryUncaughtExceptionHandler(UncaughtExceptionHandler pDefaultExceptionHandler, InternalStorage storage) { defaultExceptionHandler = pDefaultExceptionHandler; this.storage = storage; } @Override public void uncaughtException(Thread thread, Throwable e) { final Sentry sentry = Sentry.getInstance(); // Here you should have a more robust, permanent record of problems SentryEventBuilder builder = new SentryEventBuilder(e, SentryEventLevel.FATAL); addDefaultRelease(builder, sentry.appInfo); builder.event.put("breadcrumbs", sentry.breadcrumbs.current()); if (sentry.captureListener != null) { builder = sentry.captureListener.beforeCapture(builder); } if (builder != null) { builder.event.put("contexts", sentry.contexts); storage.addRequest(new SentryEventRequest(builder)); } else { Log.e(Sentry.TAG, "SentryEventBuilder in uncaughtException is null"); } // Call original handler defaultExceptionHandler.uncaughtException(thread, e); } } private static class InternalStorage { private final static String FILE_NAME = "unsent_requests"; private final List<SentryEventRequest> unsentRequests; private static InternalStorage getInstance() { return LazyHolder.instance; } private static class LazyHolder { private static final InternalStorage instance = new InternalStorage(); } private InternalStorage() { Context context = Sentry.getInstance().context; try { File unsetRequestsFile = new File(context.getFilesDir(), FILE_NAME); if (!unsetRequestsFile.exists()) { writeObject(context, new ArrayList<Sentry.SentryEventRequest>()); } } catch (Exception e) { Log.e(TAG, "Error initializing storage", e); } this.unsentRequests = this.readObject(context); } /** * @return the unsentRequests */ public List<SentryEventRequest> getUnsentRequests() { final List<SentryEventRequest> copy = new ArrayList<>(); synchronized (this) { copy.addAll(unsentRequests); } return copy; } public void addRequest(SentryEventRequest request) { synchronized (this) { log("Adding request - " + request.uuid); if (!this.unsentRequests.contains(request)) { this.unsentRequests.add(request); this.writeObject(Sentry.getInstance().context, this.unsentRequests); } } } public void removeBuilder(SentryEventRequest request) { synchronized (this) { log("Removing request - " + request.uuid); this.unsentRequests.remove(request); this.writeObject(Sentry.getInstance().context, this.unsentRequests); } } private void writeObject(Context context, List<SentryEventRequest> requests) { try { FileOutputStream fos = context.openFileOutput(FILE_NAME, Context.MODE_PRIVATE); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(requests); oos.close(); fos.close(); } catch (IOException e) { Log.e(TAG, "Error saving to storage", e); } } private List<SentryEventRequest> readObject(Context context) { try { FileInputStream fis = context.openFileInput(FILE_NAME); ObjectInputStream ois = new ObjectInputStream(fis); List<SentryEventRequest> requests = (ArrayList<SentryEventRequest>) ois.readObject(); ois.close(); fis.close(); return requests; } catch (IOException | ClassNotFoundException e) { Log.e(TAG, "Error loading from storage", e); } return new ArrayList<>(); } } public interface SentryEventCaptureListener { SentryEventBuilder beforeCapture(SentryEventBuilder builder); } private final static class Breadcrumb { enum Type { Default("default"), HTTP("http"), Navigation("navigation"); private final String value; Type(String value) { this.value = value; } } final long timestamp; final Type type; final String message; final String category; final SentryEventLevel level; final Map<String, String> data = new HashMap<>(); Breadcrumb(long timestamp, Type type, String message, String category, SentryEventLevel level) { this.timestamp = timestamp; this.type = type; this.message = message; this.category = category; this.level = level; } } static class Breadcrumbs { // The max number of breadcrumbs that will be tracked at any one time. final AtomicInteger maxBreadcrumbs = new AtomicInteger(100); // Access to this list must be thread-safe. // See GitHub Issue #110 // This list is protected by the provided ReadWriteLock. final LinkedList<Breadcrumb> breadcrumbs = new LinkedList<>(); final ReadWriteLock lock = new ReentrantReadWriteLock(); void push(Breadcrumb b) { try { lock.writeLock().lock(); final int toRemove = breadcrumbs.size() - maxBreadcrumbs.get() + 1; for (int i = 0; i < toRemove; i++) { breadcrumbs.removeFirst(); } breadcrumbs.add(b); } finally { lock.writeLock().unlock(); } } JSONArray current() { final JSONArray crumbs = new JSONArray(); try { lock.readLock().lock(); for (Breadcrumb breadcrumb : breadcrumbs) { final JSONObject json = new JSONObject(); json.put("timestamp", breadcrumb.timestamp); json.put("type", breadcrumb.type.value); json.put("message", breadcrumb.message); json.put("category", breadcrumb.category); json.put("level", breadcrumb.level.value); json.put("data", new JSONObject(breadcrumb.data)); crumbs.put(json); } } catch (Exception e) { Log.e(TAG, "Error serializing breadcrumbs", e); } finally { lock.readLock().unlock(); } return crumbs; } void setMaxBreadcrumbs(int maxBreadcrumbs) { maxBreadcrumbs = Math.min(200, Math.max(0, maxBreadcrumbs)); this.maxBreadcrumbs.set(maxBreadcrumbs); } } /** * Record a breadcrumb to log a navigation from `from` to `to`. * @param category A category to label the event under. This generally is similar to a logger * name, and will let you more easily understand the area an event took place, such as auth. * @param from A string representing the original application state / location. * @param to A string representing the new application state / location. * * @see com.joshdholtz.sentry.Sentry#addHttpBreadcrumb(String, String, int) */ public static void addNavigationBreadcrumb(String category, String from, String to) { final Breadcrumb b = new Breadcrumb( System.currentTimeMillis() / 1000, Breadcrumb.Type.Navigation, "", category, SentryEventLevel.INFO); b.data.put("from", from); b.data.put("to", to); getInstance().breadcrumbs.push(b); } /** * Record a HTTP request breadcrumb. This represents an HTTP request transmitted from your * application. This could be an AJAX request from a web application, or a server-to-server HTTP * request to an API service provider, etc. * * @param url The request URL. * @param method The HTTP request method. * @param statusCode The HTTP status code of the response. * * @see com.joshdholtz.sentry.Sentry#addHttpBreadcrumb(String, String, int) */ public static void addHttpBreadcrumb(String url, String method, int statusCode) { final String reason = httpReason(statusCode); final Breadcrumb b = new Breadcrumb( System.currentTimeMillis() / 1000, Breadcrumb.Type.HTTP, "", String.format("http.%s", method.toLowerCase()), SentryEventLevel.INFO); b.data.put("url", url); b.data.put("method", method); b.data.put("status_code", Integer.toString(statusCode)); b.data.put("reason", reason); getInstance().breadcrumbs.push(b); } /** * Sentry supports a concept called Breadcrumbs, which is a trail of events which happened prior * to an issue. Often times these events are very similar to traditional logs, but also have the * ability to record more rich structured data. * * @param category A category to label the event under. This generally is similar to a logger * name, and will let you more easily understand the area an event took place, * such as auth. * * @param message A string describing the event. The most common vector, often used as a drop-in * for a traditional log message. * * See https://docs.sentry.io/hosted/learn/breadcrumbs/ * */ public static void addBreadcrumb(String category, String message) { getInstance().breadcrumbs.push(new Breadcrumb( System.currentTimeMillis() / 1000, Breadcrumb.Type.Default, message, category, SentryEventLevel.INFO)); } private static class SentryEventRequest implements Serializable { final String requestData; final UUID uuid; SentryEventRequest(SentryEventBuilder builder) { this.requestData = new JSONObject(builder.event).toString(); this.uuid = UUID.randomUUID(); } @Override public boolean equals(Object other) { final boolean sameClass = other instanceof SentryEventRequest; return sameClass && uuid == ((SentryEventRequest) other).uuid; } } /** * The Sentry server assumes the time is in UTC. * The timestamp should be in ISO 8601 format, without a timezone. */ private static DateFormat iso8601() { final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US); format.setTimeZone(TimeZone.getTimeZone("UTC")); return format; } public static class SentryEventBuilder implements Serializable { private static final long serialVersionUID = -8589756678369463988L; // Match packages names that start with some well-known internal class-paths: // java.* // android.* // com.android.* // com.google.android.* // dalvik.system.* static final String isInternalPackage = "^(java|android|com\\.android|com\\.google\\.android|dalvik\\.system)\\..*"; private final static DateFormat timestampFormat = iso8601(); final Map<String, Object> event; public JSONObject toJSON() { return new JSONObject(event); } public SentryEventBuilder() { event = new HashMap<>(); event.put("event_id", UUID.randomUUID().toString().replace("-", "")); event.put("platform", "java"); this.setTimestamp(System.currentTimeMillis()); } public SentryEventBuilder(Throwable t, SentryEventLevel level) { this(); String culprit = getCause(t, t.getMessage()); this.setMessage(t.getMessage()) .setCulprit(culprit) .setLevel(level) .setException(t); } /** * "message": "SyntaxError: Wattttt!" * * @param message Message * @return SentryEventBuilder */ public SentryEventBuilder setMessage(String message) { event.put("message", message); return this; } /** * "timestamp": "2011-05-02T17:41:36" * * @param timestamp Timestamp * @return SentryEventBuilder */ public SentryEventBuilder setTimestamp(long timestamp) { event.put("timestamp", timestampFormat.format(new Date(timestamp))); return this; } /** * "level": "warning" * * @param level Level * @return SentryEventBuilder */ public SentryEventBuilder setLevel(SentryEventLevel level) { event.put("level", level.value); return this; } /** * "logger": "my.logger.name" * * @param logger Logger * @return SentryEventBuilder */ public SentryEventBuilder setLogger(String logger) { event.put("logger", logger); return this; } /** * "environment": "dev" * * @param env Environment * @return SentryEventBuilder */ public SentryEventBuilder setEnvironment(String env) { event.put("environment", env); return this; } /** * "culprit": "my.module.function_name" * * @param culprit Culprit * @return SentryEventBuilder */ public SentryEventBuilder setCulprit(String culprit) { event.put("culprit", culprit); return this; } /** * @param user User * @return SentryEventBuilder */ public SentryEventBuilder setUser(Map<String, String> user) { setUser(new JSONObject(user)); return this; } public SentryEventBuilder setUser(JSONObject user) { event.put("user", user); return this; } public JSONObject getUser() { if (!event.containsKey("user")) { setTags(new HashMap<String, String>()); } return (JSONObject) event.get("user"); } /** * @param tags Tags * @return SentryEventBuilder */ public SentryEventBuilder setTags(Map<String, String> tags) { setTags(new JSONObject(tags)); return this; } public SentryEventBuilder setTags(JSONObject tags) { event.put("tags", tags); return this; } public SentryEventBuilder addTag(String key, String value) { try { getTags().put(key, value); } catch (JSONException e) { Log.e(Sentry.TAG, "Error adding tag in SentryEventBuilder"); } return this; } public JSONObject getTags() { if (!event.containsKey("tags")) { setTags(new HashMap<String, String>()); } return (JSONObject) event.get("tags"); } /** * @param serverName Server name * @return SentryEventBuilder */ public SentryEventBuilder setServerName(String serverName) { event.put("server_name", serverName); return this; } /** * @param release Release * @return SentryEventBuilder */ public SentryEventBuilder setRelease(String release) { event.put("release", release); return this; } /** * @param name Name * @param version Version * @return SentryEventBuilder */ public SentryEventBuilder addModule(String name, String version) { JSONArray modules; if (!event.containsKey("modules")) { modules = new JSONArray(); event.put("modules", modules); } else { modules = (JSONArray) event.get("modules"); } if (name != null && version != null) { String[] module = {name, version}; modules.put(new JSONArray(Arrays.asList(module))); } return this; } /** * @param extra Extra * @return SentryEventBuilder */ public SentryEventBuilder setExtra(Map<String, String> extra) { setExtra(new JSONObject(extra)); return this; } public SentryEventBuilder setExtra(JSONObject extra) { event.put("extra", extra); return this; } public SentryEventBuilder addExtra(String key, String value) { try { getExtra().put(key, value); } catch (JSONException e) { Log.e(Sentry.TAG, "Error adding extra in SentryEventBuilder"); } return this; } public JSONObject getExtra() { if (!event.containsKey("extra")) { setExtra(new HashMap<String, String>()); } return (JSONObject) event.get("extra"); } /** * @param t Throwable * @return SentryEventBuilder */ public SentryEventBuilder setException(Throwable t) { JSONArray values = new JSONArray(); while (t != null) { JSONObject exception = new JSONObject(); try { exception.put("type", t.getClass().getSimpleName()); exception.put("value", t.getMessage()); exception.put("module", t.getClass().getPackage().getName()); exception.put("stacktrace", getStackTrace(t.getStackTrace())); values.put(exception); } catch (JSONException e) { Log.e(TAG, "Failed to build sentry report for " + t, e); } t = t.getCause(); } JSONObject exceptionReport = new JSONObject(); try { exceptionReport.put("values", values); event.put("exception", exceptionReport); } catch (JSONException e) { Log.e(TAG, "Unable to attach exception to event " + values, e); } return this; } private static JSONObject getStackTrace(StackTraceElement[] stackFrames) { JSONObject stacktrace = new JSONObject(); try { JSONArray frameList = new JSONArray(); // Java stack frames are in the opposite order from what the Sentry client API expects. // > The zeroth element of the array (assuming the array's length is non-zero) // > represents the top of the stack, which is the last method invocation in the // > sequence. // See: // https://docs.oracle.com/javase/7/docs/api/java/lang/Throwable.html#getStackTrace() // https://docs.sentry.io/clientdev/interfaces/#failure-interfaces // // This code uses array indices rather a foreach construct since there is no built-in // reverse iterator in the Java standard library. To use a foreach loop would require // calling Collections.reverse which would require copying the array to a list. for (int i = stackFrames.length - 1; i >= 0; i--) { frameList.put(frameJson(stackFrames[i])); } stacktrace.put("frames", frameList); } catch (JSONException e) { Log.e(TAG, "Error serializing stack frames", e); } return stacktrace; } /** * Add a stack trace to the event. * A stack trace for the current thread can be obtained by using * `Thread.currentThread().getStackTrace()`. * * @see Thread#currentThread() * @see Thread#getStackTrace() */ public SentryEventBuilder setStackTrace(StackTraceElement[] stackTrace) { this.event.put("stacktrace", getStackTrace(stackTrace)); return this; } // Convert a StackTraceElement to a sentry.interfaces.stacktrace.Stacktrace JSON object. static JSONObject frameJson(StackTraceElement ste) throws JSONException { final JSONObject frame = new JSONObject(); final String method = ste.getMethodName(); if (Present(method)) { frame.put("function", method); } final String fileName = ste.getFileName(); if (Present(fileName)) { frame.put("filename", fileName); } int lineno = ste.getLineNumber(); if (!ste.isNativeMethod() && lineno >= 0) { frame.put("lineno", lineno); } String className = ste.getClassName(); frame.put("module", className); // Take out some of the system packages to improve the exception folding on the sentry server frame.put("in_app", !className.matches(isInternalPackage)); return frame; } } /** * Store a tuple of package version information captured from PackageInfo * * @see PackageInfo */ final static class AppInfo { final static AppInfo Empty = new AppInfo("", "", 0); final String name; final String versionName; final int versionCode; AppInfo(String name, String versionName, int versionCode) { this.name = name; this.versionName = versionName; this.versionCode = versionCode; } static AppInfo Read(final Context context) { try { final PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); return new AppInfo(info.packageName, info.versionName, info.versionCode); } catch (Exception e) { Log.e(TAG, "Error reading package context", e); return Empty; } } } private static JSONObject readContexts(Context context, AppInfo appInfo) { final JSONObject contexts = new JSONObject(); try { contexts.put("os", osContext()); contexts.put("device", deviceContext(context)); contexts.put("package", packageContext(appInfo)); } catch (JSONException e) { Log.e(TAG, "Failed to build device contexts", e); } return contexts; } /** * Read the device and build into a map. * <p> * Not implemented: * - battery_level * If the device has a battery this can be an integer defining the battery level (in * the range 0-100). (Android requires registration of an intent to query the battery). * - name * The name of the device. This is typically a hostname. * <p> * See https://docs.getsentry.com/hosted/clientdev/interfaces/#context-types */ private static JSONObject deviceContext(Context context) { final JSONObject device = new JSONObject(); try { // The family of the device. This is normally the common part of model names across // generations. For instance iPhone would be a reasonable family, so would be Samsung Galaxy. device.put("family", Build.BRAND); // The model name. This for instance can be Samsung Galaxy S3. device.put("model", Build.PRODUCT); // An internal hardware revision to identify the device exactly. device.put("model_id", Build.MODEL); final String architecture = System.getProperty("os.arch"); if (Present(architecture)) { device.put("arch", architecture); } final int orient = context.getResources().getConfiguration().orientation; device.put("orientation", orient == Configuration.ORIENTATION_LANDSCAPE ? "landscape" : "portrait"); // Read screen resolution in the format "800x600" // Normalised to have wider side first. final Object windowManager = context.getSystemService(Context.WINDOW_SERVICE); if (windowManager != null && windowManager instanceof WindowManager) { final DisplayMetrics metrics = new DisplayMetrics(); ((WindowManager) windowManager).getDefaultDisplay().getMetrics(metrics); device.put("screen_resolution", String.format("%sx%s", Math.max(metrics.widthPixels, metrics.heightPixels), Math.min(metrics.widthPixels, metrics.heightPixels))); } } catch (Exception e) { Log.e(TAG, "Error reading device context", e); } return device; } private static JSONObject osContext() { final JSONObject os = new JSONObject(); try { os.put("type", "os"); os.put("name", "Android"); os.put("version", Build.VERSION.RELEASE); os.put("build", Integer.toString(Build.VERSION.SDK_INT)); final String kernelVersion = System.getProperty("os.version"); if (Present(kernelVersion)) { os.put("kernel_version", kernelVersion); } } catch (Exception e) { Log.e(TAG, "Error reading OS context", e); } return os; } /** * Read the package data into map to be sent as an event context item. * This is not a built-in context type. */ private static JSONObject packageContext(AppInfo appInfo) { final JSONObject pack = new JSONObject(); try { pack.put("type", "package"); pack.put("name", appInfo.name); pack.put("version_name", appInfo.versionName); pack.put("version_code", Integer.toString(appInfo.versionCode)); } catch (JSONException e) { Log.e(TAG, "Error reading package context", e); } return pack; } /** * Map from HTTP status code to reason description. * Sentry HTTP breadcrumbs expect a text description of the HTTP status-code. * This function implements a look-up table with a default value for unknown status-codes. * @param statusCode an integer HTTP status code, expected to be in the range [200,505]. * @return a non-empty string in all cases. */ private static String httpReason(int statusCode) { switch (statusCode) { // 2xx case HttpURLConnection.HTTP_OK: return "OK"; case HttpURLConnection.HTTP_CREATED: return "Created"; case HttpURLConnection.HTTP_ACCEPTED: return "Accepted"; case HttpURLConnection.HTTP_NOT_AUTHORITATIVE: return "Non-Authoritative Information"; case HttpURLConnection.HTTP_NO_CONTENT: return "No Content"; case HttpURLConnection.HTTP_RESET: return "Reset Content"; case HttpURLConnection.HTTP_PARTIAL: return "Partial Content"; // 3xx case HttpURLConnection.HTTP_MULT_CHOICE: return "Multiple Choices"; case HttpURLConnection.HTTP_MOVED_PERM: return "Moved Permanently"; case HttpURLConnection.HTTP_MOVED_TEMP: return "Temporary Redirect"; case HttpURLConnection.HTTP_SEE_OTHER: return "See Other"; case HttpURLConnection.HTTP_NOT_MODIFIED: return "Not Modified"; case HttpURLConnection.HTTP_USE_PROXY: return "Use Proxy"; // 4xx case HttpURLConnection.HTTP_BAD_REQUEST: return "Bad Request"; case HttpURLConnection.HTTP_UNAUTHORIZED: return "Unauthorized"; case HttpURLConnection.HTTP_PAYMENT_REQUIRED: return "Payment Required"; case HttpURLConnection.HTTP_FORBIDDEN: return "Forbidden"; case HttpURLConnection.HTTP_NOT_FOUND: return "Not Found"; case HttpURLConnection.HTTP_BAD_METHOD: return "Method Not Allowed"; case HttpURLConnection.HTTP_NOT_ACCEPTABLE: return "Not Acceptable"; case HttpURLConnection.HTTP_PROXY_AUTH: return "Proxy Authentication Required"; case HttpURLConnection.HTTP_CLIENT_TIMEOUT: return "Request Time-Out"; case HttpURLConnection.HTTP_CONFLICT: return "Conflict"; case HttpURLConnection.HTTP_GONE: return "Gone"; case HttpURLConnection.HTTP_LENGTH_REQUIRED: return "Length Required"; case HttpURLConnection.HTTP_PRECON_FAILED: return "Precondition Failed"; case HttpURLConnection.HTTP_ENTITY_TOO_LARGE: return "Request Entity Too Large"; case HttpURLConnection.HTTP_REQ_TOO_LONG: return "Request-URI Too Large"; case HttpURLConnection.HTTP_UNSUPPORTED_TYPE: return "Unsupported Media Type"; // 5xx case HttpURLConnection.HTTP_INTERNAL_ERROR: return "Internal Server Error"; case HttpURLConnection.HTTP_NOT_IMPLEMENTED: return "Not Implemented"; case HttpURLConnection.HTTP_BAD_GATEWAY: return "Bad Gateway"; case HttpURLConnection.HTTP_UNAVAILABLE: return "Service Unavailable"; case HttpURLConnection.HTTP_GATEWAY_TIMEOUT: return "Gateway Timeout"; case HttpURLConnection.HTTP_VERSION: return "Version Not Supported"; default: return "unknown"; } } /** * Take the idea of `present?` from ActiveSupport. */ private static boolean Present(String s) { return s != null && s.length() > 0; } static void addDefaultRelease(SentryEventBuilder event, AppInfo appInfo) { if (event.event.containsKey("release")) { return; } event.setRelease(appInfo.versionName); } }