feat(diagnostics): harden ingestion and delivery

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

View File

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

View File

@@ -1,6 +1,8 @@
package com.vnidrop.app.diagnostics
import com.vnidrop.app.logging.platformNowMillis
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Fixed-size ring of high-level app breadcrumbs for crash / bug context.
@@ -9,6 +11,7 @@ import com.vnidrop.app.logging.platformNowMillis
* Updates are best-effort under concurrency; losing a breadcrumb is preferable
* to blocking a dying process on a lock.
*/
@OptIn(ExperimentalAtomicApi::class)
class BreadcrumbBuffer(
private val capacity: Int = DefaultCapacity,
) {
@@ -16,28 +19,29 @@ class BreadcrumbBuffer(
require(capacity > 0) { "capacity must be positive" }
}
@Volatile
private var items: List<Breadcrumb> = emptyList()
private val items = AtomicReference<List<Breadcrumb>>(emptyList())
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
val sanitizedName = sanitizeDiagnosticName(name)
if (sanitizedName.isBlank()) return
val crumb = Breadcrumb(
name = name.take(MaxNameLength),
name = sanitizedName,
timestampMillis = timestampMillis,
properties = LogRedactor.redactMap(properties).mapValues { it.value.take(MaxPropertyValueLength) },
properties = sanitizeDiagnosticProperties(properties),
)
val current = items
items = (current + crumb).takeLast(capacity)
while (true) {
val current = items.load()
if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return
}
}
fun snapshot(): List<Breadcrumb> = items
fun snapshot(): List<Breadcrumb> = items.load()
fun clear() {
items = emptyList()
items.store(emptyList())
}
companion object {
const val DefaultCapacity = 40
private const val MaxNameLength = 64
private const val MaxPropertyValueLength = 128
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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