-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve AEI capture code quality #1694
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,35 +4,30 @@ import android.app.ActivityManager | |
import android.app.ApplicationExitInfo | ||
import android.os.Build.VERSION_CODES | ||
import androidx.annotation.RequiresApi | ||
import io.embrace.android.embracesdk.Severity | ||
import io.embrace.android.embracesdk.Severity.INFO | ||
import io.embrace.android.embracesdk.internal.arch.datasource.LogDataSourceImpl | ||
import io.embrace.android.embracesdk.internal.arch.datasource.NoInputValidation | ||
import io.embrace.android.embracesdk.internal.arch.destination.LogWriter | ||
import io.embrace.android.embracesdk.internal.arch.limits.UpToLimitStrategy | ||
import io.embrace.android.embracesdk.internal.arch.schema.SchemaType | ||
import io.embrace.android.embracesdk.internal.arch.schema.SchemaType.AeiLog | ||
import io.embrace.android.embracesdk.internal.config.behavior.AppExitInfoBehavior | ||
import io.embrace.android.embracesdk.internal.logging.EmbLogger | ||
import io.embrace.android.embracesdk.internal.logging.InternalErrorType | ||
import io.embrace.android.embracesdk.internal.payload.AppExitInfoData | ||
import io.embrace.android.embracesdk.internal.prefs.PreferencesService | ||
import io.embrace.android.embracesdk.internal.spans.toOtelSeverity | ||
import io.embrace.android.embracesdk.internal.utils.BuildVersionChecker | ||
import io.embrace.android.embracesdk.internal.utils.VersionChecker | ||
import io.embrace.android.embracesdk.internal.utils.toUTF8String | ||
import io.embrace.android.embracesdk.internal.worker.BackgroundWorker | ||
import java.io.IOException | ||
import java.util.concurrent.Future | ||
import java.util.concurrent.atomic.AtomicBoolean | ||
|
||
@RequiresApi(VERSION_CODES.R) | ||
internal class AeiDataSourceImpl( | ||
private val backgroundWorker: BackgroundWorker, | ||
private val appExitInfoBehavior: AppExitInfoBehavior, | ||
private val activityManager: ActivityManager?, | ||
private val activityManager: ActivityManager, | ||
private val preferencesService: PreferencesService, | ||
logWriter: LogWriter, | ||
private val logger: EmbLogger, | ||
private val buildVersionChecker: VersionChecker = BuildVersionChecker, | ||
private val versionChecker: VersionChecker = BuildVersionChecker, | ||
) : AeiDataSource, LogDataSourceImpl( | ||
logWriter, | ||
logger, | ||
|
@@ -43,193 +38,42 @@ internal class AeiDataSourceImpl( | |
private const val SDK_AEI_SEND_LIMIT = 32 | ||
} | ||
|
||
@Volatile | ||
private var backgroundExecution: Future<*>? = null | ||
private val sessionApplicationExitInfoData: MutableList<AppExitInfoData> = mutableListOf() | ||
private val isSessionApplicationExitInfoDataReady = AtomicBoolean(false) | ||
|
||
override fun enableDataCapture() { | ||
if (backgroundExecution != null) { | ||
return | ||
} | ||
backgroundExecution = backgroundWorker.submit { | ||
backgroundWorker.submit { | ||
try { | ||
processApplicationExitInfo() | ||
processAeiRecords() | ||
} catch (exc: Throwable) { | ||
logger.trackInternalError(InternalErrorType.ENABLE_DATA_CAPTURE, exc) | ||
} | ||
} | ||
} | ||
|
||
override fun disableDataCapture() { | ||
try { | ||
backgroundExecution?.cancel(true) | ||
backgroundExecution = null | ||
} catch (t: Throwable) { | ||
logger.trackInternalError(InternalErrorType.DISABLE_DATA_CAPTURE, t) | ||
} | ||
} | ||
|
||
private fun processApplicationExitInfo() { | ||
val historicalProcessExitReasons = getHistoricalProcessExitReasons() | ||
val unsentExitReasons = getUnsentExitReasons(historicalProcessExitReasons) | ||
|
||
unsentExitReasons.forEach { | ||
sessionApplicationExitInfoData.add(buildSessionAppExitInfoData(it, null, null)) | ||
} | ||
|
||
isSessionApplicationExitInfoDataReady.set(true) | ||
processApplicationExitInfoBlobs(unsentExitReasons) | ||
} | ||
|
||
private fun processApplicationExitInfoBlobs(unsentExitReasons: List<ApplicationExitInfo>) { | ||
unsentExitReasons.forEach { aei: ApplicationExitInfo -> | ||
val traceResult = collectExitInfoTrace(aei) | ||
if (traceResult != null) { | ||
val payload = buildSessionAppExitInfoData( | ||
aei, | ||
getTrace(traceResult), | ||
getTraceStatus(traceResult) | ||
) | ||
sendApplicationExitInfoWithTraces(listOf(payload)) | ||
} | ||
} | ||
} | ||
|
||
private fun getHistoricalProcessExitReasons(): List<ApplicationExitInfo> { | ||
// A process ID that used to belong to this package but died later; | ||
// a value of 0 means to ignore this parameter and return all matching records. | ||
val pid = 0 | ||
|
||
// number of results to be returned; a value of 0 means to ignore this parameter and return | ||
// all matching records with a maximum of 16 entries | ||
private fun processAeiRecords() { | ||
val maxNum = appExitInfoBehavior.appExitInfoMaxNum() | ||
val records = activityManager.getHistoricalProcessExitReasons(null, 0, maxNum).take(SDK_AEI_SEND_LIMIT) | ||
val unsentRecords = getUnsentRecords(records) | ||
|
||
var historicalProcessExitReasons: List<ApplicationExitInfo> = | ||
activityManager?.getHistoricalProcessExitReasons(null, pid, maxNum) | ||
?: return emptyList() | ||
|
||
if (historicalProcessExitReasons.size > SDK_AEI_SEND_LIMIT) { | ||
historicalProcessExitReasons = historicalProcessExitReasons.take(SDK_AEI_SEND_LIMIT) | ||
} | ||
|
||
return historicalProcessExitReasons | ||
} | ||
|
||
private fun getUnsentExitReasons(historicalProcessExitReasons: List<ApplicationExitInfo>): List<ApplicationExitInfo> { | ||
// Generates the set of current aei captured | ||
val allAeiHashCodes = historicalProcessExitReasons.map(::generateUniqueHash).toSet() | ||
|
||
// Get hash codes that were previously delivered | ||
val deliveredHashCodes = preferencesService.applicationExitInfoHistory ?: emptySet() | ||
|
||
// Subtracts aei hashcodes of already sent information to get new entries | ||
val unsentHashCodes = allAeiHashCodes.subtract(deliveredHashCodes) | ||
|
||
// Updates preferences with the new set of hashcodes | ||
preferencesService.applicationExitInfoHistory = allAeiHashCodes | ||
|
||
// Get AEI objects that were not sent | ||
val unsentAeiObjects = historicalProcessExitReasons.filter { | ||
unsentHashCodes.contains(generateUniqueHash(it)) | ||
} | ||
|
||
return unsentAeiObjects | ||
} | ||
|
||
private fun buildSessionAppExitInfoData( | ||
appExitInfo: ApplicationExitInfo, | ||
trace: String?, | ||
traceStatus: String?, | ||
): AppExitInfoData { | ||
val sessionId = String(appExitInfo.processStateSummary ?: ByteArray(0)) | ||
|
||
return AppExitInfoData( | ||
sessionId = sessionId, | ||
sessionIdError = getSessionIdValidationError(sessionId), | ||
importance = appExitInfo.importance, | ||
pss = appExitInfo.pss, | ||
reason = appExitInfo.reason, | ||
rss = appExitInfo.rss, | ||
status = appExitInfo.status, | ||
timestamp = appExitInfo.timestamp, | ||
trace = trace, | ||
description = appExitInfo.description, | ||
traceStatus = traceStatus | ||
) | ||
} | ||
|
||
private fun getTrace(traceResult: AppExitInfoBehavior.CollectTracesResult): String? = | ||
when (traceResult) { | ||
is AppExitInfoBehavior.CollectTracesResult.Success -> traceResult.result | ||
is AppExitInfoBehavior.CollectTracesResult.TooLarge -> traceResult.result | ||
else -> null | ||
} | ||
|
||
private fun getTraceStatus(traceResult: AppExitInfoBehavior.CollectTracesResult): String? = | ||
when (traceResult) { | ||
is AppExitInfoBehavior.CollectTracesResult.Success -> null | ||
is AppExitInfoBehavior.CollectTracesResult.TooLarge -> "Trace was too large, sending truncated trace" | ||
else -> traceResult.result | ||
} | ||
|
||
private fun sendApplicationExitInfoWithTraces(appExitInfoWithTraces: List<AppExitInfoData>) { | ||
appExitInfoWithTraces.forEach { data -> | ||
unsentRecords.forEach { | ||
val obj = it.constructAeiObject(versionChecker, appExitInfoBehavior.getTraceMaxLimit()) ?: return@forEach | ||
captureData( | ||
inputValidation = NoInputValidation, | ||
captureAction = { | ||
val schemaType = SchemaType.AeiLog(data) | ||
addLog(schemaType, Severity.INFO.toOtelSeverity(), data.trace ?: "") | ||
val schemaType = AeiLog(obj) | ||
addLog(schemaType, INFO.toOtelSeverity(), obj.trace ?: "") | ||
} | ||
) | ||
} | ||
} | ||
|
||
private fun collectExitInfoTrace(appExitInfo: ApplicationExitInfo): AppExitInfoBehavior.CollectTracesResult? { | ||
try { | ||
val trace = readTraceAsString(appExitInfo) ?: return null | ||
|
||
val traceMaxLimit = appExitInfoBehavior.getTraceMaxLimit() | ||
if (trace.length > traceMaxLimit) { | ||
return AppExitInfoBehavior.CollectTracesResult.TooLarge(trace.take(traceMaxLimit)) | ||
} | ||
|
||
return AppExitInfoBehavior.CollectTracesResult.Success(trace) | ||
} catch (e: IOException) { | ||
return AppExitInfoBehavior.CollectTracesResult.TraceException(("ioexception: ${e.message}")) | ||
} catch (e: OutOfMemoryError) { | ||
return AppExitInfoBehavior.CollectTracesResult.TraceException(("oom: ${e.message}")) | ||
} catch (tr: Throwable) { | ||
return AppExitInfoBehavior.CollectTracesResult.TraceException(("error: ${tr.message}")) | ||
} | ||
} | ||
|
||
private fun readTraceAsString(appExitInfo: ApplicationExitInfo): String? { | ||
if (appExitInfo.isNdkProtobufFile()) { | ||
val bytes = appExitInfo.traceInputStream?.readBytes() ?: return null | ||
return bytes.toUTF8String() | ||
} else { | ||
return appExitInfo.traceInputStream?.bufferedReader()?.readText() | ||
} | ||
} | ||
|
||
/** | ||
* NDK protobuf files are only available on Android 12 and above for AEI with | ||
* the REASON_CRASH_NATIVE reason. | ||
* Calculates what AEI records have been sent by subtracting a collection of IDs that have been previously | ||
* sent from the return value of getHistoricalProcessExitReasons. | ||
*/ | ||
private fun ApplicationExitInfo.isNdkProtobufFile(): Boolean { | ||
return buildVersionChecker.isAtLeast(VERSION_CODES.S) && reason == ApplicationExitInfo.REASON_CRASH_NATIVE | ||
} | ||
|
||
private fun getSessionIdValidationError(sid: String): String { | ||
return if (sid.isEmpty() || sid.matches(Regex("^[0-9a-fA-F]{32}\$"))) { | ||
"" | ||
} else { | ||
"invalid session ID: $sid" | ||
} | ||
private fun getUnsentRecords(records: List<ApplicationExitInfo>): List<ApplicationExitInfo> { | ||
val deliveredIds = preferencesService.deliveredAeiIds | ||
preferencesService.deliveredAeiIds = records.map { it.getAeiId() }.toSet() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a mechanism to cap the growth of this list? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes - we rely on the android framework's in-built limit: https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/res/res/values/config.xml;l=5853?q=config_app_exit_info_history_list_size |
||
return records.filter { !deliveredIds.contains(it.getAeiId()) } | ||
} | ||
|
||
private fun generateUniqueHash(appExitInfo: ApplicationExitInfo): String { | ||
return "${appExitInfo.timestamp}_${appExitInfo.pid}" | ||
} | ||
private fun ApplicationExitInfo.getAeiId(): String = "${timestamp}_$pid" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
package io.embrace.android.embracesdk.internal.capture.aei | ||
|
||
import android.app.ApplicationExitInfo | ||
import android.os.Build.VERSION_CODES | ||
import androidx.annotation.RequiresApi | ||
import io.embrace.android.embracesdk.internal.capture.aei.TraceResult.Failure | ||
import io.embrace.android.embracesdk.internal.capture.aei.TraceResult.Success | ||
import io.embrace.android.embracesdk.internal.payload.AppExitInfoData | ||
import io.embrace.android.embracesdk.internal.utils.VersionChecker | ||
import io.embrace.android.embracesdk.internal.utils.toUTF8String | ||
import java.io.IOException | ||
import java.util.regex.Pattern | ||
|
||
private val SESSION_ID_PATTERN by lazy { Pattern.compile("^[0-9a-fA-F]{32}\$").toRegex() } | ||
|
||
/** | ||
* Constructs an [AppExitInfoData] object from an [ApplicationExitInfo] object. The trace | ||
* will be truncated if it is above a certain character limit. If no trace is present, this function | ||
* will return null as we don't wish to capture this data right now. | ||
*/ | ||
@RequiresApi(VERSION_CODES.R) | ||
internal fun ApplicationExitInfo.constructAeiObject( | ||
versionChecker: VersionChecker, | ||
charLimit: Int, | ||
): AppExitInfoData? { | ||
val result = readAeiTrace(versionChecker, charLimit) ?: return null | ||
val sessionId = String(processStateSummary ?: ByteArray(0)) | ||
|
||
return AppExitInfoData( | ||
sessionId = sessionId, | ||
sessionIdError = getSessionIdValidationError(sessionId), | ||
importance = importance, | ||
pss = pss, | ||
reason = reason, | ||
rss = rss, | ||
status = status, | ||
timestamp = timestamp, | ||
trace = result.trace, | ||
description = description, | ||
traceStatus = result.errMsg, | ||
) | ||
} | ||
|
||
/** | ||
* Reads an AEI trace as a string and attempts to sanitize/handle exceptions + edge cases. | ||
*/ | ||
@RequiresApi(VERSION_CODES.R) | ||
private fun ApplicationExitInfo.readAeiTrace( | ||
versionChecker: VersionChecker, | ||
charLimit: Int, | ||
): TraceResult? { | ||
return try { | ||
val trace = readTraceAsString(versionChecker) ?: return null | ||
|
||
when { | ||
trace.length > charLimit -> Success(trace.take(charLimit), "Trace was too large, sending truncated trace") | ||
else -> Success(trace) | ||
} | ||
} catch (e: IOException) { | ||
Failure("ioexception: ${e.message}") | ||
} catch (e: OutOfMemoryError) { | ||
Failure("oom: ${e.message}") | ||
} catch (tr: Throwable) { | ||
Failure("error: ${tr.message}") | ||
} | ||
} | ||
|
||
@RequiresApi(VERSION_CODES.R) | ||
private fun ApplicationExitInfo.readTraceAsString(versionChecker: VersionChecker): String? { | ||
return if (isNdkProtobufFile(versionChecker)) { | ||
val bytes = traceInputStream?.buffered()?.readBytes() ?: return null | ||
bytes.toUTF8String() | ||
} else { | ||
traceInputStream?.bufferedReader()?.readText() | ||
} | ||
} | ||
|
||
/** | ||
* NDK protobuf files are only available on Android 12 and above for AEI with | ||
* the REASON_CRASH_NATIVE reason. | ||
*/ | ||
private fun ApplicationExitInfo.isNdkProtobufFile(versionChecker: VersionChecker): Boolean { | ||
return versionChecker.isAtLeast(VERSION_CODES.S) && reason == ApplicationExitInfo.REASON_CRASH_NATIVE | ||
} | ||
|
||
private fun getSessionIdValidationError(sid: String): String = when { | ||
sid.isEmpty() || sid.matches(SESSION_ID_PATTERN) -> "" | ||
else -> "invalid session ID: $sid" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package io.embrace.android.embracesdk.internal.capture.aei | ||
|
||
internal sealed class TraceResult(val trace: String?, val errMsg: String?) { | ||
class Success(result: String?, errMsg: String? = null) : TraceResult(result, errMsg) | ||
class Failure(message: String?) : TraceResult(null, message) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given that we will be dealing with all exists now rather than just those with blobs, is the limit we are setting still reasonable? Or will you tackle that when we actually implement that feature?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That will be tackled when we implement the feature. I haven't made any functional changes as part of this refactor.