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:
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.
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 classEach 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.
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.
Consent and privacy
Tracking consent
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 declinedThe 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.