Skip to content

Android SDK

Typed event tracking, activity and lifecycle auto-tracking, identity and consent for native Android apps, from Kotlin or Java, with sh.pug:android.

sh.pug:android captures events from native Android apps, and works from Kotlin and Java. It authenticates with your public key (pub_...), which is write-scoped (events + identify) and safe to ship in the app, and follows the same model as every Pug SDK: initialize once, track events, identify users, reset on sign-out.

Install

The SDK is on Maven Central. Add it to your app module:

app/build.gradle.ktskotlin
dependencies {
    implementation("sh.pug:android:0.1.0")
}

View sh.pug:android on Maven Central

In a Groovy build script that’s implementation 'sh.pug:android:0.1.0'.

Requirement Minimum
Android API 21 (minSdk 21)
kotlin-stdlib 2.0.21
kotlinx-coroutines-core 1.9.0

Those two are its only runtime dependencies - no AndroidX, OkHttp or protobuf runtime - and they are floors, so an app on newer versions keeps its own. The SDK declares only the INTERNET permission, and needs no R8 or ProGuard rules: it uses no reflection, and kotlinx-coroutines ships its own.

Smoke-test by adding a minimal Pug.init (see Initialize) with debug = true and running the app - an app_open and a screen_view appear in Live within a few seconds, and adb logcat -s Pug shows each event as it is tracked and sent.

Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in Install with AI.

Prompt
Add Pug product analytics to this Android app.

Read these first - they are the current source of truth, and more recent than your training data:
- https://docs.pug.sh/sdks/android/index.md - SDK reference
- https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog

Then read this codebase before you write anything, and work out what the app actually does: its activities, fragments or composables and how a user moves between them, its main models, and the handful of things a user opens the app to complete. The events worth tracking are the moments the app exists to produce - an account created, an order placed, a workout finished - not every tap. If you cannot state the core action in one sentence, keep reading.

Then:
1. Add sh.pug:android from Maven Central to the app module's dependencies. It needs minSdk 21, and works from Kotlin and Java.
2. Call Pug.init() once, in Application.onCreate() - create an Application subclass and register it in the manifest if the app has none. Not in an activity: the SDK has to see each launch's first screen.
3. Keep the project ID and public key (pub_...) out of source - set them as BuildConfig fields from Gradle properties. Pug.init() throws for a key that is not a public key.
4. Leave auto-tracking on: it already tracks app_open, app_close, app_install, app_update and a screen_view per activity. In a single-activity app (Compose, or fragments under one activity), set autoScreenViews = false and call Pug.screen() on every destination change instead.
5. Add tracking for the three or four actions you identified - the ones someone would look at in a weekly review.
6. If this app has authentication, call Pug.identify() after sign-in and Pug.reset() on sign-out.

Name events from the catalog before you invent one. Kotlin gets a generated named-argument method for each well-known event the SDK does not track itself - Pug.track.purchase(productId = ..., amount = ...) - and Java an event class - Pug.track(new PurchaseEvent().amount(...)) - so the compiler checks the properties for you, and the dashboards those events drive stay empty for a custom name. Reach for those first, search the catalog for every action you are about to track, and pass schema-external properties through extras. Use Pug.track(kind, properties) with a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not.

Android consent has two states, starts granted and is saved across launches, which is not the same as the Web SDK - check the consent section before you add a banner. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this app does, then list the files you changed, the event names you chose, and the Gradle properties I need to set.

Initialize

Call Pug.init once, in Application.onCreate, with your project ID and public key - every other call needs it first. Find your project ID in the dashboard URL (/p/<projectId>), and your public key on your project Overview or under Settings > API Keys.

import android.app.Application
import sh.pug.android.Options
import sh.pug.android.Pug

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Pug.init(this, "YOUR_PROJECT_ID", Options("pub_your_public_key"))
    }
}

If the app has no Application subclass yet, register this one on the <application> element of your manifest with android:name=".App". Initializing there, before any activity starts, is what lets the SDK see the first screen of each launch; initialized later, it tracks from the next activity on.

Pug.init throws IllegalArgumentException for a blank project ID, a key that isn’t a public key (pub_...) or that holds a space or control character - such as a trailing newline - or an endpoint that isn’t an http(s) URL. It does no disk or network work on the calling thread, so it is safe on the main thread under StrictMode, and the SDK’s sockets carry a TrafficStats tag, so detectUntaggedSockets doesn’t flag them. A second Pug.init is ignored with a warning until Pug.destroy(), and so is any call made before Pug.init.

