refactor(diagnostics): remove bug report breadcrumbs

This commit is contained in:
2026-08-02 19:18:18 +02:00
parent 7cb2270d56
commit 877083a3ed
12 changed files with 14 additions and 242 deletions

View File

@@ -1,47 +0,0 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Fixed-size ring of high-level app breadcrumbs for crash / bug context.
* Always in-memory only; never auto-uploaded without policy + transport.
*
* Updates are best-effort under concurrency; losing a breadcrumb is preferable
* to blocking a dying process on a lock.
*/
@OptIn(ExperimentalAtomicApi::class)
class BreadcrumbBuffer(
private val capacity: Int = DefaultCapacity,
) {
init {
require(capacity > 0) { "capacity must be positive" }
}
private val items = AtomicReference<List<Breadcrumb>>(emptyList())
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return
val crumb = Breadcrumb(
name = sanitizedName,
timestampMillis = timestampMillis,
properties = sanitizeDiagnosticProperties(properties),
)
while (true) {
val current = items.load()
if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return
}
}
fun snapshot(): List<Breadcrumb> = items.load()
fun clear() {
items.store(emptyList())
}
companion object {
const val DefaultCapacity = 40
}
}

View File

@@ -21,7 +21,6 @@ data class BugReportDraft(
class BugReportService(
private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport,
private val breadcrumbs: BreadcrumbBuffer,
private val appVersion: String,
private val platform: String,
private val logReader: () -> String = {
@@ -53,7 +52,6 @@ class BugReportService(
network = deviceInfo?.network?.takeUtf8Bytes(96),
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
),
breadcrumbs = breadcrumbs.snapshot(),
)
}

View File

@@ -13,7 +13,6 @@ import kotlinx.coroutines.launch
class DiagnosticsCoordinator(
val preferencesRepository: PreferencesRepository,
val transport: DiagnosticsTransport,
val breadcrumbs: BreadcrumbBuffer,
val bugReports: BugReportService,
private val scope: CoroutineScope,
) {
@@ -32,18 +31,15 @@ class DiagnosticsCoordinator(
scope: CoroutineScope,
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
): DiagnosticsCoordinator {
val breadcrumbs = BreadcrumbBuffer()
val bugReports = BugReportService(
preferencesRepository = preferencesRepository,
transport = transport,
breadcrumbs = breadcrumbs,
appVersion = appVersion,
platform = platform,
)
return DiagnosticsCoordinator(
preferencesRepository = preferencesRepository,
transport = transport,
breadcrumbs = breadcrumbs,
bugReports = bugReports,
scope = scope,
)

View File

@@ -8,8 +8,6 @@ internal object DiagnosticsJson {
internal const val MaxInstallIdBytes = 80
internal const val MaxAppVersionBytes = 40
internal const val MaxPlatformBytes = 40
private const val MaxBreadcrumbsJsonBytes = 16_000
private const val MaxBreadcrumbs = 40
fun bugBody(report: BugReport): String {
val logs = if (report.includeLogs) report.logs else ""
@@ -72,47 +70,7 @@ internal object DiagnosticsJson {
appendJsonField("network", report.device.network.orEmpty())
append(',')
appendJsonField("batteryLevel", report.device.batteryLevel.orEmpty())
append("},")
append("\"breadcrumbs\":")
appendBreadcrumbs(report.breadcrumbs)
append('}')
}
private fun StringBuilder.appendBreadcrumbs(crumbs: List<Breadcrumb>) {
append('[')
var encodedBytes = 2
var appended = 0
for (crumb in crumbs) {
if (appended == MaxBreadcrumbs) break
val name = sanitizeDiagnosticName(crumb.name)
if (name.isBlank() || crumb.timestampMillis < 0) continue
val encoded = buildString {
append('{')
appendJsonField("name", name)
append(',')
append("\"timestampMillis\":")
append(crumb.timestampMillis)
append(',')
append("\"properties\":")
appendStringMap(crumb.properties)
append('}')
}
val additionBytes = encoded.encodeToByteArray().size + if (appended == 0) 0 else 1
if (encodedBytes + additionBytes > MaxBreadcrumbsJsonBytes) break
if (appended > 0) append(',')
append(encoded)
encodedBytes += additionBytes
appended += 1
}
append(']')
}
private fun StringBuilder.appendStringMap(map: Map<String, String>) {
append('{')
sanitizeDiagnosticProperties(map).entries.forEachIndexed { index, (key, value) ->
if (index > 0) append(',')
appendJsonField(key, value)
}
append('}')
}

View File

@@ -5,12 +5,6 @@ package com.vnidrop.app.diagnostics
* intentionally abstracted; nothing here assumes a network backend.
*/
data class Breadcrumb(
val name: String,
val timestampMillis: Long,
val properties: Map<String, String> = emptyMap(),
)
data class DeviceSnapshot(
val deviceName: String?,
val deviceModel: String?,
@@ -32,7 +26,6 @@ data class BugReport(
val includeLogs: Boolean,
val logs: String,
val device: DeviceSnapshot,
val breadcrumbs: List<Breadcrumb>,
val schemaVersion: Int = DiagnosticsSchemaVersion,
)

View File

@@ -1,30 +1,11 @@
package com.vnidrop.app.diagnostics
internal const val MaxDiagnosticProperties = 12
internal const val MaxDiagnosticPropertyKeyBytes = 40
internal const val MaxDiagnosticPropertyValueBytes = 128
internal const val MaxDiagnosticNameBytes = 64
internal fun sanitizeDiagnosticsInstallId(value: String): String {
val trimmed = value.trim()
if (trimmed.any { it.code < 0x20 || it.code == 0x7f }) return ""
return trimmed.takeUtf8Bytes(DiagnosticsJson.MaxInstallIdBytes)
}
internal fun sanitizeDiagnosticName(name: String): String =
name.takeUtf8Bytes(MaxDiagnosticNameBytes)
internal fun sanitizeDiagnosticProperties(properties: Map<String, String>): Map<String, String> {
val sanitized = LinkedHashMap<String, String>(minOf(properties.size, MaxDiagnosticProperties))
for ((rawKey, rawValue) in properties) {
val key = rawKey.takeUtf8Bytes(MaxDiagnosticPropertyKeyBytes)
if (key.isEmpty() || key in sanitized) continue
sanitized[key] = LogRedactor.redact(rawValue).takeUtf8Bytes(MaxDiagnosticPropertyValueBytes)
if (sanitized.size == MaxDiagnosticProperties) break
}
return sanitized
}
internal fun String.takeUtf8Bytes(maxBytes: Int): String {
require(maxBytes >= 0) { "maxBytes must not be negative" }
val encoded = encodeToByteArray()

View File

@@ -204,22 +204,11 @@ class DiagnosticsTest {
assertTrue(redacted.contains("[redacted-endpoint]"))
}
@Test
fun breadcrumbBufferKeepsOnlyLatestEntries() {
val buffer = BreadcrumbBuffer(capacity = 3)
buffer.add("a")
buffer.add("b")
buffer.add("c")
buffer.add("d")
assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name })
}
@Test
fun bugReportRequiresWhatAndExpected() = runTest {
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
logReader = { "logs" },
@@ -238,7 +227,6 @@ class DiagnosticsTest {
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = throwingTransport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
)
@@ -255,7 +243,6 @@ class DiagnosticsTest {
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
logReader = { LogRedactor.redact("ticket=abcdefghijklmnopqrstuvwxyz012345 plain") },
@@ -286,7 +273,6 @@ class DiagnosticsTest {
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
logReader = { rawLogs },
@@ -304,7 +290,6 @@ class DiagnosticsTest {
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
logReader = { "🙂".repeat(60_000) },
@@ -333,7 +318,6 @@ class DiagnosticsTest {
includeLogs = includeLogs,
logs = logs,
device = DeviceSnapshot(null, null, "OS", null, null),
breadcrumbs = emptyList(),
)
private fun fakePrefs() = FakePreferencesRepository(

View File

@@ -18,7 +18,6 @@ import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.diagnostics.BreadcrumbBuffer
import com.vnidrop.app.diagnostics.BugReportService
import com.vnidrop.app.diagnostics.DiagnosticsTransport
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
@@ -989,7 +988,6 @@ class ViewModelsTest {
BugReportService(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
logReader = { "sample log line" },