# appmetrica.online documentation > Every page of the English documentation as Markdown, in the order of the sidebar, with complete code for every recipe. Integration instructions for coding agents: https://appmetrica.online/llms.txt --- # Quick start URL: https://appmetrica.online/docs How to start sending session_start and session_end events without an SDK. appmetrica.online receives system events from your apps over HTTP. There is no SDK: the app itself sends `session_start` when it comes to the foreground and `session_end` when it goes to the background. Every report is built from these two events: active and new users, sessions and their duration, retention, realtime and profiles. ## 1. Create an app In the portfolio, click **Add app** and choose the platforms and the time zone. An app is one product: if it ships on iOS, Android and the web, check all three platforms instead of creating three apps. The time zone sets the day boundaries in reports; if you change it later, past days are recalculated in the new zone. Each platform gets its own write key `pk_…`, and the **Integration** tab shows the keys and ready-made snippets for every platform. You can add platforms later in the app settings. The write key is not a secret: it is embedded in the app and visible to anyone who unpacks it. Rate limits protect against abuse, and a compromised key can be revoked and replaced with a new one. ## 2. Send your first event Test the key from a terminal. Put it in the `AM_PK` variable: ```bash export AM_PK="pk_…" ``` and send a single event. The examples in these docs use the placeholder domain `analytics.example.com`: replace `i.analytics.example.com` with the ingest address of your installation, which you'll find on the **Integration** tab. ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") curl -sS https://i.analytics.example.com/v1/events \ -H "Content-Type: application/json" \ -H "X-App-Key: $AM_PK" \ --data-binary @- < Bool { _ = InstallID.current // reads install_id from the Keychain or creates it Task { await OfflineQueue.shared.flush() } // flushes the queue from previous launches return true } } ``` ```swift tab="SceneDelegate" title="SceneDelegate.swift" import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? func sceneDidBecomeActive(_ scene: UIScene) { SessionTracker.shared.start() } func sceneDidEnterBackground(_ scene: UIScene) { // iPad can have several windows: the session ends when all scenes are in the background. let foreground = UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } if !foreground { SessionTracker.shared.end() } } } ``` An app without a `SceneDelegate` can use the option from the [recipe](https://appmetrica.online/docs/recipes/ios-swift#session-lifecycle): subscribing to `UIApplication.didBecomeActiveNotification` and `didEnterBackgroundNotification`. ### Extensions and widgets Widgets, Share Extensions, Notification Service Extensions and other extensions run in separate processes and don't send sessions: - the system launches them without the user, to refresh a widget timeline or handle a push. Each such launch would become a "session", and the installation an active user; - an extension has its own bundle ID, and therefore its own `UserDefaults` and Keychain items. Without a shared App Group or keychain access group, it would create its own `install_id`, and a second user would show up in reports. If the user opened the app from a widget or a notification, the app itself starts the session. ## Android The entry point is `Application.onCreate`, and sessions follow `ProcessLifecycleOwner`. It reports on the process as a whole: `onStart` means the first activity appeared on screen, `onStop` means the last one left the screen. `onStop` arrives with a delay of about 700 ms, so screen rotation and transitions between activities don't interrupt the session. ```kotlin title="App.kt" import android.app.Application import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner class App : Application() { lateinit var analytics: SessionTracker private set override fun onCreate() { super.onCreate() // The process also starts without UI (push, WorkManager): only setup happens here. val queue = EventQueue(this) analytics = SessionTracker(this, queue) ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) = analytics.start() override fun onStop(owner: LifecycleOwner) = analytics.end() }) queue.flush() // flushes the queue from previous launches } } ``` Why not `Activity.onCreate`: - **screen rotation**, a theme or language change recreate the activity: every rotation would open a new session and a new tracker; - **multiple activities**: moving between them produces `onStop` for one and `onStart` for the other, and one session would split into several; - **returning from the background** doesn't call `onCreate` if the activity is still alive, so such sessions would be lost. If the manifest has components with `android:process`, `Application.onCreate` is called in every process. Initialize analytics only in the main one: on API 28+, compare `Application.getProcessName()` with `packageName`. ## Web The analytics module starts once per page load: at the bundle entry point or in the SPA's root component, not on every route. Navigating between SPA pages doesn't start a new session: it lasts as long as the tab is visible. On a multi-page site, include the script in the shared template, not in the code of individual pages. ```js tab="JavaScript" title="main.js" import { start } from "./analytics.js"; start(); // once: subscribes to visibilitychange and pagehide ``` ```tsx tab="Next.js" title="app/layout.tsx" import type { ReactNode } from "react"; import { Analytics } from "./analytics"; export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} {/* The root layout lives as long as the app runs; pages don't. */} ); } ``` `` is the component from the [recipe](https://appmetrica.online/docs/recipes/web-js#setup); pass `userId` from your own session. Inside `start()`, the session is tied to the tab's visibility: ```js title="analytics.js" document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") sessionStart(); else sessionEnd(); // navigator.sendBeacon: delivery survives the tab closing }); window.addEventListener("pagehide", sessionEnd); // tab closed, user navigated away ``` `visibilitychange` with the `hidden` state is the last event the browser reliably fires, on phones too, when the user minimizes the browser and never comes back. `beforeunload` and `unload` often don't fire on mobile, and `unload` also gets in the way of the page cache (bfcache). `session_end` goes out via `navigator.sendBeacon`: the browser may cancel a regular `fetch` from a page that is closing. ## React Native Subscribe to `AppState` in the root component, which lives as long as the app runs. The `inactive` state exists only on iOS (the notification shade, the app switcher, an incoming call); skip it. ```tsx title="App.tsx" import { useEffect } from "react"; import { AppState } from "react-native"; import { analytics } from "./analytics"; import { RootNavigator } from "./navigation"; export default function App() { useEffect(() => { if (AppState.currentState === "active") analytics.start(); const subscription = AppState.addEventListener("change", (state) => { if (state === "active") analytics.start(); else if (state === "background") analytics.end(); }); return () => subscription.remove(); }, []); return ; } ``` React Native has neither `localStorage` nor `sendBeacon`, so `install_id` is stored in the Keychain on iOS and in AsyncStorage on Android, the queue lives in AsyncStorage, and `session_end` goes out via `fetch`: whatever doesn't make it out is sent on the next launch. The tracker, the queue and picking the key by `Platform.OS` are in the [React Native recipe](https://appmetrica.online/docs/recipes/react-native). ## Flutter The entry point is `main()`. A `WidgetsBindingObserver` receives the app states: `resumed` opens a session, and `paused` closes it on iOS and Android. On desktop `paused` never arrives, and the background there is `hidden`: the window is minimized or hidden. `inactive` (the notification shade, a call, an unfocused window) isn't the background yet. ```dart title="main.dart" import 'dart:io'; import 'package:flutter/widgets.dart'; import 'analytics/session_tracker.dart'; import 'app.dart'; final _desktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; void main() { WidgetsFlutterBinding.ensureInitialized(); analytics.flush(); // one tracker per process; may launch in the background, no session_start WidgetsBinding.instance.addObserver(AnalyticsLifecycle()); if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { analytics.start(); // the app is already on screen } runApp(const NotesApp()); } class AnalyticsLifecycle with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.resumed: analytics.start(); case AppLifecycleState.paused: analytics.end(); // iOS and Android case AppLifecycleState.hidden when _desktop: analytics.end(); // macOS, Windows, Linux: paused never happens here default: break; // inactive is not the background yet } } } ``` `analytics` is the tracker from the [Flutter recipe](https://appmetrica.online/docs/recipes/flutter#session-lifecycle): `duration_ms` from a `Stopwatch` (a monotonic clock), a file-backed queue, sending via `package:http`, and the key and `context.platform` picked by the current OS. ## Kotlin Multiplatform The tracker, the queue and `install_id` live in the shared module, while the entry points stay native, as in the [Android](#android) and [iOS](#ios-swiftui) sections: `Application.onCreate` with `ProcessLifecycleOwner`, and `App` with `@main` and `scenePhase`. The shared code knows nothing about the lifecycle: the platform itself calls `SessionTracker.start()` and `end()`. The subscription isn't moved into shared code: `scenePhase` is a SwiftUI API and can't be read from Kotlin. ```kotlin tab="Android" title="App.kt" import analytics.SessionTracker import analytics.initAnalytics import android.app.Application import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner class App : Application() { override fun onCreate() { super.onCreate() initAnalytics(this) // Context for the actual functions: SharedPreferences and filesDir ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) = SessionTracker.start() override fun onStop(owner: LifecycleOwner) = SessionTracker.end() }) SessionTracker.flush() // flushes the queue from previous launches } } ``` ```swift tab="iOS" title="iOSApp.swift" import Shared import SwiftUI @main struct iOSApp: App { @Environment(\.scenePhase) private var scenePhase init() { SessionTracker.shared.flush() // may launch in the background, so no session_start } var body: some Scene { WindowGroup { ContentView() } .onChange(of: scenePhase) { _, phase in switch phase { case .active: SessionTracker.shared.start() case .background: SessionTracker.shared.end() default: break } } } } ``` `SessionTracker` is the shared tracker from the [Kotlin Multiplatform recipe](https://appmetrica.online/docs/recipes/kotlin-multiplatform#session-lifecycle): each platform's `actual` functions supply the key and `context.platform`. ## Bots and backends A bot or a backend has no foreground. The tracker is created once when the process starts, and a session is opened by the user's first update after a pause. Each subsequent message extends the session, and the server closes it on a timer once the user has been silent for 30 minutes. When the process stops, open sessions are closed and the queue is sent. A ready-made Go tracker is in the [Bots and backends](https://appmetrica.online/docs/recipes/bots-and-backends#server-sessions) recipe. ## Anti-patterns - **Initializing on every screen.** Each screen opens its own session and subscribes to the lifecycle all over again: many times more sessions, an understated average duration, duplicate `session_end` events. - **Sending from widgets and extensions.** System launches without the user inflate active users, and the extension's separate storage creates extra installations. - **Synchronous sending on the main thread.** The UI freezes for the duration of the request. Android throws `NetworkOnMainThreadException`, iOS may terminate an app that stays unresponsive for too long at launch, and without a network the event is lost. Put the event in the queue and send it in the background. - **A new `install_id` on every launch.** Every launch is a new user: everyone is "new", retention is close to zero, and active users are counted by launches. Create `install_id` once and keep it permanently. - **`session_start` at the entry point.** Background launches from pushes and schedules turn into sessions the user never saw. ## install_id and user_id `install_id` is created on first launch and read at the entry point, before the first event: | Platform | Where to store it | |---|---| | iOS | Keychain, as in the [recipe](https://appmetrica.online/docs/recipes/ios-swift#install-id) | | Android | `SharedPreferences` with Auto Backup, as in the [recipe](https://appmetrica.online/docs/recipes/android-kotlin#install-id) | | Web | `localStorage` | | React Native | Keychain on iOS, AsyncStorage on Android, as in the [recipe](https://appmetrica.online/docs/recipes/react-native#install-id) | | Flutter | Keychain on iOS and macOS, `shared_preferences` elsewhere, as in the [recipe](https://appmetrica.online/docs/recipes/flutter#install-id) | | Kotlin Multiplatform | `SharedPreferences` on Android; `NSUserDefaults` or Keychain on iOS, see the [recipe](https://appmetrica.online/docs/recipes/kotlin-multiplatform#install-id) | | Bot | Derived from the chat ID via HMAC, see the [recipe](https://appmetrica.online/docs/recipes/bots-and-backends#identity) | What losing it costs you and what survives a reinstall is covered in [Identity](https://appmetrica.online/docs/concepts/identity#install-id). Set `user_id`: - right after a successful sign-in, so the next event already carries it; - at launch, if the user signed in during a previous run, before the first `session_start`; - to `nil` or `null` after sign-out. `install_id` stays the same. `user_id` is an account ID from your backend, not an email address or a phone number. An event's time is captured when the event happens, not when it's sent: the queue delivers events up to 7 days late (see [Time and the offline queue](https://appmetrica.online/docs/concepts/time-and-offline)). --- # Identity URL: https://appmetrica.online/docs/concepts/identity install_id, user_id and how they turn into people in reports. Every event carries an `install_id`, the installation identifier, and, if the user is signed in, a `user_id`, the identifier of their account. By default, reports count people, not devices: several installations of one account are one person. ## install_id A UUID that the app generates once on first launch and keeps permanently. Any UUID version works except the nil UUID `00000000-0000-0000-0000-000000000000`; we recommend a lowercase UUIDv4. | Platform | Where to store it | Survives a reinstall | |---|---|---| | iOS, macOS | Keychain | Yes | | Android | SharedPreferences with Auto Backup | Yes, if backup is enabled | | Web | `localStorage` | No: clearing site data means a new installation | | Bot | Derived from the chat ID via HMAC | Yes | | Backend | Configuration or database | Yes | If the `install_id` is lost, the user looks new and retention comes out lower than it is. Don't generate a new `install_id` on every launch. ## user_id An opaque account ID from your app's backend: a primary key, a UUID, an internal number. Length: 1 to 128 characters. Send `user_id` with every event while the user is signed in, and stop sending it after they sign out. The server rejects a `user_id` that looks like an email address or a phone number with the code `user_id_looks_like_pii`. Purely numeric strings are allowed, but Telegram IDs and other public numbers are better hashed on your side — see [Bots and backends](https://appmetrica.online/docs/recipes/bots-and-backends#identity). ## How people are counted Merging happens when a report is built; raw events are never rewritten: 1. An event with a `user_id` belongs to the person `u:`. 2. Anonymous events of an installation belong to its "first owner": the user from the earliest event of that installation that has a `user_id`. The days before sign-in are attributed to that user retroactively. 3. An installation where nobody ever signed in is a separate person `i:`. Example: installation `I1` ran anonymously for three days, and on the fourth day the user signed in as `A`. All four days count as one active person `u:A`, who became new on the first day, not on the day of sign-in. If two accounts signed in on the same device, anonymous events go to the first of them, and events with an explicit `user_id` go to their own account. ## People on multiple platforms An app unites all of its platforms, and merging works across them: a user who signed in as `A` on iOS and on the web is one person `u:A`. That's why aggregated reports count people rather than adding up platforms. Only `user_id` merges people: anonymous installations on different platforms stay different people, so send the same `user_id` everywhere the user is signed in. The platform switcher above the reports keeps only one platform's events. A person active on iOS and on the web is counted in each of them, so the sum across platforms can exceed the total. Platform shares in the portfolio are counted the same way. If the platforms of one product are connected as separate apps, people aren't merged between them. Merge such apps in the settings — see [Merging apps](https://appmetrica.online/docs/privacy-and-gdpr#merge). ## persons and installs Reports and the Read API accept a `unit` parameter: - `persons` (default) — people, according to the rules above; - `installs` — each installation on its own, as in AppMetrica. Use this mode to reconcile numbers with AppMetrica. A person's profile is opened by `person_key`: `u:` or `i:`. A colon is allowed in the URL, and a `/` inside `user_id` is encoded as `%2F`. --- # Sessions URL: https://appmetrica.online/docs/concepts/sessions When to send session_start and session_end, and how metrics are calculated from them. A session is a continuous stretch of time while the app is in the foreground. The app sends two events with a shared `session_id`: - `session_start` — when it comes to the foreground; - `session_end` — when it goes to the background, with a `duration_ms` field. ## Client rules 1. **A new `session_id` every time the app comes to the foreground.** The UUID is generated on the client; both events of a session carry the same `session_id` and `install_id`. 2. **The client calculates `duration_ms`**: time in the foreground in milliseconds, from 0 to 86,400,000. Use a monotonic clock (`systemUptime`, `SystemClock.elapsedRealtime`, `performance.now`), not a wall-clock difference: the user may change the clock during a session. 3. **Short trips to the background end a session too.** The server doesn't stitch sessions across pauses. If you want to ignore momentary switches (the notification shade, a system dialog), react to "background" rather than "inactive", as the recipes do. 4. **No foreground means activity-based sessions.** For bots and backends, a session is a series of user requests with no pauses longer than 30 minutes; see [Bots and backends](https://appmetrica.online/docs/recipes/bots-and-backends#server-sessions). Which lifecycle events start and end a session on each platform is covered in [Where to initialize analytics](https://appmetrica.online/docs/concepts/initialization). ## How metrics are calculated | Metric | Definition | |---|---| | Active | People who had at least one session during the period | | New | People whose first event in the app fell within the period | | Sessions | Unique `session_id` values among `session_start` events | | Avg. duration | Average over sessions with a `session_end`; a session's duration is the maximum `duration_ms` of its `session_end` events | | Retention D1, D7, … | Share of a cohort of new users who came back exactly 1, 7, … days after their first event | | Active now | People with a `session_start` in the last 30 minutes whose session hasn't ended yet | What follows from this: - **A lost `session_end`** doesn't lose the session: it is counted by `session_start` but left out of the average duration. If `session_end` events get lost often, the average duration is understated. - **Resending** the same event doesn't inflate the numbers: every metric is calculated over unique identifiers. - **Two `session_end` events** for one session (the client recalculated the duration after coming back): the maximum is used. - **A session that spans midnight** counts the person as active on both days, while the session itself belongs to the day of its `session_start`. Days in reports follow the app's time zone, not the device's. ## Version and environment The batch `context` describes the device and the build: `platform`, `os_name`, `os_version`, `app_version`, `app_build`, `device_model`, `device_type`, `locale`, `environment`. An event's `context` field overrides the batch context field by field. This comes in handy when the offline queue holds events from an older app version: each event goes out with its own version. `environment` is `production` (default) or `development`. Reports show production, and development is turned on with the **Environment** switch. Send `development` from debug builds so that tests don't skew the numbers. --- # Time and the offline queue URL: https://appmetrica.online/docs/concepts/time-and-offline How the server corrects skewed device clocks and how long an event can wait to be sent. A phone's clock can be hours ahead or stuck in 1970, and an event can sit in the queue for several days without a network. The server handles both cases as long as the client reports time correctly. ## Two timestamps in a batch - the event's `timestamp` — when the event happened, by the device clock; - the batch's `sent_at` — when the HTTP request was sent, by the same clock. Both fields are RFC 3339 with an explicit offset (`Z` or `+03:00`), preferably with milliseconds. A string without an offset is rejected with the code `invalid_field`. Set `sent_at` anew on every send attempt, including when you retry a batch. An example of sending late events from a terminal is in the [curl](https://appmetrica.online/docs/recipes/curl#offline) recipe. ## Clock correction The server doesn't trust the device's absolute time, but it does trust the difference between two of its timestamps: ```text event_time = received_at + (timestamp − sent_at) ``` `received_at` is the time the request was received, by the server clock. If the device clock is 3 hours fast, both timestamps are shifted by the same amount, and the difference stays correct. | Situation | Result | |---|---| | `event_time` is more than 10 minutes after `received_at` | The event is accepted with `event_time = received_at` and the warning `timestamp_in_future_clamped` | | `event_time` is more than 7 days before `received_at` | The event is rejected with the code `event_too_old` | | Exactly 7 days | Accepted: the boundary is inclusive | The original `timestamp` is kept as `client_time` and is visible in the profile. ## Offline queue Without a network, events pile up on the device and are sent later. Requirements for the queue: 1. **Store it on disk**, not in memory: the app may be killed while in the background. 2. **Send in batches** of up to 500 events and 1 MiB: on app start, on `session_start` and when the network comes back. 3. **Drop events older than 7 days** before sending: the server would reject them anyway. 4. **Remove events from the queue only after a response.** What to do for each response code is described in [Errors and retries](https://appmetrica.online/docs/errors-and-retries). 5. **Retry the same batch** with the same `insert_id` values. Duplicates don't inflate metrics, so a retry after a timeout is safe even if the first request got through. The per-device limit is 1,000 events at once, then 100 per minute. That's enough to send a week-long queue. Whatever doesn't fit into the limit, the server returns in `rejected` with `retryable: true`: keep those events in the queue and send them a minute later. ## Late events and report days An event that arrives several days late lands on the day it happened. That's why the numbers for past days can grow slightly after the fact, within the 7-day window for late events. After that, events are rejected as `event_too_old`, and the day's numbers no longer change, with two exceptions: changing the app's time zone recalculates every past day in the new zone, and deleting a person's data recalculates the days they were active on. Both are done by the next day-closing run, every 15 minutes. --- # curl URL: https://appmetrica.online/docs/recipes/curl Every request from the terminal — an event, a session, a batch, user_id, late offline delivery, gzip, retries, parsing responses with jq, the Read API, and data deletion. curl is handy for checking a key, eyeballing the server's response, and sending events from shell scripts and cron jobs. A client for backends and CLIs lives in the [Go](https://appmetrica.online/docs/recipes/go) recipe; server-side sessions and Telegram bots are covered in the [Bots and backends](https://appmetrica.online/docs/recipes/bots-and-backends) recipe. The examples assume bash with `curl`, `uuidgen`, and `date`. Parsing responses requires `jq`. ## Environment variables The `pk_…` write key is shown in the dashboard: open the app and go to the **Integration** tab. Each platform of an app has its own key. The key isn't secret — it ships inside the app (see [Quick start](https://appmetrica.online/docs#create-app)). All examples read it from the `AM_PK` variable and pass `context.platform` explicitly, so they work with any platform's key (for how the platform is determined, see [Event platform](https://appmetrica.online/docs#platform)): ```bash export AM_PK="pk_…" ``` Events are accepted by ingest at `/v1/events`. The examples use the placeholder domain `https://i.analytics.example.com`: replace it with your installation's ingest URL, which is already filled in to the snippets on the **Integration** tab. The Read API needs a different, secret key — see [Read API](#read-api). Techniques shared by all the examples: - time — `date -u +"%Y-%m-%dT%H:%M:%S.000Z"`: RFC 3339 in UTC with milliseconds; - identifiers — `uuidgen | tr A-Z a-z`: on macOS, uuidgen prints UUIDs in upper case, while lower case is the recommended format; - `"environment": "development"` — test events stay out of production reports (see [Debugging](#development)); - `"ip": "none"` — don't derive the country and city from the address of the machine running curl (see [context.ip](https://appmetrica.online/docs/recipes/bots-and-backends#context-ip)). ## A single event The smallest possible batch is a single `session_start`: ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") curl -sS https://i.analytics.example.com/v1/events \ -H "Content-Type: application/json" \ -H "X-App-Key: $AM_PK" \ --data-binary @- </dev/null || date -u -v-2d "$FMT") ENDED=$(date -u -d "-2 days +5 minutes" "$FMT" 2>/dev/null || date -u -v-2d -v+5M "$FMT") NOW=$(date -u "$FMT") INSTALL_ID=$(uuidgen | tr A-Z a-z) SESSION_ID=$(uuidgen | tr A-Z a-z) curl -sS https://i.analytics.example.com/v1/events \ -H "Content-Type: application/json" \ -H "X-App-Key: $AM_PK" \ --data-binary @- < batch.json < batch.json < 300 ? 300 : backoff * 2 )) ;; esac echo "attempt $attempt: HTTP $status, retrying in $pause s" >&2 sleep "$pause" done if [ "$status" = 200 ]; then cat response.json else echo "batch not sent: HTTP $status" >&2 [ -f response.json ] && cat response.json >&2 false # non-zero exit code for cron and CI fi ``` Why the other `4xx` responses aren't retried: - `400`, `413`, `415` — the batch is broken, and the same request will get the same response. Fix the client: `error.code` and `error.details` point to the offending field; - `401`, `403` — the key was revoked or the app is archived. Retrying won't help until someone issues a new key or restores the app from the archive. Don't delete the events in the meantime; - `429` is the only temporary `4xx`: the limit frees up after `Retry-After` seconds. Events that come back in `rejected` with `retryable: true` (currently only `install_rate_limited`) should go out in the next batch no sooner than 60 s later. The full table of actions is in [Errors and retries](https://appmetrica.online/docs/errors-and-retries#retry-policy). ## Parsing the response with jq On `200`, the server returns an `IngestResult`: `accepted` is the number of accepted events, `rejected` lists rejected events with a reason code, `warnings` lists events accepted with a correction, and `server_time` is the server's time. In the example, the second event is a `session_end` without `duration_ms`: ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") INSTALL_ID=$(uuidgen | tr A-Z a-z) SESSION_ID=$(uuidgen | tr A-Z a-z) curl -sS https://i.analytics.example.com/v1/events \ -H "Content-Type: application/json" \ -H "X-App-Key: $AM_PK" \ -o response.json \ --data-binary @- < /dev/null ``` A batch error (`4xx`, `5xx`) comes in a different format — an `error` envelope. Its `request_id` matches the `X-Request-Id` header and lets you find the request in the server logs: ```bash jq -r '.error | "\(.code): \(.message) (request_id \(.request_id))"' response.json ``` ## Read API Reports are read with the same curl, just with a different URL and key. The secret `sk_…` key is created under **Settings → API keys** and shown only once; reports require the `read` scope. Unlike `pk_…`, this key never ships inside an app: it unlocks your reports. ```bash export AM_SK="sk_…" ``` ### List apps ```bash curl -sS https://api.analytics.example.com/v1/apps \ -H "Authorization: Bearer $AM_SK" \ | jq -r '.items[] | [.id, .platform, .timezone, .name] | @tsv' ``` ```text 0199d5f0-0000-7000-8000-000000000002 ios Europe/Moscow Notes 0199d5f0-0000-7000-8000-000000000003 android Europe/Moscow Notes ``` The `include_archived=true` parameter adds archived apps. ### Report: daily active users The `id` from the list is the `app_id` in the report URL. Daily active users over a week: ```bash APP_ID="0199d5f0-0000-7000-8000-000000000002" curl -sS -G "https://api.analytics.example.com/v1/apps/$APP_ID/audience/timeseries" \ -H "Authorization: Bearer $AM_SK" \ --data-urlencode "from=2026-10-06" \ --data-urlencode "to=2026-10-12" \ --data-urlencode "metric=active" \ --data-urlencode "granularity=day" \ --data-urlencode "compare=none" \ | jq -r '.points[] | "\(.ts[0:10])\t\(.value)"' ``` ```text 2026-10-06 40987 2026-10-07 41230 2026-10-08 40876 2026-10-09 41502 2026-10-10 39811 2026-10-11 38420 2026-10-12 40115 ``` `ts` is the start of the bucket with the app's time zone offset, so its first 10 characters are the report date. `metric` is `active`, `new`, `sessions`, or `avg_duration_ms`; `granularity` is `hour` (for periods of up to 7 days), `day`, `week`, or `month`. Shared parameters and the other endpoints are described in [Read API: overview](https://appmetrica.online/docs/read-api/overview#params). The Read API is rate-limited: beyond 10 requests per second per key, requests get `429 rate_limited` with `Retry-After`. Retry them with the same logic as ingest batches. ## Deleting user data A deletion request (for example, under GDPR) goes through the Read API with a key that has the `delete` scope. `subject_type` is `user` (by `user_id`: the events of the user and of every install linked to them) or `install` (by `install_id`). An entire app can only be deleted in the dashboard. ```bash APP_ID="0199d5f0-0000-7000-8000-000000000002" REQUEST_ID=$(curl -sS -X POST "https://api.analytics.example.com/v1/apps/$APP_ID/deletion-requests" \ -H "Authorization: Bearer $AM_SK" \ -H "Content-Type: application/json" \ -d '{"subject_type": "user", "subject_value": "acct_8f14e45f"}' \ | jq -r .id) while :; do STATUS=$(curl -sS "https://api.analytics.example.com/v1/apps/$APP_ID/deletion-requests/$REQUEST_ID" \ -H "Authorization: Bearer $AM_SK" | jq -r .status) echo "$STATUS" case "$STATUS" in pending | running) sleep 10 ;; *) break ;; # done, failed, or a request error esac done ``` Creating a request returns `202` and the request itself with the `pending` status. Repeating the call with the same parameters before the deletion finishes returns the same request: ```json { "id": "0199d7a2-4c1e-7b3d-9a5f-2e8c6d0b1f34", "app_id": "0199d5f0-0000-7000-8000-000000000002", "subject_type": "user", "subject_value": "acct_8f14e45f", "status": "pending", "source": "api", "requested_at": "2026-10-14T09:20:11.042Z", "started_at": null, "completed_at": null, "error": null } ``` The status can be read with a key that has the `read` or `delete` scope. `done` means the data has been removed from every table; `failed` means the deletion didn't succeed after several attempts, and the reason is in the `error` field. Learn more in [Privacy and GDPR](https://appmetrica.online/docs/privacy-and-gdpr#deletion). --- # iOS (Swift) URL: https://appmetrica.online/docs/recipes/ios-swift URLSession, install_id in the Keychain, an offline queue, and the session lifecycle for SwiftUI and UIKit. A recipe for iOS 17+ and Swift 6 with no third-party dependencies. It consists of four files: the event model, `install_id` in the Keychain, an offline queue that sends through `URLSession`, and a session tracker that hooks into the app lifecycle. On macOS the code is the same, except for `platform: "macos"`, the macOS platform key, and `NSApplication` notifications. On Apple TV the code is the same with the iOS key: `device_type` becomes `tv` and `os_name` becomes `tvOS` on their own, and the queue file lives in `Caches`, which the system may clear when space runs low (see [TVs, watches and tablets](https://appmetrica.online/docs#device-families)). Embed the iOS platform key from the **Integration** tab. If your app also ships on other platforms, it's one app with several platforms: each has its own key, and a person with the same `user_id` on iOS and Android is counted once — see [Event platform](https://appmetrica.online/docs#platform). Where to create the tracker, why the app rather than a screen owns the session, and why extensions and widgets don't send sessions — see [Where to initialize analytics](https://appmetrica.online/docs/concepts/initialization#ios). ## Model and sending JSON keys are `snake_case`, and times are RFC 3339 with milliseconds. Every event carries its own `context`: if an event sat in the queue until the app was updated, it goes out with the old version. ```swift title="Analytics.swift" import Foundation import UIKit struct EventContext: Codable, Sendable { var platform = "ios" var osName: String var osVersion: String var appVersion: String var appBuild: String var deviceModel: String var deviceType: String var locale: String var environment: String } struct Event: Codable, Sendable { var insertId = UUID().uuidString.lowercased() var name: String var timestamp = Date.now var installId: String var sessionId: String var userId: String? var durationMs: Int? var context: EventContext } struct IngestResult: Decodable, Sendable { struct Rejection: Decodable, Sendable { let index: Int let insertId: String? let code: String let retryable: Bool } let accepted: Int let rejected: [Rejection] } enum Analytics { static let endpoint = URL(string: "https://i.analytics.example.com/v1/events")! static let writeKey = "pk_…" // iOS platform key from the Integration tab struct Response: Sendable { let status: Int let result: IngestResult? let retryAfter: TimeInterval? } static func send(_ events: [Event]) async throws -> Response { struct Batch: Encodable { let sentAt: Date; let events: [Event] } let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase encoder.dateEncodingStrategy = .custom { date, encoder in var container = encoder.singleValueContainer() try container.encode(date.formatted(.iso8601WithMilliseconds)) } var request = URLRequest(url: endpoint, timeoutInterval: 15) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(writeKey, forHTTPHeaderField: "X-App-Key") // sent_at is the moment of this attempt: the server uses it to correct a skewed device clock. request.httpBody = try encoder.encode(Batch(sentAt: .now, events: events)) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return Response( status: http.statusCode, result: http.statusCode == 200 ? try? decoder.decode(IngestResult.self, from: data) : nil, retryAfter: http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init) ) } @MainActor static func currentContext() -> EventContext { let info = Bundle.main.infoDictionary ?? [:] #if DEBUG let environment = "development" #else let environment = "production" #endif let deviceType = switch UIDevice.current.userInterfaceIdiom { case .pad: "tablet" case .tv: "tv" // Apple TV: systemName is already "tvOS" default: "phone" } return EventContext( osName: UIDevice.current.systemName, osVersion: UIDevice.current.systemVersion, appVersion: info["CFBundleShortVersionString"] as? String ?? "", appBuild: info["CFBundleVersion"] as? String ?? "", deviceModel: hardwareModel(), deviceType: deviceType, locale: Locale.current.identifier.replacingOccurrences(of: "_", with: "-"), environment: environment ) } private static func hardwareModel() -> String { var systemInfo = utsname() uname(&systemInfo) return withUnsafeBytes(of: &systemInfo.machine) { raw in String(decoding: raw.prefix(while: { $0 != 0 }), as: UTF8.self) } } } extension FormatStyle where Self == Date.ISO8601FormatStyle { static var iso8601WithMilliseconds: Self { .init(includingFractionalSeconds: true) } } ``` `deviceModel` is a machine name like `iPhone17,3`, not the device name the user set: that name often contains the person's own name. ## install_id in the Keychain `install_id` is created once and stored in the Keychain. Unlike `UserDefaults`, a Keychain item survives deleting and reinstalling the app, so a reinstall doesn't create a new user. `ThisDeviceOnly` keeps the identifier from moving to a new device via a backup: a new phone is a new install, as in AppMetrica. ```swift title="InstallID.swift" import Foundation import Security enum InstallID { private static let service = "online.appmetrica.install-id" static let current: String = load() ?? create() private static func load() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data, let id = String(data: data, encoding: .utf8), UUID(uuidString: id) != nil else { return nil } return id } private static func create() -> String { let id = UUID().uuidString.lowercased() let attributes: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecValueData as String: Data(id.utf8), ] SecItemAdd(attributes as CFDictionary, nil) return id } } ``` `AfterFirstUnlock` lets the identifier be read even when the app launches in the background while the screen is locked. ## Offline queue `OfflineQueue` stores events in a file in Application Support and sends them in batches of up to 500. The action for each server response follows [Errors and retries](https://appmetrica.online/docs/errors-and-retries): - `200` — remove the sent events, except those rejected with `retryable: true`; - `400`, `413`, `415` — drop the batch: retrying won't help; - `401`, `403` — stop sending until the next launch: the key was revoked or the app is archived; - `429`, `503` — retry after `Retry-After`; - anything else and network errors — retry with a delay from 1 s to 300 s. ```swift title="OfflineQueue.swift" import Foundation actor OfflineQueue { static let shared = OfflineQueue() private static let maxAge: TimeInterval = 7 * 24 * 3600 // tvOS gives apps no persistent storage besides the Keychain and UserDefaults. #if os(tvOS) private let fileURL = URL.cachesDirectory.appending(path: "appmetrica-queue.json") #else private let fileURL = URL.applicationSupportDirectory.appending(path: "appmetrica-queue.json") #endif private var events: [Event] private var sending = false private var stopped = false private var retryAt = Date.distantPast private var backoff: TimeInterval = 1 private init() { let data = try? Data(contentsOf: fileURL) events = data.flatMap { try? JSONDecoder().decode([Event].self, from: $0) } ?? [] } func enqueue(_ event: Event) { events.append(event) save() } func flush() async { events.removeAll { $0.timestamp < .now.addingTimeInterval(-Self.maxAge) } guard !sending, !stopped, !events.isEmpty, Date.now >= retryAt else { return } sending = true defer { sending = false; save() } let batch = Array(events.prefix(500)) let sent = Set(batch.map(\.insertId)) do { let response = try await Analytics.send(batch) switch response.status { case 200: let retry = (response.result?.rejected ?? []).filter(\.retryable).compactMap(\.insertId) let done = sent.subtracting(retry) events.removeAll { done.contains($0.insertId) } backoff = 1 retryAt = retry.isEmpty ? .distantPast : .now.addingTimeInterval(60) case 400, 413, 415: events.removeAll { sent.contains($0.insertId) } case 401, 403: stopped = true case 429, 503: retryAt = .now.addingTimeInterval(response.retryAfter ?? 5) default: scheduleRetry() } } catch { scheduleRetry() } } private func scheduleRetry() { retryAt = .now.addingTimeInterval(backoff * .random(in: 0.8...1.2)) backoff = min(backoff * 2, 300) } private func save() { try? FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) try? JSONEncoder().encode(events).write(to: fileURL, options: .atomic) } } ``` The queue keeps `timestamp` as is, so an event sent three days later still lands in the report for the day it happened (see [Time and the offline queue](https://appmetrica.online/docs/concepts/time-and-offline)). ## Session lifecycle `SessionTracker` opens a session when the app comes to the foreground and closes it when the app goes to the background. Duration is measured with `systemUptime` — a monotonic clock that doesn't change when the user adjusts the device time. ```swift title="SessionTracker.swift" import Foundation @MainActor final class SessionTracker { static let shared = SessionTracker() /// Account ID after sign-in; nil if the user isn't signed in. Not an email or phone number. var userId: String? private var sessionId: String? private var startedUptime: TimeInterval = 0 func start() { guard sessionId == nil else { return } sessionId = UUID().uuidString.lowercased() startedUptime = ProcessInfo.processInfo.systemUptime track("session_start", durationMs: nil) } func end() { guard sessionId != nil else { return } let elapsed = ProcessInfo.processInfo.systemUptime - startedUptime track("session_end", durationMs: min(max(Int(elapsed * 1000), 0), 86_400_000)) sessionId = nil } private func track(_ name: String, durationMs: Int?) { guard let sessionId else { return } let event = Event( name: name, installId: InstallID.current, sessionId: sessionId, userId: userId, durationMs: durationMs, context: Analytics.currentContext() ) Task { await OfflineQueue.shared.enqueue(event) await OfflineQueue.shared.flush() } } } ``` Hook the tracker into the app. A session starts in the "active" state and ends in "background": moving to "inactive" (the notification shade, a system alert) doesn't interrupt the session, and calling `start()` again within the same session does nothing. ```swift tab="SwiftUI" title="NotesApp.swift" import SwiftUI @main struct NotesApp: App { @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { ContentView() } .onChange(of: scenePhase) { _, phase in switch phase { case .active: SessionTracker.shared.start() case .background: SessionTracker.shared.end() default: break } } } } ``` ```swift tab="UIKit" title="AppDelegate.swift" import UIKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let center = NotificationCenter.default center.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { _ in MainActor.assumeIsolated { SessionTracker.shared.start() } } center.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { _ in MainActor.assumeIsolated { SessionTracker.shared.end() } } return true } } ``` In the background, the app has little time: if `session_end` doesn't get out in time, it stays in the queue file and is sent on the next launch. ## user_id after sign-in After sign-in, set `SessionTracker.shared.userId` to the account ID from your backend; after sign-out, set it to `nil`. The server retroactively attributes the anonymous events from before sign-in to this user (see [Identity](https://appmetrica.online/docs/concepts/identity)). ```swift tab="Sign in" func didSignIn(account: Account) { SessionTracker.shared.userId = account.id // "a3f9c2d1-77b4-…", not an email } ``` ```swift tab="Sign out" func didSignOut() { SessionTracker.shared.userId = nil } ``` ## Verification 1. Run a debug build: `#if DEBUG` sends events with `environment: "development"`. 2. Send the app to the background and open it again. 3. `session_start` and `session_end` appear on the **Integration** tab, and the session shows up on the **Realtime** tab. To see them in reports, turn on the development environment. --- # Android (Kotlin) URL: https://appmetrica.online/docs/recipes/android-kotlin HttpURLConnection, install_id in SharedPreferences, an offline queue, and sessions via ProcessLifecycleOwner. A recipe for Android 8.0+ (API 26) in Kotlin. Networking and JSON come from the Android standard library (`HttpURLConnection`, `org.json`); the lifecycle comes from `androidx.lifecycle:lifecycle-process` or from dependency-free `ActivityLifecycleCallbacks`. The code has three parts: `install_id`, an offline queue with sending, and a session tracker. Why the tracker is created in `Application.onCreate` rather than in `Activity.onCreate` is explained in [Where to initialize analytics](https://appmetrica.online/docs/concepts/initialization#android). Embed the Android platform key from the **Integration** tab. If your app also ships on iOS, add the Android platform to the existing app instead of creating a second one: each platform has its own key, and a person with the same `user_id` on both is counted once — see [Event platform](https://appmetrica.online/docs#platform). ## install_id `install_id` is created on first launch and stored in `SharedPreferences`. Auto Backup is on by default and saves `SharedPreferences` to Google Drive, so after a reinstall restored from a backup, `install_id` stays the same. ```kotlin title="InstallId.kt" import android.content.Context import java.util.UUID object InstallId { private const val PREFS = "appmetrica" private const val KEY = "install_id" @Volatile private var cached: String? = null fun get(context: Context): String = cached ?: synchronized(this) { cached ?: run { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val id = prefs.getString(KEY, null) ?: UUID.randomUUID().toString().also { prefs.edit().putString(KEY, it).commit() } id.also { cached = it } } } } ``` If your app defines its own backup rules (`android:fullBackupContent` or `android:dataExtractionRules`), include the `appmetrica.xml` file from `sharedpref` in them. ## Offline queue and sending `EventQueue` stores events in a file in `filesDir` and sends them in batches of up to 500 on a single background thread. The action for each response follows [Errors and retries](https://appmetrica.online/docs/errors-and-retries). ```kotlin title="EventQueue.kt" import android.content.Context import org.json.JSONArray import org.json.JSONObject import java.io.File import java.net.HttpURLConnection import java.net.URL import java.time.Instant import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.random.Random class EventQueue(context: Context) { private val file = File(context.filesDir, "appmetrica-queue.json") private val executor = Executors.newSingleThreadScheduledExecutor() private val events = mutableListOf() private var retryAt = 0L private var backoffMs = 1_000L private var stopped = false init { executor.execute { if (file.exists()) { val saved = JSONArray(file.readText()) for (i in 0 until saved.length()) events += saved.getJSONObject(i) } } } fun enqueue(event: JSONObject) = executor.execute { events += event save() flushNow() } fun flush() = executor.execute { flushNow() } private fun flushNow() { val minTime = Instant.now().minusSeconds(MAX_AGE_SECONDS) events.removeAll { Instant.parse(it.getString("timestamp")).isBefore(minTime) } if (stopped || events.isEmpty() || System.currentTimeMillis() < retryAt) return val batch = events.take(MAX_BATCH) val sent = batch.map { it.getString("insert_id") }.toSet() try { val response = post(batch) when (response.status) { 200 -> { val keep = response.retryableIds() events.removeAll { it.getString("insert_id") in sent - keep } backoffMs = 1_000L if (keep.isNotEmpty()) retryLater(60_000L) } 400, 413, 415 -> events.removeAll { it.getString("insert_id") in sent } 401, 403 -> stopped = true 429, 503 -> retryLater((response.retryAfterSeconds ?: 5L) * 1_000L) else -> backoff() } } catch (e: Exception) { // network, timeout, unexpected response backoff() } save() } private fun backoff() { retryLater((backoffMs * Random.nextDouble(0.8, 1.2)).toLong()) backoffMs = minOf(backoffMs * 2, 300_000L) } private fun retryLater(delayMs: Long) { retryAt = System.currentTimeMillis() + delayMs executor.schedule(Runnable { flushNow() }, delayMs, TimeUnit.MILLISECONDS) } private fun save() { val tmp = File(file.path + ".tmp") tmp.writeText(JSONArray(events).toString()) tmp.renameTo(file) } private class Response(val status: Int, val body: JSONObject?, val retryAfterSeconds: Long?) { fun retryableIds(): Set { val rejected = body?.optJSONArray("rejected") ?: return emptySet() return (0 until rejected.length()).map { rejected.getJSONObject(it) } .filter { it.optBoolean("retryable") && !it.isNull("insert_id") } .map { it.getString("insert_id") } .toSet() } } private fun post(batch: List): Response { val body = JSONObject() .put("sent_at", Instant.now().toString()) // the moment of this attempt .put("events", JSONArray(batch)) val conn = URL(ENDPOINT).openConnection() as HttpURLConnection try { conn.requestMethod = "POST" conn.doOutput = true conn.connectTimeout = 10_000 conn.readTimeout = 15_000 conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("X-App-Key", WRITE_KEY) conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) } val status = conn.responseCode val text = (if (status < 400) conn.inputStream else conn.errorStream) ?.bufferedReader()?.use { it.readText() } return Response( status = status, body = if (status == 200 && text != null) JSONObject(text) else null, retryAfterSeconds = conn.getHeaderField("Retry-After")?.toLongOrNull(), ) } finally { conn.disconnect() } } companion object { private const val ENDPOINT = "https://i.analytics.example.com/v1/events" private const val WRITE_KEY = "pk_…" // Android platform key from the Integration tab private const val MAX_BATCH = 500 private const val MAX_AGE_SECONDS = 7L * 24 * 3600 } } ``` `Instant.toString()` produces RFC 3339 in UTC with a `Z` — the server accepts that time as is. ## Sessions `SessionTracker` creates the events. Every event carries its own `context`, so queued events go out with the app version in which they happened. Duration is measured with `SystemClock.elapsedRealtime()` — a monotonic clock. ```kotlin title="SessionTracker.kt" import android.content.Context import android.content.res.Configuration import android.os.Build import android.os.SystemClock import org.json.JSONObject import java.time.Instant import java.util.Locale import java.util.UUID class SessionTracker(private val app: Context, private val queue: EventQueue) { /** Account ID after sign-in; null if the user isn't signed in. Not an email or phone number. */ @Volatile var userId: String? = null private var sessionId: String? = null private var startedAt = 0L @Synchronized fun start() { if (sessionId != null) return sessionId = UUID.randomUUID().toString() startedAt = SystemClock.elapsedRealtime() track("session_start", durationMs = null) } @Synchronized fun end() { if (sessionId == null) return val elapsed = SystemClock.elapsedRealtime() - startedAt track("session_end", durationMs = elapsed.coerceIn(0L, 86_400_000L)) sessionId = null } private fun track(name: String, durationMs: Long?) { val event = JSONObject() .put("insert_id", UUID.randomUUID().toString()) .put("name", name) .put("timestamp", Instant.now().toString()) .put("install_id", InstallId.get(app)) .put("session_id", sessionId) .put("context", context()) userId?.let { event.put("user_id", it) } durationMs?.let { event.put("duration_ms", it) } queue.enqueue(event) } private fun context(): JSONObject { val config = app.resources.configuration val deviceType = when (config.uiMode and Configuration.UI_MODE_TYPE_MASK) { Configuration.UI_MODE_TYPE_TELEVISION -> "tv" Configuration.UI_MODE_TYPE_WATCH -> "watch" else -> if (config.smallestScreenWidthDp >= 600) "tablet" else "phone" } return JSONObject() .put("platform", "android") // Reports filter by os_name: TVs and watches get an OS name of their own. .put("os_name", when (deviceType) { "tv" -> "Android TV"; "watch" -> "Wear OS"; else -> "Android" }) .put("os_version", Build.VERSION.RELEASE) .put("app_version", BuildConfig.VERSION_NAME) .put("app_build", BuildConfig.VERSION_CODE.toString()) .put("device_model", "${Build.MANUFACTURER} ${Build.MODEL}") .put("device_type", deviceType) .put("locale", Locale.getDefault().toLanguageTag()) .put("environment", if (BuildConfig.DEBUG) "development" else "production") } } ``` `BuildConfig` is generated by the Android Gradle Plugin; in AGP 8, enable it in the app module: `android { buildFeatures { buildConfig = true } }`. A session is the time during which at least one of the app's activities is visible. `ProcessLifecycleOwner` reports exactly that and doesn't flicker on screen rotation or transitions between activities. If you'd rather skip the dependency, the same counter of visible activities is easy to build on `ActivityLifecycleCallbacks`. ```kotlin tab="ProcessLifecycleOwner" title="App.kt" import android.app.Application import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner class App : Application() { lateinit var analytics: SessionTracker private set override fun onCreate() { super.onCreate() val queue = EventQueue(this) analytics = SessionTracker(this, queue) ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) = analytics.start() override fun onStop(owner: LifecycleOwner) = analytics.end() }) queue.flush() // sends what's left in the queue from previous launches } } ``` ```kotlin tab="ActivityLifecycleCallbacks" title="App.kt" import android.app.Activity import android.app.Application import android.os.Bundle class App : Application() { lateinit var analytics: SessionTracker private set override fun onCreate() { super.onCreate() val queue = EventQueue(this) analytics = SessionTracker(this, queue) registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { private var started = 0 override fun onActivityStarted(activity: Activity) { if (started++ == 0) analytics.start() } override fun onActivityStopped(activity: Activity) { // Screen rotation recreates the activity: that's not going to the background. if (--started == 0 && !activity.isChangingConfigurations) analytics.end() } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityResumed(activity: Activity) {} override fun onActivityPaused(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) {} }) queue.flush() } } ``` Don't forget `android:name=".App"` in the manifest's `` and the `android.permission.INTERNET` permission. ## user_id after sign-in After sign-in, set `analytics.userId` to the account ID from your backend; after sign-out, set it to `null`. The anonymous days before sign-in get attributed to this user (see [Identity](https://appmetrica.online/docs/concepts/identity)). ```kotlin (application as App).analytics.userId = account.id // not an email or phone number ``` ## Verification 1. Run a debug build: events go out with `environment: "development"`. 2. Send the app to the background and open it again. 3. `session_start` and `session_end` appear on the **Integration** tab. In reports, turn on the development environment. --- # Web (JavaScript) URL: https://appmetrica.online/docs/recipes/web-js Website sessions via visibilitychange, navigator.sendBeacon with text/plain and the key in the body, and a localStorage queue. A recipe for websites and web apps in plain JavaScript with no dependencies. A session is the time the tab is visible to the user. `session_end` is sent with `navigator.sendBeacon`: the browser delivers it even while the tab is being closed. Where to plug the module in for an SPA or a multi-page site is covered in [Where to initialize analytics](https://appmetrica.online/docs/concepts/initialization#web). If your product also has mobile apps, add the **Web** platform to the existing app instead of creating a separate one: then a person with the same `user_id` on the website and in the app is counted once. Embed the **Web** platform key in the module — see [Event platform](https://appmetrica.online/docs#platform). ## Why text/plain and app_key in the body `sendBeacon` can't set headers, so the key goes in the `app_key` body field instead of `X-App-Key`. The body is the same JSON, but sent with `Content-Type: text/plain`: the browser treats such a request as "simple" and sends it without a CORS preflight. Ingest accepts `text/plain` on par with `application/json`. If both the header and `app_key` are present, they must match — otherwise you get `400 invalid_batch`. The server doesn't accept the key in the query string: request URLs end up in proxy logs. ## The analytics.js module ```js title="analytics.js" const ENDPOINT = "https://i.analytics.example.com/v1/events"; const WRITE_KEY = "pk_…"; // Web platform key from the Integration tab const QUEUE_KEY = "am_queue"; const MAX_AGE_MS = 7 * 24 * 3600 * 1000; const MAX_BATCH = 500; let session = null; // { id, startedAt } let userId = null; function installId() { let id = localStorage.getItem("am_install_id"); if (!id) { id = crypto.randomUUID(); localStorage.setItem("am_install_id", id); } return id; } function context() { return { platform: "web", app_version: "1.0.0", // site version: a release number or git sha device_type: matchMedia("(pointer: coarse)").matches ? "phone" : "desktop", locale: navigator.language, environment: location.hostname === "localhost" ? "development" : "production", }; } function newEvent(name, extra = {}) { return { insert_id: crypto.randomUUID(), name, timestamp: new Date().toISOString(), install_id: installId(), session_id: session.id, ...(userId ? { user_id: userId } : {}), context: context(), ...extra, }; } // sent_at is the moment of sending: the server uses it to correct a wrong computer clock. function body(events) { return JSON.stringify({ app_key: WRITE_KEY, sent_at: new Date().toISOString(), events }); } function loadQueue() { try { return JSON.parse(localStorage.getItem(QUEUE_KEY) ?? "[]"); } catch { return []; } } function saveQueue(queue) { localStorage.setItem(QUEUE_KEY, JSON.stringify(queue)); } function enqueue(event) { saveQueue([...loadQueue(), event]); } // Sends the queue via fetch. Only what the server has processed is removed from the queue. export async function flush() { const minTime = Date.now() - MAX_AGE_MS; const queue = loadQueue().filter((e) => Date.parse(e.timestamp) >= minTime); saveQueue(queue); if (queue.length === 0 || !navigator.onLine) return; const batch = queue.slice(0, MAX_BATCH); let done; try { const res = await fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "text/plain" }, body: body(batch), }); if (res.ok) { const result = await res.json(); const retry = new Set(result.rejected.filter((r) => r.retryable).map((r) => r.insert_id)); done = batch.filter((e) => !retry.has(e.insert_id)); } else if ([400, 413, 415].includes(res.status)) { done = batch; // retrying won't help: the bug is in the client code } else { return; // 401, 403, 429, 5xx: keep in the queue until the next attempt } } catch { return; // network unavailable } const sent = new Set(done.map((e) => e.insert_id)); saveQueue(loadQueue().filter((e) => !sent.has(e.insert_id))); } function sessionStart() { if (session) return; session = { id: crypto.randomUUID(), startedAt: performance.now() }; enqueue(newEvent("session_start")); flush(); } function sessionEnd() { if (!session) return; const event = newEvent("session_end", { duration_ms: Math.min(Math.round(performance.now() - session.startedAt), 86_400_000), }); session = null; // The browser queues the beacon itself and delivers it after the tab closes. const queued = navigator.sendBeacon(ENDPOINT, new Blob([body([event])], { type: "text/plain" })); if (!queued) enqueue(event); // beacon refused (64 KB limit): it goes out on the next visit } export function setUserId(id) { userId = id; // account ID from your backend, null after sign-out; not an email } export function start() { document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") sessionStart(); else sessionEnd(); }); window.addEventListener("pagehide", sessionEnd); window.addEventListener("online", flush); if (document.visibilityState === "visible") sessionStart(); } ``` The page can't see the beacon's response, so a rejected `session_end` can't be retried. That's acceptable: a lost `session_end` doesn't lose the session, it's only left out of the average duration (see [Sessions](https://appmetrica.online/docs/concepts/sessions#metrics)). ## Setup ```html tab="HTML" title="index.html" ``` ```tsx tab="Next.js" title="app/analytics.tsx" "use client"; import { useEffect } from "react"; // Render in the root layout. The module loads after hydration and stays out of the // initial bundle. export function Analytics({ userId }: { userId: string | null }) { useEffect(() => { import("./analytics.js").then(({ start }) => start()); }, []); useEffect(() => { import("./analytics.js").then(({ setUserId }) => setUserId(userId)); }, [userId]); return null; } ``` In an SPA, navigating between pages doesn't create new sessions: a session lasts as long as the tab is visible. ## Web caveats - **Each tab is its own session.** Two open tabs produce two sessions for the same person: active users stay the same, but the session count grows. - **`install_id` lives in `localStorage`.** Clearing site data, incognito mode, or a different browser means a new install. Signing in with a `user_id` stitches such installs into one person. - **Ad blockers** may block analytics requests. If that matters, proxy ingest through your own domain — for example, `/a/v1/events` to `https://i.analytics.example.com/v1/events`. - **Country and city** are determined from the browser's IP; the IP itself isn't stored. ## Verification Open the site on `localhost` (events go out with `environment: "development"`), switch to another tab, and come back. `session_start` and `session_end` appear on the **Integration** tab. In DevTools, the Network tab shows the beacon as a request of type `ping`. --- # React Native (TypeScript) URL: https://appmetrica.online/docs/recipes/react-native fetch, install_id in the Keychain and AsyncStorage, an offline queue, and sessions driven by AppState on iOS and Android. A recipe for React Native 0.7x+ in TypeScript, on iOS and Android. Networking is the built-in `fetch`, the queue is `@react-native-async-storage/async-storage`, `install_id` on iOS is `react-native-keychain`, the app version and device model come from `react-native-device-info`, and UUIDs come from `uuid` with the `react-native-get-random-values` polyfill. The code is split into three modules: `install_id`, an offline queue with sending, and a session tracker. ```bash npm install @react-native-async-storage/async-storage react-native-keychain \ react-native-device-info react-native-get-random-values uuid cd ios && pod install ``` Import the polyfill on the first line of the `index.js` entry point: `import "react-native-get-random-values";`. Without it, `uuid` can't find `crypto.getRandomValues`. In an Expo project, the same jobs are done by `expo-secure-store` (the Keychain on iOS), `expo-application` (version and build number), `expo-device` (device model and type), and `expo-crypto` (`randomUUID()` instead of `uuid`); AsyncStorage works in Expo too. Where to subscribe to `AppState`, and why not in a screen, is covered in [Where to initialize analytics](https://appmetrica.online/docs/concepts/initialization#react-native). A React Native app is one app in appmetrica.online with iOS and Android platforms, not two apps. Each platform has its own key on the Integration tab: the same JS bundle picks the key and `context.platform` from `Platform.OS` at runtime. A person with the same `user_id` on an iPhone and an Android tablet is counted once, and reports are split by platform — see [Event platform](https://appmetrica.online/docs#platform). Instead of two keys, you can issue a single "Any platform" key: the recipe sends `context.platform` with every event anyway. ## install_id `install_id` is created on first launch and never changes. On iOS it's stored in the Keychain, as in the [iOS recipe](https://appmetrica.online/docs/recipes/ios-swift#install-id): a Keychain item survives deleting and reinstalling the app. On Android it's stored in AsyncStorage: Auto Backup keeps its database, so after a reinstall restored from a backup, `install_id` stays the same, as in the [Android recipe](https://appmetrica.online/docs/recipes/android-kotlin#install-id). ```ts title="installId.ts" import AsyncStorage from "@react-native-async-storage/async-storage"; import { Platform } from "react-native"; import * as Keychain from "react-native-keychain"; import { v4 as uuidv4 } from "uuid"; const SERVICE = "online.appmetrica.install-id"; const STORAGE_KEY = "am_install_id"; let cached: Promise | null = null; // Reads install_id or creates it on first launch. The promise is cached per process. export function installId(): Promise { cached ??= Platform.OS === "ios" ? fromKeychain() : fromStorage(); return cached; } async function fromKeychain(): Promise { const saved = await Keychain.getGenericPassword({ service: SERVICE }); if (saved) return saved.password; const id = uuidv4(); await Keychain.setGenericPassword("install_id", id, { service: SERVICE, // As in the iOS recipe: readable on background launches, never moves to a new device. accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, }); return id; } async function fromStorage(): Promise { const saved = await AsyncStorage.getItem(STORAGE_KEY); if (saved) return saved; const id = uuidv4(); await AsyncStorage.setItem(STORAGE_KEY, id); return id; } ``` If the app has its own backup rules (`android:fullBackupContent` or `android:dataExtractionRules`), don't exclude the AsyncStorage database from them. ## Offline queue and sending `queue.ts` stores events in AsyncStorage and sends them in batches of up to 500. The UI never waits on the network: an event is written to disk first, and sending happens in the background. How to handle each response follows [Errors and retries](https://appmetrica.online/docs/errors-and-retries): - `200` — remove the sent events, except those rejected with `retryable: true`: retry those in a minute; - `400`, `413`, `415` — drop the batch: retrying won't help; - `401`, `403` — stop sending until the app restarts: the key was revoked or the app is archived; - `429`, `503` — retry after `Retry-After`; - anything else and network errors — retry with a backoff from 1 s to 300 s and ±20% jitter. ```ts title="queue.ts" import AsyncStorage from "@react-native-async-storage/async-storage"; import { Platform } from "react-native"; const ENDPOINT = "https://i.analytics.example.com/v1/events"; // Each platform of the app has its own key: the code picks it by OS at runtime. const WRITE_KEY = Platform.select({ ios: "pk_…", // iOS platform key from the Integration tab android: "pk_…", // Android platform key from the Integration tab default: "", }); const QUEUE_KEY = "am_queue"; const MAX_BATCH = 500; const MAX_AGE_MS = 7 * 24 * 3600 * 1000; const TIMEOUT_MS = 15_000; export type AnalyticsEvent = { insert_id: string; name: "session_start" | "session_end"; timestamp: string; install_id: string; session_id: string; user_id?: string; duration_ms?: number; context: Record; }; type IngestResult = { rejected: { insert_id: string | null; retryable: boolean }[] }; let events: AnalyticsEvent[] = []; let sending = false; let stopped = false; let retryAt = 0; let backoffMs = 1000; // The queue from previous launches is read once; every operation waits for it first. const loaded = AsyncStorage.getItem(QUEUE_KEY) .then((saved) => { events = saved ? JSON.parse(saved) : []; }) .catch(() => {}); function save(): Promise { return AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(events)); } export async function enqueue(event: AnalyticsEvent): Promise { await loaded; events.push(event); await save(); // disk first: the event survives the app being closed await flush(); } export async function flush(): Promise { await loaded; const minTime = Date.now() - MAX_AGE_MS; events = events.filter((e) => Date.parse(e.timestamp) >= minTime); if (sending || stopped || events.length === 0 || Date.now() < retryAt) return; sending = true; const batch = events.slice(0, MAX_BATCH); const sent = new Set(batch.map((e) => e.insert_id)); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); try { const res = await fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", "X-App-Key": WRITE_KEY }, // sent_at is this attempt's time: the server uses it to correct a skewed device clock. body: JSON.stringify({ sent_at: new Date().toISOString(), events: batch }), signal: controller.signal, }); if (res.status === 200) { const result: IngestResult = await res.json(); const retry = new Set(result.rejected.filter((r) => r.retryable).map((r) => r.insert_id)); events = events.filter((e) => !sent.has(e.insert_id) || retry.has(e.insert_id)); backoffMs = 1000; if (retry.size > 0) retryLater(60_000); } else if ([400, 413, 415].includes(res.status)) { events = events.filter((e) => !sent.has(e.insert_id)); // retrying won't help } else if (res.status === 401 || res.status === 403) { stopped = true; // key revoked or app archived } else if (res.status === 429 || res.status === 503) { retryLater((Number(res.headers.get("Retry-After")) || 5) * 1000); } else { backoff(); } } catch { backoff(); // network error, timeout, unexpected response } finally { clearTimeout(timer); sending = false; await save(); } } function backoff() { retryLater(backoffMs * (0.8 + Math.random() * 0.4)); backoffMs = Math.min(backoffMs * 2, 300_000); } function retryLater(delayMs: number) { retryAt = Date.now() + delayMs; setTimeout(() => void flush(), delayMs); } ``` Events older than 7 days are dropped before sending: the server would reject them with `event_too_old` anyway. An event sent three days late lands in the report for the day it happened (see [Time and the offline queue](https://appmetrica.online/docs/concepts/time-and-offline)). ## Sessions `analytics.ts` creates the events. Each event carries its own `context`, so queued events go out with the app version they happened in. Duration is measured with `performance.now()`, a monotonic clock that doesn't change when the user changes the system time. ```ts title="analytics.ts" import { Platform } from "react-native"; import DeviceInfo from "react-native-device-info"; import { v4 as uuidv4 } from "uuid"; import { installId } from "./installId"; import { type AnalyticsEvent, enqueue, flush } from "./queue"; let session: { id: string; startedAt: number } | null = null; let userId: string | null = null; function context(): Record { const ios = Platform.OS === "ios"; return { platform: ios ? "ios" : "android", os_name: DeviceInfo.getSystemName(), os_version: DeviceInfo.getSystemVersion(), app_version: DeviceInfo.getVersion(), app_build: DeviceInfo.getBuildNumber(), // On iOS, a machine name like iPhone17,3: the device name often contains the owner's name. device_model: ios ? DeviceInfo.getDeviceId() : `${DeviceInfo.getBrand()} ${DeviceInfo.getModel()}`, device_type: DeviceInfo.isTablet() ? "tablet" : "phone", locale: Intl.DateTimeFormat().resolvedOptions().locale, environment: __DEV__ ? "development" : "production", }; } async function track(name: AnalyticsEvent["name"], sessionId: string, durationMs?: number) { // Time, user_id and context are captured when the event happens, not once install_id is read. const event = { insert_id: uuidv4(), name, timestamp: new Date().toISOString(), session_id: sessionId, user_id: userId ?? undefined, // undefined is left out of the JSON duration_ms: durationMs, context: context(), }; await enqueue({ ...event, install_id: await installId() }); } export const analytics = { start() { if (session) return; session = { id: uuidv4(), startedAt: performance.now() }; void track("session_start", session.id); }, end() { if (!session) return; const elapsed = Math.round(performance.now() - session.startedAt); void track("session_end", session.id, Math.min(Math.max(elapsed, 0), 86_400_000)); session = null; }, // Account ID from your backend, null after sign-out. Not an email or phone number. setUserId(id: string | null) { userId = id; }, flush, }; ``` Subscribe to `AppState` in the root component: it lives as long as the app runs. `active` opens a session, `background` closes it. The `inactive` state exists only on iOS (Notification Center, the app switcher, an incoming call) — skip it. Calling `start()` again while a session is open, or `end()` with no session, does nothing. ```tsx title="App.tsx" import { useEffect } from "react"; import { AppState } from "react-native"; import { analytics } from "./analytics"; import { RootNavigator } from "./navigation"; export default function App() { useEffect(() => { if (AppState.currentState === "active") analytics.start(); const subscription = AppState.addEventListener("change", (state) => { if (state === "active") analytics.start(); else if (state === "background") analytics.end(); }); return () => subscription.remove(); }, []); return ; } ``` On Android, `AppState` switches to `background` as early as the activity's `onPause`, so a system permission dialog shown over the app closes the session and opens a new one. An app gets little time in the background: if `session_end` doesn't go out in time, it stays in the queue and is sent on the next launch. ## user_id after sign-in After sign-in, pass the account ID from your backend; after sign-out, pass `null`. Anonymous events from before sign-in are attributed to that user (see [Identity](https://appmetrica.online/docs/concepts/identity)). If the user signed in during a previous run, call `setUserId` before the first `session_start` — for example, when you restore the signed-in session at launch. ```ts tab="Sign in" analytics.setUserId(account.id); // "a3f9c2d1-77b4-…", not an email ``` ```ts tab="Sign out" analytics.setUserId(null); ``` ## Verify 1. Run a debug build (`npx react-native run-ios` or `run-android`): with `__DEV__`, events go out with `environment: "development"`. 2. Send the app to the background and open it again. 3. `session_start` and `session_end` show up on the Integration tab with the iOS or Android platform. In reports, turn on the development environment. --- # Flutter (Dart) URL: https://appmetrica.online/docs/recipes/flutter package:http, install_id in the Keychain and shared_preferences, a file-backed queue, and sessions via WidgetsBindingObserver. A recipe for Flutter on Dart 3: iOS, Android, macOS, Windows, and Linux. Networking is `package:http`, the queue is a file in the `getApplicationSupportDirectory()` directory from `path_provider`, `install_id` is `flutter_secure_storage` on iOS and macOS and `shared_preferences` on the other platforms, the version and model come from `package_info_plus` and `device_info_plus`, and UUIDs come from `uuid`. The code is split into three files: `install_id`, an offline queue with sending, and a session tracker. ```bash flutter pub add http path_provider flutter_secure_storage shared_preferences \ package_info_plus device_info_plus uuid ``` Where to create the tracker and how to subscribe to the lifecycle is covered in [Where to initialize analytics](https://appmetrica.online/docs/concepts/initialization#flutter). This code doesn't work for Flutter web: the browser has no `dart:io`, and website sessions follow tab visibility — see the [web recipe](https://appmetrica.online/docs/recipes/web-js). A Flutter app is one app in appmetrica.online with several platforms: iOS and Android, plus macOS, Windows, and Linux if you also build for desktop. Select all the platforms in a single app instead of creating an app per build. Each platform has its own key on the Integration tab: the code picks the key and `context.platform` at runtime from `Platform.isIOS`, `Platform.isAndroid`, and so on. A person with the same `user_id` on a phone and a laptop is counted once, and reports are split by platform — see [Event platform](https://appmetrica.online/docs#platform). ## install_id `install_id` is created on first launch and never changes. On iOS and macOS it's stored in the Keychain via `flutter_secure_storage`: the item survives deleting and reinstalling the app, as in the [iOS recipe](https://appmetrica.online/docs/recipes/ios-swift#install-id). On Android it's stored in `shared_preferences`, which Auto Backup keeps, as in the [Android recipe](https://appmetrica.online/docs/recipes/android-kotlin#install-id). `flutter_secure_storage` doesn't fit there: its encryption key lives in the Android Keystore and isn't restored from a backup. On Windows and Linux, `shared_preferences` is a file in the user's profile. ```dart title="lib/analytics/install_id.dart" import 'dart:io'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; abstract final class InstallId { static const _key = 'appmetrica.install_id'; static const _keychain = FlutterSecureStorage( // As in the iOS recipe: readable on background launches, never moves to a new device. iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), mOptions: MacOsOptions(accessibility: KeychainAccessibility.first_unlock_this_device), ); static Future? _value; /// Reads install_id or creates it on first launch. The Future is cached per process. static Future get() => _value ??= _load(); static Future _load() async { if (Platform.isIOS || Platform.isMacOS) { return await _keychain.read(key: _key) ?? await _create((id) => _keychain.write(key: _key, value: id)); } final prefs = SharedPreferencesAsync(); return await prefs.getString(_key) ?? await _create((id) => prefs.setString(_key, id)); } static Future _create(Future Function(String id) save) async { final id = const Uuid().v4(); await save(id); return id; } } ``` On macOS, `flutter_secure_storage` writes to the data protection keychain, so the app needs the Keychain Sharing entitlement (`keychain-access-groups`) — the setup is described in the package README. ## Offline queue and sending `EventQueue` stores events in a file and sends them in batches of up to 500. Dart runs code on a single thread, so the queue state changes without locks, and file writes happen strictly one at a time. The UI never waits on the network: an event is written to disk first, and sending happens in the background. How to handle each response follows [Errors and retries](https://appmetrica.online/docs/errors-and-retries): - `200` — remove the sent events, except those rejected with `retryable: true`: retry those in a minute; - `400`, `413`, `415` — drop the batch: retrying won't help; - `401`, `403` — stop sending until the app restarts: the key was revoked or the app is archived; - `429`, `503` — retry after `Retry-After`; - anything else and network errors — retry with a backoff from 1 s to 300 s and ±20% jitter. ```dart title="lib/analytics/event_queue.dart" import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; /// The event platform and its key: each platform of the app has its own key on the Integration tab. final appPlatform = _detectPlatform(); ({String name, String writeKey}) _detectPlatform() { if (Platform.isIOS) return (name: 'ios', writeKey: 'pk_…'); // iOS platform key if (Platform.isAndroid) return (name: 'android', writeKey: 'pk_…'); // Android platform key if (Platform.isMacOS) return (name: 'macos', writeKey: 'pk_…'); // macOS platform key if (Platform.isWindows) return (name: 'windows', writeKey: 'pk_…'); // Windows platform key if (Platform.isLinux) return (name: 'linux', writeKey: 'pk_…'); // Linux platform key throw UnsupportedError('appmetrica: platform ${Platform.operatingSystem} is not supported'); } class EventQueue { static const _endpoint = 'https://i.analytics.example.com/v1/events'; static const _maxBatch = 500; static const _maxAge = Duration(days: 7); static const _maxBackoff = Duration(seconds: 300); final _random = Random(); late final Future _file = _openFile(); late final Future>> _events = _read(); Future _saving = Future.value(); bool _sending = false; bool _stopped = false; DateTime _retryAt = DateTime(0); Duration _backoff = const Duration(seconds: 1); Timer? _timer; Future enqueue(Map event) async { (await _events).add(event); await _save(); // disk first: the event survives the app being closed await flush(); } Future flush() async { final events = await _events; final minTime = DateTime.now().subtract(_maxAge); events.removeWhere((e) => DateTime.parse(e['timestamp'] as String).isBefore(minTime)); if (_sending || _stopped || events.isEmpty || DateTime.now().isBefore(_retryAt)) return; _sending = true; final batch = events.take(_maxBatch).toList(); final sent = {for (final e in batch) e['insert_id'] as String}; try { final response = await http .post( Uri.parse(_endpoint), headers: {'Content-Type': 'application/json', 'X-App-Key': appPlatform.writeKey}, // sent_at is this attempt's time: the server uses it to correct a skewed device clock. body: jsonEncode({'sent_at': _now(), 'events': batch}), ) .timeout(const Duration(seconds: 15)); switch (response.statusCode) { case 200: final rejected = (jsonDecode(response.body) as Map)['rejected'] as List; final retry = { for (final r in rejected.cast>()) if (r['retryable'] == true) r['insert_id'], }; final done = sent.difference(retry); events.removeWhere((e) => done.contains(e['insert_id'])); _backoff = const Duration(seconds: 1); if (retry.isNotEmpty) _retryLater(const Duration(minutes: 1)); case 400 || 413 || 415: events.removeWhere((e) => sent.contains(e['insert_id'])); // retrying won't help case 401 || 403: _stopped = true; // key revoked or app archived case 429 || 503: final seconds = int.tryParse(response.headers['retry-after'] ?? '') ?? 5; _retryLater(Duration(seconds: seconds)); default: _scheduleBackoff(); } } catch (_) { _scheduleBackoff(); // network error, timeout, unexpected response } finally { _sending = false; await _save(); } } void _retryLater(Duration delay) { _retryAt = DateTime.now().add(delay); _timer?.cancel(); _timer = Timer(delay, flush); } void _scheduleBackoff() { _retryLater(_backoff * (0.8 + _random.nextDouble() * 0.4)); _backoff = _backoff * 2 > _maxBackoff ? _maxBackoff : _backoff * 2; } /// Writes the queue via a temp file. Writes run one at a time: two at once would corrupt it. Future _save() => _saving = _saving.then((_) async { final file = await _file; final tmp = File('${file.path}.tmp'); await tmp.writeAsString(jsonEncode(await _events)); await tmp.rename(file.path); }).catchError((_) {}); // disk error: the queue stays in memory until the next write Future _openFile() async { final dir = await getApplicationSupportDirectory(); // created if it doesn't exist return File('${dir.path}/appmetrica-queue.json'); } Future>> _read() async { try { final saved = jsonDecode(await (await _file).readAsString()) as List; return saved.cast>().toList(); } catch (_) { return []; // first launch or a corrupted file } } } /// RFC 3339 in UTC with a Z suffix — the server accepts this time as is. String _now() => DateTime.now().toUtc().toIso8601String(); ``` The queue keeps `timestamp` as is, so an event sent three days late lands in the report for the day it happened (see [Time and the offline queue](https://appmetrica.online/docs/concepts/time-and-offline)). ## Sessions `SessionTracker` creates the events. Each event carries its own `context`, so queued events go out with the app version they happened in. Duration is measured with `Stopwatch`, a monotonic clock that doesn't change when the user changes the system time. ```dart title="lib/analytics/session_tracker.dart" import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:ui'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:uuid/uuid.dart'; import 'event_queue.dart'; import 'install_id.dart'; /// One tracker per process. final analytics = SessionTracker(); class SessionTracker { /// Account ID after sign-in; null if the user isn't signed in. Not an email or phone number. String? userId; final _queue = EventQueue(); final _stopwatch = Stopwatch(); late final Future> _context = _loadContext(); String? _sessionId; /// Sends the events left in the queue from previous launches. void flush() => unawaited(_queue.flush()); void start() { if (_sessionId != null) return; final sessionId = const Uuid().v4(); _sessionId = sessionId; _stopwatch ..reset() ..start(); unawaited(_track('session_start', sessionId)); } void end() { final sessionId = _sessionId; if (sessionId == null) return; _sessionId = null; final durationMs = min(_stopwatch.elapsedMilliseconds, 86400000); unawaited(_track('session_end', sessionId, durationMs: durationMs)); } Future _track(String name, String sessionId, {int? durationMs}) async { // Time and user_id are captured when the event happens, before the first await. final event = { 'insert_id': const Uuid().v4(), 'name': name, 'timestamp': DateTime.now().toUtc().toIso8601String(), 'session_id': sessionId, if (userId != null) 'user_id': userId, if (durationMs != null) 'duration_ms': durationMs, }; event['install_id'] = await InstallId.get(); event['context'] = await _context; await _queue.enqueue(event); } Future> _loadContext() async { final package = await PackageInfo.fromPlatform(); final device = await _deviceInfo(); return { 'platform': appPlatform.name, 'os_name': device.osName, 'os_version': device.osVersion, 'app_version': package.version, 'app_build': package.buildNumber, if (device.model != null) 'device_model': device.model!, 'device_type': _deviceType(), 'locale': PlatformDispatcher.instance.locale.toLanguageTag(), 'environment': kDebugMode ? 'development' : 'production', }; } } typedef _Device = ({String osName, String osVersion, String? model}); Future<_Device> _deviceInfo() async { return switch (await DeviceInfoPlugin().deviceInfo) { AndroidDeviceInfo(:final version, :final manufacturer, :final model) => (osName: 'Android', osVersion: version.release, model: '$manufacturer $model'), // On iOS, a machine name like iPhone17,3: the device name often contains the owner's name. IosDeviceInfo(:final systemName, :final systemVersion, :final utsname) => (osName: systemName, osVersion: systemVersion, model: utsname.machine), MacOsDeviceInfo(:final majorVersion, :final minorVersion, :final patchVersion, :final model) => (osName: 'macOS', osVersion: '$majorVersion.$minorVersion.$patchVersion', model: model), WindowsDeviceInfo(:final majorVersion, :final minorVersion, :final buildNumber) => (osName: 'Windows', osVersion: '$majorVersion.$minorVersion.$buildNumber', model: null), LinuxDeviceInfo(:final name, :final versionId) => (osName: name, osVersion: versionId ?? '', model: null), _ => (osName: Platform.operatingSystem, osVersion: '', model: null), }; } /// Like smallestScreenWidthDp in the Android recipe: 600 logical pixels or more is a tablet. String _deviceType() { if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) return 'desktop'; final view = PlatformDispatcher.instance.implicitView; if (view == null) return 'phone'; final shortestSide = view.physicalSize.shortestSide / view.devicePixelRatio; return shortestSide >= 600 ? 'tablet' : 'phone'; } ``` Hook the tracker up in `main()`. `resumed` opens a session, and `paused` closes it on iOS and Android. On desktop, Flutter never sends `paused`: a minimized or hidden window is `hidden`, and on macOS, Windows, and Linux that's what closes the session. `inactive` — the notification shade, an incoming call, a window without focus — doesn't interrupt the session. Calling `start()` again while a session is open, or `end()` with no session, does nothing. ```dart title="lib/main.dart" import 'dart:io'; import 'package:flutter/widgets.dart'; import 'analytics/session_tracker.dart'; import 'app.dart'; final _desktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux; void main() { WidgetsFlutterBinding.ensureInitialized(); analytics.flush(); // the launch may be a background one, so no session_start here WidgetsBinding.instance.addObserver(AnalyticsLifecycle()); if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { analytics.start(); // the app is already on screen } runApp(const NotesApp()); } class AnalyticsLifecycle with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.resumed: analytics.start(); case AppLifecycleState.paused: analytics.end(); // iOS and Android: the app went to the background case AppLifecycleState.hidden when _desktop: analytics.end(); // desktop: the window is minimized; paused never comes here default: break; // inactive: notification shade, call, unfocused window — the session goes on } } } ``` A mobile app gets little time in the background: if `session_end` doesn't go out in time, it stays in the queue file and is sent on the next launch. A desktop app may exit when its window is closed without ever going `hidden` — then the session has no `session_end`. The session is still counted from `session_start`, but it isn't included in the average duration (see [Sessions](https://appmetrica.online/docs/concepts/sessions#metrics)). ## user_id after sign-in After sign-in, set `analytics.userId` to the account ID from your backend; after sign-out, set it to `null`. Anonymous events from before sign-in are attributed to that user (see [Identity](https://appmetrica.online/docs/concepts/identity)). If the user signed in during a previous run, set `userId` in `main()` before the first `session_start`. ```dart tab="Sign in" analytics.userId = account.id; // "a3f9c2d1-77b4-…", not an email ``` ```dart tab="Sign out" analytics.userId = null; ``` ## Verify 1. Run the app in debug mode with `flutter run`: with `kDebugMode`, events go out with `environment: "development"`. 2. Minimize the app and open it again. 3. `session_start` and `session_end` show up on the Integration tab with the current OS as the platform. In reports, turn on the development environment. --- # Kotlin Multiplatform URL: https://appmetrica.online/docs/recipes/kotlin-multiplatform A shared module on Ktor and kotlinx.serialization, expect/actual for storage, and sessions from ProcessLifecycleOwner and scenePhase. A recipe for Kotlin Multiplatform targeting Android and iOS on Kotlin 2.3+: as of this version, `kotlin.time.Clock` and `Instant` are stable (in 2.1.20–2.2 they need `@OptIn(ExperimentalTime::class)`). The queue, sending, and the session tracker live in the shared module, in `commonMain`: networking is the Ktor 3 client with the OkHttp engine on Android and Darwin on iOS, JSON is kotlinx.serialization, background work is kotlinx.coroutines, and UUIDs come from `kotlin.uuid.Uuid`. The platform-specific parts — the key, `install_id`, the queue file, and the device context — are declared with `expect`/`actual`. Sessions are driven by the app entry points: `ProcessLifecycleOwner` on Android and `scenePhase` in SwiftUI on iOS. Where to create the tracker on each platform, and why the lifecycle subscription stays in native code, is covered in [Where to initialize analytics](https://appmetrica.online/docs/concepts/initialization#kotlin-multiplatform). A KMP app is one app in appmetrica.online with iOS and Android platforms, not two apps. Each platform has its own key on the Integration tab: the key and `context.platform` are supplied at runtime by the `actual` of the platform the code runs on, and the shared code knows nothing about the platform. A person with the same `user_id` on both platforms is counted once, and reports are split by platform — see [Event platform](https://appmetrica.online/docs#platform). The shared module's dependencies are shown as a `build.gradle.kts` fragment; the Android and iOS targets and the framework with `baseName = "Shared"` are as in the KMP project template. Add `androidx.lifecycle:lifecycle-process` to the Android app module for `ProcessLifecycleOwner`. ```kotlin title="shared/build.gradle.kts" plugins { // …plugins from the KMP project template kotlin("plugin.serialization") // same version as Kotlin } // Versions live in gradle.properties: Ktor 3.x, kotlinx-coroutines, kotlinx-serialization. val ktorVersion: String by project val coroutinesVersion: String by project val serializationVersion: String by project kotlin { sourceSets { commonMain.dependencies { implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") } androidMain.dependencies { implementation("io.ktor:ktor-client-okhttp:$ktorVersion") } iosMain.dependencies { implementation("io.ktor:ktor-client-darwin:$ktorVersion") } } } ``` ## install_id The shared code declares the platform-specific part with `expect`, and `androidMain` and `iosMain` provide the `actual` declarations. These are functions and properties rather than `expect` classes: those are still in beta, and without the `-Xexpect-actual-classes` flag the compiler warns about them. The `EventContext` model is in the next section. ```kotlin title="commonMain/Platform.kt" package analytics /** Platform key from the Integration tab: Android and iOS have different keys. */ internal expect val writeKey: String /** Device and build context; platform is "android" or "ios". */ internal expect fun deviceContext(): EventContext internal expect fun loadInstallId(): String? internal expect fun saveInstallId(id: String) /** Contents of the queue file, or null if the file doesn't exist yet. */ internal expect fun readQueue(): String? internal expect fun writeQueue(json: String) ``` ```kotlin title="commonMain/InstallId.kt" package analytics import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @OptIn(ExperimentalUuidApi::class) internal object InstallId { /** Created on first launch and kept permanently. */ val current: String by lazy { loadInstallId() ?: Uuid.random().toString().also(::saveInstallId) } } ``` On Android, `install_id` lives in `SharedPreferences` and the queue in `filesDir`, as in the [Android recipe](https://appmetrica.online/docs/recipes/android-kotlin#install-id): Auto Backup keeps both, so after a reinstall restored from a backup, `install_id` stays the same. The `actual` reads the version and the debug-build flag from the app via `PackageManager` rather than from `BuildConfig`: the shared module's `BuildConfig` describes the library, not the app. On iOS, `install_id` lives in `NSUserDefaults` and the queue in a file in Application Support. This is a trade-off: `NSUserDefaults` takes one line to read from Kotlin, but deleting the app wipes it, so a reinstall becomes a new install. In the [iOS recipe](https://appmetrica.online/docs/recipes/ios-swift#install-id), `install_id` is stored in the Keychain and survives a reinstall. If that matters to you, implement `loadInstallId` and `saveInstallId` on top of the Keychain: from Kotlin/Native that means `SecItemCopyMatching` and `SecItemAdd` via cinterop with a hand-built `CFDictionary` — noticeably more code. A ready-made wrapper exists, for example `KeychainSettings` from the multiplatform-settings library. ```kotlin tab="Android" title="androidMain/Platform.android.kt" package analytics import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build import java.io.File import java.util.Locale // applicationContext lives as long as the process, so nothing leaks. private lateinit var app: Context /** Call in Application.onCreate before the first event. */ fun initAnalytics(context: Context) { app = context.applicationContext } internal actual val writeKey: String = "pk_…" // Android platform key from the Integration tab private val prefs get() = app.getSharedPreferences("appmetrica", Context.MODE_PRIVATE) private val queueFile get() = File(app.filesDir, "appmetrica-queue.json") internal actual fun loadInstallId(): String? = prefs.getString("install_id", null) internal actual fun saveInstallId(id: String) { prefs.edit().putString("install_id", id).commit() } internal actual fun readQueue(): String? = queueFile.takeIf { it.exists() }?.readText() internal actual fun writeQueue(json: String) { val tmp = File(queueFile.path + ".tmp") tmp.writeText(json) tmp.renameTo(queueFile) } @Suppress("DEPRECATION") // getPackageInfo(String, Int) and versionCode are needed for API 26–32 internal actual fun deviceContext(): EventContext { val info = app.packageManager.getPackageInfo(app.packageName, 0) val versionCode = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong() val debuggable = (app.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 val tablet = app.resources.configuration.smallestScreenWidthDp >= 600 return EventContext( platform = "android", osName = "Android", osVersion = Build.VERSION.RELEASE, appVersion = info.versionName.orEmpty(), appBuild = versionCode.toString(), deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}", deviceType = if (tablet) "tablet" else "phone", locale = Locale.getDefault().toLanguageTag(), environment = if (debuggable) "development" else "production", ) } ``` ```kotlin tab="iOS" title="iosMain/Platform.ios.kt" @file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, ExperimentalNativeApi::class) package analytics import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr import kotlinx.cinterop.toKString import platform.Foundation.* import platform.UIKit.UIDevice import platform.UIKit.UIUserInterfaceIdiomPad import platform.posix.uname import platform.posix.utsname import kotlin.experimental.ExperimentalNativeApi internal actual val writeKey: String = "pk_…" // iOS platform key from the Integration tab private const val INSTALL_ID_KEY = "online.appmetrica.install_id" internal actual fun loadInstallId(): String? = NSUserDefaults.standardUserDefaults.stringForKey(INSTALL_ID_KEY) internal actual fun saveInstallId(id: String) { NSUserDefaults.standardUserDefaults.setObject(id, forKey = INSTALL_ID_KEY) } private val queuePath: String by lazy { val dir = NSSearchPathForDirectoriesInDomains( NSApplicationSupportDirectory, NSUserDomainMask, true, ).first() as String NSFileManager.defaultManager.createDirectoryAtPath( dir, withIntermediateDirectories = true, attributes = null, error = null, ) "$dir/appmetrica-queue.json" } @Suppress("CAST_NEVER_SUCCEEDS") // on iOS, NSString and String are the same object internal actual fun readQueue(): String? = NSString.create( contentsOfFile = queuePath, encoding = NSUTF8StringEncoding, error = null, ) as String? @Suppress("CAST_NEVER_SUCCEEDS") internal actual fun writeQueue(json: String) { (json as NSString).writeToFile( queuePath, atomically = true, encoding = NSUTF8StringEncoding, error = null, ) } internal actual fun deviceContext(): EventContext { val device = UIDevice.currentDevice val info = NSBundle.mainBundle val tablet = device.userInterfaceIdiom == UIUserInterfaceIdiomPad return EventContext( platform = "ios", osName = device.systemName, osVersion = device.systemVersion, appVersion = info.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "", appBuild = info.objectForInfoDictionaryKey("CFBundleVersion") as? String ?: "", deviceModel = hardwareModel(), deviceType = if (tablet) "tablet" else "phone", locale = NSLocale.currentLocale.localeIdentifier.replace('_', '-'), // The debug Kotlin framework is built for the Debug configuration in Xcode. environment = if (kotlin.native.Platform.isDebugBinary) "development" else "production", ) } /** Machine name like iPhone17,3: the device name often contains the owner's name. */ private fun hardwareModel(): String = memScoped { val info = alloc() uname(info.ptr) info.machine.toKString() } ``` ## Offline queue and sending The event model is a set of kotlinx.serialization classes with `snake_case` field names. Each event carries its own `context`, so queued events go out with the app version they happened in. ```kotlin title="commonMain/Event.kt" package analytics import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable internal data class EventContext( val platform: String, @SerialName("os_name") val osName: String, @SerialName("os_version") val osVersion: String, @SerialName("app_version") val appVersion: String, @SerialName("app_build") val appBuild: String, @SerialName("device_model") val deviceModel: String, @SerialName("device_type") val deviceType: String, val locale: String, val environment: String, ) /** user_id and duration_ms set to null are left out of the JSON: encodeDefaults is off. */ @Serializable internal data class Event( @SerialName("insert_id") val insertId: String, val name: String, val timestamp: String, // RFC 3339 in UTC: Clock.System.now().toString() @SerialName("install_id") val installId: String, @SerialName("session_id") val sessionId: String, @SerialName("user_id") val userId: String? = null, @SerialName("duration_ms") val durationMs: Long? = null, val context: EventContext, ) @Serializable internal class Batch(@SerialName("sent_at") val sentAt: String, val events: List) @Serializable internal class IngestResult(val rejected: List = emptyList()) { @Serializable class Rejection( @SerialName("insert_id") val insertId: String? = null, val retryable: Boolean = false, ) } ``` `EventQueue` stores events in a file and sends them in batches of up to 500. The queue is modified only on the `limitedParallelism(1)` dispatcher: it runs code between suspension points strictly one piece at a time, and the `sending` flag keeps a second send from starting while the first one waits for a response. The UI never waits on the network: `enqueue` returns immediately. How to handle each response follows [Errors and retries](https://appmetrica.online/docs/errors-and-retries): - `200` — remove the sent events, except those rejected with `retryable: true`: retry those in a minute; - `400`, `413`, `415` — drop the batch: retrying won't help; - `401`, `403` — stop sending until the app restarts: the key was revoked or the app is archived; - `429`, `503` — retry after `Retry-After`; - anything else and network errors — retry with a backoff from 1 s to 300 s and ±20% jitter. ```kotlin title="commonMain/EventQueue.kt" package analytics import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.contentType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Instant internal object EventQueue { private const val ENDPOINT = "https://i.analytics.example.com/v1/events" private const val MAX_BATCH = 500 private val json = Json { ignoreUnknownKeys = true } private val listSerializer = ListSerializer(Event.serializer()) // Ktor takes the engine — OkHttp or Darwin — from the platform's dependencies. private val client = HttpClient { install(HttpTimeout) { requestTimeoutMillis = 15_000 } } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) // Read on first access — already in the background, on the queue's dispatcher. private val events: MutableList by lazy { val saved = readQueue() ?: return@lazy mutableListOf() runCatching { json.decodeFromString(listSerializer, saved) }.getOrDefault(emptyList()) .toMutableList() // a corrupted file means an empty queue } private var sending = false private var stopped = false private var retryAtMs = 0L private var backoffMs = 1_000L fun enqueue(event: Event) { scope.launch { events += event save() // disk first: the event survives the app being closed flushNow() } } fun flush() { scope.launch { flushNow() } } private suspend fun flushNow() { val minTime = Clock.System.now() - 7.days events.removeAll { Instant.parse(it.timestamp) < minTime } val now = Clock.System.now().toEpochMilliseconds() if (sending || stopped || events.isEmpty() || now < retryAtMs) return sending = true val batch = events.take(MAX_BATCH) val sent = batch.map { it.insertId }.toSet() try { // sent_at is this attempt's time: the server uses it to correct a skewed device clock. val body = Batch(sentAt = Clock.System.now().toString(), events = batch) val response = client.post(ENDPOINT) { contentType(ContentType.Application.Json) header("X-App-Key", writeKey) setBody(json.encodeToString(Batch.serializer(), body)) } when (response.status.value) { 200 -> { val text = response.bodyAsText() val result = json.decodeFromString(IngestResult.serializer(), text) val retry = result.rejected.filter { it.retryable }.mapNotNull { it.insertId } val done = sent - retry.toSet() events.removeAll { it.insertId in done } backoffMs = 1_000L if (retry.isNotEmpty()) retryLater(60_000L) } 400, 413, 415 -> events.removeAll { it.insertId in sent } // retrying won't help 401, 403 -> stopped = true // key revoked or app archived 429, 503 -> { val seconds = response.headers["Retry-After"]?.toLongOrNull() ?: 5L retryLater(seconds * 1_000L) } else -> backoff() } } catch (e: Exception) { // network error, timeout, unexpected response backoff() } finally { sending = false save() } } private fun backoff() { retryLater((backoffMs * Random.nextDouble(0.8, 1.2)).toLong()) backoffMs = minOf(backoffMs * 2, 300_000L) } private fun retryLater(delayMs: Long) { retryAtMs = Clock.System.now().toEpochMilliseconds() + delayMs scope.launch { delay(delayMs) flushNow() } } private fun save() = writeQueue(json.encodeToString(listSerializer, events)) } ``` `Clock.System.now().toString()` produces RFC 3339 in UTC with a `Z` — the server accepts this time as is. Events older than 7 days are dropped before sending: the server would reject them with `event_too_old` anyway. ## Sessions `SessionTracker` is a single shared tracker for both platforms. Duration is measured with `TimeSource.Monotonic`, a monotonic clock that doesn't change when the user changes the system time. Calling `start()` again while a session is open, or `end()` with no session, does nothing. ```kotlin title="commonMain/SessionTracker.kt" package analytics import kotlin.time.Clock import kotlin.time.TimeSource import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid /** Session tracker for Android and iOS. Call start() and end() from the main thread. */ @OptIn(ExperimentalUuidApi::class) object SessionTracker { /** Account ID after sign-in; null if the user isn't signed in. Not an email or phone number. */ var userId: String? = null private var sessionId: String? = null private var startedAt = TimeSource.Monotonic.markNow() fun start() { if (sessionId != null) return val id = Uuid.random().toString() sessionId = id startedAt = TimeSource.Monotonic.markNow() track("session_start", id, durationMs = null) } fun end() { val id = sessionId ?: return val elapsed = startedAt.elapsedNow().inWholeMilliseconds track("session_end", id, durationMs = elapsed.coerceIn(0L, 86_400_000L)) sessionId = null } /** Sends the events left in the queue from previous launches. */ fun flush() = EventQueue.flush() private fun track(name: String, sessionId: String, durationMs: Long?) { EventQueue.enqueue( Event( insertId = Uuid.random().toString(), name = name, timestamp = Clock.System.now().toString(), installId = InstallId.current, sessionId = sessionId, userId = userId, durationMs = durationMs, context = deviceContext(), ), ) } } ``` The shared code knows nothing about the lifecycle: `start()` and `end()` are called by the app entry points. On Android that's `ProcessLifecycleOwner` in `Application.onCreate`, as in the [Android recipe](https://appmetrica.online/docs/recipes/android-kotlin#session-lifecycle). On iOS it's `scenePhase` in the `@main` `App`, as in the [iOS recipe](https://appmetrica.online/docs/recipes/ios-swift#session-lifecycle): it's a SwiftUI API, so it's read in Swift. The Kotlin object `SessionTracker` is visible in Swift as `SessionTracker.shared`. ```kotlin tab="Android" title="androidApp/App.kt" import analytics.SessionTracker import analytics.initAnalytics import android.app.Application import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner class App : Application() { override fun onCreate() { super.onCreate() initAnalytics(this) // Context for SharedPreferences and filesDir ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) = SessionTracker.start() override fun onStop(owner: LifecycleOwner) = SessionTracker.end() }) SessionTracker.flush() // sends the events left in the queue from previous launches } } ``` ```swift tab="iOS" title="iosApp/iOSApp.swift" import Shared import SwiftUI @main struct iOSApp: App { @Environment(\.scenePhase) private var scenePhase init() { // The launch may be a background one, so no session_start here. SessionTracker.shared.flush() } var body: some Scene { WindowGroup { ContentView() } .onChange(of: scenePhase) { _, phase in switch phase { case .active: SessionTracker.shared.start() case .background: SessionTracker.shared.end() default: break // .inactive: Notification Center, a system alert — the session goes on } } } } ``` On Android, don't forget `android:name=".App"` on the manifest's `` and the `android.permission.INTERNET` permission. An app gets little time in the background: if `session_end` doesn't go out in time, it stays in the queue file and is sent on the next launch. ## user_id after sign-in After sign-in, set `SessionTracker.userId` to the account ID from your backend; after sign-out, set it to `null`. Anonymous events from before sign-in are attributed to that user (see [Identity](https://appmetrica.online/docs/concepts/identity)). You can set it from shared code or from either platform. ```kotlin tab="Kotlin" SessionTracker.userId = account.id // "a3f9c2d1-77b4-…", not an email; null after sign-out ``` ```swift tab="Swift" SessionTracker.shared.userId = account.id // nil after sign-out ``` ## Verify 1. Run debug builds: on Android with `android:debuggable`, on iOS in the Debug configuration. Events go out with `environment: "development"`. 2. Minimize the app and open it again on both platforms. 3. `session_start` and `session_end` show up on the Integration tab with the Android and iOS platforms — in the same app. In reports, turn on the development environment. --- # Bots and backends URL: https://appmetrica.online/docs/recipes/bots-and-backends Telegram bots and server-side sources — HMAC instead of Telegram IDs, server-side sessions, and context.ip. A bot or a backend has no screen and no foreground, so your server has to work out identity, sessions, and geo on its own. This recipe shows how to do it in Go without third-party dependencies. The logic is the same in other languages. A bot uses the `bot` platform, a backend uses `server`. If the bot or backend serves the same people as a mobile app, add the platform to that app and send events with its key: a person with the same `user_id` in the bot and in the app is counted once. For a standalone bot or service, create a separate app. Learn more in [Event platform](https://appmetrica.online/docs#platform). ## User identity A Telegram ID is someone else's identifier, and a public one: it makes a person easy to find. It shouldn't end up in analytics as is. The server doesn't reject such strings (purely numeric `user_id` values are allowed), so protecting them is the bot's job: - `user_id` = HMAC-SHA256 of the Telegram ID keyed with the bot's secret, in hex (64 characters). Without the secret, the Telegram ID can't be recovered from it, and the same person always gets the same `user_id`. - `install_id` — a stable UUID per chat. It's convenient to derive it from the same HMAC: then you don't need to store a mapping table. Store the secret like the bot's other secrets, for example in the `ANALYTICS_HMAC_SECRET` variable. If you change the secret, every user becomes a new one. ```go title="identity.go" package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "os" "strconv" ) // UserID returns the analytics user_id: the HMAC-SHA256 of the user's Telegram ID, in hex. func UserID(secret []byte, telegramUserID int64) string { mac := hmac.New(sha256.New, secret) mac.Write([]byte("user:" + strconv.FormatInt(telegramUserID, 10))) return hex.EncodeToString(mac.Sum(nil)) } // InstallID returns a stable install_id for the chat: the first 16 HMAC bytes as a UUIDv8 (RFC 9562). func InstallID(secret []byte, chatID int64) string { mac := hmac.New(sha256.New, secret) mac.Write([]byte("chat:" + strconv.FormatInt(chatID, 10))) b := mac.Sum(nil)[:16] b[6] = (b[6] & 0x0f) | 0x80 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } func main() { secret := []byte(os.Getenv("ANALYTICS_HMAC_SECRET")) fmt.Println("user_id: ", UserID(secret, 123456789)) fmt.Println("install_id:", InstallID(secret, 123456789)) } ``` In a private chat, `chat_id` equals the user's ID, so each person has a single "install". In groups, every chat is an install of its own, and a person is stitched together by `user_id` (see [Identity](https://appmetrica.online/docs/concepts/identity)). ## Server-side sessions A bot session is a series of messages from a user with no gap longer than 30 minutes: 1. The first message after a gap opens a session: `session_start` with that message's time. 2. Every following message extends the session. 3. If the user stays silent for 30 minutes, the session closes: `session_end` with the time of the last message and `duration_ms` = last message − first message. `timestamp` is the time of the event itself, not the moment the batch is sent. A session with a single message has zero duration — that's fine, the session still makes it into reports. When the bot shuts down, close the open sessions and flush the queue. Otherwise the `session_end` events are lost, and the average session duration comes out too low. ## Queue and sending Sending an event for every message is wasteful. The queue accumulates events and sends them as a batch every 5 seconds or as soon as 500 events pile up. If ingest is unavailable, the batch stays in the queue until the next attempt. The full retry policy is in [Errors and retries](https://appmetrica.online/docs/errors-and-retries) and in the client from the [Go](https://appmetrica.online/docs/recipes/go#go-client) recipe. ```go title="sessions.go" package main import ( "bytes" "context" crand "crypto/rand" "encoding/json" "fmt" "log" "net/http" "os" "os/signal" "sync" "time" ) const ( endpoint = "https://i.analytics.example.com/v1/events" idleTimeout = 30 * time.Minute flushEvery = 5 * time.Second maxBatch = 500 ) type Event struct { InsertID string `json:"insert_id"` Name string `json:"name"` Timestamp time.Time `json:"timestamp"` InstallID string `json:"install_id"` SessionID string `json:"session_id"` UserID string `json:"user_id,omitempty"` DurationMs *int64 `json:"duration_ms,omitempty"` } type session struct { id, installID, userID string start, last time.Time } // Tracker keeps track of the bot users' sessions and accumulates events for sending. type Tracker struct { key string http *http.Client mu sync.Mutex sessions map[string]*session // keyed by install_id queue []Event } func NewTracker(key string) *Tracker { return &Tracker{key: key, http: &http.Client{Timeout: 10 * time.Second}, sessions: map[string]*session{}} } // Touch records a user message received at time at. func (t *Tracker) Touch(installID, userID string, at time.Time) { t.mu.Lock() defer t.mu.Unlock() s := t.sessions[installID] if s != nil && at.Sub(s.last) >= idleTimeout { t.end(s) s = nil } if s == nil { s = &session{id: newUUID(), installID: installID, userID: userID, start: at, last: at} t.sessions[installID] = s t.queue = append(t.queue, Event{ InsertID: newUUID(), Name: "session_start", Timestamp: at, InstallID: installID, SessionID: s.id, UserID: userID, }) return } s.last = at } // CloseIdle closes sessions idle longer than idleTimeout; all closes every session (bot shutdown). func (t *Tracker) CloseIdle(now time.Time, all bool) { t.mu.Lock() defer t.mu.Unlock() for _, s := range t.sessions { if all || now.Sub(s.last) >= idleTimeout { t.end(s) } } } func (t *Tracker) end(s *session) { duration := s.last.Sub(s.start).Milliseconds() t.queue = append(t.queue, Event{ InsertID: newUUID(), Name: "session_end", Timestamp: s.last, InstallID: s.installID, SessionID: s.id, UserID: s.userID, DurationMs: &duration, }) delete(t.sessions, s.installID) } // Flush sends the queue in batches. On error, unsent events go back to the front of the queue. func (t *Tracker) Flush(ctx context.Context) error { t.mu.Lock() pending := t.queue t.queue = nil t.mu.Unlock() for len(pending) > 0 { n := min(len(pending), maxBatch) if err := t.send(ctx, pending[:n]); err != nil { t.mu.Lock() t.queue = append(pending, t.queue...) t.mu.Unlock() return err } pending = pending[n:] } return nil } func (t *Tracker) send(ctx context.Context, events []Event) error { body, err := json.Marshal(map[string]any{ "sent_at": time.Now(), // The bot doesn't know the user's IP: "none" disables geo instead of locating the bot's server. "context": map[string]string{"platform": "bot", "app_version": "1.0.0", "ip": "none"}, "events": events, }) if err != nil { return fmt.Errorf("marshal batch: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("new request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-App-Key", t.key) resp, err := t.http.Do(req) if err != nil { return fmt.Errorf("send batch: %w", err) } defer resp.Body.Close() switch { case resp.StatusCode == http.StatusOK: return nil case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: return fmt.Errorf("ingest: HTTP %d, retry later", resp.StatusCode) default: // 4xx: a retry won't fix the batch, so log it and don't put it back in the queue. log.Printf("ingest: HTTP %d, batch dropped", resp.StatusCode) return nil } } func newUUID() string { var b [16]byte _, _ = crand.Read(b[:]) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() tracker := NewTracker(os.Getenv("AM_PK")) // In a real bot, Touch is called from the handler of every incoming message. tracker.Touch("0f8fad5b-d9cb-469f-a165-70867728950e", "", time.Now()) ticker := time.NewTicker(flushEvery) defer ticker.Stop() for { select { case <-ticker.C: tracker.CloseIdle(time.Now(), false) if err := tracker.Flush(ctx); err != nil { log.Print(err) } case <-ctx.Done(): tracker.CloseIdle(time.Now(), true) flushCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) if err := tracker.Flush(flushCtx); err != nil { log.Print(err) } cancel() return } } } ``` The queue in this example lives in memory: if the process crashes, the events are lost. If that matters, keep the queue in Redis or in a table in the bot's database. Delete events older than 7 days before sending — the server would reject them anyway with the `event_too_old` code. ## context.ip Country and city are determined from the IP address. By default, that's the address the HTTP request came from. That's correct for mobile apps and websites, but not for server-side sources: the request comes from your server's address. | Source | What to pass in `context.ip` | |---|---| | App, website | Nothing: the server uses the client's address | | Telegram bot | `"none"`: Telegram doesn't share the user's IP | | Backend that knows the user's IP | The user's IP from the request to your backend | | Cron job, internal service | `"none"` | The IP is used only to look up the country and city and is discarded right away: it isn't stored in ClickHouse, in logs, or in rejected events. An invalid address doesn't get the event rejected: the event is accepted with the `geo_ip_invalid` warning and without geo. If a batch contains events from different users, pass the IP in each event's `context` — it overrides the batch `context` field by field: ```go title="backend.go" package main import ( "crypto/rand" "fmt" "log" "net" "net/http" "strings" "time" ) type EventContext struct { IP string `json:"ip,omitempty"` } type Event struct { InsertID string `json:"insert_id"` Name string `json:"name"` Timestamp time.Time `json:"timestamp"` InstallID string `json:"install_id"` SessionID string `json:"session_id"` Context *EventContext `json:"context,omitempty"` } // userIP returns the user's address behind your load balancer: the last X-Forwarded-For address, // the one appended by your trusted proxy. Without a proxy, it's the address of the TCP connection. func userIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { parts := strings.Split(xff, ",") return strings.TrimSpace(parts[len(parts)-1]) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return "none" } return host } // pending is read by the batch sender — one like Tracker.Flush in sessions.go. var pending = make(chan Event, 1024) func main() { http.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) { event := Event{ InsertID: newUUID(), Name: "session_start", Timestamp: time.Now(), InstallID: r.Header.Get("X-Install-Id"), SessionID: newUUID(), Context: &EventContext{IP: userIP(r)}, } select { case pending <- event: default: // the queue is full: the user's response doesn't wait for analytics } w.WriteHeader(http.StatusNoContent) }) go func() { for range pending { // the batch sender runs here } }() log.Fatal(http.ListenAndServe(":8080", nil)) } func newUUID() string { var b [16]byte _, _ = rand.Read(b[:]) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } ``` ## Verification Send a single event from the terminal, as in the [curl](https://appmetrica.online/docs/recipes/curl#single) recipe, and open the app's **Integration** tab: the event shows up within a few seconds. Rejected events are listed there too, with their reason codes. --- # Go URL: https://appmetrica.online/docs/recipes/go A dependency-free Go client — sending a batch, retries via Retry-After, and exponential backoff. A Go client fits backends, CLIs, and cron jobs. To check a key or look at the server's response, the terminal is quicker — plain curl requests are collected in the [curl](https://appmetrica.online/docs/recipes/curl) recipe. Bots and server-side sessions are covered in the [Bots and backends](https://appmetrica.online/docs/recipes/bots-and-backends) recipe. The client reads the write key from the `AM_PK` environment variable. The `server` platform key is shown on the app's **Integration** tab; the client passes `context.platform` explicitly (see [Event platform](https://appmetrica.online/docs#platform)): ```bash export AM_PK="pk_…" ``` ## A client with retries The client has no third-party dependencies. It sends a batch and retries it following the rules in [Errors and retries](https://appmetrica.online/docs/errors-and-retries): `429` and `503` after `Retry-After`, `5xx` and network errors with exponential backoff from 1 s to 300 s and ±20% jitter. Retrying the same batch is safe: duplicates are dropped by `insert_id`. ```go title="main.go" package main import ( "bytes" "context" crand "crypto/rand" "encoding/json" "errors" "fmt" "log" "math/rand/v2" "net/http" "os" "strconv" "time" ) const endpoint = "https://i.analytics.example.com/v1/events" type Context struct { Platform string `json:"platform,omitempty"` OSName string `json:"os_name,omitempty"` AppVersion string `json:"app_version,omitempty"` DeviceType string `json:"device_type,omitempty"` Environment string `json:"environment,omitempty"` IP string `json:"ip,omitempty"` } type Event struct { InsertID string `json:"insert_id"` Name string `json:"name"` Timestamp time.Time `json:"timestamp"` InstallID string `json:"install_id"` SessionID string `json:"session_id"` UserID string `json:"user_id,omitempty"` DurationMs *int64 `json:"duration_ms,omitempty"` } type Batch struct { SentAt time.Time `json:"sent_at"` Context Context `json:"context"` Events []Event `json:"events"` } type Rejection struct { Index int `json:"index"` InsertID string `json:"insert_id"` Code string `json:"code"` Retryable bool `json:"retryable"` } type Result struct { Accepted int `json:"accepted"` Rejected []Rejection `json:"rejected"` } // errPermanent means retrying the batch is pointless: the client, the key, or the app is at fault. var errPermanent = errors.New("permanent ingest error") type Client struct { Key string HTTP *http.Client } // Send sends the batch and retries it until the server responds with 200 or a permanent error. // sent_at is set anew on every attempt: the server uses it to correct the client's clock. func (c *Client) Send(ctx context.Context, b Batch) (Result, error) { backoff := time.Second for { b.SentAt = time.Now() res, retryAfter, err := c.post(ctx, b) if err == nil || errors.Is(err, errPermanent) { return res, err } wait := retryAfter if wait == 0 { wait = time.Duration(float64(backoff) * (0.8 + 0.4*rand.Float64())) backoff = min(backoff*2, 300*time.Second) } log.Printf("ingest: %v, retry in %s", err, wait.Round(time.Millisecond)) select { case <-ctx.Done(): return Result{}, ctx.Err() case <-time.After(wait): } } } // post makes a single attempt. retryAfter > 0 means the server named the pause itself (429, 503). func (c *Client) post(ctx context.Context, b Batch) (Result, time.Duration, error) { body, err := json.Marshal(b) if err != nil { return Result{}, 0, fmt.Errorf("%w: marshal: %v", errPermanent, err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return Result{}, 0, fmt.Errorf("%w: %v", errPermanent, err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-App-Key", c.Key) resp, err := c.HTTP.Do(req) if err != nil { return Result{}, 0, err } defer resp.Body.Close() switch { case resp.StatusCode == http.StatusOK: var res Result if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { return Result{}, 0, fmt.Errorf("decode result: %w", err) } return res, 0, nil case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable: seconds, _ := strconv.Atoi(resp.Header.Get("Retry-After")) return Result{}, time.Duration(max(seconds, 1)) * time.Second, fmt.Errorf("HTTP %d", resp.StatusCode) case resp.StatusCode >= 500: return Result{}, 0, fmt.Errorf("HTTP %d", resp.StatusCode) default: // 400, 401, 403, 413, 415: retrying won't help; the client or the key needs fixing. return Result{}, 0, fmt.Errorf("%w: HTTP %d", errPermanent, resp.StatusCode) } } func newUUID() string { var b [16]byte _, _ = crand.Read(b[:]) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } func main() { client := &Client{Key: os.Getenv("AM_PK"), HTTP: &http.Client{Timeout: 10 * time.Second}} installID, sessionID := newUUID(), newUUID() start := time.Now().Add(-time.Minute) duration := time.Since(start).Milliseconds() res, err := client.Send(context.Background(), Batch{ Context: Context{Platform: "server", AppVersion: "1.0.0", Environment: "development", IP: "none"}, Events: []Event{ {InsertID: newUUID(), Name: "session_start", Timestamp: start, InstallID: installID, SessionID: sessionID}, {InsertID: newUUID(), Name: "session_end", Timestamp: time.Now(), InstallID: installID, SessionID: sessionID, DurationMs: &duration}, }, }) if err != nil { log.Fatal(err) } for _, r := range res.Rejected { log.Printf("event %d rejected: %s (retryable=%t)", r.Index, r.Code, r.Retryable) } log.Printf("accepted: %d", res.Accepted) } ``` Run it: ```bash AM_PK="pk_…" go run main.go ``` What to improve in a real service: - keep events that come back in `rejected` with `retryable: true` (currently only `install_rate_limited`) in the queue and send them in the next batch no sooner than 60 s later; - accumulate events in memory or on disk and send them in batches on a timer — an example of such a queue is in the [Bots and backends](https://appmetrica.online/docs/recipes/bots-and-backends#queue) recipe; - `time.Time` encodes to RFC 3339 with nanoseconds and an offset — the server stores time with millisecond precision. --- # Errors and retries URL: https://appmetrica.online/docs/errors-and-retries Ingest response codes, event rejection reasons and what the client should do in each case. Ingest responds in one of two ways: - **`200` with an `IngestResult`**: the batch has been processed. Some events may be rejected (`rejected`) or accepted with a correction (`warnings`). This is not a batch error. - **`4xx` or `5xx` with an `error` envelope**: the batch as a whole was not processed. The client branches only on the HTTP status and the `code` field, never on the `message` text. ## What the client should do | Response | Action | |---|---| | `200`, `rejected` is empty | Remove the batch from the queue | | `200`, `rejected` has `retryable: false` | Drop those events for good and log the `code` | | `200`, `rejected` has `retryable: true` | Keep those events in the queue and send them with the next batch no sooner than 60 s later | | `400`, `413`, `415` | Don't retry: drop the batch and log the error, it's a client bug | | `401`, `403` | Don't retry and don't clear the queue: stop sending until the app or the key is updated | | `429`, `503` | Retry the whole batch after `Retry-After` seconds | | `500`, network error, timeout | Retry the whole batch: 1 s delay, then ×2, up to 300 s, ±20% jitter | | Events older than 7 days in the queue | Drop them before sending | Retrying the same batch is safe: duplicates are filtered out by `insert_id`, and metrics are calculated over unique identifiers. Set a new `sent_at` on every retry. Ready-made retry logic is in the [curl](https://appmetrica.online/docs/recipes/curl#retry) (a bash script) and [Go](https://appmetrica.online/docs/recipes/go#go-client) recipes. ## Partial success A batch of two events where the second event's `user_id` looks like a phone number: ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") curl -sS https://i.analytics.example.com/v1/events \ -H "Content-Type: application/json" \ -H "X-App-Key: $AM_PK" \ --data-binary @- <