Skip to content

Commit

Permalink
refactor: improve aei code quality
Browse files Browse the repository at this point in the history
  • Loading branch information
fractalwrench committed Nov 19, 2024
1 parent 6fc701c commit 7ab7b56
Show file tree
Hide file tree
Showing 9 changed files with 123 additions and 189 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,4 @@ interface AppExitInfoBehavior : ConfigBehavior<EnabledFeatureConfig, AppExitInfo
fun isAeiCaptureEnabled(): Boolean

fun appExitInfoMaxNum(): Int

sealed class CollectTracesResult(val result: String?) {
class Success(result: String?) : CollectTracesResult(result)
class TooLarge(result: String?) : CollectTracesResult(result)
class TraceException(message: String?) : CollectTracesResult(message)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ internal class EmbracePreferencesService(
get() = prefs.getStringPreference(SCREEN_RESOLUTION_KEY)
set(value) = prefs.setStringPreference(SCREEN_RESOLUTION_KEY, value)

override var applicationExitInfoHistory: Set<String>?
get() = prefs.getStringSet(AEI_HASH_CODES, null)
override var deliveredAeiIds: Set<String>
get() = prefs.getStringSet(AEI_HASH_CODES, null) ?: emptySet()
set(value) = prefs.setArrayPreference(AEI_HASH_CODES, value)

override fun isUsersFirstDay(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ interface PreferencesService {
/**
* Set of hashcodes derived from ApplicationExitInfo objects
*/
var applicationExitInfoHistory: Set<String>?
var deliveredAeiIds: Set<String>

/**
* Whether or not the app was installed within the last 24 hours.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,12 @@ internal class FeatureModuleImpl(
}

private val aeiService: AeiDataSourceImpl? by singleton {
if (BuildVersionChecker.isAtLeast(Build.VERSION_CODES.R)) {
val activityManager = systemServiceModule.activityManager
if (BuildVersionChecker.isAtLeast(Build.VERSION_CODES.R) && activityManager != null) {
AeiDataSourceImpl(
workerThreadModule.backgroundWorker(Worker.Background.NonIoRegWorker),
configService.appExitInfoBehavior,
systemServiceModule.activityManager,
activityManager,
androidServicesModule.preferencesService,
logWriter,
initModule.logger
Expand Down
Loading

0 comments on commit 7ab7b56

Please sign in to comment.