Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import com.itsaky.androidide.events.LspJavaEventsIndex
import com.itsaky.androidide.events.ProjectsApiEventsIndex
import com.itsaky.androidide.handlers.CrashEventSubscriber
import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext
import com.itsaky.androidide.handlers.MetricsCrashAttachment
import com.itsaky.androidide.logging.provider.IdeLogRouter
import com.itsaky.androidide.preferences.internal.StatPreferences
import com.itsaky.androidide.preferences.internal.TelemetryConsent
import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE
import com.itsaky.androidide.ui.themes.IThemeManager
import com.itsaky.androidide.utils.Environment
import com.itsaky.androidide.utils.FeatureFlags
import com.itsaky.androidide.utils.MetricsScratch
import com.termux.shared.reflection.ReflectionUtils
import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
import io.sentry.Breadcrumb
Expand Down Expand Up @@ -126,6 +128,12 @@ internal object DeviceProtectedApplicationLoader :

// Enrich every GlitchTip event with app-specific diagnostic context.
GlitchTipDiagnosticsContext.install(options)

// And with what the machine was doing in the minutes before it (ADFA-5526). The
// destinations that snapshot writes into are taken now, while failing to get them is
// survivable -- a crash handler is the wrong place to ask for memory.
MetricsScratch.install()
MetricsCrashAttachment.install(options, app)
}

// Forward INFO+ logs to GlitchTip as breadcrumbs (never as events; crash events are
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* 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.Context
import android.os.SystemClock
import com.itsaky.androidide.utils.MetricsCsv
import com.itsaky.androidide.utils.MetricsCsvFile
import com.itsaky.androidide.utils.MetricsSnapshotAssembler
import com.itsaky.androidide.utils.MetricsSource
import io.sentry.Attachment
import io.sentry.EventProcessor
import io.sentry.Hint
import io.sentry.SentryEvent
import io.sentry.SentryOptions
import org.slf4j.LoggerFactory
import java.io.File

