diff --git a/app/build.gradle b/app/build.gradle index 5304c3aecc..2a3bc2289c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -380,7 +380,7 @@ dependencies { def dnsjava_version = "2.1.9" def openpgp_version = "12.0" def badge_version = "1.1.22" - def bugsnag_version = "5.23.0" + def bugsnag_version = "5.28.2" def biweekly_version = "0.6.6" def vcard_version = "0.11.3" def relinker_version = "1.4.5" diff --git a/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt b/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt index c171c23d50..76a92312a6 100644 --- a/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt +++ b/app/src/main/java/com/bugsnag/android/BackgroundTaskService.kt @@ -3,13 +3,16 @@ package com.bugsnag.android import androidx.annotation.VisibleForTesting import java.util.concurrent.BlockingQueue import java.util.concurrent.Callable +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.Future +import java.util.concurrent.FutureTask import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ThreadFactory import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit +import java.lang.Thread as JThread /** * The type of task which is being submitted. This determines which execution queue @@ -55,9 +58,14 @@ private const val THREAD_POOL_SIZE = 1 private const val KEEP_ALIVE_SECS = 30L private const val TASK_QUEUE_SIZE = 128 -internal fun createExecutor(name: String, keepAlive: Boolean): ThreadPoolExecutor { +private class TaskTypeThread(runnable: Runnable, name: String, val taskType: TaskType) : + JThread(runnable, name) + +internal val JThread.taskType get() = (this as? TaskTypeThread)?.taskType + +internal fun createExecutor(name: String, type: TaskType, keepAlive: Boolean): ExecutorService { val queue: BlockingQueue = LinkedBlockingQueue(TASK_QUEUE_SIZE) - val threadFactory = ThreadFactory { Thread(it, name) } + val threadFactory = ThreadFactory { TaskTypeThread(it, name, type) } // certain executors (error/session/io) should always keep their threads alive, but others // are less important so are allowed a pool size of 0 that expands on demand. @@ -86,33 +94,38 @@ internal fun createExecutor(name: String, keepAlive: Boolean): ThreadPoolExecuto internal class BackgroundTaskService( // these executors must remain single-threaded - the SDK makes assumptions // about synchronization based on this. - @VisibleForTesting - internal val errorExecutor: ThreadPoolExecutor = createExecutor( + @get:VisibleForTesting + internal val errorExecutor: ExecutorService = createExecutor( "Bugsnag Error thread", + TaskType.ERROR_REQUEST, true ), - @VisibleForTesting - internal val sessionExecutor: ThreadPoolExecutor = createExecutor( + @get:VisibleForTesting + internal val sessionExecutor: ExecutorService = createExecutor( "Bugsnag Session thread", + TaskType.SESSION_REQUEST, true ), - @VisibleForTesting - internal val ioExecutor: ThreadPoolExecutor = createExecutor( + @get:VisibleForTesting + internal val ioExecutor: ExecutorService = createExecutor( "Bugsnag IO thread", + TaskType.IO, true ), - @VisibleForTesting - internal val internalReportExecutor: ThreadPoolExecutor = createExecutor( + @get:VisibleForTesting + internal val internalReportExecutor: ExecutorService = createExecutor( "Bugsnag Internal Report thread", + TaskType.INTERNAL_REPORT, false ), - @VisibleForTesting - internal val defaultExecutor: ThreadPoolExecutor = createExecutor( + @get:VisibleForTesting + internal val defaultExecutor: ExecutorService = createExecutor( "Bugsnag Default thread", + TaskType.DEFAULT, false ) ) { @@ -138,13 +151,17 @@ internal class BackgroundTaskService( */ @Throws(RejectedExecutionException::class) fun submitTask(taskType: TaskType, callable: Callable): Future { - return when (taskType) { - TaskType.ERROR_REQUEST -> errorExecutor.submit(callable) - TaskType.SESSION_REQUEST -> sessionExecutor.submit(callable) - TaskType.IO -> ioExecutor.submit(callable) - TaskType.INTERNAL_REPORT -> internalReportExecutor.submit(callable) - TaskType.DEFAULT -> defaultExecutor.submit(callable) + val task = FutureTask(callable) + + when (taskType) { + TaskType.ERROR_REQUEST -> errorExecutor.execute(task) + TaskType.SESSION_REQUEST -> sessionExecutor.execute(task) + TaskType.IO -> ioExecutor.execute(task) + TaskType.INTERNAL_REPORT -> internalReportExecutor.execute(task) + TaskType.DEFAULT -> defaultExecutor.execute(task) } + + return SafeFuture(task, taskType) } /** @@ -168,11 +185,34 @@ internal class BackgroundTaskService( ioExecutor.awaitTerminationSafe() } - private fun ThreadPoolExecutor.awaitTerminationSafe() { + private fun ExecutorService.awaitTerminationSafe() { try { awaitTermination(SHUTDOWN_WAIT_MS, TimeUnit.MILLISECONDS) } catch (ignored: InterruptedException) { // ignore interrupted exception as the JVM is shutting down } } + + private class SafeFuture( + private val delegate: FutureTask, + private val taskType: TaskType + ) : Future by delegate { + override fun get(): V { + ensureTaskGetSafe() + return delegate.get() + } + + override fun get(timeout: Long, unit: TimeUnit?): V { + ensureTaskGetSafe() + return delegate.get(timeout, unit) + } + + private fun ensureTaskGetSafe() { + if (!delegate.isDone && JThread.currentThread().taskType == taskType) { + // if this is the execution queue for the wrapped FutureTask && it is not yet 'done' + // then it has not yet been started, so we run it immediately + delegate.run() + } + } + } } diff --git a/app/src/main/java/com/bugsnag/android/BreadcrumbInternal.kt b/app/src/main/java/com/bugsnag/android/BreadcrumbInternal.kt index 49499b770d..14d6730f4b 100644 --- a/app/src/main/java/com/bugsnag/android/BreadcrumbInternal.kt +++ b/app/src/main/java/com/bugsnag/android/BreadcrumbInternal.kt @@ -1,5 +1,7 @@ package com.bugsnag.android +import com.bugsnag.android.internal.StringUtils +import com.bugsnag.android.internal.TrimMetrics import java.io.IOException import java.util.Date @@ -22,6 +24,11 @@ internal class BreadcrumbInternal internal constructor( Date() ) + internal fun trimMetadataStringsTo(maxStringLength: Int): TrimMetrics { + val metadata = this.metadata ?: return TrimMetrics(0, 0) + return StringUtils.trimStringValuesTo(maxStringLength, metadata) + } + @Throws(IOException::class) override fun toStream(writer: JsonStream) { writer.beginObject() diff --git a/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt b/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt index d850562671..1cfdfd90f0 100644 --- a/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt +++ b/app/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt @@ -1,6 +1,7 @@ package com.bugsnag.android import com.bugsnag.android.internal.DateUtils +import com.bugsnag.android.internal.InternalMetricsImpl import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -17,7 +18,7 @@ internal class BugsnagEventMapper( @Suppress("UNCHECKED_CAST") internal fun convertToEventImpl(map: Map, apiKey: String): EventInternal { - val event = EventInternal(apiKey) + val event = EventInternal(apiKey, logger) // populate exceptions. check this early to avoid unnecessary serialization if // no stacktrace was gathered. @@ -86,6 +87,9 @@ internal class BugsnagEventMapper( event.updateSeverityReasonInternal(reason) event.normalizeStackframeErrorTypes() + // populate internalMetrics + event.internalMetrics = InternalMetricsImpl(map["usage"] as MutableMap?) + return event } @@ -184,31 +188,7 @@ internal class BugsnagEventMapper( } internal fun convertStacktrace(trace: List>): Stacktrace { - return Stacktrace(trace.map { convertStackframe(it) }) - } - - internal fun convertStackframe(frame: Map): Stackframe { - val copy: MutableMap = frame.toMutableMap() - val lineNumber = frame["lineNumber"] as? Number - copy["lineNumber"] = lineNumber?.toLong() - - (frame["frameAddress"] as? String)?.let { - copy["frameAddress"] = java.lang.Long.decode(it) - } - - (frame["symbolAddress"] as? String)?.let { - copy["symbolAddress"] = java.lang.Long.decode(it) - } - - (frame["loadAddress"] as? String)?.let { - copy["loadAddress"] = java.lang.Long.decode(it) - } - - (frame["isPC"] as? Boolean)?.let { - copy["isPC"] = it - } - - return Stackframe(copy) + return Stacktrace(trace.map { Stackframe(it) }) } internal fun deserializeSeverityReason( diff --git a/app/src/main/java/com/bugsnag/android/BugsnagStateModule.kt b/app/src/main/java/com/bugsnag/android/BugsnagStateModule.kt index ae8db30b52..2aa375226d 100644 --- a/app/src/main/java/com/bugsnag/android/BugsnagStateModule.kt +++ b/app/src/main/java/com/bugsnag/android/BugsnagStateModule.kt @@ -1,6 +1,6 @@ package com.bugsnag.android -import com.bugsnag.android.internal.dag.ConfigModule +import com.bugsnag.android.internal.ImmutableConfig import com.bugsnag.android.internal.dag.DependencyModule /** @@ -8,15 +8,13 @@ import com.bugsnag.android.internal.dag.DependencyModule * class is responsible for creating classes which track the current breadcrumb/metadata state. */ internal class BugsnagStateModule( - configModule: ConfigModule, + cfg: ImmutableConfig, configuration: Configuration ) : DependencyModule() { - private val cfg = configModule.config - val clientObservable = ClientObservable() - val callbackState = configuration.impl.callbackState.copy() + val callbackState = configuration.impl.callbackState val contextState = ContextState().apply { if (configuration.context != null) { diff --git a/app/src/main/java/com/bugsnag/android/CallbackState.kt b/app/src/main/java/com/bugsnag/android/CallbackState.kt index 39218f7d5c..facd203e80 100644 --- a/app/src/main/java/com/bugsnag/android/CallbackState.kt +++ b/app/src/main/java/com/bugsnag/android/CallbackState.kt @@ -1,5 +1,7 @@ package com.bugsnag.android +import com.bugsnag.android.internal.InternalMetrics +import com.bugsnag.android.internal.InternalMetricsNoop import java.util.concurrent.CopyOnWriteArrayList internal data class CallbackState( @@ -9,36 +11,66 @@ internal data class CallbackState( val onSendTasks: MutableCollection = CopyOnWriteArrayList() ) : CallbackAware { + private var internalMetrics: InternalMetrics = InternalMetricsNoop() + + companion object { + private const val onBreadcrumbName = "onBreadcrumb" + private const val onErrorName = "onError" + private const val onSendName = "onSendError" + private const val onSessionName = "onSession" + } + + fun setInternalMetrics(metrics: InternalMetrics) { + internalMetrics = metrics + internalMetrics.setCallbackCounts(getCallbackCounts()) + } + override fun addOnError(onError: OnErrorCallback) { - onErrorTasks.add(onError) + if (onErrorTasks.add(onError)) { + internalMetrics.notifyAddCallback(onErrorName) + } } override fun removeOnError(onError: OnErrorCallback) { - onErrorTasks.remove(onError) + if (onErrorTasks.remove(onError)) { + internalMetrics.notifyRemoveCallback(onErrorName) + } } override fun addOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) { - onBreadcrumbTasks.add(onBreadcrumb) + if (onBreadcrumbTasks.add(onBreadcrumb)) { + internalMetrics.notifyAddCallback(onBreadcrumbName) + } } override fun removeOnBreadcrumb(onBreadcrumb: OnBreadcrumbCallback) { - onBreadcrumbTasks.remove(onBreadcrumb) + if (onBreadcrumbTasks.remove(onBreadcrumb)) { + internalMetrics.notifyRemoveCallback(onBreadcrumbName) + } } override fun addOnSession(onSession: OnSessionCallback) { - onSessionTasks.add(onSession) + if (onSessionTasks.add(onSession)) { + internalMetrics.notifyAddCallback(onSessionName) + } } override fun removeOnSession(onSession: OnSessionCallback) { - onSessionTasks.remove(onSession) + if (onSessionTasks.remove(onSession)) { + internalMetrics.notifyRemoveCallback(onSessionName) + } } fun addOnSend(onSend: OnSendCallback) { - onSendTasks.add(onSend) + if (onSendTasks.add(onSend)) { + internalMetrics.notifyAddCallback(onSendName) + } } fun removeOnSend(onSend: OnSendCallback) { - onSendTasks.remove(onSend) + if (onSendTasks.remove(onSend)) { + internalMetrics.notifyRemoveCallback(onSendName) + } } fun runOnErrorTasks(event: Event, logger: Logger): Boolean { @@ -120,4 +152,13 @@ internal data class CallbackState( onSessionTasks = onSessionTasks, onSendTasks = onSendTasks ) + + private fun getCallbackCounts(): Map { + return hashMapOf().also { map -> + if (onBreadcrumbTasks.count() > 0) map[onBreadcrumbName] = onBreadcrumbTasks.count() + if (onErrorTasks.count() > 0) map[onErrorName] = onErrorTasks.count() + if (onSendTasks.count() > 0) map[onSendName] = onSendTasks.count() + if (onSessionTasks.count() > 0) map[onSessionName] = onSessionTasks.count() + } + } } diff --git a/app/src/main/java/com/bugsnag/android/Client.java b/app/src/main/java/com/bugsnag/android/Client.java index c91becd799..bd532bc4d3 100644 --- a/app/src/main/java/com/bugsnag/android/Client.java +++ b/app/src/main/java/com/bugsnag/android/Client.java @@ -3,6 +3,9 @@ package com.bugsnag.android; import static com.bugsnag.android.SeverityReason.REASON_HANDLED_EXCEPTION; import com.bugsnag.android.internal.ImmutableConfig; +import com.bugsnag.android.internal.InternalMetrics; +import com.bugsnag.android.internal.InternalMetricsImpl; +import com.bugsnag.android.internal.InternalMetricsNoop; import com.bugsnag.android.internal.StateObserver; import com.bugsnag.android.internal.dag.ConfigModule; import com.bugsnag.android.internal.dag.ContextModule; @@ -16,7 +19,6 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import kotlin.Unit; -import kotlin.jvm.functions.Function1; import kotlin.jvm.functions.Function2; import java.io.File; @@ -49,9 +51,11 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF final MetadataState metadataState; final FeatureFlagState featureFlagState; + private final InternalMetrics internalMetrics; private final ContextState contextState; private final CallbackState callbackState; private final UserState userState; + private final Map configDifferences; final Context appContext; @@ -140,7 +144,18 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF ConfigModule configModule = new ConfigModule(contextModule, configuration, connectivity); immutableConfig = configModule.getConfig(); logger = immutableConfig.getLogger(); - warnIfNotAppContext(androidContext); + + if (!(androidContext instanceof Application)) { + logger.w("You should initialize Bugsnag from the onCreate() callback of your " + + "Application subclass, as this guarantees errors are captured as early " + + "as possible. " + + "If a custom Application subclass is not possible in your app then you " + + "should suppress this warning by passing the Application context instead: " + + "Bugsnag.start(context.getApplicationContext()). " + + "For further info see: " + + "https://docs.bugsnag.com/platforms/android/#basic-configuration"); + + } // setup storage as soon as possible final StorageModule storageModule = new StorageModule(appContext, @@ -148,7 +163,7 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF // setup state trackers for bugsnag BugsnagStateModule bugsnagStateModule = new BugsnagStateModule( - configModule, configuration); + immutableConfig, configuration); clientObservable = bugsnagStateModule.getClientObservable(); callbackState = bugsnagStateModule.getCallbackState(); breadcrumbState = bugsnagStateModule.getBreadcrumbState(); @@ -180,8 +195,6 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF userState = storageModule.getUserStore().load(configuration.getUser()); storageModule.getSharedPrefMigrator().deleteLegacyPrefs(); - registerLifecycleCallbacks(); - EventStorageModule eventStorageModule = new EventStorageModule(contextModule, configModule, dataCollectionModule, bgTaskService, trackerModule, systemServiceModule, notifier, callbackState); @@ -191,33 +204,25 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF deliveryDelegate = new DeliveryDelegate(logger, eventStore, immutableConfig, callbackState, notifier, bgTaskService); - // Install a default exception handler with this client exceptionHandler = new ExceptionHandler(this, logger); - if (immutableConfig.getEnabledErrorTypes().getUnhandledExceptions()) { - exceptionHandler.install(); - } // load last run info lastRunInfoStore = storageModule.getLastRunInfoStore(); lastRunInfo = storageModule.getLastRunInfo(); - // initialise plugins before attempting to flush any errors - loadPlugins(configuration); + Set userPlugins = configuration.getPlugins(); + pluginClient = new PluginClient(userPlugins, immutableConfig, logger); - // Flush any on-disk errors and sessions - eventStore.flushOnLaunch(); - eventStore.flushAsync(); - sessionTracker.flushAsync(); + if (configuration.getTelemetry().contains(Telemetry.USAGE)) { + internalMetrics = new InternalMetricsImpl(); + } else { + internalMetrics = new InternalMetricsNoop(); + } - // register listeners for system events in the background. + configDifferences = configuration.impl.getConfigDifferences(); systemBroadcastReceiver = new SystemBroadcastReceiver(this, logger); - registerComponentCallbacks(); - registerListenersInBackground(); - // leave auto breadcrumb - Map data = Collections.emptyMap(); - leaveAutoBreadcrumb("Bugsnag loaded", BreadcrumbType.STATE, data); - logger.d("Bugsnag loaded"); + start(); } @VisibleForTesting @@ -266,6 +271,42 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF this.lastRunInfo = null; this.exceptionHandler = exceptionHandler; this.notifier = notifier; + internalMetrics = new InternalMetricsNoop(); + configDifferences = new HashMap<>(); + } + + private void start() { + if (immutableConfig.getEnabledErrorTypes().getUnhandledExceptions()) { + exceptionHandler.install(); + } + + // Initialise plugins before attempting anything else + NativeInterface.setClient(Client.this); + pluginClient.loadPlugins(Client.this); + NdkPluginCaller.INSTANCE.setNdkPlugin(pluginClient.getNdkPlugin()); + if (immutableConfig.getTelemetry().contains(Telemetry.USAGE)) { + NdkPluginCaller.INSTANCE.setInternalMetricsEnabled(true); + } + + // Flush any on-disk errors and sessions + eventStore.flushOnLaunch(); + eventStore.flushAsync(); + sessionTracker.flushAsync(); + + // These call into NdkPluginCaller to sync with the native side, so they must happen later + internalMetrics.setConfigDifferences(configDifferences); + callbackState.setInternalMetrics(internalMetrics); + + // Register listeners for system events in the background + registerLifecycleCallbacks(); + registerComponentCallbacks(); + registerListenersInBackground(); + + // Leave auto breadcrumb + Map data = Collections.emptyMap(); + leaveAutoBreadcrumb("Bugsnag loaded", BreadcrumbType.STATE, data); + + logger.d("Bugsnag loaded"); } void registerLifecycleCallbacks() { @@ -327,13 +368,6 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF } } - private void loadPlugins(@NonNull final Configuration configuration) { - NativeInterface.setClient(Client.this); - Set userPlugins = configuration.getPlugins(); - pluginClient = new PluginClient(userPlugins, immutableConfig, logger); - pluginClient.loadPlugins(Client.this); - } - private void logNull(String property) { logger.e("Invalid null value supplied to client." + property + ", ignoring"); } @@ -390,7 +424,7 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF return bgTaskService.submitTask(TaskType.IO, new Callable() { @Override public Boolean call() { - File outFile = new File(NativeInterface.getNativeReportPath()); + File outFile = NativeInterface.getNativeReportPath(); return outFile.exists() || outFile.mkdirs(); } }).get(); @@ -746,6 +780,9 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF // Attach context to the event event.setContext(contextState.getContext()); + + event.setInternalMetrics(internalMetrics); + notifyInternal(event, onError); } @@ -1064,20 +1101,6 @@ public class Client implements MetadataAware, CallbackAware, UserAware, FeatureF super.finalize(); } - private void warnIfNotAppContext(Context androidContext) { - if (!(androidContext instanceof Application)) { - logger.w("You should initialize Bugsnag from the onCreate() callback of your " - + "Application subclass, as this guarantees errors are captured as early " - + "as possible. " - + "If a custom Application subclass is not possible in your app then you " - + "should suppress this warning by passing the Application context instead: " - + "Bugsnag.start(context.getApplicationContext()). " - + "For further info see: " - + "https://docs.bugsnag.com/platforms/android/#basic-configuration"); - - } - } - ImmutableConfig getConfig() { return immutableConfig; } diff --git a/app/src/main/java/com/bugsnag/android/ConfigInternal.kt b/app/src/main/java/com/bugsnag/android/ConfigInternal.kt index 9a967d1853..bd1e449895 100644 --- a/app/src/main/java/com/bugsnag/android/ConfigInternal.kt +++ b/app/src/main/java/com/bugsnag/android/ConfigInternal.kt @@ -42,6 +42,7 @@ internal class ConfigInternal( var maxPersistedEvents: Int = DEFAULT_MAX_PERSISTED_EVENTS var maxPersistedSessions: Int = DEFAULT_MAX_PERSISTED_SESSIONS var maxReportedThreads: Int = DEFAULT_MAX_REPORTED_THREADS + var maxStringValueLength: Int = DEFAULT_MAX_STRING_VALUE_LENGTH var context: String? = null var redactedKeys: Set @@ -53,10 +54,12 @@ internal class ConfigInternal( var discardClasses: Set = emptySet() var enabledReleaseStages: Set? = null var enabledBreadcrumbTypes: Set? = null - var telemetry: Set = EnumSet.of(Telemetry.INTERNAL_ERRORS) + var telemetry: Set = EnumSet.of(Telemetry.INTERNAL_ERRORS, Telemetry.USAGE) var projectPackages: Set = emptySet() var persistenceDirectory: File? = null + var attemptDeliveryOnCrash: Boolean = false + val notifier: Notifier = Notifier() protected val plugins = HashSet() @@ -98,12 +101,59 @@ internal class ConfigInternal( plugins.add(plugin) } + private fun toCommaSeparated(coll: Collection?): String { + return coll?.map { it.toString() }?.sorted()?.joinToString(",") ?: "" + } + + fun getConfigDifferences(): Map { + // allocate a local ConfigInternal with all-defaults to compare against + val defaultConfig = ConfigInternal("") + + return listOfNotNull( + if (plugins.count() > 0) "pluginCount" to plugins.count() else null, + if (autoDetectErrors != defaultConfig.autoDetectErrors) + "autoDetectErrors" to autoDetectErrors else null, + if (autoTrackSessions != defaultConfig.autoTrackSessions) + "autoTrackSessions" to autoTrackSessions else null, + if (discardClasses.count() > 0) + "discardClassesCount" to discardClasses.count() else null, + if (enabledBreadcrumbTypes != defaultConfig.enabledBreadcrumbTypes) + "enabledBreadcrumbTypes" to toCommaSeparated(enabledBreadcrumbTypes) else null, + if (enabledErrorTypes != defaultConfig.enabledErrorTypes) + "enabledErrorTypes" to toCommaSeparated( + listOfNotNull( + if (enabledErrorTypes.anrs) "anrs" else null, + if (enabledErrorTypes.ndkCrashes) "ndkCrashes" else null, + if (enabledErrorTypes.unhandledExceptions) "unhandledExceptions" else null, + if (enabledErrorTypes.unhandledRejections) "unhandledRejections" else null, + ) + ) else null, + if (launchDurationMillis != 0L) "launchDurationMillis" to launchDurationMillis else null, + if (logger != NoopLogger) "logger" to true else null, + if (maxBreadcrumbs != defaultConfig.maxBreadcrumbs) + "maxBreadcrumbs" to maxBreadcrumbs else null, + if (maxPersistedEvents != defaultConfig.maxPersistedEvents) + "maxPersistedEvents" to maxPersistedEvents else null, + if (maxPersistedSessions != defaultConfig.maxPersistedSessions) + "maxPersistedSessions" to maxPersistedSessions else null, + if (maxReportedThreads != defaultConfig.maxReportedThreads) + "maxReportedThreads" to maxReportedThreads else null, + if (persistenceDirectory != null) + "persistenceDirectorySet" to true else null, + if (sendThreads != defaultConfig.sendThreads) + "sendThreads" to sendThreads else null, + if (attemptDeliveryOnCrash != defaultConfig.attemptDeliveryOnCrash) + "attemptDeliveryOnCrash" to attemptDeliveryOnCrash else null + ).toMap() + } + companion object { - private const val DEFAULT_MAX_BREADCRUMBS = 50 + private const val DEFAULT_MAX_BREADCRUMBS = 100 private const val DEFAULT_MAX_PERSISTED_SESSIONS = 128 private const val DEFAULT_MAX_PERSISTED_EVENTS = 32 private const val DEFAULT_MAX_REPORTED_THREADS = 200 private const val DEFAULT_LAUNCH_CRASH_THRESHOLD_MS: Long = 5000 + private const val DEFAULT_MAX_STRING_VALUE_LENGTH = 10000 @JvmStatic fun load(context: Context): Configuration = load(context, null) diff --git a/app/src/main/java/com/bugsnag/android/Configuration.java b/app/src/main/java/com/bugsnag/android/Configuration.java index 0be3d0ff9a..56f987d4cc 100644 --- a/app/src/main/java/com/bugsnag/android/Configuration.java +++ b/app/src/main/java/com/bugsnag/android/Configuration.java @@ -7,7 +7,6 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import java.io.File; -import java.util.Locale; import java.util.Map; import java.util.Set; @@ -19,7 +18,7 @@ import java.util.Set; public class Configuration implements CallbackAware, MetadataAware, UserAware, FeatureFlagAware { private static final int MIN_BREADCRUMBS = 0; - private static final int MAX_BREADCRUMBS = 100; + private static final int MAX_BREADCRUMBS = 500; private static final int VALID_API_KEY_LEN = 32; private static final long MIN_LAUNCH_CRASH_THRESHOLD_MS = 0; @@ -513,7 +512,7 @@ public class Configuration implements CallbackAware, MetadataAware, UserAware, F * Sets the maximum number of breadcrumbs which will be stored. Once the threshold is reached, * the oldest breadcrumbs will be deleted. * - * By default, 50 breadcrumbs are stored: this can be amended up to a maximum of 100. + * By default, 100 breadcrumbs are stored: this can be amended up to a maximum of 500. */ public int getMaxBreadcrumbs() { return impl.getMaxBreadcrumbs(); @@ -523,7 +522,7 @@ public class Configuration implements CallbackAware, MetadataAware, UserAware, F * Sets the maximum number of breadcrumbs which will be stored. Once the threshold is reached, * the oldest breadcrumbs will be deleted. * - * By default, 50 breadcrumbs are stored: this can be amended up to a maximum of 100. + * By default, 100 breadcrumbs are stored: this can be amended up to a maximum of 500. */ public void setMaxBreadcrumbs(int maxBreadcrumbs) { if (maxBreadcrumbs >= MIN_BREADCRUMBS && maxBreadcrumbs <= MAX_BREADCRUMBS) { @@ -613,6 +612,32 @@ public class Configuration implements CallbackAware, MetadataAware, UserAware, F } } + /** + * Gets the maximum string length in any metadata field. Once the threshold is + * reached in a particular string, all excess characters will be deleted. + * + * By default, the limit is 10,000. + */ + public int getMaxStringValueLength() { + return impl.getMaxStringValueLength(); + } + + /** + * Sets the maximum string length in any metadata field. Once the threshold is + * reached in a particular string, all excess characters will be deleted. + * + * By default, the limit is 10,000. + */ + public void setMaxStringValueLength(int maxStringValueLength) { + if (maxStringValueLength >= 0) { + impl.setMaxStringValueLength(maxStringValueLength); + } else { + getLogger().e("Invalid configuration value detected. " + + "Option maxStringValueLength should be a positive integer." + + "Supplied value is " + maxStringValueLength); + } + } + /** * Bugsnag uses the concept of "contexts" to help display and group your errors. Contexts * represent what was happening in your application at the time an error occurs. @@ -1112,6 +1137,39 @@ public class Configuration implements CallbackAware, MetadataAware, UserAware, F } } + /** + * Whether Bugsnag should try to send crashing errors prior to app termination. + * + * Delivery will only be attempted for uncaught Java / Kotlin exceptions or errors, and + * while in progress will block the crashing thread for up to 3 seconds. + * + * Delivery on crash should be considered unreliable due to the necessary short timeout and + * potential for generating "errors on errors". + * + * Use of this feature is discouraged because it: + * - may cause Application Not Responding (ANR) errors on-top of existing crashes + * - will result in duplicate errors in your Dashboard when errors are not detected as sent + * before termination + * - may prevent other error handlers from detecting or reporting a crash + * + * By default this value is {@code false}. + * + * @param attemptDeliveryOnCrash {@code true} if Bugsnag should try to send crashing errors + * prior to app termination + */ + public void setAttemptDeliveryOnCrash(boolean attemptDeliveryOnCrash) { + impl.setAttemptDeliveryOnCrash(attemptDeliveryOnCrash); + } + + /** + * Whether Bugsnag should try to send crashing errors prior to app termination. + * + * @see #setAttemptDeliveryOnCrash(boolean) + */ + public boolean isAttemptDeliveryOnCrash() { + return impl.getAttemptDeliveryOnCrash(); + } + Set getPlugins() { return impl.getPlugins(); } diff --git a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt index e1bac196e2..1832989b7c 100644 --- a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt +++ b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt @@ -1,45 +1,79 @@ package com.bugsnag.android import android.net.TrafficStats -import java.io.ByteArrayOutputStream +import com.bugsnag.android.internal.JsonHelper import java.io.IOException -import java.io.PrintWriter import java.net.HttpURLConnection import java.net.HttpURLConnection.HTTP_BAD_REQUEST import java.net.HttpURLConnection.HTTP_CLIENT_TIMEOUT import java.net.HttpURLConnection.HTTP_OK import java.net.URL -/** - * Converts a [JsonStream.Streamable] into JSON, placing it in a [ByteArray] - */ -internal fun serializeJsonPayload(streamable: JsonStream.Streamable): ByteArray { - return ByteArrayOutputStream().use { baos -> - JsonStream(PrintWriter(baos).buffered()).use(streamable::toStream) - baos.toByteArray() - } -} - internal class DefaultDelivery( private val connectivity: Connectivity?, - val logger: Logger + private val apiKey: String, + private val maxStringValueLength: Int, + private val logger: Logger ) : Delivery { + companion object { + // 1MB with some fiddle room in case of encoding overhead + const val maxPayloadSize = 999700 + } + override fun deliver(payload: Session, deliveryParams: DeliveryParams): DeliveryStatus { - val status = deliver(deliveryParams.endpoint, payload, deliveryParams.headers) + val status = deliver( + deliveryParams.endpoint, + JsonHelper.serialize(payload), + deliveryParams.headers + ) logger.i("Session API request finished with status $status") return status } + private fun serializePayload(payload: EventPayload): ByteArray { + var json = JsonHelper.serialize(payload) + if (json.size <= maxPayloadSize) { + return json + } + + var event = payload.event + if (event == null) { + event = MarshalledEventSource(payload.eventFile!!, apiKey, logger).invoke() + payload.event = event + payload.apiKey = apiKey + } + + val (itemsTrimmed, dataTrimmed) = event.impl.trimMetadataStringsTo(maxStringValueLength) + event.impl.internalMetrics.setMetadataTrimMetrics( + itemsTrimmed, + dataTrimmed + ) + + json = JsonHelper.serialize(payload) + if (json.size <= maxPayloadSize) { + return json + } + + val breadcrumbAndBytesRemovedCounts = + event.impl.trimBreadcrumbsBy(json.size - maxPayloadSize) + event.impl.internalMetrics.setBreadcrumbTrimMetrics( + breadcrumbAndBytesRemovedCounts.itemsTrimmed, + breadcrumbAndBytesRemovedCounts.dataTrimmed + ) + return JsonHelper.serialize(payload) + } + override fun deliver(payload: EventPayload, deliveryParams: DeliveryParams): DeliveryStatus { - val status = deliver(deliveryParams.endpoint, payload, deliveryParams.headers) + val json = serializePayload(payload) + val status = deliver(deliveryParams.endpoint, json, deliveryParams.headers) logger.i("Error API request finished with status $status") return status } fun deliver( urlString: String, - streamable: JsonStream.Streamable, + json: ByteArray, headers: Map ): DeliveryStatus { @@ -50,7 +84,6 @@ internal class DefaultDelivery( var conn: HttpURLConnection? = null try { - val json = serializeJsonPayload(streamable) conn = makeRequest(URL(urlString), json, headers) // End the request, get the response code diff --git a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt.orig b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt.orig new file mode 100644 index 0000000000..72c8a9200f --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt.orig @@ -0,0 +1,175 @@ +package com.bugsnag.android + +import android.net.TrafficStats +import com.bugsnag.android.internal.JsonHelper +import java.io.IOException +import java.net.HttpURLConnection +import java.net.HttpURLConnection.HTTP_BAD_REQUEST +import java.net.HttpURLConnection.HTTP_CLIENT_TIMEOUT +import java.net.HttpURLConnection.HTTP_OK +import java.net.URL + +internal class DefaultDelivery( + private val connectivity: Connectivity?, + private val apiKey: String, + private val maxStringValueLength: Int, + private val logger: Logger +) : Delivery { + + companion object { + // 1MB with some fiddle room in case of encoding overhead + const val maxPayloadSize = 999700 + } + + override fun deliver(payload: Session, deliveryParams: DeliveryParams): DeliveryStatus { + val status = deliver( + deliveryParams.endpoint, + JsonHelper.serialize(payload), + deliveryParams.headers + ) + logger.i("Session API request finished with status $status") + return status + } + + private fun serializePayload(payload: EventPayload): ByteArray { + var json = JsonHelper.serialize(payload) + if (json.size <= maxPayloadSize) { + return json + } + + var event = payload.event + if (event == null) { + event = MarshalledEventSource(payload.eventFile!!, apiKey, logger).invoke() + payload.event = event + payload.apiKey = apiKey + } + + val (itemsTrimmed, dataTrimmed) = event.impl.trimMetadataStringsTo(maxStringValueLength) + event.impl.internalMetrics.setMetadataTrimMetrics( + itemsTrimmed, + dataTrimmed + ) + + json = JsonHelper.serialize(payload) + if (json.size <= maxPayloadSize) { + return json + } + + val breadcrumbAndBytesRemovedCounts = + event.impl.trimBreadcrumbsBy(json.size - maxPayloadSize) + event.impl.internalMetrics.setBreadcrumbTrimMetrics( + breadcrumbAndBytesRemovedCounts.itemsTrimmed, + breadcrumbAndBytesRemovedCounts.dataTrimmed + ) + return JsonHelper.serialize(payload) + } + + override fun deliver(payload: EventPayload, deliveryParams: DeliveryParams): DeliveryStatus { + val json = serializePayload(payload) + val status = deliver(deliveryParams.endpoint, json, deliveryParams.headers) + logger.i("Error API request finished with status $status") + return status + } + + fun deliver( + urlString: String, + json: ByteArray, + headers: Map + ): DeliveryStatus { + + TrafficStats.setThreadStatsTag(1) + if (connectivity != null && !connectivity.hasNetworkConnection()) { + return DeliveryStatus.UNDELIVERED + } + var conn: HttpURLConnection? = null + + try { + conn = makeRequest(URL(urlString), json, headers) + + // End the request, get the response code + val responseCode = conn.responseCode + val status = getDeliveryStatus(responseCode) + logRequestInfo(responseCode, conn, status) + return status + } catch (oom: OutOfMemoryError) { + // attempt to persist the payload on disk. This approach uses streams to write to a + // file, which takes less memory than serializing the payload into a ByteArray, and + // therefore has a reasonable chance of retaining the payload for future delivery. + logger.w("Encountered OOM delivering payload, falling back to persist on disk", oom) + return DeliveryStatus.UNDELIVERED + } catch (exception: IOException) { + logger.w("IOException encountered in request", exception) + return DeliveryStatus.UNDELIVERED + } catch (exception: Exception) { + logger.w("Unexpected error delivering payload", exception) + return DeliveryStatus.FAILURE + } finally { + conn?.disconnect() + } + } + + private fun makeRequest( + url: URL, + json: ByteArray, + headers: Map + ): HttpURLConnection { + val conn = url.openConnection() as HttpURLConnection + conn.doOutput = true + + // avoids creating a buffer within HttpUrlConnection, see + // https://developer.android.com/reference/java/net/HttpURLConnection + conn.setFixedLengthStreamingMode(json.size) + + // calculate the SHA-1 digest and add all other headers + computeSha1Digest(json)?.let { digest -> + conn.addRequestProperty(HEADER_BUGSNAG_INTEGRITY, digest) + } + headers.forEach { (key, value) -> + if (value != null) { + conn.addRequestProperty(key, value) + } + } + + // write the JSON payload + conn.outputStream.use { + it.write(json) + } + return conn + } + + private fun logRequestInfo(code: Int, conn: HttpURLConnection, status: DeliveryStatus) { + runCatching { + logger.i( + "Request completed with code $code, " + + "message: ${conn.responseMessage}, " + + "headers: ${conn.headerFields}" + ) + } + runCatching { + conn.inputStream.bufferedReader().use { + logger.d("Received request response: ${it.readText()}") + } + } + + runCatching { + if (status != DeliveryStatus.DELIVERED) { + conn.errorStream.bufferedReader().use { + logger.w("Request error details: ${it.readText()}") + } + } + } + } + + internal fun getDeliveryStatus(responseCode: Int): DeliveryStatus { + return when { + responseCode in HTTP_OK..299 -> DeliveryStatus.DELIVERED + isUnrecoverableStatusCode(responseCode) -> DeliveryStatus.FAILURE + else -> DeliveryStatus.UNDELIVERED + } + } + + private fun isUnrecoverableStatusCode(responseCode: Int) = + responseCode in HTTP_BAD_REQUEST..499 && // 400-499 are considered unrecoverable + responseCode != HTTP_CLIENT_TIMEOUT && // except for 408 + responseCode != 429 // and 429 +} diff --git a/app/src/main/java/com/bugsnag/android/DeliveryDelegate.java b/app/src/main/java/com/bugsnag/android/DeliveryDelegate.java index d9dd1aa168..7e4d1b207b 100644 --- a/app/src/main/java/com/bugsnag/android/DeliveryDelegate.java +++ b/app/src/main/java/com/bugsnag/android/DeliveryDelegate.java @@ -7,14 +7,15 @@ import com.bugsnag.android.internal.ImmutableConfig; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; class DeliveryDelegate extends BaseObservable { + @VisibleForTesting + static long DELIVERY_TIMEOUT = 3000L; + final Logger logger; private final EventStore eventStore; private final ImmutableConfig immutableConfig; @@ -55,7 +56,13 @@ class DeliveryDelegate extends BaseObservable { String severityReasonType = event.getImpl().getSeverityReasonType(); boolean promiseRejection = REASON_PROMISE_REJECTION.equals(severityReasonType); boolean anr = event.getImpl().isAnr(event); - cacheEvent(event, anr || promiseRejection); + if (anr || promiseRejection) { + cacheEvent(event, true); + } else if (immutableConfig.getAttemptDeliveryOnCrash()) { + cacheAndSendSynchronously(event); + } else { + cacheEvent(event, false); + } } else if (callbackState.runOnSendTasks(event, logger)) { // Build the eventPayload String apiKey = event.getApiKey(); @@ -107,6 +114,24 @@ class DeliveryDelegate extends BaseObservable { return deliveryStatus; } + private void cacheAndSendSynchronously(@NonNull Event event) { + long cutoffTime = System.currentTimeMillis() + DELIVERY_TIMEOUT; + Future task = eventStore.writeAndDeliver(event); + + long timeout = cutoffTime - System.currentTimeMillis(); + if (task != null && timeout > 0) { + try { + task.get(timeout, TimeUnit.MILLISECONDS); + } catch (Exception ex) { + logger.w("failed to immediately deliver event", ex); + } + + if (!task.isDone()) { + task.cancel(true); + } + } + } + private void cacheEvent(@NonNull Event event, boolean attemptSend) { eventStore.write(event); if (attemptSend) { diff --git a/app/src/main/java/com/bugsnag/android/ErrorTypes.kt b/app/src/main/java/com/bugsnag/android/ErrorTypes.kt index 03b75e7e87..63975d5d9c 100644 --- a/app/src/main/java/com/bugsnag/android/ErrorTypes.kt +++ b/app/src/main/java/com/bugsnag/android/ErrorTypes.kt @@ -33,4 +33,20 @@ class ErrorTypes( internal constructor(detectErrors: Boolean) : this(detectErrors, detectErrors, detectErrors, detectErrors) internal fun copy() = ErrorTypes(anrs, ndkCrashes, unhandledExceptions, unhandledRejections) + + override fun equals(other: Any?): Boolean { + return other is ErrorTypes && + anrs == other.anrs && + ndkCrashes == other.ndkCrashes && + unhandledExceptions == other.unhandledExceptions && + unhandledRejections == other.unhandledRejections + } + + override fun hashCode(): Int { + var result = anrs.hashCode() + result = 31 * result + ndkCrashes.hashCode() + result = 31 * result + unhandledExceptions.hashCode() + result = 31 * result + unhandledRejections.hashCode() + return result + } } diff --git a/app/src/main/java/com/bugsnag/android/Event.java b/app/src/main/java/com/bugsnag/android/Event.java index e3c9fa7d4e..181afeb75a 100644 --- a/app/src/main/java/com/bugsnag/android/Event.java +++ b/app/src/main/java/com/bugsnag/android/Event.java @@ -1,6 +1,7 @@ package com.bugsnag.android; import com.bugsnag.android.internal.ImmutableConfig; +import com.bugsnag.android.internal.InternalMetrics; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -9,7 +10,6 @@ import java.io.IOException; import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.Set; /** * An Event object represents a Throwable captured by Bugsnag and is available as a parameter on @@ -91,6 +91,15 @@ public class Event implements JsonStream.Streamable, MetadataAware, UserAware, F return impl.getBreadcrumbs(); } + /** + * A list of feature flags active at the time of the event. + * See {@link FeatureFlag} for details of the data available. + */ + @NonNull + public List getFeatureFlags() { + return impl.getFeatureFlags().toList(); + } + /** * Information set by the notifier about your app can be found in this field. These values * can be accessed and amended if necessary. @@ -412,4 +421,8 @@ public class Event implements JsonStream.Streamable, MetadataAware, UserAware, F void setRedactedKeys(Collection redactedKeys) { impl.setRedactedKeys(redactedKeys); } + + void setInternalMetrics(InternalMetrics metrics) { + impl.setInternalMetrics(metrics); + } } diff --git a/app/src/main/java/com/bugsnag/android/EventInternal.kt b/app/src/main/java/com/bugsnag/android/EventInternal.kt index 7c10ee61d9..a7224b2f93 100644 --- a/app/src/main/java/com/bugsnag/android/EventInternal.kt +++ b/app/src/main/java/com/bugsnag/android/EventInternal.kt @@ -1,6 +1,10 @@ package com.bugsnag.android import com.bugsnag.android.internal.ImmutableConfig +import com.bugsnag.android.internal.InternalMetrics +import com.bugsnag.android.internal.InternalMetricsNoop +import com.bugsnag.android.internal.JsonHelper +import com.bugsnag.android.internal.TrimMetrics import java.io.IOException internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, MetadataAware, UserAware { @@ -14,6 +18,7 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata featureFlags: FeatureFlags = FeatureFlags() ) : this( config.apiKey, + config.logger, mutableListOf(), config.discardClasses.toSet(), when (originalError) { @@ -32,6 +37,7 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata internal constructor( apiKey: String, + logger: Logger, breadcrumbs: MutableList = mutableListOf(), discardClasses: Set = setOf(), errors: MutableList = mutableListOf(), @@ -44,6 +50,7 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata user: User = User(), redactionKeys: Set? = null ) { + this.logger = logger this.apiKey = apiKey this.breadcrumbs = breadcrumbs this.discardClasses = discardClasses @@ -64,6 +71,7 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata val originalError: Throwable? internal var severityReason: SeverityReason + val logger: Logger val metadata: Metadata val featureFlags: FeatureFlags private val discardClasses: Set @@ -103,6 +111,7 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata jsonStreamer.redactedKeys = value.toSet() metadata.redactedKeys = value.toSet() } + var internalMetrics: InternalMetrics = InternalMetricsNoop() /** * @return user information associated with this Event @@ -162,6 +171,15 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata writer.name("device").value(device) writer.name("breadcrumbs").value(breadcrumbs) writer.name("groupingHash").value(groupingHash) + val usage = internalMetrics.toJsonableMap() + if (usage.isNotEmpty()) { + writer.name("usage") + writer.beginObject() + usage.forEach { entry -> + writer.name(entry.key).value(entry.value) + } + writer.endObject() + } writer.name("threads") writer.beginArray() @@ -229,6 +247,41 @@ internal class EventInternal : FeatureFlagAware, JsonStream.Streamable, Metadata fun getSeverityReasonType(): String = severityReason.severityReasonType + fun trimMetadataStringsTo(maxLength: Int): TrimMetrics { + var stringCount = 0 + var charCount = 0 + + var stringAndCharCounts = metadata.trimMetadataStringsTo(maxLength) + stringCount += stringAndCharCounts.itemsTrimmed + charCount += stringAndCharCounts.dataTrimmed + for (breadcrumb in breadcrumbs) { + stringAndCharCounts = breadcrumb.impl.trimMetadataStringsTo(maxLength) + stringCount += stringAndCharCounts.itemsTrimmed + charCount += stringAndCharCounts.dataTrimmed + } + return TrimMetrics(stringCount, charCount) + } + + fun trimBreadcrumbsBy(byteCount: Int): TrimMetrics { + var removedBreadcrumbCount = 0 + var removedByteCount = 0 + while (removedByteCount < byteCount && breadcrumbs.isNotEmpty()) { + val breadcrumb = breadcrumbs.removeAt(0) + removedByteCount += JsonHelper.serialize(breadcrumb).size + removedBreadcrumbCount++ + } + when (removedBreadcrumbCount) { + 1 -> breadcrumbs.add(Breadcrumb("Removed to reduce payload size", logger)) + else -> breadcrumbs.add( + Breadcrumb( + "Removed, along with ${removedBreadcrumbCount - 1} older breadcrumbs, to reduce payload size", + logger + ) + ) + } + return TrimMetrics(removedBreadcrumbCount, removedByteCount) + } + override fun setUser(id: String?, email: String?, name: String?) { userImpl = User(id, email, name) } diff --git a/app/src/main/java/com/bugsnag/android/EventPayload.kt b/app/src/main/java/com/bugsnag/android/EventPayload.kt index f0f70e83f9..51f213016d 100644 --- a/app/src/main/java/com/bugsnag/android/EventPayload.kt +++ b/app/src/main/java/com/bugsnag/android/EventPayload.kt @@ -12,17 +12,21 @@ import java.io.IOException */ class EventPayload @JvmOverloads internal constructor( var apiKey: String?, - val event: Event? = null, + event: Event? = null, internal val eventFile: File? = null, notifier: Notifier, private val config: ImmutableConfig ) : JsonStream.Streamable { + var event = event + internal set(value) { field = value } + internal val notifier = Notifier(notifier.name, notifier.version, notifier.url).apply { dependencies = notifier.dependencies.toMutableList() } internal fun getErrorTypes(): Set { + val event = this.event return when { event != null -> event.impl.getErrorTypesFromStackframes() eventFile != null -> EventFilenameInfo.fromFile(eventFile, config).errorTypes diff --git a/app/src/main/java/com/bugsnag/android/EventStore.java b/app/src/main/java/com/bugsnag/android/EventStore.java index 5ae0c04c50..593003ce99 100644 --- a/app/src/main/java/com/bugsnag/android/EventStore.java +++ b/app/src/main/java/com/bugsnag/android/EventStore.java @@ -13,6 +13,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; +import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; @@ -132,6 +133,26 @@ class EventStore extends FileStore { return launchCrashes.isEmpty() ? null : launchCrashes.get(launchCrashes.size() - 1); } + @Nullable + Future writeAndDeliver(@NonNull final JsonStream.Streamable streamable) { + final String filename = write(streamable); + + if (filename != null) { + try { + return bgTaskSevice.submitTask(TaskType.ERROR_REQUEST, new Callable() { + public String call() { + flushEventFile(new File(filename)); + return filename; + } + }); + } catch (RejectedExecutionException exception) { + logger.w("Failed to flush all on-disk errors, retaining unsent errors for later."); + } + } + + return null; + } + /** * Flush any on-disk errors to Bugsnag */ @@ -163,7 +184,7 @@ class EventStore extends FileStore { } } - private void flushEventFile(File eventFile) { + void flushEventFile(File eventFile) { try { EventFilenameInfo eventInfo = EventFilenameInfo.fromFile(eventFile, config); String apiKey = eventInfo.getApiKey(); diff --git a/app/src/main/java/com/bugsnag/android/FeatureFlags.kt b/app/src/main/java/com/bugsnag/android/FeatureFlags.kt index bce01bff5d..97f3ed47d6 100644 --- a/app/src/main/java/com/bugsnag/android/FeatureFlags.kt +++ b/app/src/main/java/com/bugsnag/android/FeatureFlags.kt @@ -1,39 +1,40 @@ package com.bugsnag.android import java.io.IOException -import java.util.concurrent.ConcurrentHashMap internal class FeatureFlags( - internal val store: MutableMap = ConcurrentHashMap() + internal val store: MutableMap = mutableMapOf() ) : JsonStream.Streamable, FeatureFlagAware { private val emptyVariant = "__EMPTY_VARIANT_SENTINEL__" - override fun addFeatureFlag(name: String) { - store[name] = emptyVariant + @Synchronized override fun addFeatureFlag(name: String) { + addFeatureFlag(name, null) } - override fun addFeatureFlag(name: String, variant: String?) { + @Synchronized override fun addFeatureFlag(name: String, variant: String?) { + store.remove(name) store[name] = variant ?: emptyVariant } - override fun addFeatureFlags(featureFlags: Iterable) { + @Synchronized override fun addFeatureFlags(featureFlags: Iterable) { featureFlags.forEach { (name, variant) -> addFeatureFlag(name, variant) } } - override fun clearFeatureFlag(name: String) { + @Synchronized override fun clearFeatureFlag(name: String) { store.remove(name) } - override fun clearFeatureFlags() { + @Synchronized override fun clearFeatureFlags() { store.clear() } @Throws(IOException::class) override fun toStream(stream: JsonStream) { + val storeCopy = synchronized(this) { store.toMap() } stream.beginArray() - store.forEach { (name, variant) -> + storeCopy.forEach { (name, variant) -> stream.beginObject() stream.name("featureFlag").value(name) if (variant != emptyVariant) { @@ -44,9 +45,9 @@ internal class FeatureFlags( stream.endArray() } - fun toList(): List = store.entries.map { (name, variant) -> + @Synchronized fun toList(): List = store.entries.map { (name, variant) -> FeatureFlag(name, variant.takeUnless { it == emptyVariant }) } - fun copy() = FeatureFlags(store.toMutableMap()) + @Synchronized fun copy() = FeatureFlags(store.toMutableMap()) } diff --git a/app/src/main/java/com/bugsnag/android/InternalReportDelegate.java b/app/src/main/java/com/bugsnag/android/InternalReportDelegate.java index b299ac201c..d4001aa3e5 100644 --- a/app/src/main/java/com/bugsnag/android/InternalReportDelegate.java +++ b/app/src/main/java/com/bugsnag/android/InternalReportDelegate.java @@ -4,6 +4,7 @@ import static com.bugsnag.android.DeliveryHeadersKt.HEADER_INTERNAL_ERROR; import static com.bugsnag.android.SeverityReason.REASON_UNHANDLED_EXCEPTION; import com.bugsnag.android.internal.ImmutableConfig; +import com.bugsnag.android.internal.JsonHelper; import android.annotation.SuppressLint; import android.content.Context; @@ -121,7 +122,11 @@ class InternalReportDelegate implements EventStore.Delegate { headers.put(HEADER_INTERNAL_ERROR, "bugsnag-android"); headers.remove(DeliveryHeadersKt.HEADER_API_KEY); DefaultDelivery defaultDelivery = (DefaultDelivery) delivery; - defaultDelivery.deliver(params.getEndpoint(), payload, headers); + defaultDelivery.deliver( + params.getEndpoint(), + JsonHelper.INSTANCE.serialize(payload), + headers + ); } } catch (Exception exception) { diff --git a/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt b/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt index ad166e35b8..b121cbf83a 100644 --- a/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt +++ b/app/src/main/java/com/bugsnag/android/ManifestConfigLoader.kt @@ -42,6 +42,7 @@ internal class ManifestConfigLoader { private const val LAUNCH_DURATION_MILLIS = "$BUGSNAG_NS.LAUNCH_DURATION_MILLIS" private const val SEND_LAUNCH_CRASHES_SYNCHRONOUSLY = "$BUGSNAG_NS.SEND_LAUNCH_CRASHES_SYNCHRONOUSLY" private const val APP_TYPE = "$BUGSNAG_NS.APP_TYPE" + private const val ATTEMPT_DELIVERY_ON_CRASH = "$BUGSNAG_NS.ATTEMPT_DELIVERY_ON_CRASH" } fun load(ctx: Context, userSuppliedApiKey: String?): Configuration { @@ -91,6 +92,10 @@ internal class ManifestConfigLoader { SEND_LAUNCH_CRASHES_SYNCHRONOUSLY, sendLaunchCrashesSynchronously ) + isAttemptDeliveryOnCrash = data.getBoolean( + ATTEMPT_DELIVERY_ON_CRASH, + isAttemptDeliveryOnCrash + ) } } return config diff --git a/app/src/main/java/com/bugsnag/android/Metadata.kt b/app/src/main/java/com/bugsnag/android/Metadata.kt index 4dc8a51668..7333c1185b 100644 --- a/app/src/main/java/com/bugsnag/android/Metadata.kt +++ b/app/src/main/java/com/bugsnag/android/Metadata.kt @@ -2,6 +2,8 @@ package com.bugsnag.android +import com.bugsnag.android.internal.StringUtils +import com.bugsnag.android.internal.TrimMetrics import java.io.IOException import java.util.concurrent.ConcurrentHashMap @@ -137,4 +139,19 @@ internal data class Metadata @JvmOverloads constructor( return this.copy(store = toMap()) .also { it.redactedKeys = redactedKeys.toSet() } } + + fun trimMetadataStringsTo(maxStringLength: Int): TrimMetrics { + var stringCount = 0 + var charCount = 0 + store.forEach { entry -> + val stringAndCharCounts = StringUtils.trimStringValuesTo( + maxStringLength, + entry.value as MutableMap + ) + + stringCount += stringAndCharCounts.itemsTrimmed + charCount += stringAndCharCounts.dataTrimmed + } + return TrimMetrics(stringCount, charCount) + } } diff --git a/app/src/main/java/com/bugsnag/android/NativeInterface.java b/app/src/main/java/com/bugsnag/android/NativeInterface.java index e7b90736fa..610c9d6a19 100644 --- a/app/src/main/java/com/bugsnag/android/NativeInterface.java +++ b/app/src/main/java/com/bugsnag/android/NativeInterface.java @@ -1,13 +1,18 @@ package com.bugsnag.android; import com.bugsnag.android.internal.ImmutableConfig; +import com.bugsnag.android.internal.JsonHelper; import android.annotation.SuppressLint; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.nio.charset.Charset; +import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashMap; @@ -38,6 +43,26 @@ public class NativeInterface { } } + /** + * Create an empty Event for a "handled exception" report. The returned Event will have + * no Error objects, metadata, breadcrumbs, or feature flags. It's indented that the caller + * will populate the Error and then pass the Event object to + * {@link Client#populateAndNotifyAndroidEvent(Event, OnErrorCallback)}. + */ + private static Event createEmptyEvent() { + Client client = getClient(); + + return new Event( + new EventInternal( + (Throwable) null, + client.getConfig(), + SeverityReason.newInstance(SeverityReason.REASON_HANDLED_EXCEPTION), + client.getMetadataState().getMetadata().copy() + ), + client.getLogger() + ); + } + /** * Caches a client instance for responding to future events */ @@ -54,10 +79,16 @@ public class NativeInterface { * Retrieves the directory used to store native crash reports */ @NonNull - public static String getNativeReportPath() { - ImmutableConfig config = getClient().getConfig(); - File persistenceDirectory = config.getPersistenceDirectory().getValue(); - return new File(persistenceDirectory, "bugsnag-native").getAbsolutePath(); + public static File getNativeReportPath() { + return getNativeReportPath(getPersistenceDirectory()); + } + + private static @NonNull File getNativeReportPath(@NonNull File persistenceDirectory) { + return new File(persistenceDirectory, "bugsnag-native"); + } + + private static @NonNull File getPersistenceDirectory() { + return getClient().getConfig().getPersistenceDirectory().getValue(); } /** @@ -65,7 +96,7 @@ public class NativeInterface { */ @NonNull @SuppressWarnings("unused") - public static Map getUser() { + public static Map getUser() { HashMap userData = new HashMap<>(); User user = getClient().getUser(); userData.put("id", user.getId()); @@ -79,8 +110,8 @@ public class NativeInterface { */ @NonNull @SuppressWarnings("unused") - public static Map getApp() { - HashMap data = new HashMap<>(); + public static Map getApp() { + HashMap data = new HashMap<>(); AppDataCollector source = getClient().getAppDataCollector(); AppWithState app = source.generateAppWithState(); data.put("version", app.getVersion()); @@ -103,7 +134,7 @@ public class NativeInterface { */ @NonNull @SuppressWarnings("unused") - public static Map getDevice() { + public static Map getDevice() { DeviceDataCollector source = getClient().getDeviceDataCollector(); HashMap deviceData = new HashMap<>(source.getDeviceMetadata()); @@ -152,9 +183,9 @@ public class NativeInterface { /** * Sets the user * - * @param id id + * @param id id * @param email email - * @param name name + * @param name name */ @SuppressWarnings("unused") public static void setUser(@Nullable final String id, @@ -167,9 +198,9 @@ public class NativeInterface { /** * Sets the user * - * @param idBytes id + * @param idBytes id * @param emailBytes email - * @param nameBytes name + * @param nameBytes name */ @SuppressWarnings("unused") public static void setUser(@Nullable final byte[] idBytes, @@ -300,6 +331,36 @@ public class NativeInterface { unhandledCount, handledCount); } + /** + * Ask if an error class is on the configurable discard list. + * This is used by the native layer to decide whether to pass an event to + * deliverReport() or not. + * + * @param name The error class to ask about. + */ + @SuppressWarnings("unused") + public static boolean isDiscardErrorClass(@NonNull String name) { + return getClient().getConfig().getDiscardClasses().contains(name); + } + + @SuppressWarnings("unchecked") + private static void deepMerge(Map src, Map dst) { + for (Map.Entry entry: src.entrySet()) { + String key = entry.getKey(); + Object srcValue = entry.getValue(); + Object dstValue = dst.get(key); + if (srcValue instanceof Map && (dstValue instanceof Map)) { + deepMerge((Map)srcValue, (Map)dstValue); + } else if (srcValue instanceof Collection && dstValue instanceof Collection) { + // Just append everything because we don't know enough about the context or + // provenance of the data to make an intelligent decision about this. + ((Collection)dstValue).addAll((Collection)srcValue); + } else { + dst.put(key, srcValue); + } + } + } + /** * Deliver a report, serialized as an event JSON payload. * @@ -307,18 +368,31 @@ public class NativeInterface { * captured. Used to determine whether the report * should be discarded, based on configured release * stages - * @param payloadBytes The raw JSON payload of the event - * @param apiKey The apiKey for the event - * @param isLaunching whether the crash occurred when the app was launching + * @param payloadBytes The raw JSON payload of the event + * @param apiKey The apiKey for the event + * @param isLaunching whether the crash occurred when the app was launching */ @SuppressWarnings("unused") public static void deliverReport(@Nullable byte[] releaseStageBytes, @NonNull byte[] payloadBytes, + @Nullable byte[] staticDataBytes, @NonNull String apiKey, boolean isLaunching) { - if (payloadBytes == null) { - return; + // If there's saved static data, merge it directly into the payload map. + if (staticDataBytes != null) { + @SuppressWarnings("unchecked") + Map payloadMap = (Map) JsonHelper.INSTANCE.deserialize( + new ByteArrayInputStream(payloadBytes)); + @SuppressWarnings("unchecked") + Map staticDataMap = + (Map) JsonHelper.INSTANCE.deserialize( + new ByteArrayInputStream(staticDataBytes)); + deepMerge(staticDataMap, payloadMap); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + JsonHelper.INSTANCE.serialize(payloadMap, os); + payloadBytes = os.toByteArray(); } + String payload = new String(payloadBytes, UTF8Charset); String releaseStage = releaseStageBytes == null ? null @@ -341,10 +415,10 @@ public class NativeInterface { /** * Notifies using the Android SDK * - * @param nameBytes the error name + * @param nameBytes the error name * @param messageBytes the error message - * @param severity the error severity - * @param stacktrace a stacktrace + * @param severity the error severity + * @param stacktrace a stacktrace */ public static void notify(@NonNull final byte[] nameBytes, @NonNull final byte[] messageBytes, @@ -361,9 +435,9 @@ public class NativeInterface { /** * Notifies using the Android SDK * - * @param name the error name - * @param message the error message - * @param severity the error severity + * @param name the error name + * @param message the error message + * @param severity the error severity * @param stacktrace a stacktrace */ public static void notify(@NonNull final String name, @@ -397,11 +471,65 @@ public class NativeInterface { }); } + /** + * Notifies using the Android SDK + * + * @param nameBytes the error name + * @param messageBytes the error message + * @param severity the error severity + * @param stacktrace a stacktrace + */ + public static void notify(@NonNull final byte[] nameBytes, + @NonNull final byte[] messageBytes, + @NonNull final Severity severity, + @NonNull final NativeStackframe[] stacktrace) { + + if (nameBytes == null || messageBytes == null || stacktrace == null) { + return; + } + String name = new String(nameBytes, UTF8Charset); + String message = new String(messageBytes, UTF8Charset); + notify(name, message, severity, stacktrace); + } + + /** + * Notifies using the Android SDK + * + * @param name the error name + * @param message the error message + * @param severity the error severity + * @param stacktrace a stacktrace + */ + public static void notify(@NonNull final String name, + @NonNull final String message, + @NonNull final Severity severity, + @NonNull final NativeStackframe[] stacktrace) { + Client client = getClient(); + + if (client.getConfig().shouldDiscardError(name)) { + return; + } + + Event event = createEmptyEvent(); + event.updateSeverityInternal(severity); + + List stackframes = new ArrayList<>(stacktrace.length); + for (NativeStackframe nativeStackframe : stacktrace) { + stackframes.add(new Stackframe(nativeStackframe)); + } + event.getErrors().add(new Error( + new ErrorInternal(name, message, new Stacktrace(stackframes), ErrorType.C), + client.getLogger() + )); + + getClient().populateAndNotifyAndroidEvent(event, null); + } + /** * Create an {@code Event} object * - * @param exc the Throwable object that caused the event - * @param client the Client object that the event is associated with + * @param exc the Throwable object that caused the event + * @param client the Client object that the event is associated with * @param severityReason the severity of the Event * @return a new {@code Event} object */ @@ -471,5 +599,4 @@ public class NativeInterface { public static LastRunInfo getLastRunInfo() { return getClient().getLastRunInfo(); } - } diff --git a/app/src/main/java/com/bugsnag/android/NativeStackframe.kt b/app/src/main/java/com/bugsnag/android/NativeStackframe.kt index 3e82416a85..30b40be440 100644 --- a/app/src/main/java/com/bugsnag/android/NativeStackframe.kt +++ b/app/src/main/java/com/bugsnag/android/NativeStackframe.kt @@ -1,5 +1,6 @@ package com.bugsnag.android +import com.bugsnag.android.internal.JsonHelper import java.io.IOException /** @@ -59,9 +60,9 @@ class NativeStackframe internal constructor( writer.name("method").value(method) writer.name("file").value(file) writer.name("lineNumber").value(lineNumber) - writer.name("frameAddress").value(frameAddress) - writer.name("symbolAddress").value(symbolAddress) - writer.name("loadAddress").value(loadAddress) + frameAddress?.let { writer.name("frameAddress").value(JsonHelper.ulongToHex(frameAddress)) } + symbolAddress?.let { writer.name("symbolAddress").value(JsonHelper.ulongToHex(symbolAddress)) } + loadAddress?.let { writer.name("loadAddress").value(JsonHelper.ulongToHex(loadAddress)) } writer.name("codeIdentifier").value(codeIdentifier) writer.name("isPC").value(isPC) diff --git a/app/src/main/java/com/bugsnag/android/NdkPluginCaller.kt b/app/src/main/java/com/bugsnag/android/NdkPluginCaller.kt new file mode 100644 index 0000000000..11deefa3cf --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/NdkPluginCaller.kt @@ -0,0 +1,101 @@ +package com.bugsnag.android + +import java.lang.reflect.Method + +/** + * Calls the NDK plugin if it is loaded, otherwise does nothing / returns the default. + */ +internal object NdkPluginCaller { + private var ndkPlugin: Plugin? = null + private var setInternalMetricsEnabled: Method? = null + private var setStaticData: Method? = null + private var getSignalUnwindStackFunction: Method? = null + private var getCurrentCallbackSetCounts: Method? = null + private var getCurrentNativeApiCallUsage: Method? = null + private var initCallbackCounts: Method? = null + private var notifyAddCallback: Method? = null + private var notifyRemoveCallback: Method? = null + + private fun getMethod(name: String, vararg parameterTypes: Class<*>): Method? { + val plugin = ndkPlugin + if (plugin == null) { + return null + } + return plugin.javaClass.getMethod(name, *parameterTypes) + } + + fun setNdkPlugin(plugin: Plugin?) { + if (plugin != null) { + ndkPlugin = plugin + setInternalMetricsEnabled = getMethod("setInternalMetricsEnabled", Boolean::class.java) + setStaticData = getMethod("setStaticData", Map::class.java) + getSignalUnwindStackFunction = getMethod("getSignalUnwindStackFunction") + getCurrentCallbackSetCounts = getMethod("getCurrentCallbackSetCounts") + getCurrentNativeApiCallUsage = getMethod("getCurrentNativeApiCallUsage") + initCallbackCounts = getMethod("initCallbackCounts", Map::class.java) + notifyAddCallback = getMethod("notifyAddCallback", String::class.java) + notifyRemoveCallback = getMethod("notifyRemoveCallback", String::class.java) + } + } + + fun getSignalUnwindStackFunction(): Long { + val method = getSignalUnwindStackFunction + if (method != null) { + return method.invoke(ndkPlugin) as Long + } + return 0 + } + + fun setInternalMetricsEnabled(enabled: Boolean) { + val method = setInternalMetricsEnabled + if (method != null) { + method.invoke(ndkPlugin, enabled) + } + } + + fun getCurrentCallbackSetCounts(): Map? { + val method = getCurrentCallbackSetCounts + if (method != null) { + @Suppress("UNCHECKED_CAST") + return method.invoke(ndkPlugin) as Map + } + return null + } + + fun getCurrentNativeApiCallUsage(): Map? { + val method = getCurrentNativeApiCallUsage + if (method != null) { + @Suppress("UNCHECKED_CAST") + return method.invoke(ndkPlugin) as Map + } + return null + } + + fun initCallbackCounts(counts: Map) { + val method = initCallbackCounts + if (method != null) { + method.invoke(ndkPlugin, counts) + } + } + + fun notifyAddCallback(callback: String) { + val method = notifyAddCallback + if (method != null) { + method.invoke(ndkPlugin, callback) + } + } + + fun notifyRemoveCallback(callback: String) { + val method = notifyRemoveCallback + if (method != null) { + method.invoke(ndkPlugin, callback) + } + } + + fun setStaticData(data: Map) { + val method = setStaticData + if (method != null) { + method.invoke(ndkPlugin, data) + } + } +} diff --git a/app/src/main/java/com/bugsnag/android/Notifier.kt b/app/src/main/java/com/bugsnag/android/Notifier.kt index d131059fc1..8341ec00b9 100644 --- a/app/src/main/java/com/bugsnag/android/Notifier.kt +++ b/app/src/main/java/com/bugsnag/android/Notifier.kt @@ -7,7 +7,7 @@ import java.io.IOException */ class Notifier @JvmOverloads constructor( var name: String = "Android Bugsnag Notifier", - var version: String = "5.23.0", + var version: String = "5.28.2", var url: String = "https://bugsnag.com" ) : JsonStream.Streamable { diff --git a/app/src/main/java/com/bugsnag/android/PluginClient.kt b/app/src/main/java/com/bugsnag/android/PluginClient.kt index 01f268bd39..3764cf180c 100644 --- a/app/src/main/java/com/bugsnag/android/PluginClient.kt +++ b/app/src/main/java/com/bugsnag/android/PluginClient.kt @@ -45,6 +45,8 @@ internal class PluginClient( } } + fun getNdkPlugin(): Plugin? = ndkPlugin + fun loadPlugins(client: Client) { plugins.forEach { plugin -> try { diff --git a/app/src/main/java/com/bugsnag/android/Stackframe.kt b/app/src/main/java/com/bugsnag/android/Stackframe.kt index 7ac08889bd..ed43d5b7e9 100644 --- a/app/src/main/java/com/bugsnag/android/Stackframe.kt +++ b/app/src/main/java/com/bugsnag/android/Stackframe.kt @@ -1,5 +1,6 @@ package com.bugsnag.android +import com.bugsnag.android.internal.JsonHelper import java.io.IOException /** @@ -103,13 +104,13 @@ class Stackframe : JsonStream.Streamable { internal constructor(json: Map) { method = json["method"] as? String file = json["file"] as? String - lineNumber = json["lineNumber"] as? Number + lineNumber = JsonHelper.jsonToLong(json["lineNumber"]) inProject = json["inProject"] as? Boolean columnNumber = json["columnNumber"] as? Number - frameAddress = (json["frameAddress"] as? Number)?.toLong() - symbolAddress = (json["symbolAddress"] as? Number)?.toLong() - loadAddress = (json["loadAddress"] as? Number)?.toLong() - codeIdentifier = (json["codeIdentifier"] as? String) + frameAddress = JsonHelper.jsonToLong(json["frameAddress"]) + symbolAddress = JsonHelper.jsonToLong(json["symbolAddress"]) + loadAddress = JsonHelper.jsonToLong(json["loadAddress"]) + codeIdentifier = json["codeIdentifier"] as? String isPC = json["isPC"] as? Boolean @Suppress("UNCHECKED_CAST") @@ -128,9 +129,9 @@ class Stackframe : JsonStream.Streamable { writer.name("columnNumber").value(columnNumber) - frameAddress?.let { writer.name("frameAddress").value(it) } - symbolAddress?.let { writer.name("symbolAddress").value(it) } - loadAddress?.let { writer.name("loadAddress").value(it) } + frameAddress?.let { writer.name("frameAddress").value(JsonHelper.ulongToHex(frameAddress)) } + symbolAddress?.let { writer.name("symbolAddress").value(JsonHelper.ulongToHex(symbolAddress)) } + loadAddress?.let { writer.name("loadAddress").value(JsonHelper.ulongToHex(loadAddress)) } codeIdentifier?.let { writer.name("codeIdentifier").value(it) } isPC?.let { writer.name("isPC").value(it) } diff --git a/app/src/main/java/com/bugsnag/android/Telemetry.kt b/app/src/main/java/com/bugsnag/android/Telemetry.kt index 7d5a070a71..dde78fd5d6 100644 --- a/app/src/main/java/com/bugsnag/android/Telemetry.kt +++ b/app/src/main/java/com/bugsnag/android/Telemetry.kt @@ -8,7 +8,12 @@ enum class Telemetry { /** * Errors within the Bugsnag SDK. */ - INTERNAL_ERRORS; + INTERNAL_ERRORS, + + /** + * Differences from the default configuration. + */ + USAGE; internal companion object { fun fromString(str: String) = values().find { it.name == str } ?: INTERNAL_ERRORS diff --git a/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt b/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt index 06558cfca2..07f2dcb835 100644 --- a/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt +++ b/app/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt @@ -52,6 +52,7 @@ data class ImmutableConfig( val maxReportedThreads: Int, val persistenceDirectory: Lazy, val sendLaunchCrashesSynchronously: Boolean, + val attemptDeliveryOnCrash: Boolean, // results cached here to avoid unnecessary lookups in Client. val packageInfo: PackageInfo?, @@ -167,6 +168,7 @@ internal fun convertToImmutableConfig( telemetry = config.telemetry.toSet(), persistenceDirectory = persistenceDir, sendLaunchCrashesSynchronously = config.sendLaunchCrashesSynchronously, + attemptDeliveryOnCrash = config.isAttemptDeliveryOnCrash, packageInfo = packageInfo, appInfo = appInfo, redactedKeys = config.redactedKeys.toSet() @@ -220,7 +222,12 @@ internal fun sanitiseConfiguration( @Suppress("SENSELESS_COMPARISON") if (configuration.delivery == null) { - configuration.delivery = DefaultDelivery(connectivity, configuration.logger!!) + configuration.delivery = DefaultDelivery( + connectivity, + configuration.apiKey, + configuration.maxStringValueLength, + configuration.logger!! + ) } return convertToImmutableConfig( configuration, diff --git a/app/src/main/java/com/bugsnag/android/internal/InternalMetrics.kt b/app/src/main/java/com/bugsnag/android/internal/InternalMetrics.kt new file mode 100644 index 0000000000..0fbdb53f63 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/internal/InternalMetrics.kt @@ -0,0 +1,28 @@ +package com.bugsnag.android.internal + +/** + * Stores internal metrics for Bugsnag use. + */ +interface InternalMetrics { + /** + * Returns a map that can be merged with the top-level JSON report. + */ + fun toJsonableMap(): Map + + fun setConfigDifferences(differences: Map) + + fun setCallbackCounts(newCallbackCounts: Map) + + fun notifyAddCallback(callback: String) + + fun notifyRemoveCallback(callback: String) + + fun setMetadataTrimMetrics(stringsTrimmed: Int, charsRemoved: Int) + + fun setBreadcrumbTrimMetrics(breadcrumbsRemoved: Int, bytesRemoved: Int) +} + +internal data class TrimMetrics( + val itemsTrimmed: Int, // breadcrumbs, strings, whatever + val dataTrimmed: Int // chars, bytes, whatever +) diff --git a/app/src/main/java/com/bugsnag/android/internal/InternalMetricsImpl.kt b/app/src/main/java/com/bugsnag/android/internal/InternalMetricsImpl.kt new file mode 100644 index 0000000000..6e648e5f48 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/internal/InternalMetricsImpl.kt @@ -0,0 +1,111 @@ +package com.bugsnag.android.internal + +import com.bugsnag.android.NdkPluginCaller + +class InternalMetricsImpl(source: Map? = null) : InternalMetrics { + private val configDifferences: MutableMap + private val callbackCounts: MutableMap + private var metadataStringsTrimmedCount = 0 + private var metadataCharsTruncatedCount = 0 + private var breadcrumbsRemovedCount = 0 + private var breadcrumbBytesRemovedCount = 0 + + init { + if (source != null) { + @Suppress("UNCHECKED_CAST") + configDifferences = (source["config"] as MutableMap?) ?: hashMapOf() + @Suppress("UNCHECKED_CAST") + callbackCounts = (source["callbacks"] as MutableMap?) ?: hashMapOf() + @Suppress("UNCHECKED_CAST") + val system = source["system"] as MutableMap? + if (system != null) { + metadataStringsTrimmedCount = (system["stringsTruncated"] as Number?)?.toInt() ?: 0 + metadataCharsTruncatedCount = (system["stringCharsTruncated"] as Number?)?.toInt() ?: 0 + breadcrumbsRemovedCount = (system["breadcrumbsRemovedCount"] as Number?)?.toInt() ?: 0 + breadcrumbBytesRemovedCount = (system["breadcrumbBytesRemoved"] as Number?)?.toInt() ?: 0 + } + } else { + configDifferences = hashMapOf() + callbackCounts = hashMapOf() + } + } + + override fun toJsonableMap(): Map { + val callbacks = allCallbacks() + + val system = listOfNotNull( + if (metadataStringsTrimmedCount > 0) "stringsTruncated" to metadataStringsTrimmedCount else null, + if (metadataCharsTruncatedCount > 0) "stringCharsTruncated" to metadataCharsTruncatedCount else null, + if (breadcrumbsRemovedCount > 0) "breadcrumbsRemoved" to breadcrumbsRemovedCount else null, + if (breadcrumbBytesRemovedCount > 0) "breadcrumbBytesRemoved" to breadcrumbBytesRemovedCount else null, + ).toMap() + + return listOfNotNull( + if (configDifferences.isNotEmpty()) "config" to configDifferences else null, + if (callbacks.isNotEmpty()) "callbacks" to callbacks else null, + if (system.isNotEmpty()) "system" to system else null, + ).toMap() + } + + override fun setConfigDifferences(differences: Map) { + configDifferences.clear() + configDifferences.putAll(differences) + // This is currently the only place where we set static data. + // When that changes in future, we'll need a StaticData object to properly merge data + // coming from multiple sources. + NdkPluginCaller.setStaticData(mapOf("usage" to mapOf("config" to configDifferences))) + } + + override fun setCallbackCounts(newCallbackCounts: Map) { + callbackCounts.clear() + callbackCounts.putAll(newCallbackCounts) + NdkPluginCaller.initCallbackCounts(newCallbackCounts) + } + + override fun notifyAddCallback(callback: String) { + modifyCallback(callback, 1) + NdkPluginCaller.notifyAddCallback(callback) + } + + override fun notifyRemoveCallback(callback: String) { + modifyCallback(callback, -1) + NdkPluginCaller.notifyRemoveCallback(callback) + } + + private fun modifyCallback(callback: String, delta: Int) { + var currentValue = callbackCounts[callback] ?: 0 + currentValue += delta + callbackCounts[callback] = currentValue.coerceAtLeast(0) + } + + private fun allCallbacks(): Map { + val result = hashMapOf() + result.putAll(callbackCounts) + + val counts = NdkPluginCaller.getCurrentCallbackSetCounts() + if (counts != null) { + // ndkOnError comes from the native side. The rest we already have. + val ndkOnError = counts["ndkOnError"] + if (ndkOnError != null) { + result["ndkOnError"] = ndkOnError + } + } + + val usage = NdkPluginCaller.getCurrentNativeApiCallUsage() + if (usage != null) { + result.putAll(usage) + } + + return result + } + + override fun setMetadataTrimMetrics(stringsTrimmed: Int, charsRemoved: Int) { + metadataStringsTrimmedCount = stringsTrimmed + metadataCharsTruncatedCount = charsRemoved + } + + override fun setBreadcrumbTrimMetrics(breadcrumbsRemoved: Int, bytesRemoved: Int) { + breadcrumbsRemovedCount = breadcrumbsRemoved + breadcrumbBytesRemovedCount = bytesRemoved + } +} diff --git a/app/src/main/java/com/bugsnag/android/internal/InternalMetricsNoop.kt b/app/src/main/java/com/bugsnag/android/internal/InternalMetricsNoop.kt new file mode 100644 index 0000000000..743ef8d20d --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/internal/InternalMetricsNoop.kt @@ -0,0 +1,11 @@ +package com.bugsnag.android.internal + +class InternalMetricsNoop : InternalMetrics { + override fun toJsonableMap(): Map = emptyMap() + override fun setConfigDifferences(differences: Map) = Unit + override fun setCallbackCounts(newCallbackCounts: Map) = Unit + override fun notifyAddCallback(callback: String) = Unit + override fun notifyRemoveCallback(callback: String) = Unit + override fun setMetadataTrimMetrics(stringsTrimmed: Int, charsRemoved: Int) = Unit + override fun setBreadcrumbTrimMetrics(breadcrumbsRemoved: Int, bytesRemoved: Int) = Unit +} diff --git a/app/src/main/java/com/bugsnag/android/internal/JsonHelper.kt b/app/src/main/java/com/bugsnag/android/internal/JsonHelper.kt index 8493d47895..2453e35974 100644 --- a/app/src/main/java/com/bugsnag/android/internal/JsonHelper.kt +++ b/app/src/main/java/com/bugsnag/android/internal/JsonHelper.kt @@ -1,7 +1,9 @@ package com.bugsnag.android.internal +import com.bugsnag.android.JsonStream import com.bugsnag.android.repackaged.dslplatform.json.DslJson import com.bugsnag.android.repackaged.dslplatform.json.JsonWriter +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException @@ -9,6 +11,7 @@ import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.io.PrintWriter import java.util.Date internal object JsonHelper { @@ -31,6 +34,20 @@ internal object JsonHelper { } } + fun serialize(streamable: JsonStream.Streamable): ByteArray { + return ByteArrayOutputStream().use { baos -> + JsonStream(PrintWriter(baos)).use(streamable::toStream) + baos.toByteArray() + } + } + + fun serialize(value: Any): ByteArray { + return ByteArrayOutputStream().use { baos -> + serialize(value, baos) + baos.toByteArray() + } + } + fun serialize(value: Any, stream: OutputStream) { dslJson.serialize(value, stream) } @@ -76,4 +93,68 @@ internal object JsonHelper { throw IOException("Could not deserialize from $file", ex) } } + + /** + * Convert a long that technically contains an unsigned long value into its (unsigned) hex string equivalent. + * Negative values are interpreted as if the sign bit is the high bit of an unsigned integer. + * + * Returns null if null is passed in. + */ + fun ulongToHex(value: Long?): String? { + return if (value == null) { + null + } else if (value >= 0) { + "0x%x".format(value) + } else { + return "0x%x%02x".format(value.ushr(8), value.and(0xff)) + } + } + + /** + * Convert a JSON-decoded value into a long. Accepts numeric types, or numeric encoded strings + * (e.g. "1234", "0xb1ff"). + * + * Returns null if null or an empty string is passed in. + */ + fun jsonToLong(value: Any?): Long? { + return when (value) { + null -> null + is Number -> value.toLong() + is String -> { + if (value.length == 0) { + null + } else { + try { + java.lang.Long.decode(value) + } catch (e: NumberFormatException) { + // Check if the value overflows a long, and correct for it. + if (value.startsWith("0x")) { + // All problematic hex values (e.g. 0x8000000000000000) have 18 characters + if (value.length != 18) { + throw e + } + // Decode all but the last byte, then shift and add it. + // This overflows and gives the "correct" signed result. + val headLength = value.length - 2 + java.lang.Long.decode(value.substring(0, headLength)) + .shl(8) + .or(value.substring(headLength, value.length).toLong(16)) + } else { + // The first problematic decimal value (9223372036854775808) has 19 digits + if (value.length < 19) { + throw e + } + // Decode all but the last 3 chars, then multiply and add them. + // This overflows and gives the "correct" signed result. + val headLength = value.length - 3 + java.lang.Long.decode(value.substring(0, headLength)) * + 1000 + + java.lang.Long.decode(value.substring(headLength, value.length)) + } + } + } + } + else -> throw IllegalArgumentException("Cannot convert " + value + " to long") + } + } } diff --git a/app/src/main/java/com/bugsnag/android/internal/StringUtils.kt b/app/src/main/java/com/bugsnag/android/internal/StringUtils.kt new file mode 100644 index 0000000000..4007caf7c4 --- /dev/null +++ b/app/src/main/java/com/bugsnag/android/internal/StringUtils.kt @@ -0,0 +1,107 @@ +package com.bugsnag.android.internal + +import java.util.EnumMap +import java.util.Hashtable +import java.util.LinkedList +import java.util.TreeMap +import java.util.Vector +import java.util.WeakHashMap +import java.util.concurrent.ConcurrentMap +import java.util.concurrent.CopyOnWriteArrayList + +internal object StringUtils { + private const val trimMessageLength = "***<9> CHARS TRUNCATED***".length + + fun stringTrimmedTo(maxLength: Int, str: String): String { + val excessCharCount = str.length - maxLength + return when { + excessCharCount < trimMessageLength -> str + else -> "${str.substring(0, maxLength)}***<$excessCharCount> CHARS TRUNCATED***" + } + } + + @Suppress("unchecked_cast") + fun trimStringValuesTo(maxStringLength: Int, list: MutableList): TrimMetrics { + var stringCount = 0 + var charCount = 0 + + repeat(list.size) { index -> + trimValue(maxStringLength, list[index]) { newValue, stringTrimmed, charsTrimmed -> + list[index] = newValue + stringCount += stringTrimmed + charCount += charsTrimmed + } + } + + return TrimMetrics(stringCount, charCount) + } + + @Suppress("unchecked_cast") + fun trimStringValuesTo(maxStringLength: Int, map: MutableMap): TrimMetrics { + var stringCount = 0 + var charCount = 0 + map.entries.forEach { entry -> + trimValue(maxStringLength, entry.value) { newValue, stringTrimmed, charsTrimmed -> + entry.setValue(newValue) + stringCount += stringTrimmed + charCount += charsTrimmed + } + } + + return TrimMetrics(stringCount, charCount) + } + + @Suppress("unchecked_cast") + private inline fun trimValue( + maxStringLength: Int, + value: Any?, + update: (newValue: Any, stringTrimmed: Int, charsTrimmed: Int) -> Unit + ) { + if (value is String && value.length > maxStringLength) { + update(stringTrimmedTo(maxStringLength, value), 1, value.length - maxStringLength) + } else if (value.isDefinitelyMutableMap()) { + val (innerStringCount, innerCharCount) = trimStringValuesTo( + maxStringLength, + value as MutableMap + ) + + update(value, innerStringCount, innerCharCount) + } else if (value.isDefinitelyMutableList()) { + val (innerStringCount, innerCharCount) = trimStringValuesTo( + maxStringLength, + value as MutableList + ) + + update(value, innerStringCount, innerCharCount) + } else if (value is Map<*, *>) { + val newValue = value.toMutableMap() as MutableMap + val (innerStringCount, innerCharCount) = trimStringValuesTo(maxStringLength, newValue) + update(newValue, innerStringCount, innerCharCount) + } else if (value is Collection<*>) { + val newValue = value.toMutableList() + val (innerStringCount, innerCharCount) = trimStringValuesTo(maxStringLength, newValue) + update(newValue, innerStringCount, innerCharCount) + } + } + + /** + * In order to avoid surprises we have a small list of commonly used Map types that are known + * to be mutable (avoiding issues around Kotlin trying to determine whether + * `Collections.singletonMap` (and such) is mutable or not). + * + * It is technically possible that a HashMap was extended to be immutable, but it's unlikely. + */ + private fun Any?.isDefinitelyMutableMap() = + this is HashMap<*, *> || + this is TreeMap<*, *> || + this is ConcurrentMap<*, *> || // concurrent automatically implies mutability + this is EnumMap<*, *> || + this is Hashtable<*, *> || + this is WeakHashMap<*, *> + + private fun Any?.isDefinitelyMutableList() = + this is ArrayList<*> || + this is LinkedList<*> || + this is CopyOnWriteArrayList<*> || + this is Vector<*> +} diff --git a/patches/Bugsnag.patch b/patches/Bugsnag.patch index a45f6efb2a..76e5933f6e 100644 --- a/patches/Bugsnag.patch +++ b/patches/Bugsnag.patch @@ -11,48 +11,3 @@ index 0ce2eec8c4..e1bac196e2 100644 } catch (exception: Exception) { logger.w("Unexpected error delivering payload", exception) return DeliveryStatus.FAILURE -diff --git a/patches/Bugsnag.patch b/patches/Bugsnag.patch -index c762d488a1..e69de29bb2 100644 ---- a/patches/Bugsnag.patch -+++ b/patches/Bugsnag.patch -@@ -1,40 +0,0 @@ --diff --git a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt --index 0ce2eec8c..e1bac196e 100644 ----- a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt --+++ b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt --@@ -66,7 +66,7 @@ internal class DefaultDelivery( -- return DeliveryStatus.UNDELIVERED -- } catch (exception: IOException) { -- logger.w("IOException encountered in request", exception) --- return DeliveryStatus.UNDELIVERED --+ return DeliveryStatus.FAILURE -- } catch (exception: Exception) { -- logger.w("Unexpected error delivering payload", exception) -- return DeliveryStatus.FAILURE --diff --git a/patches/Bugsnag.patch b/patches/Bugsnag.patch --index 25c19fd4c..e69de29bb 100644 ----- a/patches/Bugsnag.patch --+++ b/patches/Bugsnag.patch --@@ -1,22 +0,0 @@ ---From 3270faf44aea11754c940ba43ee6db72b7462f14 Mon Sep 17 00:00:00 2001 ---From: M66B ---Date: Sat, 15 May 2021 22:07:24 +0200 ---Subject: [PATCH] Bugsnag failure on I/O error --- ------ --- app/src/main/java/com/bugsnag/android/DefaultDelivery.kt | 2 +- --- 1 file changed, 1 insertion(+), 1 deletion(-) --- ---diff --git a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt ---index a7995164cb4e..5620f0bacd80 100644 ------ a/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt ---+++ b/app/src/main/java/com/bugsnag/android/DefaultDelivery.kt ---@@ -64,7 +64,7 @@ internal class DefaultDelivery( --- return DeliveryStatus.UNDELIVERED --- } catch (exception: IOException) { --- logger.w("IOException encountered in request", exception) ---- return DeliveryStatus.UNDELIVERED ---+ return DeliveryStatus.FAILURE --- } catch (exception: Exception) { --- logger.w("Unexpected error delivering payload", exception) --- return DeliveryStatus.FAILURE