docs

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.

ArtifactUse it for
net.unhandled:unhandled-androidAndroid apps. Adds the cache-dir queue, release name, device class, lifecycle and frame timing.
net.unhandled:unhandled-coreAny other JVM. Pulled in by unhandled-android.
  1. Add the dependency

    app/build.gradle.kts
    dependencies {
        implementation("net.unhandled:unhandled-android:0.1.0")
    }
  2. Start it in Application.onCreate

    App.kt
    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:

    App.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

dsnStringrequired

Ingest endpoint, API key and service id. Empty turns the SDK off.

environmentPRODUCTION | STAGING | DEVELOPMENTrequired

Sent as x-unhandled-env.

releaseStringdefault packageName@versionName+versionCode

Sent as service.version.

sampleRateDoubledefault 1.0

Share of errors and messages to send.

tracesSampleRateDoubledefault 1.0

Share of spans to send.

ignoreErrorsList<String>default []

Drops errors whose message contains any of these strings.

errorFilterErrorFilter

Receives each error's type, message and mechanism. Return true to drop it.

queueLimitsQueueLimits

Bounds of the on-disk queue: maxBytes (default 4000000) and maxEnvelopes (default 200).

captureCrashesBooleandefault true

Install the uncaught exception handler.

storageDirectoryFiledefault cacheDir/unhandled/<service id>

Where the queue lives. On a plain JVM the default is the user's cache directory (~/.cache, ~/Library/Caches or %LOCALAPPDATA%), created with 0700 permissions.

enabledBooleandefault true

false returns the inactive client.

Errors

kotlin
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]"))
SourcemechanismhandledSeverity
captureExceptionmanualtrueERROR
An uncaught exceptionuncaught_exceptionfalseFATAL

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:

Terminal
retrace app/build/outputs/mapping/release/mapping.txt stacktrace.txt

Analytics

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.

kotlin
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

kotlin
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

kotlin
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

SpanWhen
The screen nameEach trackScreen(name) call that changes the screen, to the next frame. app.screen.previous names where it came from.
app startOnce per launch, from process start to the first screen or appReady(). app.start.type is cold.
app resumeBack 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.