/**
* Attaches the carousel's metrics to every report the IDE sends (ADFA-5526).
*
* A crash arrives with a stack and no idea what the machine was doing. The minutes of memory,
* network, temperature and power leading up to it are what turn "it died" into a diagnosis -- and
* for an out-of-memory kill they are most of the answer.
*
* Registered as a Sentry [EventProcessor] beside [GlitchTipDiagnosticsContext], not on the uncaught
* exception handler, so it also covers the non-fatal `Sentry.captureException` calls the IDE makes
* deliberately.
*
* ADFA-5494 will keep this history across process death; this does not need it. A crash is the one
* loss cause with a hookable moment, which is exactly why it can be served on its own. The kill that
* 5494 exists for produces no report at all -- nothing runs on a SIGKILL -- so it was never this
* ticket's case.
*/
class MetricsCrashAttachment(
private val context: Context,
private val nowMillis: () -> Long = SystemClock::elapsedRealtime,
private val writeFile: (MetricsCsv.Snapshot) -> File? = { snapshot ->
MetricsCsvFile.writeForReport(context, snapshot)
},
) : EventProcessor {
/**
* The last file written, and when. Reused rather than rewritten for a moment afterwards.
*
* Not synchronised: two events racing here write two files and one of them wins the field,
* which costs a write and loses nothing. A lock would be the more expensive mistake, since this
* runs on the thread of whatever is being reported.
*/
@Volatile
private var recent: Recent? = null

private class Recent(
val atMillis: Long,
val file: File,
)

override fun process(
event: SentryEvent,
hint: Hint,
): SentryEvent {
// Everything, including Errors. This runs while the process is dying, and an OutOfMemoryError
// raised in here would replace a useful report with no report -- losing the attachment is the
// right way to fail. runCatching is what makes that true: it catches Throwable.
runCatching { attach(hint) }
.onFailure { failure -> log.warn("Could not attach the metrics file to the report", failure) }
return event
}

private fun attach(hint: Hint) {
// No source before the editor has run: a crash in onboarding, in the project chooser or in
// direct boot has no history to report, and direct boot has no credential-protected cache to
// write it to either.
val metrics = MetricsSource.current ?: return
val file = writeSnapshot(metrics) ?: return
hint.addAttachment(Attachment(file.absolutePath, file.name, MetricsCsvFile.COMPRESSED_MIME_TYPE))
}

/**
* The file to attach, writing one if the last is too old to stand in.
*
* This runs on the thread of whatever is being reported, and it is not cheap: a full buffer is
* 3600 rows, which format and gzip in 10-15ms on a desktop JVM and a good deal more on a phone.
* A crash pays that once and it does not matter. But this processor is deliberately registered
* for *every* event, including the non-fatal `Sentry.captureException` calls the IDE makes on
* purpose -- and those arrive in bursts, on whatever thread noticed, the main one included. Paid
* per event that is a visible stutter per event.
*
* So a file written moments ago is handed out again instead. The window is short because
* freshness matters most at exactly the moment this is for: a crash gets at most
* [REUSE_WINDOW_MS] less of its own tail, while a burst of non-fatals collapses to one write.
* Every event still gets an attachment, which distinguishing crashes from non-fatals would not
* manage here -- the IDE reports its own crashes through a plain `captureException`, so
* `SentryEvent.isCrashed` is false for them and there is nothing at this level to tell the two
* apart.
*
* The existence check is not belt and braces: [MetricsCsvFile] prunes its directory to the few
* most recent, so a file handed out here can be deleted by a later write.
*/
private fun writeSnapshot(metrics: MetricsSource.Metrics): File? {
val now = nowMillis()
recent?.let { last ->
if (now - last.atMillis < REUSE_WINDOW_MS && last.file.exists()) {
return last.file
}
}

val file =
MetricsSnapshotAssembler.withSnapshot(
context = context,
memory = metrics.memoryUsageWatcher,
network = metrics.networkUsageWatcher,
power = metrics.powerUsageWatcher,
annotations = metrics.annotations,
) { snapshot ->
// Nothing sampled yet is nothing to say. A header-only attachment on every early
// crash would be noise in the reports rather than context.
if (!snapshot.hasRows) null else writeFile(snapshot)
}
if (file != null) {
recent = Recent(now, file)
}
return file
}

companion object {
private val log = LoggerFactory.getLogger(MetricsCrashAttachment::class.java)

/**
* How long a written file stands in for the next one.
*
* Short deliberately: the cost this bounds is a burst of non-fatals, which arrive far
* faster than this, and the thing it risks is the tail of a crash, which is the part worth
* having.
*/
const val REUSE_WINDOW_MS = 5_000L

/** Registers this processor. Call once, from within `SentryAndroid.init`. */
fun install(
options: SentryOptions,
context: Context,
) {
options.addEventProcessor(MetricsCrashAttachment(context))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,18 @@ class MemoryUsageWatcher
}

/**
* Samples retained per series: nearly three hours at [DEFAULT_UPDATE_INTERVAL] (ADFA-5486).
* About 80KB of longs per series, so the cost is in drawing rather than holding --
* see MetricsChartRenderer, which shows a window of this rather than all of it.
* Samples retained per series.
*
* An hour at [DEFAULT_UPDATE_INTERVAL], and the chart shows sixty of them at a time
* (ADFA-5486). It was 10,000, which is nearly three hours nobody was looking at -- and
* eleven buffers of that is 859KB held for the life of the process, doubled by the
* pre-allocated snapshot destinations [MetricsScratch] adds so a crash handler never has
* to allocate. At 3,600 the two together cost less than the one did (ADFA-5526).
*
* A count of samples, not a duration: at the fastest offered rate of 100ms it is six
* minutes rather than an hour.
*/
const val MAX_USAGE_ENTRIES = 10000
const val MAX_USAGE_ENTRIES = 3600
const val DEFAULT_UPDATE_INTERVAL = 1000L
private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java)
}
Expand Down Expand Up @@ -336,6 +343,31 @@ class MemoryUsageWatcher
)
}

