Kotlin SDK
Report errors and crashes, send analytics events, identify users, track revenue and run experiments from native Android apps and any JVM, from Kotlin or Java.
The Kotlin SDK reports what happens inside your Android app. It captures exceptions you catch and every uncaught exception, keeps everything in an on-disk queue until the device is online, records app start, resume and screen spans, and sends analytics events, user profiles, revenue and experiment exposures. It posts OTLP/HTTP JSON to the ingest endpoint in your DSN, the same data @unhandled/react-native sends, so a native app and an Expo app look the same in Unhandled.
It depends only on the Kotlin standard library and reads no device identifiers. Android needs API level 24; a plain JVM needs Java 11.
| Artifact | Use it for |
|---|---|
net.unhandled:unhandled-android | Android apps. Adds the cache-dir queue, release name, device class, lifecycle and frame timing. |
net.unhandled:unhandled-core | Any other JVM. Pulled in by unhandled-android. |
Add the dependency
dependencies { implementation("net.unhandled:unhandled-android:0.1.0") }Start it in Application.onCreate
class App : Application() { override fun onCreate() { super.onCreate() UnhandledAndroid.start( this, Options( dsn = "https://uh_live_…@ingest.unhandled.net/<service id>", environment = Environment.PRODUCTION, ), ) } }From Java:
UnhandledAndroid.start(this, Options.builder("https://uh_live_…@ingest.unhandled.net/<service id>", Environment.PRODUCTION).build());
start never throws. It returns the client, and Unhandled.shared (Unhandled.getShared() in Java) returns it anywhere else. An empty dsn gives an inactive client whose isActive is false. A malformed DSN logs a warning and returns the same inactive client. A second start keeps the first client. On a plain JVM, call Unhandled.start(options).
Options
dsnStringrequiredIngest endpoint, API key and service id. Empty turns the SDK off.
environmentPRODUCTION | STAGING | DEVELOPMENTrequiredSent as
x-unhandled-env.releaseStringdefaultpackageName@versionName+versionCodeSent as
service.version.sampleRateDoubledefault1.0Share of errors and messages to send.
tracesSampleRateDoubledefault1.0Share of spans to send.
ignoreErrorsList<String>default[]Drops errors whose message contains any of these strings.
errorFilterErrorFilterReceives each error's type, message and mechanism. Return
trueto drop it.queueLimitsQueueLimitsBounds of the on-disk queue:
maxBytes(default4000000) andmaxEnvelopes(default200).captureCrashesBooleandefaulttrueInstall the uncaught exception handler.
storageDirectoryFiledefaultcacheDir/unhandled/<service id>Where the queue lives. On a plain JVM the default is the user's cache directory (
~/.cache,~/Library/Cachesor%LOCALAPPDATA%), created with0700permissions.enabledBooleandefaulttruefalsereturns the inactive client.
Errors
try {
checkout()
} catch (error: IOException) {
Unhandled.shared.captureException(error, CaptureContext(tags = mapOf("flow" to "checkout")))
}
Unhandled.shared.captureMessage("Payment sheet dismissed twice", MessageLevel.WARNING)
Unhandled.shared.setUser(CaptureUser(id = "42", email = "[email protected]"))| Source | mechanism | handled | Severity |
|---|---|---|---|
captureException | manual | true | ERROR |
| An uncaught exception | uncaught_exception | false | FATAL |
The uncaught exception handler chains to the one installed before it, so the app still crashes as it would have. The error is on disk before that handler runs, and the next launch sends it. Each error carries its cause chain and up to 10 suppressed exceptions, the current screen as app.screen.name, and the tags os.name and device.class. NDK crashes and ANRs are not captured.
ProGuard and R8
The SDK needs no keep rules and adds none to your app. For line numbers in release stack traces, add -keepattributes SourceFile,LineNumberTable to your own rules. An obfuscated build reports obfuscated class and method names, and Unhandled does not deobfuscate them on the server yet (#336). Keep each release's app/build/outputs/mapping/release/mapping.txt and retrace a stack trace with R8's retrace from the Android SDK command-line tools:
retrace app/build/outputs/mapping/release/mapping.txt stacktrace.txtAnalytics
Analytics are anonymous until you call identify. There is no device id, install id or cookie, so an event sent before identify counts toward totals and belongs to no one.
Unhandled.shared.track("checkout_started", mapOf("items" to 3, "gift" to false))
Unhandled.shared.identify(account.id, mapOf("plan" to "pro"))
Unhandled.shared.setUserProperties(mapOf("plan" to "team"))
Unhandled.shared.reset()identify(userId, properties) sends a profile update, makes later events carry the user id and sets the error user to that id. The id lives in memory only, so call it again after each launch while the user is signed in. setUserProperties does nothing before identify, and reset() forgets the user on sign-out. Values are String, Boolean or a finite Number, and properties follow the analytics rules; a call that breaks one is dropped.
Events from the app have no site or path, so they count as events and users, never as visitors or visits.
Revenue
Unhandled.shared.trackRevenue(
Revenue(
amount = 1999,
currency = "usd",
id = purchase.orderId,
product = "pro",
subscription = Subscription("sub_123", SubscriptionInterval.MONTH),
),
)amount is a whole number in the currency's smallest unit, negative for a refund. Pass the Play order id as id so a retried call counts once. See Revenue.
Experiments
val variant = Unhandled.shared.getVariant("paywall", "control", "annual-first")
val weighted = Unhandled.shared.getVariant("pricing-page", listOf(Variant("control", 3), Variant("green")))
Unhandled.shared.trackExposure("onboarding", myOwnAssignment)getVariant returns the identified user's variant, the same one every Unhandled SDK picks, and records an exposure once per variant per process. Before identify it returns the first variant and records nothing. trackExposure records a variant you assigned yourself and does nothing before identify. See Experiments.
Spans
| Span | When |
|---|---|
| The screen name | Each trackScreen(name) call that changes the screen, to the next frame. app.screen.previous names where it came from. |
app start | Once per launch, from process start to the first screen or appReady(). app.start.type is cold. |
app resume | Back from the background to the next frame. app.start.type is resume. |
Offline queue
Errors are written to a file under the cache directory before captureException returns. Analytics and spans are batched for a second, or 50 records, before they are written; going to the background writes them at once. Files go out oldest first, one at a time, and a file is deleted only after the ingest answers. A 408, 429, 5xx or network error backs off exponentially up to five minutes; coming back to the foreground retries at once. Any other 4xx drops the file. Past maxEnvelopes or maxBytes, the oldest analytics and spans go first and errors last.
Analytics calls never touch the disk on the calling thread. captureException and captureMessage write one small file before they return, which StrictMode's detectDiskWrites flags on the main thread.
flush() and shutdown() are suspend functions. From Java, or outside a coroutine, use flushAsync() (a CompletableFuture) or flushBlocking(timeoutMillis), never on the main thread.
Privacy
The resource carries os.name, os.version and device.class (phone, tablet, tv). The SDK never reads ANDROID_ID, the advertising id or Build.MODEL, sends no install id, locale or IP address, and needs no permission beyond INTERNET. A user is attached only when you call identify or setUser.