Skip to content
Documentation menuAndroid SDK (Kotlin)

Postlip Metrics for Android

Native Android SDK of Postlip Metrics. It speaks the Segment Tracking API Spec (see docs/api/tracking-api.md): screens, product events, identity and consent, plus automatic sessions, app lifecycle events, install attribution from Google Play, and crash and ANR capture. Kotlin with a Java-friendly facade, minSdk 21, one dependency (the Google Play Install Referrer library). It sends no device id and reads no IP address.

This page is generated at build time from packages/sdk-android/README.md in the repository.

Install

KOTLIN
// build.gradle.kts of the app
dependencies {
    implementation("com.postlip:metrics-android:0.1.0")
}

The library declares only INTERNET. Two optional additions in the app:

AddGives
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />context.network (wifi / cellular). Without it the field is left out.
implementation("com.google.android.gms:play-services-ads-identifier:18.2.0") and <uses-permission android:name="com.google.android.gms.permission.AD_ID" />The advertising id, sent only with marketing consent (see Consent). The SDK reads it by reflection and has no compile dependency on Play services; its R8 rules ship in the AAR.

Quick start

KOTLIN
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        PostlipMetrics.load(this, "pk_live_…", PostlipMetricsOptions(trackScreens = true))
    }
}

PostlipMetrics.screen("ProductDetail", mapOf("sku" to "KUPA-1"))
PostlipMetrics.identify("usr_7c1e", mapOf("email" to "deneme@example.com", "plan" to "pro"))
val id = PostlipMetrics.track("purchase_completed", mapOf("order_id" to "HP9f2c", "value" to 240, "currency" to "TRY"), "purchase:HP9f2c")
PostlipMetrics.consent(analytics = true, marketing = false)

Java:

JAVA
PostlipMetrics.load(this, "pk_live_…", PostlipMetricsOptions.builder().trackScreens(true).build());
String id = PostlipMetrics.track("purchase_completed", props, "purchase:HP9f2c");
PostlipMetrics.consent(true, null);

Call load() in Application.onCreate: the cold start, the first activity and early crashes are then seen. example/ is a runnable app (Activities built in code, a local collector at 10.0.2.2:7110).

API

All calls are non-blocking and thread-safe and never throw into the app. Calls made before load() or before the remote config arrived (at most 3 s) are buffered, at most 200 (the oldest dropped), and sent with their call time as timestamp.

CallNotes
load(context, writeKey, options?)Once per process; a second call is ignored and logged. Reads GET {host}/v1/config/{writeKey} (kept in memory for the process), restores the stored state and observes the app's lifecycle. If the config fails, built-in defaults are used.
track(event, properties?, messageId?) → StringProduct event. event is trimmed, 1–200 characters; names starting with $ are reserved for system events and refused (logged). messageId (1–100 printable ASCII characters, no spaces) is the id a server copy of the same event carries, so the event is stored once; anything else is ignored and the SDK makes its own (UUID v4). Returns the message's id — also before load() finished and for a refused event: pass it to an ad platform's app SDK (Meta, TikTok) as its event id.
screen(name, properties?)Screen view (type: "screen"). name is trimmed, 1–500 characters. properties["path"], a string starting with /, becomes the screen's path; else / + name.
identify(userId?, traits?)userId (trimmed, ≤ 200) must be opaque (no e-mail or phone). It is kept for every later message until reset() (stored only with analytics consent). Traits are sent as given and never stored by the SDK. The identity traits (email, phone, firstName, lastName, name, address.{street, city, postalCode, country, state}) open or update the person's encrypted Mailing & SMS contact and are never stored with the event (Tracking API §4.1). identify(traits = mapOf("email" to …)) without a user id identifies a guest.
group(groupId, traits?)Links the person to an account. An empty id is logged and nothing is sent.
alias(userId, previousId?)previousId defaults to the current user id, else the anonymous id. The kept user id does not change.
reset()On logout: new anonymous id, no user id, new session. Consent stays.
consent(analytics?, marketing?)Applies and remembers the user's choice; only the given flags change (see below).
flush()Sends the queue now (fire-and-forget).
getAnonymousId(), getContext()getContext() returns { anonymousId, userId?, session: { id }, consent: { analytics, marketing } }: forward it as the context of an event your backend sends for this user, so it joins this device's session and carries its consent. Both return null until the SDK is ready (shortly after load()).

