mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(diagnostics): harden ingestion and delivery
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)""")
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user