Skip to content

Commit

Permalink
feat: add AEI data source
Browse files Browse the repository at this point in the history
  • Loading branch information
fractalwrench committed Mar 7, 2024
1 parent 4e21f52 commit 8201255
Show file tree
Hide file tree
Showing 4 changed files with 712 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.embrace.android.embracesdk.capture.aei

import io.embrace.android.embracesdk.arch.datasource.LogDataSource

internal interface AeiDataSource : LogDataSource
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
package io.embrace.android.embracesdk.capture.aei

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.arch.datasource.LogDataSourceImpl
import io.embrace.android.embracesdk.arch.destination.LogEventData
import io.embrace.android.embracesdk.arch.destination.LogEventMapper
import io.embrace.android.embracesdk.arch.destination.LogWriter
import io.embrace.android.embracesdk.arch.limits.UpToLimitStrategy
import io.embrace.android.embracesdk.capture.metadata.MetadataService
import io.embrace.android.embracesdk.capture.user.UserService
import io.embrace.android.embracesdk.config.ConfigService
import io.embrace.android.embracesdk.config.behavior.AppExitInfoBehavior
import io.embrace.android.embracesdk.internal.utils.BuildVersionChecker
import io.embrace.android.embracesdk.internal.utils.VersionChecker
import io.embrace.android.embracesdk.internal.utils.toNonNullMap
import io.embrace.android.embracesdk.logging.InternalStaticEmbraceLogger.Companion.logDebug
import io.embrace.android.embracesdk.logging.InternalStaticEmbraceLogger.Companion.logInfoWithException
import io.embrace.android.embracesdk.logging.InternalStaticEmbraceLogger.Companion.logWarningWithException
import io.embrace.android.embracesdk.payload.AppExitInfoData
import io.embrace.android.embracesdk.payload.BlobMessage
import io.embrace.android.embracesdk.payload.BlobSession
import io.embrace.android.embracesdk.prefs.PreferencesService
import io.embrace.android.embracesdk.session.id.SessionIdTracker
import io.embrace.android.embracesdk.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 configService: ConfigService,
private val activityManager: ActivityManager?,
private val preferencesService: PreferencesService,
private val metadataService: MetadataService,
private val sessionIdTracker: SessionIdTracker,
private val userService: UserService,
logWriter: LogWriter,
private val buildVersionChecker: VersionChecker = BuildVersionChecker,
) : AeiDataSource, LogEventMapper<BlobMessage>, LogDataSourceImpl(
logWriter,
limitStrategy = UpToLimitStrategy({ SDK_AEI_SEND_LIMIT })
) {

companion object {
private const val TYPE_NAME = "system.exit"
private const val LOG_NAME = "aei-record"
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 {
try {
processApplicationExitInfo()
} catch (exc: Throwable) {
logWarningWithException(
"AEI - Failed to process AEIs due to unexpected error",
exc,

Check warning on line 70 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L70

Added line #L70 was not covered by tests
true
)
}
}
}

override fun disableDataCapture() {
try {

Check warning on line 78 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L78

Added line #L78 was not covered by tests
backgroundExecution?.cancel(true)
backgroundExecution = null
} catch (t: Throwable) {
logWarningWithException(
"AEI - Failed to disable EmbraceApplicationExitInfoService work",
t

Check warning on line 84 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L80-L84

Added lines #L80 - L84 were not covered by tests
)
}
}

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
val maxNum = configService.appExitInfoBehavior.appExitInfoMaxNum()

var historicalProcessExitReasons: List<ApplicationExitInfo> =
activityManager?.getHistoricalProcessExitReasons(null, pid, maxNum)
?: return emptyList()

Check warning on line 126 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L126

Added line #L126 was not covered by tests

if (historicalProcessExitReasons.size > SDK_AEI_SEND_LIMIT) {
logInfoWithException("AEI - size greater than $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 ->
alterSessionSpan(
inputValidation = { true },
captureAction = {
val blob = BlobMessage(
metadataService.getAppInfo(),
listOf(data),
metadataService.getDeviceInfo(),
BlobSession(sessionIdTracker.getActiveSessionId()),
userService.getUserInfo()
)
addLog(blob, ::toLogEventData)
}
)
}
}

override fun toLogEventData(obj: BlobMessage): LogEventData {
val message: AppExitInfoData = obj.applicationExits.single()
val attrs = mapOf(
"session-id" to message.sessionId,
"session-id-error" to message.sessionIdError,
"process-importance" to message.importance.toString(),
"pss" to message.pss.toString(),
"rs" to message.reason.toString(),
"rss" to message.rss.toString(),
"exit-status" to message.status.toString(),
"timestamp" to message.timestamp.toString(),
"blob" to message.trace,
"description" to message.description,
"trace-status" to message.traceStatus
)
return LogEventData(
TYPE_NAME,
severity = Severity.INFO,
message = LOG_NAME,
attributes = attrs.toNonNullMap()
)
}

private fun collectExitInfoTrace(appExitInfo: ApplicationExitInfo): AppExitInfoBehavior.CollectTracesResult? {
try {
val trace = readTraceAsString(appExitInfo)

if (trace == null) {
logDebug("AEI - No info trace collected")
return null
}

val traceMaxLimit = configService.appExitInfoBehavior.getTraceMaxLimit()
if (trace.length > traceMaxLimit) {
logInfoWithException("AEI - Blob size was reduced. Current size is ${trace.length} and the limit is $traceMaxLimit")
return AppExitInfoBehavior.CollectTracesResult.TooLarge(trace.take(traceMaxLimit))
}

return AppExitInfoBehavior.CollectTracesResult.Success(trace)
} catch (e: IOException) {
logWarningWithException("AEI - IOException: ${e.message}", e, true)
return AppExitInfoBehavior.CollectTracesResult.TraceException(("ioexception: ${e.message}"))
} catch (e: OutOfMemoryError) {
logWarningWithException("AEI - Out of Memory: ${e.message}", e, true)
return AppExitInfoBehavior.CollectTracesResult.TraceException(("oom: ${e.message}"))
} catch (tr: Throwable) {
logWarningWithException("AEI - An error occurred: ${tr.message}", tr, true)
return AppExitInfoBehavior.CollectTracesResult.TraceException(("error: ${tr.message}"))
}
}

private fun readTraceAsString(appExitInfo: ApplicationExitInfo): String? {
if (appExitInfo.isNdkProtobufFile()) {
val bytes = appExitInfo.traceInputStream?.readBytes()

if (bytes == null) {
logDebug("AEI - No info trace collected")
return null

Check warning on line 268 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L267-L268

Added lines #L267 - L268 were not covered by tests
}
return bytesToUTF8String(bytes)

Check warning on line 270 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L270

Added line #L270 was not covered by tests
} 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.
*/
private fun ApplicationExitInfo.isNdkProtobufFile(): Boolean {
return buildVersionChecker.isAtLeast(VERSION_CODES.S) && reason == ApplicationExitInfo.REASON_CRASH_NATIVE
}

/**
* Converts a byte array to a UTF-8 string, escaping non-encodable bytes as
* 2-byte UTF-8 sequences, which will later be converted into unicode by JSON marshalling.
* This allows us to send arbitrary binary data from the NDK
* protobuf file without needing to encode it as Base64 (which compresses poorly).
*/
private fun bytesToUTF8String(bytes: ByteArray): String {
val encoded = ByteArray(bytes.size * 2)
var i = 0

Check warning on line 292 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L291-L292

Added lines #L291 - L292 were not covered by tests
for (b in bytes) {
val u = b.toInt() and 0xFF

Check warning on line 294 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L294

Added line #L294 was not covered by tests
if (u < 128) {
encoded[i++] = u.toByte()
continue

Check warning on line 297 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L296-L297

Added lines #L296 - L297 were not covered by tests
}
encoded[i++] = (0xC0 or (u shr 6)).toByte()
encoded[i++] = (0x80 or (u and 0x3F)).toByte()

Check warning on line 300 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L299-L300

Added lines #L299 - L300 were not covered by tests
}
return String(encoded.copyOf(i), Charsets.UTF_8)

Check warning on line 302 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt

View check run for this annotation

Codecov / codecov/patch

embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt#L302

Added line #L302 was not covered by tests
}

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 generateUniqueHash(appExitInfo: ApplicationExitInfo): String {
return "${appExitInfo.timestamp}_${appExitInfo.pid}"
}
}
Loading

0 comments on commit 8201255

Please sign in to comment.