Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
db8b439
Add consent-gated Android Sentry runtime init
Just-Insane Jul 8, 2026
ac01ae0
Fix Android Sentry consent wiring
Just-Insane Jul 8, 2026
202d5f8
Keep Android Sentry consent scoped
Just-Insane Jul 8, 2026
b7b4ded
Restore frontend Sentry DSN env
Just-Insane Jul 8, 2026
13c7a4d
Merge remote-tracking branch 'origin/main' into codex/sentry-android-…
Just-Insane Jul 8, 2026
7ca0aee
Restore Vite Sentry DSN for frontend upload
Just-Insane Jul 8, 2026
d75197a
Tolerate Android observability store paths
Just-Insane Jul 8, 2026
7600411
Cache Android observability consent reads
Just-Insane Jul 8, 2026
4ca4f22
Gate Android breadcrumbs by consent
Just-Insane Jul 8, 2026
45febdd
Tighten Android Sentry manifest docs
Just-Insane Jul 8, 2026
ca64f54
Set Android Sentry environment
Just-Insane Jul 8, 2026
c15d1e5
Merge remote-tracking branch 'origin/main' into codex/sentry-android-…
Just-Insane Jul 8, 2026
9da61c1
Avoid repeated Android consent path list allocation
Just-Insane Jul 8, 2026
97fc618
Tighten Android Sentry option and consent cache handling
Just-Insane Jul 8, 2026
70799b6
Address Android Sentry follow-up review comments
Just-Insane Jul 8, 2026
eff75c5
Clarify Android Sentry runtime env split
Just-Insane Jul 8, 2026
848cd56
Document disabled Android tracing behavior
Just-Insane Jul 8, 2026
17374c6
Harden Android Sentry consent diagnostics
Just-Insane Jul 8, 2026
9d3edad
Disable Android Sentry NDK capture
Just-Insane Jul 8, 2026
130ac5e
Prefer fresh Android Sentry consent reads
Just-Insane Jul 8, 2026
ee3bf68
Merge remote-tracking branch 'origin/main' into codex/sentry-android-…
Just-Insane Jul 8, 2026
31b24db
Document fresh Android consent reads
Just-Insane Jul 8, 2026
6069453
Use in-memory Android Sentry consent gate
Just-Insane Jul 8, 2026
45fea54
Flush observability consent store writes
Just-Insane Jul 8, 2026
d2c5330
Tighten Android consent observer coverage
Just-Insane Jul 8, 2026
d937b63
Document Sentry provider authority source
Just-Insane Jul 8, 2026
146723c
Merge remote-tracking branch 'origin/main' into codex/sentry-android-…
Just-Insane Jul 8, 2026
84b4100
Avoid mid-write Android consent observer reads
Just-Insane Jul 8, 2026
0a80490
Tighten observability consent write handling
Just-Insane Jul 8, 2026
444a8f9
Broaden Sentry provider removal stubs
Just-Insane Jul 8, 2026
b554c71
Keep compatible Sentry auto-init manifest keys
Just-Insane Jul 9, 2026
7d5b77f
Gate Rust log opt-in on store persistence
Just-Insane Jul 9, 2026
9f3968b
Clarify Android Sentry same-session opt-in docs
Just-Insane Jul 9, 2026
2659ee0
Use verified Sentry Android provider authorities
Just-Insane Jul 9, 2026
3266113
Serialize observability store persistence
Just-Insane Jul 9, 2026
d7c11e7
Clarify Android Sentry opt-out queue limits
Just-Insane Jul 9, 2026
67d5ddc
Use explicit observability store saves
Just-Insane Jul 9, 2026
2b14c87
Handle nested Android consent store events
Just-Insane Jul 9, 2026
b1550ba
Avoid Android consent reads on null observer paths
Just-Insane Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/sentry-release-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 32 additions & 5 deletions SENTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
26 changes: 26 additions & 0 deletions src-tauri/gen/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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") ?: ""),
Comment thread
Just-Insane marked this conversation as resolved.
)
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") {
Expand Down Expand Up @@ -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")
Comment thread
Just-Insane marked this conversation as resolved.
// 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
Expand Down
16 changes: 15 additions & 1 deletion src-tauri/gen/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Spec 11: required on API 33+ to actually show a push-triggered
notification; requested from the frontend's notifications settings
Expand All @@ -25,12 +26,25 @@
</queries>

<application
android:name=".CharmApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.charm"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:dataExtractionRules="@xml/secure_store_backup_rules"
android:fullBackupContent="@xml/secure_store_backup_rules_legacy">
<!-- Keep both Sentry auto-init keys for SDK-version compatibility. -->
<meta-data android:name="io.sentry.auto-init" android:value="false" />
<meta-data android:name="io.sentry.sdk.auto-init" android:value="false" />
<!-- sentry-android-core 8.48.0 declares these provider authorities. -->
<provider
android:name="io.sentry.android.core.SentryInitProvider"
android:authorities="${applicationId}.SentryInitProvider"
tools:node="remove" />
Comment thread
Just-Insane marked this conversation as resolved.
Comment thread
Just-Insane marked this conversation as resolved.
<provider
android:name="io.sentry.android.core.SentryPerformanceProvider"
android:authorities="${applicationId}.SentryPerformanceProvider"
tools:node="remove" />
Comment thread
Just-Insane marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Just-Insane marked this conversation as resolved.
Comment thread
Just-Insane marked this conversation as resolved.
Comment thread
Just-Insane marked this conversation as resolved.
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package social.cloudhub.charm

import android.app.Application
import android.os.FileObserver
import io.sentry.SentryOptions.BeforeBreadcrumbCallback
import io.sentry.SentryOptions.BeforeSendCallback
import io.sentry.android.core.SentryAndroid
import org.json.JSONObject
import java.io.File

class CharmApplication : Application() {
@Volatile
private var sentryConsentEnabled: Boolean = false
private val sentryConsentObservers = mutableListOf<FileObserver>()
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
Comment thread
Copilot marked this conversation as resolved.
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
})
Comment thread
Just-Insane marked this conversation as resolved.
Comment thread
Just-Insane marked this conversation as resolved.
}
}

private fun startSentryConsentObservers() {
val mask = FileObserver.CLOSE_WRITE or
FileObserver.DELETE or
FileObserver.MOVED_FROM or
FileObserver.MOVED_TO
Comment thread
Copilot marked this conversation as resolved.
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
Comment on lines +57 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore null observer paths instead of disabling consent

Because this observer is attached to the whole app-data/filesDir directory, Android's documented null-path callbacks for the watched directory itself are not evidence that observability.json was deleted or changed. In that scenario this branch flips the consent cache to false, so an opted-in Android session silently drops all later Sentry events until another settings write restores the cache; ignore null/non-store paths or re-read the actual store instead of treating them as opt-out.

Useful? React with 👍 / 👎.

}
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)
}
}
81 changes: 76 additions & 5 deletions src/observability/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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<void>((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(
Expand All @@ -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<void>((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(
Expand All @@ -124,7 +183,7 @@ describe("observability persistence", () => {
expect(mocks.storeSet).toHaveBeenCalledTimes(1);
});

await persistObservabilitySettings(
const optOut = persistObservabilitySettings(
{
...DEFAULT_OBSERVABILITY_SETTINGS,
sentryEnabled: true,
Expand All @@ -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,
Expand Down
Loading
Loading