This is a submission for DEV's Summer Bug Smash: Smash Stories.
TL;DR. I wired Sentry into my Android app behind a careful guard so that a build with no DSN behaves exactly as if the SDK were absent. I reviewed it. I built it both ways. Both green. Then I put the APK on a phone and it died on launch, every time, before a single line of my code ran. The guard was real code that was never reached, because a dependency had quietly added a ContentProvider to my manifest. The fix is one line. The reason I could not have caught it is the story.
The setup
I was adding Sentry to WhyRep, a workout tracker I am building solo. The app is native Kotlin and Compose on Android, with about 10,000 lines in the app module, and until that week it had no error reporting at all.
I made one design choice I was pleased with. The SDK would be DSN-gated:
// WhyRepApplication.kt
override fun onCreate() {
super.onCreate()
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
SentryAndroid.init(this) { options ->
options.dsn = BuildConfig.SENTRY_DSN
options.beforeSend = ScrubbingPolicy::scrub
// ...
}
}
}
The DSN comes from a gitignored local.properties. No DSN, no init, and the app behaves exactly as it would with no Sentry dependency compiled in. Anyone can clone this repo without a Sentry account and build it and run it.
I liked that. I wrote the guarantee in a comment above the block so nobody would delete it later.
Then, the same day, I reviewed my own work and confirmed it. Built with a DSN: green. Built with a blank DSN: green. ./gradlew test: green. The iOS cross-compile: green.
DSN-gating verified. I wrote that down.
Then I put the release APK on my phone and it died on launch.
java.lang.RuntimeException: Unable to get provider
io.sentry.android.core.SentryInitProvider:
java.lang.IllegalArgumentException: DSN is required. Use empty string
or set enabled to false in SentryOptions to disable SDK.
at android.app.ActivityThread.installProvider(ActivityThread.java:9157)
...
at io.sentry.android.core.SentryInitProvider.onCreate(SentryInitProvider.java:27)
Not a slow start. Not a degraded mode. A hundred percent crash on launch, on a real device, immediately after install.
The only report that existed. A crash reporter cannot report the crash that is its own startup.
What I missed
sentry-android-core ships its own AndroidManifest.xml. Inside it:
<provider android:name="io.sentry.android.core.SentryInitProvider"
android:authorities="${applicationId}.SentryInitProvider" />
The Android manifest merger folds that into your manifest at build time. I confirmed it in the merged output rather than assuming, because by this point I did not trust anything I had not looked at:
app/build/intermediates/merged_manifest/release/AndroidManifest.xml
There it was. A provider I never wrote, in a manifest I thought I owned.
And ContentProviders are installed before Application.onCreate() runs.
So Sentry initializes itself, from a DSN it expects to find in manifest meta-data, before my Application class exists. Disassembling the shipped 8.50.1 class shows SentryInitProvider.onCreate gated only on ManifestMetadataReader.isAutoInit(context, logger), which defaults to true when the meta-data is absent, and then calling SentryAndroid.init with no options lambda. That reads the DSN from io.sentry.dsn manifest meta-data. My project deliberately does not set it, because the DSN comes from local.properties through BuildConfig.
So Sentry.preInitConfigurations threw. Inside a ContentProvider. During process startup.
My careful if (BuildConfig.SENTRY_DSN.isNotBlank()) guard was correct, well-commented, and had never once been in force.
The whole design was right. It simply never got a turn.
The part that actually stings
Here is the thing I keep coming back to. Every check this project has was structurally incapable of catching it.
| Check | Result with DSN | Result without DSN |
|---|---|---|
assembleDebug |
green | green |
assembleRelease |
green | green |
./gradlew test |
green | green |
:shared:compileKotlinIosArm64 |
green | green |
Look at what those four have in common. None of them installs an APK. None of them starts a process. None of them instantiates a ContentProvider. They compile code and they run JVM unit tests, and the bug lives in a phase that happens after compilation and before my code.
I had verified "builds with no DSN" and I had written down "DSN-gating verified."
Those are two different sentences. I had let them become one.
And the part that stings past that: a few hours earlier, in this same integration, I had caught two privacy leaks precisely because I stopped trusting my own code and intercepted the actual bytes leaving the browser. I found a lifter's shoulder injury note in an outbound request payload. I wrote up the lesson. I put it in a runbook, in this repo, in writing.
Then I turned to the Android side and verified it by compiling it.
The lesson did not transfer, and it did not transfer because I filed it under "web" instead of under "verification."
The fix
One line.
<!--
Load-bearing. sentry-android-core ships a <provider> that the manifest merger
folds into this file, and ContentProviders run before Application.onCreate.
Without this line the SDK auto-inits from manifest meta-data (default true),
finds no io.sentry.dsn, and throws inside process startup. The DSN-gating in
WhyRepApplication.kt is unreachable until this is here.
Do not delete this because it looks like dead config.
-->
<meta-data android:name="io.sentry.auto-init" android:value="false" />
The comment is six times longer than the line, on purpose. The line looks deletable and is not.
I considered the alternative, which is setting io.sentry.dsn in the manifest and letting auto-init do the work. I rejected it for three reasons: it moves the DSN into a checked-in file, it gives up the beforeSend and beforeBreadcrumb scrubbing hooks that my PII policy depends on, and it removes the DSN-absent no-op that lets a contributor build this repo without a Sentry account.
That last one was the whole point of the design. Fixing the bug by deleting the feature is not fixing the bug.
The verification, done properly this time
Not a build. An install.
I pushed the release variant, the exact flow that produced the crash, onto the same physical device:
| Check | Before | After |
|---|---|---|
pidof com.whyrep.app after launch |
(empty, process dead) | 5771 |
adb logcat -b crash |
RuntimeException: Unable to get provider |
(empty) |
dumpsys activity topResumedActivity |
(none) | com.whyrep.app/.MainActivity |
uiautomator dump screen text |
(none) |
"Know why, not just what", "Continue"
|
That last row is the one I care about. pidof returning a number tells you a process exists. uiautomator dump reading my actual onboarding copy back off the screen tells you the app is running and rendering the thing it was supposed to render.
A process that exists is not the same claim as an app that started.
The guard was in the right file and the wrong phase. Compilation cannot observe anything to the right of station one.
What I take from it
A dependency can add code to your app's startup without appearing anywhere in your source. The manifest merger is a build step that runs on other people's XML. Nothing in your Kotlin will ever remind you that it happened. If you want to know what is in your manifest, read the merged one in build/intermediates, not the one you wrote.
An initialization guard is a claim about ordering, and compiling proves nothing about ordering. This is the general version and it is the one I would put on a wall. Type checking answers "is this code valid." It has no opinion on "does this code run, and when, relative to what."
"Verified" needs an object. I verified compilation. I recorded it as having verified gating. The gap between those two sentences is where this bug lived for a day. Now I try to write verification claims with the method attached: not "DSN-gating verified" but "DSN-gating verified by installing the release APK with a blank DSN and reading the onboarding text off the screen." It is uglier and it cannot lie as easily.
And the obvious one, said out loud rather than hoped past: Sentry could not possibly have caught this. The process died inside Sentry's own initialization. The tool cannot instrument its own birth, and no amount of observability budget changes that. There is exactly one way to test whether an app starts, which is to start it.
An honest note on blast radius, because I would rather say it than have you wonder: this was caught by dogfooding, on a build only I had, before any release. Nobody else was ever affected. The story is the class of mistake, not an outage.
I still do not have an automated guard for this one. Catching it requires installing an APK on a device or emulator and my CI does not do that. A cheap partial guard would be asserting on the merged manifest at build time. The full guard is an instrumentation test that launches the app with no DSN set. It is written down as a real gap rather than quietly closed, because a gap I have named is less dangerous than a green check I have not earned.
So here is the question I would actually like answered.
How many of your dependencies are running code before your Application.onCreate? I did not know the number for my own app until it crashed. If you use WorkManager, Firebase, Sentry, LeakCanary, or anything from Jetpack Startup, the number is not zero.
Go read your merged manifest. I will wait.


Top comments (0)