diff --git a/README.md b/README.md index cc25cb7..78bac11 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,31 @@ pnpm add @formo/analytics-react-native @react-native-async-storage/async-storage cd ios && pod install ``` +### Android Setup (web-to-mobile attribution) + +To attribute installs to where the user came from (e.g. a link on `example.com` +→ Play Store → install), install the Play Install Referrer module and rebuild +the native app: + +```bash +pnpm add react-native-play-install-referrer +cd android && ./gradlew clean && cd .. # then rebuild, e.g. npx expo run:android +``` + +This is an **optional** peer dependency. Without it the SDK still works, but +Android install attribution is skipped (the SDK logs a warning on startup) and +`Application Installed` events will not carry `referrer` / `utm_*`. + +Point your marketing links at the Play Store with a `referrer` parameter, e.g.: + +``` +https://play.google.com/store/apps/details?id=&referrer=utm_source%3Dexample.com%26utm_campaign%3Dspring +``` + +iOS note: Apple exposes no install-referrer API, so web-to-mobile install +attribution is not possible on iOS from the SDK. It requires a third-party +attribution service (Branch/AppsFlyer). + ## Quick Start ### 1. Wrap your app with the provider diff --git a/package.json b/package.json index ac6df36..5df373e 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "react": ">=18.0.0", "react-native": ">=0.70.0", "react-native-device-info": ">=10.0.0", + "react-native-play-install-referrer": ">=1.1.8", "wagmi": ">=2.0.0" }, "peerDependenciesMeta": { @@ -86,6 +87,9 @@ "react-native-device-info": { "optional": true }, + "react-native-play-install-referrer": { + "optional": true + }, "wagmi": { "optional": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd8cb1f..0178c52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: ethereum-cryptography: specifier: 3.2.0 version: 3.2.0 + react-native-play-install-referrer: + specifier: '>=1.1.8' + version: 1.1.9 wagmi: specifier: '>=2.0.0' version: 3.7.1(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(viem@2.55.1(typescript@6.0.3)(zod@3.25.76)) @@ -3384,6 +3387,9 @@ packages: react-native-monorepo-config@0.3.5: resolution: {integrity: sha512-Xdu7k0s4V2FHn2OGnlr3D22Dgt5ij0ek9yzloPoLkHh3rL/jUQbPrawieKsAbkqoenv/7pfPJwMlPT27bvN1fg==} + react-native-play-install-referrer@1.1.9: + resolution: {integrity: sha512-COKQA/eS2I9NigxZDVZuLFzGNLBXF4+o3o9EIgULx+aagjVQexlPBh4g9fdnhiSvZk1rzq/asVwIJKRPc9Cy7A==} + react-native@0.85.3: resolution: {integrity: sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -8021,6 +8027,8 @@ snapshots: escape-string-regexp: 5.0.0 fast-glob: 3.3.3 + react-native-play-install-referrer@1.1.9: {} + react-native@0.85.3(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7): dependencies: '@react-native/assets-registry': 0.85.3 diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 84b3a3c..ad74f53 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -134,14 +134,14 @@ export class FormoAnalytics implements IFormoAnalytics { const analytics = new FormoAnalytics(writeKey, options); // Capture attribution BEFORE lifecycle tracking so the first - // Application Installed/Opened events carry utm_*/ref/referrer context. + // Application Installed/Opened events carry utm_*/ref/referrer. // - // Deep-link initial URL is awaited because Linking.getInitialURL() is a - // fast native bridge call and we need its result before lifecycle events - // fire. The url-event subscription is set up synchronously for runtime - // deep links. Install-referrer capture (Play / AdServices) is fire-and- - // forget since it involves potentially-slow network I/O on iOS and is - // best-effort; it'll still populate attribution for subsequent events. + // Both are awaited so the stored traffic source is populated before + // lifecycle fires — that's what lets Application Installed report the + // web-to-mobile referrer (e.g. referrer=example.com). Deep-link initial URL + // is a fast native bridge call; the Android Play Install Referrer is a fast + // one-shot native call (and no-ops instantly when the native module or the + // platform isn't Android), so awaiting it does not meaningfully delay init. if (analytics.isAttributionEnabled("deeplinks")) { try { await analytics.startDeepLinkCapture(); @@ -151,12 +151,14 @@ export class FormoAnalytics implements IFormoAnalytics { } if (analytics.isAttributionEnabled("installReferrer")) { - captureInstallReferrer({ - customRefParams: analytics.options.referral?.queryParams, - pathPattern: analytics.options.referral?.pathPattern, - }).catch((error) => { + try { + await captureInstallReferrer({ + customRefParams: analytics.options.referral?.queryParams, + pathPattern: analytics.options.referral?.pathPattern, + }); + } catch (error) { logger.debug("FormoAnalytics: install referrer capture failed", error); - }); + } } // Initialize lifecycle tracking if enabled diff --git a/src/__tests__/installReferrer.test.ts b/src/__tests__/installReferrer.test.ts new file mode 100644 index 0000000..91e5a2c --- /dev/null +++ b/src/__tests__/installReferrer.test.ts @@ -0,0 +1,90 @@ +/** + * SDK init awaits captureInstallReferrer so the referrer is available when the + * Application Installed event fires. That makes a hung native call dangerous: + * if the Play Store service connection stalls and the callback never arrives, + * an unbounded await would block FormoAnalytics.init() forever, leaving every + * consumer on the no-op context with tracking silently dead. + * + * These tests pin the timeout that prevents that. + */ + +jest.mock("react-native", () => ({ + Platform: { OS: "android" }, +})); + +const mockStorageInstance = { + get: jest.fn().mockReturnValue(null), + set: jest.fn(), + setAsync: jest.fn().mockResolvedValue(undefined), + remove: jest.fn(), + isAvailable: jest.fn().mockReturnValue(true), +}; + +const mockStorageManager = { + hasPersistentStorage: jest.fn().mockReturnValue(true), +}; + +jest.mock("../lib/storage", () => ({ + __esModule: true, + storage: jest.fn(() => mockStorageInstance), + getStorageManager: jest.fn(() => mockStorageManager), +})); + +jest.mock("../lib/logger", () => ({ + __esModule: true, + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + }, +})); + +// Simulate a stalled Play Store service: the callback is never invoked. +jest.mock( + "react-native-play-install-referrer", + () => ({ + PlayInstallReferrer: { + getInstallReferrerInfo: () => { + /* intentionally never calls back */ + }, + }, + }), + { virtual: true } +); + +import { captureInstallReferrer } from "../lib/installReferrer"; + +describe("captureInstallReferrer — hung native call", () => { + beforeEach(() => { + jest.useFakeTimers(); + mockStorageInstance.get.mockReturnValue(null); + mockStorageInstance.setAsync.mockClear(); + mockStorageManager.hasPersistentStorage.mockReturnValue(true); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("resolves (does not hang init) when the Play callback never fires", async () => { + const promise = captureInstallReferrer(); + // Advance past the bound; if the timeout were missing this would never settle + // and the test would time out. + jest.advanceTimersByTime(1501); + await expect(promise).resolves.toBeUndefined(); + }); + + it("does not mark attribution resolved on timeout, so it retries next launch", async () => { + const promise = captureInstallReferrer(); + jest.advanceTimersByTime(1501); + await promise; + + // The one-shot flag must NOT be persisted — a timeout is transient. + expect(mockStorageInstance.setAsync).not.toHaveBeenCalledWith( + "install_referrer_resolved", + "true" + ); + }); +}); diff --git a/src/__tests__/lifecycle.test.ts b/src/__tests__/lifecycle.test.ts index 3393780..6bee821 100644 --- a/src/__tests__/lifecycle.test.ts +++ b/src/__tests__/lifecycle.test.ts @@ -74,6 +74,32 @@ describe('AppLifecycleManager', () => { }); }); + it('should attach captured web-to-mobile attribution to Application Installed', async () => { + // Simulate a stored traffic source (e.g. from the Android Play Install + // Referrer) present when the install event fires. Empty fields must be + // filtered out; referrer/utm_source must appear on the event. + mockStorageInstance.get.mockImplementation((key: string) => + key === 'traffic_source' + ? JSON.stringify({ + referrer: 'example.com', + utm_source: 'example.com', + utm_campaign: 'spring', + utm_medium: '', + }) + : null + ); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Installed', { + version: '1.0.0', + build: '1', + referrer: 'example.com', + utm_source: 'example.com', + utm_campaign: 'spring', + }); + }); + it('should fire Application Opened after install', async () => { mockStorageInstance.get.mockReturnValue(null); diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 5505ad0..358bf64 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -9,8 +9,8 @@ export const LOCAL_APP_BUILD_KEY = "app_build"; // app restart, but expires after SESSION_TIMEOUT_MS of inactivity (see EventFactory). export const LOCAL_SESSION_ID_KEY = "session_id"; export const LOCAL_SESSION_LAST_ACTIVITY_KEY = "session_last_activity"; -// One-shot flag: set once the Install Referrer (Android) or AdServices (iOS) -// attribution has been fetched, so we never call the native API again. +// One-shot flag: set once the Android Play Install Referrer attribution has +// been fetched, so we never call the native API again. export const LOCAL_INSTALL_REFERRER_RESOLVED_KEY = "install_referrer_resolved"; // Session storage keys (cleared on app restart) diff --git a/src/lib/installReferrer/index.ts b/src/lib/installReferrer/index.ts index fd27c32..23ebc79 100644 --- a/src/lib/installReferrer/index.ts +++ b/src/lib/installReferrer/index.ts @@ -1,28 +1,31 @@ /** - * Install Referrer / attribution capture + * Install Referrer / attribution capture (Android) * * Populates the existing traffic source fields (utm_source, utm_medium, - * utm_campaign, utm_term, utm_content, ref, referrer) from platform install - * attribution APIs on first launch: + * utm_campaign, utm_term, utm_content, ref, referrer) from the Google Play + * Install Referrer on first launch. This is what enables web-to-mobile + * attribution: a user who tapped a link on example.com before installing shows + * up with that referrer on their first events (see P-2207). * * - Android: Google Play Install Referrer API via react-native-play-install-referrer * (optional peer dep). Returns a URL-encoded query string like - * "utm_source=google&utm_campaign=spring_sale&..." which we parse with + * "utm_source=example.com&utm_campaign=spring_sale&..." which we parse with * parseTrafficSource. * - * - iOS: AdServices attribution via react-native-ad-services-attribution - * (optional peer dep). Returns an attribution token which we exchange with - * Apple's AdServices endpoint; the response is mapped onto utm_* fields. + * - iOS: NOT supported. Apple exposes no install-referrer API, so an install + * cannot be attributed to a referring website from the SDK. Doing so requires + * a third-party attribution service (Branch/AppsFlyer) or fingerprint + * matching — out of scope. On iOS this capture is a no-op. * - * Both native modules are lazy-required and the capture silently no-ops when - * they are not installed (keeps Expo Go and minimal integrations working). + * The native module is lazy-required and capture silently no-ops when it is not + * installed (keeps Expo Go and minimal integrations working). * * Result is merged with mergeTrafficSourceFill so a deep link that arrived * via Linking.getInitialURL() takes precedence over install-referrer data. * * The resolution is one-shot: on success we set LOCAL_INSTALL_REFERRER_RESOLVED_KEY * so we never call the native API again (Play returns meaningful data only on - * the first fetch; Apple only within ~24h of install). + * the first fetch). */ import { Platform } from "react-native"; @@ -35,7 +38,7 @@ import { } from "../../utils/trafficSource"; import type { ITrafficSource } from "../../types"; -// Lazy-load optional native modules. Absence is fine — attribution is best-effort. +// Lazy-load the optional native module. Absence is fine — attribution is best-effort. let PlayInstallReferrer: { getInstallReferrerInfo: ( cb: (info: { installReferrer?: string } | null, error?: unknown) => void @@ -49,16 +52,21 @@ try { // Not installed — Android install referrer capture will no-op. } -let AdServicesAttribution: { - getAttributionToken: () => Promise; -} | null = null; - -try { - const mod = require("react-native-ad-services-attribution"); - AdServicesAttribution = mod.default ?? mod; -} catch { - // Not installed — iOS AdServices capture will no-op. -} +/** + * Upper bound on the Play Install Referrer native call. + * + * SDK init awaits this capture so the referrer is available for the + * Application Installed event, which means it must never block init + * indefinitely (a stalled Play Store service connection can leave the callback + * pending forever). Until init resolves the provider serves its no-op context, + * so any delay here is a window where startup events are dropped. + * + * That cost is paid on the FIRST launch only: the capture is one-shot, and + * every later launch short-circuits on LOCAL_INSTALL_REFERRER_RESOLVED_KEY + * before reaching the native call. The bind is normally sub-second, so this is + * kept tight rather than generous — on timeout we skip and retry next launch. + */ +const INSTALL_REFERRER_TIMEOUT_MS = 1500; export interface CaptureOptions { customRefParams?: string[]; @@ -96,11 +104,12 @@ export async function captureInstallReferrer( if (Platform.OS === "android") { didResolve = await captureAndroidReferrer(options); - } else if (Platform.OS === "ios") { - didResolve = await captureIOSAttribution(); } else { + // iOS (and any non-Android platform): no OS-level install referrer exists. + // Attributing an install to a referring website requires a third-party + // attribution SDK (Branch/AppsFlyer) or fingerprint matching. No-op. logger.debug( - `InstallReferrer: unsupported platform ${Platform.OS}, skipping` + `InstallReferrer: no install-referrer source on ${Platform.OS}, skipping` ); return; } @@ -122,8 +131,13 @@ async function captureAndroidReferrer( options: CaptureOptions ): Promise { if (!PlayInstallReferrer) { - logger.debug( - "InstallReferrer: react-native-play-install-referrer not installed, skipping Android capture" + // Warn (not debug) on Android: attribution silently does nothing here, and + // marking the peer optional suppresses the missing-peer install warning, so + // this is the only actionable signal the integrator gets. + logger.warn( + "InstallReferrer: react-native-play-install-referrer is not installed — " + + "Android install attribution is disabled. Install it and rebuild the " + + "native app to enable web-to-mobile attribution." ); return false; } @@ -131,22 +145,46 @@ async function captureAndroidReferrer( // Distinguish "native API errored" (retry next launch) from "native API // succeeded but no referrer data" (organic install — mark resolved so we // don't re-call every launch). + // + // The native callback is bounded by a timeout: a stalled Play Store service + // connection would otherwise leave this promise pending forever, and since + // init() awaits this, that would hang SDK initialization and leave every + // consumer on the no-op context. On timeout we resolve as "errored" so the + // capture is retried on the next launch. const result = await new Promise<{ ok: boolean; info: { installReferrer?: string } | null; }>((resolve) => { + let settled = false; + const finish = (value: { + ok: boolean; + info: { installReferrer?: string } | null; + }) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + + const timer = setTimeout(() => { + logger.debug( + `InstallReferrer: Play API did not respond within ${INSTALL_REFERRER_TIMEOUT_MS}ms, continuing` + ); + finish({ ok: false, info: null }); + }, INSTALL_REFERRER_TIMEOUT_MS); + try { PlayInstallReferrer!.getInstallReferrerInfo((info, error) => { if (error) { logger.debug("InstallReferrer: Play API error", error); - resolve({ ok: false, info: null }); + finish({ ok: false, info: null }); return; } - resolve({ ok: true, info: info ?? null }); + finish({ ok: true, info: info ?? null }); }); } catch (e) { logger.debug("InstallReferrer: Play API threw", e); - resolve({ ok: false, info: null }); + finish({ ok: false, info: null }); } }); @@ -185,78 +223,3 @@ async function captureAndroidReferrer( logger.info("InstallReferrer: captured Android install referrer"); return true; } - -/** - * iOS: fetch AdServices attribution token and exchange it with Apple for - * campaign metadata. Map campaignId/adGroupId/keywordId onto utm_* fields. - * Falls back to no-op if the native module isn't present. - */ -async function captureIOSAttribution(): Promise { - if (!AdServicesAttribution) { - logger.debug( - "InstallReferrer: react-native-ad-services-attribution not installed, skipping iOS capture" - ); - return false; - } - - let token: string | null; - try { - token = await AdServicesAttribution.getAttributionToken(); - } catch (e) { - logger.debug("InstallReferrer: failed to get AdServices token", e); - return false; - } - if (!token) return false; - - let data: Record | null = null; - // Guard against poor-network hangs — this runs fire-and-forget during init. - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - try { - const response = await fetch("https://api-adservices.apple.com/api/v1/", { - method: "POST", - headers: { "Content-Type": "text/plain" }, - body: token, - signal: controller.signal, - }); - if (!response.ok) { - // 4xx are permanent (400 = bad/consumed token, 404 = past Apple's ~24h - // attribution window). Mark resolved so we stop retrying. 5xx and - // other transient failures return false so we re-attempt next launch. - const permanent = response.status >= 400 && response.status < 500; - logger.debug( - `InstallReferrer: AdServices returned ${response.status}, ${ - permanent ? "permanent — marking resolved" : "transient — will retry" - }` - ); - return permanent; - } - data = (await response.json()) as Record; - } catch (e) { - // Network / abort / parse errors — treat as transient. - logger.debug("InstallReferrer: AdServices exchange failed", e); - return false; - } finally { - clearTimeout(timeoutId); - } - - if (!data || data.attribution === false) { - // Organic install - return true; - } - - const toStr = (v: unknown): string | undefined => - v === undefined || v === null ? undefined : String(v); - - const attributed: Partial = { - utm_source: "apple_search_ads", - utm_medium: "cpc", - ...(toStr(data.campaignId) && { utm_campaign: toStr(data.campaignId)! }), - ...(toStr(data.adGroupId) && { utm_content: toStr(data.adGroupId)! }), - ...(toStr(data.keywordId) && { utm_term: toStr(data.keywordId)! }), - }; - - mergeTrafficSourceFill(attributed); - logger.info("InstallReferrer: captured iOS AdServices attribution"); - return true; -} diff --git a/src/lib/lifecycle/index.ts b/src/lib/lifecycle/index.ts index 2a80a69..8fb037f 100644 --- a/src/lib/lifecycle/index.ts +++ b/src/lib/lifecycle/index.ts @@ -17,6 +17,7 @@ import { LOCAL_APP_VERSION_KEY, LOCAL_APP_BUILD_KEY, } from "../../constants/storage"; +import { getStoredTrafficSource } from "../../utils/trafficSource"; /** Interface for the analytics instance to avoid circular deps */ interface IAnalyticsInstance { @@ -141,11 +142,19 @@ export class AppLifecycleManager { const { version, build } = this.appVersionInfo; if (previousVersion === null && previousBuild === null) { - // No stored version — first install + // No stored version — first install. Attach the captured web-to-mobile + // attribution (Android Play Install Referrer / deep link) so the install + // event reports where the user came from, e.g. referrer=example.com. + // Only non-empty fields are included to keep properties clean. + const trafficSource = getStoredTrafficSource() ?? {}; + const attribution = Object.fromEntries( + Object.entries(trafficSource).filter(([, value]) => Boolean(value)) + ); logger.info("AppLifecycleManager: Application Installed"); await this.analytics.track("Application Installed", { version, build, + ...attribution, }); } else if (previousVersion !== version || previousBuild !== build) { // Version or build changed — update diff --git a/src/types/base.ts b/src/types/base.ts index dcea78c..225b094 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -178,9 +178,10 @@ export interface AttributionOptions { deeplinks?: boolean; /** - * Capture install-time attribution from the platform on first launch: - * - Android: Google Play Install Referrer API (requires react-native-play-install-referrer) - * - iOS: AdServices attribution token (requires react-native-ad-services-attribution) + * Capture install-time attribution on first launch: + * - Android: Google Play Install Referrer API (requires react-native-play-install-referrer). + * Enables web-to-mobile attribution, e.g. referrer=example.com. + * - iOS: not supported — Apple exposes no install-referrer API, so this is a no-op. * * Resolved once on first successful fetch and cached; subsequent launches * skip the native call. Silently no-ops when the optional native module diff --git a/src/utils/trafficSource.ts b/src/utils/trafficSource.ts index dbe03f9..9eb6536 100644 --- a/src/utils/trafficSource.ts +++ b/src/utils/trafficSource.ts @@ -197,7 +197,7 @@ export function updateStoredTrafficSource( /** * Merge a partial traffic source into the stored one, only filling in fields * that are currently empty. Used by the Install Referrer flow so campaign data - * from the Play Store / Apple AdServices cannot clobber a deep-link attribution + * from the Play Store install referrer cannot clobber a deep-link attribution * that arrived earlier in the same cold start. */ export function mergeTrafficSourceFill(