-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
- Loading branch information
There are no files selected for viewing
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
|
||
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
|
||
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
|
||
) | ||
} | ||
} | ||
|
||
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
|
||
|
||
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
|
||
} | ||
return bytesToUTF8String(bytes) | ||
Check warning on line 270 in embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/aei/AeiDataSourceImpl.kt
|
||
} 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
|
||
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
|
||
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
|
||
} | ||
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
|
||
} | ||
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
|
||
} | ||
|
||
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}" | ||
} | ||
} |