-
-
Notifications
You must be signed in to change notification settings - Fork 29
ADFA-2314 | Enrich Sentry reports with diagnostic context #1468
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
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
203 changes: 203 additions & 0 deletions
203
app/src/main/java/com/itsaky/androidide/handlers/SentryDiagnosticsContext.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| /* | ||
| * This file is part of AndroidIDE. | ||
| * | ||
| * AndroidIDE is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * AndroidIDE is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>. | ||
| * | ||
| */ | ||
|
|
||
| package com.itsaky.androidide.handlers | ||
|
|
||
| import android.content.pm.ApplicationInfo | ||
| import android.os.SystemClock | ||
| import com.blankj.utilcode.util.AppUtils | ||
| import com.blankj.utilcode.util.DeviceUtils | ||
| import com.itsaky.androidide.app.IDEApplication | ||
| import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider | ||
| import com.itsaky.androidide.buildinfo.BuildInfo | ||
| import com.itsaky.androidide.plugins.manager.core.PluginManager | ||
| import com.itsaky.androidide.utils.BuildInfoUtils | ||
| import com.termux.shared.android.PackageUtils | ||
| import com.termux.shared.android.SELinuxUtils | ||
| import io.sentry.EventProcessor | ||
| import io.sentry.Hint | ||
| import io.sentry.SentryEvent | ||
| import io.sentry.SentryOptions | ||
| import org.slf4j.LoggerFactory | ||
|
|
||
| /** | ||
| * Enriches every Sentry event with app-specific diagnostic context (SELinux | ||
| * labels, boot mode, install location, signing certificate, active plugins, | ||
| * release/ABI/device posture). | ||
| * | ||
| * The context is attached through a single [EventProcessor] registered in | ||
| * [io.sentry.android.core.SentryAndroid.init]. Running at capture time means a | ||
| * single processor sees the live boot state and the currently loaded plugins | ||
| * for fatal crashes, caught exceptions and plugin crashes alike, with no | ||
| * per-call-site wiring. | ||
| * | ||
| * Every field is collected inside its own [runCatching] so that a single | ||
| * failing collector (e.g. an SELinux read denied by policy, or credential | ||
| * protected storage being inaccessible in direct boot) drops only that one | ||
| * field — the event is still reported with everything else intact. | ||
| * | ||
| * No source code or project file paths are ever read. The SELinux file-context | ||
| * collector keeps only the returned security label, not the directory path. | ||
| * | ||
| * @author Hal Eisen | ||
| */ | ||
| object SentryDiagnosticsContext { | ||
|
|
||
| private val log = LoggerFactory.getLogger(SentryDiagnosticsContext::class.java) | ||
|
|
||
| /** Process/boot start stamp, used to compute the direct-boot locked duration. */ | ||
| private val bootElapsedStartMs = SystemClock.elapsedRealtime() | ||
|
|
||
| /** Elapsed-time stamp of when the user credential-unlocked the device, if observed. */ | ||
| @Volatile | ||
| private var userUnlockedElapsedMs: Long? = null | ||
|
|
||
| /** Whether the app process started while the device was still credential-locked. */ | ||
| @Volatile | ||
| private var startedInDirectBoot = false | ||
|
|
||
| // --- Static per-process values, computed once and cached lazily. --- | ||
|
|
||
| private val appInfo: ApplicationInfo? by lazy { | ||
| val app = IDEApplication.instance | ||
| PackageUtils.getApplicationInfoForPackage(app, app.packageName) | ||
| } | ||
|
|
||
| private val seInfo: String? by lazy { | ||
| appInfo?.let { PackageUtils.getApplicationInfoSeInfoForPackage(it) } | ||
| } | ||
|
|
||
| private val signingDigest: String? by lazy { | ||
| val app = IDEApplication.instance | ||
| PackageUtils.getSigningCertificateSHA256DigestForPackage(app, app.packageName) | ||
| } | ||
|
|
||
| private val installLocation: String? by lazy { | ||
| appInfo?.let { | ||
| if (PackageUtils.isAppInstalledOnExternalStorage(it)) "external" else "internal" | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Registers the diagnostics [EventProcessor] on the given Sentry [options]. | ||
| * Call once from within `SentryAndroid.init`. | ||
| */ | ||
| fun install(options: SentryOptions) { | ||
| // Capture whether we started locked at install time; install() runs from | ||
| // DeviceProtectedApplicationLoader, which is reachable in direct boot. | ||
| startedInDirectBoot = runCatching { !IDEApplication.instance.isUserUnlocked }.getOrDefault(false) | ||
|
|
||
| options.addEventProcessor(object : EventProcessor { | ||
| override fun process(event: SentryEvent, hint: Hint): SentryEvent { | ||
| runCatching { enrich(event) }.onFailure { log.warn("Failed to enrich Sentry event", it) } | ||
| return event | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Stamps the moment the device transitioned to credential-unlocked, so the | ||
| * direct-boot locked duration can be reported. Idempotent — only the first | ||
| * unlock is recorded. | ||
| */ | ||
| fun onUserUnlocked() { | ||
| if (userUnlockedElapsedMs == null) { | ||
| userUnlockedElapsedMs = SystemClock.elapsedRealtime() | ||
| } | ||
| } | ||
|
|
||
| private fun enrich(event: SentryEvent) { | ||
| val app = runCatching { IDEApplication.instance }.getOrNull() ?: return | ||
|
|
||
| // ① SELinux contexts (process, private-data-dir file label, seinfo). | ||
| context(event, "selinux") { | ||
| buildMap { | ||
| runCatching { SELinuxUtils.getContext() }.getOrNull()?.let { put("process_context", it) } | ||
| runCatching { SELinuxUtils.getFileContext(app.filesDir.absolutePath) } | ||
| .getOrNull()?.let { put("file_context", it) } | ||
| runCatching { seInfo }.getOrNull()?.let { put("seinfo", it) } | ||
| } | ||
| } | ||
|
|
||
| // ② Boot mode, queried live, plus the locked duration if we started locked. | ||
| tag(event, "boot_mode") { | ||
| if (app.isUserUnlocked) "credential_unlocked" else "direct_boot" | ||
| } | ||
| if (startedInDirectBoot) { | ||
| tag(event, "boot_locked_duration_ms") { | ||
| val end = userUnlockedElapsedMs ?: SystemClock.elapsedRealtime() | ||
| (end - bootElapsedStartMs).toString() | ||
| } | ||
| } | ||
|
|
||
| // ③ Install location (internal vs external/SD). | ||
| tag(event, "install_location") { installLocation } | ||
|
|
||
| // ④ Signing certificate digest + official/unofficial build flag. | ||
| tag(event, "signing_sha256") { signingDigest } | ||
| tag(event, "signing_official") { BuildInfoUtils.isOfficialBuild(app).toString() } | ||
|
|
||
| // ⑤ Active plugins (enabled + loaded) with version and recent crash count. | ||
| context(event, "active_plugins") { | ||
| val pm = PluginManager.getInstance() ?: return@context null | ||
| val plugins = pm.getAllPlugins() | ||
| .filter { it.isEnabled && it.isLoaded } | ||
| .map { info -> | ||
| mapOf( | ||
| "id" to info.metadata.id, | ||
| "version" to info.metadata.version, | ||
| "min_ide_version" to info.metadata.minIdeVersion, | ||
| "crash_count" to runCatching { | ||
| pm.crashTracker.getCrashCount(info.metadata.id) | ||
| }.getOrDefault(0), | ||
| ) | ||
| } | ||
| mapOf("count" to plugins.size, "plugins" to plugins) | ||
| } | ||
|
|
||
| // A — release identifier + version code. | ||
| tag(event, "app_version_name") { BuildInfo.VERSION_NAME_SIMPLE } | ||
| tag(event, "app_version_code") { AppUtils.getAppVersionCode().toString() } | ||
| tag(event, "app_git_commit") { BuildInfo.CI_GIT_COMMIT_HASH } | ||
| tag(event, "app_ci_build") { BuildInfo.CI_BUILD.toString() } | ||
|
|
||
| // B — process ABI / variant. | ||
| val buildConfig = IDEBuildConfigProvider.getInstance() | ||
| tag(event, "abi") { buildConfig.cpuAbiName } | ||
| tag(event, "cpu_arch") { buildConfig.cpuArch.name } | ||
|
|
||
| // H — device posture (emulator vs physical, rooted). | ||
| tag(event, "device_emulator") { DeviceUtils.isEmulator().toString() } | ||
| tag(event, "device_rooted") { DeviceUtils.isDeviceRooted().toString() } | ||
| } | ||
|
|
||
| /** Sets a single tag, guarded so a failing collector drops only that tag. */ | ||
| private inline fun tag(event: SentryEvent, key: String, value: () -> String?) { | ||
| runCatching { value()?.let { event.setTag(key, it) } } | ||
| .onFailure { log.debug("Sentry diagnostics: dropped tag '{}'", key, it) } | ||
| } | ||
|
|
||
| /** | ||
| * Attaches a structured context group, guarded so a failing collector drops | ||
| * only that group. Empty/null maps are skipped. | ||
| */ | ||
| private inline fun context(event: SentryEvent, key: String, value: () -> Map<String, Any?>?) { | ||
| runCatching { value()?.takeIf { it.isNotEmpty() }?.let { event.contexts.put(key, it) } } | ||
| .onFailure { log.debug("Sentry diagnostics: dropped context '{}'", key, it) } | ||
| } | ||
| } | ||
98 changes: 98 additions & 0 deletions
98
app/src/test/java/com/itsaky/androidide/handlers/SentryDiagnosticsContextTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| /* | ||
| * This file is part of AndroidIDE. | ||
| * | ||
| * AndroidIDE is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * AndroidIDE is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package com.itsaky.androidide.handlers | ||
|
|
||
| import com.google.common.truth.Truth.assertThat | ||
| import com.itsaky.androidide.app.IDEApplication | ||
| import com.itsaky.androidide.buildinfo.BuildInfo | ||
| import io.mockk.every | ||
| import io.mockk.mockk | ||
| import io.mockk.mockkObject | ||
| import io.mockk.unmockkAll | ||
| import io.sentry.Hint | ||
| import io.sentry.SentryEvent | ||
| import io.sentry.SentryOptions | ||
| import org.junit.After | ||
| import org.junit.Before | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import org.robolectric.RobolectricTestRunner | ||
| import java.io.File | ||
|
|
||
| /** | ||
| * Verifies that [SentryDiagnosticsContext] enriches events and, crucially, that | ||
| * a single failing field collector never prevents the rest of the event from | ||
| * being reported. | ||
| * | ||
| * @author Hal Eisen | ||
| */ | ||
| @RunWith(RobolectricTestRunner::class) | ||
| class SentryDiagnosticsContextTest { | ||
|
|
||
| private lateinit var app: IDEApplication | ||
|
|
||
| @Before | ||
| fun setUp() { | ||
| // Skip the native-library block in IDEApplication's static initializer. | ||
| System.setProperty("androidide.test.mode", "true") | ||
|
|
||
| app = mockk(relaxed = true) | ||
| every { app.packageName } returns "com.itsaky.androidide" | ||
| every { app.filesDir } returns File("/tmp/androidide-test") | ||
| every { app.isUserUnlocked } returns true | ||
|
|
||
| mockkObject(IDEApplication.Companion) | ||
| every { IDEApplication.instance } returns app | ||
| } | ||
|
|
||
| @After | ||
| fun tearDown() { | ||
| unmockkAll() | ||
| } | ||
|
|
||
| /** Installs the processor on a fresh options instance and enriches a new event. */ | ||
| private fun enrichNewEvent(): SentryEvent { | ||
| val options = SentryOptions() | ||
| SentryDiagnosticsContext.install(options) | ||
| val processor = options.eventProcessors | ||
| .first { it.javaClass.name.contains("SentryDiagnosticsContext") } | ||
| return processor.process(SentryEvent(), Hint())!! | ||
| } | ||
|
|
||
| @Test | ||
| fun `enrich populates diagnostic context on the event`() { | ||
| val event = enrichNewEvent() | ||
|
|
||
| // Live boot state and a compile-time release constant are both attached. | ||
| assertThat(event.getTag("boot_mode")).isEqualTo("credential_unlocked") | ||
| assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) | ||
| } | ||
|
|
||
| @Test | ||
| fun `a single failing collector never breaks the rest of the event`() { | ||
| // Make the boot-mode collector blow up (simulating e.g. an SELinux denial). | ||
| every { app.isUserUnlocked } throws RuntimeException("read denied") | ||
|
|
||
| val event = enrichNewEvent() | ||
|
|
||
| // The failing field is simply dropped... | ||
| assertThat(event.getTag("boot_mode")).isNull() | ||
| // ...the event is still returned, with every other field intact. | ||
| assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.