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