/**
* [history], into destinations the caller owns (ADFA-5526).
*
* Processes beyond the destinations given are dropped rather than allocated for -- the caller
* sized itself for [MetricsCsv.MEMORY_COLUMNS], which is every process the chart can plot.
*/
fun copyHistoryInto(
timesDest: LongArray,
destinations: List<LongArray>,
): MemoryHistory =
synchronized(historyLock) {
MemoryHistory(
times = sampleTimes.copyInto(timesDest),
processes =
memoryUsage.values.take(destinations.size).mapIndexed { index, proc ->
ProcessHistory(
pid = proc.pid,
pname = proc.pname,
usage = proc._history.copyInto(destinations[index]),
watchedSinceMillis = proc.watchedSinceMillis,
)
},
)
}

/**
* Returns the memory usage of all the registered processes.
*/
Expand Down Expand Up @@ -412,6 +444,9 @@ class MemoryUsageWatcher
/**
* One process's retained samples, detached from the watcher.
*
* Deliberately not [ProcessMemoryInfo], which carries a MemoryInfo and a ring buffer of its
* own and is what [getMemoryUsages] allocates.
*
* @property usage The samples, oldest first, in bytes.
* @property watchedSinceMillis When this process started being watched. Its buffer reaches
* back to the start of the session however late in it the process appeared, and this is what
Expand Down
95 changes: 95 additions & 0 deletions app/src/main/java/com/itsaky/androidide/utils/MetricsScratch.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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.utils

import androidx.annotation.VisibleForTesting
import java.util.concurrent.atomic.AtomicBoolean

/**
* Destinations for one metrics snapshot, allocated once so that taking one needs no memory.
*
* A crash handler is the wrong place to ask for memory: the crash being reported may be the heap
* running out, and a handler that throws replaces a useful report with a useless one. Snapshotting
* the watchers otherwise takes eleven fresh arrays -- around 300KB at the retained length -- so the
* arrays are taken at startup instead, when failing to get them is survivable and obvious.
*
* Held for the life of the process, which is the trade: this is memory reserved against a crash that
* may never come, in a process that is already a fat target for the low-memory killer. It is paid
* for by [MemoryUsageWatcher.MAX_USAGE_ENTRIES] coming down at the same time -- the live buffers plus
* these cost less than the live buffers alone did before (ADFA-5526).
*
* Not thread-confined but single-use at a time: [claim] hands it to one caller and [release] gives it
* back. A caller that cannot claim it allocates for itself rather than waiting or sharing, because
* two writers into one array is a scrambled file and a crash must not block on an export.
*/
class MetricsScratch(
@VisibleForTesting internal val entries: Int,
memorySeries: Int,
) {
private val inUse = AtomicBoolean(false)

val memoryTimes = LongArray(entries)
val memoryValues: List<LongArray> = List(memorySeries) { LongArray(entries) }
val networkTimes = LongArray(entries)
val networkReceived = LongArray(entries)
val networkTransmitted = LongArray(entries)
val powerTimes = LongArray(entries)
val temperature = LongArray(entries)
val power = LongArray(entries)
val thermal = LongArray(entries)

/** Takes this scratch, or returns false if something else already has it. */
fun claim(): Boolean = inUse.compareAndSet(false, true)

fun release() {
inUse.set(false)
}

companion object {
/**
* The process-wide scratch, or `null` before [install] or if it could not be allocated.
*
* A crash arrives on whatever thread threw, from anywhere in the process, so this cannot
* live on an activity-scoped ViewModel the way the watchers do.
*/
@Volatile
var instance: MetricsScratch? = null
private set

/**
* Allocates the process-wide scratch. Call once, from application startup.
*
* Failure is not fatal and not worth retrying: the crash path simply allocates for itself,
* which is what it did before this existed.
*/
fun install(
entries: Int = MemoryUsageWatcher.MAX_USAGE_ENTRIES,
memorySeries: Int = MetricsCsv.MEMORY_COLUMNS.size,
) {
if (instance != null) {
return
}
instance = runCatching { MetricsScratch(entries, memorySeries) }.getOrNull()
}

@VisibleForTesting
internal fun resetForTesting() {
instance = null
}
}
}
Loading
Loading