Properties and traits take JSON values: strings, numbers, booleans, null, maps, lists and arrays. Dates and calendars become ISO 8601 strings, NaN and Infinity become null, other objects their toString(); org.json objects keep their structure. A message over 32 KB is dropped and logged.

Options

PostlipMetricsOptions (Kotlin named arguments, or PostlipMetricsOptions.builder() from Java). Options override what the panel configured.

OptionDefault
hosthttps://e.postlip.comCollector origin; trailing slashes are removed.
consentthe remote config's consentMode, else REQUIREDREQUIRED · IMPLIED · NONE (see below).
debugfalseLogs every message, drop, retry and server rejection (rejected[], warnings[]) to Logcat, tag PostlipMetrics. A refused write key (401) is always logged.
flushAt20Queue size that triggers a send; also the maximum messages per request (1–20).
flushInterval30000 (ms)Longest time a message waits before a send.
maxQueueSize1000Queue bound; the oldest messages are dropped first.
sessionTimeoutMinutesthe remote config's, else 30Background time after which a new session starts; 5–240.
trackLifecycletrue$app_open, $first_open, $app_updated.
trackScreensfalseAutomatic screen views of activities.
captureCrashestrue$crash for uncaught exceptions.
captureAnrtrue$crash with kind: "anr" (with captureCrashes).
collectInstallReferrertrueGoogle Play Install Referrer for $first_open.
collectAdvertisingIdtrueStill only with marketing consent.
remoteConfigtruefalse skips GET /v1/config (built-in defaults and these options).

Of the remote config's autocapture switches, pageviews: false turns automatic screen views off (even with trackScreens), pageleave: false turns screen leave off, errors: false turns crash and ANR capture off. A test source (test: true) marks every message context.test.

ModeBefore consent()
REQUIRED (default)analytics = marketing = false. Messages are still sent (screens, $app_open, track, identify) and counted cookieless by the server; ids, session and queue live in memory only, so every launch is a new anonymous visitor. No $first_open / $app_updated, no crash capture, no screen leave, no advertising id, the install referrer is not read.
IMPLIEDEverything is on until the user declines.
NONEEverything is on (the app does not ask).
  • analytics controls persistence (ids, user id, session, the offline queue, lifecycle markers, pending crash reports), $first_open / $app_updated, crash and ANR capture and screen leave ($pageleave).
  • marketing controls the advertising id and the click ids in the install referrer.
  • The decision itself is stored and wins over the mode's default at the next start. Without analytics consent it is the only thing stored.
  • Granting analytics keeps the same anonymous id (earlier events join), stores the in-memory state and queue, then runs the first-open / update checks and installs crash capture. Revoking it deletes everything stored except the decision; the in-memory ids and queue stay for the rest of the process. Revoking marketing stops the advertising id at once.
  • Every message carries context.consent.

Automatic capture

EventWhen
$app_openCold start (load() with the app in the foreground, or the first activity start) and every return to the foreground; once per foreground. { from_background }, false on a cold start. Sent with or without analytics consent.
$first_openOnce per install, with analytics consent. Its timestamp is the launch's start (the load() call, also when consent comes later in that launch), so it is never later than the launch's other messages and its install source becomes the session's touch; it waits for the install facts (at most 10 s) and carries context.install. If the process dies first, the next launch sends it with the stored timestamp, session and message id.
$app_updatedWith analytics consent, at start, when the app's version or build differs from the stored one: { previous_version, previous_build, version, build }.
screenWith trackScreens: each resumed activity, named by its own android:label if set, else its simple class name. Resuming the same activity (a dialog, a return from the background) continues its screen view. Call screen() yourself for fragments or Compose destinations.
$pageleaveWith analytics consent: when the next screen view starts (final: true) and when the app goes to the background (final: false; coming back continues the same screen view). { path, pageview_id, active_ms, total_ms, final } — active_ms is foreground time on the screen view, total_ms the time since it started. No scroll depth in apps.
$crashSee Crashes and ANRs.

