Makes product analytics in an Expo app something a reviewer can trust: nothing leaves the device until the user said yes, the vendor SDK exists only after that yes, every event comes from a typed catalog that cannot carry personal data, analytics never tracks unless the app deliberately opts into Apple's ATT, and the App Store and Google Play answers are generated from the same contract the code is checked against. Two zero-dependency checkers prove it on the real code.
Rules that must hold
- Opt-in before any analytics SDK exists. The consent state starts
'unknown';createAnalyticsdrops every event untilsetConsent('granted')answers the current question, and callscreateSdk(the vendor adapter) only then, in a build whose mode is notoff. Why: Apple 5.1.1(ii) requires consent for usage data even when anonymous, ePrivacy requires it for an install id, and some SDKs contact their servers when constructed, opted out or not (PostHog 4.79.1 loads remote config). - Withdrawing is as easy as giving, and stops and forgets. A settings row calls
setConsentat any time; a no runsreset()(anonymous and device ids and the unsent queue dropped) thenoptOut(), in that order, orclearSdkStoragewhen no SDK runs in the session, andforget()also clears the saved answer (account deletion). Why: 5.1.1(ii) and the GDPR require an easy withdrawal; PostHog'sreset()clears a saved opt-out (the reverse order opts the user back in) and by default keeps the device id and the queue, which a flush still sends; an earlier session's ids stay on the device until something deletes them. - A changed question asks again. Raise
ANALYTICS_CONSENT_VERSIONwhenevercollects,sdkorendpointsincontracts/analytics.jsonchange; an older answer reads as'unknown'. The contract'sconsent: { version, covers }records the fingerprint of what that version covers (consent-covers). Why: consent covers the purposes the user was shown, not the ones added later. - Ads consent is not analytics consent, and analytics does not track by default. Never derive the
analytics answer from UMP's
canRequestAds; the default contract saystracking: false, the adapter reads no advertising id, andcreateAnalyticsgets notrackingoption. The tracking profile passes the canonicalcreateExpoTrackingConsentAdapter(never a second ATT import) and the adapter learnsisTrackingAllowedonly from an'authorized'answer. Why: UMP asks for Google's ad vendors, not the app's analytics vendor; tracking without ATT breaks 5.1.2(i). - One vendor, one importer, created lazily in the root-kit registry.
contracts/analytics.jsonsdkis the only analytics package installed; onlysrc/services/analytics/<vendor>-analytics-adapter.tsimports it; the adapter is created only insidecreateAnalytics({ createSdk })insrc/services/create-root-kits.ts(app-startup-and-bootstrap's registry, part of the composition root), which registerscreateAnalyticsKit: features reach the port throughuseAnalytics(), never through an edited root file; the client is constructed inside the adapter's factory, never at module level; tests use the fakes. Auto-start SDKs (expo-insights; expo-observe unless the app writesdispatchingEnabled: false) and session replay are not allowed. Why: one seam to audit and upgrade, and a structural guarantee that nothing runs before consent. - The SDK's privacy options are written out. PostHog:
defaultOptIn: false,personProfiles: 'never'(anonymous),disableGeoip: trueor a declared coarse location, no lifecycle events, no autocapture, no session replay, noPostHogProvider, error autocapture off, an explicithost,disableRemoteFeatureFlags: true, and a reset that isreset([])plus clearing the queue. Why: every default above either collects more than the catalog or survives a withdrawal. - Every event is in the typed catalog, with a purpose, sent by a literal name.
ANALYTICS_EVENTSis plain data; names are snake_case, at most 40 characters, never vendor-reserved;track()is called from a model hook's handler astrack({ name: '<event>', props }). Why: tsc and a text search then show every event and where it is sent; an event without a decision behind it is collection without a purpose. - Events carry no personal data. Properties are
enum,boolean, bucketedcountorroute, never free text; names that mean ids, text, contact or location fail;sanitizeEventdrops anything that does not match at run time. Why: free strings are where titles, search terms and emails leak, usually by accident. - Screens are route patterns.
useScreenViewssendsscreenNameFromSegments(useSegments())(tasks/[id]); nothing sendsusePathname(), search params or URLs. Why: a concrete path holds record ids and whatever a deep link carried. - Anonymous by default. No identify, alias or person properties unless the contract says
identifies: true, and then a linkeduser-idis declared. Why: the declarations and the consent text must match what is sent. - Builds decide the project. Development and preview are
off(ortest), production islive(oroff); key and host come from the build's environment throughanalyticsExtra()intoexpo.extra, read back withreadAnalyticsExtra(extra, applicationId); never a key in code, neverEXPO_PUBLIC_ANALYTICS_*. Why: testing builds must not pollute production data, E2E and audit builds must send nothing, and an EAS Update made for another variant must not redirect events. - Store declarations come from the contract.
collectscovers what the catalog, the SDK and the identity setting imply;app.config.tswritesprivacyManifests: { ...PRIVACY_MANIFESTS, NSPrivacyCollectedDataTypes: [...analyticsCollectedDataTypes(projectRoot)] };contracts/privacy.jsonsdks["<sdk>"].collectsholds the same rows; the developer enters them in App Store Connect and Play's Data safety form. Why: a JavaScript SDK ships no privacy manifest, replacing the manifest drops its required-reason APIs (ITMS-91053), and answers that differ from the code get apps rejected. - Apps for children ship no third-party analytics.
audience: 'child-directed'meanssdk: 'none', no tracking, no identify; stop and ask before any analytics there. Why: App Review 1.3 and 5.1.4 and Play Families forbid device information to third parties. - Analytics is silent and never breaks a feature. No port method throws or rejects; failures go to the error log; at most 100 events wait while the SDK starts. Why: analytics is never what the user came for, and an escaped rejection is lost in release builds.
Workflow
- Decide with the developer (stop and ask; never invent): the vendor (default PostHog), the region
(
endpoints), the audience, whether the app identifies users, and whether any SDK tracks. Read references/vendors-and-sdk-facts.md and references/att-and-tracking.md. The vendor project, its data retention and its data processing agreement are the developer's to set up. - Install:
npm install -E posthog-react-native@4.79.1(outside the Expo map, pinned exactly) andnpx expo install expo-application(readAnalyticsExtracompares the binary's application id); classify it incontracts/architecture.json("vendorSdks": { "posthog-react-native": "AnalyticsPort" }, plusvendor('posthog-react-native', 'AnalyticsPort')in the ESLint config) and in the dependency policy. - Copy the templates at the paths their first lines name:
templates/src/services/analytics/,templates/src/config/analytics-config.ts(+ test),templates/src/hooks/use-screen-views.ts(+ test),templates/scripts/analytics/(+ test),templates/contracts/analytics.json(+ schema; fill it). The synced canonical files (src/services/consent/*,src/config/app-variant.ts,src/core/result.ts, the tracking mock) are copied only when the app lacks them. - Write the catalog from the app's real questions: read references/event-catalog.md; one event per decision, closed property kinds.
- Write the vendor adapter after examples/posthog-analytics-adapter.ts and its test (mock the package; the only test that imports it).
- Register the kit in app-startup-and-bootstrap's registry
src/services/create-root-kits.ts(examples/create-root-kits.ts):createAnalyticswith the adapter call insidecreateSdk, thencreateAnalyticsKit({ analytics, errorLog: data.errorLog })in the list. The kit provides the port (useAnalytics()), sends screen views beside the root Stack and flushes on the way to the background; the root files stay unchanged. Wireapp.config.ts(examples/app-config-analytics.ts) and the EAS environments (references/modes-keys-and-testing.md). - Ask and let the user change the answer: read references/consent-gate.md;
the prompt after onboarding and the settings row (examples/use-usage-data-model.ts,
examples/usage-data-prompt.tsx), its keys merged into every catalog
(
templates/src/i18n/catalogs/);forget()from account deletion. - Send events from model handlers through
useAnalytics()(examples/use-tasks-model.ts); screen views and the background flush come with the kit (analytics-root-kit.tsx). - Declare: read references/store-declarations.md; fill
collects(the one source), copy it intocontracts/privacy.jsonsdks["<sdk>"].collectswhen that contract exists, give the developer the App Privacy and Data safety rows; hand the manifest and network audit to privacy-and-store-declarations. - Run the tests:
npx jest src/services/analytics src/config/analytics-config.test.ts src/hooks/use-screen-views.test.tsx scripts/analytics --ci --maxWorkers=2. An app with the port and catalog but no vendor yet setssdk: "none"(every build off, nothing collected): check-analytics then notes thatmanifest-wiringandconsent-uiwait for the vendor. - Run the checkers:
node ${CLAUDE_SKILL_DIR}/scripts/check-analytics.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-analytics-behaviour.mjs .; fix everyFAILline and rerun until both printRESULT: PASS. - Tell the developer what is theirs (vendor-console and store steps the agent cannot do): the
vendor project and region, its "Discard client IP data" setting turned on (
disableGeoipdoes not stop the server storing the IP; otherwise declare and justify keeping it), retention, the agreement, the store forms, the privacy policy text, and the device walkthrough (references/modes-keys-and-testing.md).
Definition of done
- One analytics SDK, declared in
contracts/analytics.json, pinned, imported only by its adapter, created only insidecreateSdkin the root-kit registry, which registerscreateAnalyticsKit; no auto-start or replay SDK; tests use the fakes. - Nothing is sent and no SDK exists before a yes; a no resets then opts out (or
clearSdkStoragedeletes the stored SDK state when no SDK runs);forget()is wired to account deletion; the settings row changes the answer;ANALYTICS_CONSENT_VERSIONequalsconsent.versionandconsent.coversis the fingerprint of the currentsdk,endpointsandcollects(check-analyticsconsent-covers). - Every event is in
ANALYTICS_EVENTSwith a purpose and closed property kinds; screen views are route patterns; no identify unless declared. - Default profile: no ATT from analytics, no advertising id, nothing declared as tracking (or the tracking profile done as references/att-and-tracking.md says); child-directed apps have no vendor.
-
ANALYTICS_MODEper variant set in the EAS environments; no key in code; off builds for E2E, screenshots and the network audit. -
collectsdrives the privacy manifest; the developer has the App Privacy and Data safety rows; the device walkthrough is done or reported as owed. - The Jest suites of step 10 pass.
- Expected only:
SKIP app.config.ts [manifest-wiring] not yet created (expo-project-bootstrap creates it)before the app has a config,SKIP contracts/privacy.json [privacy-sync] not yet created (privacy-and-store-declarations creates it)before the privacy contract exists, andNOT APPLICABLEin an app with no analytics folder, contract or SDK. -
node ${CLAUDE_SKILL_DIR}/scripts/check-analytics.mjs .printsRESULT: PASS -
node ${CLAUDE_SKILL_DIR}/scripts/check-analytics-behaviour.mjs .printsRESULT: PASS
Anti-patterns
- Constructing the SDK at startup "opted out until consent". Some SDKs still call home on
construction; create it inside
createSdk(check-analyticssdk-lazy). optOut()thenreset(). PostHog's reset clears the saved opt-out; reset first, then opt out.- Treating UMP's
canRequestAdsas analytics consent, or putting analytics in Google's form (check-analyticsconsent-source). setConsent('granted')in an effect, a render, a service or at startup. Only the user's tap answers (consent-source).- Constructing the SDK only to reset it after a no. Pass
clearSdkStorage(sdk-clear). const client = new PostHog(...)at the top of the adapter, as vendor guides show: importing the module builds the client at launch (check-analyticssdk-lazy).privacyManifests: { NSPrivacyCollectedDataTypes: [...] }without...PRIVACY_MANIFESTS. It drops the required-reason APIs and the upload is rejected (manifest-wiring).- A pre-checked box, a bigger "Share" button, or a feature unlocked by sharing. Two equal choices; nothing depends on the answer (5.1.1(ii)).
track({ name, props: { title: task.title } })or any free-text property. Use enums, booleans and bucketed counts (event-pii,event-props).- Screen tracking with
usePathname()and params, as generic guides show. Send route patterns. identify(user.email). Anonymous by default; an account id only withidentifies: trueand a linkeduser-iddeclaration.PostHogProviderwith autocapture, session replay, or the SDK's automatic screen capture. They collect what the catalog does not allow.- Listing the analytics host in
NSPrivacyTrackingDomains. iOS then blocks it for everyone who declined ATT; that list is for tracking hosts only. EXPO_PUBLIC_ANALYTICS_KEYor a key typed insrc/. Use the build environment andexpo.extra.- A second
expo-tracking-transparencyimport for analytics. Use the canonical tracking adapter. - Asking for consent at first launch, over a task, or more than once per question version.
- Fixing a failing checker by loosening the catalog or the fixture. Fix the event or the module.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/consent-gate.md | Opt-in default and its sources, ads vs analytics consent, the record and version, the gate's life cycle, lazy creation, withdrawal order, forget, when to ask, modes, failures, tests | Workflow step 7; before changing the gate |
| references/event-catalog.md | Catalog shape, property kinds, names and limits, personal data, screen views with Expo Router, where to track, changing events, tests | Workflow steps 4 and 8 |
| references/att-and-tracking.md | Apple's tracking definition, the default and tracking profiles, the canonical ATT adapter, Android's advertising ID, manifest tracking keys, apps for children | Workflow step 1; before any attribution SDK |
| references/store-declarations.md | App Privacy answers, the manifest of a JavaScript SDK, Data safety, the mapping table, what the checker requires, when answers change, the privacy policy | Workflow step 9 |
| references/vendors-and-sdk-facts.md | Why PostHog, install, every option with its default, source-verified pitfalls, repo wiring, other vendors, re-verify commands | Workflow steps 1, 2 and 5; on an SDK upgrade |
| references/modes-keys-and-testing.md | Keys and hosts, modes per variant and EAS environments, the binary guard, off builds, the device walkthrough, test recipes, troubleshooting | Workflow steps 6 and 12; when events do not arrive |
templates/src/services/analytics/ |
Port and SDK seam, consent-store port, event catalog, sanitizer, consent record, the gate (createAnalytics), kv-store consent adapter, fakes, screen names, the root kit (analytics-root-kit.tsx: provider, screen views, flusher) and analytics-context.tsx (useAnalytics), each with its test |
Workflow steps 3 and 6 |
templates/src/config/ |
analytics-config.ts (+ test): modes per variant, analyticsExtra, readAnalyticsExtra; app-variant.ts (+ test), synced (app-variants-and-environment owns it) |
Workflow steps 3 and 6 |
templates/src/hooks/ |
use-screen-views.ts (+ test): screen views by route pattern |
Workflow step 8 |
templates/scripts/analytics/ |
analytics-manifest.ts (+ test): collects to privacy-manifest entries and Data safety rows |
Workflow steps 6 and 9 |
templates/src/i18n/catalogs/ |
en.json, ar.json: the prompt's and the settings row's keys (merge into every catalog of the app) |
Workflow step 7 |
templates/contracts/ |
analytics.json (the app's analytics contract) and analytics.schema.json (every key documented) |
Workflow step 3 |
templates/src/services/consent/ |
The canonical ConsentPort, tracking adapter (+ iOS and Android tests) and fake (+ test), synced; admob-ads-and-consent owns them | Workflow step 3 (tracking profile) |
templates/src/core/ |
result.ts (+ test), synced (error-handling-typed-results owns it) |
Workflow step 3, only when missing |
templates/__mocks__/ |
The expo-tracking-transparency root mock, synced (do not edit here) |
Workflow step 3, tracking profile |
| examples/posthog-analytics-adapter.ts | The PostHog vendor adapter with every privacy option | Workflow step 5 |
| examples/create-root-kits.ts | The analytics lines of the root-kit registry: the gate with the vendor inside createSdk, and the kit |
Workflow step 6 |
| examples/app-config-analytics.ts | The analytics lines of app.config.ts |
Workflow step 6 |
| examples/use-usage-data-model.ts | The prompt and settings-row model | Workflow step 7 |
| examples/usage-data-prompt.tsx | The opt-in prompt, a pure view | Workflow step 7 |
| examples/use-tasks-model.ts | Tracking from a model handler | Workflow step 8 |
scripts/check-analytics.mjs |
Static checks: contract, files, inventory, imports, lazy creation, the root kit, stored-state clear, options, catalog, track calls, screens, identity, tracking, audience, declarations, wiring, privacy-contract sync, keys, test isolation, consent UI and its source | Workflow step 11 |
scripts/check-analytics-behaviour.mjs |
Runs the app's gate, consent codec, sanitizer, screen names, config and declaration mapping against stand-ins | Workflow step 11 |
scripts/lib/ |
analytics-repo.mjs (facts, layout, contract, sources), analytics-rules.mjs (check-analytics' rules), analytics-scenarios.mjs (the behaviour scenarios); app-modules.mjs, source-scan.mjs, ts-literal.mjs synced (do not edit here) |
Only to change a checker |
scripts/check-lib.mjs |
Shared script helper (synced; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers: templates and examples pass, every planted bug fails for its rule | After changing a checker, template or fixture |
assets/analytics-facts.json |
SDK facts (options, auto-start, kinds), advertising-id names, key patterns, catalog limits and personal-data words, Apple identifiers | Read by the checkers; update on an SDK upgrade |
assets/shared.json |
The shared files copied into this skill | When adding a shared file |
tests/ |
The base app (a trimmed privacy contract and privacy-manifest module, silent planted findings under .agents/skills/, skills/ and a vendored folder) and the overlay cases per checker |
When adding a rule |
Related skills
admob-ads-and-consent- Google's consent form and the ATT prompt for ads; owns the ConsentPort.privacy-and-store-declarations- the whole privacy manifest, SDK inventory, network audit and release audit.crash-reporting-and-observability- crash and performance data, its own port and consent decision.architecture-and-boundaries- ports, adapters, the composition root, SDK classification.app-variants-and-environment- variants and environment variables the modes build on.settings-and-preferences- the look of the usage-data row.auth-and-secure-storage- account deletion, which callsforget().app-startup-and-bootstrap- the root-kit registry and slots the kit plugs into (provider, effects, flushers).dependency-management- pinning the vendor SDK.ota-updates- publishing updates with the channel's environment.e2e-maestro- flows onANALYTICS_MODE=offbuilds.i18n-strings-and-catalogs- the prompt's text in every language.