Ship a build with crash reporting and a build with none from one codebase, by swapping a module that exposes three top-level functions instead of an interface, so call sites are byte-identical and the no-tracking build provably contains no reporting code. Also covers a desktop crash dialog built on the older widget toolkit, because the modern UI may be exactly what just died, and how to marshal it onto that toolkit's event thread. Use when a privacy build must contain no reporting dependency at all, when a swapped implementation is drifting from its counterpart, or when the app dies with no visible error and no way for a user to send you the details.
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill swappable-crash-reporting-and-dialog
The unit of the swap is a module, not a class. Two source modules share one package name and
one file name, and each defines the same three top-level functions:
// adapted — reporting module
package com.example.crashreporting
fun configCrashReporting(applicationContext: Context, dsn: String) {
ReportingClient.init(applicationContext) { options -> options.dsn = dsn }
}
fun reportCrash(throwable: Throwable) {
ReportingClient.captureException(throwable)
}
fun pushPlayerError(error: PlayerError) {
ReportingClient.captureMessage("Player Error: ${error.message}, code: ${error.errorCode}")
}
// adapted — no-tracking module: identical package, identical signatures, logs instead
package com.example.crashreporting
fun configCrashReporting(applicationContext: Context, dsn: String) = Logger.d(TAG, "reporting disabled: start app")
fun reportCrash(throwable: Throwable) = Logger.e(TAG, "reporting disabled: crash: ${throwable.localizedMessage}")
fun pushPlayerError(error: PlayerError) = Logger.e(TAG, "reporting disabled: ${error.message}, code: ${error.errorCode}")
The build script picks one:
// adapted
if (isFullBuild) implementation(projects.crashReporting) else implementation(projects.crashReportingEmpty)
Why top-level functions and not an interface. There is no instance, so nothing to construct,
register, inject, or forget to register. The import line at every call site is the same string in
both builds, and so is the call. An interface would need a binding in each build's dependency
graph — a second place to keep in sync, and one where the no-tracking build can end up bound to
the real thing.
Why three functions and not one. Each is a distinct verb: configure once at start-up, report a
caught throwable, report a domain-level non-fatal that is not a throwable at all. Do not collapse
them into report(Any) — the third carries structured fields, and it is the one that grows.
The no-op is not empty. It logs. A privacy build still needs to be debuggable by the person
running it; a body that is literally {} deletes the only signal a maintainer has when reproducing
a user's report locally.
The uncaught-exception handler is installed first thing at start-up, before the UI framework
exists. When it fires, the modern UI is the least trustworthy thing in the process — it may be
exactly what threw. Build the dialog from the JDK's own widget toolkit, which needs nothing your
app initialised:
// adapted
fun install() {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
if (reportingConfigured) reportCrash(throwable)
} catch (_: Exception) { /* reporting may never have initialised */ }
try {
if (SwingUtilities.isEventDispatchThread()) {
showCrashDialog(thread, throwable) // already on the event thread
} else {
SwingUtilities.invokeAndWait { showCrashDialog(thread, throwable) }
}
} catch (_: Exception) {
System.err.println("Fatal crash in thread ${thread.name}:")
throwable.printStackTrace() // last resort
}
exitProcess(1)
}
}
The dialog carries the version, the thread name, the full stack trace in a selectable read-only
text area, and a copy button — the user's only realistic way to send you what happened.
In the reference the two halves never meet. The module pair above is an *Android library* —
Android namespace, a minimum SDK, the Android launcher as its only consumer — so the desktop entry
cannot depend on it. Its handler skips reportCrash and calls the vendor client directly, gated
on whether a key was compiled in: if (dsn.isNotEmpty()) ReportingClient.captureException(t).
The sample above is therefore the composition you *want*, not the one that is there. Reaching it
means making the module pair multiplatform, so a desktop source set sees the same three functions
— not bolting on a second gate. The split already shows in the surface: the launcher calls
configCrashReporting and pushPlayerError, while reportCrash is defined in both modules and
called from nowhere, which is the drift the last trap below is about.
Report before you show. The dialog blocks until dismissed and the process exits immediately
after; a report attempted after the dialog can be cut off by the user closing it.
Wrap the report in its own catch. The reporting client may never have been configured — that
is the normal state in the no-tracking build, and also the state when the crash happened *during*
start-up. A throw here would replace your crash dialog with a second crash.
Dispatch to the event thread only when you are not already on it — and *wait*, do not
post-and-forget. A synchronous "invoke and wait" issued *from* the event thread fails, and a
UI-thread crash is the common case, so the branch you skip testing is the one that fires most. The
process exits on the next line, so a fire-and-forget post shows nothing at all.
Keep a last resort that needs no windowing at all. If the event thread is itself broken, the
stack trace still has to reach standard error — that branch is what makes the feature honest on a
headless or wedged session. Set the look-and-feel inside its own try for the same reason: it is
cosmetic, and must not cost you the dialog.
The two modules drift. Nothing checks that their public surfaces still match except the
compiler — and the compiler only ever sees one of them per build. Compare them deliberately.
the check cannot see the thing that makes the swap work at all:
grep -rn "^package \|^fun " --include='*.kt' <reporting-module>/src <empty-module>/src.
The two lists must match line for line, paths aside.
library. Absence there is the actual privacy claim; a disabled flag is not.
appears in both cases and the process exits.
dialog rather than a silent exit.
install(), not in the entry file, so grep the source set rather than one file —
grep -rn "setDefaultUncaughtExceptionHandler" --include='*.kt' <desktop-src> — then check
that the entry function's *first statement* is the call to it.
Take maxrave-dev/swappable-crash-reporting-and-dialog from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.