Every other call returns at once, can be made from any thread, and never throws: calls run in order on one background thread, and failures are logged rather than raised.

Options are set in a block after the key; the ones left out keep their defaults. From Java, build them with new Options.Builder("pub_...") - see From Java.

Options("pub_your_public_key") {
    debug = BuildConfig.DEBUG
    batchMaxSize = 20
    defaultTrackingConsent = TrackingConsent.DENIED
}
Option Type Default Description
endpoint String https://api.pugs.dev API base URL: set it only when self-hosting.
debug Boolean false Log debug messages: each event tracked, sent or dropped, and why. Warnings and errors are logged regardless.
dryRun Boolean false Build and log events without sending anything; identify isn’t sent either. Consent is unaffected.
batchMaxSize Int 10 Events per request, at most 1000: a batch goes out once this many are queued, or batchMaxWaitMs after the first of them.
batchMaxWaitMs Long 5000 How long the first queued event waits for a full batch.
batchMaxQueueSize Int 1000 Undelivered events to keep; beyond this, the oldest is dropped.
sessionIdleTimeoutMs Long 30 min A session ends after this long without events.
sessionMaxDurationMs Long 24 h A session ends this long after it began.
defaultTrackingConsent TrackingConsent GRANTED Consent until the user chooses. See Tracking consent.
persistTrackingConsent Boolean true Save the user’s choice for the next launch. See Tracking consent.
autoOpenCloseEvents Boolean true Track app_open and app_close. Screen views and install events have their own switches. See Auto-tracking.
autoScreenViews Boolean true Track a screen_view each time an activity resumes on a different screen.
autoInstallEvents Boolean true Track app_install and app_update.
logger Logger Logcat, tag Pug Receive the SDK’s messages instead. A logger that throws is contained.
storage Storage files Keep the SDK’s state somewhere else. MemoryStorage() keeps nothing on the device.

Nothing in Options throws: a number out of range is logged and replaced by its default, and a batchMaxSize above 1000 is capped.

Delivery and storage

Events are queued and sent in batches, one request at a time. A request that fails for a reason that may pass - no network, a timeout, a server error, a captive portal - is retried with exponential backoff, from batchMaxWaitMs (at least a second) up to five minutes. One the server rejects for good is dropped and logged as an error. Requests are capped at 4 MiB, so a single event over that, which only very large properties make, is dropped when tracked.

Undelivered events, identity and consent live in files under the app’s noBackupFilesDir, so they survive restarts but aren’t restored onto another device by Auto Backup. Writes are grouped and land within a second, and at once when the app leaves the foreground, on Pug.flush() and on Pug.destroy(). If a custom storage throws, the SDK falls back to memory until Pug.destroy() or the process ends.

In a multi-process app, call Pug.init in one process only, usually the main one - processes would otherwise share the SDK’s files.

Track events

Events are the foundation of every analytic in Pug. The first argument is the event name (the kind): custom names work without a schema, while well-known events add typed properties and richer dashboards.

// Custom event - any name
Pug.track("button_clicked", mapOf("label" to "Sign up", "screen" to "pricing"))

// Typed well-known event - compile-time checked
Pug.track.purchase(productId = "sku-1", amount = 99.50, currency = "USD")

A kind may contain letters, digits, _, . and -; any other kind is dropped with a warning. Properties are read during the call, so the map can be changed or reused as soon as track returns. A key starting with $ is reserved for auto-properties and dropped with a warning - the property, not the event.

Property values map by type:

Value Sent as
String, CharSequence, Char, an enum (its name) string, cut to 1024 UTF-8 bytes
Boolean bool
Int, Long, Short, Byte, BigInteger, unsigned integers, AtomicInteger, AtomicLong int - one beyond the signed 64-bit range as a string of its digits
Double, Float, any other Number double - NaN and infinities are dropped
Date, Calendar, Instant (API 26+) timestamp
Map, Iterable, arrays JSON string
null omitted
anything else, such as UUID or Uri its toString(), cut to 1024 UTF-8 bytes

TrackOptions (on Pug.track and every typed method):

Option Type Default Description
immediate Boolean false Send the event on its own right away instead of with the next batch. If that send fails, it joins the queue for a retry.
timestamp Long? time of the call Override the event time as epoch milliseconds, for an event recorded after the fact. A time before 1970 or after 9999 is logged and replaced by the time of the call.
Pug.track("order_completed", mapOf("orderId" to "o-1"), TrackOptions { immediate = true })

