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.

By Ansh AgrawalUpdated

Which mobile languages and analytics SDKs does the Tracking Agent support?

LanguageTyped trackerScannerGenerated tests
Kotlin (Android)Tracking.kt, in your packageKotlin syntax treesJUnit on the JVM, through Gradle
Swift (iOS)Tracking.swiftSwift syntax treesXCTest, through swift test or xcodebuild
Dart (Flutter)tracking.dart, safe for Flutter webDart syntax treesflutter test or dart test
React NativeThe 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 scannerNode's test runner, as for web code
Analytics SDKKotlinSwiftDart
Mixpanelmixpanel-androidmixpanel-swiftmixpanel_flutter
PostHogposthog-androidposthog-iosposthog_flutter
Amplitudeanalytics-androidAmplitude-Swiftamplitude_flutter
Segmentanalytics-kotlinanalytics-swiftsegment_analytics
RudderStackthe Android SDKrudder-sdk-iosrudder_sdk_flutter
GA4Firebase AnalyticsFirebase Analyticsfirebase_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?

  1. Run setup as for any repo. The install audit picks the tracker from the kind of app: an Android app module's namespace, a Flutter pubspec.yaml, or an Xcode project. datalyze init run in the app's checkout proposes the same tracker and writes it to _conventions.yaml. To choose yourself, set typed_tracker there before the audit runs.
  2. Add your unit test command under checks in .datalyze/config.yaml, such as ./gradlew testDebugUnitTest, so every change is built and tested.
  3. Set tests.native: the language, the folder your unit tests live in, and the command that runs one test class. datalyze init proposes 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 a datalyzetests folder inside that folder.
  4. 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.
yaml
# .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
yaml
# 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.tracking

For 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.

kotlin
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)
swift
Tracking.configure(adapter: MixpanelTrackingAdapter(Mixpanel.mainInstance()))
Tracking.track.checkoutStarted(cartId: cart.id, itemCount: 3, entryPoint: .buyNow)
Tracking.user.identify(account.id)
dart
Tracking.configure(adapter: MixpanelTrackingAdapter(mixpanel));
Tracking.track.checkoutStarted(cartId: cart.id, itemCount: 3, entryPoint: CheckoutStartedEntryPoint.buyNow);
  • A property whose example allows null is a required argument that may be nil; one with required: false can 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) or DATALYZE_CAPTURE=1, every call is recorded instead of sent: in memory, as a DATALYZE_CAPTURE line in the device log, and in the file named by DATALYZE_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.

SettingDefaultWhat it does
watch.app_versionFound from the usual filesWhere the version is written: { file, pattern } with the version in group 1, or false to watch by time
watch.releaseDecided from tagsat_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_days45Days to wait for the release to appear before the watch stops and says so
watch.lookback_days60 for a mobile appHow 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.