feat(diagnostics): harden ingestion and delivery

This commit is contained in:
2026-07-15 00:43:49 +02:00
parent ead4e09a60
commit b602a3acb6
42 changed files with 22428 additions and 141 deletions

View File

@@ -13,8 +13,16 @@ private class AndroidPendingCrashStore(
@Synchronized
override fun write(report: CrashReport) {
if (!isValidDiagnosticId(report.id)) return
directory.mkdirs()
File(directory, "${report.id}.crash").writeText(CrashReportCodec.encode(report), StandardCharsets.UTF_8)
val target = File(directory, "${report.id}.crash")
val temporary = File(directory, ".${report.id}.tmp")
val payload = CrashReportCodec.encode(report)
temporary.writeText(payload, StandardCharsets.UTF_8)
if (!temporary.renameTo(target)) {
target.writeText(payload, StandardCharsets.UTF_8)
temporary.delete()
}
}
@Synchronized
@@ -31,6 +39,34 @@ private class AndroidPendingCrashStore(
@Synchronized
override fun delete(id: String) {
if (!isValidDiagnosticId(id)) return
File(directory, "$id.crash").delete()
}
@Synchronized
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
require(maxCount > 0) { "maxCount must be positive" }
if (!directory.isDirectory) return
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
.orEmpty()
.forEach(File::delete)
val reports = directory
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
.orEmpty()
.mapNotNull { file ->
val report = runCatching {
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
}.getOrNull()
if (report == null) {
file.delete()
null
} else {
file to report
}
}
.sortedByDescending { (_, report) -> report.timestampMillis }
reports.forEachIndexed { index, (file, report) ->
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
}
}
}

View File

@@ -0,0 +1,37 @@
package com.vnidrop.app.diagnostics
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URI
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
actual suspend fun platformHttpPost(
url: String,
headers: Map<String, String>,
bodyUtf8: String,
): PlatformHttpResponse = withContext(Dispatchers.IO) {
val connection = (URI(url).toURL().openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("Content-Type", "application/json; charset=utf-8")
headers.forEach { (key, value) -> setRequestProperty(key, value) }
}
try {
connection.outputStream.use { output ->
output.write(bodyUtf8.toByteArray(StandardCharsets.UTF_8))
}
val code = connection.responseCode
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
val body = stream?.use { input ->
BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).readText()
}.orEmpty()
PlatformHttpResponse(code, body)
} finally {
connection.disconnect()
}
}

View File

@@ -3,7 +3,7 @@ package com.vnidrop.app
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
import com.vnidrop.app.diagnostics.createDiagnosticsTransport
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
import com.vnidrop.app.feature.send.AppFilePreviewRepository
import com.vnidrop.app.feature.send.createPlatformPreviewStore
@@ -18,6 +18,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class AppGraph(
val dependencies: AppDependencies,
@@ -45,7 +48,11 @@ class AppGraph(
platform = dependencies.environment.name,
preferencesRepository = preferencesRepository,
scope = applicationScope,
transport = NoOpDiagnosticsTransport(),
transport = createDiagnosticsTransport(
appVersion = dependencies.environment.appVersion,
platform = dependencies.environment.name,
installIdProvider = { preferencesRepository.ensureDiagnosticsInstallId() },
),
)
val approvalCoordinator = ApprovalCoordinator(
repository = coreRepository,
@@ -59,6 +66,12 @@ class AppGraph(
init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
diagnostics.start()
applicationScope.launch {
visibility.isForeground
.drop(1)
.filter { isForeground -> !isForeground }
.collect { diagnostics.telemetry.flush() }
}
}
fun close() {

View File

@@ -1,6 +1,8 @@
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.
@@ -9,6 +11,7 @@ import com.vnidrop.app.logging.platformNowMillis
* 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,
) {
@@ -16,28 +19,29 @@ class BreadcrumbBuffer(
require(capacity > 0) { "capacity must be positive" }
}
@Volatile
private var items: List<Breadcrumb> = emptyList()
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 = name.take(MaxNameLength),
name = sanitizedName,
timestampMillis = timestampMillis,
properties = LogRedactor.redactMap(properties).mapValues { it.value.take(MaxPropertyValueLength) },
properties = sanitizeDiagnosticProperties(properties),
)
val current = items
items = (current + crumb).takeLast(capacity)
while (true) {
val current = items.load()
if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return
}
}
fun snapshot(): List<Breadcrumb> = items
fun snapshot(): List<Breadcrumb> = items.load()
fun clear() {
items = emptyList()
items.store(emptyList())
}
companion object {
const val DefaultCapacity = 40
private const val MaxNameLength = 64
private const val MaxPropertyValueLength = 128
}
}

View File

