@formo/analytics-react-native 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/FormoAnalytics.js +91 -16
- package/lib/commonjs/FormoAnalytics.js.map +1 -1
- package/lib/commonjs/constants/storage.js +4 -1
- package/lib/commonjs/constants/storage.js.map +1 -1
- package/lib/commonjs/lib/event/EventFactory.js +6 -13
- package/lib/commonjs/lib/event/EventFactory.js.map +1 -1
- package/lib/commonjs/lib/installReferrer/index.js +227 -0
- package/lib/commonjs/lib/installReferrer/index.js.map +1 -0
- package/lib/commonjs/lib/wagmi/WagmiEventHandler.js +1 -6
- package/lib/commonjs/lib/wagmi/WagmiEventHandler.js.map +1 -1
- package/lib/commonjs/solana/address.js +110 -0
- package/lib/commonjs/solana/address.js.map +1 -0
- package/lib/commonjs/solana/index.js +28 -0
- package/lib/commonjs/solana/index.js.map +1 -0
- package/lib/commonjs/solana/types.js +47 -0
- package/lib/commonjs/solana/types.js.map +1 -0
- package/lib/commonjs/types/events.js.map +1 -1
- package/lib/commonjs/utils/address.js +60 -6
- package/lib/commonjs/utils/address.js.map +1 -1
- package/lib/commonjs/utils/trafficSource.js +61 -0
- package/lib/commonjs/utils/trafficSource.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/FormoAnalytics.js +93 -18
- package/lib/module/FormoAnalytics.js.map +1 -1
- package/lib/module/constants/storage.js +3 -0
- package/lib/module/constants/storage.js.map +1 -1
- package/lib/module/lib/event/EventFactory.js +7 -14
- package/lib/module/lib/event/EventFactory.js.map +1 -1
- package/lib/module/lib/installReferrer/index.js +221 -0
- package/lib/module/lib/installReferrer/index.js.map +1 -0
- package/lib/module/lib/wagmi/WagmiEventHandler.js +1 -6
- package/lib/module/lib/wagmi/WagmiEventHandler.js.map +1 -1
- package/lib/module/solana/address.js +100 -0
- package/lib/module/solana/address.js.map +1 -0
- package/lib/module/solana/index.js +3 -0
- package/lib/module/solana/index.js.map +1 -0
- package/lib/module/solana/types.js +40 -0
- package/lib/module/solana/types.js.map +1 -0
- package/lib/module/types/events.js.map +1 -1
- package/lib/module/utils/address.js +58 -6
- package/lib/module/utils/address.js.map +1 -1
- package/lib/module/utils/trafficSource.js +59 -0
- package/lib/module/utils/trafficSource.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/FormoAnalytics.d.ts +23 -4
- package/lib/typescript/FormoAnalytics.d.ts.map +1 -1
- package/lib/typescript/constants/storage.d.ts +1 -0
- package/lib/typescript/constants/storage.d.ts.map +1 -1
- package/lib/typescript/lib/event/EventFactory.d.ts +1 -1
- package/lib/typescript/lib/event/EventFactory.d.ts.map +1 -1
- package/lib/typescript/lib/event/types.d.ts +1 -1
- package/lib/typescript/lib/event/types.d.ts.map +1 -1
- package/lib/typescript/lib/installReferrer/index.d.ts +36 -0
- package/lib/typescript/lib/installReferrer/index.d.ts.map +1 -0
- package/lib/typescript/lib/wagmi/WagmiEventHandler.d.ts +0 -1
- package/lib/typescript/lib/wagmi/WagmiEventHandler.d.ts.map +1 -1
- package/lib/typescript/solana/address.d.ts +42 -0
- package/lib/typescript/solana/address.d.ts.map +1 -0
- package/lib/typescript/solana/index.d.ts +3 -0
- package/lib/typescript/solana/index.d.ts.map +1 -0
- package/lib/typescript/solana/types.d.ts +34 -0
- package/lib/typescript/solana/types.d.ts.map +1 -0
- package/lib/typescript/types/base.d.ts +38 -1
- package/lib/typescript/types/base.d.ts.map +1 -1
- package/lib/typescript/types/events.d.ts +0 -1
- package/lib/typescript/types/events.d.ts.map +1 -1
- package/lib/typescript/utils/address.d.ts +24 -4
- package/lib/typescript/utils/address.d.ts.map +1 -1
- package/lib/typescript/utils/trafficSource.d.ts +16 -0
- package/lib/typescript/utils/trafficSource.d.ts.map +1 -1
- package/lib/typescript/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/FormoAnalytics.ts +112 -16
- package/src/constants/storage.ts +3 -0
- package/src/lib/event/EventFactory.ts +7 -15
- package/src/lib/event/types.ts +0 -1
- package/src/lib/installReferrer/index.ts +262 -0
- package/src/lib/wagmi/WagmiEventHandler.ts +0 -4
- package/src/solana/address.ts +122 -0
- package/src/solana/index.ts +2 -0
- package/src/solana/types.ts +46 -0
- package/src/types/base.ts +40 -1
- package/src/types/events.ts +0 -1
- package/src/utils/address.ts +72 -6
- package/src/utils/trafficSource.ts +73 -0
- package/src/version.ts +1 -1
package/src/FormoAnalytics.ts
CHANGED
|
@@ -35,8 +35,10 @@ import {
|
|
|
35
35
|
TrackingOptions,
|
|
36
36
|
TransactionStatus,
|
|
37
37
|
} from "./types";
|
|
38
|
-
import {
|
|
39
|
-
import { parseTrafficSource,
|
|
38
|
+
import { validateAddress } from "./utils";
|
|
39
|
+
import { parseTrafficSource, updateStoredTrafficSource } from "./utils/trafficSource";
|
|
40
|
+
import { captureInstallReferrer } from "./lib/installReferrer";
|
|
41
|
+
import { Linking, EmitterSubscription } from "react-native";
|
|
40
42
|
|
|
41
43
|
export class FormoAnalytics implements IFormoAnalytics {
|
|
42
44
|
private session: FormoAnalyticsSession;
|
|
@@ -44,6 +46,7 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
44
46
|
private eventQueue: EventQueue;
|
|
45
47
|
private wagmiHandler?: WagmiEventHandler;
|
|
46
48
|
private lifecycleManager?: AppLifecycleManager;
|
|
49
|
+
private linkingSubscription?: EmitterSubscription;
|
|
47
50
|
|
|
48
51
|
config: Config;
|
|
49
52
|
currentChainId?: ChainID;
|
|
@@ -128,6 +131,32 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
128
131
|
|
|
129
132
|
const analytics = new FormoAnalytics(writeKey, options);
|
|
130
133
|
|
|
134
|
+
// Capture attribution BEFORE lifecycle tracking so the first
|
|
135
|
+
// Application Installed/Opened events carry utm_*/ref/referrer context.
|
|
136
|
+
//
|
|
137
|
+
// Deep-link initial URL is awaited because Linking.getInitialURL() is a
|
|
138
|
+
// fast native bridge call and we need its result before lifecycle events
|
|
139
|
+
// fire. The url-event subscription is set up synchronously for runtime
|
|
140
|
+
// deep links. Install-referrer capture (Play / AdServices) is fire-and-
|
|
141
|
+
// forget since it involves potentially-slow network I/O on iOS and is
|
|
142
|
+
// best-effort; it'll still populate attribution for subsequent events.
|
|
143
|
+
if (analytics.isAttributionEnabled("deeplinks")) {
|
|
144
|
+
try {
|
|
145
|
+
await analytics.startDeepLinkCapture();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.error("FormoAnalytics: Failed to initialize deep link capture", error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (analytics.isAttributionEnabled("installReferrer")) {
|
|
152
|
+
captureInstallReferrer({
|
|
153
|
+
customRefParams: analytics.options.referral?.queryParams,
|
|
154
|
+
pathPattern: analytics.options.referral?.pathPattern,
|
|
155
|
+
}).catch((error) => {
|
|
156
|
+
logger.debug("FormoAnalytics: install referrer capture failed", error);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
131
160
|
// Initialize lifecycle tracking if enabled
|
|
132
161
|
// Wrapped in try-catch so a transient storage failure doesn't prevent SDK init
|
|
133
162
|
if (analytics.isAutocaptureEnabled("lifecycle")) {
|
|
@@ -147,6 +176,25 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
147
176
|
return analytics;
|
|
148
177
|
}
|
|
149
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Hook into React Native's Linking API to auto-capture traffic source from
|
|
181
|
+
* the launch URL and any subsequent deep-link opens. Awaits the initial URL
|
|
182
|
+
* so attribution is in storage before the first lifecycle event fires.
|
|
183
|
+
*/
|
|
184
|
+
private async startDeepLinkCapture(): Promise<void> {
|
|
185
|
+
try {
|
|
186
|
+
const url = await Linking.getInitialURL();
|
|
187
|
+
if (url) this.setTrafficSourceFromUrl(url);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
logger.debug("FormoAnalytics: Linking.getInitialURL failed", error);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Runtime deep links (foreground opens, universal links).
|
|
193
|
+
this.linkingSubscription = Linking.addEventListener("url", (event) => {
|
|
194
|
+
if (event?.url) this.setTrafficSourceFromUrl(event.url);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
/**
|
|
151
199
|
* Track a screen view (mobile equivalent of page view)
|
|
152
200
|
*/
|
|
@@ -195,7 +243,10 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
195
243
|
this.options.referral?.queryParams,
|
|
196
244
|
this.options.referral?.pathPattern
|
|
197
245
|
);
|
|
198
|
-
|
|
246
|
+
// Per-field merge: incoming non-empty fields win, empty fields preserve
|
|
247
|
+
// stored values. A non-marketing deep link (e.g. "myapp://home") with only
|
|
248
|
+
// a referrer will not destroy previously captured utm_*/ref attribution.
|
|
249
|
+
updateStoredTrafficSource(trafficSource);
|
|
199
250
|
logger.debug("Traffic source set from URL:", trafficSource);
|
|
200
251
|
}
|
|
201
252
|
|
|
@@ -220,6 +271,11 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
220
271
|
this.lifecycleManager = undefined;
|
|
221
272
|
}
|
|
222
273
|
|
|
274
|
+
if (this.linkingSubscription) {
|
|
275
|
+
this.linkingSubscription.remove();
|
|
276
|
+
this.linkingSubscription = undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
223
279
|
if (this.wagmiHandler) {
|
|
224
280
|
this.wagmiHandler.cleanup();
|
|
225
281
|
this.wagmiHandler = undefined;
|
|
@@ -250,8 +306,8 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
250
306
|
return;
|
|
251
307
|
}
|
|
252
308
|
|
|
253
|
-
const
|
|
254
|
-
if (!
|
|
309
|
+
const validatedAddress = this.validateAndChecksumAddress(address, chainId);
|
|
310
|
+
if (!validatedAddress) {
|
|
255
311
|
logger.warn(`Connect: Invalid address provided ("${address}")`);
|
|
256
312
|
return;
|
|
257
313
|
}
|
|
@@ -259,14 +315,14 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
259
315
|
// Track event before updating state so connect events TO excluded chains are tracked
|
|
260
316
|
await this.trackEvent(
|
|
261
317
|
EventType.CONNECT,
|
|
262
|
-
{ chainId, address:
|
|
318
|
+
{ chainId, address: validatedAddress },
|
|
263
319
|
properties,
|
|
264
320
|
context,
|
|
265
321
|
callback
|
|
266
322
|
);
|
|
267
323
|
|
|
268
324
|
this.currentChainId = chainId;
|
|
269
|
-
this.currentAddress =
|
|
325
|
+
this.currentAddress = validatedAddress;
|
|
270
326
|
}
|
|
271
327
|
|
|
272
328
|
/**
|
|
@@ -345,13 +401,11 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
345
401
|
chainId,
|
|
346
402
|
address,
|
|
347
403
|
message,
|
|
348
|
-
signatureHash,
|
|
349
404
|
}: {
|
|
350
405
|
status: SignatureStatus;
|
|
351
406
|
chainId?: ChainID;
|
|
352
407
|
address: Address;
|
|
353
408
|
message: string;
|
|
354
|
-
signatureHash?: string;
|
|
355
409
|
},
|
|
356
410
|
properties?: IFormoEventProperties,
|
|
357
411
|
context?: IFormoEventContext,
|
|
@@ -368,7 +422,6 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
368
422
|
...(chainId !== undefined && chainId !== null && { chainId }),
|
|
369
423
|
address,
|
|
370
424
|
message,
|
|
371
|
-
...(signatureHash && { signatureHash }),
|
|
372
425
|
},
|
|
373
426
|
properties,
|
|
374
427
|
context,
|
|
@@ -458,6 +511,8 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
458
511
|
return;
|
|
459
512
|
}
|
|
460
513
|
this.currentAddress = validAddress;
|
|
514
|
+
// Note: validateAddress returns Solana addresses unchanged (Base58, case-sensitive)
|
|
515
|
+
// and EVM addresses checksummed.
|
|
461
516
|
} else {
|
|
462
517
|
this.currentAddress = undefined;
|
|
463
518
|
}
|
|
@@ -566,10 +621,19 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
566
621
|
}
|
|
567
622
|
|
|
568
623
|
/**
|
|
569
|
-
* Check if autocapture is enabled for event type
|
|
624
|
+
* Check if autocapture is enabled for a given event type.
|
|
625
|
+
* Applies only to event-generating behaviors (wallet events, lifecycle
|
|
626
|
+
* events). Attribution is controlled separately via `options.attribution`
|
|
627
|
+
* because it enriches events rather than generating them.
|
|
570
628
|
*/
|
|
571
629
|
public isAutocaptureEnabled(
|
|
572
|
-
eventType:
|
|
630
|
+
eventType:
|
|
631
|
+
| "connect"
|
|
632
|
+
| "disconnect"
|
|
633
|
+
| "signature"
|
|
634
|
+
| "transaction"
|
|
635
|
+
| "chain"
|
|
636
|
+
| "lifecycle"
|
|
573
637
|
): boolean {
|
|
574
638
|
if (this.options.autocapture === undefined) {
|
|
575
639
|
return true;
|
|
@@ -590,6 +654,32 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
590
654
|
return true;
|
|
591
655
|
}
|
|
592
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Check if an attribution source is enabled. Attribution is not an event
|
|
659
|
+
* type — it decorates every tracked event with `utm_*`, `ref`, and
|
|
660
|
+
* `referrer` context fields.
|
|
661
|
+
*/
|
|
662
|
+
public isAttributionEnabled(
|
|
663
|
+
source: "deeplinks" | "installReferrer"
|
|
664
|
+
): boolean {
|
|
665
|
+
if (this.options.attribution === undefined) {
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (typeof this.options.attribution === "boolean") {
|
|
670
|
+
return this.options.attribution;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (
|
|
674
|
+
this.options.attribution !== null &&
|
|
675
|
+
typeof this.options.attribution === "object"
|
|
676
|
+
) {
|
|
677
|
+
return this.options.attribution[source] !== false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
|
|
593
683
|
/**
|
|
594
684
|
* Internal method to track events
|
|
595
685
|
* This is the single enforcement point for shouldTrack() - all public tracking
|
|
@@ -669,11 +759,17 @@ export class FormoAnalytics implements IFormoAnalytics {
|
|
|
669
759
|
}
|
|
670
760
|
|
|
671
761
|
/**
|
|
672
|
-
* Validate and
|
|
762
|
+
* Validate and normalize an address for the given chain.
|
|
763
|
+
*
|
|
764
|
+
* EVM addresses are returned in EIP-55 checksum format.
|
|
765
|
+
* Solana addresses are returned as-is (Base58 is case-sensitive).
|
|
766
|
+
* When chainId is omitted, EVM is tried first with Solana as fallback.
|
|
673
767
|
*/
|
|
674
|
-
private validateAndChecksumAddress(
|
|
675
|
-
|
|
676
|
-
|
|
768
|
+
private validateAndChecksumAddress(
|
|
769
|
+
address: string,
|
|
770
|
+
chainId?: ChainID
|
|
771
|
+
): Address | undefined {
|
|
772
|
+
return validateAddress(address, chainId);
|
|
677
773
|
}
|
|
678
774
|
|
|
679
775
|
/**
|
package/src/constants/storage.ts
CHANGED
|
@@ -5,6 +5,9 @@ export const STORAGE_PREFIX = "formo_rn_";
|
|
|
5
5
|
export const LOCAL_ANONYMOUS_ID_KEY = "anonymous_id";
|
|
6
6
|
export const LOCAL_APP_VERSION_KEY = "app_version";
|
|
7
7
|
export const LOCAL_APP_BUILD_KEY = "app_build";
|
|
8
|
+
// One-shot flag: set once the Install Referrer (Android) or AdServices (iOS)
|
|
9
|
+
// attribution has been fetched, so we never call the native API again.
|
|
10
|
+
export const LOCAL_INSTALL_REFERRER_RESOLVED_KEY = "install_referrer_resolved";
|
|
8
11
|
|
|
9
12
|
// Session storage keys (cleared on app restart)
|
|
10
13
|
export const SESSION_USER_ID_KEY = "user_id";
|
|
@@ -39,8 +39,7 @@ import {
|
|
|
39
39
|
TransactionStatus,
|
|
40
40
|
} from "../../types";
|
|
41
41
|
import {
|
|
42
|
-
|
|
43
|
-
getValidAddress,
|
|
42
|
+
validateAddress,
|
|
44
43
|
toSnakeCase,
|
|
45
44
|
mergeDeepRight,
|
|
46
45
|
getStoredTrafficSource,
|
|
@@ -337,12 +336,11 @@ class EventFactory implements IEventFactory {
|
|
|
337
336
|
commonEventData.anonymous_id = generateAnonymousId(LOCAL_ANONYMOUS_ID_KEY);
|
|
338
337
|
|
|
339
338
|
// Handle address - convert undefined to null for consistency
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
339
|
+
// Try EVM first, then Solana fallback (chainId is not always present here).
|
|
340
|
+
const validAddress = formoEvent.address
|
|
341
|
+
? validateAddress(formoEvent.address)
|
|
342
|
+
: undefined;
|
|
343
|
+
commonEventData.address = validAddress ?? null;
|
|
346
344
|
|
|
347
345
|
const processedEvent = mergeDeepRight(
|
|
348
346
|
formoEvent as Record<string, unknown>,
|
|
@@ -501,7 +499,6 @@ class EventFactory implements IEventFactory {
|
|
|
501
499
|
chainId: ChainID | undefined,
|
|
502
500
|
address: Address,
|
|
503
501
|
message: string,
|
|
504
|
-
signatureHash?: string,
|
|
505
502
|
properties?: IFormoEventProperties,
|
|
506
503
|
context?: IFormoEventContext
|
|
507
504
|
): Promise<IFormoEvent> {
|
|
@@ -510,7 +507,6 @@ class EventFactory implements IEventFactory {
|
|
|
510
507
|
status,
|
|
511
508
|
...(chainId !== undefined && chainId !== null && { chainId }),
|
|
512
509
|
message,
|
|
513
|
-
...(signatureHash && { signatureHash }),
|
|
514
510
|
...properties,
|
|
515
511
|
},
|
|
516
512
|
address,
|
|
@@ -648,7 +644,6 @@ class EventFactory implements IEventFactory {
|
|
|
648
644
|
event.chainId,
|
|
649
645
|
event.address,
|
|
650
646
|
event.message,
|
|
651
|
-
event.signatureHash,
|
|
652
647
|
event.properties,
|
|
653
648
|
event.context
|
|
654
649
|
);
|
|
@@ -680,10 +675,7 @@ class EventFactory implements IEventFactory {
|
|
|
680
675
|
|
|
681
676
|
// Set address if not already set by the specific event generator
|
|
682
677
|
if (formoEvent.address === undefined || formoEvent.address === null) {
|
|
683
|
-
|
|
684
|
-
formoEvent.address = validAddress
|
|
685
|
-
? toChecksumAddress(validAddress)
|
|
686
|
-
: null;
|
|
678
|
+
formoEvent.address = address ? validateAddress(address) ?? null : null;
|
|
687
679
|
}
|
|
688
680
|
formoEvent.user_id = userId || null;
|
|
689
681
|
|
package/src/lib/event/types.ts
CHANGED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install Referrer / attribution capture
|
|
3
|
+
*
|
|
4
|
+
* Populates the existing traffic source fields (utm_source, utm_medium,
|
|
5
|
+
* utm_campaign, utm_term, utm_content, ref, referrer) from platform install
|
|
6
|
+
* attribution APIs on first launch:
|
|
7
|
+
*
|
|
8
|
+
* - Android: Google Play Install Referrer API via react-native-play-install-referrer
|
|
9
|
+
* (optional peer dep). Returns a URL-encoded query string like
|
|
10
|
+
* "utm_source=google&utm_campaign=spring_sale&..." which we parse with
|
|
11
|
+
* parseTrafficSource.
|
|
12
|
+
*
|
|
13
|
+
* - iOS: AdServices attribution via react-native-ad-services-attribution
|
|
14
|
+
* (optional peer dep). Returns an attribution token which we exchange with
|
|
15
|
+
* Apple's AdServices endpoint; the response is mapped onto utm_* fields.
|
|
16
|
+
*
|
|
17
|
+
* Both native modules are lazy-required and the capture silently no-ops when
|
|
18
|
+
* they are not installed (keeps Expo Go and minimal integrations working).
|
|
19
|
+
*
|
|
20
|
+
* Result is merged with mergeTrafficSourceFill so a deep link that arrived
|
|
21
|
+
* via Linking.getInitialURL() takes precedence over install-referrer data.
|
|
22
|
+
*
|
|
23
|
+
* The resolution is one-shot: on success we set LOCAL_INSTALL_REFERRER_RESOLVED_KEY
|
|
24
|
+
* so we never call the native API again (Play returns meaningful data only on
|
|
25
|
+
* the first fetch; Apple only within ~24h of install).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Platform } from "react-native";
|
|
29
|
+
import { logger } from "../logger";
|
|
30
|
+
import { storage, getStorageManager } from "../storage";
|
|
31
|
+
import { LOCAL_INSTALL_REFERRER_RESOLVED_KEY } from "../../constants/storage";
|
|
32
|
+
import {
|
|
33
|
+
parseTrafficSource,
|
|
34
|
+
mergeTrafficSourceFill,
|
|
35
|
+
} from "../../utils/trafficSource";
|
|
36
|
+
import type { ITrafficSource } from "../../types";
|
|
37
|
+
|
|
38
|
+
// Lazy-load optional native modules. Absence is fine — attribution is best-effort.
|
|
39
|
+
let PlayInstallReferrer: {
|
|
40
|
+
getInstallReferrerInfo: (
|
|
41
|
+
cb: (info: { installReferrer?: string } | null, error?: unknown) => void
|
|
42
|
+
) => void;
|
|
43
|
+
} | null = null;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
PlayInstallReferrer = require("react-native-play-install-referrer")
|
|
47
|
+
.PlayInstallReferrer;
|
|
48
|
+
} catch {
|
|
49
|
+
// Not installed — Android install referrer capture will no-op.
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let AdServicesAttribution: {
|
|
53
|
+
getAttributionToken: () => Promise<string | null>;
|
|
54
|
+
} | null = null;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const mod = require("react-native-ad-services-attribution");
|
|
58
|
+
AdServicesAttribution = mod.default ?? mod;
|
|
59
|
+
} catch {
|
|
60
|
+
// Not installed — iOS AdServices capture will no-op.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CaptureOptions {
|
|
64
|
+
customRefParams?: string[];
|
|
65
|
+
pathPattern?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Capture install-time attribution and merge into the stored traffic source.
|
|
70
|
+
* One-shot: returns immediately if already resolved on a previous launch.
|
|
71
|
+
*/
|
|
72
|
+
export async function captureInstallReferrer(
|
|
73
|
+
options: CaptureOptions = {}
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
// The one-shot flag is only useful if it can persist across launches.
|
|
77
|
+
// Without AsyncStorage (MemoryStorage fallback) the flag is lost every
|
|
78
|
+
// restart, so we'd re-hit the native API every cold start. Mirror the
|
|
79
|
+
// lifecycle manager's guard and skip capture entirely in that case.
|
|
80
|
+
const hasPersistentStorage =
|
|
81
|
+
getStorageManager()?.hasPersistentStorage() ?? false;
|
|
82
|
+
if (!hasPersistentStorage) {
|
|
83
|
+
logger.debug(
|
|
84
|
+
"InstallReferrer: persistent storage unavailable, skipping capture"
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resolved = storage().get(LOCAL_INSTALL_REFERRER_RESOLVED_KEY);
|
|
90
|
+
if (resolved === "true") {
|
|
91
|
+
logger.debug("InstallReferrer: already resolved, skipping");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let didResolve = false;
|
|
96
|
+
|
|
97
|
+
if (Platform.OS === "android") {
|
|
98
|
+
didResolve = await captureAndroidReferrer(options);
|
|
99
|
+
} else if (Platform.OS === "ios") {
|
|
100
|
+
didResolve = await captureIOSAttribution();
|
|
101
|
+
} else {
|
|
102
|
+
logger.debug(
|
|
103
|
+
`InstallReferrer: unsupported platform ${Platform.OS}, skipping`
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (didResolve) {
|
|
109
|
+
await storage().setAsync(LOCAL_INSTALL_REFERRER_RESOLVED_KEY, "true");
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Never let attribution failures break SDK init.
|
|
113
|
+
logger.debug("InstallReferrer: capture failed", error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Android: call Play Install Referrer API once, parse the returned UTM query
|
|
119
|
+
* string, fill in any empty traffic-source fields.
|
|
120
|
+
*/
|
|
121
|
+
async function captureAndroidReferrer(
|
|
122
|
+
options: CaptureOptions
|
|
123
|
+
): Promise<boolean> {
|
|
124
|
+
if (!PlayInstallReferrer) {
|
|
125
|
+
logger.debug(
|
|
126
|
+
"InstallReferrer: react-native-play-install-referrer not installed, skipping Android capture"
|
|
127
|
+
);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Distinguish "native API errored" (retry next launch) from "native API
|
|
132
|
+
// succeeded but no referrer data" (organic install — mark resolved so we
|
|
133
|
+
// don't re-call every launch).
|
|
134
|
+
const result = await new Promise<{
|
|
135
|
+
ok: boolean;
|
|
136
|
+
info: { installReferrer?: string } | null;
|
|
137
|
+
}>((resolve) => {
|
|
138
|
+
try {
|
|
139
|
+
PlayInstallReferrer!.getInstallReferrerInfo((info, error) => {
|
|
140
|
+
if (error) {
|
|
141
|
+
logger.debug("InstallReferrer: Play API error", error);
|
|
142
|
+
resolve({ ok: false, info: null });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
resolve({ ok: true, info: info ?? null });
|
|
146
|
+
});
|
|
147
|
+
} catch (e) {
|
|
148
|
+
logger.debug("InstallReferrer: Play API threw", e);
|
|
149
|
+
resolve({ ok: false, info: null });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!result.ok) return false; // errored — retry next launch
|
|
154
|
+
|
|
155
|
+
const referrerQuery = result.info?.installReferrer;
|
|
156
|
+
if (!referrerQuery) {
|
|
157
|
+
// Organic install (or untracked). API answered definitively — mark
|
|
158
|
+
// resolved so we don't ask Play again on every launch.
|
|
159
|
+
logger.debug("InstallReferrer: no Play referrer (organic install)");
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The referrer string is already URL-encoded UTM params, e.g.
|
|
164
|
+
// "utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_term=kw&utm_content=ad1"
|
|
165
|
+
// Wrap in a dummy URL so parseTrafficSource can read it.
|
|
166
|
+
const parsed = parseTrafficSource(
|
|
167
|
+
`https://play.google.com/store/apps?${referrerQuery}`,
|
|
168
|
+
options.customRefParams,
|
|
169
|
+
options.pathPattern
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Don't let the dummy play.google.com URL overwrite a real referrer — clear
|
|
173
|
+
// referrer if it's the synthetic one and no deep link was present.
|
|
174
|
+
const toMerge: Partial<ITrafficSource> = { ...parsed };
|
|
175
|
+
if (
|
|
176
|
+
toMerge.referrer &&
|
|
177
|
+
toMerge.referrer.startsWith("https://play.google.com/store/apps?")
|
|
178
|
+
) {
|
|
179
|
+
// Preserve the raw referrer query as-is, useful for debugging campaigns
|
|
180
|
+
// that use non-UTM keys.
|
|
181
|
+
toMerge.referrer = referrerQuery;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
mergeTrafficSourceFill(toMerge);
|
|
185
|
+
logger.info("InstallReferrer: captured Android install referrer");
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* iOS: fetch AdServices attribution token and exchange it with Apple for
|
|
191
|
+
* campaign metadata. Map campaignId/adGroupId/keywordId onto utm_* fields.
|
|
192
|
+
* Falls back to no-op if the native module isn't present.
|
|
193
|
+
*/
|
|
194
|
+
async function captureIOSAttribution(): Promise<boolean> {
|
|
195
|
+
if (!AdServicesAttribution) {
|
|
196
|
+
logger.debug(
|
|
197
|
+
"InstallReferrer: react-native-ad-services-attribution not installed, skipping iOS capture"
|
|
198
|
+
);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let token: string | null;
|
|
203
|
+
try {
|
|
204
|
+
token = await AdServicesAttribution.getAttributionToken();
|
|
205
|
+
} catch (e) {
|
|
206
|
+
logger.debug("InstallReferrer: failed to get AdServices token", e);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (!token) return false;
|
|
210
|
+
|
|
211
|
+
let data: Record<string, unknown> | null = null;
|
|
212
|
+
// Guard against poor-network hangs — this runs fire-and-forget during init.
|
|
213
|
+
const controller = new AbortController();
|
|
214
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
215
|
+
try {
|
|
216
|
+
const response = await fetch("https://api-adservices.apple.com/api/v1/", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "Content-Type": "text/plain" },
|
|
219
|
+
body: token,
|
|
220
|
+
signal: controller.signal,
|
|
221
|
+
});
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
// 4xx are permanent (400 = bad/consumed token, 404 = past Apple's ~24h
|
|
224
|
+
// attribution window). Mark resolved so we stop retrying. 5xx and
|
|
225
|
+
// other transient failures return false so we re-attempt next launch.
|
|
226
|
+
const permanent = response.status >= 400 && response.status < 500;
|
|
227
|
+
logger.debug(
|
|
228
|
+
`InstallReferrer: AdServices returned ${response.status}, ${
|
|
229
|
+
permanent ? "permanent — marking resolved" : "transient — will retry"
|
|
230
|
+
}`
|
|
231
|
+
);
|
|
232
|
+
return permanent;
|
|
233
|
+
}
|
|
234
|
+
data = (await response.json()) as Record<string, unknown>;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
// Network / abort / parse errors — treat as transient.
|
|
237
|
+
logger.debug("InstallReferrer: AdServices exchange failed", e);
|
|
238
|
+
return false;
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(timeoutId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!data || data.attribution === false) {
|
|
244
|
+
// Organic install
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const toStr = (v: unknown): string | undefined =>
|
|
249
|
+
v === undefined || v === null ? undefined : String(v);
|
|
250
|
+
|
|
251
|
+
const attributed: Partial<ITrafficSource> = {
|
|
252
|
+
utm_source: "apple_search_ads",
|
|
253
|
+
utm_medium: "cpc",
|
|
254
|
+
...(toStr(data.campaignId) && { utm_campaign: toStr(data.campaignId)! }),
|
|
255
|
+
...(toStr(data.adGroupId) && { utm_content: toStr(data.adGroupId)! }),
|
|
256
|
+
...(toStr(data.keywordId) && { utm_term: toStr(data.keywordId)! }),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
mergeTrafficSourceFill(attributed);
|
|
260
|
+
logger.info("InstallReferrer: captured iOS AdServices attribution");
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
@@ -33,7 +33,6 @@ interface IFormoAnalyticsInstance {
|
|
|
33
33
|
chainId: number;
|
|
34
34
|
address: string;
|
|
35
35
|
message: string;
|
|
36
|
-
signatureHash?: string;
|
|
37
36
|
}): Promise<void>;
|
|
38
37
|
transaction(params: {
|
|
39
38
|
status: TransactionStatus;
|
|
@@ -362,13 +361,11 @@ export class WagmiEventHandler {
|
|
|
362
361
|
|
|
363
362
|
try {
|
|
364
363
|
let status: SignatureStatus;
|
|
365
|
-
let signatureHash: string | undefined;
|
|
366
364
|
|
|
367
365
|
if (state.status === "pending") {
|
|
368
366
|
status = SignatureStatus.REQUESTED;
|
|
369
367
|
} else if (state.status === "success") {
|
|
370
368
|
status = SignatureStatus.CONFIRMED;
|
|
371
|
-
signatureHash = state.data as string;
|
|
372
369
|
} else if (state.status === "error") {
|
|
373
370
|
status = SignatureStatus.REJECTED;
|
|
374
371
|
} else {
|
|
@@ -394,7 +391,6 @@ export class WagmiEventHandler {
|
|
|
394
391
|
chainId,
|
|
395
392
|
address,
|
|
396
393
|
message,
|
|
397
|
-
...(signatureHash && { signatureHash }),
|
|
398
394
|
}).catch((error) => {
|
|
399
395
|
logger.error("WagmiEventHandler: Error tracking signature:", error);
|
|
400
396
|
});
|