- contact@insightclickiq.com
- Every click, every session, explained by AI
React Native and Expo, iOS in Swift, Android in Kotlin. The same calls, the same events, the same store as the web.
A native app sends the same batch payload as the browser with two conventions: screens are pageviews at app://<bundle id>/<Screen> (so the path is /<Screen> and every page report, journey and funnel works), and a device object replaces the User-Agent. Taps are clicks; errors and crashes are error events.
npm install @insight/clickiq-react-native @react-native-async-storage/async-storage
cd ios && pod install # bare React Native onlyApp.tsximport { ClickIQProvider, useScreenTracking } from "@insight/clickiq-react-native";
import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
export default function App() {
const navigationRef = useNavigationContainerRef();
useScreenTracking(navigationRef); // one screen() per route change
return (
<ClickIQProvider options={{ key: "ciq_…", host: "https://clickiq.example.com", appVersion: "2.3.0" }}>
<NavigationContainer ref={navigationRef}>{/* … */}</NavigationContainer>
</ClickIQProvider>
);
}import { screen, track, tap, identify, reset, flush, optOut, error } from "@insight/clickiq-react-native";
screen("LessonList", { tab: "math" }); // pageview at app://<bundle>/LessonList
tap("Start lesson"); // click with el.selector "LessonList/start-lesson"
track("Lesson started", { subject: "math" }); // custom event
identify("guid-rn-0001", { role: "student" }); // after login; persisted
reset(); // on logout
await flush(); // send now
optOut(true); // stop and forget, persistedReact Native 0.72 or later, managed or bare Expo. Optional peers are picked up when installed: AsyncStorage (persistence of ids, session and queue), react-native-device-info or expo-constants/expo-device (model, version, bundle id). Uncaught JS errors are captured through ErrorUtils (the previous handler still runs); unhandled promise rejections on Hermes and JSC where the runtime allows.
iOS 15+ (UIKit and SwiftUI) and tvOS 15+, Swift 6 with strict concurrency, no dependencies. Add the package with Swift Package Manager and configure once at launch.
Swiftimport ClickIQ
import SwiftUI
@main
struct LearnApp: App {
init() { ClickIQ.configure(key: "ciq_…", host: "https://clickiq.example.com") }
var body: some Scene { WindowGroup { RootView() } }
}
// a screen: pageview on appear, page_leave with time on screen on disappear
List(lessons) { LessonRow($0) }.clickiqScreen("LessonList")
ClickIQ.tap("Start lesson", id: "start")
ClickIQ.track("Lesson started", props: ["subject": "math"])
ClickIQ.identify("guid-0001", traits: ["role": "student", "plan": "pro"])
ClickIQ.reset()Crashes: the SDK chains an uncaught-exception handler and handles fatal signals, writes a small file, and sends an error with fatal: true on the next launch. It is not a symbolicating crash reporter; if you ship one, configure ClickIQ first or pass captureCrashes: false.
minSdk 24, no third-party dependencies. Two modules: clickiq-core (pure Kotlin) and clickiq (lifecycle, storage, device info, crash handler, Compose and Fragment helpers).
// app/build.gradle.kts
dependencies { implementation("com.insight.clickiq:clickiq:0.1.0") }
class App : Application() {
override fun onCreate() {
super.onCreate()
ClickIQ.init(this, key = "ciq_…", host = "https://clickiq.example.com",
options = ClickIQOptions(autoActivityScreens = false, captureCrashes = true))
}
}
composable("lessons") { ClickIQScreen("LessonList"); LessonListScreen() } // Compose
ClickIQ.tap("Start lesson")
ClickIQ.track("Lesson started", mapOf("subject" to "math"))
ClickIQ.identify("user-123", mapOf("role" to "student"))Until the library is published to a Maven repository, include it as a composite build (includeBuild("../insightclickiq-sdk-android")); the published coordinates will be com.insight.clickiq:clickiq.
| Behaviour | Detail |
|---|---|
| Sessions | 30 minutes of inactivity; time in the background counts. Returning later starts a new session and re-sends the screen on display |
| Offline queue | Persisted on the device, 500 events, oldest dropped first; survives the app being killed |
| Batches | Up to 50 events every 10 s, when enough are waiting, on background, and on flush() |
| Retries | Network errors, 429 and 5xx back off (10 s, 20 s, 40 s …); other 4xx drop the batch |
| Headers | X-ClickIQ-Platform: ios|android; no Origin header, which the ingest accepts |
| Opt-out | optOut(true) clears ids, session and queue and stays off across launches |
{"type": "pageview", "name": "screen", "url": "app://com.example.app/LessonList", "title": "LessonList",
"device": {"platform": "android", "os": "Android", "os_version": "14", "model": "Google Pixel 7",
"app_version": "2.3.0", "sdk": "clickiq-android", "sdk_version": "0.1.0", "device_type": "mobile"}}The server stores platform, app_version and device_model as columns (filter and break down by them), sets os to “Android 14” and browser to the SDK name. Browser events carry platform = "web".
Taps carry their position (x as a fraction of the width, y in points from the top of the screen, or of the content inside a vertical scroll view), so the console draws tap, rage and dead layers per screen with a Web / iOS / Android switch. Three taps within 600 ms inside 24 points also send a rage_click; a tap on a view nothing interactive claimed sends a dead_click.
| Turn it on | Element names come from | |
|---|---|---|
| iOS | ClickIQ.installTapCapture(window: window) (a passive recognizer; never cancels or delays touches) | accessibilityIdentifier, accessibilityLabel, a button title |
| Android | On by default (captureTaps): every Activity’s touch dispatch is observed, never consumed | contentDescription, the TextView text, the resource id name; Compose taps keep coordinates only |
| React Native | Wrap the app in <ClickIQTouchCapture> (it only listens) | <ClickIQView label id>, testID, nativeID; otherwise coordinates only |
A view marked private (below), or with an id starting with ciq-private, is counted without its label.
| Vital | What | Good / poor |
|---|---|---|
APP_START | Cold launch to the first frame after the first screen(), once per launch. iOS and Android from the process start; React Native from the JS bundle start (pass init({appStartTime}) to measure from the process) | ≤ 2 s / > 5 s |
SCREEN_LOAD | screen() to the next frame | ≤ 700 ms / > 2 s |
FROZEN_FRAMES | Frames over 700 ms in one screen view, sent with its page_leave | 2% / 10% of screen views |
ANR | Main thread blocked 2 s (iOS) or 5 s (Android), sent when it recovers; not on React Native | 0.47% / 1% of sessions |
captureVitals is on by default; vital(name, value, props) sends your own measurement. Performance → Mobile shows them by platform, app version and screen.
Native replay records masked screenshots: on a screen change, 0.3 s after a tap, and while the screen changes at most once a second (an unchanged screen sends nothing). JPEG at about half quality, at most 720 px wide, in a separate 5 MB queue sent after the events; replayOnCellular: false holds uploads for Wi-Fi. It records only when the project’s replay setting is on and the session is sampled in. The server refuses any frame that is neither masked on the device nor carries the regions it must paint.
| iOS | Android | React Native | |
|---|---|---|---|
| Masked automatically | UITextField, UITextView, secure entry, WKWebView | EditText, password inputs, WebView | ClickIQTextInput, the focused TextInput |
| Mark your own | view.clickiqPrivate = true; SwiftUI .clickiqPrivate() | view.clickiqPrivate(); Compose: ClickIQ.privateRegion(id, rect) | <ClickIQPrivate>, useClickIQPrivate(ref) |
| Painted black | In the renderer, before the image is encoded | On the bitmap, before compression | By the server, before the frame is stored |
Project setting mask_all_text | Every UILabel and SwiftUI text | Every TextView | The whole frame is blurred |
| Needs | Nothing | ACCESS_NETWORK_STATE only for Wi-Fi-only uploads | react-native-view-shot (without it replay stays off) |
// iOS
passwordField.clickiqPrivate = true // text fields are already masked
Text(account.iban).clickiqPrivate() // SwiftUI
// Android
pinView.clickiqPrivate()
Modifier.onGloballyPositioned { ClickIQ.privateRegion("pin", it.boundsInWindow().toAndroidRect()) } // Compose
// React Native: plain TextInputs are not found automatically, so use the drop-in
<ClickIQTextInput placeholder="Email" value={email} onChangeText={setEmail} />
<ClickIQPrivate><Text>{user.cardNumber}</Text></ClickIQPrivate>Measured on busy scripted sessions with a screen change every 3 seconds: about 24 frames a minute and 1.3–1.5 MB a minute uploaded; a calmer app (a change every 10 s) is about 300–500 KB a minute. Turn replay off with replay: false (React Native), .enabled = false (iOS) or ReplayOptions(enabled = false) (Android).
// React Native
if (isEnabled("new-onboarding")) { /* … */ }
const v = variant("checkout-redesign"); // "control" | "new" | true | false | undefined
// iOS
if ClickIQ.variant("checkout-redesign") == "new" { … }
// Android
if (ClickIQ.isEnabled("new-onboarding")) { … }Flags are evaluated by the server on start (with the device object, so platform, app version and device type targeting works), after identify and after reset, and cached on the device so reads are synchronous from the next launch. The first read of each flag per session sends $flag_exposure. For surveys, the SDKs return the live list for this app (surveys()) and send the answers with surveyShown, surveyResponded and surveyDismissed; the app draws the questions itself. A project’s session sample rate applies here too (sampleRate overrides it).
Identify people by a stable account id, never an email or a name. Only what you pass to the SDK, the taps and vitals it measures, and (if replay is on) masked frames leave the device.