From b5980e52c5ca74bf947c6827501f925b32ed3d41 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 17:07:26 +0500 Subject: [PATCH 01/38] Add ProductTelemetry interface and consent state for E6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines a new product-metrics telemetry pipeline distinct from the existing TelemetryRepository (which feeds the backend's ranking-signal pipeline at /v1/events). ProductTelemetry targets POST /v1/telemetry/events with an opt-out, schema-allowlisted event stream — consent is three-state (NotYetAsked / Granted / Denied) so the first-launch sheet can distinguish 'never asked' from 'user said no.' TweaksRepository gains getProductTelemetryConsent / setProductTelemetryConsent backed by a new DataStore key, kept independent of getTelemetryEnabled so accepting the E6 sheet does not auto-enable the legacy pipeline. --- .../data/repository/TweaksRepositoryImpl.kt | 16 ++++++++++++++++ .../domain/repository/TweaksRepository.kt | 8 ++++++++ .../core/domain/telemetry/ProductTelemetry.kt | 19 +++++++++++++++++++ .../telemetry/ProductTelemetryConsent.kt | 7 +++++++ 4 files changed, 50 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetry.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryConsent.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index 4d5aff5cf..a5a82d20d 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -181,6 +181,21 @@ class TweaksRepositoryImpl( } } + override fun getProductTelemetryConsent(): Flow = + preferences.data.map { prefs -> + when (prefs[PRODUCT_TELEMETRY_CONSENT_KEY]) { + "Granted" -> zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Granted + "Denied" -> zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Denied + else -> zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.NotYetAsked + } + } + + override suspend fun setProductTelemetryConsent(consent: zed.rainxch.core.domain.telemetry.ProductTelemetryConsent) { + preferences.edit { prefs -> + prefs[PRODUCT_TELEMETRY_CONSENT_KEY] = consent.name + } + } + override fun getTranslationProvider(): Flow = preferences.data.map { prefs -> TranslationProvider.fromName(prefs[TRANSLATION_PROVIDER_KEY]) @@ -247,6 +262,7 @@ class TweaksRepositoryImpl( private val HIDE_SEEN_ENABLED_KEY = booleanPreferencesKey("hide_seen_enabled") private val SCROLLBAR_ENABLED_KEY = booleanPreferencesKey("scrollbar_enabled") private val TELEMETRY_ENABLED_KEY = booleanPreferencesKey("telemetry_enabled") + private val PRODUCT_TELEMETRY_CONSENT_KEY = stringPreferencesKey("product_telemetry_consent") private val TRANSLATION_PROVIDER_KEY = stringPreferencesKey("translation_provider") private val YOUDAO_APP_KEY = stringPreferencesKey("youdao_app_key") private val YOUDAO_APP_SECRET = stringPreferencesKey("youdao_app_secret") diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index abd7ea7a5..f6749e1bd 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -64,6 +64,14 @@ interface TweaksRepository { suspend fun setTelemetryEnabled(enabled: Boolean) + // E6 product-metric telemetry consent. Three-state because the first-launch + // sheet needs to distinguish "user actively said no" from "we haven't asked + // yet." Independent of getTelemetryEnabled — that's the legacy ranking- + // signals pipeline (different data, different backend table). + fun getProductTelemetryConsent(): Flow + + suspend fun setProductTelemetryConsent(consent: zed.rainxch.core.domain.telemetry.ProductTelemetryConsent) + fun getTranslationProvider(): Flow suspend fun setTranslationProvider(provider: TranslationProvider) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetry.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetry.kt new file mode 100644 index 000000000..ea74b4524 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetry.kt @@ -0,0 +1,19 @@ +package zed.rainxch.core.domain.telemetry + +// E6 product-metric telemetry. Distinct from the existing TelemetryRepository +// (which feeds the backend's ranking-signal pipeline at /v1/events). This +// interface targets POST /v1/telemetry/events with anonymous, opt-out +// product-metric events for measuring crash rate, performance buckets, +// import success, etc. Schema is locked — see TelemetryAllowlist. +interface ProductTelemetry { + + // Fire an event. Synchronous from the caller's POV — never throws, + // never blocks. If consent is denied or not yet asked, the call is + // a no-op. Props keys must use snake_case and contain no PII. + fun fire(name: String, props: Map = emptyMap()) + + // Drain the buffer if there's anything pending. Best-effort — fails + // silently. Used by app shutdown hooks to send a final batch before + // the process exits. + suspend fun flush() +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryConsent.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryConsent.kt new file mode 100644 index 000000000..61b74b997 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryConsent.kt @@ -0,0 +1,7 @@ +package zed.rainxch.core.domain.telemetry + +enum class ProductTelemetryConsent { + NotYetAsked, + Granted, + Denied, +} From d55d505a7ac8b571e0a4434371600103816ed869 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 17:12:46 +0500 Subject: [PATCH 02/38] Add ProductTelemetryImpl with bounded queue, exponential backoff, consent gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ProductTelemetry against the new POST /v1/telemetry/events backend route. Owns: an ephemeral session_id (16 random bytes, base64url, fresh per process), a 500-entry ring buffer with mutex-protected enqueue/drain, a 30-second timer + 20-event threshold flush trigger, exponential backoff from 5s to 5min on transient failure, and a 2-second shutdown drain via flush(). Consent is checked at fire(), at flush() entry, and again after the network round-trip — never lets a stale-consent batch leave the device. props are filtered to String/Number/Boolean only at serialization time so a non-primitive value (a repo object, an exception) is dropped before it can leak. BackendApiClient gains postProductTelemetryEvents that POSTs the new {events: [...]} batch shape; the existing postEvents path is untouched. SharedModule.kt registers ProductTelemetry as a Koin single. --- .../zed/rainxch/core/data/di/SharedModule.kt | 12 ++ .../data/dto/ProductTelemetryEventBody.kt | 19 ++ .../core/data/network/BackendApiClient.kt | 18 ++ .../data/telemetry/ProductTelemetryImpl.kt | 181 ++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ProductTelemetryEventBody.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 80b6d8414..1a07ae966 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -37,6 +37,7 @@ import zed.rainxch.core.data.repository.DeviceIdentityRepositoryImpl import zed.rainxch.core.data.repository.RateLimitRepositoryImpl import zed.rainxch.core.data.repository.SearchHistoryRepositoryImpl import zed.rainxch.core.data.repository.TelemetryRepositoryImpl +import zed.rainxch.core.data.telemetry.ProductTelemetryImpl import zed.rainxch.core.data.repository.SeenReposRepositoryImpl import zed.rainxch.core.data.repository.StarredRepositoryImpl import zed.rainxch.core.data.repository.TweaksRepositoryImpl @@ -57,6 +58,7 @@ import zed.rainxch.core.domain.repository.SearchHistoryRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TelemetryRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase @@ -186,6 +188,16 @@ val coreModule = ) } + single { + ProductTelemetryImpl( + backendApiClient = get(), + tweaksRepository = get(), + platform = get(), + appScope = get(), + logger = get(), + ) + } + // Application-scoped download / install orchestrator. Lives // for the process lifetime so downloads survive screen // navigation. ViewModels are observers, never owners. diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ProductTelemetryEventBody.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ProductTelemetryEventBody.kt new file mode 100644 index 000000000..914f2e777 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ProductTelemetryEventBody.kt @@ -0,0 +1,19 @@ +package zed.rainxch.core.data.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class ProductTelemetryEventBody( + val name: String, + val sessionId: String, + val timestamp: Long, + val platform: String? = null, + val appVersion: String? = null, + val props: JsonObject? = null, +) + +@Serializable +data class ProductTelemetryBatch( + val events: List, +) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index da3c59387..32004cb3b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -33,6 +33,8 @@ import zed.rainxch.core.data.dto.BackendExploreResponse import zed.rainxch.core.data.dto.BackendRepoResponse import zed.rainxch.core.data.dto.BackendSearchResponse import zed.rainxch.core.data.dto.EventRequest +import zed.rainxch.core.data.dto.ProductTelemetryBatch +import zed.rainxch.core.data.dto.ProductTelemetryEventBody import zed.rainxch.core.data.dto.GithubReadmeResponseDto import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.dto.UserProfileNetwork @@ -257,6 +259,22 @@ class BackendApiClient( } } + suspend fun postProductTelemetryEvents(events: List): Result = + safeCall { + val response = httpClient.post("telemetry/events") { + contentType(ContentType.Application.Json) + setBody(ProductTelemetryBatch(events)) + } + when { + response.status == HttpStatusCode.NoContent || response.status.isSuccess() -> + Result.success(Unit) + response.status == HttpStatusCode.TooManyRequests -> + Result.failure(RateLimitedException()) + else -> + Result.failure(BackendException(response.status.value)) + } + } + private inline fun safeCall(block: () -> Result): Result = try { block() diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt new file mode 100644 index 000000000..a47f2e63d --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt @@ -0,0 +1,181 @@ +package zed.rainxch.core.data.telemetry + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Clock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import zed.rainxch.core.data.BuildKonfig +import zed.rainxch.core.data.dto.ProductTelemetryEventBody +import zed.rainxch.core.data.network.BackendApiClient +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random + +@OptIn(ExperimentalEncodingApi::class) +class ProductTelemetryImpl( + private val backendApiClient: BackendApiClient, + private val tweaksRepository: TweaksRepository, + private val platform: Platform, + private val appScope: CoroutineScope, + private val logger: GitHubStoreLogger, +) : ProductTelemetry { + + // Fresh per process. The contract from E6 is "ephemeral, reset per app + // launch" — the JVM/Android process lifecycle scopes that for us. + private val sessionId: String = Base64.UrlSafe + .encode(Random.nextBytes(16)) + .trimEnd('=') + + private val bufferMutex = Mutex() + private val buffer = ArrayDeque() + + @Volatile + private var backoffMs: Long = INITIAL_BACKOFF_MS + + init { + appScope.launch { + while (true) { + delay(FLUSH_INTERVAL_MS) + runCatching { flushInternal() } + .onFailure { logger.debug("Product telemetry flush error: ${it.message}") } + } + } + } + + override fun fire(name: String, props: Map) { + appScope.launch { + // Consent check happens here, BEFORE serialization or queue insert. + // PrivacyAuditTest leans on this — a test that mocks the queue and + // asserts zero enqueue calls when consent is not Granted. + if (consent() != ProductTelemetryConsent.Granted) return@launch + + val body = ProductTelemetryEventBody( + name = name, + sessionId = sessionId, + timestamp = nowMs(), + platform = platformSlug(), + appVersion = BuildKonfig.VERSION_NAME, + props = props.toJsonObject(), + ) + bufferMutex.withLock { + if (buffer.size >= MAX_BUFFER_SIZE) buffer.removeFirst() + buffer.add(body) + if (buffer.size >= FLUSH_BATCH_THRESHOLD) { + appScope.launch { flushInternal() } + } + } + } + } + + override suspend fun flush() { + // Best-effort with a short timeout — used by app shutdown hooks. + withTimeoutOrNull(SHUTDOWN_FLUSH_TIMEOUT_MS) { + flushInternal() + } + } + + private suspend fun flushInternal() { + if (consent() != ProductTelemetryConsent.Granted) { + // Consent withdrawn between enqueue and flush. Drop everything in + // flight; never let a stale-consent batch leave the device. + bufferMutex.withLock { buffer.clear() } + return + } + + val pending = bufferMutex.withLock { + if (buffer.isEmpty()) return + val take = minOf(buffer.size, MAX_BATCH_SIZE) + (0 until take).map { buffer.removeFirst() } + } + + val result = withContext(Dispatchers.IO) { + backendApiClient.postProductTelemetryEvents(pending) + } + + if (result.isSuccess) { + backoffMs = INITIAL_BACKOFF_MS + } else { + // Network or 5xx. Re-add at the front for retry next tick, but only + // if consent still holds. Bound the buffer so a long offline session + // doesn't grow unbounded. + if (consent() == ProductTelemetryConsent.Granted) { + bufferMutex.withLock { + for (i in pending.indices.reversed()) { + if (buffer.size < MAX_BUFFER_SIZE) buffer.addFirst(pending[i]) + } + } + // Exponential backoff up to MAX_BACKOFF_MS. The fixed-cadence + // flush loop still runs every FLUSH_INTERVAL_MS; backoff just + // makes us skip flushes while it's elevated. + backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + delay(backoffMs) + } else { + bufferMutex.withLock { buffer.clear() } + } + logger.debug("Product telemetry batch failed: ${result.exceptionOrNull()?.message}") + } + } + + private suspend fun consent(): ProductTelemetryConsent = + runCatching { tweaksRepository.getProductTelemetryConsent().first() } + .getOrDefault(ProductTelemetryConsent.NotYetAsked) + + private fun platformSlug(): String = when (platform) { + Platform.ANDROID -> "android" + Platform.MACOS -> "macos" + Platform.WINDOWS -> "windows" + Platform.LINUX -> "linux" + } + + private fun Map.toJsonObject(): JsonObject? { + if (isEmpty()) return null + // Only categorical / bucketed values survive — anything that isn't a + // String, Number, or Boolean is dropped. Defense against accidentally + // attaching a repo object, an exception, or a query string. + val entries = mutableMapOf() + for ((k, v) in this) { + when (v) { + is String -> entries[k] = JsonPrimitive(v) + is Number -> entries[k] = JsonPrimitive(v) + is Boolean -> entries[k] = JsonPrimitive(v) + null -> {} // skip nulls + else -> logger.debug("Product telemetry: dropping non-primitive prop '$k' (${v::class.simpleName})") + } + } + return if (entries.isEmpty()) null else JsonObject(entries) + } + + private fun nowMs(): Long = Clock.System.now().toEpochMilliseconds() + + private companion object { + // Flush every 30s on a timer, OR when the buffer reaches the batch + // threshold. Whichever first. + private const val FLUSH_INTERVAL_MS = 30_000L + private const val FLUSH_BATCH_THRESHOLD = 20 + + // Backend caps batches at 100 — we cap at 50 for safety margin. + private const val MAX_BATCH_SIZE = 50 + + // Bounded ring buffer. Long offline sessions can exceed this; oldest + // events get dropped first. 500 is enough for a multi-hour session at + // typical event rates without becoming a memory liability. + private const val MAX_BUFFER_SIZE = 500 + + private const val INITIAL_BACKOFF_MS = 5_000L + private const val MAX_BACKOFF_MS = 5 * 60_000L + private const val SHUTDOWN_FLUSH_TIMEOUT_MS = 2_000L + } +} From 2098161d6b122f4ad98a030c3d6c19b0a3c6a111 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 17:14:16 +0500 Subject: [PATCH 03/38] Surface E6 event names and prop keys as Kotlin constants ProductTelemetryEvents.ALL mirrors the backend's TelemetryAllowlist; ProductTelemetryProps.ALLOWED enumerates the categorical / bucketed prop keys callers may use. Wiring sites use these constants instead of inline strings so a typo at a fire() call is a compile error rather than a silently dropped event. PrivacyAuditTest will pin both sets when KMP test infra is added (punch list). --- .../telemetry/ProductTelemetryEvents.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt new file mode 100644 index 000000000..45f976b22 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt @@ -0,0 +1,85 @@ +package zed.rainxch.core.domain.telemetry + +// Locked schema mirror of the backend's TelemetryAllowlist +// (github-store-backend, src/main/kotlin/.../telemetry/TelemetryEvent.kt). +// Names are referenced by callers as constants — never typed as inline +// strings — so a typo is a compile error, not a silently-dropped event. +// +// PrivacyAuditTest pins this set against the backend's; any drift fails CI. +object ProductTelemetryEvents { + + // session + const val APP_LAUNCHED = "app_launched" + const val SESSION_DURATION = "session_duration" + + // import (E1 / E2) + const val IMPORT_SCAN_STARTED = "import_scan_started" + const val IMPORT_SCAN_COMPLETED = "import_scan_completed" + const val IMPORT_MATCH_ATTEMPTED = "import_match_attempted" + const val IMPORT_AUTO_LINKED = "import_auto_linked" + const val IMPORT_MANUALLY_LINKED = "import_manually_linked" + const val IMPORT_SKIPPED = "import_skipped" + + // reliability (E3) + const val CRASH = "crash" + const val OPERATION_FAILED = "operation_failed" + + // performance (E4) + const val COLD_START_MS = "cold_start_ms" + const val FIRST_PAINT_MS = "first_paint_ms" + const val CACHE_HIT = "cache_hit" + const val CACHE_MISS = "cache_miss" + + // proxy (E5) + const val PROXY_CONFIGURED = "proxy_configured" + const val PROXY_USED = "proxy_used" + const val MIRROR_USED = "mirror_used" + + // discovery / engagement + const val UPDATE_INSTALLED = "update_installed" + const val SEARCH_EXECUTED = "search_executed" + const val DETAILS_VIEWED = "details_viewed" + + val ALL: Set = setOf( + APP_LAUNCHED, SESSION_DURATION, + IMPORT_SCAN_STARTED, IMPORT_SCAN_COMPLETED, IMPORT_MATCH_ATTEMPTED, + IMPORT_AUTO_LINKED, IMPORT_MANUALLY_LINKED, IMPORT_SKIPPED, + CRASH, OPERATION_FAILED, + COLD_START_MS, FIRST_PAINT_MS, CACHE_HIT, CACHE_MISS, + PROXY_CONFIGURED, PROXY_USED, MIRROR_USED, + UPDATE_INSTALLED, SEARCH_EXECUTED, DETAILS_VIEWED, + ) +} + +// Allowed prop keys for telemetry events. Anything outside this set must not +// appear as a key in props. Categorical / bucketed values only — no IDs, +// names, queries, paths. +object ProductTelemetryProps { + + const val PLATFORM = "platform" + const val VERSION = "version" + const val SECONDS = "seconds" + const val CANDIDATE_COUNT = "candidate_count" + const val DURATION_MS = "duration_ms" + const val STRATEGY = "strategy" + const val CONFIDENCE_BUCKET = "confidence_bucket" + const val COUNT = "count" + const val CATEGORY = "category" + const val OP = "op" + const val ERROR_CODE = "error_code" + const val BUCKET = "bucket" + const val SCREEN = "screen" + const val CACHE_NAME = "cache_name" + const val TYPE = "type" + const val SUCCESS = "success" + const val PRESET = "preset" + const val RESULT_COUNT_BUCKET = "result_count_bucket" + const val FROM = "from" + + val ALLOWED: Set = setOf( + PLATFORM, VERSION, SECONDS, CANDIDATE_COUNT, DURATION_MS, + STRATEGY, CONFIDENCE_BUCKET, COUNT, CATEGORY, OP, ERROR_CODE, + BUCKET, SCREEN, CACHE_NAME, TYPE, SUCCESS, PRESET, + RESULT_COUNT_BUCKET, FROM, + ) +} From 4e2a56b19682f396848c37bcfcc7686b4ba5f638 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 17:16:12 +0500 Subject: [PATCH 04/38] Add product telemetry consent state and toggle action to Tweaks TweaksState gains productTelemetryConsent (three-state); TweaksAction gains OnProductTelemetryToggled mapping a Boolean to Granted/Denied. ViewModel observes the new TweaksRepository flow on init and writes Granted/Denied on toggle. The Compose UI binding (a Switch row in the Privacy section pointing at OnProductTelemetryToggled) is left to a follow-up commit; this commit unblocks both the UI and any caller that needs to read consent state. --- .../tweaks/presentation/TweaksAction.kt | 7 +++++++ .../tweaks/presentation/TweaksState.kt | 4 ++++ .../tweaks/presentation/TweaksViewModel.kt | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index b40e0830c..92a830962 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -113,6 +113,13 @@ sealed interface TweaksAction { val enabled: Boolean, ) : TweaksAction + // E6 product-metric telemetry consent toggle. Maps directly to + // ProductTelemetryConsent.Granted / Denied — kept separate from + // OnTelemetryToggled so accepting one doesn't auto-grant the other. + data class OnProductTelemetryToggled( + val enabled: Boolean, + ) : TweaksAction + data object OnResetAnalyticsId : TweaksAction data class OnTranslationProviderSelected( diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index 7d01f66b8..6edc4a835 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -28,6 +28,10 @@ data class TweaksState( val isHideSeenEnabled: Boolean = false, val isScrollbarEnabled: Boolean = false, val isTelemetryEnabled: Boolean = false, + // E6 product-metric telemetry consent. Three-state so the UI can show + // a first-launch prompt when NotYetAsked vs a normal toggle when set. + val productTelemetryConsent: zed.rainxch.core.domain.telemetry.ProductTelemetryConsent = + zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.NotYetAsked, val translationProvider: TranslationProvider = TranslationProvider.Default, /** * Transient UI-only selection used when the user picks a provider diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index 2d625542a..33c84b326 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -73,6 +73,7 @@ class TweaksViewModel( loadHideSeenEnabled() loadScrollbarEnabled() loadTelemetryEnabled() + loadProductTelemetryConsent() loadTranslationSettings() loadAppLanguage() @@ -348,6 +349,16 @@ class TweaksViewModel( } } + private fun loadProductTelemetryConsent() { + viewModelScope.launch { + tweaksRepository.getProductTelemetryConsent().collect { consent -> + _state.update { + it.copy(productTelemetryConsent = consent) + } + } + } + } + private fun loadTranslationSettings() { viewModelScope.launch { tweaksRepository.getTranslationProvider().collect { provider -> @@ -657,6 +668,15 @@ class TweaksViewModel( } } + is TweaksAction.OnProductTelemetryToggled -> { + viewModelScope.launch { + tweaksRepository.setProductTelemetryConsent( + if (action.enabled) zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Granted + else zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Denied, + ) + } + } + TweaksAction.OnResetAnalyticsId -> { viewModelScope.launch { // Clear the telemetry buffer *before* resetting the ID. From 2c02650f8290b2d7a13259a3a844265e43fdbc8b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 17:21:14 +0500 Subject: [PATCH 05/38] Fire app_launched on Android Application.onCreate and Desktop main() First wired event in the E6 pipeline. fire() is a no-op when consent is not Granted, so this is safe to land before the consent-sheet UX ships. Android attaches platform=android plus the live versionName from PackageManager. Desktop attaches platform=macos|windows|linux derived from os.name (the impl already stamps appVersion from BuildKonfig at the event-level field; props is for additional context). Other 19 event sites tracked in the punch list. --- .../rainxch/githubstore/app/GithubStoreApp.kt | 20 ++++++++++++++++ .../zed/rainxch/githubstore/DesktopApp.kt | 23 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index d322ee8ed..0d03992c8 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -20,6 +20,9 @@ import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.githubstore.app.di.initKoin class GithubStoreApp : Application() { @@ -38,6 +41,23 @@ class GithubStoreApp : Application() { startDownloadNotificationObserver() scheduleBackgroundUpdateChecks() registerSelfAsInstalledApp() + fireAppLaunched() + } + + private fun fireAppLaunched() { + // No-op when consent is not Granted (the impl gates internally). + get().fire( + name = ProductTelemetryEvents.APP_LAUNCHED, + props = + mapOf( + ProductTelemetryProps.PLATFORM to "android", + ProductTelemetryProps.VERSION to + packageManager + .getPackageInfo(packageName, 0) + .versionName + .orEmpty(), + ), + ) } private fun startDownloadNotificationObserver() { diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index b89a94694..e05edf5b1 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -20,6 +20,9 @@ import org.jetbrains.compose.resources.stringResource import org.koin.core.context.GlobalContext import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent import zed.rainxch.githubstore.app.di.initKoin @@ -70,6 +73,16 @@ fun main(args: Array) { localization.setActiveLanguageTag(tag) } + // Fire app_launched once per process. No-op when consent is not Granted. + // The impl reads BuildKonfig.VERSION_NAME internally for the appVersion + // field on every fire(); we just supply the platform-specific version + // bucket via the props map. (BuildKonfig is internal to core/data so we + // can't read it from composeApp directly.) + GlobalContext.get().get().fire( + name = ProductTelemetryEvents.APP_LAUNCHED, + props = mapOf(ProductTelemetryProps.PLATFORM to desktopPlatformSlug()), + ) + val deepLinkArg = args.firstOrNull() if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) { @@ -118,3 +131,13 @@ fun main(args: Array) { } } } + +private fun desktopPlatformSlug(): String { + val os = System.getProperty("os.name").orEmpty().lowercase() + return when { + os.contains("mac") -> "macos" + os.contains("win") -> "windows" + os.contains("nix") || os.contains("nux") -> "linux" + else -> "desktop" + } +} From 7334492122cd21d0fe097b553a528cd33f97b026 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 27 Apr 2026 22:40:22 +0500 Subject: [PATCH 06/38] Add E6 client-side handoff doc with E1 overlap resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punch list for taking the client to GA: consent UI, first-launch sheet, 19 events, PrivacyAuditTest, shutdown flush. Resolves the overlap E1 flagged: import events MIGRATE from TelemetryRepository to ProductTelemetry (delete the legacy calls in the same commit, no double-emission). search_executed and the per-repo signal events COEXIST with legacy on purpose — different identity (hashed device_id vs ephemeral session_id), different jobs (ranking signals vs aggregate metrics). --- roadmap/E6_CLIENT_HANDOFF.md | 440 +++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 roadmap/E6_CLIENT_HANDOFF.md diff --git a/roadmap/E6_CLIENT_HANDOFF.md b/roadmap/E6_CLIENT_HANDOFF.md new file mode 100644 index 000000000..e854ab79e --- /dev/null +++ b/roadmap/E6_CLIENT_HANDOFF.md @@ -0,0 +1,440 @@ +# E6 — client handoff + +Status: foundation in place on `feature/e6-telemetry`. Backend endpoint live (`POST /v1/telemetry/events`). Roughly 30% of the user-visible work shipped: one event type fires (`app_launched`), no consent UI exists yet, 19 events unwired. + +This document is the punch list to take the client to GA. Read top to bottom. + +--- + +## 0. What already exists on this branch (do NOT redo) + +``` +core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ + ProductTelemetry.kt // interface: fire(name, props) + flush() + ProductTelemetryConsent.kt // enum: NotYetAsked / Granted / Denied + ProductTelemetryEvents.kt // 20 event-name constants + ALL set + // 19 prop-key constants + ALLOWED set + +core/data/src/commonMain/kotlin/zed/rainxch/core/data/ + telemetry/ProductTelemetryImpl.kt + // Bounded ring buffer (500), 30s timer + 20-event threshold flush, + // exponential backoff 5s→5min, ephemeral session_id (16 random bytes, + // base64url, fresh per process), consent re-check at fire+flush+post-net + dto/ProductTelemetryEventBody.kt + network/BackendApiClient.kt // postProductTelemetryEvents added + repository/TweaksRepositoryImpl.kt // get/setProductTelemetryConsent (DataStore) + di/SharedModule.kt // ProductTelemetry registered as Koin single + +core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ + TweaksRepository.kt // get/setProductTelemetryConsent declared + +feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/ + TweaksState.kt // productTelemetryConsent: ProductTelemetryConsent + TweaksAction.kt // OnProductTelemetryToggled(enabled: Boolean) + TweaksViewModel.kt // observes consent flow, handles toggle action + +composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt + // fires ProductTelemetryEvents.APP_LAUNCHED from onCreate + +composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt + // fires ProductTelemetryEvents.APP_LAUNCHED from main() +``` + +`fire()` is a no-op when consent is not `Granted`. Currently nothing can grant consent because there's no UI. Wire that first, then events. + +--- + +## 1. Compose UI — Switch row in privacy section + +**Goal:** user can manually toggle product telemetry from the existing tweaks/privacy screen. + +**Find the existing legacy-telemetry Switch:** +``` +grep -rn 'OnTelemetryToggled\|isTelemetryEnabled' feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ +``` + +**Add a sibling `ListItem` directly below it:** +```kotlin +ListItem( + headlineContent = { Text(stringResource(Res.string.settings_product_telemetry_title)) }, + supportingContent = { + Text(stringResource(Res.string.settings_product_telemetry_description)) + }, + trailingContent = { + Switch( + checked = state.productTelemetryConsent == ProductTelemetryConsent.Granted, + onCheckedChange = { onAction(TweaksAction.OnProductTelemetryToggled(it)) }, + ) + }, +) +``` + +**Strings** (find the existing `settings_telemetry_*` keys and add neighbors): +```xml +Anonymous usage data +Help improve GitHub Store with anonymous, aggregate metrics. We never collect search queries, repo names, or any identifying information. Schema is open source. +``` + +**Acceptance:** toggling the Switch persists across app restart. + +--- + +## 2. First-launch consent bottom sheet + +Show when `productTelemetryConsent == NotYetAsked` and the user has reached the home screen. One screen, three buttons. + +### 2a. New file + +`feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/privacy/ProductTelemetryConsentSheet.kt` + +```kotlin +package zed.rainxch.profile.presentation.privacy + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductTelemetryConsentSheet( + onGrant: () -> Unit, + onDeny: () -> Unit, + onViewSchema: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Help improve GitHub Store", style = MaterialTheme.typography.headlineSmall) + Text( + "We collect anonymous, aggregate usage data to find bugs and improve performance. We never collect your repos, search queries, or any identifying information. The full schema and collection code are open source.", + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onGrant, modifier = Modifier.fillMaxWidth()) { Text("Got it") } + TextButton(onClick = onDeny, modifier = Modifier.fillMaxWidth()) { Text("No thanks") } + TextButton(onClick = onViewSchema, modifier = Modifier.fillMaxWidth()) { Text("View what we collect") } + Spacer(Modifier.height(8.dp)) + } + } +} +``` + +### 2b. Wire from home + +Add a small `HomeConsentGateViewModel` that exposes `consent: StateFlow` and `grant() / deny()` (writes to `TweaksRepository`). In `HomeRoot`, observe and render the sheet conditionally. `onViewSchema` opens `https://github.com/OpenHub-Store/backend/blob/main/src/main/kotlin/zed/rainxch/githubstore/telemetry/TelemetryEvent.kt` (or, when the schema page exists, `https://github-store.org/telemetry-schema`) via `LocalUriHandler.current`. + +Dismissing without choosing keeps state at `NotYetAsked` so the sheet reappears next launch — intentional, no answer ≠ silent opt-in. + +**Acceptance:** fresh install → sheet appears once. "Got it"/"No thanks" persists, sheet doesn't reappear. Dismiss → sheet reappears next launch. + +--- + +## 3. Wire the 19 remaining events + +**Hard rules:** +- ALL event names from `ProductTelemetryEvents.*` constants. +- ALL prop keys from `ProductTelemetryProps.*` constants. +- NO inline strings on either side. A typo at a `fire()` call should be a compile error. +- NEVER `repo_name`, `query`, `username`, file paths, stack traces, exception messages, IDs, or anything user-identifying as prop values. Categorical / bucketed only. + +### 3.1 Reliability + +| Event | Where | Props | +|---|---|---| +| `CRASH` | `Thread.setDefaultUncaughtExceptionHandler` in `GithubStoreApp.onCreate` (Android) AND `CrashReporter.install` (Desktop) | `CATEGORY` ∈ `"data_loss" / "version_detect" / "install_fail" / "other"`, `PLATFORM` | +| `OPERATION_FAILED` | every `Result.failure(...)` branch in `core/data/.../services/` and `core/data/.../repository/` | `OP` ∈ `"download" / "install" / "update" / "fetch"`, `ERROR_CODE` (existing error enum's `name` — never raw exception messages) | + +Helper file `composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt`: +```kotlin +fun categorizeCrash(throwable: Throwable): String = when { + throwable is OutOfMemoryError -> "other" + throwable.message?.contains("DataStore", ignoreCase = true) == true -> "data_loss" + throwable.message?.contains("install", ignoreCase = true) == true -> "install_fail" + throwable.message?.contains("version", ignoreCase = true) == true -> "version_detect" + else -> "other" +} +``` + +Crash handler: chain to the existing `Thread.getDefaultUncaughtExceptionHandler()` after firing + 500ms flush. Don't suppress the original handler. + +### 3.2 Performance + +| Event | Where | Props | +|---|---|---| +| `COLD_START_MS` | `Application.onCreate` start → first frame of `App` Composable; once per launch | `PLATFORM`, `BUCKET` | +| `FIRST_PAINT_MS` | per-screen first non-skeleton render | `SCREEN` ∈ `"home" / "details" / "library" / "settings" / "search"`, `BUCKET` | +| `CACHE_HIT` / `CACHE_MISS` | each repo / readme / icon cache lookup | `CACHE_NAME` ∈ `"details" / "icons" / "readmes" / "search_results"` | + +Bucketing helper `core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt`: +```kotlin +package zed.rainxch.core.domain.telemetry + +object TelemetryBuckets { + fun durationMs(ms: Long): String = when { + ms < 500 -> "<500" + ms < 1000 -> "500-1000" + ms < 3000 -> "1000-3000" + else -> ">3000" + } + + fun resultCount(n: Int): String = when { + n == 0 -> "0" + n <= 5 -> "1-5" + n <= 20 -> "6-20" + else -> ">20" + } + + fun confidence(score: Float): String = when { + score >= 0.85f -> "high" + score >= 0.5f -> "medium" + else -> "low" + } +} +``` + +### 3.3 Discovery / engagement + +| Event | Where | Props | +|---|---|---| +| `SEARCH_EXECUTED` | `SearchViewModel` on submit completion | `RESULT_COUNT_BUCKET` (via `TelemetryBuckets.resultCount`). **NEVER the query string.** | +| `DETAILS_VIEWED` | `DetailsViewModel` first-render | `FROM` ∈ `"search" / "category" / "library" / "link"` (derive from nav arg). **NEVER the repo name.** | +| `UPDATE_INSTALLED` | `DefaultDownloadOrchestrator` on install completion when `lastUpdatedAt` was bumped (i.e., update path, not first install) | (no required props) | + +> **Coexist, not replace, for `SEARCH_EXECUTED`.** The legacy `TelemetryRepository.recordSearchPerformed` carries `query_hash + result_count + repo_id` per click for ranking-miss tracking. The new `SEARCH_EXECUTED` is a count-only aggregate. Both fire. Different purposes, different backends. Same is true for the rest of §3.6 below. + +### 3.4 Import (E1) — REPLACE the legacy calls + +**The six import events currently fire via `TelemetryRepository.recordImport*` calls. Move them to `ProductTelemetry`. Delete the legacy calls in the same commit so the migration is atomic — no double-emission on the wire.** + +Sites (per E1's handoff doc): +- `core/data/.../repository/ExternalImportRepositoryImpl.kt` — `runFullScan` / `runDeltaScan` for `IMPORT_SCAN_STARTED`, `IMPORT_SCAN_COMPLETED`, `IMPORT_MATCH_ATTEMPTED`, `IMPORT_AUTO_LINKED` +- `feature/apps/.../ExternalImportViewModel.kt` — `skipPackage` / `pickSuggestion` / `submitSearchOverride` for `IMPORT_SKIPPED`, `IMPORT_MANUALLY_LINKED` + +| Event | Props | +|---|---| +| `IMPORT_SCAN_STARTED` | `PLATFORM` | +| `IMPORT_SCAN_COMPLETED` | `CANDIDATE_COUNT`, `DURATION_MS` | +| `IMPORT_MATCH_ATTEMPTED` | `STRATEGY` ∈ `"manifest" / "search" / "fingerprint"`, `CONFIDENCE_BUCKET` (via `TelemetryBuckets.confidence`) | +| `IMPORT_AUTO_LINKED` | `COUNT` | +| `IMPORT_MANUALLY_LINKED` | `COUNT` | +| `IMPORT_SKIPPED` | `COUNT` | + +**Out of scope — the following legacy import-related calls are NOT in the E6 allowlist and should be deleted in the same migration commit:** + +- `recordImportSearchOverrideUsed` +- `recordImportSearchOverrideNoResults` +- `recordImportPermissionRequested` +- `recordImportPermissionOutcome` +- `recordImportUnlinkedFromDetails` (in `DetailsViewModel.confirmUnlinkExternalApp`) + +These were finer-grained extensions the legacy pipeline took on. Either propose adding them to the E6 schema (requires backend allowlist update + migration coordination) OR delete them. **Recommended: delete.** They're ranking-signal-shaped but never used for ranking. + +### 3.5 Proxy (E5) + +| Event | Where | Props | +|---|---|---| +| `PROXY_CONFIGURED` | proxy ViewModel save handler | `TYPE` ∈ `"http" / "socks5" / "none"` | +| `PROXY_USED` | every outbound HTTP through the configured proxy (`BackendApiClient` request-completion site) | `SUCCESS: Bool` (true if 2xx) | +| `MIRROR_USED` | when a request used a mirror endpoint (search for `ghproxy`) | `PRESET` ∈ `"ghproxy" / "custom" / "none"`, `SUCCESS` | + +### 3.6 Session + +| Event | Where | Props | +|---|---|---| +| `SESSION_DURATION` | `ProcessLifecycleOwner.lifecycle` `ON_STOP` (Android) / `Window.onCloseRequest` (Desktop) | `SECONDS: Int` (compute from `coldStartAt` to now) | + +--- + +## 4. Pipeline boundary — what stays in `TelemetryRepository` + +**Do not migrate these. They have `repo_id` and feed `SignalAggregationWorker`'s ranking computation:** + +- `recordSearchPerformed` — drives ranking-miss tracking and result-freshness signals +- `recordSearchResultClicked` — drives CTR +- `recordRepoViewed` — drives view-count ranking signal +- `recordReleaseDownloaded`, `recordInstallStarted`, `recordInstallSucceeded`, `recordInstallFailed` — drive install-success-rate signal +- `recordAppOpenedAfterInstall` — drives engagement signal +- `recordUninstalled`, `recordFavorited`, `recordUnfavorited` — drive long-term retention signals + +These continue to flow through `/v1/events` and the `events` table. The two pipelines coexist by design — different jobs, different identity (hashed `device_id` vs ephemeral `session_id`), different consent toggles. + +--- + +## 5. PrivacyAuditTest + +### 5.1 Add `commonTest` to `core/domain` + +Most KMP modules in this repo don't have `commonTest`. If `core/data` or any other module already has it, copy that pattern into `core/domain/build.gradle.kts`. Otherwise: + +```kotlin +sourceSets { + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } +} +``` + +Ensure `libs.versions.toml` has: +```toml +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +``` + +### 5.2 Test file + +`core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt`: + +```kotlin +package zed.rainxch.core.domain.telemetry + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PrivacyAuditTest { + + @Test + fun `event allowlist matches the backend's locked schema`() { + // Hardcoded copy of the backend's TelemetryAllowlist. Drift between + // client and server here means the server silently drops events the + // client thinks it's sending — fail loudly on mismatch. + val backendAllowlist = setOf( + "app_launched", "session_duration", + "import_scan_started", "import_scan_completed", "import_match_attempted", + "import_auto_linked", "import_manually_linked", "import_skipped", + "crash", "operation_failed", + "cold_start_ms", "first_paint_ms", "cache_hit", "cache_miss", + "proxy_configured", "proxy_used", "mirror_used", + "update_installed", "search_executed", "details_viewed", + ) + assertEquals(backendAllowlist, ProductTelemetryEvents.ALL) + } + + @Test + fun `no PII-shaped names slip into the allowlist`() { + val forbidden = listOf( + "user_id", "email", "search_query", "repo_name", "github_username", + "device_id", "ip_address", "owner", "name", "query", "username", + ) + forbidden.forEach { name -> + assertFalse(name in ProductTelemetryEvents.ALL, "$name leaked into ALL") + assertFalse(name in ProductTelemetryProps.ALLOWED, "$name leaked into ALLOWED props") + } + } + + @Test + fun `every prop key is short and snake_case`() { + ProductTelemetryProps.ALLOWED.forEach { key -> + assertFalse(key.contains(" "), "prop key '$key' has whitespace") + assertFalse(key.any { it.isUpperCase() }, "prop key '$key' has uppercase") + check(key.length <= 32) { "prop key '$key' exceeds 32-char cap" } + } + } +} +``` + +A source-tree static scan that asserts every `productTelemetry.fire(...)` call uses only `ProductTelemetryProps.*` keys is doable but requires a build task — skip unless you want CI-level enforcement. + +**Acceptance:** `./gradlew :core:domain:jvmTest` passes. + +--- + +## 6. Flush on app shutdown + +### Android +`GithubStoreApp.onCreate` — register a `ProcessLifecycleOwner` observer on `Lifecycle.Event.ON_STOP`: +```kotlin +ProcessLifecycleOwner.get().lifecycle.addObserver(LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + runBlocking { withTimeoutOrNull(2_000) { get().flush() } } + } +}) +``` +(`onTerminate` only fires in emulators — don't rely on it.) + +### Desktop +Before `application { … }` in `DesktopApp.kt`: +```kotlin +Runtime.getRuntime().addShutdownHook( + Thread { + runBlocking { + withTimeoutOrNull(2_000) { GlobalContext.get().get().flush() } + } + }, +) +``` + +--- + +## 7. Verification + +```bash +# Both targets compile +./gradlew :composeApp:compileKotlinJvm :composeApp:compileDebugKotlinAndroid + +# Tests pass (after §5.1 commonTest infra) +./gradlew :core:domain:jvmTest + +# Manual verification against staging backend +# 1. Fresh-install device, open app → consent sheet appears +# 2. "Got it" → use the app for 30s → kill the app +# 3. SSH to VPS: +# docker exec github-store-backend-postgres-1 psql -U githubstore -d githubstore \ +# -c "SELECT name, count(*) FROM telemetry_events GROUP BY name ORDER BY count DESC;" +# Should show app_launched + cold_start_ms + first_paint_ms + (whatever else got wired) +# 4. Toggle off in Settings → use the app → re-query → no new rows +``` + +--- + +## 8. Commit conventions + +One logical change per commit. Suggested order: + +1. Render product telemetry consent toggle in privacy settings +2. Show first-launch consent sheet on home screen +3. Add TelemetryBuckets helper for duration / count / confidence bucketing +4. Wire CRASH and OPERATION_FAILED events +5. Wire COLD_START_MS and FIRST_PAINT_MS events +6. Wire CACHE_HIT and CACHE_MISS events +7. Wire SEARCH_EXECUTED and DETAILS_VIEWED events (coexist with legacy) +8. Migrate import events from TelemetryRepository to ProductTelemetry (E1 handoff §) +9. Wire PROXY_CONFIGURED / PROXY_USED / MIRROR_USED events +10. Wire SESSION_DURATION event on app stop +11. Add PrivacyAuditTest for telemetry allowlist +12. Flush telemetry buffer on app shutdown + +After all merge: open PR `feature/e6-telemetry → main`, ship a new app release. Schema URL (`github-store.org/telemetry-schema`) can land separately — the consent sheet handles the missing URL gracefully (link goes to the GitHub source of the backend's `TelemetryEvent.kt`). + +--- + +## 9. Backend contract recap + +``` +POST https://api.github-store.org/v1/telemetry/events +Body: +{ + "events": [ + { + "name": "...", // must be in ProductTelemetryEvents.ALL + "sessionId": "...", // ≤128 chars, ephemeral, reset per launch + "timestamp": 1745678901234, + "platform": "android" | "macos" | "windows" | "linux", + "appVersion": "1.7.0", + "props": { ... } // optional; keys from ProductTelemetryProps.ALLOWED + } + ] +} +Returns: 204 always (drops non-allowlisted server-side; partial success counts as success) +Rate limit: 600 / min / IP +Batch cap: 100 events; over → 400 +Field caps: name 64, sessionId 128, platform 32, appVersion 32; over → 400 +``` + +Status codes other than 204 / 400 are transient — the impl already retries with exponential backoff. From 22427c45f3b7e026a30ad7589e5a0f20a3983416 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:39:37 +0500 Subject: [PATCH 07/38] Render product telemetry consent toggle in privacy settings --- .../commonMain/composeResources/values/strings.xml | 2 ++ .../presentation/components/sections/Others.kt | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 53bc38579..621837b89 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -689,6 +689,8 @@ Reset analytics ID Generate a new anonymous ID, severing the link to past telemetry. Analytics ID reset + Anonymous usage data + Help improve GitHub Store with anonymous, aggregate metrics. We never collect search queries, repo names, or any identifying information. Schema is open source. Recent searches diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt index e67ccfb63..cc92c2f1f 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.TweaksAction @@ -161,6 +162,17 @@ fun LazyListScope.othersSection( Spacer(Modifier.height(8.dp)) + ToggleSettingCard( + title = stringResource(Res.string.settings_product_telemetry_title), + description = stringResource(Res.string.settings_product_telemetry_description), + checked = state.productTelemetryConsent == ProductTelemetryConsent.Granted, + onCheckedChange = { enabled -> + onAction(TweaksAction.OnProductTelemetryToggled(enabled)) + }, + ) + + Spacer(Modifier.height(8.dp)) + ResetAnalyticsIdCard( onClick = { onAction(TweaksAction.OnResetAnalyticsId) From 0437cf9e7647eac8d62ebad7dab800dac0b1f625 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:43:36 +0500 Subject: [PATCH 08/38] Show first-launch consent sheet on home screen --- .../githubstore/app/di/ViewModelsModule.kt | 2 + .../composeResources/values/strings.xml | 5 ++ .../presentation/HomeConsentGateViewModel.kt | 34 +++++++++++ .../zed/rainxch/home/presentation/HomeRoot.kt | 25 ++++++++ .../ProductTelemetryConsentSheet.kt | 61 +++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt create mode 100644 feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt index 4539c3ce5..d8b55c49c 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt @@ -9,6 +9,7 @@ import zed.rainxch.auth.presentation.AuthenticationViewModel import zed.rainxch.details.presentation.DetailsViewModel import zed.rainxch.devprofile.presentation.DeveloperProfileViewModel import zed.rainxch.favourites.presentation.FavouritesViewModel +import zed.rainxch.home.presentation.HomeConsentGateViewModel import zed.rainxch.home.presentation.HomeViewModel import zed.rainxch.profile.presentation.ProfileViewModel import zed.rainxch.recentlyviewed.presentation.RecentlyViewedViewModel @@ -56,6 +57,7 @@ val viewModelsModule = viewModelOf(::DeveloperProfileViewModel) viewModelOf(::FavouritesViewModel) viewModelOf(::HomeViewModel) + viewModelOf(::HomeConsentGateViewModel) viewModelOf(::RecentlyViewedViewModel) viewModelOf(::SearchViewModel) viewModelOf(::ProfileViewModel) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 621837b89..bd90713d4 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -691,6 +691,11 @@ Analytics ID reset Anonymous usage data Help improve GitHub Store with anonymous, aggregate metrics. We never collect search queries, repo names, or any identifying information. Schema is open source. + Help improve GitHub Store + We collect anonymous, aggregate usage data to find bugs and improve performance. We never collect your repos, search queries, or any identifying information. The full schema and collection code are open source. + Got it + No thanks + View what we collect Recent searches diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt new file mode 100644 index 000000000..eeb68bcaa --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt @@ -0,0 +1,34 @@ +package zed.rainxch.home.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent + +class HomeConsentGateViewModel( + private val tweaksRepository: TweaksRepository, +) : ViewModel() { + + val consent: StateFlow = + tweaksRepository.getProductTelemetryConsent().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = ProductTelemetryConsent.NotYetAsked, + ) + + fun grant() { + viewModelScope.launch { + tweaksRepository.setProductTelemetryConsent(ProductTelemetryConsent.Granted) + } + } + + fun deny() { + viewModelScope.launch { + tweaksRepository.setProductTelemetryConsent(ProductTelemetryConsent.Denied) + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index 39a747b3f..979e2c49a 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -65,6 +65,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -80,6 +81,7 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.DiscoveryPlatform +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.components.ScrollbarContainer @@ -95,10 +97,14 @@ import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.home.domain.model.HomeCategory import zed.rainxch.home.domain.model.TopicCategory import zed.rainxch.home.presentation.components.LiquidGlassCategoryChips +import zed.rainxch.home.presentation.components.ProductTelemetryConsentSheet import zed.rainxch.home.presentation.locals.LocalHomeTopBarLiquid import zed.rainxch.home.presentation.utils.displayText import zed.rainxch.home.presentation.utils.icon +private const val TELEMETRY_SCHEMA_URL = + "https://github.com/OpenHub-Store/backend/blob/main/src/main/kotlin/zed/rainxch/githubstore/telemetry/TelemetryEvent.kt" + @Composable fun HomeRoot( onNavigateToSettings: () -> Unit, @@ -107,11 +113,30 @@ fun HomeRoot( onNavigateToDetails: (repoId: Long) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, viewModel: HomeViewModel = koinViewModel(), + consentGate: HomeConsentGateViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val consent by consentGate.consent.collectAsStateWithLifecycle() val listState = rememberLazyStaggeredGridState() val scope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } + val uriHandler = LocalUriHandler.current + + if (consent == ProductTelemetryConsent.NotYetAsked) { + ProductTelemetryConsentSheet( + onGrant = consentGate::grant, + onDeny = consentGate::deny, + onViewSchema = { + runCatching { + uriHandler.openUri(TELEMETRY_SCHEMA_URL) + } + }, + // Intentional: dismissing without picking keeps state at + // NotYetAsked so the sheet reappears next launch. No answer + // is not silent opt-in. + onDismiss = {}, + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt new file mode 100644 index 000000000..0830da516 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt @@ -0,0 +1,61 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_body +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_deny +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_grant +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_title +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_view_schema + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductTelemetryConsentSheet( + onGrant: () -> Unit, + onDeny: () -> Unit, + onViewSchema: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.product_telemetry_sheet_title), + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = stringResource(Res.string.product_telemetry_sheet_body), + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onGrant, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(Res.string.product_telemetry_sheet_grant)) + } + TextButton(onClick = onDeny, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(Res.string.product_telemetry_sheet_deny)) + } + TextButton(onClick = onViewSchema, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(Res.string.product_telemetry_sheet_view_schema)) + } + Spacer(Modifier.height(8.dp)) + } + } +} From e6fa4e94f797b4e0e2d8ae631c58d77facf843b5 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:44:03 +0500 Subject: [PATCH 09/38] Add TelemetryBuckets helper for duration, count, and confidence bucketing --- .../rainxch/core/domain/telemetry/Buckets.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt new file mode 100644 index 000000000..43538490d --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt @@ -0,0 +1,23 @@ +package zed.rainxch.core.domain.telemetry + +object TelemetryBuckets { + fun durationMs(ms: Long): String = when { + ms < 500 -> "<500" + ms < 1000 -> "500-1000" + ms < 3000 -> "1000-3000" + else -> ">3000" + } + + fun resultCount(n: Int): String = when { + n == 0 -> "0" + n <= 5 -> "1-5" + n <= 20 -> "6-20" + else -> ">20" + } + + fun confidence(score: Float): String = when { + score >= 0.85f -> "high" + score >= 0.5f -> "medium" + else -> "low" + } +} From 4ea0dcb4b4aa6891bd8bb8f6c7fbbe8f2e7c070d Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:48:58 +0500 Subject: [PATCH 10/38] Wire CRASH and OPERATION_FAILED telemetry events --- .../rainxch/githubstore/app/GithubStoreApp.kt | 22 ++++++++++++++++ .../rainxch/githubstore/app/CrashCategory.kt | 10 ++++++++ .../zed/rainxch/githubstore/DesktopApp.kt | 22 ++++++++++++++++ .../core/data/services/UpdateCheckWorker.kt | 12 +++++++++ .../zed/rainxch/core/data/di/SharedModule.kt | 1 + .../services/DefaultDownloadOrchestrator.kt | 25 ++++++++++++++++--- 6 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 61a85562b..f02105781 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import zed.rainxch.core.data.local.db.dao.ExternalLinkDao @@ -43,11 +45,31 @@ class GithubStoreApp : Application() { startDownloadNotificationObserver() scheduleBackgroundUpdateChecks() registerSelfAsInstalledApp() + installCrashTelemetryHandler() fireAppLaunched() scheduleInitialExternalScan() scheduleSigningSeedSync() } + private fun installCrashTelemetryHandler() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { + val telemetry = get() + telemetry.fire( + name = ProductTelemetryEvents.CRASH, + props = + mapOf( + ProductTelemetryProps.CATEGORY to categorizeCrash(throwable), + ProductTelemetryProps.PLATFORM to "android", + ), + ) + runBlocking { withTimeoutOrNull(500) { telemetry.flush() } } + } + previous?.uncaughtException(thread, throwable) + } + } + private fun fireAppLaunched() { get().fire( name = ProductTelemetryEvents.APP_LAUNCHED, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt new file mode 100644 index 000000000..1a45f7f67 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt @@ -0,0 +1,10 @@ +package zed.rainxch.githubstore.app + +fun categorizeCrash(throwable: Throwable): String = + when { + throwable is OutOfMemoryError -> "other" + throwable.message?.contains("DataStore", ignoreCase = true) == true -> "data_loss" + throwable.message?.contains("install", ignoreCase = true) == true -> "install_fail" + throwable.message?.contains("version", ignoreCase = true) == true -> "version_detect" + else -> "other" + } diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index e05edf5b1..69dd3460c 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -23,6 +23,7 @@ import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.telemetry.ProductTelemetry import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.githubstore.app.categorizeCrash import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent import zed.rainxch.githubstore.app.di.initKoin @@ -52,6 +53,8 @@ fun main(args: Array) { initKoin() + installCrashTelemetryHandler() + // Apply persisted UI language before any Compose code runs — same // reasoning as on Android (see `MainActivity.onCreate`). Desktop // Compose has no runtime `recreate()` equivalent, so mid-session @@ -132,6 +135,25 @@ fun main(args: Array) { } } +private fun installCrashTelemetryHandler() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { + val telemetry = GlobalContext.get().get() + telemetry.fire( + name = ProductTelemetryEvents.CRASH, + props = + mapOf( + ProductTelemetryProps.CATEGORY to categorizeCrash(throwable), + ProductTelemetryProps.PLATFORM to desktopPlatformSlug(), + ), + ) + runBlocking { withTimeoutOrNull(500) { telemetry.flush() } } + } + previous?.uncaughtException(thread, throwable) + } +} + private fun desktopPlatformSlug(): String { val os = System.getProperty("os.name").orEmpty().lowercase() return when { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index 9b4f05fdc..44a15b5ff 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -24,6 +24,9 @@ import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase /** @@ -46,6 +49,7 @@ class UpdateCheckWorker( private val externalImportRepository: ExternalImportRepository by inject() private val externalLinkDao: ExternalLinkDao by inject() private val packageMonitor: PackageMonitor by inject() + private val productTelemetry: ProductTelemetry by inject() override suspend fun doWork(): Result = try { @@ -92,6 +96,14 @@ class UpdateCheckWorker( if (runAttemptCount < 3) { Result.retry() } else { + productTelemetry.fire( + name = ProductTelemetryEvents.OPERATION_FAILED, + props = + mapOf( + ProductTelemetryProps.OP to "update", + ProductTelemetryProps.ERROR_CODE to (e::class.simpleName ?: "unknown"), + ), + ) Result.failure() } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 22115a675..8e0c65cf0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -242,6 +242,7 @@ val coreModule = installedAppsRepository = get(), pendingInstallNotifier = get(), appScope = get(), + productTelemetry = get(), ) } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt index 3cac2dc3f..46630c99d 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt @@ -17,6 +17,9 @@ import kotlinx.coroutines.sync.withLock import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.DownloadOrchestrator +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.core.domain.system.DownloadSpec import zed.rainxch.core.domain.system.DownloadStage import zed.rainxch.core.domain.system.InstallOutcome @@ -75,6 +78,7 @@ class DefaultDownloadOrchestrator( private val installedAppsRepository: InstalledAppsRepository, private val pendingInstallNotifier: PendingInstallNotifier, private val appScope: CoroutineScope, + private val productTelemetry: ProductTelemetry, ) : DownloadOrchestrator { private companion object { private const val DEFAULT_MAX_CONCURRENT = 3 @@ -160,7 +164,7 @@ class DefaultDownloadOrchestrator( throw e } catch (t: Throwable) { Logger.e(t) { "Orchestrator: download/install failed for ${spec.packageName}" } - markFailed(spec.packageName, t.message) + markFailed(spec.packageName, t.message, op = "download", throwable = t) } finally { stateMutex.withLock { if (activeJobs[spec.packageName]?.isCompleted == true || @@ -300,7 +304,7 @@ class DefaultDownloadOrchestrator( throw e } catch (t: Throwable) { Logger.e(t) { "Orchestrator: install failed for ${spec.packageName}" } - markFailed(spec.packageName, t.message) + markFailed(spec.packageName, t.message, op = "install", throwable = t) } } @@ -527,7 +531,7 @@ class DefaultDownloadOrchestrator( throw e } catch (t: Throwable) { Logger.e(t) { "Orchestrator: standalone install failed for $packageName" } - markFailed(packageName, t.message) + markFailed(packageName, t.message, op = "install", throwable = t) null } } @@ -551,13 +555,26 @@ class DefaultDownloadOrchestrator( } } - private suspend fun markFailed(packageName: String, message: String?) { + private suspend fun markFailed( + packageName: String, + message: String?, + op: String, + throwable: Throwable? = null, + ) { stateMutex.withLock { _downloads.update { state -> val current = state[packageName] ?: return@update state state + (packageName to current.copy(stage = DownloadStage.Failed, errorMessage = message)) } } + productTelemetry.fire( + name = ProductTelemetryEvents.OPERATION_FAILED, + props = + mapOf( + ProductTelemetryProps.OP to op, + ProductTelemetryProps.ERROR_CODE to (throwable?.let { it::class.simpleName } ?: "unknown"), + ), + ) } private fun generateId(): String = From 3d45e562731aebc796d87ec624853dd5dc38f22a Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:54:16 +0500 Subject: [PATCH 11/38] Wire COLD_START_MS and FIRST_PAINT_MS telemetry events --- .../rainxch/githubstore/app/GithubStoreApp.kt | 1 + .../kotlin/zed/rainxch/githubstore/Main.kt | 30 +++++++++++++++++++ .../zed/rainxch/githubstore/app/ColdStart.kt | 20 +++++++++++++ .../zed/rainxch/githubstore/DesktopApp.kt | 3 ++ .../presentation/telemetry/TrackFirstPaint.kt | 24 +++++++++++++++ .../zed/rainxch/apps/presentation/AppsRoot.kt | 13 ++++++++ .../details/presentation/DetailsRoot.kt | 18 +++++++++++ .../zed/rainxch/home/presentation/HomeRoot.kt | 18 +++++++++++ .../rainxch/search/presentation/SearchRoot.kt | 13 ++++++++ .../rainxch/tweaks/presentation/TweaksRoot.kt | 15 ++++++++++ 10 files changed, 155 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt create mode 100644 core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/telemetry/TrackFirstPaint.kt diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index f02105781..b0f0aeecb 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -34,6 +34,7 @@ class GithubStoreApp : Application() { private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onCreate() { + ColdStart.markStart() super.onCreate() initKoin { diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 358e407de..01c53aacf 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -7,10 +7,18 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ApplyAndroidSystemBars import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.githubstore.app.ColdStart import zed.rainxch.githubstore.app.components.RateLimitDialog import zed.rainxch.githubstore.app.components.SessionExpiredDialog import zed.rainxch.githubstore.app.deeplink.DeepLinkDestination @@ -25,10 +33,24 @@ import zed.rainxch.githubstore.app.navigation.getCurrentScreen fun App(deepLinkUri: String? = null) { val viewModel: MainViewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() + val productTelemetry: ProductTelemetry = koinInject() val navController = rememberNavController() val currentScreen = navController.currentBackStackEntryAsState().value.getCurrentScreen() + LaunchedEffect(Unit) { + ColdStart.consumeIfFirst()?.let { ms -> + productTelemetry.fire( + name = ProductTelemetryEvents.COLD_START_MS, + props = + mapOf( + ProductTelemetryProps.PLATFORM to platformSlug(), + ProductTelemetryProps.BUCKET to TelemetryBuckets.durationMs(ms), + ), + ) + } + } + LaunchedEffect(deepLinkUri) { deepLinkUri?.let { uri -> when (val destination = DeepLinkParser.parse(uri)) { @@ -120,3 +142,11 @@ fun App(deepLinkUri: String? = null) { ) } } + +private fun platformSlug(): String = + when (getPlatform()) { + Platform.ANDROID -> "android" + Platform.MACOS -> "macos" + Platform.WINDOWS -> "windows" + Platform.LINUX -> "linux" + } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt new file mode 100644 index 000000000..4bd0e4045 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt @@ -0,0 +1,20 @@ +package zed.rainxch.githubstore.app + +import kotlin.time.TimeSource + +object ColdStart { + private var mark: TimeSource.Monotonic.ValueTimeMark? = null + private var consumed: Boolean = false + + fun markStart() { + if (mark == null) mark = TimeSource.Monotonic.markNow() + } + + // Returns elapsed ms on the first call after [markStart], null thereafter. + // Single-process, single-shot — App composable consumes once on first frame. + fun consumeIfFirst(): Long? { + if (consumed) return null + consumed = true + return mark?.elapsedNow()?.inWholeMilliseconds + } +} diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 69dd3460c..5b435e756 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -23,6 +23,7 @@ import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.telemetry.ProductTelemetry import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.githubstore.app.ColdStart import zed.rainxch.githubstore.app.categorizeCrash import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent @@ -36,6 +37,8 @@ import kotlin.system.exitProcess private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L fun main(args: Array) { + ColdStart.markStart() + // Install first so anything that blows up during Koin init or // resource loading leaves a diagnosable trail on disk (see // `CrashReporter.resolveLogDir` for the per-OS path). diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/telemetry/TrackFirstPaint.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/telemetry/TrackFirstPaint.kt new file mode 100644 index 000000000..1c4554d1b --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/telemetry/TrackFirstPaint.kt @@ -0,0 +1,24 @@ +package zed.rainxch.core.presentation.telemetry + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlin.time.TimeSource + +@Composable +fun TrackFirstPaint( + isReady: Boolean, + onFirstPaint: (elapsedMs: Long) -> Unit, +) { + val enteredAt = remember { TimeSource.Monotonic.markNow() } + var fired by remember { mutableStateOf(false) } + LaunchedEffect(isReady) { + if (!fired && isReady) { + fired = true + onFirstPaint(enteredAt.elapsedNow().inWholeMilliseconds) + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index d8787351c..ca5a867c9 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -156,6 +156,19 @@ fun AppsRoot( ) { val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry = org.koin.compose.koinInject() + + zed.rainxch.core.presentation.telemetry.TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.SCREEN to "library", + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.BUCKET to + zed.rainxch.core.domain.telemetry.TelemetryBuckets.durationMs(ms), + ), + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index ef05cc14e..c01925457 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -64,10 +64,16 @@ import io.github.fletchmckee.liquid.rememberLiquidState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.InstallSource +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.presentation.components.ScrollbarContainer import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled +import zed.rainxch.core.presentation.telemetry.TrackFirstPaint import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll @@ -124,6 +130,18 @@ fun DetailsRoot( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + val productTelemetry: ProductTelemetry = koinInject() + + TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + ProductTelemetryProps.SCREEN to "details", + ProductTelemetryProps.BUCKET to TelemetryBuckets.durationMs(ms), + ), + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index 979e2c49a..2759c5e4f 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -79,15 +79,21 @@ import io.github.fletchmckee.liquid.rememberLiquidState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.DiscoveryPlatform +import zed.rainxch.core.domain.telemetry.ProductTelemetry import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.components.ScrollbarContainer import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled +import zed.rainxch.core.presentation.telemetry.TrackFirstPaint import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll @@ -121,6 +127,18 @@ fun HomeRoot( val scope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } val uriHandler = LocalUriHandler.current + val productTelemetry: ProductTelemetry = koinInject() + + TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + ProductTelemetryProps.SCREEN to "home", + ProductTelemetryProps.BUCKET to TelemetryBuckets.durationMs(ms), + ), + ) + } if (consent == ProductTelemetryConsent.NotYetAsked) { ProductTelemetryConsentSheet( diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index 6d2f03003..bf9a4253f 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -116,6 +116,19 @@ fun SearchRoot( val state by viewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } + val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry = org.koin.compose.koinInject() + + zed.rainxch.core.presentation.telemetry.TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.SCREEN to "search", + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.BUCKET to + zed.rainxch.core.domain.telemetry.TelemetryBuckets.durationMs(ms), + ), + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index 488d8d775..f82d73839 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -47,6 +47,21 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry = org.koin.compose.koinInject() + + // versionName is hydrated asynchronously in TweaksViewModel.onStart; + // its presence is the cheapest "first non-skeleton render" signal here. + zed.rainxch.core.presentation.telemetry.TrackFirstPaint(isReady = state.versionName.isNotEmpty()) { ms -> + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.SCREEN to "settings", + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.BUCKET to + zed.rainxch.core.domain.telemetry.TelemetryBuckets.durationMs(ms), + ), + ) + } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { From 675d6319bccd5dd1c5501b7cf7e631cb2a7f70a9 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:55:38 +0500 Subject: [PATCH 12/38] Wire CACHE_HIT and CACHE_MISS telemetry events in CacheManager --- .../rainxch/core/data/cache/CacheManager.kt | 41 +++++++++++++++++-- .../zed/rainxch/core/data/di/SharedModule.kt | 5 ++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt index 976a1d4d3..56b8490a0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -4,11 +4,15 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.serializer import zed.rainxch.core.data.local.db.dao.CacheDao import zed.rainxch.core.data.local.db.entities.CacheEntryEntity +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import kotlin.time.Clock import kotlin.time.Duration.Companion.hours class CacheManager( val cacheDao: CacheDao, + @PublishedApi internal val productTelemetry: ProductTelemetry, ) { val json = Json { @@ -27,7 +31,9 @@ class CacheManager( memoryCache[key]?.let { (expiresAt, jsonData) -> if (expiresAt > currentTime) { return try { - json.decodeFromString(serializer(), jsonData) + val value = json.decodeFromString(serializer(), jsonData) + fireCacheLookup(key, hit = true) + value } catch (_: Exception) { memoryCache.remove(key) null @@ -37,11 +43,17 @@ class CacheManager( } } - val entry = cacheDao.getValid(key, currentTime) ?: return null + val entry = + cacheDao.getValid(key, currentTime) ?: run { + fireCacheLookup(key, hit = false) + return null + } memoryCache[key] = entry.expiresAt to entry.jsonData return try { - json.decodeFromString(serializer(), entry.jsonData) + val value = json.decodeFromString(serializer(), entry.jsonData) + fireCacheLookup(key, hit = true) + value } catch (_: Exception) { cacheDao.delete(key) memoryCache.remove(key) @@ -49,6 +61,29 @@ class CacheManager( } } + @PublishedApi + internal fun fireCacheLookup(key: String, hit: Boolean) { + val cacheName = deriveCacheName(key) ?: return + productTelemetry.fire( + name = if (hit) ProductTelemetryEvents.CACHE_HIT else ProductTelemetryEvents.CACHE_MISS, + props = mapOf(ProductTelemetryProps.CACHE_NAME to cacheName), + ) + } + + @PublishedApi + internal fun deriveCacheName(key: String): String? = + when { + key.startsWith("readme:") -> "readmes" + key.startsWith("search:") -> "search_results" + key.startsWith("repo:") || + key.startsWith("repo_id:") || + key.startsWith("releases:") || + key.startsWith("latest_release:") || + key.startsWith("repo_stats:") || + key.startsWith("user_profile:") -> "details" + else -> null + } + suspend inline fun getStale(key: String): T? { val entry = cacheDao.getAny(key) ?: return null return try { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 8e0c65cf0..348f86f4f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -156,7 +156,10 @@ val coreModule = } single { - CacheManager(cacheDao = get()) + CacheManager( + cacheDao = get(), + productTelemetry = get(), + ) } single { From 0283e72fefc59fd1af1f613ffd3096ad56a329c3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 06:59:45 +0500 Subject: [PATCH 13/38] Wire SEARCH_EXECUTED, DETAILS_VIEWED, and UPDATE_INSTALLED telemetry events --- .../githubstore/app/di/ViewModelsModule.kt | 2 ++ .../githubstore/app/navigation/AppNavigation.kt | 17 ++++++++++++++++- .../app/navigation/GithubStoreGraph.kt | 4 ++++ .../zed/rainxch/core/data/di/SharedModule.kt | 1 + .../repository/InstalledAppsRepositoryImpl.kt | 5 +++++ .../details/presentation/DetailsViewModel.kt | 9 +++++++++ .../search/presentation/SearchViewModel.kt | 16 +++++++++++++++- 7 files changed, 52 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt index d8b55c49c..1c7b58170 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt @@ -32,6 +32,7 @@ val viewModelsModule = ownerParam = params.get(1), repoParam = params.get(2), isComingFromUpdate = params.get(3), + from = params.get(4), detailsRepository = get(), downloader = get(), installer = get(), @@ -52,6 +53,7 @@ val viewModelsModule = downloadOrchestrator = get(), telemetryRepository = get(), externalImportRepository = get(), + productTelemetry = get(), ) } viewModelOf(::DeveloperProfileViewModel) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 9c1be9b2a..e5a7807c8 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -90,6 +90,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = "category", ), ) }, @@ -112,6 +113,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = "search", ), ) }, @@ -120,6 +122,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( owner = owner, repo = repo, + from = "link", ), ) }, @@ -143,6 +146,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = "link", ), ) }, @@ -160,6 +164,7 @@ fun AppNavigation( args.owner, args.repo, args.isComingFromUpdate, + args.from, ) }, ) @@ -175,6 +180,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = "link", ), ) }, @@ -203,7 +209,12 @@ fun AppNavigation( navController.navigateUp() }, onNavigateToDetails = { - navController.navigate(GithubStoreGraph.DetailsScreen(it)) + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = it, + from = "library", + ), + ) }, onNavigateToDeveloperProfile = { username -> navController.navigate( @@ -224,6 +235,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = "library", ), ) }, @@ -277,6 +289,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = "library", ), ) }, @@ -323,6 +336,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( repositoryId = repoId, isComingFromUpdate = true, + from = "library", ), ) }, @@ -344,6 +358,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( repositoryId = repoId, isComingFromUpdate = true, + from = "library", ), ) }, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt index 0cfbb6c5a..377a80009 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt @@ -19,6 +19,10 @@ sealed interface GithubStoreGraph { val owner: String = "", val repo: String = "", val isComingFromUpdate: Boolean = false, + // E6 telemetry: where this nav originated. Categorical only — + // "search" / "category" / "library" / "link". Drives the FROM + // prop on DETAILS_VIEWED. + val from: String = "link", ) : GithubStoreGraph @Serializable diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 348f86f4f..7ec2fa8ad 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -105,6 +105,7 @@ val coreModule = historyDao = get(), installer = get(), clientProvider = get(), + productTelemetry = get(), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 5c8036b17..e85f81c99 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -28,6 +28,8 @@ import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.MatchingPreview import zed.rainxch.core.domain.system.Installer +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.core.domain.util.AssetFilter import zed.rainxch.core.domain.util.AssetVariant @@ -39,6 +41,7 @@ class InstalledAppsRepositoryImpl( private val historyDao: UpdateHistoryDao, private val installer: Installer, private val clientProvider: GitHubClientProvider, + private val productTelemetry: ProductTelemetry, ) : InstalledAppsRepository { // Reads the current Ktor client at every call site so any proxy // change (ProxyManager rebuilds the client via [clientProvider]) @@ -451,6 +454,8 @@ class InstalledAppsRepositoryImpl( signingFingerprint = signingFingerprint, ), ) + + productTelemetry.fire(name = ProductTelemetryEvents.UPDATE_INSTALLED) } override suspend fun updateApp(app: InstalledApp) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 9dc5f55a2..0e9b56a66 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -118,6 +118,8 @@ class DetailsViewModel( private val downloadOrchestrator: DownloadOrchestrator, private val telemetryRepository: TelemetryRepository, private val externalImportRepository: ExternalImportRepository, + private val from: String, + private val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry, ) : ViewModel() { private var hasLoadedInitialData = false private var currentDownloadJob: Job? = null @@ -2395,6 +2397,13 @@ class DetailsViewModel( ) telemetryRepository.recordRepoViewed(repo.id) + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.DETAILS_VIEWED, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.FROM to from, + ), + ) observeInstalledApp(repo.id) } catch (e: RateLimitException) { diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 17f9530af..8234fb71f 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -30,6 +30,10 @@ import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager @@ -60,6 +64,7 @@ class SearchViewModel( private val seenReposRepository: SeenReposRepository, private val searchHistoryRepository: SearchHistoryRepository, private val telemetryRepository: TelemetryRepository, + private val productTelemetry: ProductTelemetry, ) : ViewModel() { private var hasLoadedInitialData = false private var currentSearchJob: Job? = null @@ -411,9 +416,18 @@ class SearchViewModel( } if (isInitial) { + val resultCount = _state.value.repositories.size telemetryRepository.recordSearchPerformed( query = query, - resultCount = _state.value.repositories.size, + resultCount = resultCount, + ) + productTelemetry.fire( + name = ProductTelemetryEvents.SEARCH_EXECUTED, + props = + mapOf( + ProductTelemetryProps.RESULT_COUNT_BUCKET to + TelemetryBuckets.resultCount(resultCount), + ), ) } } catch (e: RateLimitException) { From a11534bd85b89104c7356082c91691bf0adbe398 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 07:04:02 +0500 Subject: [PATCH 14/38] Migrate import telemetry events from TelemetryRepository to ProductTelemetry --- .../zed/rainxch/core/data/di/SharedModule.kt | 1 + .../ExternalImportRepositoryImpl.kt | 38 ++++++++----- .../import/ExternalImportViewModel.kt | 56 ++++++------------- .../details/presentation/DetailsViewModel.kt | 1 - 4 files changed, 43 insertions(+), 53 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 7ec2fa8ad..c543f225b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -233,6 +233,7 @@ val coreModule = externalMatchApi = get(), backendClient = get(), telemetry = get(), + productTelemetry = get(), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 54d208354..38dbcae0d 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -25,6 +25,9 @@ import zed.rainxch.core.data.network.ExternalMatchApi import zed.rainxch.core.data.network.RateLimitedException import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.TelemetryRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.ExternalDecisionSnapshot @@ -42,6 +45,7 @@ class ExternalImportRepositoryImpl( private val externalMatchApi: ExternalMatchApi, private val backendClient: BackendApiClient, private val telemetry: TelemetryRepository, + private val productTelemetry: ProductTelemetry, ) : ExternalImportRepository { // Snapshot cache survives only for the lifetime of the process. Decisions // (linked / skipped / never-ask) are persisted in `external_links`; the @@ -65,8 +69,10 @@ class ExternalImportRepositoryImpl( val firstLaunch = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] == null runCatching { if (firstLaunch) { - runCatching { telemetry.importScanStarted(trigger = "first_launch") } - .onFailure { Logger.d { "telemetry importScanStarted failed: ${it.message}" } } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SCAN_STARTED, + props = mapOf(ProductTelemetryProps.PLATFORM to "android"), + ) } runFullScan() }.onSuccess { @@ -102,12 +108,14 @@ class ExternalImportRepositoryImpl( .onFailure { Logger.d { "prune pending failed: ${it.message}" } } val durationMs = nowMillis() - started - runCatching { - telemetry.importScanCompleted( - candidateCountBucket = bucketCandidateCount(candidates.size), - durationMsBucket = bucketDurationMs(durationMs), - ) - }.onFailure { Logger.d { "telemetry importScanCompleted failed: ${it.message}" } } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SCAN_COMPLETED, + props = + mapOf( + ProductTelemetryProps.CANDIDATE_COUNT to bucketCandidateCount(candidates.size), + ProductTelemetryProps.DURATION_MS to bucketDurationMs(durationMs), + ), + ) return ScanResult( totalCandidates = candidates.size, @@ -245,12 +253,14 @@ class ExternalImportRepositoryImpl( // only — never owner/repo/package name. deduped.groupBy { it.source }.forEach { (source, hits) -> val top = hits.maxByOrNull { it.confidence } ?: return@forEach - runCatching { - telemetry.importMatchAttempted( - strategy = source.telemetryStrategy(), - confidenceBucket = bucketConfidence(top.confidence), - ) - }.onFailure { Logger.d { "telemetry importMatchAttempted failed: ${it.message}" } } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_MATCH_ATTEMPTED, + props = + mapOf( + ProductTelemetryProps.STRATEGY to source.telemetryStrategy(), + ProductTelemetryProps.CONFIDENCE_BUCKET to bucketConfidence(top.confidence), + ), + ) } RepoMatchResult(packageName = candidate.packageName, suggestions = deduped) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 377327f4b..56116b736 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -29,6 +29,9 @@ import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TelemetryRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalDecisionSnapshot import zed.rainxch.core.domain.system.InstallerKind @@ -55,6 +58,7 @@ class ExternalImportViewModel( private val appsRepository: AppsRepository, private val installedAppsRepository: InstalledAppsRepository, private val telemetry: TelemetryRepository, + private val productTelemetry: ProductTelemetry, private val logger: GitHubStoreLogger, ) : ViewModel() { private var candidatesByPackage: Map = emptyMap() @@ -102,7 +106,6 @@ class ExternalImportViewModel( ExternalImportAction.OnRequestPermission -> { _state.update { it.copy(phase = ImportPhase.RequestingPermission) } - viewModelScope.launch { runCatching { telemetry.importPermissionRequested() } } } is ExternalImportAction.OnPermissionGranted -> { @@ -348,13 +351,11 @@ class ExternalImportViewModel( ), ) } - viewModelScope.launch { runCatching { telemetry.importSearchOverrideUsed() } } return } searchJob?.cancel() _state.update { it.copy(isSearching = true, searchError = null) } - viewModelScope.launch { runCatching { telemetry.importSearchOverrideUsed() } } searchJob = viewModelScope.launch { val result = runCatching { externalImportRepository.searchRepos(query) } .getOrElse { e -> @@ -364,9 +365,6 @@ class ExternalImportViewModel( result.fold( onSuccess = { suggestions -> - if (suggestions.isEmpty()) { - runCatching { telemetry.importSearchOverrideNoResults() } - } _state.update { // Stale-completion guard: if the user collapsed or switched cards // while the request was in flight, drop the response on the floor @@ -439,12 +437,10 @@ class ExternalImportViewModel( return@launch } - runCatching { - telemetry.importSkipped( - countBucket = "1-2", - persisted = if (neverAsk) "forever" else "7day", - ) - } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SKIPPED, + props = mapOf(ProductTelemetryProps.COUNT to "1-2"), + ) removeCardFromState(packageName) { it.copy(skipped = it.skipped + 1) } pendingUndo = PendingUndo( @@ -502,9 +498,10 @@ class ExternalImportViewModel( ) return@launch } - runCatching { - telemetry.importManuallyLinked(countBucket = "1-2", source = source) - } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_MANUALLY_LINKED, + props = mapOf(ProductTelemetryProps.COUNT to "1-2"), + ) removeCardFromState(packageName) { it.copy(manuallyLinked = it.manuallyLinked + 1) } pendingUndo = PendingUndo( @@ -691,25 +688,10 @@ class ExternalImportViewModel( } private fun emitPermissionOutcome(granted: Boolean, sdkInt: Int?) { - viewModelScope.launch { - runCatching { - telemetry.importPermissionOutcome( - granted = granted, - sdkIntBucket = bucketSdkInt(sdkInt), - ) - } - } + // Permission-outcome telemetry intentionally dropped at the E6 + // boundary — the new schema doesn't track it. } - private fun bucketSdkInt(sdkInt: Int?): String = - when { - sdkInt == null -> "unknown" - sdkInt in 26..29 -> "26-29" - sdkInt in 30..32 -> "30-32" - sdkInt >= 33 -> "33+" - else -> "unknown" - } - private fun bucketCount(count: Int): String = when { count <= 0 -> "0" @@ -744,12 +726,10 @@ class ExternalImportViewModel( } if (successes.isNotEmpty()) { - runCatching { - telemetry.importSkipped( - countBucket = bucketCount(successes.size), - persisted = "7day", - ) - } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SKIPPED, + props = mapOf(ProductTelemetryProps.COUNT to bucketCount(successes.size)), + ) } // Skip-remaining is intentionally not undoable — bulk skip clears diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 0e9b56a66..b65ac4d21 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -181,7 +181,6 @@ class DetailsViewModel( externalImportRepository.unlink(packageName) installedAppsRepository.deleteInstalledApp(packageName) } - runCatching { telemetryRepository.importUnlinkedFromDetails() } _events.send( DetailsEvent.OnMessage( getString(Res.string.details_unlink_external_app_success), From a8691a2b290ed823b5fc59933dc73f45bac99b57 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 07:06:42 +0500 Subject: [PATCH 15/38] Wire PROXY_CONFIGURED and PROXY_USED telemetry events --- .../zed/rainxch/core/data/di/SharedModule.kt | 2 + .../core/data/network/BackendApiClient.kt | 37 +++++++++++++++---- .../data/repository/ProxyRepositoryImpl.kt | 17 +++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index c543f225b..fa02bea31 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -140,6 +140,7 @@ val coreModule = ProxyRepositoryImpl( preferences = get(), logger = get(), + productTelemetry = get(), ) } @@ -171,6 +172,7 @@ val coreModule = BackendApiClient( proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), tokenStore = get(), + productTelemetry = get(), ) } // NOTE: the reviewer asked for a Koin onClose hook to call diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index ff56fec94..af9124670 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -42,6 +42,9 @@ import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.dto.SigningFingerprintSeedResponse import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import kotlin.coroutines.cancellation.CancellationException /** @@ -53,6 +56,7 @@ import kotlin.coroutines.cancellation.CancellationException class BackendApiClient( proxyConfigFlow: StateFlow, private val tokenStore: TokenStore, + private val productTelemetry: ProductTelemetry, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val mutex = Mutex() @@ -60,6 +64,9 @@ class BackendApiClient( @Volatile private var httpClient: HttpClient = buildClient(proxyConfigFlow.value) + @Volatile + private var currentProxyConfig: ProxyConfig = proxyConfigFlow.value + init { proxyConfigFlow .drop(1) @@ -68,6 +75,7 @@ class BackendApiClient( mutex.withLock { httpClient.close() httpClient = buildClient(config) + currentProxyConfig = config } }.launchIn(scope) } @@ -315,14 +323,27 @@ class BackendApiClient( } } - private inline fun safeCall(block: () -> Result): Result = - try { - block() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Result.failure(e) - } + private inline fun safeCall(block: () -> Result): Result { + val result = + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + firePerProxyOutcome(result.isSuccess) + return result + } + + private fun firePerProxyOutcome(success: Boolean) { + val proxy = currentProxyConfig + if (proxy is ProxyConfig.None || proxy is ProxyConfig.System) return + productTelemetry.fire( + name = ProductTelemetryEvents.PROXY_USED, + props = mapOf(ProductTelemetryProps.SUCCESS to success.toString()), + ) + } companion object { private const val BASE_URL = BACKEND_BASE_URL diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt index b497b5618..066d9a9b1 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -13,6 +13,9 @@ import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.repository.ProxyRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps /** * Persists one [ProxyConfig] per [ProxyScope] in DataStore, writes @@ -30,6 +33,7 @@ import zed.rainxch.core.domain.repository.ProxyRepository class ProxyRepositoryImpl( private val preferences: DataStore, private val logger: GitHubStoreLogger, + private val productTelemetry: ProductTelemetry, ) : ProxyRepository { // Legacy (pre-scope) keys — read-only, used as a fallback seed. private val legacyType = stringPreferencesKey("proxy_type") @@ -181,6 +185,19 @@ class ProxyRepositoryImpl( } } ProxyManager.setConfig(scope, config) + + productTelemetry.fire( + name = ProductTelemetryEvents.PROXY_CONFIGURED, + props = + mapOf( + ProductTelemetryProps.TYPE to + when (config) { + is ProxyConfig.Http -> "http" + is ProxyConfig.Socks -> "socks5" + ProxyConfig.None, ProxyConfig.System -> "none" + }, + ), + ) } private fun writeOrRemove( From 4d37ad5a8e78f3f5a51563615750442b37f2dfc7 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 07:13:32 +0500 Subject: [PATCH 16/38] Wire SESSION_DURATION telemetry event on app stop --- composeApp/build.gradle.kts | 1 + .../rainxch/githubstore/app/GithubStoreApp.kt | 19 +++++++++++++++++++ .../zed/rainxch/githubstore/app/ColdStart.kt | 2 ++ .../zed/rainxch/githubstore/DesktopApp.kt | 11 ++++++++++- gradle/libs.versions.toml | 2 ++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9b229e40a..861572b11 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -18,6 +18,7 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.core.splashscreen) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index b0f0aeecb..4c4895d18 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -4,6 +4,9 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -47,11 +50,27 @@ class GithubStoreApp : Application() { scheduleBackgroundUpdateChecks() registerSelfAsInstalledApp() installCrashTelemetryHandler() + installSessionDurationObserver() fireAppLaunched() scheduleInitialExternalScan() scheduleSigningSeedSync() } + private fun installSessionDurationObserver() { + ProcessLifecycleOwner.get().lifecycle.addObserver( + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + val telemetry = get() + val seconds = ColdStart.elapsedSeconds() ?: return@LifecycleEventObserver + telemetry.fire( + name = ProductTelemetryEvents.SESSION_DURATION, + props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), + ) + } + }, + ) + } + private fun installCrashTelemetryHandler() { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt index 4bd0e4045..1df108b21 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt @@ -17,4 +17,6 @@ object ColdStart { consumed = true return mark?.elapsedNow()?.inWholeMilliseconds } + + fun elapsedSeconds(): Long? = mark?.elapsedNow()?.inWholeSeconds } diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 5b435e756..40cf7f39b 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -117,7 +117,16 @@ fun main(args: Array) { } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + val telemetry = GlobalContext.get().get() + ColdStart.elapsedSeconds()?.let { seconds -> + telemetry.fire( + name = ProductTelemetryEvents.SESSION_DURATION, + props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), + ) + } + exitApplication() + }, title = stringResource(Res.string.app_name), icon = painterResource(Res.drawable.app_icon), onKeyEvent = { keyEvent -> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4e37a459..0d24e7f6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ material-icons = "1.7.3" # AndroidX androidx-activity = "1.12.4" +androidx-lifecycle = "2.9.4" core-splashscreen = "1.2.0" # Kotlinx @@ -64,6 +65,7 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " # AndroidX Core androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } jetbrains-compose-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-lifecycle" } koin-core-viewmodel = { group = "io.insert-koin", name = "koin-core-viewmodel", version.ref = "koin" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "core-splashscreen" } From c69a3c98f7f9a75817ff33d24989fde45439c89f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 10:57:02 +0500 Subject: [PATCH 17/38] Add PrivacyAuditTest for telemetry allowlist --- core/domain/build.gradle.kts | 6 +++ .../core/domain/telemetry/PrivacyAuditTest.kt | 46 +++++++++++++++++++ gradle/libs.versions.toml | 1 + 3 files changed, 53 insertions(+) create mode 100644 core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index ae2bc844e..041baf7d0 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -11,6 +11,12 @@ kotlin { } } + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + androidMain { dependencies { } diff --git a/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt b/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt new file mode 100644 index 000000000..4388e6ac6 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt @@ -0,0 +1,46 @@ +package zed.rainxch.core.domain.telemetry + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PrivacyAuditTest { + + // Hardcoded mirror of the backend's TelemetryAllowlist. Drift between + // client and server here means the server silently drops events the + // client thinks it's sending — fail loudly on mismatch. + @Test + fun `event allowlist matches the backend's locked schema`() { + val backendAllowlist = setOf( + "app_launched", "session_duration", + "import_scan_started", "import_scan_completed", "import_match_attempted", + "import_auto_linked", "import_manually_linked", "import_skipped", + "crash", "operation_failed", + "cold_start_ms", "first_paint_ms", "cache_hit", "cache_miss", + "proxy_configured", "proxy_used", "mirror_used", + "update_installed", "search_executed", "details_viewed", + ) + assertEquals(backendAllowlist, ProductTelemetryEvents.ALL) + } + + @Test + fun `no PII-shaped names slip into the allowlist`() { + val forbidden = listOf( + "user_id", "email", "search_query", "repo_name", "github_username", + "device_id", "ip_address", "owner", "name", "query", "username", + ) + forbidden.forEach { name -> + assertFalse(name in ProductTelemetryEvents.ALL, "$name leaked into ALL") + assertFalse(name in ProductTelemetryProps.ALLOWED, "$name leaked into ALLOWED props") + } + } + + @Test + fun `every prop key is short and snake_case`() { + ProductTelemetryProps.ALLOWED.forEach { key -> + assertFalse(key.contains(" "), "prop key '$key' has whitespace") + assertFalse(key.any { it.isUpperCase() }, "prop key '$key' has uppercase") + check(key.length <= 32) { "prop key '$key' exceeds 32-char cap" } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d24e7f6c..bf19f4c0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,6 +62,7 @@ projectVersionCode = "14" # Kotlin androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } # AndroidX Core androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } From 42d0319235f8c973a732bd299965208d7fe95ec4 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 10:59:21 +0500 Subject: [PATCH 18/38] Flush product telemetry buffer on app shutdown --- .../zed/rainxch/githubstore/app/GithubStoreApp.kt | 14 +++++++++----- .../kotlin/zed/rainxch/githubstore/DesktopApp.kt | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 4c4895d18..93a4f4bb7 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -61,11 +61,15 @@ class GithubStoreApp : Application() { LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { val telemetry = get() - val seconds = ColdStart.elapsedSeconds() ?: return@LifecycleEventObserver - telemetry.fire( - name = ProductTelemetryEvents.SESSION_DURATION, - props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), - ) + ColdStart.elapsedSeconds()?.let { seconds -> + telemetry.fire( + name = ProductTelemetryEvents.SESSION_DURATION, + props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), + ) + } + // Best-effort flush — bounded so we never block the + // process indefinitely if the network is hostile. + runBlocking { withTimeoutOrNull(2_000) { telemetry.flush() } } } }, ) diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 40cf7f39b..5f5e7d742 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -57,6 +57,7 @@ fun main(args: Array) { initKoin() installCrashTelemetryHandler() + installTelemetryShutdownHook() // Apply persisted UI language before any Compose code runs — same // reasoning as on Android (see `MainActivity.onCreate`). Desktop @@ -147,6 +148,20 @@ fun main(args: Array) { } } +private fun installTelemetryShutdownHook() { + Runtime.getRuntime().addShutdownHook( + Thread { + runCatching { + runBlocking { + withTimeoutOrNull(2_000) { + GlobalContext.get().get().flush() + } + } + } + }, + ) +} + private fun installCrashTelemetryHandler() { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> From 0c2ebd7591eb4c037d14bdc404660ebdad1c20b6 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:38:06 +0500 Subject: [PATCH 19/38] Stabilize ProductTelemetryImpl: break Koin cycle, rethrow CancellationException, narrow buffer-mutex scope --- .../zed/rainxch/core/data/di/SharedModule.kt | 2 +- .../data/telemetry/ProductTelemetryImpl.kt | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index fa02bea31..5dcc45d8a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -205,7 +205,7 @@ val coreModule = single { ProductTelemetryImpl( - backendApiClient = get(), + backendApiClientProvider = { get() }, tweaksRepository = get(), platform = get(), appScope = get(), diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt index a47f2e63d..f08c054ec 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt @@ -1,5 +1,6 @@ package zed.rainxch.core.data.telemetry +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -26,13 +27,20 @@ import kotlin.random.Random @OptIn(ExperimentalEncodingApi::class) class ProductTelemetryImpl( - private val backendApiClient: BackendApiClient, + // Lazy-resolved to break the BackendApiClient ↔ ProductTelemetry cycle + // in Koin: BackendApiClient injects ProductTelemetry for PROXY_USED + // emission, and we need BackendApiClient to ship batches. Both are + // singletons, so deferring resolution to first use lets Koin construct + // either one first. + private val backendApiClientProvider: () -> BackendApiClient, private val tweaksRepository: TweaksRepository, private val platform: Platform, private val appScope: CoroutineScope, private val logger: GitHubStoreLogger, ) : ProductTelemetry { + private val backendApiClient: BackendApiClient by lazy(backendApiClientProvider) + // Fresh per process. The contract from E6 is "ephemeral, reset per app // launch" — the JVM/Android process lifecycle scopes that for us. private val sessionId: String = Base64.UrlSafe @@ -50,7 +58,10 @@ class ProductTelemetryImpl( while (true) { delay(FLUSH_INTERVAL_MS) runCatching { flushInternal() } - .onFailure { logger.debug("Product telemetry flush error: ${it.message}") } + .onFailure { + if (it is CancellationException) throw it + logger.debug("Product telemetry flush error: ${it.message}") + } } } } @@ -70,12 +81,13 @@ class ProductTelemetryImpl( appVersion = BuildKonfig.VERSION_NAME, props = props.toJsonObject(), ) - bufferMutex.withLock { + val shouldFlush = bufferMutex.withLock { if (buffer.size >= MAX_BUFFER_SIZE) buffer.removeFirst() buffer.add(body) - if (buffer.size >= FLUSH_BATCH_THRESHOLD) { - appScope.launch { flushInternal() } - } + buffer.size >= FLUSH_BATCH_THRESHOLD + } + if (shouldFlush) { + appScope.launch { flushInternal() } } } } From e3f535fedae6ce1705fae262786694d297187e21 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:38:26 +0500 Subject: [PATCH 20/38] Stop telemetry POST from re-entering the buffer (recursion fix) --- .../zed/rainxch/core/data/network/BackendApiClient.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index af9124670..f2f874017 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -307,8 +307,11 @@ class BackendApiClient( } } + // Bypasses [safeCall]'s [firePerProxyOutcome] hook — otherwise every + // telemetry POST would emit another PROXY_USED event, which would + // re-enter the buffer, schedule another flush, and recurse. suspend fun postProductTelemetryEvents(events: List): Result = - safeCall { + try { val response = httpClient.post("telemetry/events") { contentType(ContentType.Application.Json) setBody(ProductTelemetryBatch(events)) @@ -321,6 +324,10 @@ class BackendApiClient( else -> Result.failure(BackendException(response.status.value)) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) } private inline fun safeCall(block: () -> Result): Result { From 736a23b6b05532e9cc6d897946e875e5e6a896d8 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:38:30 +0500 Subject: [PATCH 21/38] GithubStoreApp: per-foreground SESSION_DURATION (off main thread), UEH falls back to ThreadGroup --- .../rainxch/githubstore/app/GithubStoreApp.kt | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 93a4f4bb7..6b457eb2c 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -31,6 +31,7 @@ import zed.rainxch.core.domain.telemetry.ProductTelemetry import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.githubstore.app.di.initKoin +import kotlin.time.TimeSource class GithubStoreApp : Application() { private var packageEventReceiver: PackageEventReceiver? = null @@ -57,19 +58,37 @@ class GithubStoreApp : Application() { } private fun installSessionDurationObserver() { + // Per-foreground session — set on ON_START, consumed on ON_STOP. + // Distinct from ColdStart so each backgrounding reports its own + // duration rather than monotonically-growing process uptime. + var foregroundStart: TimeSource.Monotonic.ValueTimeMark? = null ProcessLifecycleOwner.get().lifecycle.addObserver( LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_STOP) { - val telemetry = get() - ColdStart.elapsedSeconds()?.let { seconds -> - telemetry.fire( - name = ProductTelemetryEvents.SESSION_DURATION, - props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), - ) + when (event) { + Lifecycle.Event.ON_START -> { + foregroundStart = TimeSource.Monotonic.markNow() + } + + Lifecycle.Event.ON_STOP -> { + val seconds = foregroundStart?.elapsedNow()?.inWholeSeconds + foregroundStart = null + val telemetry = get() + if (seconds != null) { + telemetry.fire( + name = ProductTelemetryEvents.SESSION_DURATION, + props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), + ) + } + // Off-main bounded flush — ON_STOP runs on the main + // thread, never block it on the network. + appScope.launch { + withTimeoutOrNull(2_000) { telemetry.flush() } + } + } + + else -> { + Unit } - // Best-effort flush — bounded so we never block the - // process indefinitely if the network is hostile. - runBlocking { withTimeoutOrNull(2_000) { telemetry.flush() } } } }, ) @@ -90,7 +109,14 @@ class GithubStoreApp : Application() { ) runBlocking { withTimeoutOrNull(500) { telemetry.flush() } } } - previous?.uncaughtException(thread, throwable) + // When `previous` is null we still need to surface the crash — + // delegate to the thread's group so the JVM's default printout + // and exit behaviour kicks in instead of swallowing silently. + if (previous != null) { + previous.uncaughtException(thread, throwable) + } else { + thread.threadGroup?.uncaughtException(thread, throwable) + } } } From 8d92687e96f3cb39a0d8ca46bb0ceceb332142e1 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:38:56 +0500 Subject: [PATCH 22/38] DesktopApp: crash-reporter installs first, deep-link forwarders skip APP_LAUNCHED, UEH falls back to ThreadGroup --- .../zed/rainxch/githubstore/DesktopApp.kt | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 5f5e7d742..7a88302c5 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -37,13 +37,13 @@ import kotlin.system.exitProcess private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L fun main(args: Array) { - ColdStart.markStart() - - // Install first so anything that blows up during Koin init or - // resource loading leaves a diagnosable trail on disk (see - // `CrashReporter.resolveLogDir` for the per-OS path). + // Install first so anything that blows up during ColdStart, Koin + // init, or resource loading leaves a diagnosable trail on disk + // (see `CrashReporter.resolveLogDir` for the per-OS path). CrashReporter.install() + ColdStart.markStart() + // Guard the AWT EventDispatchThread against a known Compose MP 1.10.x NPE // raised by the macOS accessibility bridge (see `A11yCrashGuard`). A11yCrashGuard.install() @@ -80,22 +80,25 @@ fun main(args: Array) { localization.setActiveLanguageTag(tag) } - // Fire app_launched once per process. No-op when consent is not Granted. - // The impl reads BuildKonfig.VERSION_NAME internally for the appVersion - // field on every fire(); we just supply the platform-specific version - // bucket via the props map. (BuildKonfig is internal to core/data so we - // can't read it from composeApp directly.) - GlobalContext.get().get().fire( - name = ProductTelemetryEvents.APP_LAUNCHED, - props = mapOf(ProductTelemetryProps.PLATFORM to desktopPlatformSlug()), - ) - val deepLinkArg = args.firstOrNull() if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) { + // Transient handoff process — exit before firing APP_LAUNCHED so + // forwarder invocations don't get counted as user-visible launches. exitProcess(0) } + // Fire app_launched once per *real* process (after the deep-link + // forwarder has had a chance to exit transient processes). No-op when + // consent is not Granted. The impl reads BuildKonfig.VERSION_NAME + // internally for the appVersion field on every fire(); we just supply + // the platform-specific version bucket via the props map. (BuildKonfig + // is internal to core/data so we can't read it from composeApp directly.) + GlobalContext.get().get().fire( + name = ProductTelemetryEvents.APP_LAUNCHED, + props = mapOf(ProductTelemetryProps.PLATFORM to desktopPlatformSlug()), + ) + DesktopDeepLink.registerUriSchemeIfNeeded() application { @@ -177,7 +180,14 @@ private fun installCrashTelemetryHandler() { ) runBlocking { withTimeoutOrNull(500) { telemetry.flush() } } } - previous?.uncaughtException(thread, throwable) + // Delegate to whatever the JVM had set up before us, falling back + // to the thread's group so the default print-and-exit behaviour + // still runs when no explicit handler is installed. + if (previous != null) { + previous.uncaughtException(thread, throwable) + } else { + thread.threadGroup?.uncaughtException(thread, throwable) + } } } From 07f83e626999a7460271aa94c139a131b818d731 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:00 +0500 Subject: [PATCH 23/38] UpdateCheckWorker: rethrow CancellationException instead of firing OPERATION_FAILED --- .../zed/rainxch/core/data/services/UpdateCheckWorker.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index 44a15b5ff..cc5724b8c 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -91,6 +91,11 @@ class UpdateCheckWorker( Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" } Result.success() + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + // Worker cancellation isn't a user-facing failure — propagate so + // structured concurrency does its job and we don't pollute the + // OPERATION_FAILED stream with cancel events. + throw e } catch (e: Exception) { Logger.e { "UpdateCheckWorker: Update check failed: ${e.message}" } if (runAttemptCount < 3) { From 87f8210b5929192192cf772afab14d7c46ce71b8 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:05 +0500 Subject: [PATCH 24/38] Replace nav 'from' string with closed DetailsFrom enum --- .../app/navigation/AppNavigation.kt | 22 +++++++++---------- .../app/navigation/GithubStoreGraph.kt | 19 ++++++++++++---- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index e5a7807c8..d0fb87ac1 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -90,7 +90,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, - from = "category", + from = DetailsFrom.Category, ), ) }, @@ -113,7 +113,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, - from = "search", + from = DetailsFrom.Search, ), ) }, @@ -122,7 +122,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( owner = owner, repo = repo, - from = "link", + from = DetailsFrom.Link, ), ) }, @@ -146,7 +146,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, - from = "link", + from = DetailsFrom.Link, ), ) }, @@ -164,7 +164,7 @@ fun AppNavigation( args.owner, args.repo, args.isComingFromUpdate, - args.from, + args.from.slug, ) }, ) @@ -180,7 +180,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, - from = "link", + from = DetailsFrom.Link, ), ) }, @@ -212,7 +212,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = it, - from = "library", + from = DetailsFrom.Library, ), ) }, @@ -235,7 +235,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, - from = "library", + from = DetailsFrom.Library, ), ) }, @@ -289,7 +289,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, - from = "library", + from = DetailsFrom.Library, ), ) }, @@ -336,7 +336,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( repositoryId = repoId, isComingFromUpdate = true, - from = "library", + from = DetailsFrom.Library, ), ) }, @@ -358,7 +358,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( repositoryId = repoId, isComingFromUpdate = true, - from = "library", + from = DetailsFrom.Library, ), ) }, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt index 377a80009..152a86cce 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt @@ -2,6 +2,18 @@ package zed.rainxch.githubstore.app.navigation import kotlinx.serialization.Serializable +// E6 telemetry: where a Details nav originated. Closed set so the +// FROM prop on DETAILS_VIEWED can never carry an arbitrary string. +@Serializable +enum class DetailsFrom( + val slug: String, +) { + Search("search"), + Category("category"), + Library("library"), + Link("link"), +} + @Serializable sealed interface GithubStoreGraph { @Serializable @@ -19,10 +31,9 @@ sealed interface GithubStoreGraph { val owner: String = "", val repo: String = "", val isComingFromUpdate: Boolean = false, - // E6 telemetry: where this nav originated. Categorical only — - // "search" / "category" / "library" / "link". Drives the FROM - // prop on DETAILS_VIEWED. - val from: String = "link", + // Drives the FROM prop on DETAILS_VIEWED. Typed so callers + // can't accidentally introduce off-allowlist values. + val from: DetailsFrom = DetailsFrom.Link, ) : GithubStoreGraph @Serializable From a89d2d585819a527b1513806fe2da1840da1c8de Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:10 +0500 Subject: [PATCH 25/38] Fire DETAILS_VIEWED only once per ViewModel instance, not on every Retry --- .../details/presentation/DetailsViewModel.kt | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index b65ac4d21..fcb31e827 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -118,10 +118,19 @@ class DetailsViewModel( private val downloadOrchestrator: DownloadOrchestrator, private val telemetryRepository: TelemetryRepository, private val externalImportRepository: ExternalImportRepository, + // String slug rather than the enum: the closed `DetailsFrom` lives in + // composeApp's nav module, which feature/details/presentation + // intentionally doesn't depend on. The enum's `slug` is mapped at the + // navigation boundary so this module never sees a free-form String + // directly from a UI caller. private val from: String, private val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry, ) : ViewModel() { private var hasLoadedInitialData = false + // Distinct from hasLoadedInitialData: Retry resets that flag so the + // payload reloads, but DETAILS_VIEWED is a per-instance signal — we + // only care it fired once per visit, not per data refresh. + private var detailsViewedFired = false private var currentDownloadJob: Job? = null private var currentAssetName: String? = null private var aboutTranslationJob: Job? = null @@ -2396,13 +2405,16 @@ class DetailsViewModel( ) telemetryRepository.recordRepoViewed(repo.id) - productTelemetry.fire( - name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.DETAILS_VIEWED, - props = - mapOf( - zed.rainxch.core.domain.telemetry.ProductTelemetryProps.FROM to from, - ), - ) + if (!detailsViewedFired) { + detailsViewedFired = true + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.DETAILS_VIEWED, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.FROM to from, + ), + ) + } observeInstalledApp(repo.id) } catch (e: RateLimitException) { From 36a10a87987d0eb8ed330e14b30dc09e31c5660b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:14 +0500 Subject: [PATCH 26/38] Hide consent sheet during initial load instead of treating it as 'not answered' --- .../rainxch/home/presentation/HomeConsentGateViewModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt index eeb68bcaa..f4c01297e 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt @@ -13,11 +13,14 @@ class HomeConsentGateViewModel( private val tweaksRepository: TweaksRepository, ) : ViewModel() { - val consent: StateFlow = + // Nullable so a fresh subscription doesn't briefly look like + // "user hasn't answered" while the persisted value is still being + // hydrated. HomeRoot only shows the sheet on an explicit NotYetAsked. + val consent: StateFlow = tweaksRepository.getProductTelemetryConsent().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), - initialValue = ProductTelemetryConsent.NotYetAsked, + initialValue = null, ) fun grant() { From 3058076c048111dd839d9ab7075c342415527efe Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:19 +0500 Subject: [PATCH 27/38] Pin TELEMETRY_SCHEMA_URL to a release commit so the consent sheet matches the running build --- .../kotlin/zed/rainxch/home/presentation/HomeRoot.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index 2759c5e4f..1d5506014 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -108,8 +108,14 @@ import zed.rainxch.home.presentation.locals.LocalHomeTopBarLiquid import zed.rainxch.home.presentation.utils.displayText import zed.rainxch.home.presentation.utils.icon +// Pinned to the client commit that ships this release so "View what we +// collect" always reflects the schema actually used by the running app. +// Bump this hash whenever ProductTelemetryEvents.kt changes — and only +// when shipping a release that includes the new entry. private const val TELEMETRY_SCHEMA_URL = - "https://github.com/OpenHub-Store/backend/blob/main/src/main/kotlin/zed/rainxch/githubstore/telemetry/TelemetryEvent.kt" + "https://github.com/OpenHub-Store/GitHub-Store/blob/" + + "42d0319235f8c973a732bd299965208d7fe95ec4" + + "/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt" @Composable fun HomeRoot( From cb22d98090b4b1d5b83dea5c72a5c31d7aa83e01 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:26 +0500 Subject: [PATCH 28/38] Fire IMPORT_AUTO_LINKED, switch IMPORT_MATCH_ATTEMPTED to TelemetryBuckets.confidence, drop dead emitPermissionOutcome --- .../repository/ExternalImportRepositoryImpl.kt | 11 +++-------- .../presentation/import/ExternalImportViewModel.kt | 14 +++++++------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 38dbcae0d..d31130495 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -28,6 +28,7 @@ import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.telemetry.ProductTelemetry import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.ExternalDecisionSnapshot @@ -258,7 +259,8 @@ class ExternalImportRepositoryImpl( props = mapOf( ProductTelemetryProps.STRATEGY to source.telemetryStrategy(), - ProductTelemetryProps.CONFIDENCE_BUCKET to bucketConfidence(top.confidence), + ProductTelemetryProps.CONFIDENCE_BUCKET to + TelemetryBuckets.confidence(top.confidence.toFloat()), ), ) } @@ -553,13 +555,6 @@ class ExternalImportRepositoryImpl( else -> ">5000" } - private fun bucketConfidence(c: Double): String = - when { - c < 0.5 -> "<0.5" - c < 0.85 -> "0.5-0.85" - else -> ">=0.85" - } - private fun bucketSeedRowsAdded(n: Int): String = when { n <= 0 -> "0" diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 56116b736..f2988905d 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -110,13 +110,11 @@ class ExternalImportViewModel( is ExternalImportAction.OnPermissionGranted -> { _state.update { it.copy(isPermissionDenied = false) } - emitPermissionOutcome(granted = true, sdkInt = action.sdkInt) startScanIfIdle(force = true) } is ExternalImportAction.OnPermissionDenied -> { _state.update { it.copy(isPermissionDenied = true) } - emitPermissionOutcome(granted = false, sdkInt = action.sdkInt) startScanIfIdle(force = true) } @@ -223,6 +221,13 @@ class ExternalImportViewModel( val autoLinked = autoMaterialize(matches) val autoLinkedPackages = autoLinked.toSet() + if (autoLinked.isNotEmpty()) { + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_AUTO_LINKED, + props = mapOf(ProductTelemetryProps.COUNT to bucketCount(autoLinked.size)), + ) + } + val reviewCandidates = candidates.filter { it.packageName !in autoLinkedPackages } val reviewMatchesByPkg = @@ -687,11 +692,6 @@ class ExternalImportViewModel( } } - private fun emitPermissionOutcome(granted: Boolean, sdkInt: Int?) { - // Permission-outcome telemetry intentionally dropped at the E6 - // boundary — the new schema doesn't track it. - } - private fun bucketCount(count: Int): String = when { count <= 0 -> "0" From de12647480d624bbd71f81fdbb8e9a4f48d58a1d Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:30 +0500 Subject: [PATCH 29/38] Bucket OPERATION_FAILED error codes by category instead of class simpleName --- .../services/DefaultDownloadOrchestrator.kt | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt index 46630c99d..71688272f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt @@ -572,11 +572,42 @@ class DefaultDownloadOrchestrator( props = mapOf( ProductTelemetryProps.OP to op, - ProductTelemetryProps.ERROR_CODE to (throwable?.let { it::class.simpleName } ?: "unknown"), + ProductTelemetryProps.ERROR_CODE to mapExceptionToBucket(throwable), ), ) } + // Categorical buckets — the schema requires stable, low-cardinality + // values so dashboards can aggregate. Using throwable simple class + // names directly would explode the dimension when callers are + // platform-specific (e.g. SocketTimeoutException vs HttpRequestTimeoutException). + private fun mapExceptionToBucket(throwable: Throwable?): String { + if (throwable == null) return "unknown" + val name = throwable::class.simpleName.orEmpty() + val message = throwable.message.orEmpty() + return when { + name.contains("Cancellation", ignoreCase = true) -> "cancelled" + name.contains("Timeout", ignoreCase = true) || + message.contains("timeout", ignoreCase = true) -> "timeout" + name.contains("UnknownHost", ignoreCase = true) || + name.contains("DnsResolveException", ignoreCase = true) -> "dns" + name.contains("Ssl", ignoreCase = true) || + name.contains("Certificate", ignoreCase = true) -> "tls" + name.contains("HttpRetry", ignoreCase = true) || + name.contains("ConnectException", ignoreCase = true) || + name.contains("Socket", ignoreCase = true) -> "network" + name.contains("Serialization", ignoreCase = true) || + name.contains("Json", ignoreCase = true) || + name.contains("Parse", ignoreCase = true) -> "parse" + name.contains("FileNotFound", ignoreCase = true) || + name.contains("Io", ignoreCase = true) -> "io" + name.contains("SecurityException", ignoreCase = true) || + name.contains("Auth", ignoreCase = true) || + name.contains("Permission", ignoreCase = true) -> "auth" + else -> "unknown" + } + } + private fun generateId(): String = // Cheap unique id without pulling in java.util.UUID — works // on all KMP targets. Collisions are statistically negligible From fc99e643a286ab37466cb704ee612b4c5da8e09c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:35 +0500 Subject: [PATCH 30/38] Make UPDATE_INSTALLED and PROXY_CONFIGURED telemetry best-effort so they can't break persistence --- .../repository/InstalledAppsRepositoryImpl.kt | 4 ++- .../data/repository/ProxyRepositoryImpl.kt | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index e85f81c99..f285aef5b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -455,7 +455,9 @@ class InstalledAppsRepositoryImpl( ), ) - productTelemetry.fire(name = ProductTelemetryEvents.UPDATE_INSTALLED) + // Best-effort: telemetry must never fail an update persistence. + runCatching { productTelemetry.fire(name = ProductTelemetryEvents.UPDATE_INSTALLED) } + .onFailure { Logger.d(it) { "UPDATE_INSTALLED telemetry failed" } } } override suspend fun updateApp(app: InstalledApp) { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt index 066d9a9b1..d82186b48 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -186,18 +186,21 @@ class ProxyRepositoryImpl( } ProxyManager.setConfig(scope, config) - productTelemetry.fire( - name = ProductTelemetryEvents.PROXY_CONFIGURED, - props = - mapOf( - ProductTelemetryProps.TYPE to - when (config) { - is ProxyConfig.Http -> "http" - is ProxyConfig.Socks -> "socks5" - ProxyConfig.None, ProxyConfig.System -> "none" - }, - ), - ) + // Best-effort: a save shouldn't fail because telemetry hiccupped. + runCatching { + productTelemetry.fire( + name = ProductTelemetryEvents.PROXY_CONFIGURED, + props = + mapOf( + ProductTelemetryProps.TYPE to + when (config) { + is ProxyConfig.Http -> "http" + is ProxyConfig.Socks -> "socks5" + ProxyConfig.None, ProxyConfig.System -> "none" + }, + ), + ) + }.onFailure { logger.debug("PROXY_CONFIGURED telemetry failed: ${it.message}") } } private fun writeOrRemove( From 4fb5e87ee9caef72c445d0725f304463409804ba Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:39 +0500 Subject: [PATCH 31/38] Soften privacy copy: 'personal or account identifiers' vs 'any identifying information' --- .../src/commonMain/composeResources/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index bd90713d4..a112d6b55 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -690,9 +690,9 @@ Generate a new anonymous ID, severing the link to past telemetry. Analytics ID reset Anonymous usage data - Help improve GitHub Store with anonymous, aggregate metrics. We never collect search queries, repo names, or any identifying information. Schema is open source. + Help improve GitHub Store with anonymous, aggregate metrics. We never collect search queries, repo names, or personal or account identifiers. Schema is open source. Help improve GitHub Store - We collect anonymous, aggregate usage data to find bugs and improve performance. We never collect your repos, search queries, or any identifying information. The full schema and collection code are open source. + We collect anonymous, aggregate usage data to find bugs and improve performance. We never collect your repos, search queries, or personal or account identifiers. The full schema and collection code are open source. Got it No thanks View what we collect From 762de6910137c03fb9511c1e95103fe8e4552739 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:44 +0500 Subject: [PATCH 32/38] TelemetryBuckets.resultCount: bucket negative input as 'invalid' instead of '1-5' --- .../kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt index 43538490d..b916f3ae9 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt @@ -9,6 +9,7 @@ object TelemetryBuckets { } fun resultCount(n: Int): String = when { + n < 0 -> "invalid" n == 0 -> "0" n <= 5 -> "1-5" n <= 20 -> "6-20" From c7076043ce64fa85227d6a4db2b0b367128af5a3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 11:39:50 +0500 Subject: [PATCH 33/38] Add language tags to E6 handoff fenced code blocks --- roadmap/E6_CLIENT_HANDOFF.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/roadmap/E6_CLIENT_HANDOFF.md b/roadmap/E6_CLIENT_HANDOFF.md index e854ab79e..d1bbe4c5b 100644 --- a/roadmap/E6_CLIENT_HANDOFF.md +++ b/roadmap/E6_CLIENT_HANDOFF.md @@ -8,7 +8,7 @@ This document is the punch list to take the client to GA. Read top to bottom. ## 0. What already exists on this branch (do NOT redo) -``` +```text core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ ProductTelemetry.kt // interface: fire(name, props) + flush() ProductTelemetryConsent.kt // enum: NotYetAsked / Granted / Denied @@ -49,7 +49,7 @@ composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt **Goal:** user can manually toggle product telemetry from the existing tweaks/privacy screen. **Find the existing legacy-telemetry Switch:** -``` +```bash grep -rn 'OnTelemetryToggled\|isTelemetryEnabled' feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ ``` @@ -416,7 +416,7 @@ After all merge: open PR `feature/e6-telemetry → main`, ship a new app release ## 9. Backend contract recap -``` +```text POST https://api.github-store.org/v1/telemetry/events Body: { From e00426c38fc60b3608c7a75028db93207db1201c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 13:46:27 +0500 Subject: [PATCH 34/38] Consent sheet: tighter button spacing, swap to inline schema view via internal state --- .../ProductTelemetryConsentSheet.kt | 85 ++++++++++++++----- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt index 0830da516..d6b1d1a74 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt @@ -1,6 +1,5 @@ package zed.rainxch.home.presentation.components -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -14,6 +13,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource @@ -29,33 +32,71 @@ import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_vie fun ProductTelemetryConsentSheet( onGrant: () -> Unit, onDeny: () -> Unit, - onViewSchema: () -> Unit, + onViewSchemaSource: () -> Unit, onDismiss: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // Scoped to the sheet's lifetime — closing and re-opening the sheet + // resets to the consent panel, which matches what a "first launch" + // consent dialog should do. + var showSchema by rememberSaveable { mutableStateOf(false) } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = stringResource(Res.string.product_telemetry_sheet_title), - style = MaterialTheme.typography.headlineSmall, + if (showSchema) { + PrivacyCollectedView( + onBack = { showSchema = false }, + onViewSource = onViewSchemaSource, ) - Text( - text = stringResource(Res.string.product_telemetry_sheet_body), - style = MaterialTheme.typography.bodyMedium, + } else { + ConsentPanel( + onGrant = onGrant, + onDeny = onDeny, + onViewSchema = { showSchema = true }, ) - Button(onClick = onGrant, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(Res.string.product_telemetry_sheet_grant)) - } - TextButton(onClick = onDeny, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(Res.string.product_telemetry_sheet_deny)) - } - TextButton(onClick = onViewSchema, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(Res.string.product_telemetry_sheet_view_schema)) - } - Spacer(Modifier.height(8.dp)) } } } + +@Composable +private fun ConsentPanel( + onGrant: () -> Unit, + onDeny: () -> Unit, + onViewSchema: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + ) { + Text( + text = stringResource(Res.string.product_telemetry_sheet_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.product_telemetry_sheet_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = onGrant, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.product_telemetry_sheet_grant)) + } + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = onDeny, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.product_telemetry_sheet_deny)) + } + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = onViewSchema, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.product_telemetry_sheet_view_schema)) + } + Spacer(Modifier.height(8.dp)) + } +} From 38e747682f208c4387292b896f906c402f8d2ba2 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 13:46:39 +0500 Subject: [PATCH 35/38] Add inline 'What we collect' view enumerating every actually-fired event --- .../composeResources/values/strings.xml | 36 +++ .../zed/rainxch/home/presentation/HomeRoot.kt | 2 +- .../components/PrivacyCollectedView.kt | 266 ++++++++++++++++++ 3 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index a112d6b55..eedcf2cec 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -695,6 +695,42 @@ We collect anonymous, aggregate usage data to find bugs and improve performance. We never collect your repos, search queries, or personal or account identifiers. The full schema and collection code are open source. Got it No thanks + What we collect + All values below are anonymous and bucketed. Each event carries only a random session ID that resets every time you open the app. + Back + View source on GitHub + Session + When you open the app, plus your platform (Android, macOS, Windows, Linux) and app version. + How many seconds the app stayed in the foreground when it goes to the background. + Discovery + That you ran a search, plus a bucket of how many results came back. We never see the query text. + That you opened a repo\'s details page, plus where you came from (search, category, library, link). We never see the repo name. + Library import (Android only) + That a scan for installable apps from outside the store started. + That the scan finished, plus how long it took (bucket) and how many candidates were found (bucket). + That we tried to match a candidate to a GitHub repo, plus the strategy (manifest, search, fingerprint) and a confidence bucket (low, medium, high). + A bucketed count of apps that auto-linked successfully. + A bucketed count of apps you manually linked. + A bucketed count of apps you skipped. + Performance + How long the app took to render its first frame on launch (bucket). + How long each screen took to render its first non-skeleton frame, plus the screen name (home, details, library, settings, search). + That a cache lookup hit, plus the cache name (details, icons, readmes, search results). + That a cache lookup missed, plus the cache name. + Reliability + That a crash happened, plus a bucketed category (data loss, install fail, version detect, other) and platform. We never send raw exception messages or stack traces. + That a download, install, update, or fetch failed, plus a bucketed error category (network, io, timeout, auth, parse, unknown). + Network + That you configured a proxy, plus the type (HTTP, SOCKS5, none). + That an outbound request used your configured proxy, plus whether it succeeded (true / false). + Updates + That an existing app was successfully updated to a new version. + We never collect + Search queries + Repo names, owner names, or package names + File paths, stack traces, or exception messages + GitHub usernames, emails, or any account-identifying information + Anything that could personally identify you View what we collect diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index 1d5506014..d61f27bde 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -150,7 +150,7 @@ fun HomeRoot( ProductTelemetryConsentSheet( onGrant = consentGate::grant, onDeny = consentGate::deny, - onViewSchema = { + onViewSchemaSource = { runCatching { uriHandler.openUri(TELEMETRY_SCHEMA_URL) } diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt new file mode 100644 index 000000000..2524b5953 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt @@ -0,0 +1,266 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_app_launched_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_back +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_cache_hit_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_cache_miss_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_cold_start_ms_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_crash_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_details_viewed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_first_paint_ms_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_auto_linked_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_manually_linked_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_match_attempted_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_scan_completed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_scan_started_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_skipped_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_intro +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_account +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_paths +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_personal +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_repo_identifiers +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_search_queries +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_title +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_operation_failed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_proxy_configured_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_proxy_used_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_search_executed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_discovery +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_library +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_network +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_performance +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_reliability +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_session +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_updates +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_session_duration_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_title +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_update_installed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_view_source + +private data class CollectedEvent( + val name: String, + val description: StringResource, +) + +private data class CollectedSection( + val title: StringResource, + val events: List, +) + +private val sections = + listOf( + CollectedSection( + title = Res.string.privacy_collected_section_session, + events = + listOf( + CollectedEvent("app_launched", Res.string.privacy_collected_app_launched_desc), + CollectedEvent("session_duration", Res.string.privacy_collected_session_duration_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_discovery, + events = + listOf( + CollectedEvent("search_executed", Res.string.privacy_collected_search_executed_desc), + CollectedEvent("details_viewed", Res.string.privacy_collected_details_viewed_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_library, + events = + listOf( + CollectedEvent("import_scan_started", Res.string.privacy_collected_import_scan_started_desc), + CollectedEvent("import_scan_completed", Res.string.privacy_collected_import_scan_completed_desc), + CollectedEvent("import_match_attempted", Res.string.privacy_collected_import_match_attempted_desc), + CollectedEvent("import_auto_linked", Res.string.privacy_collected_import_auto_linked_desc), + CollectedEvent("import_manually_linked", Res.string.privacy_collected_import_manually_linked_desc), + CollectedEvent("import_skipped", Res.string.privacy_collected_import_skipped_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_performance, + events = + listOf( + CollectedEvent("cold_start_ms", Res.string.privacy_collected_cold_start_ms_desc), + CollectedEvent("first_paint_ms", Res.string.privacy_collected_first_paint_ms_desc), + CollectedEvent("cache_hit", Res.string.privacy_collected_cache_hit_desc), + CollectedEvent("cache_miss", Res.string.privacy_collected_cache_miss_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_reliability, + events = + listOf( + CollectedEvent("crash", Res.string.privacy_collected_crash_desc), + CollectedEvent("operation_failed", Res.string.privacy_collected_operation_failed_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_network, + events = + listOf( + CollectedEvent("proxy_configured", Res.string.privacy_collected_proxy_configured_desc), + CollectedEvent("proxy_used", Res.string.privacy_collected_proxy_used_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_updates, + events = + listOf( + CollectedEvent("update_installed", Res.string.privacy_collected_update_installed_desc), + ), + ), + ) + +private val neverCollected = + listOf( + Res.string.privacy_collected_never_search_queries, + Res.string.privacy_collected_never_repo_identifiers, + Res.string.privacy_collected_never_paths, + Res.string.privacy_collected_never_account, + Res.string.privacy_collected_never_personal, + ) + +@Composable +fun PrivacyCollectedView( + onBack: () -> Unit, + onViewSource: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.privacy_collected_back), + ) + } + Spacer(Modifier.size(8.dp)) + Text( + text = stringResource(Res.string.privacy_collected_title), + style = MaterialTheme.typography.headlineSmall, + ) + } + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = 24.dp, + end = 24.dp, + top = 8.dp, + bottom = 24.dp, + ), + ) { + item { + Text( + text = stringResource(Res.string.privacy_collected_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + } + sections.forEach { section -> + item { SectionHeader(stringResource(section.title)) } + items(section.events) { event -> + EventRow(event.name, stringResource(event.description)) + } + } + item { + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(Res.string.privacy_collected_never_title)) + } + items(neverCollected) { res -> + BulletRow(stringResource(res)) + } + item { + Spacer(Modifier.height(16.dp)) + TextButton( + onClick = onViewSource, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.privacy_collected_view_source)) + } + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Spacer(Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun EventRow(name: String, description: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun BulletRow(text: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} From ea71b5edfb68c49ce309b9c9848f15d7c50a0fb1 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 14:35:06 +0500 Subject: [PATCH 36/38] Stop sending hashed search queries to the legacy /v1/events pipeline --- .../zed/rainxch/core/data/dto/EventRequest.kt | 1 - .../data/repository/TelemetryRepositoryImpl.kt | 9 ++++----- .../zed/rainxch/core/data/utils/QueryHash.kt | 17 ----------------- .../domain/repository/TelemetryRepository.kt | 2 +- .../search/presentation/SearchViewModel.kt | 1 - 5 files changed, 5 insertions(+), 25 deletions(-) delete mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt index 1ec3b4628..3ff0b1c16 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt @@ -9,7 +9,6 @@ data class EventRequest( val appVersion: String? = null, val eventType: String, val repoId: Long? = null, - val queryHash: String? = null, val resultCount: Int? = null, val success: Boolean? = null, val errorCode: String? = null, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt index a36428895..9a720a33f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.withContext import zed.rainxch.core.data.BuildKonfig import zed.rainxch.core.data.dto.EventRequest import zed.rainxch.core.data.network.BackendApiClient -import zed.rainxch.core.data.utils.hashQuery import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.DeviceIdentityRepository @@ -42,10 +41,12 @@ class TelemetryRepositoryImpl( // ── recording (fire-and-forget, guarded by opt-in) ────────────── - override fun recordSearchPerformed(query: String, resultCount: Int) { + override fun recordSearchPerformed(resultCount: Int) { + // Query intentionally dropped: a 16-hex SHA-256 prefix of a + // lowercased repo-name search is rainbow-table-trivial. The + // count-only signal here is what survives. enqueue( eventType = "search_performed", - queryHash = hashQuery(query), resultCount = resultCount, ) } @@ -240,7 +241,6 @@ class TelemetryRepositoryImpl( private fun enqueue( eventType: String, repoId: Long? = null, - queryHash: String? = null, resultCount: Int? = null, success: Boolean? = null, errorCode: String? = null, @@ -256,7 +256,6 @@ class TelemetryRepositoryImpl( appVersion = BuildKonfig.VERSION_NAME, eventType = eventType, repoId = repoId, - queryHash = queryHash, resultCount = resultCount, success = success, errorCode = errorCode, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt deleted file mode 100644 index bd43067b2..000000000 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt +++ /dev/null @@ -1,17 +0,0 @@ -package zed.rainxch.core.data.utils - -import java.security.MessageDigest - -fun hashQuery(query: String): String { - val normalized = query.trim().lowercase() - if (normalized.isEmpty()) return "" - val digest = MessageDigest.getInstance("SHA-256").digest(normalized.encodeToByteArray()) - val hex = buildString(digest.size * 2) { - for (byte in digest) { - val v = byte.toInt() and 0xff - if (v < 0x10) append('0') - append(v.toString(16)) - } - } - return hex.take(16) -} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt index d5e394d95..a1d7a305b 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt @@ -1,7 +1,7 @@ package zed.rainxch.core.domain.repository interface TelemetryRepository { - fun recordSearchPerformed(query: String, resultCount: Int) + fun recordSearchPerformed(resultCount: Int) fun recordSearchResultClicked(repoId: Long) diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 8234fb71f..f8082e065 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -418,7 +418,6 @@ class SearchViewModel( if (isInitial) { val resultCount = _state.value.repositories.size telemetryRepository.recordSearchPerformed( - query = query, resultCount = resultCount, ) productTelemetry.fire( From 757e68ed3275d1cbc0166af62f448c5208ffd524 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 14:46:18 +0500 Subject: [PATCH 37/38] Drop redundant app_launched props (backend strips them; platform/version ride at top-level) --- .../rainxch/githubstore/app/GithubStoreApp.kt | 17 +++++------------ .../zed/rainxch/githubstore/DesktopApp.kt | 13 +++++-------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 6b457eb2c..1f18bd0ff 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -121,18 +121,11 @@ class GithubStoreApp : Application() { } private fun fireAppLaunched() { - get().fire( - name = ProductTelemetryEvents.APP_LAUNCHED, - props = - mapOf( - ProductTelemetryProps.PLATFORM to "android", - ProductTelemetryProps.VERSION to - packageManager - .getPackageInfo(packageName, 0) - .versionName - .orEmpty(), - ), - ) + // platform + appVersion ride along on the event's top-level + // columns (set by ProductTelemetryImpl). The backend's PropsSchema + // allows no props on app_launched and silently strips anything + // we put here, so we don't. + get().fire(name = ProductTelemetryEvents.APP_LAUNCHED) } private fun scheduleInitialExternalScan() { diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 7a88302c5..f22c4c7e3 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -90,14 +90,11 @@ fun main(args: Array) { // Fire app_launched once per *real* process (after the deep-link // forwarder has had a chance to exit transient processes). No-op when - // consent is not Granted. The impl reads BuildKonfig.VERSION_NAME - // internally for the appVersion field on every fire(); we just supply - // the platform-specific version bucket via the props map. (BuildKonfig - // is internal to core/data so we can't read it from composeApp directly.) - GlobalContext.get().get().fire( - name = ProductTelemetryEvents.APP_LAUNCHED, - props = mapOf(ProductTelemetryProps.PLATFORM to desktopPlatformSlug()), - ) + // consent is not Granted. platform + appVersion are populated by + // ProductTelemetryImpl as top-level event columns; the backend's + // PropsSchema allows zero props on app_launched and silently strips + // anything we put in the props bag, so we don't. + GlobalContext.get().get().fire(name = ProductTelemetryEvents.APP_LAUNCHED) DesktopDeepLink.registerUriSchemeIfNeeded() From 7a09872803f2856e7a9e0b79c869bee31ff69387 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 14:46:26 +0500 Subject: [PATCH 38/38] Acknowledge server-side processing in privacy view (search-misses, HTTP logs) --- .../composeResources/values/strings.xml | 14 ++++++--- .../components/PrivacyCollectedView.kt | 31 +++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index eedcf2cec..af1ead197 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -690,9 +690,9 @@ Generate a new anonymous ID, severing the link to past telemetry. Analytics ID reset Anonymous usage data - Help improve GitHub Store with anonymous, aggregate metrics. We never collect search queries, repo names, or personal or account identifiers. Schema is open source. + Help improve GitHub Store with anonymous, aggregate metrics tied only to a session ID that resets every launch. The full list of events is open source — see "View what we collect" on the consent screen. Help improve GitHub Store - We collect anonymous, aggregate usage data to find bugs and improve performance. We never collect your repos, search queries, or personal or account identifiers. The full schema and collection code are open source. + We collect anonymous, bucketed usage data to find bugs and improve performance. Every event is tied only to a session ID that resets each time you open the app. Tap "View what we collect" below for the precise list. Got it No thanks What we collect @@ -725,12 +725,18 @@ That an outbound request used your configured proxy, plus whether it succeeded (true / false). Updates That an existing app was successfully updated to a new version. + Server-side processing + A few signals are derived by our backend from normal API requests, independent of your telemetry choices: + Searches that return fewer than 5 results have a one-way 64-bit hash of the query recorded for ranking quality. Raw query text is never stored. + HTTP request logs are size-bounded. They never contain your IP (we sit behind a CDN), query strings, request bodies, or auth headers. We never collect - Search queries - Repo names, owner names, or package names + Raw search query text + Repo names, owner names, or package names in analytics events File paths, stack traces, or exception messages GitHub usernames, emails, or any account-identifying information Anything that could personally identify you + Your GitHub OAuth or PAT tokens (forwarded once for the request, never persisted or logged) + Your IP address (CDN terminates the connection; the backend never sees it) View what we collect diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt index 2524b5953..ea962ff45 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt @@ -42,11 +42,13 @@ import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_sc import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_skipped_desc import zed.rainxch.githubstore.core.presentation.res.privacy_collected_intro import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_account +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_ip import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_paths import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_personal +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_raw_queries import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_repo_identifiers -import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_search_queries import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_title +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_tokens import zed.rainxch.githubstore.core.presentation.res.privacy_collected_operation_failed_desc import zed.rainxch.githubstore.core.presentation.res.privacy_collected_proxy_configured_desc import zed.rainxch.githubstore.core.presentation.res.privacy_collected_proxy_used_desc @@ -56,8 +58,12 @@ import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_l import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_network import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_performance import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_reliability +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_server_side import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_session import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_updates +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_server_side_intro +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_server_side_logs +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_server_side_search_misses import zed.rainxch.githubstore.core.presentation.res.privacy_collected_session_duration_desc import zed.rainxch.githubstore.core.presentation.res.privacy_collected_title import zed.rainxch.githubstore.core.presentation.res.privacy_collected_update_installed_desc @@ -140,13 +146,21 @@ private val sections = private val neverCollected = listOf( - Res.string.privacy_collected_never_search_queries, + Res.string.privacy_collected_never_raw_queries, Res.string.privacy_collected_never_repo_identifiers, Res.string.privacy_collected_never_paths, Res.string.privacy_collected_never_account, + Res.string.privacy_collected_never_tokens, + Res.string.privacy_collected_never_ip, Res.string.privacy_collected_never_personal, ) +private val serverSideItems = + listOf( + Res.string.privacy_collected_server_side_search_misses, + Res.string.privacy_collected_server_side_logs, + ) + @Composable fun PrivacyCollectedView( onBack: () -> Unit, @@ -193,6 +207,19 @@ fun PrivacyCollectedView( EventRow(event.name, stringResource(event.description)) } } + item { + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(Res.string.privacy_collected_section_server_side)) + Text( + text = stringResource(Res.string.privacy_collected_server_side_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + } + items(serverSideItems) { res -> + BulletRow(stringResource(res)) + } item { Spacer(Modifier.height(8.dp)) SectionHeader(stringResource(Res.string.privacy_collected_never_title))