Pug.track is also a namespace with a generated method per well-known event, 118 of them: every event in the catalog but the four web-only ones (page_view, click, rage_click, dead_click) and the five the SDK tracks itself. An event’s properties are its arguments - required ones first and non-null, optional ones nullable with a default - and two more follow: extras for properties the schema doesn’t define, and options.

Pug.track.search(query = "trail shoes")
Pug.track.videoPlay(videoId = "v1", positionMs = 1_500)
Pug.track.addToCart(
    productId = "sku-1",
    extras = mapOf("coupon" to "SPRING"),
    options = TrackOptions { immediate = true },
)

An extra named like an argument is dropped with a warning when that argument is set. Durations are milliseconds in a parameter ending in Ms, and are sent in seconds as text: positionMs = 1_500 sends position as "1.5s". Values are sent as given - the schemas name and type the properties, but ranges and patterns aren’t checked on the device.

The kinds are constants in Events (Events.ADD_TO_CART is "add_to_cart"). Tracking a well-known kind with Pug.track(kind, properties) works too, and a whole number given for a decimal property is sent as a decimal ("price" to 10 arrives as 10.0), so both paths fill the same column. With debug on, such an event missing a required property is logged.

From Java

Pug.init(this, "YOUR_PROJECT_ID",
        new Options.Builder("pub_your_public_key")
                .debug(BuildConfig.DEBUG)
                .defaultTrackingConsent(TrackingConsent.DENIED)
                .build());

Map<String, Object> properties = new HashMap<>();
properties.put("cartId", "c1");
Pug.track("checkout_started", properties);
Pug.track("order_completed", properties, new TrackOptions.Builder().immediate(true).build());

Pug.track(new PurchaseEvent().orderId("o-1").amount(89).currency("USD").extra("coupon", "FALL10"));
Pug.track(new AddToCartEvent("sku-1").price(9.99).quantity(2));
Pug.track.signup(); // an event without properties has no class

Each well-known event with properties has a class in sh.pug.android.events: the properties the event requires are constructor arguments, every other property has a setter (numbers as primitives, so amount(89) compiles), and extra(key, value) adds one the event doesn’t define. A property never set isn’t sent. From Java, Pug.track has methods only for the events without properties, such as Pug.track.signup(), so each event has one typed form there. A null passed for a required argument drops the event with a warning instead of throwing, and the Events constants work in a switch.

Identify and sessions

Identity turns anonymous events into actionable profiles. The pattern: events accumulate against an anonymous ID, you Pug.identify() on sign-in to merge that history into a known profile, and Pug.reset() on sign-out. Use a stable, unique external ID - your database user ID, not an email (which can change). Only the first identify after an anonymous session merges the prior activity; the same external ID always resolves to the same profile, and traits are merged (never removed) server-side.

// On sign-in
Pug.identify("user-123", mapOf("email" to "user@example.com", "plan" to "pro"))

// On sign-out
Pug.reset()

// New session ID, identity kept
Pug.rotateSession()

Pug.identify(externalId, traits) returns at once and never throws. Once the server accepts it, events carry the external ID as their distinct ID. Traits may nest maps and lists. A blank ID, or one starting with the reserved cookieless- prefix, is rejected with an error in the log. A failed identify is logged and not retried, so events keep their current distinct ID until a later call succeeds.

reset() starts a new anonymous ID and session and forgets the external ID (the profile stays in the dashboard). Events queued before it keep the user they were tracked for, and it sends them without waiting for the next batch. Pug.flush() sends what’s queued now and writes the SDK’s state to disk; Pug.destroy() stops the SDK after a final flush of at most ten seconds, keeping identity, saved consent and undelivered events for the next Pug.init, which waits for the teardown to finish.

Function Forgets the external ID New anonymous ID New session ID
rotateSession() No No Yes
reset() Yes Yes Yes

Sessions

Sessions group events within a single visit. The SDK creates a session ID on the first event, extends it on each event within the idle window, and starts a new one once the idle or max-duration timeout passes - identity is preserved across the boundary.

First eventsession ID created
Events within timeoutsame session ID
Idle past timeoutsession expires
Next eventnew session ID (identity kept)

The session is stored with the rest of the SDK’s state and restored on the next launch, with the timeouts checked again. Configure them at init with sessionIdleTimeoutMs and sessionMaxDurationMs. For the anonymous->identified model, see Core concepts.

