Tracking Agent docs
How to use the Tracking Agent with a mobile app
The Tracking Agent checks Swift, Kotlin and Dart apps the way it checks web code, with a typed tracker in the app's own language. Its scanner reads Swift, Kotlin and Dart syntax, verification writes unit tests at the view-model level, and the post-release watch waits for the app version that ships each change. React Native apps use the TypeScript tracker. Nothing runs on a device or an emulator: the unit tests are the mobile check.
Which mobile languages and analytics SDKs does the Tracking Agent support?
| Language | Typed tracker | Scanner | Generated tests |
|---|---|---|---|
| Kotlin (Android) | Tracking.kt, in your package | Kotlin syntax trees | JUnit on the JVM, through Gradle |
| Swift (iOS) | Tracking.swift | Swift syntax trees | XCTest, through swift test or xcodebuild |
| Dart (Flutter) | tracking.dart, safe for Flutter web | Dart syntax trees | flutter test or dart test |
| React Native | The TypeScript tracker; the adapter for your React Native SDK is one file you write (the TypeScript adapters are for the browser and Node SDKs) | The TypeScript scanner | Node's test runner, as for web code |
| Analytics SDK | Kotlin | Swift | Dart |
|---|---|---|---|
| Mixpanel | mixpanel-android | mixpanel-swift | mixpanel_flutter |
| PostHog | posthog-android | posthog-ios | posthog_flutter |
| Amplitude | analytics-android | Amplitude-Swift | amplitude_flutter |
| Segment | analytics-kotlin | analytics-swift | segment_analytics |
| RudderStack | the Android SDK | rudder-sdk-ios | rudder_sdk_flutter |
| GA4 | Firebase Analytics | Firebase Analytics | firebase_analytics |
Each adapter is one file you own, written by datalyze adapter <vendor>. They were checked against the vendors' SDKs when they were written: the Kotlin ones compiled against each Android SDK (a test repeats it when the SDKs are on the machine), four Swift ones built against their SDKs and Firebase's type-checked against its framework, RudderStack's iOS one read against the SDK's header, since that SDK builds only for iOS, and the Dart ones analyzed against each package's source.
How do I set up the Tracking Agent on an Android, iOS or Flutter app?
- Run setup as for any repo. The install audit picks the tracker from the kind of app: an Android app module's
namespace, a Flutterpubspec.yaml, or an Xcode project.datalyze initrun in the app's checkout proposes the same tracker and writes it to_conventions.yaml. To choose yourself, settyped_trackerthere before the audit runs. - Add your unit test command under
checksin.datalyze/config.yaml, such as./gradlew testDebugUnitTest, so every change is built and tested. - Set
tests.native: the language, the folder your unit tests live in, and the command that runs one test class.datalyze initproposes all three from the app it finds; browser setup doesn't ask yet, so there you add them yourself. The agent writes its tests in adatalyzetestsfolder inside that folder. - If your data comes from a warehouse while the app sends through an SDK, setup asks for that SDK and writes it as
analytics.adapter, so the install audit writes the right adapter.
# .datalyze/config.yaml (an Android app)
checks:
- { name: Unit tests, run: ./gradlew testDebugUnitTest, timeout_minutes: 15 }
tests:
enabled: true
native:
language: kotlin # kotlin | swift | dart
dir: app/src/test/java # your unit tests
run: ./gradlew testDebugUnitTest --rerun --tests 'datalyzetests.{class}'
watch:
app_version: { file: app/build.gradle.kts, pattern: 'versionName\s*=\s*"([^"]+)"' } # found by default# analytics/registry/_conventions.yaml, if you set it yourself
typed_tracker:
language: kotlin
out_dir: app/src/main/java/com/acme/shop/tracking
import_path: com.acme.shop.tracking
package: com.acme.shop.trackingFor iOS, run is usually swift test --filter {class}, or xcodebuild test -project App.xcodeproj -scheme App -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' -only-testing:AppTests/{class} (-workspace App.xcworkspace if the app has a workspace). xcodebuild test needs a real simulator, not a generic destination, so name one your runner has, and check the scheme: datalyze init proposes your shared scheme, or the project's name, which is only a guess. In an Xcode project (not a Swift package), add Tracking.swift and the adapter to the app target and the datalyzetests folder to the test target, as a folder reference, so new files in it are built. For Flutter it's flutter test {file}. {class} is the test file's class and {file} its path.
What does the mobile typed tracker look like?
datalyze generate writes the same calls in each language, with named parameters, so a wrong event or property name is a compile error, and enums for planned values. Parameters are camelCase; what's sent keeps the registry's names, so cartId arrives as cart_id.
Tracking.configure(adapter = MixpanelTrackingAdapter(mixpanel))
Tracking.track.checkoutStarted(cartId = cart.id, itemCount = 3, entryPoint = CheckoutStartedEntryPoint.BUY_NOW)
Tracking.user.identify(account.id)
Tracking.user.setOnce(firstOpenDate = today)Tracking.configure(adapter: MixpanelTrackingAdapter(Mixpanel.mainInstance()))
Tracking.track.checkoutStarted(cartId: cart.id, itemCount: 3, entryPoint: .buyNow)
Tracking.user.identify(account.id)Tracking.configure(adapter: MixpanelTrackingAdapter(mixpanel));
Tracking.track.checkoutStarted(cartId: cart.id, itemCount: 3, entryPoint: CheckoutStartedEntryPoint.buyNow);- A property whose example allows
nullis a required argument that may be nil; one withrequired: falsecan be left out, and then nothing is sent. - Events and user properties set from your backend aren't on a mobile tracker. The backend gets its own TypeScript tracker from the same registry.
- In capture mode,
Tracking.configure(capture: true)orDATALYZE_CAPTURE=1, every call is recorded instead of sent: in memory, as aDATALYZE_CAPTUREline in the device log, and in the file named byDATALYZE_CAPTURE_FILE. - A repo with several apps lists the others under
typed_tracker.targets, and each gets its own tracker from the one registry.
How does the Tracking Agent read Swift, Kotlin and Dart code?
The scanner parses each file with tree-sitter grammars instead of matching text, so a call in a comment or a string isn't counted, and properties come from the call's own arguments. It reads event names from string literals and from constants anywhere in the repo, such as Events.SIGN_UP or an enum's raw value, and properties from dictionary and map literals, mapOf("k" to v) and JSONObject().put("k", v). A name built at runtime is reported, unless scan.dynamic_names lists what it can be.
Legacy calls are named by their dotted path in scan.legacy_trackers, with calls in the chain left out: Mixpanel.mainInstance().track(...) is Mixpanel.mainInstance.track, and a * stands for any one name, as in AnalyticsManager.*.track. A Swift enum case with no raw value sends its own name, which the scan shows as it is. An entry whose from is a JavaScript module (mixpanel-browser, @/lib/analytics) counts in app code only where a file imports it, and one with extensions only in files with those extensions, so a web entry such as { callee: mixpanel.track, from: mixpanel-browser } never matches app code. A bare name passed to the call is read from the calling function first: its own parameter is a name sent at runtime, and a local val or let there counts; another function's local never does.
How are tracking changes verified in a mobile app?
The agent writes unit tests in your app's own framework, at the view-model or presenter level, with the typed tracker in capture mode. Each test file runs on its own with its own capture file, and code compares what was captured with the registry, exactly as for web tests. The tests live in a datalyzetests folder that is never committed or scanned.
A call that goes straight to a singleton SDK object can't be faked in a unit test, so that item is reported as not testable and the verifier checks it by reading. Moving the call to the typed tracker makes it testable. There's no emulator or simulator layer: on mobile, the unit tests, your own checks and the independent verifier are the verification.
How does the post-release watch handle app store releases?
A mobile change reaches users only when a release with it is installed. The watch reads the app version at the merge commit (Android versionName, Flutter's pubspec.yaml, Xcode's MARKETING_VERSION). If a tag shows that version was already released before the merge, the change ships in the next version. The watch waits until events from that release arrive, then counts only it and newer versions. A deprecated event still sent by older installs is expected and doesn't fail; one still sent by the new release does. Without release tags, the version at the merge is taken as the one that ships, so a team that bumps the version when it cuts a release sets watch.release: after_merge. Setup asks how you cut releases and writes it.
| Setting | Default | What it does |
|---|---|---|
watch.app_version | Found from the usual files | Where the version is written: { file, pattern } with the version in group 1, or false to watch by time |
watch.release | Decided from tags | at_merge: the version at the merge ships the change. after_merge: the next version does, for teams that bump the version when they cut a release |
watch.release_wait_days | 45 | Days to wait for the release to appear before the watch stops and says so |
watch.lookback_days | 60 for a mobile app | How far back to look for merged pull requests |
The data needs each watched event counted by app version over the whole window, not read from a sample: a rare event can be missing from a sample, and a sample's counts aren't the window's. The PostHog recipe counts every event by version; the Mixpanel and Amplitude recipes count the events the watch is watching, one request each, and mark the rest as sampled. A warehouse needs the column mapped (analytics.source.columns.app_version), which the Segment, RudderStack and GA4 layouts do and the export presets don't. An event that no version is ever sent with, such as one from the backend, is counted since the merge like a web event, and the report says so. When a watched event's versions can't be counted yet (only a sample, or the tool rate-limited the query), the watch says so on the PR and waits, counting from when the release first appeared; with no versions in the data at all it waits and then says it couldn't check, rather than calling new events missing before the release is out. A snapshot you build yourself needs app_versions_complete: true on each watched event for this. For what to check once a release is out, see the playbook chapter on event verification.
Questions about mobile apps
Does the Tracking Agent run our app on an emulator?
No. Mobile verification is unit tests at the view-model level, your own checks and an independent verifier. No device, emulator or simulator is needed.
Does it work with React Native?
Yes, through the TypeScript tracker and scanner, and in capture mode the tracker also writes each call to the device log. The adapter for your React Native SDK is a short file you write against the same interface.
Do we have to move every call to the typed tracker first?
No. The install audit records your existing calls as legacy trackers and the check works with them. Moving an area to the typed tracker is its own reviewed pull request, and it makes those calls testable.
Can someone set this up for us?
Yes. Our analytics setup covers tracking plans and SDK set-up for mobile apps, and improving app retention shows what the data is for.