@@ -5,6 +5,7 @@ import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.CancellationException
data class BugReportDraft(
val whatHappened: String,
@@ -24,7 +25,7 @@ class BugReportService(
private val appVersion: String,
private val platform: String,
private val logReader: () -> String = {
LogRedactor.redact(AppLogger.readLatestLogs(AppLogger.DefaultBugReportLogBytes))
AppLogger.readLatestLogs(AppLogger.DefaultBugReportLogBytes)
},
) {
fun assemble(
@@ -32,25 +33,25 @@ class BugReportService(
deviceInfo: DeviceInfo?,
installId: String,
): BugReport {
val logs = if (draft.includeLogs) logReader() else ""
val logs = if (draft.includeLogs) readReportLogs() else ""
return BugReport(
id = randomUuidString(),
timestampMillis = platformNowMillis(),
installId = installId,
appVersion = appVersion,
platform = platform,
whatHappened = draft.whatHappened.trim().take(MaxFieldLength),
expected = draft.expected.trim().take(MaxFieldLength),
steps = draft.steps.trim().take(MaxFieldLength),
contact = draft.contact.trim().take(MaxContactLength),
installId = sanitizeDiagnosticsInstallId(installId),
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
whatHappened = draft.whatHappened.trim().takeUtf8Bytes(MaxFieldBytes),
expected = draft.expected.trim().takeUtf8Bytes(MaxFieldBytes),
steps = draft.steps.trim().takeUtf8Bytes(MaxFieldBytes),
contact = draft.contact.trim().takeUtf8Bytes(MaxContactBytes),
includeLogs = draft.includeLogs,
logs = logs,
device = DeviceSnapshot(
deviceName = deviceInfo?.deviceName,
deviceModel = deviceInfo?.deviceModel,
operatingSystem = deviceInfo?.operatingSystem ?: platform,
network = deviceInfo?.network,
batteryLevel = deviceInfo?.batteryLevel,
deviceName = deviceInfo?.deviceName?.takeUtf8Bytes(128),
deviceModel = deviceInfo?.deviceModel?.takeUtf8Bytes(128),
operatingSystem = (deviceInfo?.operatingSystem ?: platform).takeUtf8Bytes(192),
network = deviceInfo?.network?.takeUtf8Bytes(96),
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
),
breadcrumbs = breadcrumbs.snapshot(),
)
@@ -67,7 +68,13 @@ class BugReportService(
}
val installId = preferencesRepository.ensureDiagnosticsInstallId()
val report = assemble(draft, deviceInfo, installId)
val send = transport.sendBugReport(report)
val send = try {
transport.sendBugReport(report)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
return send.fold(
onSuccess = {
AppLogger.info("bug_report", "submitted", mapOf("id" to report.id))
@@ -80,10 +87,14 @@ class BugReportService(
)
}
fun previewLogBytes(): Int = logReader().encodeToByteArray().size
fun previewLogBytes(): Int = readReportLogs().encodeToByteArray().size
private fun readReportLogs(): String =
LogRedactor.redact(logReader()).takeUtf8Bytes(MaxLogBytes)
companion object {
private const val MaxFieldLength = 4_000
private const val MaxContactLength = 320
internal const val MaxLogBytes = 192 * 1024
private const val MaxFieldBytes = 4_000
private const val MaxContactBytes = 320
}
}

View File

@@ -5,13 +5,18 @@ import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Captures uncaught exceptions to disk, then uploads on a later launch when
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
*/
@OptIn(ExperimentalAtomicApi::class)
class CrashReporter(
private val store: PendingCrashStore,
private val preferencesRepository: PreferencesRepository,
@@ -21,69 +26,133 @@ class CrashReporter(
private val platform: String,
private val scope: CoroutineScope,
) {
@Volatile private var installed = false
@Volatile private var lastInstallId: String = ""
@Volatile private var lastDiagnosticsEnabled: Boolean = false
private val installed = AtomicBoolean(false)
private val observingPreferences = AtomicBoolean(false)
private val capturePolicy = AtomicReference(CrashCapturePolicy())
fun startObservingPreferences() {
if (!observingPreferences.compareAndSet(false, true)) return
scope.launch {
preferencesRepository.preferences.collect { prefs ->
lastInstallId = prefs.diagnosticsInstallId
lastDiagnosticsEnabled = prefs.diagnosticsEnabled
capturePolicy.store(
CrashCapturePolicy(
installId = prefs.diagnosticsInstallId,
diagnosticsEnabled = prefs.diagnosticsEnabled,
),
)
if (!prefs.diagnosticsEnabled) {
runCatching(::deleteAllPending)
}
}
}
}
fun installUnhandledExceptionHandler() {
if (!DiagnosticsBuildConfig.INCLUDED) return
if (installed) return
installed = true
if (!installed.compareAndSet(false, true)) return
installPlatformCrashHook { throwable ->
capture(throwable)
}
}
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
val policy = capturePolicy.load()
val report = CrashReport(
id = randomUuidString(),
timestampMillis = platformNowMillis(),
installId = lastInstallId,
appVersion = appVersion,
platform = platform,
installId = sanitizeDiagnosticsInstallId(policy.installId),
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
exceptionType = throwable::class.simpleName ?: "Throwable",
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).take(MaxMessageLength),
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).take(MaxStackLength),
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).takeUtf8Bytes(MaxMessageBytes),
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).takeUtf8Bytes(MaxStackBytes),
breadcrumbs = breadcrumbs.snapshot(),
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: lastDiagnosticsEnabled,
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: policy.diagnosticsEnabled,
)
runCatching { store.write(report) }
if (report.diagnosticsEnabledAtCapture != false) {
runCatching { store.write(report) }
runCatching {
store.prune(
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
maxCount = MaxLocalCrashCount,
)
}
}
AppLogger.error("crash", "captured crash ${report.id}", throwable)
return report
}
/**
* Uploads pending crashes that were captured with diagnostics enabled.
* Local files are always kept for bug-report attachment until deleted after successful send.
* Local files are deleted after successful delivery or bounded by local retention.
*/
suspend fun flushPending() {
val diagnosticsEnabled = preferencesRepository.preferences.first().diagnosticsEnabled
if (!diagnosticsEnabled) return
store.prune(
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
maxCount = MaxLocalCrashCount,
)
for (report in store.list()) {
// Only auto-upload crashes captured while diagnostics was on.
if (!report.diagnosticsEnabledAtCapture) continue
val result = transport.sendCrash(report)
if (result.isSuccess) {
store.delete(report.id)
val preferences = preferencesRepository.preferences.first()
if (!preferences.diagnosticsEnabled || capturePolicy.load().diagnosticsEnabled == false) {
deleteAllPending()
return
}
if (report.diagnosticsEnabledAtCapture == false) {
store.delete(report.id)
continue
}
val installId = sanitizeDiagnosticsInstallId(
preferences.diagnosticsInstallId.ifBlank {
preferencesRepository.ensureDiagnosticsInstallId()
},
)
val resolved = report.copy(
installId = report.installId.ifBlank { installId },
diagnosticsEnabledAtCapture = true,
)
if (resolved != report) store.write(resolved)
if (
!preferencesRepository.preferences.first().diagnosticsEnabled ||
capturePolicy.load().diagnosticsEnabled == false
) {
deleteAllPending()
return
}
val result = try {
transport.sendCrash(resolved)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
if (result.isSuccess) {
store.delete(resolved.id)
continue
}
val error = result.exceptionOrNull()
if (error?.isPermanentDiagnosticsPayloadRejection() == true) {
store.delete(resolved.id)
continue
}
break
}
}
fun latestLocalCrash(): CrashReport? = store.list().maxByOrNull { it.timestampMillis }
private fun deleteAllPending() {
store.list().forEach { report -> store.delete(report.id) }
}
companion object {
private const val MaxMessageLength = 2_000
private const val MaxStackLength = 32_000
private const val MaxMessageBytes = 2_000
private const val MaxStackBytes = 32_000
private const val MaxLocalCrashCount = 20
private const val LocalRetentionMillis = 30L * 86_400_000L
}
}
private data class CrashCapturePolicy(
val installId: String = "",
val diagnosticsEnabled: Boolean? = null,
)
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)

View File

@@ -0,0 +1,221 @@
package com.vnidrop.app.diagnostics
/**
* Minimal JSON encoding for diagnostics payloads (no kotlinx.serialization dependency).
*/
internal object DiagnosticsJson {
internal const val MaxRequestBytes = 256 * 1024
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
private const val SizedBatchId = "00000000-0000-4000-8000-000000000000"
fun eventsBody(
batchId: String,
installId: String,
appVersion: String,
platform: String,
events: List<TelemetryEvent>,
): String = buildString {
append('{')
appendJsonField("batchId", batchId)
append(',')
appendJsonField("installId", installId)
append(',')
appendJsonField("appVersion", appVersion)
append(',')
appendJsonField("platform", platform)
append(',')
append("\"events\":[")
events.forEachIndexed { index, event ->
if (index > 0) append(',')
append('{')
appendJsonField("name", event.name)
append(',')
append("\"timestampMillis\":")
append(event.timestampMillis)
append(',')
append("\"schemaVersion\":")
append(event.schemaVersion)
append(',')
append("\"properties\":")
appendStringMap(event.properties)
append('}')
}
append("]}")
}
fun eventBatchFitsRequest(events: List<TelemetryEvent>): Boolean =
eventsBody(
batchId = SizedBatchId,
installId = "\u0000".repeat(MaxInstallIdBytes),
appVersion = "\u0000".repeat(MaxAppVersionBytes),
platform = "\u0000".repeat(MaxPlatformBytes),
events = events,
).encodeToByteArray().size <= MaxRequestBytes
fun crashBody(report: CrashReport): String = buildString {
append('{')
appendJsonField("id", report.id)
append(',')
append("\"timestampMillis\":")
append(report.timestampMillis)
append(',')
appendJsonField("installId", report.installId)
append(',')
appendJsonField("appVersion", report.appVersion)
append(',')
appendJsonField("platform", report.platform)
append(',')
appendJsonField("exceptionType", report.exceptionType)
append(',')
appendJsonField("exceptionMessage", report.exceptionMessage)
append(',')
appendJsonField("stackTrace", report.stackTrace)
append(',')
append("\"diagnosticsEnabledAtCapture\":")
append(requireNotNull(report.diagnosticsEnabledAtCapture) {
"crash consent must be resolved before delivery"
})
append(',')
append("\"schemaVersion\":")
append(report.schemaVersion)
append(',')
append("\"breadcrumbs\":")
appendBreadcrumbs(report.breadcrumbs)
append('}')
}
fun bugBody(report: BugReport): String {
val logs = if (report.includeLogs) report.logs else ""
val complete = buildBugBody(report, logs)
if (complete.encodeToByteArray().size <= MaxRequestBytes || logs.isEmpty()) return complete
var best = buildBugBody(report, "")
if (best.encodeToByteArray().size > MaxRequestBytes) return best
var minimumBytes = 0
var maximumBytes = logs.encodeToByteArray().size
while (minimumBytes <= maximumBytes) {
val candidateBytes = minimumBytes + (maximumBytes - minimumBytes) / 2
val candidate = buildBugBody(report, logs.takeUtf8Bytes(candidateBytes))
if (candidate.encodeToByteArray().size <= MaxRequestBytes) {
best = candidate
minimumBytes = candidateBytes + 1
} else {
maximumBytes = candidateBytes - 1
}
}
return best
}
private fun buildBugBody(report: BugReport, logs: String): String = buildString {
append('{')
appendJsonField("id", report.id)
append(',')
append("\"timestampMillis\":")
append(report.timestampMillis)
append(',')
appendJsonField("installId", report.installId)
append(',')
appendJsonField("appVersion", report.appVersion)
append(',')
appendJsonField("platform", report.platform)
append(',')
appendJsonField("whatHappened", report.whatHappened)
append(',')
appendJsonField("expected", report.expected)
append(',')
appendJsonField("steps", report.steps)
append(',')
appendJsonField("contact", report.contact)
append(',')
append("\"includeLogs\":")
append(report.includeLogs)
append(',')
appendJsonField("logs", logs)
append(',')
append("\"schemaVersion\":")
append(report.schemaVersion)
append(',')
append("\"device\":{")
appendJsonField("deviceName", report.device.deviceName.orEmpty())
append(',')
appendJsonField("deviceModel", report.device.deviceModel.orEmpty())
append(',')
appendJsonField("operatingSystem", report.device.operatingSystem)
append(',')
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('}')
}
private fun StringBuilder.appendJsonField(key: String, value: String) {
append('"')
append(escape(key))
append("\":\"")
append(escape(value))
append('"')
}
internal fun escape(raw: String): String = buildString(raw.length + 8) {
for (ch in raw) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (ch.code < 0x20) {
append("\\u")
append(ch.code.toString(16).padStart(4, '0'))
} else {
append(ch)
}
}
}
}
}

View File