Sessions follow the foreground: a new session starts on the first message, after reset(), and when the app comes to the foreground after more than sessionTimeoutMinutes in the background (or since the last message sent in the background, e.g. from a push handler). While the app is in the foreground a session never expires. The first message of a session carries context.session.start: true. With trackScreens, a new session at foreground sends the current screen again. Every message carries context.app, device (type, manufacturer, model), os, screen, locale, timezone, userAgent, session and consent.

Install attribution

$first_open carries context.install:

JSON
{ "installer": "google_play",
  "referrer": "utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=Cj0KCQ",
  "referrerClickAt": "2026-11-10T09:10:00.000Z", "installBeginAt": "2026-11-10T09:11:00.000Z" }
  • referrer is the Google Play Install Referrer string as returned. Without marketing consent at the moment the event is queued, its click-id parameters (gclid, gbraid, wbraid, dclid, fbclid, msclkid, ttclid, li_fat_id, yclid) are removed; the utm_* stay.
  • installer is google_play for installs from the Play Store, else the installer's package name; left out when unknown.
  • The referrer is read once per install, only with analytics consent, and never blocks load().

Crashes and ANRs

With analytics consent and captureCrashes, the SDK installs a default uncaught exception handler that writes the crash to a file synchronously and then hands it to the previously installed handler (the crash is never swallowed; other crash reporters keep working). The next launch sends it as $crash with the crashed process's own timestamp, anonymous id, user id and session. With captureAnr, a watchdog notices the main thread being blocked for 5 s or more while the app is in the foreground (not while a debugger is attached) and queues a $crash with kind: "anr" at once.

JSON
{ "path": "/Checkout", "kind": "crash", "fatal": true, "type": "java.lang.IllegalStateException",
  "message": "Payment state [redacted]", "stack": "at com.example.Checkout.pay(Checkout.kt:42)\n…",
  "stack_hash": "<32 hex>", "thread": "main", "app_version": "1.4.0", "app_build": "140" }
  • path is the current screen's path, else /. stack holds the top frames first and one Caused by, at most 4000 characters.
  • Before anything is written, message and stack are redacted on the device: e-mail addresses and runs of 7+ digits become [redacted], URL query strings are removed, absolute user paths become ~/. The server scrubs again.
  • stack_hash is SHA-256 (first 32 hex characters) of the exception type and each frame's class and method, without line numbers or the message, so the same crash groups across launches and builds.

Privacy

  • No device.id: no ANDROID_ID and no other hardware id. The advertising id is sent only with marketing consent and when the user has not limited ad tracking.
  • The SDK reads no IP address. The server sees the request's IP address and user agent and stores neither with the event (Tracking API §5, §9).
  • The user agent is PostlipMetrics/0.1.0 (Linux; Android <version>; Mobile) (Mobile on phones only). It never carries the app's name, package or the device model.
  • Storage lives in noBackupFilesDir/postlip-metrics/, which is never restored to another device. Without analytics consent only the consent decision is stored.
  • Traits are never stored by the SDK; crash reports are redacted before they touch the disk.

Transport

Messages are queued and sent as POST {host}/v1/batch with Authorization: Basic base64("<writeKey>:"), a JSON body { batch, sentAt } (sentAt set at each attempt, so the server can correct the device clock) and at most flushAt (≤ 20) messages and 512 000 bytes per request, one request at a time. A send happens when the queue reaches flushAt, after flushInterval, when the app goes to the background, on flush() and once after load(). With analytics consent the queue is stored (one file per message, written atomically off the main thread) and survives restarts and offline periods; it is bounded by maxQueueSize and messages older than 30 days are dropped.

AnswerAction
200Done. In debug mode, rejected[] and warnings[] are logged; rejected messages are never resent.
400, 403 (and other 4xx except 408)Dropped and logged.
401Kept and retried with backoff; logged as an error each time (check the write key).
413The batch is split in two; a single message is dropped.
429Kept; the same batch is resent after Retry-After seconds (else backoff).
5xx, network error, timeoutKept; backoff min(24 h, 10 s × 4^attempt) ± 20 %.

The backoff resets on success and when the app returns to the foreground. Timeouts: connect 10 s, read 20 s.