One student, four apps: web and mobile analytics on one contract

An education product has a problem most analytics setups make worse. The same family touches it from three places: a parent compares tutors on a laptop, signs up and books a trial; the student opens the iPhone app that evening to start the lesson; a week later the parent checks progress on an Android tablet. If each of those surfaces reports to a different tool, or to the same tool in a different shape, the question the business actually cares about — how many sign-ups become a booked lesson, and where do the rest stop? — has no single answer.

iLearnRight is a tutoring and SAT-prep service with a React web app (served under /rt) and native apps. This post walks through how it is wired into InsightClickIQ: one contract for the browser and three mobile SDKs, people identified by an account guid and nothing else, and the funnel that runs across all of it. It also shows the real state of the project on the day the SDKs were verified, because a post about an install that pretends to have months of data would not help anyone planning one.

The idea: a screen is a page

The browser SDK sends a pageview every time the route changes. The mobile SDKs do not invent a second vocabulary for apps; they send the same event type with an app:// URL. A screen called LessonList in the app with bundle id com.ilearnright.app arrives as a pageview at app://com.ilearnright.app/LessonList. The server stores the path as /LessonList and the host as the bundle id. From that moment every report that works on pages — top pages, paths, journeys, funnels, segments, an AI answer about where people drop off — works on screens without a line of extra code.

Four clients, one contract, one event table The React web app, the React Native app, the iOS app and the Android app each send the same batch payload to one endpoint. Screens become pageviews, taps become clicks, the device object replaces the user agent, and every event lands in one table with a platform column that every report can filter on. What each client sends Same field names, same event types, same identity rule React web app (/rt) snippet, autocapture, routes as pageviews platform = web · replay, heatmaps, vitals identify(account guid) React Native / Expo useScreenTracking(navigationRef) platform = ios | android identify(guid, {role}) iOS (Swift package) .clickiqScreen("LessonList") crashes sent on next launch identify(guid) Android (Kotlin) ClickIQScreen("LessonList") offline queue on disk, cap 500 identify(guid) POST /api/ingest/batch screen → pageview url app://com.ilearnright.app/LessonList tap → click el.selector "LessonList/Start lesson" device → platform, app_version, device_model, os "iOS 17.5" track("Lesson booked", {subject}) user_id = guid · no name, no email One event table path /LessonList and /rt/… platform web | ios | android host = bundle id or domain Unchanged reports funnels, journeys, paths, segments, AI answers — breakdown by platform Native apps in this version: no replay, no heatmaps, no Core Web Vitals. Only what the app passes to screen, tap, track, identify and error leaves the device.
Four clients, one payload. The differences between web and native are carried as columns (platform, app_version, device_model) rather than as separate event types, so one query can cover every surface or be broken down by any of them.

The other conventions follow the same rule: keep the event, change the field.

What happens in the appWhat the SDK sendsWhere it lands
The student opens a screenpageview, name: "screen", url app://<bundle>/<Screen>path = /LessonList, host = com.ilearnright.app
The student leaves itpage_leave with duration_ms (time on screen)Average time on page, as for the web
A button is tappedclick with el: {tag: "button", text: "Start lesson", selector: "LessonList/Start lesson"}, no x/yElement-level click counts per screen
The app describes itselfdevice: {platform, os, os_version, model, app_version, sdk} instead of a User-Agentplatform, app_version, device_model; os = “iOS 17.5”; browser = the SDK name
Something throwserror with source = file or class; crashes with props.fatal = trueErrors page, grouped by fingerprint
The app goes to the background for 30 minutesA new session_id on return, the current screen re-sent with props.resumedSessions that mean the same thing on every platform

The three native SDKs are separate code bases with the same surface: init (or configure on iOS) with a key and a host, then screen, tap, track, identify, reset, flush and optOut. Each keeps an offline queue on disk capped at 500 events, sends batches of up to 50, and backs off on network errors, 429 and 5xx. None has a third-party dependency: the Android library uses HttpURLConnection and a hand-written JSON encoder, the iOS package is Swift 6 with nothing else, and the React Native package picks up AsyncStorage and device-info libraries only if they are already installed.

How each surface is wired