@@ -12,6 +12,12 @@ data class TelemetryEvent(
val schemaVersion: Int = DiagnosticsSchemaVersion,
)
/** One idempotent upload unit; [id] remains stable when delivery is retried. */
data class TelemetryBatch(
val id: String,
val events: List<TelemetryEvent>,
)
data class Breadcrumb(
val name: String,
val timestampMillis: Long,
@@ -28,8 +34,8 @@ data class CrashReport(
val exceptionMessage: String,
val stackTrace: String,
val breadcrumbs: List<Breadcrumb>,
/** Whether diagnostics was opted in when the crash was captured. */
val diagnosticsEnabledAtCapture: Boolean,
/** `null` only while a startup crash is waiting for the persisted preference to load. */
val diagnosticsEnabledAtCapture: Boolean?,
val schemaVersion: Int = DiagnosticsSchemaVersion,
)

View File

@@ -0,0 +1,39 @@
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()
if (encoded.size <= maxBytes) return this
for (endIndex in maxBytes downTo (maxBytes - 3).coerceAtLeast(0)) {
val decoded = runCatching {
encoded.decodeToString(0, endIndex, throwOnInvalidSequence = true)
}.getOrNull()
if (decoded != null) return decoded
}
return ""
}

View File

@@ -1,35 +1,42 @@
package com.vnidrop.app.diagnostics
/**
* Network boundary for diagnostics. Production will swap [NoOpDiagnosticsTransport]
* for a Cloudflare Worker client; keep batching/validation client-side.
*/
/** Network boundary for diagnostics; keep batching and validation client-side. */
interface DiagnosticsTransport {
suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit>
suspend fun sendEvents(batch: TelemetryBatch): Result<Unit>
suspend fun sendCrash(report: CrashReport): Result<Unit>
suspend fun sendBugReport(report: BugReport): Result<Unit>
}
/** Accepts payloads without leaving the device. Used until Cloudflare is wired. */
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured")
internal fun Throwable.isPermanentDiagnosticsPayloadRejection(): Boolean =
this is DiagnosticsPayloadException ||
(this is DiagnosticsHttpException && statusCode in setOf(400, 413, 415, 422))
/** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
class NoOpDiagnosticsTransport : DiagnosticsTransport {
override suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit> = Result.success(Unit)
override suspend fun sendCrash(report: CrashReport): Result<Unit> = Result.success(Unit)
override suspend fun sendBugReport(report: BugReport): Result<Unit> = Result.success(Unit)
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> = unavailable()
override suspend fun sendCrash(report: CrashReport): Result<Unit> = unavailable()
override suspend fun sendBugReport(report: BugReport): Result<Unit> = unavailable()
private fun unavailable(): Result<Unit> = Result.failure(DiagnosticsUnavailableException())
}
/**
* Test double that records calls and can fail on demand.
*/
class RecordingDiagnosticsTransport : DiagnosticsTransport {
val events = mutableListOf<List<TelemetryEvent>>()
val eventBatches = mutableListOf<TelemetryBatch>()
val events: List<List<TelemetryEvent>>
get() = eventBatches.map(TelemetryBatch::events)
val crashes = mutableListOf<CrashReport>()
val bugReports = mutableListOf<BugReport>()
var eventsResult: Result<Unit> = Result.success(Unit)
var crashResult: Result<Unit> = Result.success(Unit)
var bugResult: Result<Unit> = Result.success(Unit)
override suspend fun sendEvents(events: List<TelemetryEvent>): Result<Unit> {
this.events += events
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
eventBatches += batch
return eventsResult
}

View File

@@ -0,0 +1,192 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.AppLogger
import kotlinx.coroutines.CancellationException
/**
* HTTPS client for the Cloudflare diagnostics Worker.
* No-ops are preferred when [baseUrl] is blank — see [createDiagnosticsTransport].
*/
class HttpDiagnosticsTransport(
baseUrl: String,
private val ingestKey: String,
private val appVersion: String = "",
private val platform: String = "",
private val installIdProvider: suspend () -> String = { "" },
private val post: suspend (url: String, headers: Map<String, String>, body: String) -> PlatformHttpResponse =
{ url, headers, body -> platformHttpPost(url, headers, body) },
) : DiagnosticsTransport {
private val root = baseUrl.trim().trimEnd('/')
init {
require(root.isEmpty() || root.isAllowedDiagnosticsEndpoint()) {
"diagnostics endpoint must use HTTPS unless it targets a loopback host"
}
require(root.isEmpty() || ingestKey.isNotBlank()) {
"diagnostics ingest key must be configured when the endpoint is set"
}
}
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
if (batch.events.isEmpty()) return Result.success(Unit)
if (batch.events.size > TelemetryRecorder.MaxEventsPerBatch) {
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is too large"))
}
if (batch.events.any { it.name.isBlank() || it.timestampMillis < 0 }) {
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is invalid"))
}
val installId = sanitizeDiagnosticsInstallId(installIdProvider())
val body = DiagnosticsJson.eventsBody(
batch.id,
installId,
appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
batch.events,
)
return postJson("/v1/events", body, installId, batch.id)
}
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
if (report.diagnosticsEnabledAtCapture == null) {
return Result.failure(DiagnosticsPayloadException("crash consent is unresolved"))
}
val body = DiagnosticsJson.crashBody(report)
return postJson("/v1/crashes", body, report.installId, report.id)
}
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
val body = DiagnosticsJson.bugBody(report)
return postJson("/v1/bugs", body, report.installId, report.id)
}
private suspend fun postJson(
path: String,
body: String,
installId: String,
expectedId: String,
): Result<Unit> {
if (root.isEmpty()) {
return Result.failure(IllegalStateException("diagnostics endpoint is not configured"))
}
if (body.encodeToByteArray().size > DiagnosticsJson.MaxRequestBytes) {
return Result.failure(DiagnosticsPayloadException("diagnostics $path payload is too large"))
}
return try {
val response = post(
"$root$path",
mapOf(
"X-VniDrop-Key" to ingestKey,
"X-VniDrop-Install-Id" to installId,
"Accept" to "application/json",
),
body,
)
if (response.statusCode !in 200..299) {
AppLogger.warn(
"diagnostics",
"transport rejected $path",
mapOf("status" to response.statusCode.toString()),
)
throw DiagnosticsHttpException(response.statusCode, path)
}
if (!response.body.isSuccessfulDiagnosticsAcknowledgement(expectedId)) {
throw DiagnosticsProtocolException(path)
}
Result.success(Unit)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
}
}
internal class DiagnosticsHttpException(
val statusCode: Int,
path: String,
) : IllegalStateException("diagnostics $path failed: HTTP $statusCode")
internal class DiagnosticsProtocolException(path: String) :
IllegalStateException("diagnostics $path returned an invalid acknowledgement")
internal class DiagnosticsPayloadException(message: String) : IllegalArgumentException(message)
/**
* Builds transport from compile-time config. Empty endpoint and key → [NoOpDiagnosticsTransport].
*/
fun createDiagnosticsTransport(
appVersion: String,
platform: String,
installIdProvider: suspend () -> String,
): DiagnosticsTransport {
val endpoint = DiagnosticsBuildConfig.ENDPOINT.trim()
val ingestKey = DiagnosticsBuildConfig.INGEST_KEY.trim()
if (endpoint.isEmpty() && ingestKey.isEmpty()) return NoOpDiagnosticsTransport()
check(endpoint.isNotEmpty() && ingestKey.isNotEmpty()) {
"diagnostics endpoint and ingest key must be configured together"
}
return HttpDiagnosticsTransport(
baseUrl = endpoint,
ingestKey = ingestKey,
appVersion = appVersion,
platform = platform,
installIdProvider = installIdProvider,
)
}
private fun String.isSuccessfulDiagnosticsAcknowledgement(expectedId: String): Boolean {
val json = trim()
if (!SuccessfulAcknowledgement.matches(json)) return false
if (AcknowledgementOk.findAll(json).count() != 1) return false
val ids = AcknowledgementId.findAll(json).toList()
return ids.size == 1 &&
ids.single().groupValues[1] == "\"${DiagnosticsJson.escape(expectedId.lowercase())}\""
}
private fun String.isAllowedDiagnosticsEndpoint(): Boolean {
if (any(Char::isWhitespace) || '?' in this || '#' in this) return false
val schemeSeparator = indexOf("://")
if (schemeSeparator <= 0) return false
val scheme = substring(0, schemeSeparator).lowercase()
val authority = substring(schemeSeparator + 3).substringBefore('/')
if (authority.isEmpty() || '@' in authority) return false
val host = when {
authority.startsWith('[') -> {
val end = authority.indexOf(']')
if (end <= 1) return false
val suffix = authority.substring(end + 1)
if (suffix.isNotEmpty() && !suffix.isValidPortSuffix()) return false
authority.substring(1, end)
}
else -> {
if (authority.count { it == ':' } > 1) return false
val portSeparator = authority.indexOf(':')
if (portSeparator >= 0 && !authority.substring(portSeparator).isValidPortSuffix()) return false
authority.substringBefore(':')
}
}.lowercase()
if (host.isEmpty()) return false
if (scheme == "https") return true
return scheme == "http" && host.isLoopbackHost()
}
private fun String.isValidPortSuffix(): Boolean =
startsWith(':') && drop(1).toIntOrNull() in 1..65_535
private fun String.isLoopbackHost(): Boolean {
if (this == "localhost" || this == "::1") return true
val octets = split('.')
return octets.size == 4 &&
octets.first() == "127" &&
octets.all { it.toIntOrNull() in 0..255 }
}
private const val JsonStringPattern =
""""(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*""""
private const val JsonNumberPattern =
"""-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?"""
private val SuccessfulAcknowledgement = Regex(
"""^\s*\{\s*"ok"\s*:\s*true(?:\s*,\s*$JsonStringPattern\s*:\s*(?:$JsonStringPattern|$JsonNumberPattern|true|false|null))*\s*}\s*$""",
)
private val AcknowledgementOk = Regex(""""ok"\s*:""")
private val AcknowledgementId = Regex(""""id"\s*:\s*($JsonStringPattern)""")

