Skip to content

Commit 5222b1c

Browse files
yosriadyclaude
andauthored
P-1925 P-1926 Enhance analytics API with optional parameters and referral config (#2)
* Fix API signature inconsistencies and code quality issues for web SDK parity - Make signature() chainId optional to match web SDK (chain-agnostic signatures) - Add function_name and function_args to transaction() for contract call tracking - Add optional category parameter to screen() matching web page() API - Consolidate FormoAnalyticsProviderProps into single complete type - Replace require() with static import in setTrafficSourceFromUrl - Add errorHandler option to Options for global error handling - Add ReferralOptions for configurable referral query parameter parsing - Update tests for new optional chainId behavior https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Add package-lock.json for dependency version locking https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Fix screen() test to pass properties as third arg after category param The screen() signature changed to screen(name, category?, properties?, ...), so the test needs to pass undefined for category before the properties object. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Address review feedback: backward-compat screen(), implement pathPattern, dedup refParams - Revert screen() to original (name, properties?, context?, callback?) signature to avoid breaking existing callers. Category is passed via properties.category. - Implement pathPattern support in parseTrafficSource so ReferralOptions.pathPattern actually extracts referral codes from URL paths via regex capture group. - Deduplicate referral query params using Set to avoid redundant lookups. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Guard errorHandler callback with try/catch to prevent consumer errors from bubbling A throwing errorHandler would cause trackEvent to reject, turning telemetry failures into app-level failures. Now secondary handler errors are logged instead of propagated. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Add category as positional parameter to screen() for web SDK parity Since this is v1.0.0 with no existing consumers, use the clean signature screen(name, category?, properties?, context?, callback?) instead of the properties.category workaround. Matches web SDK's page(category?, name?). https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Add application lifecycle event tracking (installed, updated, opened, backgrounded) Implements the Segment/RudderStack mobile lifecycle spec using a JS-side approach (no native modules required, works in Expo Go): - Application Installed: first launch (no stored version in AsyncStorage) - Application Updated: version or build changed from stored values - Application Opened: every cold start (from_background: false) and foreground return (from_background: true), includes deep link URL - Application Backgrounded: app goes to background Detection compares stored app version/build in AsyncStorage against current values from react-native-device-info, expo-application, or options.app config. Controlled via autocapture.lifecycle option (default: true). Adds AppLifecycleManager with its own AppState listener, separate from EventQueue's flush-on-background listener. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Guard lifecycle install/update detection against non-persistent storage When AsyncStorage is not provided, the SDK falls back to MemoryStorage which is empty on every cold start. Without this guard, detectInstallOrUpdate would false-positive as "Application Installed" on every launch. Now checks if AsyncStorage adapter is actually available before comparing stored version/build. Skips install/update events (with warning) if only MemoryStorage is available. Application Opened/Backgrounded still fire. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Fix persistent storage check that was defeated by MemoryStorage fallback StorageManager.getStorage("asyncStorage") silently falls back to MemoryStorage when AsyncStorage hasn't been initialized, and MemoryStorage.isAvailable() always returns true. This made the lifecycle guard ineffective — install events would still fire on every cold start without AsyncStorage. Fix: Add StorageManager.hasPersistentStorage() that checks if an initialized AsyncStorage adapter exists in the storages map, bypassing the fallback logic in getStorage(). https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Await async storage persistence for app version to prevent duplicate install events storage().set() writes to the in-memory cache synchronously but persists to AsyncStorage asynchronously without being awaited. If the app crashes or is force-quit before the async write completes, the version data is lost and the next launch would fire a duplicate "Application Installed" event. Switch to awaiting setAsync() to ensure persistence completes. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Wrap lifecycle init in try-catch to prevent SDK init failure A transient AsyncStorage write failure in lifecycle tracking (e.g., storage full, permission error) would reject the init() promise and leave the SDK uninitialized with no-op methods for the entire session. Lifecycle tracking is non-critical — it should not block the core analytics SDK from functioning. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Fix hasPersistentStorage() to use instanceof check against fallback getStorage("asyncStorage") caches a MemoryStorage fallback at the asyncStorage slot when AsyncStorage hasn't been initialized. The previous check (stored !== undefined && stored.isAvailable()) would pass for this cached MemoryStorage since MemoryStorage.isAvailable() always returns true. Use instanceof AsyncStorageAdapter to ensure we're checking the real persistent adapter, not a MemoryStorage fallback cached at that slot. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Preserve function_args key casing from toSnakeCase conversion The toSnakeCase utility recursively converts all object keys, which corrupts function_args values that represent smart contract ABI parameter names (e.g., "tokenId" becomes "token_id"). Extract function_args before conversion and re-attach after to preserve the original key casing. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N * Apply pnpm supply chain security recommendations Upgrade pnpm from 9.0.0 to 10.27.0 and add security settings per https://pnpm.io/supply-chain-security: 1. onlyBuiltDependencies: [] — block lifecycle scripts by default, only explicitly approved packages can run postinstall scripts 2. blockExoticSubdeps: true — prevent transitive deps from using git repos or direct tarball URLs 3. minimumReleaseAge: 1440 — wait 24h before installing newly published versions to avoid the malware exposure window 4. trustPolicy: no-downgrade — prevent installation if a package's trust level has decreased from previous releases Also removes package-lock.json (npm artifact) in favor of pnpm-lock.yaml. https://claude.ai/code/session_0117WwRELH1nzVbxRj7Kmm5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8f3be5 commit 5222b1c

14 files changed

Lines changed: 701 additions & 55 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@formo/react-native-analytics",
33
"version": "1.0.0",
44
"description": "Formo Analytics SDK for React Native - Track wallet events and user analytics in mobile dApps",
5-
"packageManager": "pnpm@9.0.0",
5+
"packageManager": "pnpm@10.27.0",
66
"repository": {
77
"type": "git",
88
"url": "git+https://github.com/getformo/sdk.git",

pnpm-workspace.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Supply chain security settings
2+
# See: https://pnpm.io/supply-chain-security
3+
4+
# 1. Block lifecycle scripts by default
5+
# Only explicitly approved packages can run postinstall scripts.
6+
# Use `pnpm approve-builds` to manage this list.
7+
onlyBuiltDependencies: []
8+
9+
# 2. Block exotic subdependencies
10+
# Prevents transitive deps from using git repos or direct tarball URLs.
11+
blockExoticSubdeps: true
12+
13+
# 3. Minimum release age (in minutes)
14+
# Wait 24 hours before installing newly published versions.
15+
# Malware is usually detected quickly, so this avoids the exposure window.
16+
minimumReleaseAge: 1440
17+
18+
# 4. Trust policy
19+
# Prevent installation if a package's trust level has decreased
20+
# (e.g., was published by trusted publisher, now only has provenance).
21+
trustPolicy: no-downgrade

src/FormoAnalytics.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "./lib/consent";
2323
import { FormoAnalyticsSession } from "./lib/session";
2424
import { WagmiEventHandler } from "./lib/wagmi";
25+
import { AppLifecycleManager } from "./lib/lifecycle";
2526
import {
2627
Address,
2728
ChainID,
@@ -35,12 +36,14 @@ import {
3536
TransactionStatus,
3637
} from "./types";
3738
import { toChecksumAddress, getValidAddress } from "./utils";
39+
import { parseTrafficSource, storeTrafficSource } from "./utils/trafficSource";
3840

3941
export class FormoAnalytics implements IFormoAnalytics {
4042
private session: FormoAnalyticsSession;
4143
private eventManager: IEventManager;
4244
private eventQueue: EventQueue;
4345
private wagmiHandler?: WagmiEventHandler;
46+
private lifecycleManager?: AppLifecycleManager;
4447

4548
config: Config;
4649
currentChainId?: ChainID;
@@ -125,6 +128,17 @@ export class FormoAnalytics implements IFormoAnalytics {
125128

126129
const analytics = new FormoAnalytics(writeKey, options);
127130

131+
// Initialize lifecycle tracking if enabled
132+
// Wrapped in try-catch so a transient storage failure doesn't prevent SDK init
133+
if (analytics.isAutocaptureEnabled("lifecycle")) {
134+
try {
135+
analytics.lifecycleManager = new AppLifecycleManager(analytics);
136+
await analytics.lifecycleManager.start(options?.app);
137+
} catch (error) {
138+
logger.error("FormoAnalytics: Failed to initialize lifecycle tracking", error);
139+
}
140+
}
141+
128142
// Call ready callback
129143
if (options?.ready) {
130144
options.ready(analytics);
@@ -138,14 +152,15 @@ export class FormoAnalytics implements IFormoAnalytics {
138152
*/
139153
public async screen(
140154
name: string,
155+
category?: string,
141156
properties?: IFormoEventProperties,
142157
context?: IFormoEventContext,
143158
callback?: (...args: unknown[]) => void
144159
): Promise<void> {
145160
// Note: shouldTrack() is called in trackEvent() - no need to check here
146161
await this.trackEvent(
147162
EventType.SCREEN,
148-
{ name },
163+
{ name, ...(category && { category }) },
149164
properties,
150165
context,
151166
callback
@@ -175,8 +190,11 @@ export class FormoAnalytics implements IFormoAnalytics {
175190
* ```
176191
*/
177192
public setTrafficSourceFromUrl(url: string): void {
178-
const { parseTrafficSource, storeTrafficSource } = require("./utils/trafficSource");
179-
const trafficSource = parseTrafficSource(url);
193+
const trafficSource = parseTrafficSource(
194+
url,
195+
this.options.referral?.queryParams,
196+
this.options.referral?.pathPattern
197+
);
180198
storeTrafficSource(trafficSource);
181199
logger.debug("Traffic source set from URL:", trafficSource);
182200
}
@@ -197,6 +215,11 @@ export class FormoAnalytics implements IFormoAnalytics {
197215
public async cleanup(): Promise<void> {
198216
logger.info("FormoAnalytics: Cleaning up resources");
199217

218+
if (this.lifecycleManager) {
219+
this.lifecycleManager.cleanup();
220+
this.lifecycleManager = undefined;
221+
}
222+
200223
if (this.wagmiHandler) {
201224
this.wagmiHandler.cleanup();
202225
this.wagmiHandler = undefined;
@@ -325,7 +348,7 @@ export class FormoAnalytics implements IFormoAnalytics {
325348
signatureHash,
326349
}: {
327350
status: SignatureStatus;
328-
chainId: ChainID;
351+
chainId?: ChainID;
329352
address: Address;
330353
message: string;
331354
signatureHash?: string;
@@ -334,10 +357,6 @@ export class FormoAnalytics implements IFormoAnalytics {
334357
context?: IFormoEventContext,
335358
callback?: (...args: unknown[]) => void
336359
): Promise<void> {
337-
if (chainId === null || chainId === undefined || Number(chainId) === 0) {
338-
logger.warn("Signature: Chain ID cannot be null, undefined, or 0");
339-
return;
340-
}
341360
if (!address) {
342361
logger.warn("Signature: Address cannot be empty");
343362
return;
@@ -346,7 +365,7 @@ export class FormoAnalytics implements IFormoAnalytics {
346365
EventType.SIGNATURE,
347366
{
348367
status,
349-
chainId,
368+
...(chainId !== undefined && chainId !== null && { chainId }),
350369
address,
351370
message,
352371
...(signatureHash && { signatureHash }),
@@ -369,6 +388,8 @@ export class FormoAnalytics implements IFormoAnalytics {
369388
to,
370389
value,
371390
transactionHash,
391+
function_name,
392+
function_args,
372393
}: {
373394
status: TransactionStatus;
374395
chainId: ChainID;
@@ -377,6 +398,8 @@ export class FormoAnalytics implements IFormoAnalytics {
377398
to?: string;
378399
value?: string;
379400
transactionHash?: string;
401+
function_name?: string;
402+
function_args?: Record<string, unknown>;
380403
},
381404
properties?: IFormoEventProperties,
382405
context?: IFormoEventContext,
@@ -400,6 +423,8 @@ export class FormoAnalytics implements IFormoAnalytics {
400423
to,
401424
value,
402425
...(transactionHash && { transactionHash }),
426+
...(function_name && { function_name }),
427+
...(function_args && { function_args }),
403428
},
404429
properties,
405430
context,
@@ -544,7 +569,7 @@ export class FormoAnalytics implements IFormoAnalytics {
544569
* Check if autocapture is enabled for event type
545570
*/
546571
public isAutocaptureEnabled(
547-
eventType: "connect" | "disconnect" | "signature" | "transaction" | "chain"
572+
eventType: "connect" | "disconnect" | "signature" | "transaction" | "chain" | "lifecycle"
548573
): boolean {
549574
if (this.options.autocapture === undefined) {
550575
return true;
@@ -596,6 +621,13 @@ export class FormoAnalytics implements IFormoAnalytics {
596621
);
597622
} catch (error) {
598623
logger.error("Error tracking event:", error);
624+
if (this.options.errorHandler) {
625+
try {
626+
this.options.errorHandler(error instanceof Error ? error : new Error(String(error)));
627+
} catch (handlerError) {
628+
logger.error("Error in errorHandler callback:", handlerError);
629+
}
630+
}
599631
}
600632
}
601633

src/FormoAnalyticsProvider.tsx

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import React, {
88
FC,
99
} from "react";
1010
import { FormoAnalytics } from "./FormoAnalytics";
11-
import { initStorageManager, AsyncStorageInterface } from "./lib/storage";
11+
import { initStorageManager } from "./lib/storage";
1212
import { logger } from "./lib/logger";
1313
import { FormoAnalyticsProviderProps, IFormoAnalytics } from "./types";
1414

@@ -34,25 +34,6 @@ const defaultContext: IFormoAnalytics = {
3434
export const FormoAnalyticsContext =
3535
createContext<IFormoAnalytics>(defaultContext);
3636

37-
export interface FormoAnalyticsProviderPropsWithStorage
38-
extends FormoAnalyticsProviderProps {
39-
/**
40-
* AsyncStorage instance from @react-native-async-storage/async-storage
41-
* Required for persistent storage
42-
*/
43-
asyncStorage?: AsyncStorageInterface;
44-
/**
45-
* Callback when SDK is ready
46-
* Note: Use useCallback to avoid re-initialization on every render
47-
*/
48-
onReady?: (sdk: IFormoAnalytics) => void;
49-
/**
50-
* Callback when SDK initialization fails
51-
* Note: Use useCallback to avoid re-initialization on every render
52-
*/
53-
onError?: (error: Error) => void;
54-
}
55-
5637
/**
5738
* Formo Analytics Provider for React Native
5839
*
@@ -76,7 +57,7 @@ export interface FormoAnalyticsProviderPropsWithStorage
7657
* }
7758
* ```
7859
*/
79-
export const FormoAnalyticsProvider: FC<FormoAnalyticsProviderPropsWithStorage> = (
60+
export const FormoAnalyticsProvider: FC<FormoAnalyticsProviderProps> = (
8061
props
8162
) => {
8263
const { writeKey, disabled = false, children } = props;
@@ -102,7 +83,7 @@ export const FormoAnalyticsProvider: FC<FormoAnalyticsProviderPropsWithStorage>
10283
return <InitializedAnalytics {...props} />;
10384
};
10485

105-
const InitializedAnalytics: FC<FormoAnalyticsProviderPropsWithStorage> = ({
86+
const InitializedAnalytics: FC<FormoAnalyticsProviderProps> = ({
10687
writeKey,
10788
options,
10889
asyncStorage,

src/__tests__/FormoAnalytics.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ jest.mock('../lib/consent', () => ({
5858
removeConsentFlag: jest.fn(),
5959
}));
6060

61+
const mockLifecycleManager = {
62+
start: jest.fn(),
63+
cleanup: jest.fn(),
64+
};
65+
66+
jest.mock('../lib/lifecycle', () => ({
67+
__esModule: true,
68+
AppLifecycleManager: jest.fn(),
69+
}));
70+
6171
jest.mock('../lib/logger', () => ({
6272
__esModule: true,
6373
logger: {
@@ -78,6 +88,7 @@ import { initStorageManager, storage } from '../lib/storage';
7888
import { EventManager, EventQueue } from '../lib/event';
7989
import { FormoAnalyticsSession } from '../lib/session';
8090
import { setConsentFlag, getConsentFlag, removeConsentFlag } from '../lib/consent';
91+
import { AppLifecycleManager } from '../lib/lifecycle';
8192

8293
// Helper to setup all mock implementations
8394
const setupMocks = () => {
@@ -114,6 +125,11 @@ const setupMocks = () => {
114125

115126
// Consent mocks
116127
(getConsentFlag as jest.Mock).mockReturnValue(null);
128+
129+
// Lifecycle mocks
130+
mockLifecycleManager.start.mockResolvedValue(undefined);
131+
mockLifecycleManager.cleanup.mockReturnValue(undefined);
132+
(AppLifecycleManager as jest.Mock).mockImplementation(() => mockLifecycleManager);
117133
};
118134

119135
describe('FormoAnalytics', () => {
@@ -260,15 +276,25 @@ describe('FormoAnalytics', () => {
260276
});
261277

262278
describe('signature()', () => {
263-
it('should not track if chainId is invalid', async () => {
279+
it('should track signature with chainId 0 (chainId is optional)', async () => {
264280
await analytics.signature({
265281
status: SignatureStatus.REQUESTED,
266282
chainId: 0,
267283
address: '0x742d35cc6634c0532925a3b844bc9e7595f3f6d2',
268284
message: 'test message',
269285
});
270286

271-
expect(mockEventManager.addEvent).not.toHaveBeenCalled();
287+
expect(mockEventManager.addEvent).toHaveBeenCalled();
288+
});
289+
290+
it('should track signature without chainId', async () => {
291+
await analytics.signature({
292+
status: SignatureStatus.REQUESTED,
293+
address: '0x742d35cc6634c0532925a3b844bc9e7595f3f6d2',
294+
message: 'test message',
295+
});
296+
297+
expect(mockEventManager.addEvent).toHaveBeenCalled();
272298
});
273299

274300
it('should not track if address is empty', async () => {
@@ -339,7 +365,7 @@ describe('FormoAnalytics', () => {
339365

340366
describe('screen()', () => {
341367
it('should track screen views', async () => {
342-
await analytics.screen('HomeScreen', { section: 'featured' });
368+
await analytics.screen('HomeScreen', undefined, { section: 'featured' });
343369

344370
expect(mockEventManager.addEvent).toHaveBeenCalled();
345371
});

0 commit comments

Comments
 (0)