The React web app

The web app loads the snippet from a small module, src/lib/clickiq.ts, and only when both VITE_CLICKIQ_KEY and VITE_CLICKIQ_HOST are set — otherwise every helper is a no-op, so the app never depends on analytics being reachable. Route changes, clicks, rage and dead clicks, form timing, errors, Core Web Vitals and replay are captured automatically. The learning events are named explicitly where they happen: track("Lesson booked", {subject, duration_minutes, lesson_type, amount}) in the booking page’s success handler, Signed up on registration, Profile completed, Diagnostic completed, Mock test completed, Course enrolled, Asked AI tutor and a dozen more.

React Native

One provider and one hook. ClickIQProvider takes the key, host, app version and bundle id; useScreenTracking(navigationRef) listens to React Navigation and calls screen() once per focused route, walking nested navigators. A button that matters calls tap("Start lesson") and the business event beside it:

<Button title="Start lesson" onPress={() => { tap("Start lesson"); lessonStarted("math"); }} />

iOS and Android

SwiftUI gets a modifier, .clickiqScreen("LessonList"), that sends the pageview on appear and the page_leave on disappear; UIKit calls it from viewDidAppear. Compose gets ClickIQScreen("LessonList") inside each destination, and fragments call clickiqScreen() from onResume. Both native SDKs install a crash handler that writes the crash to disk and reports it as an error with fatal: true on the next launch, in the session it belonged to.

Identify by guid, and nothing else

This is the rule that matters most for a business whose users include children. Every surface identifies a signed-in person by their account guid, never by an email or a name. The web helper sends the guid plus four non-identifying traits — role, time zone, intent and whether the profile is complete — and the React Native example sends the guid and the role. A student’s identity stays in iLearnRight’s own database; the analytics only ever sees a stable id it can join sessions on.

WHAT MOST SETUPS DOidentify(email, {name, school})

The student’s name and email now live in a second system, in every export, and in every AI prompt that reads the people table. Erasing them means finding them everywhere.

WHAT ILEARNRIGHT DOESidentify(guid, {role, timezone})

The analytics can count, join and segment people without knowing who they are. A parent and a student are told apart by role, not by a name.

The rest of the privacy posture comes from the product rather than from discipline:

  • Native apps send what you allow. Without tap capture and replay, only what the app passes to screen, tap, track, identify and error leaves the device. Native replay, added since this was first written, records screenshots with text inputs and views you mark private painted black on the device; a children’s app can leave it off, or turn on masking of all text.
  • On the web, field values never leave the browser. Form analytics sends lengths (bucketed), counts and timings; password and card fields are skipped. Replay masks every input by default, data-private masks text, and data-clickiq-block removes an element from recording and click capture entirely.
  • Keep props flat and free of personal content. No free text the user typed, no addresses. Profile completed carries minor as a flag — useful for segmenting, not a fact about a named child.
  • Sign-out is reset(), which forgets the user, starts a new anonymous id and a new session, so a shared family tablet does not merge two people.
  • optOut(true) clears ids, the session and the queue and stays off across launches.

Erasure is one call

When a family asks to be forgotten, DELETE /api/people/{id} removes the person, every anonymous id they were seen under, their sessions, events and replay objects. Because the analytics only ever held a guid, there is no second copy of their name to chase.

The funnel the business cares about

iLearnRight’s project lists six conversion events: Signed up, Profile completed, Lesson booked, Tutor application submitted, Diagnostic completed and Mock test completed. The headline funnel is the first three in order — sign-up, a completed profile, a booked lesson — as a single QuerySpec:

{"kind": "funnel", "range": "30d", "window": "7d",
 "steps": [{"event": "Signed up"}, {"event": "Profile completed"}, {"event": "Lesson booked"}]}

Because a screen is a pageview and a named event is a named event on every platform, the same spec counts a parent who signs up on the web and books in the iOS app as one journey once both surfaces identify them with the same guid. Add "breakdown": "platform" to the trend of Lesson booked and the split between web, iOS and Android falls out; filter on app_version and a release that broke booking on Android shows up as one line going flat. None of this needs a mobile-specific report.

What the first day actually looked like

Here is the honest part. The project was created on October 26, the web app and all three SDKs were verified against it that afternoon, and real traffic had not started. So the data below is the verification runs — a Playwright script driving the web app, and the end-to-end test of each native SDK — not students.

Sessions · iLearnRight · last 30 days
The Sessions list for iLearnRight: guid-ios-0001 on clickiq-ios iOS 17.5, guid-rn-0001 on clickiq-react-native iOS 17.5, guid-android-0001 on clickiq-android Android 14, and several web sessions marked Bot on /rt/auth/login, with an AI strip saying only 8 sessions in 30 days, half bots, data too thin
Eight sessions, four sources. The three identified rows are the native SDK test runs, each identified by a guid. The Bot rows are the headless browser that verified the web app. The AI strip above the list reads the same numbers and says the honest thing: too thin to conclude anything yet.

That one screen is the argument for the contract. Three different SDKs written in three languages produced rows that sit in the same list, with the same columns, and the console did not need to know which was which: the browser column carries the SDK name, the os column reads “iOS 17.5” or “Android 14”, and the entry path is a screen.

Session · guid-android-0001
A session page for guid-android-0001 in Chicago, 11 minutes 28 seconds, with no replay recorded and a timeline of 16 events: session started, viewed /LessonList, clicked Start lesson, Lesson started, identified as guid-android-0001, an error named boom, repeated at 9:59 and 11:28
A native session reads like a web one. No replay was recorded for this session, and the page says so plainly — but the timeline is the same: the screen, the tap with its label, the named event, the identify by guid, the error.
Analyze · events by SDK
The Analyze table view for iLearnRight: any event, total events, broken down by browser, showing Bot 86, clickiq-android 16, clickiq-ios 10, clickiq-react-native 9, ran in 16 ms
One query over every surface. The breakdown that separates platforms is just a column. Swap browser for platform or app_version and the same table splits by operating system or by release.
4SDKs on one ingest contract
121events from the verification day
0names or emails sent
16 msto break every event down by SDK

What you get on each surface, honestly

The contract is shared; the capabilities are not identical, and it is better to plan around the gaps than to discover them.

CapabilityReact web appReact Native / iOS / Android
Pages / screens, sessions, people, funnels, journeys, segmentsYesYes, unchanged
Named product events and identifyYesYes
ClicksAutocaptured, with page coordinatesTaps with coordinates, rage and dead taps (tap capture or tap())
ErrorsUncaught errors, rejections, console errorsUncaught errors; crashes sent on next launch
Session replayYes, inputs maskedMasked screenshots, opt-in per project
Heatmaps and zonesYesTap heatmaps and zones per screen
Core Web VitalsYesMobile vitals instead: app start, screen load, frozen frames, ANR
Form field analyticsYes, never valuesNo
Remote config and flagsYesReplay and sampling settings; feature flags on all three

For a tutoring app the remaining gap is small. The questions that need form analytics — why parents abandon the booking form — are web questions, because booking and payment happen on the web. The questions about the apps are behavioural: which screens students reach, how long a lesson screen stays open, which release crashes or freezes. Screens, taps, named events and errors answer those, and tap heatmaps, mobile vitals and masked replay (added since this post was first written) show where and how.

What to put in the plan

  1. Agree the event names once, across teams. Lesson booked must be spelt the same in the React app, the Swift code and the Kotlin code, or the funnel splits in three. A short list in the repository beats a wiki page.
  2. Name screens for people, not for classes. LessonList reads well in a journey; LessonListFragmentV2 does not. Android’s automatic activity screens are convenient but name screens after classes; explicit ClickIQScreen calls give you the choice.
  3. Identify on login and reset on logout, on every surface. This is what joins the parent on the laptop to the student on the phone.
  4. Give the AI time. The console already reads the first day correctly — “data is too thin for reliable conclusions” — and will say more once there is more to read. Journeys need sessions in the hundreds before their patterns are worth acting on.

The parent on the laptop and the student on the phone are one family. One contract, one guid and one funnel is what lets the numbers say so.

How the privacy controls work in detail →  ·  Ask the same data from Claude or Cursor →

← All posts   Request a demo