View File

@@ -8,6 +8,7 @@ interface PendingCrashStore {
fun write(report: CrashReport)
fun list(): List<CrashReport>
fun delete(id: String)
fun prune(olderThanTimestampMillis: Long, maxCount: Int)
}
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
@@ -15,14 +16,17 @@ expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
internal object CrashReportCodec {
private const val FieldSep = "\u001f"
private const val RecordSep = "\u001e"
private const val Version2Prefix = "vnidrop-crash-v2\n"
private const val MaxEncodedChars = 512 * 1024
fun encode(report: CrashReport): String = buildString {
fun field(key: String, value: String) {
append(key)
append('=')
append(value.replace("\n", "\\n").replace("\r", "\\r"))
append(FieldSep)
append(value.hexEncode())
append('\n')
}
append(Version2Prefix)
field("id", report.id)
field("ts", report.timestampMillis.toString())
field("install", report.installId)
@@ -31,22 +35,70 @@ internal object CrashReportCodec {
field("type", report.exceptionType)
field("message", report.exceptionMessage)
field("stack", report.stackTrace)
field("diag", if (report.diagnosticsEnabledAtCapture) "1" else "0")
field("schema", report.schemaVersion.toString())
field(
"crumbs",
report.breadcrumbs.joinToString(RecordSep) { crumb ->
listOf(
crumb.timestampMillis.toString(),
crumb.name,
crumb.properties.entries.joinToString(",") { "${it.key}:${it.value}" },
).joinToString("|")
"diag",
when (report.diagnosticsEnabledAtCapture) {
true -> "1"
false -> "0"
null -> "u"
},
)
field("schema", report.schemaVersion.toString())
val breadcrumbs = report.breadcrumbs.take(40)
field("crumb.count", breadcrumbs.size.toString())
breadcrumbs.forEachIndexed { crumbIndex, crumb ->
field("crumb.$crumbIndex.ts", crumb.timestampMillis.toString())
field("crumb.$crumbIndex.name", crumb.name)
val properties = crumb.properties.entries.take(MaxDiagnosticProperties)
field("crumb.$crumbIndex.prop.count", properties.size.toString())
properties.forEachIndexed { propertyIndex, (key, value) ->
field("crumb.$crumbIndex.prop.$propertyIndex.key", key)
field("crumb.$crumbIndex.prop.$propertyIndex.value", value)
}
}
}
fun decode(raw: String): CrashReport? {
if (raw.isBlank()) return null
if (raw.isBlank() || raw.length > MaxEncodedChars) return null
return if (raw.startsWith(Version2Prefix)) decodeVersion2(raw) else decodeLegacy(raw)
}
private fun decodeVersion2(raw: String): CrashReport? {
val map = linkedMapOf<String, String>()
for (part in raw.removePrefix(Version2Prefix).lineSequence()) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1).hexDecode() ?: return null
map[key] = value
}
val crumbCount = map["crumb.count"]?.toIntOrNull()?.takeIf { it in 0..40 } ?: return null
val crumbs = buildList {
repeat(crumbCount) { crumbIndex ->
val timestamp = map["crumb.$crumbIndex.ts"]
?.toLongOrNull()
?.takeIf { it >= 0 }
?: return null
val name = map["crumb.$crumbIndex.name"]?.takeIf { it.isNotBlank() } ?: return null
val propertyCount = map["crumb.$crumbIndex.prop.count"]
?.toIntOrNull()
?.takeIf { it in 0..MaxDiagnosticProperties }
?: return null
val properties = buildMap {
repeat(propertyCount) { propertyIndex ->
val key = map["crumb.$crumbIndex.prop.$propertyIndex.key"] ?: return null
val value = map["crumb.$crumbIndex.prop.$propertyIndex.value"] ?: return null
put(key, value)
}
}
add(Breadcrumb(name = name, timestampMillis = timestamp, properties = properties))
}
}
return reportFromFields(map, crumbs)
}
private fun decodeLegacy(raw: String): CrashReport? {
val map = linkedMapOf<String, String>()
for (part in raw.split(FieldSep)) {
if (part.isEmpty()) continue
@@ -58,15 +110,14 @@ internal object CrashReportCodec {
.replace("\\r", "\r")
map[key] = value
}
val id = map["id"] ?: return null
val crumbs = map["crumbs"].orEmpty()
.split(RecordSep)
.filter { it.isNotBlank() }
.mapNotNull { entry ->
val pieces = entry.split('|', limit = 3)
if (pieces.size < 2) return@mapNotNull null
val ts = pieces[0].toLongOrNull() ?: return@mapNotNull null
val name = pieces[1]
val ts = pieces[0].toLongOrNull()?.takeIf { it >= 0 } ?: return@mapNotNull null
val name = pieces[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null
val props = if (pieces.size > 2 && pieces[2].isNotBlank()) {
pieces[2].split(',').mapNotNull { kv ->
val colon = kv.indexOf(':')
@@ -78,18 +129,65 @@ internal object CrashReportCodec {
}
Breadcrumb(name = name, timestampMillis = ts, properties = props)
}
return reportFromFields(map, crumbs)
}
private fun reportFromFields(
map: Map<String, String>,
crumbs: List<Breadcrumb>,
): CrashReport? {
val id = map["id"]?.takeIf(::isValidDiagnosticId) ?: return null
val timestamp = map["ts"]?.toLongOrNull()?.takeIf { it >= 0 } ?: return null
val exceptionType = map["type"]?.takeIf { it.isNotBlank() } ?: return null
val schemaVersion = map["schema"]?.toIntOrNull()
?.takeIf { it == DiagnosticsSchemaVersion }
?: return null
val diagnosticsEnabled: Boolean? = when (map["diag"]) {
"1" -> true
"0" -> false
"u" -> null
else -> return null
}
return CrashReport(
id = id,
timestampMillis = map["ts"]?.toLongOrNull() ?: 0L,
timestampMillis = timestamp,
installId = map["install"].orEmpty(),
appVersion = map["app"].orEmpty(),
platform = map["platform"].orEmpty(),
exceptionType = map["type"].orEmpty(),
exceptionType = exceptionType,
exceptionMessage = map["message"].orEmpty(),
stackTrace = map["stack"].orEmpty(),
breadcrumbs = crumbs,
diagnosticsEnabledAtCapture = map["diag"] == "1",
schemaVersion = map["schema"]?.toIntOrNull() ?: DiagnosticsSchemaVersion,
diagnosticsEnabledAtCapture = diagnosticsEnabled,
schemaVersion = schemaVersion,
)
}
}
internal fun isValidDiagnosticId(id: String): Boolean =
DiagnosticIdPattern.matches(id)
private val DiagnosticIdPattern =
Regex("^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
private fun String.hexEncode(): String {
val digits = "0123456789abcdef"
return buildString(length * 2) {
for (byte in this@hexEncode.encodeToByteArray()) {
val value = byte.toInt() and 0xff
append(digits[value ushr 4])
append(digits[value and 0x0f])
}
}
}
private fun String.hexDecode(): String? {
if (length % 2 != 0) return null
val bytes = ByteArray(length / 2)
for (index in bytes.indices) {
val high = this[index * 2].digitToIntOrNull(16) ?: return null
val low = this[index * 2 + 1].digitToIntOrNull(16) ?: return null
bytes[index] = ((high shl 4) or low).toByte()
}
return runCatching { bytes.decodeToString(throwOnInvalidSequence = true) }.getOrNull()
}

View File

@@ -0,0 +1,12 @@
package com.vnidrop.app.diagnostics
data class PlatformHttpResponse(
val statusCode: Int,
val body: String,
)
expect suspend fun platformHttpPost(
url: String,
headers: Map<String, String>,
bodyUtf8: String,
): PlatformHttpResponse

View File

@@ -2,17 +2,25 @@ package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Product telemetry: sparse events, gated by diagnostics opt-in.
* Events are buffered and flushed in batches when transport is available.
*/
@OptIn(ExperimentalAtomicApi::class)
class TelemetryRecorder(
private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport,
@@ -20,64 +28,186 @@ class TelemetryRecorder(
private val scope: CoroutineScope,
private val maxBufferSize: Int = DefaultMaxBuffer,
private val flushThreshold: Int = DefaultFlushThreshold,
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
) {
private val bufferMutex = Mutex()
@Volatile private var buffer: List<TelemetryEvent> = emptyList()
@Volatile private var enabled: Boolean = false
private val state = AtomicReference(TelemetryState())
private val flushSignals = Channel<Unit>(Channel.CONFLATED)
init {
require(maxBufferSize > 0) { "maxBufferSize must be positive" }
require(flushThreshold > 0) { "flushThreshold must be positive" }
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
scope.launch {
preferencesRepository.preferences
.map { it.diagnosticsEnabled }
.distinctUntilChanged()
.collect { isEnabled ->
enabled = isEnabled
if (!isEnabled) {
buffer = emptyList()
updateState { current ->
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
}
flushSignals.trySend(Unit)
}
}
scope.launch { runAutomaticFlushes() }
}
fun record(name: String, properties: Map<String, String> = emptyMap()) {
if (!DiagnosticsBuildConfig.INCLUDED) return
val redacted = LogRedactor.redactMap(properties)
breadcrumbs.add(name, redacted)
if (!enabled) return
val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
breadcrumbs.add(sanitizedName, sanitizedProperties)
val event = TelemetryEvent(
name = name.take(MaxNameLength),
name = sanitizedName,
timestampMillis = platformNowMillis(),
properties = redacted.mapValues { it.value.take(MaxPropertyValueLength) },
properties = sanitizedProperties,
)
val next = (buffer + event).takeLast(maxBufferSize)
buffer = next
if (next.size >= flushThreshold) {
scope.launch { flush() }
while (true) {
val current = state.load()
if (current.enabled == false) return
val remainingCapacity =
(maxBufferSize - current.retryBatch?.events.orEmpty().size).coerceAtLeast(0)
val nextBuffer = (current.buffer + event).takeLast(remainingCapacity)
if (state.compareAndSet(current, current.copy(buffer = nextBuffer))) {
flushSignals.trySend(Unit)
return
}
}
}
suspend fun flush(): Result<Unit> = bufferMutex.withLock {
if (!enabled) {
buffer = emptyList()
return Result.success(Unit)
suspend fun flush(): Result<Unit> {
return bufferMutex.withLock {
var discardedFailure: Throwable? = null
var outcome: Result<Unit>? = null
while (outcome == null) {
val current = state.load()
if (current.enabled != true) return@withLock Result.success(Unit)
val pendingRetry = current.retryBatch
val events = if (pendingRetry == null) nextBatchEvents(current.buffer) else emptyList()
if (pendingRetry == null && events.isEmpty()) {
outcome = discardedFailure?.let { Result.failure(it) } ?: Result.success(Unit)
continue
}
val batch: TelemetryBatch
if (pendingRetry != null) {
batch = pendingRetry
} else {
val prepared = TelemetryBatch(id = randomUuidString(), events = events)
val next = current.copy(
buffer = current.buffer.drop(events.size),
retryBatch = prepared,
)
if (!state.compareAndSet(current, next)) continue
batch = prepared
}
if (state.load().retryBatch != batch) continue
val result = try {
transport.sendEvents(batch)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
if (result.isSuccess) {
clearRetryBatch(batch)
continue
}
val error = result.exceptionOrNull() ?: IllegalStateException("diagnostics event delivery failed")
if (error.isPermanentDiagnosticsPayloadRejection()) {
clearRetryBatch(batch)
discardedFailure = discardedFailure ?: error
continue
}
outcome = result
}
checkNotNull(outcome)
}
val batch = buffer
if (batch.isEmpty()) return Result.success(Unit)
buffer = emptyList()
val result = transport.sendEvents(batch)
if (result.isFailure) {
// Re-queue on failure so a future transport can retry once online.
buffer = (batch + buffer).takeLast(maxBufferSize)
}
return result
}
fun pendingCount(): Int = buffer.size
private fun nextBatchEvents(events: List<TelemetryEvent>): List<TelemetryEvent> {
if (events.isEmpty()) return emptyList()
var minimum = 1
var maximum = minOf(events.size, MaxEventsPerBatch)
var accepted = 1
while (minimum <= maximum) {
val candidateSize = minimum + (maximum - minimum) / 2
if (DiagnosticsJson.eventBatchFitsRequest(events.take(candidateSize))) {
accepted = candidateSize
minimum = candidateSize + 1
} else {
maximum = candidateSize - 1
}
}
return events.take(accepted)
}
fun pendingCount(): Int {
val current = state.load()
return current.retryBatch?.events.orEmpty().size + current.buffer.size
}
private suspend fun runAutomaticFlushes() {
while (true) {
flushSignals.receive()
var retries = 0
while (true) {
val current = state.load()
if (current.enabled != true || pendingCount() == 0) break
if (current.retryBatch == null && pendingCount() < flushThreshold) {
val signalled = withTimeoutOrNull(flushIntervalMillis) {
flushSignals.receive()
true
} ?: false
if (signalled) continue
}
val result = flush()
if (result.isSuccess || pendingCount() == 0) {
retries = 0
continue
}
val error = result.exceptionOrNull()
if (error?.isPermanentDiagnosticsPayloadRejection() == true || retries >= automaticRetryCount) {
break
}
retries += 1
delay(retryBackoffMillis)
}
}
}
private fun clearRetryBatch(batch: TelemetryBatch) {
while (true) {
val current = state.load()
if (current.retryBatch != batch) return
if (state.compareAndSet(current, current.copy(retryBatch = null))) return
}
}
private fun updateState(update: (TelemetryState) -> TelemetryState) {
while (true) {
val current = state.load()
if (state.compareAndSet(current, update(current))) return
}
}
companion object {
const val DefaultMaxBuffer = 100
const val DefaultFlushThreshold = 20
private const val MaxNameLength = 64
private const val MaxPropertyValueLength = 128
const val MaxEventsPerBatch = 50
const val DefaultFlushIntervalMillis = 30_000L
const val DefaultRetryBackoffMillis = 30_000L
const val DefaultAutomaticRetryCount = 3
}
}
private data class TelemetryState(
val enabled: Boolean? = null,
val buffer: List<TelemetryEvent> = emptyList(),
val retryBatch: TelemetryBatch? = null,
)

View File

@@ -1,27 +1,250 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class DiagnosticsTest {
@Test
fun diagnosticsBuildConfigDefaultsToIncluded() {
fun diagnosticsBuildConfigDefaultsToIncludedWithEmptyEndpoint() {
// Production default is true; builds can override with -Pvnidrop.diagnostics.included=false.
assertTrue(DiagnosticsBuildConfig.INCLUDED)
// Empty endpoint keeps shipping safe (NoOp transport) until Cloudflare is configured.
assertEquals("", DiagnosticsBuildConfig.ENDPOINT)
}
@Test
fun diagnosticsJsonEscapesAndShapesPayloads() {
val eventsJson = DiagnosticsJson.eventsBody(
batchId = "batch-1",
installId = "inst-1",
appVersion = "1.0",
platform = "Test",
events = listOf(
TelemetryEvent("app_open", 10L, mapOf("a" to "quote\"here")),
),
)
assertTrue(eventsJson.contains("\"batchId\":\"batch-1\""))
assertTrue(eventsJson.contains("\"installId\":\"inst-1\""))
assertTrue(eventsJson.contains("\"name\":\"app_open\""))
assertTrue(eventsJson.contains("quote\\\"here"))
val crashJson = DiagnosticsJson.crashBody(
CrashReport(
id = "c1",
timestampMillis = 1L,
installId = "inst",
appVersion = "1.0",
platform = "Test",
exceptionType = "E",
exceptionMessage = "line\nbreak",
stackTrace = "stack",
breadcrumbs = emptyList(),
diagnosticsEnabledAtCapture = true,
),
)
assertTrue(crashJson.contains("line\\nbreak"))
assertTrue(crashJson.contains("\"diagnosticsEnabledAtCapture\":true"))
}
@Test
fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() {
val report = BugReport(
id = "b",
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
whatHappened = "w",
expected = "e",
steps = "",
contact = "",
includeLogs = true,
logs = "\n".repeat(BugReportService.MaxLogBytes),
device = DeviceSnapshot(null, null, "OS", null, null),
breadcrumbs = emptyList(),
)
val body = DiagnosticsJson.bugBody(report)
assertTrue(body.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes)
assertTrue(body.contains("\"includeLogs\":true"))
assertTrue(body.endsWith("}"))
}
@Test
fun httpTransportPostsExpectedPaths() = runTest {
val calls = mutableListOf<Pair<String, String>>()
val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b"))
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
appVersion = "1.0",
platform = "Test",
installIdProvider = { "install-x" },
post = { url, headers, body ->
assertEquals("secret", headers["X-VniDrop-Key"])
calls += url to body
PlatformHttpResponse(
202,
"""{"ok":true,"id":"${acknowledgementIds.removeFirst()}","stored":1}""",
)
},
)
val eventResult = transport.sendEvents(
TelemetryBatch("batch-1", listOf(TelemetryEvent("nav", 1L))),
)
assertTrue(eventResult.isSuccess)
assertEquals("https://diag.example/v1/events", calls[0].first)
assertTrue(calls[0].second.contains("install-x"))
val crashResult = transport.sendCrash(
CrashReport(
id = "c",
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
exceptionType = "E",
exceptionMessage = "m",
stackTrace = "s",
breadcrumbs = emptyList(),
diagnosticsEnabledAtCapture = true,
),
)
assertTrue(crashResult.isSuccess)
assertEquals("https://diag.example/v1/crashes", calls[1].first)
val bugResult = transport.sendBugReport(
BugReport(
id = "b",
timestampMillis = 1L,
installId = "i",
appVersion = "1.0",
platform = "Test",
whatHappened = "w",
expected = "e",
steps = "",
contact = "",
includeLogs = false,
logs = "",
device = DeviceSnapshot(null, null, "OS", null, null),
breadcrumbs = emptyList(),
),
)
assertTrue(bugResult.isSuccess)
assertEquals("https://diag.example/v1/bugs", calls[2].first)
}
@Test
fun httpTransportFailsOnHttpError() = runTest {
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") },
)
assertTrue(
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure,
)
}
@Test
fun httpTransportPropagatesCancellation() = runTest {
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
post = { _, _, _ -> throw CancellationException("cancelled") },
)
assertFailsWith<CancellationException> {
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1))))
}
}
@Test
fun httpTransportRejectsMissingOrNegativeAcknowledgement() = runTest {
val responses = ArrayDeque(
listOf(
PlatformHttpResponse(202, ""),
PlatformHttpResponse(202, """{"ok":false}"""),
PlatformHttpResponse(202, """{,"ok":true}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""),
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4","id":"batch-4"}"""),
),
)
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
post = { _, _, _ -> responses.removeFirst() },
)
repeat(5) { index ->
val result = transport.sendEvents(
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
}
}
@Test
fun httpTransportRequiresHttpsExceptForLoopbackDevelopment() {
assertFailsWith<IllegalArgumentException> {
HttpDiagnosticsTransport("http://diag.example", "secret")
}
assertFailsWith<IllegalArgumentException> {
HttpDiagnosticsTransport("http://[::1].example", "secret")
}
HttpDiagnosticsTransport("http://localhost:8787", "secret")
HttpDiagnosticsTransport("http://127.0.0.1:8787", "secret")
HttpDiagnosticsTransport("http://[::1]:8787", "secret")
assertFailsWith<IllegalArgumentException> {
HttpDiagnosticsTransport("https://diag.example", "")
}
}
@Test
fun createTransportIsNoOpWhenEndpointBlank() {
// With default generated config endpoint is empty.
val transport = createDiagnosticsTransport(
appVersion = "1.0",
platform = "Test",
installIdProvider = { "id" },
)
assertIs<NoOpDiagnosticsTransport>(transport)
}
@Test
fun noOpTransportReportsUnavailableDelivery() = runTest {
val result = NoOpDiagnosticsTransport().sendEvents(
TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))),
)
assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull())
}
@Test
@@ -72,6 +295,35 @@ class DiagnosticsTest {
assertEquals(1, breadcrumbs.snapshot().size)
}
@Test
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
val backing = fakePrefs(diagnosticsEnabled = true)
val preferenceGate = CompletableDeferred<Unit>()
val delayedPreferences = object : PreferencesRepository by backing {
override val preferences = flow {
preferenceGate.await()
emitAll(backing.preferences)
}
}
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = delayedPreferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 20,
)
runCurrent()
recorder.record("app_open")
assertEquals(1, recorder.pendingCount())
preferenceGate.complete(Unit)
runCurrent()
assertTrue(recorder.flush().isSuccess)
assertEquals(listOf("app_open"), transport.events.single().map { it.name })
}
@Test
fun telemetryBuffersAndFlushesWhenEnabled() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
@@ -92,6 +344,222 @@ class DiagnosticsTest {
assertEquals(listOf("one", "two"), transport.events.single().map { it.name })
}
@Test
fun telemetryFlushesSparseEventsAfterTheInterval() = runTest {
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 20,
flushIntervalMillis = 1_000,
)
advanceUntilIdle()
recorder.record("sparse")
advanceTimeBy(999)
runCurrent()
assertTrue(transport.eventBatches.isEmpty())
advanceTimeBy(1)
runCurrent()
assertEquals(listOf("sparse"), transport.events.single().map { it.name })
}
@Test
fun telemetryCoalescesAutomaticRetriesWithBackoff() = runTest {
val transport = RecordingDiagnosticsTransport().apply {
eventsResult = Result.failure(IllegalStateException("offline"))
}
val recorder = TelemetryRecorder(
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 1,
flushIntervalMillis = 10_000,
retryBackoffMillis = 1_000,
automaticRetryCount = 1,
)
advanceUntilIdle()
recorder.record("retry")
runCurrent()
assertEquals(1, transport.eventBatches.size)
advanceTimeBy(999)
runCurrent()
assertEquals(1, transport.eventBatches.size)
advanceTimeBy(1)
runCurrent()
assertEquals(2, transport.eventBatches.size)
advanceTimeBy(10_000)
runCurrent()
assertEquals(2, transport.eventBatches.size)
}
@Test
fun telemetryFlushesAtMostFiftyEventsPerBatch() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 101,
maxBufferSize = 100,
)
advanceUntilIdle()
repeat(75) { recorder.record("event-$it") }
assertTrue(recorder.flush().isSuccess)
assertEquals(listOf(50, 25), transport.eventBatches.map { it.events.size })
assertTrue(transport.eventBatches.all { it.events.size <= TelemetryRecorder.MaxEventsPerBatch })
}
@Test
fun telemetrySplitsBatchesByEscapedRequestBytes() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val requestBodies = mutableListOf<String>()
val transport = HttpDiagnosticsTransport(
baseUrl = "https://diag.example",
ingestKey = "secret",
appVersion = "1.0",
platform = "Test",
installIdProvider = { "test-install" },
post = { _, _, body ->
requestBodies += body
val id = Regex(""""batchId":"([^"]+)"""").find(body)?.groupValues?.get(1)
PlatformHttpResponse(202, """{"ok":true,"id":"$id"}""")
},
)
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 101,
maxBufferSize = 100,
)
advanceUntilIdle()
val properties = LinkedHashMap<String, String>().apply {
repeat(MaxDiagnosticProperties) { index ->
put("key-$index-${"\u0001".repeat(40)}", "\u0001".repeat(MaxDiagnosticPropertyValueBytes))
}
}
repeat(50) { index ->
recorder.record("event-$index-${"\u0001".repeat(64)}", properties)
}
assertTrue(recorder.flush().isSuccess)
assertTrue(requestBodies.size > 1)
assertTrue(requestBodies.all { it.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes })
assertEquals(50, requestBodies.sumOf { body -> "\"schemaVersion\"".toRegex().findAll(body).count() })
}
@Test
fun telemetrySanitizesNamesAndPropertiesToServerByteLimits() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 100,
)
advanceUntilIdle()
val properties = LinkedHashMap<String, String>().apply {
repeat(20) { index -> put("key-$index-${"🙂".repeat(20)}", "🙂".repeat(100)) }
}
recorder.record("🙂".repeat(100), properties)
assertTrue(recorder.flush().isSuccess)
val event = transport.eventBatches.single().events.single()
assertTrue(event.name.encodeToByteArray().size <= MaxDiagnosticNameBytes)
assertEquals(MaxDiagnosticProperties, event.properties.size)
assertTrue(event.properties.keys.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyKeyBytes })
assertTrue(event.properties.values.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyValueBytes })
}
@Test
fun telemetryKeepsConcurrentRecordsWithoutExceedingItsBuffer() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport()
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 101,
maxBufferSize = 100,
)
advanceUntilIdle()
coroutineScope {
repeat(100) { index ->
launch(Dispatchers.Default) { recorder.record("event-$index") }
}
}
assertEquals(
100,
recorder.pendingCount() + transport.eventBatches.sumOf { it.events.size },
)
assertTrue(recorder.flush().isSuccess)
assertEquals(100, transport.eventBatches.sumOf { it.events.size })
}
@Test
fun telemetryRetryReusesBatchIdAndEvents() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport().apply {
eventsResult = Result.failure(IllegalStateException("offline"))
}
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 10,
)
advanceUntilIdle()
recorder.record("one")
recorder.record("two")
assertTrue(recorder.flush().isFailure)
val firstAttempt = transport.eventBatches.single()
transport.eventsResult = Result.success(Unit)
assertTrue(recorder.flush().isSuccess)
assertEquals(listOf(firstAttempt, firstAttempt), transport.eventBatches)
assertEquals(0, recorder.pendingCount())
}
@Test
fun telemetryDoesNotRequeuePermanentlyRejectedPayload() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val transport = RecordingDiagnosticsTransport().apply {
eventsResult = Result.failure(DiagnosticsHttpException(400, "/v1/events"))
}
val recorder = TelemetryRecorder(
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
flushThreshold = 10,
)
advanceUntilIdle()
recorder.record("invalid")
assertTrue(recorder.flush().isFailure)
assertEquals(0, recorder.pendingCount())
transport.eventsResult = Result.success(Unit)
assertTrue(recorder.flush().isSuccess)
assertEquals(1, transport.eventBatches.size)
}
@Test
fun telemetryClearsBufferWhenOptedOut() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
@@ -114,21 +582,49 @@ class DiagnosticsTest {
@Test
fun crashCodecRoundTrips() {
val original = CrashReport(
id = "crash-1",
id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
timestampMillis = 42L,
installId = "install",
appVersion = "1.0",
platform = "Test",
exceptionType = "IllegalStateException",
exceptionMessage = "boom\nline",
stackTrace = "stack\ntrace",
exceptionMessage = "boom\nline\\nliteral\u001fseparator",
stackTrace = "stack\ntrace\u001erecord",
breadcrumbs = listOf(
Breadcrumb("open", 1L, mapOf("screen" to "send")),
Breadcrumb("open|send", 1L, mapOf("screen:key" to "send,value|next")),
),
diagnosticsEnabledAtCapture = true,
)
val decoded = CrashReportCodec.decode(CrashReportCodec.encode(original))
assertEquals(original, decoded)
assertNull(CrashReportCodec.decode(CrashReportCodec.encode(original.copy(id = "../../escape"))))
}
@Test
fun crashCodecMigratesLegacyV1Envelope() {
val raw = listOf(
"id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"ts=42",
"install=legacy-install",
"app=0.9",
"platform=Desktop",
"type=IllegalStateException",
"message=first\\nsecond",
"stack=frame one\\nframe two",
"diag=1",
"schema=1",
"crumbs=1|opened|screen:send",
).joinToString("\u001f")
val report = requireNotNull(CrashReportCodec.decode(raw))
assertEquals("first\nsecond", report.exceptionMessage)
assertEquals("frame one\nframe two", report.stackTrace)
assertEquals(true, report.diagnosticsEnabledAtCapture)
assertEquals(
listOf(Breadcrumb("opened", 1, mapOf("screen" to "send"))),
report.breadcrumbs,
)
}
@Test
@@ -150,13 +646,165 @@ class DiagnosticsTest {
val optedIn = reporter.capture(RuntimeException("a"), diagnosticsEnabledOverride = true)
reporter.capture(RuntimeException("b"), diagnosticsEnabledOverride = false)
assertEquals(2, store.list().size)
assertEquals(1, store.list().size)
reporter.flushPending()
assertEquals(1, transport.crashes.size)
assertEquals(optedIn.id, transport.crashes.single().id)
assertTrue(store.list().isEmpty())
}
@Test
fun crashReporterRetainsCrashWhenDeliveryIsUnavailable() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = preferences,
transport = NoOpDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
reporter.capture(RuntimeException("boom"), diagnosticsEnabledOverride = true)
reporter.flushPending()
assertEquals(1, store.list().size)
assertFalse(store.list().single().diagnosticsEnabledAtCapture)
}
@Test
fun crashReporterDropsPermanentPayloadFailuresAndStopsAfterTransientFailures() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val permanentTransport = RecordingDiagnosticsTransport().apply {
crashResult = Result.failure(DiagnosticsHttpException(400, "/v1/crashes"))
}
val permanentStore = InMemoryPendingCrashStore()
val permanentReporter = CrashReporter(
store = permanentStore,
preferencesRepository = preferences,
transport = permanentTransport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
permanentReporter.capture(RuntimeException("invalid"), diagnosticsEnabledOverride = true)
permanentReporter.flushPending()
assertTrue(permanentStore.list().isEmpty())
val transientTransport = RecordingDiagnosticsTransport().apply {
crashResult = Result.failure(IllegalStateException("offline"))
}
val transientStore = InMemoryPendingCrashStore()
val transientReporter = CrashReporter(
store = transientStore,
preferencesRepository = preferences,
transport = transientTransport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
repeat(2) {
transientReporter.capture(RuntimeException("offline-$it"), diagnosticsEnabledOverride = true)
}
transientReporter.flushPending()
assertEquals(1, transientTransport.crashes.size)
assertEquals(2, transientStore.list().size)
}
@Test
fun crashReporterResolvesStartupConsentBeforeUploading() = runTest {
val transport = RecordingDiagnosticsTransport()
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
val startupCrash = reporter.capture(RuntimeException("startup"))
assertNull(startupCrash.diagnosticsEnabledAtCapture)
reporter.flushPending()
assertEquals(true, transport.crashes.single().diagnosticsEnabledAtCapture)
assertEquals("test-install", transport.crashes.single().installId)
assertTrue(store.list().isEmpty())
}
@Test
fun crashReporterDoesNotRetroactivelyUploadAnOptedOutStartupCrash() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = false)
val transport = RecordingDiagnosticsTransport()
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
reporter.capture(RuntimeException("startup"))
reporter.flushPending()
assertTrue(store.list().isEmpty())
preferences.setDiagnosticsEnabled(true)
reporter.flushPending()
assertTrue(transport.crashes.isEmpty())
assertTrue(store.list().isEmpty())
}
@Test
fun crashReporterStopsAFlushWhenTheUserOptsOut() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = true)
val firstSendStarted = CompletableDeferred<Unit>()
val releaseFirstSend = CompletableDeferred<Unit>()
val sentIds = mutableListOf<String>()
val transport = object : DiagnosticsTransport {
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
override suspend fun sendBugReport(report: BugReport) = Result.success(Unit)
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
sentIds += report.id
if (sentIds.size == 1) {
firstSendStarted.complete(Unit)
releaseFirstSend.await()
}
return Result.success(Unit)
}
}
val store = InMemoryPendingCrashStore()
val reporter = CrashReporter(
store = store,
preferencesRepository = preferences,
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
)
reporter.startObservingPreferences()
advanceUntilIdle()
repeat(2) {
reporter.capture(RuntimeException("crash-$it"), diagnosticsEnabledOverride = true)
}
val flush = launch { reporter.flushPending() }
firstSendStarted.await()
preferences.setDiagnosticsEnabled(false)
runCurrent()
releaseFirstSend.complete(Unit)
flush.join()
assertEquals(1, sentIds.size)
assertTrue(store.list().isEmpty())
}
@Test
@@ -174,6 +822,29 @@ class DiagnosticsTest {
assertTrue(service.submit(BugReportDraft("what", ""), device()).isFailure)
}
@Test
fun bugReportConvertsTransportExceptionsToFailure() = runTest {
val throwingTransport = object : DiagnosticsTransport {
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
override suspend fun sendCrash(report: CrashReport) = Result.success(Unit)
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
throw IllegalStateException("offline")
}
}
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = throwingTransport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
)
val result = service.submit(BugReportDraft("what", "expected"), device())
assertTrue(result.isFailure)
assertEquals("offline", result.exceptionOrNull()?.message)
}
@Test
fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest {
val preferences = fakePrefs(diagnosticsEnabled = false)
@@ -205,6 +876,43 @@ class DiagnosticsTest {
assertEquals("test-install", report.installId)
}
@Test
fun bugReportRedactsLogsBeforeApplyingUtf8Limit() {
val secret = "abcdefghijklmnopqrstuvwxyz012345"
val rawLogs = "ticket=$secret\n".repeat(6_000) + "tail-marker"
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
logReader = { rawLogs },
)
val logs = service.assemble(BugReportDraft("what", "expected"), device(), "install").logs
assertFalse(logs.contains(secret))
assertTrue(logs.endsWith("tail-marker"))
assertTrue(logs.encodeToByteArray().size <= BugReportService.MaxLogBytes)
}
@Test
fun bugReportLogLimitCountsUtf8Bytes() {
val service = BugReportService(
preferencesRepository = fakePrefs(),
transport = RecordingDiagnosticsTransport(),
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",
logReader = { "🙂".repeat(60_000) },
)
val logs = service.assemble(BugReportDraft("what", "expected"), device(), "install").logs
assertEquals(BugReportService.MaxLogBytes, logs.encodeToByteArray().size)
assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes())
}
private fun fakePrefs(diagnosticsEnabled: Boolean = false) = FakePreferencesRepository(
AppPreferences(
username = "User",
@@ -228,4 +936,15 @@ private class InMemoryPendingCrashStore : PendingCrashStore {
override fun delete(id: String) {
items.remove(id)
}
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
items.values
.sortedByDescending { it.timestampMillis }
.drop(maxCount)
.map(CrashReport::id)
.forEach(items::remove)
items.values
.filter { it.timestampMillis < olderThanTimestampMillis }
.map(CrashReport::id)
.forEach(items::remove)
}
}

View File

@@ -10,13 +10,16 @@ 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
import com.vnidrop.app.diagnostics.RecordingDiagnosticsTransport
import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.diagnostics.BugReportService
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
import com.vnidrop.app.diagnostics.BreadcrumbBuffer
import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.AppPreferences
@@ -165,6 +168,24 @@ class ViewModelsTest {
assertEquals("", viewModel.state.value.bugExpected)
}
@Test
fun settingsKeepsBugReportWhenDeliveryIsUnavailable() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = settingsViewModel(transport = NoOpDiagnosticsTransport())
advanceUntilIdle()
viewModel.selectSection(SettingsSection.BugReport)
viewModel.setBugWhatHappened("Transfer stuck")
viewModel.setBugExpected("It should finish")
viewModel.submitBugReport()
advanceUntilIdle()
assertEquals("Transfer stuck", viewModel.state.value.bugWhatHappened)
assertEquals("It should finish", viewModel.state.value.bugExpected)
assertEquals(SettingsSection.BugReport, viewModel.state.value.selectedSection)
assertFalse(viewModel.state.value.isSubmittingBugReport)
}
@Test
fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -512,6 +533,7 @@ class ViewModelsTest {
private fun settingsViewModel(
preferences: PreferencesRepository = preferences(),
notifications: FakeNotificationService = FakeNotificationService(),
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
) = SettingsViewModel(
environment(),
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
@@ -521,7 +543,7 @@ class ViewModelsTest {
UiMessageController(),
BugReportService(
preferencesRepository = preferences,
transport = NoOpDiagnosticsTransport(),
transport = transport,
breadcrumbs = BreadcrumbBuffer(),
appVersion = "1.0",
platform = "Test",

View File

@@ -1,15 +1,13 @@
package com.vnidrop.app.diagnostics
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create
import platform.Foundation.dataUsingEncoding
import platform.Foundation.dataWithContentsOfFile
import platform.Foundation.writeToFile
import platform.posix.memcpy
@@ -25,10 +23,11 @@ private class IosPendingCrashStore(
private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes"
override fun write(report: CrashReport) {
if (!isValidDiagnosticId(report.id)) return
ensureDirectory()
val path = "$directory/${report.id}.crash"
val payload = CrashReportCodec.encode(report)
val data = (payload as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return
val data = payload.encodeToByteArray().toNSData()
data.writeToFile(path, atomically = true)
}
@@ -46,14 +45,47 @@ private class IosPendingCrashStore(
}
override fun delete(id: String) {
if (!isValidDiagnosticId(id)) return
fileManager.removeItemAtPath("$directory/$id.crash", null)
}
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
require(maxCount > 0) { "maxCount must be positive" }
ensureDirectory()
val reports = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
.filterIsInstance<String>()
.filter { it.endsWith(".crash") }
.mapNotNull { name ->
val path = "$directory/$name"
val report = NSData.dataWithContentsOfFile(path)
?.toUtf8String()
?.let(CrashReportCodec::decode)
if (report == null) {
fileManager.removeItemAtPath(path, null)
null
} else {
name to report
}
}
.sortedByDescending { (_, report) -> report.timestampMillis }
reports.forEachIndexed { index, (name, report) ->
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) {
fileManager.removeItemAtPath("$directory/$name", null)
}
}
}
private fun ensureDirectory() {
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
}
}
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun ByteArray.toNSData(): NSData =
usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toUtf8String(): String {
val size = length.toInt()

View File

@@ -0,0 +1,73 @@
package com.vnidrop.app.diagnostics
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSData
import platform.Foundation.NSHTTPURLResponse
import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSURL
import platform.Foundation.NSURLSession
import platform.Foundation.create
import platform.Foundation.dataTaskWithRequest
import platform.Foundation.setHTTPBody
import platform.Foundation.setHTTPMethod
import platform.Foundation.setValue
import platform.posix.memcpy
import kotlin.coroutines.resume
@OptIn(ExperimentalForeignApi::class)
actual suspend fun platformHttpPost(
url: String,
headers: Map<String, String>,
bodyUtf8: String,
): PlatformHttpResponse = suspendCancellableCoroutine { cont ->
val nsUrl = NSURL.URLWithString(url)
if (nsUrl == null) {
cont.resume(PlatformHttpResponse(statusCode = 0, body = "invalid_url"))
return@suspendCancellableCoroutine
}
val request = NSMutableURLRequest.requestWithURL(nsUrl).apply {
setHTTPMethod("POST")
setValue("application/json; charset=utf-8", forHTTPHeaderField = "Content-Type")
headers.forEach { (key, value) ->
setValue(value, forHTTPHeaderField = key)
}
setHTTPBody(bodyUtf8.encodeToByteArray().toNSData())
}
val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error ->
if (!cont.isActive) return@dataTaskWithRequest
if (error != null) {
val message = error.localizedDescription
cont.resume(PlatformHttpResponse(statusCode = 0, body = message))
return@dataTaskWithRequest
}
val http = response as? NSHTTPURLResponse
val status = http?.statusCode?.toInt() ?: 0
val body = data?.toUtf8String().orEmpty()
cont.resume(PlatformHttpResponse(statusCode = status, body = body))
}
cont.invokeOnCancellation { task.cancel() }
task.resume()
}
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun ByteArray.toNSData(): NSData =
usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toUtf8String(): String {
val size = length.toInt()
if (size == 0) return ""
val result = ByteArray(size)
val source = bytes ?: return ""
result.usePinned { pinned ->
memcpy(pinned.addressOf(0), source, size.convert())
}
return result.decodeToString()
}

View File

@@ -13,8 +13,16 @@ private class JvmPendingCrashStore(
@Synchronized
override fun write(report: CrashReport) {
if (!isValidDiagnosticId(report.id)) return
directory.mkdirs()
File(directory, "${report.id}.crash").writeText(CrashReportCodec.encode(report), StandardCharsets.UTF_8)
val target = File(directory, "${report.id}.crash")
val temporary = File(directory, ".${report.id}.tmp")
val payload = CrashReportCodec.encode(report)
temporary.writeText(payload, StandardCharsets.UTF_8)
if (!temporary.renameTo(target)) {
target.writeText(payload, StandardCharsets.UTF_8)
temporary.delete()
}
}
@Synchronized
@@ -31,6 +39,34 @@ private class JvmPendingCrashStore(
@Synchronized
override fun delete(id: String) {
if (!isValidDiagnosticId(id)) return
File(directory, "$id.crash").delete()
}
@Synchronized
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
require(maxCount > 0) { "maxCount must be positive" }
if (!directory.isDirectory) return
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
.orEmpty()
.forEach(File::delete)
val reports = directory
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
.orEmpty()
.mapNotNull { file ->
val report = runCatching {
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
}.getOrNull()
if (report == null) {
file.delete()
null
} else {
file to report
}
}
.sortedByDescending { (_, report) -> report.timestampMillis }
reports.forEachIndexed { index, (file, report) ->
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
}
}
}

View File

@@ -0,0 +1,37 @@
package com.vnidrop.app.diagnostics
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URI
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
actual suspend fun platformHttpPost(
url: String,
headers: Map<String, String>,
bodyUtf8: String,
): PlatformHttpResponse = withContext(Dispatchers.IO) {
val connection = (URI(url).toURL().openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("Content-Type", "application/json; charset=utf-8")
headers.forEach { (key, value) -> setRequestProperty(key, value) }
}
try {
connection.outputStream.use { output ->
output.write(bodyUtf8.toByteArray(StandardCharsets.UTF_8))
}
val code = connection.responseCode
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
val body = stream?.use { input ->
BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).readText()
}.orEmpty()
PlatformHttpResponse(code, body)
} finally {
connection.disconnect()
}
}

View File

@@ -0,0 +1,56 @@
package com.vnidrop.app.diagnostics
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PendingCrashStoreJvmTest {
@Test
fun replacesReportsAndPrunesOldCorruptAndTemporaryFiles() {
val root = Files.createTempDirectory("vnidrop-crash-store").toFile()
try {
val store = createPendingCrashStore(root.absolutePath)
val older = report("10000000-0000-4000-8000-000000000001", 1, "older")
val current = report("10000000-0000-4000-8000-000000000002", 2, "current")
store.write(older)
store.write(current)
store.write(current.copy(exceptionMessage = "replaced"))
val directory = File(root, "diagnostics/crashes")
File(directory, "corrupt.crash").writeText("not a crash envelope")
File(directory, ".orphan.tmp").writeText("partial")
store.write(current.copy(id = "../../escape"))
val escapedPath = File(directory, "../../escape.crash").canonicalFile
assertEquals(
listOf("replaced", "older"),
store.list().map(CrashReport::exceptionMessage),
)
store.prune(olderThanTimestampMillis = 0, maxCount = 1)
assertEquals(listOf("replaced"), store.list().map(CrashReport::exceptionMessage))
assertFalse(File(directory, "corrupt.crash").exists())
assertFalse(File(directory, ".orphan.tmp").exists())
assertFalse(escapedPath.exists())
assertTrue(directory.listFiles().orEmpty().all { it.parentFile == directory })
} finally {
root.deleteRecursively()
}
}
private fun report(id: String, timestampMillis: Long, message: String) = CrashReport(
id = id,
timestampMillis = timestampMillis,
installId = "install",
appVersion = "1.0",
platform = "Desktop",
exceptionType = "TestError",
exceptionMessage = message,
stackTrace = "stack",
breadcrumbs = emptyList(),
diagnosticsEnabledAtCapture = true,
)
}