Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<your.package>&referrer=utm_source%3Dexample.com%26utm_campaign%3Dspring
```

<sub>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).</sub>

## Quick Start

### 1. Wrap your app with the provider
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -86,6 +87,9 @@
"react-native-device-info": {
"optional": true
},
"react-native-play-install-referrer": {
"optional": true
Comment on lines +90 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the native referrer module non-optional

When an Android consumer does not install this peer, Metro will fail while bundling: src/lib/installReferrer/index.ts contains a statically analyzable require("react-native-play-install-referrer"), and Metro resolves such dependencies before the try/catch can run. Marking the peer optional therefore makes the documented no-module path a build-time Unable to resolve module failure rather than a runtime warning/no-op. Make it required, or replace the static dependency with an integration approach that Metro can safely omit.

Useful? React with 👍 / 👎.

},
Comment on lines +90 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document installation of the Android referrer module

For Android consumers following the README installation command, this optional peer is not installed, so the lazy require in captureAndroidReferrer resolves to null and the new attribution path silently skips. Because marking a peer optional also suppresses the missing-peer warning, users receive neither the P-2207 behavior nor an actionable indication of what to add; document react-native-play-install-referrer as an Android setup dependency (including native rebuild instructions).

Useful? React with 👍 / 👎.

"wagmi": {
"optional": true
}
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 14 additions & 12 deletions src/FormoAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
});
Comment on lines +155 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a timeout to the install-referrer await

When the Play Install Referrer native callback never arrives (for example, a stalled Play Store/service connection), captureAndroidReferrer leaves its promise pending indefinitely. This new await makes FormoAnalytics.init hang in that case; consequently the provider never sets a real SDK instance and all tracking remains on the no-op context. Keep the attribution fetch bounded and continue initialization after the timeout, as the previous fire-and-forget path did.

Useful? React with 👍 / 👎.

Comment on lines 153 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid blocking provider initialization on the referrer timeout

When the Play Store service stalls and never invokes its callback, this awaits the full three-second timeout before the SDK is available. FormoAnalyticsProvider keeps supplying its no-op context until FormoAnalytics.init() resolves, so screens or startup interactions during that interval silently drop their analytics events—the same failure mode the timeout comment identifies, just bounded to three seconds. Capture the referrer without holding up the usable SDK (or otherwise preserve events while it is pending).

Useful? React with 👍 / 👎.

} catch (error) {
logger.debug("FormoAnalytics: install referrer capture failed", error);
});
}
}

// Initialize lifecycle tracking if enabled
Expand Down
90 changes: 90 additions & 0 deletions src/__tests__/installReferrer.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
26 changes: 26 additions & 0 deletions src/__tests__/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions src/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading