diff --git a/.github/workflows/sentry-release-artifacts.yml b/.github/workflows/sentry-release-artifacts.yml index 90efc18b..bf395d42 100644 --- a/.github/workflows/sentry-release-artifacts.yml +++ b/.github/workflows/sentry-release-artifacts.yml @@ -396,6 +396,11 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + # Feed native Android BuildConfig only; keep VITE_* unset here so the + # Android WebView Sentry client stays disabled until CSP/sourcemap + # wiring is handled explicitly. + SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_ENVIRONMENT: ${{ github.event.inputs.environment || 'production' }} run: | export NDK_HOME="$ANDROID_HOME/ndk/27.0.12077973" export RANLIB="$ANDROID_HOME/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ranlib" diff --git a/SENTRY.md b/SENTRY.md index 36230de1..970800d8 100644 --- a/SENTRY.md +++ b/SENTRY.md @@ -24,6 +24,31 @@ already-running frontend client for the current window and apply to Rust crash monitoring on restart. Re-enabling after a same-window opt-out flips the frontend client back on without calling `Sentry.init()` a second time. +Android JVM setup lives in +`src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt`. +The app manifest removes Sentry's Android `ContentProvider` auto-init path, so +adding the runtime SDK does not start Sentry before application code runs. +`CharmApplication` initializes `SentryAndroid` only when all conditions are true: + +- `SENTRY_DSN` or `VITE_SENTRY_DSN` was present at Android build time. +- `observability.json` in Android app storage has + `observability.state.sentryEnabled: true`. + +The Android runtime initializer watches the same store and gates `beforeSend` +and `beforeBreadcrumb` from an in-memory consent flag, keeps `sendDefaultPii` +off, disables Android auto-session tracking, and leaves performance tracing +unconfigured. This initial Android coverage is therefore scoped to Sentry +Android's JVM crash and ANR capture after opt-in. Same-session opt-out prevents +new captures through the callback gates, but events already accepted by the SDK +can still be retried or delivered from Sentry's queue. Opting back in during the +same session resumes events only if Sentry was already initialized at startup. If +Android starts with consent disabled, first opt-in still requires an app restart +because `SentryAndroid.init` only runs from `Application.onCreate`. +NDK/native crash capture, Android Mobile Vitals, and performance transactions +remain disabled until Charm has the corresponding SDK integration and a native +consent bridge that can shut down or reconfigure the SDK immediately when a user +opts out. + When Sentry consent is enabled, Rust installs a Sentry `tracing` layer after Sentry initialization, even if `logsEnabled` is false at startup, so same-session log opt-in can start native tracing without a restart. The layer is @@ -41,7 +66,8 @@ drops debug logs outside debug builds. Use these variables for local or release builds: - `VITE_SENTRY_DSN`: public frontend DSN. -- `SENTRY_DSN`: Rust/native DSN. +- `SENTRY_DSN`: Rust/native DSN. Android also embeds this at build time for + JVM crash and ANR coverage. - `VITE_SENTRY_ENVIRONMENT` / `SENTRY_ENVIRONMENT`: Sentry environment. - `VITE_SENTRY_RELEASE` / `SENTRY_RELEASE`: release override. - `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT`: artifact upload through @@ -89,8 +115,8 @@ archive or IPA exists. Frontend and desktop bundle sizes are reported in GitHub because Sentry Size Analysis is a mobile build-size product, not a generic web or desktop bundle analyzer. -Signed iOS device-release dSYMs and native Android SDK runtime crash coverage -are still Phase 3 follow-ups. +Signed iOS device-release dSYMs, NDK/native Android crash capture, Android +Mobile Vitals, and performance transactions are still Phase 3 follow-ups. ## Scrubbing Rules @@ -135,8 +161,9 @@ attachment IPC, and push decrypt fallback events. It also covers opt-in user feedback from settings and the crash fallback, with optional SDK-provided screenshot capture when supported. -Broader Rust tracing/log bridges, native Android SDK runtime coverage, and -signed iOS device-release dSYMs remain separate follow-up phases from Spec 21. +Broader Rust tracing/log bridges, NDK/native Android crash capture, Android +Mobile Vitals, and signed iOS device-release dSYMs remain separate follow-up +phases from Spec 21. Broader per-command Rust instrumentation is still intentionally incremental; new producers should stay Charm-targeted and avoid raw Matrix identifiers, file paths, and secrets. diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index 0c114f57..dd37d158 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -30,6 +30,16 @@ if (hasGoogleServicesConfig) { fun requiredSentryEnv(name: String): String = System.getenv(name) ?: error("$name is required when SENTRY_ANDROID_UPLOAD=true") +fun buildConfigString(value: String): String = + "\"${ + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + }\"" + val sentryAndroidUpload = System.getenv("SENTRY_ANDROID_UPLOAD") == "true" if (sentryAndroidUpload) { apply(plugin = "io.sentry.android.gradle") @@ -64,6 +74,21 @@ android { targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + buildConfigField( + "String", + "SENTRY_DSN", + buildConfigString(System.getenv("SENTRY_DSN") ?: System.getenv("VITE_SENTRY_DSN") ?: ""), + ) + buildConfigField( + "String", + "SENTRY_ENVIRONMENT", + buildConfigString(System.getenv("SENTRY_ENVIRONMENT") ?: System.getenv("VITE_SENTRY_ENVIRONMENT") ?: ""), + ) + buildConfigField( + "String", + "SENTRY_RELEASE", + buildConfigString(System.getenv("SENTRY_RELEASE") ?: System.getenv("VITE_SENTRY_RELEASE") ?: ""), + ) } buildTypes { getByName("debug") { @@ -108,6 +133,7 @@ dependencies { // — `matrix::secret_store`'s Android implementation, called via JNI, // stands in for `keyring`'s desktop-only OS-keychain backends there. implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("io.sentry:sentry-android:8.48.0") // Spec 11: UnifiedPush-first push transport, falling back to the // embedded FCM distributor when no external distributor is installed — // see `push::android`'s doc comment. Coordinates/versions per each diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 3358394d..aae06728 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + + + + () + private val observabilityStoreFilename = "observability.json" + + override fun onCreate() { + super.onCreate() + initializeSentryIfConsented() + } + + private fun initializeSentryIfConsented() { + val dsn = BuildConfig.SENTRY_DSN.takeIf { it.isNotBlank() } ?: return + sentryConsentEnabled = readSentryEnabledFromStore() + if (!sentryConsentEnabled) return + startSentryConsentObservers() + + SentryAndroid.init(this) { options -> + options.setDsn(dsn) + BuildConfig.SENTRY_ENVIRONMENT.takeIf { it.isNotBlank() }?.let { options.setEnvironment(it) } + BuildConfig.SENTRY_RELEASE.takeIf { it.isNotBlank() }?.let { options.setRelease(it) } + options.setSendDefaultPii(false) + options.setSendClientReports(false) + options.setEnableNdk(false) + // Leave tracesSampleRate unset; 0.0 still enables tracing instrumentation overhead. + options.setEnableAutoSessionTracking(false) + options.setBeforeBreadcrumb(BeforeBreadcrumbCallback { breadcrumb, _ -> + if (sentryConsentEnabled) breadcrumb else null + }) + options.setBeforeSend(BeforeSendCallback { event, _ -> + if (sentryConsentEnabled) event else null + }) + } + } + + private fun startSentryConsentObservers() { + val mask = FileObserver.CLOSE_WRITE or + FileObserver.DELETE or + FileObserver.MOVED_FROM or + FileObserver.MOVED_TO + listOf(File(applicationInfo.dataDir), filesDir) + .distinctBy { it.absolutePath } + .forEach { directory -> + @Suppress("DEPRECATION") + val observer = object : FileObserver(directory.absolutePath, mask) { + override fun onEvent(event: Int, path: String?) { + if (path == null) { + sentryConsentEnabled = false + return + } + if (isObservabilityStoreEvent(path)) { + sentryConsentEnabled = readSentryEnabledFromStore() + } + } + } + observer.startWatching() + sentryConsentObservers.add(observer) + } + } + + private fun isObservabilityStoreEvent(path: String): Boolean = + path == observabilityStoreFilename || + path.endsWith("/$observabilityStoreFilename") + + private fun readSentryEnabledFromStore(): Boolean { + val appDataFile = File(applicationInfo.dataDir, observabilityStoreFilename) + val file = if (appDataFile.isFile) { + appDataFile + } else { + File(filesDir, observabilityStoreFilename).takeIf { it.isFile } + } + if (file == null) return false + + return runCatching { + val root = JSONObject(file.readText(Charsets.UTF_8)) + val state = root.optJSONObject("observability")?.optJSONObject("state") + ?: root.optJSONObject("state") + ?: root.optJSONObject("observability") + + state?.optBoolean("sentryEnabled", false) == true + }.getOrDefault(false) + } +} diff --git a/src/observability/persistence.test.ts b/src/observability/persistence.test.ts index 70b8732f..708b851e 100644 --- a/src/observability/persistence.test.ts +++ b/src/observability/persistence.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ isTauri: vi.fn(), load: vi.fn(), storeSet: vi.fn(), + storeSave: vi.fn(), })); vi.mock("@tauri-apps/plugin-store", () => ({ @@ -31,6 +32,7 @@ beforeEach(() => { mocks.isTauri.mockReset().mockReturnValue(false); mocks.load.mockReset().mockRejectedValue(new Error("store unavailable")); mocks.storeSet.mockReset().mockResolvedValue(undefined); + mocks.storeSave.mockReset().mockResolvedValue(undefined); }); describe("observability persistence", () => { @@ -72,13 +74,70 @@ describe("observability persistence", () => { }); }); + it("flushes successful store writes", async () => { + mocks.load.mockResolvedValue({ get: vi.fn(), set: mocks.storeSet, save: mocks.storeSave }); + + const settings = { + ...DEFAULT_OBSERVABILITY_SETTINGS, + sentryEnabled: true, + anonymousUserId: "anon-1", + }; + await persistObservabilitySettings(settings, 42); + + expect(mocks.load).toHaveBeenCalledWith("observability.json", { + autoSave: false, + defaults: {}, + }); + expect(mocks.storeSet).toHaveBeenCalledWith("observability", { + state: settings, + updatedAt: 42, + }); + expect(mocks.storeSave).toHaveBeenCalledOnce(); + expect(mocks.storeSet.mock.invocationCallOrder[0]).toBeLessThan( + mocks.storeSave.mock.invocationCallOrder[0], + ); + }); + + it("warns when Tauri store persistence fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = new Error("store failed"); + mocks.isTauri.mockReturnValue(true); + mocks.load.mockRejectedValue(error); + + await persistObservabilitySettings(DEFAULT_OBSERVABILITY_SETTINGS, 42); + + expect(warn).toHaveBeenCalledWith( + "Failed to persist observability settings to the Tauri store", + error, + ); + warn.mockRestore(); + }); + + it("does not sync log opt-ins to Rust when durable persistence fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + mocks.isTauri.mockReturnValue(true); + mocks.load.mockRejectedValue(new Error("store failed")); + + await persistObservabilitySettings( + { + ...DEFAULT_OBSERVABILITY_SETTINGS, + sentryEnabled: true, + logsEnabled: true, + }, + 42, + ); + + expect(mocks.invoke).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + it("syncs log opt-outs to Rust before awaiting durable persistence", async () => { let resolveStoreWrite!: () => void; const storeWrite = new Promise((resolve) => { resolveStoreWrite = resolve; }); mocks.isTauri.mockReturnValue(true); - mocks.load.mockResolvedValue({ set: mocks.storeSet }); + mocks.load.mockResolvedValue({ set: mocks.storeSet, save: mocks.storeSave }); mocks.storeSet.mockReturnValue(storeWrite); const persist = persistObservabilitySettings( @@ -103,13 +162,13 @@ describe("observability persistence", () => { await persist; }); - it("does not let an older opt-in overwrite a newer opt-out IPC sync", async () => { + it("does not let an older opt-in overwrite a newer opt-out durable write or IPC sync", async () => { let resolveFirstWrite!: () => void; const firstWrite = new Promise((resolve) => { resolveFirstWrite = resolve; }); mocks.isTauri.mockReturnValue(true); - mocks.load.mockResolvedValue({ set: mocks.storeSet }); + mocks.load.mockResolvedValue({ set: mocks.storeSet, save: mocks.storeSave }); mocks.storeSet.mockReturnValueOnce(firstWrite).mockResolvedValueOnce(undefined); const optIn = persistObservabilitySettings( @@ -124,7 +183,7 @@ describe("observability persistence", () => { expect(mocks.storeSet).toHaveBeenCalledTimes(1); }); - await persistObservabilitySettings( + const optOut = persistObservabilitySettings( { ...DEFAULT_OBSERVABILITY_SETTINGS, sentryEnabled: true, @@ -133,8 +192,20 @@ describe("observability persistence", () => { 101, ); resolveFirstWrite(); - await optIn; + await Promise.all([optIn, optOut]); + expect(mocks.storeSet).toHaveBeenNthCalledWith(2, "observability", { + state: { + ...DEFAULT_OBSERVABILITY_SETTINGS, + sentryEnabled: true, + logsEnabled: false, + }, + updatedAt: 101, + }); + expect(mocks.storeSave).toHaveBeenCalledOnce(); + expect(mocks.storeSet.mock.invocationCallOrder[1]).toBeLessThan( + mocks.storeSave.mock.invocationCallOrder[0], + ); expect(mocks.invoke).toHaveBeenCalledTimes(1); expect(mocks.invoke).toHaveBeenCalledWith("update_observability_log_consent", { logsEnabled: false, diff --git a/src/observability/persistence.ts b/src/observability/persistence.ts index 2c34b35c..68ff0f6b 100644 --- a/src/observability/persistence.ts +++ b/src/observability/persistence.ts @@ -15,6 +15,7 @@ interface PersistedEnvelope { } let persistMutationId = 0; +let durablePersistTail = Promise.resolve(); function isPersistedEnvelope(value: unknown): value is PersistedEnvelope { return ( @@ -55,7 +56,7 @@ function writeLocalEnvelope(envelope: PersistedEnvelope): void { async function getStore() { const { load } = await import("@tauri-apps/plugin-store"); - return load(OBSERVABILITY_STORE_FILENAME, { autoSave: true, defaults: {} }); + return load(OBSERVABILITY_STORE_FILENAME, { autoSave: false, defaults: {} }); } async function syncRustLogConsent(logsEnabled: boolean): Promise { @@ -103,13 +104,35 @@ export async function persistObservabilitySettings( if (!envelope.state.logsEnabled) { await syncRustLogConsent(false); } + let persisted = false; try { - const store = await getStore(); - await store.set(OBSERVABILITY_STORE_KEY, envelope); - } catch { + const durablePersist = durablePersistTail.then(async () => { + if (mutationId !== persistMutationId) { + return false; + } + const store = await getStore(); + if (mutationId !== persistMutationId) { + return false; + } + await store.set(OBSERVABILITY_STORE_KEY, envelope); + if (mutationId !== persistMutationId) { + return false; + } + await store.save(); + return mutationId === persistMutationId; + }); + durablePersistTail = durablePersist.then( + () => undefined, + () => undefined, + ); + persisted = await durablePersist; + } catch (error) { + if (isTauri()) { + console.warn("Failed to persist observability settings to the Tauri store", error); + } // The local mirror already landed; plain-browser tests and dev previews use it. } - if (mutationId === persistMutationId && envelope.state.logsEnabled) { + if (persisted && mutationId === persistMutationId && envelope.state.logsEnabled) { await syncRustLogConsent(true); } }