Where to initialize analytics
Entry points and lifecycle hooks for session_start and session_end on iOS, Android, the web, React Native, Flutter, Kotlin Multiplatform and in bots.
Create analytics once per process, at the app's entry point, before the first screen. Tie sessions to
the lifecycle of the whole app: session_start when it comes to the foreground, session_end when it
goes to the background. Screens, routes and activities know nothing about sessions.
The examples on this page call start() and end() of the session tracker from the recipes
(iOS, Android,
web, React Native,
Flutter, Kotlin Multiplatform).
A repeated start() within the same session and an end() with no open session do nothing, so
duplicate lifecycle callbacks are safe.
General rules
- One tracker per process. The entry point reads or creates the
install_id, sets up the offline queue and sends the events left over from previous launches. - A launch is not a session yet. The app also starts in the background: from a push
notification, a background refresh, a scheduled task. At the entry point, only prepare the
tracker, and send
session_starton the "entered the foreground" event. - A session ends when the app goes to the background, not when it becomes "inactive". The notification shade, a system dialog and an incoming call don't interrupt a session (see Sessions).
- Send asynchronously, through an on-disk queue. The UI never waits for the network.
| Platform | Entry point | Session start and end |
|---|---|---|
| iOS, SwiftUI | App with @main | scenePhase: .active and .background |
| iOS, UIKit | didFinishLaunchingWithOptions | sceneDidBecomeActive, sceneDidEnterBackground |
| Android | Application.onCreate | ProcessLifecycleOwner: onStart and onStop |
| Web | Bundle entry point | visibilitychange, pagehide |
| React Native | Root component | AppState: active and background |
| Flutter | main() | AppLifecycleState: resumed and paused, hidden on desktop |
| Kotlin Multiplatform | Android and iOS entry points | Android and iOS hooks call the shared tracker |
| Bot, backend | Process start | First message after a pause, 30 minutes of silence |
iOS
SwiftUI
The entry point is the struct marked @main. Read scenePhase in the App, not in a View: at the
App level it's the phase of the whole app (.active if at least one scene is active), while in a
View it's the phase of a single window. On an iPad with two windows, a tracker in a View would
count extra sessions.
import SwiftUI
@main
struct NotesApp: App {
@Environment(\.scenePhase) private var scenePhase
init() {
// Once per process. The launch may happen in the background, so no session_start here.
Task { await OfflineQueue.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 shade, system alert — the session goes on
}
}
}
}UIKit
application(_:didFinishLaunchingWithOptions:) is called once per process, including launches in
the background. This is where the tracker gets ready, while SceneDelegate handles sessions.
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> 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
}
}An app without a SceneDelegate can use the option from the
recipe: 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
UserDefaultsand Keychain items. Without a shared App Group or keychain access group, it would create its owninstall_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.
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
onStopfor one andonStartfor the other, and one session would split into several; - returning from the background doesn't call
onCreateif 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.
import { start } from "./analytics.js";
start(); // once: subscribes to visibilitychange and pagehide<Analytics /> is the component from the recipe; pass userId from
your own session. Inside start(), the session is tied to the tab's visibility:
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 awayvisibilitychange 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.
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 <RootNavigator />;
}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.
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.
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:
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 and iOS 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.
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
}
}SessionTracker is the shared tracker from the
Kotlin Multiplatform recipe: 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 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_endevents. - 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_idon every launch. Every launch is a new user: everyone is "new", retention is close to zero, and active users are counted by launches. Createinstall_idonce and keep it permanently. session_startat 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 |
| Android | SharedPreferences with Auto Backup, as in the recipe |
| Web | localStorage |
| React Native | Keychain on iOS, AsyncStorage on Android, as in the recipe |
| Flutter | Keychain on iOS and macOS, shared_preferences elsewhere, as in the recipe |
| Kotlin Multiplatform | SharedPreferences on Android; NSUserDefaults or Keychain on iOS, see the recipe |
| Bot | Derived from the chat ID via HMAC, see the recipe |
What losing it costs you and what survives a reinstall is covered in Identity.
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
nilornullafter sign-out.install_idstays 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).