Auto-tracking

With the defaults, the SDK tracks the app’s lifecycle, its activities and its installs with no Pug.track calls:

Event When Option
app_open The app comes to the foreground: an activity starts while none was started. autoOpenCloseEvents
app_close The app leaves the foreground: its last started activity stops, but not for a rotation or other configuration change. Sent at once, not batched. autoOpenCloseEvents
screen_view An activity resumes on a different screen: screenName is its class’s simple name, screenClass the full one. autoScreenViews
app_install The first launch with the SDK on this device, with appVersion and installSource. autoInstallEvents
app_update The first launch of a new app version, with appVersion and previousVersion. autoInstallEvents

Every event also carries the SDK’s auto-properties - device, OS, app version, display, locale, time zone and the current screen - and the server adds geo. $networkType is set only when your app holds ACCESS_NETWORK_STATE, which the SDK doesn’t declare. The device name is never collected.

When the app leaves the foreground the SDK sends what is queued and writes its state to disk, whatever autoOpenCloseEvents says, since the process may be frozen or killed from then on.

Screens are activities until you name them. An activity that resumes after being only paused, as under a permission dialog, is the same screen and isn’t tracked again. For a screen that isn’t an activity, such as a composable or a fragment, call Pug.screen:

Pug.screen("Settings")

It tracks a screen_view unless that screen is already the current one, and every event after it carries the screen as $url and the one before as $referrer. Activities feed the same state, so in a single-activity app - Compose, or fragments under one activity - set autoScreenViews = false and name every screen yourself; otherwise the activity is reported as the screen each time it resumes, as when the app returns to the foreground. With Jetpack Navigation, a destination listener does it:

navController.addOnDestinationChangedListener { _, destination, _ ->
    (destination.route ?: destination.label)?.let { Pug.screen(it.toString()) }
}

Installs and updates are decided once at launch, from the SDK’s own state on the device: app_install when the SDK has never run there, and app_update, carrying previousVersion, on the first launch after versionName changes. An app without a versionName reports neither. installSource is the package name of the app that installed yours, such as com.android.vending for Google Play, and is left out when none is recorded, as for an app installed with adb.

Because the check reads the SDK’s state rather than the app’s install date, adding the SDK to an app that is already out reports each existing user’s first launch with it as an app_install. Turning autoInstallEvents on later, in an app already shipping the SDK, doesn’t, and neither does a destroy-and-init cycle.

sh.pug:android gates capture with two states, TrackingConsent.GRANTED and TrackingConsent.DENIED, and starts granted. To wait for the user’s choice, start denied, and show your banner while the choice is still pending:

Pug.init(this, "YOUR_PROJECT_ID", Options("pub_your_public_key") {
    defaultTrackingConsent = TrackingConsent.DENIED
})

Pug.whenConsentReady { if (Pug.isConsentPending()) showConsentBanner() }

Pug.optInTracking()   // the user accepted
Pug.optOutTracking()  // the user declined

The choice is saved, so the banner shows only until the user answers, and an opt-out made in your settings screen still holds after a restart. While consent is denied, events and identify are dropped, not kept for later. Opting out drops queued events unsent and removes the stored identity; opting in again starts a new anonymous identity.

When the user opts in, the SDK tracks what consent held back: an app_install or app_update it couldn’t track at launch and, if the app is on screen, app_open and a screen_view of the current screen. A consent banner on the first launch therefore loses none of them.

The consent getters answer at once and never wait. Right after Pug.init, while the SDK is still reading the saved choice on its own thread, they answer “not known yet” - which is why the banner check above runs in Pug.whenConsentReady { ... }, whose block runs on the main thread as soon as they answer for this user, or on the main thread’s next turn if they already do.

Call Returns Not known yet
getTrackingConsent() The consent in effect null
isTrackingEnabled() Whether consent is granted false
isConsentPending() Whether the user has yet to choose true

If your app keeps the choice itself and calls optInTracking() or optOutTracking() on every launch, set persistTrackingConsent = false: the choice then lasts until Pug.destroy() or the process ends. Consent is independent of dryRun, which suppresses delivery without changing consent.

Redacting PII

sh.pug:android has no device-side redaction - no beforeSend hook - so keep PII out of the properties you pass to Pug.track yourself.

Upgrading

0.1.0 is the first release of sh.pug:android, so there is nothing to upgrade from yet.

Navigation

Type to search...

↑↓ navigate↵ selectEsc close