diff --git a/package.json b/package.json index 8d12be5..604595e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@formo/react-native-analytics", "version": "1.0.0", "description": "Formo Analytics SDK for React Native - Track wallet events and user analytics in mobile dApps", - "packageManager": "pnpm@9.0.0", + "packageManager": "pnpm@10.27.0", "repository": { "type": "git", "url": "git+https://github.com/getformo/sdk.git", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..8a7de47 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,21 @@ +# Supply chain security settings +# See: https://pnpm.io/supply-chain-security + +# 1. Block lifecycle scripts by default +# Only explicitly approved packages can run postinstall scripts. +# Use `pnpm approve-builds` to manage this list. +onlyBuiltDependencies: [] + +# 2. Block exotic subdependencies +# Prevents transitive deps from using git repos or direct tarball URLs. +blockExoticSubdeps: true + +# 3. Minimum release age (in minutes) +# Wait 24 hours before installing newly published versions. +# Malware is usually detected quickly, so this avoids the exposure window. +minimumReleaseAge: 1440 + +# 4. Trust policy +# Prevent installation if a package's trust level has decreased +# (e.g., was published by trusted publisher, now only has provenance). +trustPolicy: no-downgrade diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 2b89649..9cb88eb 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -22,6 +22,7 @@ import { } from "./lib/consent"; import { FormoAnalyticsSession } from "./lib/session"; import { WagmiEventHandler } from "./lib/wagmi"; +import { AppLifecycleManager } from "./lib/lifecycle"; import { Address, ChainID, @@ -35,12 +36,14 @@ import { TransactionStatus, } from "./types"; import { toChecksumAddress, getValidAddress } from "./utils"; +import { parseTrafficSource, storeTrafficSource } from "./utils/trafficSource"; export class FormoAnalytics implements IFormoAnalytics { private session: FormoAnalyticsSession; private eventManager: IEventManager; private eventQueue: EventQueue; private wagmiHandler?: WagmiEventHandler; + private lifecycleManager?: AppLifecycleManager; config: Config; currentChainId?: ChainID; @@ -125,6 +128,17 @@ export class FormoAnalytics implements IFormoAnalytics { const analytics = new FormoAnalytics(writeKey, options); + // Initialize lifecycle tracking if enabled + // Wrapped in try-catch so a transient storage failure doesn't prevent SDK init + if (analytics.isAutocaptureEnabled("lifecycle")) { + try { + analytics.lifecycleManager = new AppLifecycleManager(analytics); + await analytics.lifecycleManager.start(options?.app); + } catch (error) { + logger.error("FormoAnalytics: Failed to initialize lifecycle tracking", error); + } + } + // Call ready callback if (options?.ready) { options.ready(analytics); @@ -138,6 +152,7 @@ export class FormoAnalytics implements IFormoAnalytics { */ public async screen( name: string, + category?: string, properties?: IFormoEventProperties, context?: IFormoEventContext, callback?: (...args: unknown[]) => void @@ -145,7 +160,7 @@ export class FormoAnalytics implements IFormoAnalytics { // Note: shouldTrack() is called in trackEvent() - no need to check here await this.trackEvent( EventType.SCREEN, - { name }, + { name, ...(category && { category }) }, properties, context, callback @@ -175,8 +190,11 @@ export class FormoAnalytics implements IFormoAnalytics { * ``` */ public setTrafficSourceFromUrl(url: string): void { - const { parseTrafficSource, storeTrafficSource } = require("./utils/trafficSource"); - const trafficSource = parseTrafficSource(url); + const trafficSource = parseTrafficSource( + url, + this.options.referral?.queryParams, + this.options.referral?.pathPattern + ); storeTrafficSource(trafficSource); logger.debug("Traffic source set from URL:", trafficSource); } @@ -197,6 +215,11 @@ export class FormoAnalytics implements IFormoAnalytics { public async cleanup(): Promise { logger.info("FormoAnalytics: Cleaning up resources"); + if (this.lifecycleManager) { + this.lifecycleManager.cleanup(); + this.lifecycleManager = undefined; + } + if (this.wagmiHandler) { this.wagmiHandler.cleanup(); this.wagmiHandler = undefined; @@ -325,7 +348,7 @@ export class FormoAnalytics implements IFormoAnalytics { signatureHash, }: { status: SignatureStatus; - chainId: ChainID; + chainId?: ChainID; address: Address; message: string; signatureHash?: string; @@ -334,10 +357,6 @@ export class FormoAnalytics implements IFormoAnalytics { context?: IFormoEventContext, callback?: (...args: unknown[]) => void ): Promise { - if (chainId === null || chainId === undefined || Number(chainId) === 0) { - logger.warn("Signature: Chain ID cannot be null, undefined, or 0"); - return; - } if (!address) { logger.warn("Signature: Address cannot be empty"); return; @@ -346,7 +365,7 @@ export class FormoAnalytics implements IFormoAnalytics { EventType.SIGNATURE, { status, - chainId, + ...(chainId !== undefined && chainId !== null && { chainId }), address, message, ...(signatureHash && { signatureHash }), @@ -369,6 +388,8 @@ export class FormoAnalytics implements IFormoAnalytics { to, value, transactionHash, + function_name, + function_args, }: { status: TransactionStatus; chainId: ChainID; @@ -377,6 +398,8 @@ export class FormoAnalytics implements IFormoAnalytics { to?: string; value?: string; transactionHash?: string; + function_name?: string; + function_args?: Record; }, properties?: IFormoEventProperties, context?: IFormoEventContext, @@ -400,6 +423,8 @@ export class FormoAnalytics implements IFormoAnalytics { to, value, ...(transactionHash && { transactionHash }), + ...(function_name && { function_name }), + ...(function_args && { function_args }), }, properties, context, @@ -544,7 +569,7 @@ export class FormoAnalytics implements IFormoAnalytics { * Check if autocapture is enabled for event type */ public isAutocaptureEnabled( - eventType: "connect" | "disconnect" | "signature" | "transaction" | "chain" + eventType: "connect" | "disconnect" | "signature" | "transaction" | "chain" | "lifecycle" ): boolean { if (this.options.autocapture === undefined) { return true; @@ -596,6 +621,13 @@ export class FormoAnalytics implements IFormoAnalytics { ); } catch (error) { logger.error("Error tracking event:", error); + if (this.options.errorHandler) { + try { + this.options.errorHandler(error instanceof Error ? error : new Error(String(error))); + } catch (handlerError) { + logger.error("Error in errorHandler callback:", handlerError); + } + } } } diff --git a/src/FormoAnalyticsProvider.tsx b/src/FormoAnalyticsProvider.tsx index 93aa443..16e3697 100644 --- a/src/FormoAnalyticsProvider.tsx +++ b/src/FormoAnalyticsProvider.tsx @@ -8,7 +8,7 @@ import React, { FC, } from "react"; import { FormoAnalytics } from "./FormoAnalytics"; -import { initStorageManager, AsyncStorageInterface } from "./lib/storage"; +import { initStorageManager } from "./lib/storage"; import { logger } from "./lib/logger"; import { FormoAnalyticsProviderProps, IFormoAnalytics } from "./types"; @@ -34,25 +34,6 @@ const defaultContext: IFormoAnalytics = { export const FormoAnalyticsContext = createContext(defaultContext); -export interface FormoAnalyticsProviderPropsWithStorage - extends FormoAnalyticsProviderProps { - /** - * AsyncStorage instance from @react-native-async-storage/async-storage - * Required for persistent storage - */ - asyncStorage?: AsyncStorageInterface; - /** - * Callback when SDK is ready - * Note: Use useCallback to avoid re-initialization on every render - */ - onReady?: (sdk: IFormoAnalytics) => void; - /** - * Callback when SDK initialization fails - * Note: Use useCallback to avoid re-initialization on every render - */ - onError?: (error: Error) => void; -} - /** * Formo Analytics Provider for React Native * @@ -76,7 +57,7 @@ export interface FormoAnalyticsProviderPropsWithStorage * } * ``` */ -export const FormoAnalyticsProvider: FC = ( +export const FormoAnalyticsProvider: FC = ( props ) => { const { writeKey, disabled = false, children } = props; @@ -102,7 +83,7 @@ export const FormoAnalyticsProvider: FC return ; }; -const InitializedAnalytics: FC = ({ +const InitializedAnalytics: FC = ({ writeKey, options, asyncStorage, diff --git a/src/__tests__/FormoAnalytics.test.ts b/src/__tests__/FormoAnalytics.test.ts index 41f4090..e75e06e 100644 --- a/src/__tests__/FormoAnalytics.test.ts +++ b/src/__tests__/FormoAnalytics.test.ts @@ -58,6 +58,16 @@ jest.mock('../lib/consent', () => ({ removeConsentFlag: jest.fn(), })); +const mockLifecycleManager = { + start: jest.fn(), + cleanup: jest.fn(), +}; + +jest.mock('../lib/lifecycle', () => ({ + __esModule: true, + AppLifecycleManager: jest.fn(), +})); + jest.mock('../lib/logger', () => ({ __esModule: true, logger: { @@ -78,6 +88,7 @@ import { initStorageManager, storage } from '../lib/storage'; import { EventManager, EventQueue } from '../lib/event'; import { FormoAnalyticsSession } from '../lib/session'; import { setConsentFlag, getConsentFlag, removeConsentFlag } from '../lib/consent'; +import { AppLifecycleManager } from '../lib/lifecycle'; // Helper to setup all mock implementations const setupMocks = () => { @@ -114,6 +125,11 @@ const setupMocks = () => { // Consent mocks (getConsentFlag as jest.Mock).mockReturnValue(null); + + // Lifecycle mocks + mockLifecycleManager.start.mockResolvedValue(undefined); + mockLifecycleManager.cleanup.mockReturnValue(undefined); + (AppLifecycleManager as jest.Mock).mockImplementation(() => mockLifecycleManager); }; describe('FormoAnalytics', () => { @@ -260,7 +276,7 @@ describe('FormoAnalytics', () => { }); describe('signature()', () => { - it('should not track if chainId is invalid', async () => { + it('should track signature with chainId 0 (chainId is optional)', async () => { await analytics.signature({ status: SignatureStatus.REQUESTED, chainId: 0, @@ -268,7 +284,17 @@ describe('FormoAnalytics', () => { message: 'test message', }); - expect(mockEventManager.addEvent).not.toHaveBeenCalled(); + expect(mockEventManager.addEvent).toHaveBeenCalled(); + }); + + it('should track signature without chainId', async () => { + await analytics.signature({ + status: SignatureStatus.REQUESTED, + address: '0x742d35cc6634c0532925a3b844bc9e7595f3f6d2', + message: 'test message', + }); + + expect(mockEventManager.addEvent).toHaveBeenCalled(); }); it('should not track if address is empty', async () => { @@ -339,7 +365,7 @@ describe('FormoAnalytics', () => { describe('screen()', () => { it('should track screen views', async () => { - await analytics.screen('HomeScreen', { section: 'featured' }); + await analytics.screen('HomeScreen', undefined, { section: 'featured' }); expect(mockEventManager.addEvent).toHaveBeenCalled(); }); diff --git a/src/__tests__/lifecycle.test.ts b/src/__tests__/lifecycle.test.ts new file mode 100644 index 0000000..3393780 --- /dev/null +++ b/src/__tests__/lifecycle.test.ts @@ -0,0 +1,267 @@ +import { AppState } from 'react-native'; + +// Mock react-native +jest.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: jest.fn().mockReturnValue({ remove: jest.fn() }), + }, + Linking: { + getInitialURL: jest.fn().mockResolvedValue(null), + }, +})); + +// Mock storage +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(), + }, +})); + +import { AppLifecycleManager } from '../lib/lifecycle'; +import { storage, getStorageManager } from '../lib/storage'; +import { Linking } from 'react-native'; + +describe('AppLifecycleManager', () => { + let manager: AppLifecycleManager; + let mockAnalytics: { track: jest.Mock }; + + beforeEach(() => { + mockAnalytics = { track: jest.fn().mockResolvedValue(undefined) }; + mockStorageInstance.get.mockReturnValue(null); + mockStorageInstance.set.mockReturnValue(undefined); + mockStorageInstance.setAsync.mockResolvedValue(undefined); + mockStorageManager.hasPersistentStorage.mockReturnValue(true); + (storage as jest.Mock).mockReturnValue(mockStorageInstance); + (getStorageManager as jest.Mock).mockReturnValue(mockStorageManager); + (AppState.addEventListener as jest.Mock).mockReturnValue({ remove: jest.fn() }); + (Linking.getInitialURL as jest.Mock).mockResolvedValue(null); + manager = new AppLifecycleManager(mockAnalytics); + }); + + describe('first install', () => { + it('should fire Application Installed on first launch', async () => { + mockStorageInstance.get.mockReturnValue(null); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Installed', { + version: '1.0.0', + build: '1', + }); + }); + + it('should fire Application Opened after install', async () => { + mockStorageInstance.get.mockReturnValue(null); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Opened', { + version: '1.0.0', + build: '1', + from_background: false, + }); + }); + + it('should persist version and build after install', async () => { + mockStorageInstance.get.mockReturnValue(null); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockStorageInstance.setAsync).toHaveBeenCalledWith('app_version', '1.0.0'); + expect(mockStorageInstance.setAsync).toHaveBeenCalledWith('app_build', '1'); + }); + }); + + describe('app update', () => { + it('should fire Application Updated when version changes', async () => { + // Simulate stored version differs from current + mockStorageInstance.get.mockImplementation((key: string) => { + if (key === 'app_version') return '1.0.0'; + if (key === 'app_build') return '1'; + return null; + }); + + await manager.start({ version: '2.0.0', build: '2' }); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Updated', { + version: '2.0.0', + build: '2', + previous_version: '1.0.0', + previous_build: '1', + }); + }); + + it('should fire Application Updated when only build changes', async () => { + mockStorageInstance.get.mockImplementation((key: string) => { + if (key === 'app_version') return '1.0.0'; + if (key === 'app_build') return '1'; + return null; + }); + + await manager.start({ version: '1.0.0', build: '2' }); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Updated', { + version: '1.0.0', + build: '2', + previous_version: '1.0.0', + previous_build: '1', + }); + }); + }); + + describe('regular open', () => { + it('should not fire Installed or Updated when version matches', async () => { + mockStorageInstance.get.mockImplementation((key: string) => { + if (key === 'app_version') return '1.0.0'; + if (key === 'app_build') return '1'; + return null; + }); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockAnalytics.track).not.toHaveBeenCalledWith( + 'Application Installed', + expect.anything() + ); + expect(mockAnalytics.track).not.toHaveBeenCalledWith( + 'Application Updated', + expect.anything() + ); + }); + + it('should still fire Application Opened on regular launch', async () => { + mockStorageInstance.get.mockImplementation((key: string) => { + if (key === 'app_version') return '1.0.0'; + if (key === 'app_build') return '1'; + return null; + }); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Opened', { + version: '1.0.0', + build: '1', + from_background: false, + }); + }); + }); + + describe('AppState transitions', () => { + it('should subscribe to AppState changes', async () => { + await manager.start({ version: '1.0.0', build: '1' }); + + expect(AppState.addEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function) + ); + }); + + it('should fire Application Backgrounded on background', async () => { + await manager.start({ version: '1.0.0', build: '1' }); + + // Get the AppState callback + const callback = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + + // Simulate going to background + callback('background'); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Backgrounded', { + version: '1.0.0', + build: '1', + }); + }); + + it('should fire Application Opened with from_background on foreground return', async () => { + await manager.start({ version: '1.0.0', build: '1' }); + + const callback = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + + // Simulate background then foreground + callback('background'); + mockAnalytics.track.mockClear(); + callback('active'); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Opened', { + version: '1.0.0', + build: '1', + from_background: true, + }); + }); + + it('should ignore inactive state', async () => { + await manager.start({ version: '1.0.0', build: '1' }); + + const callback = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + mockAnalytics.track.mockClear(); + + callback('inactive'); + + expect(mockAnalytics.track).not.toHaveBeenCalled(); + }); + }); + + describe('no persistent storage', () => { + it('should skip install/update detection when AsyncStorage is not available', async () => { + mockStorageManager.hasPersistentStorage.mockReturnValue(false); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockAnalytics.track).not.toHaveBeenCalledWith( + 'Application Installed', + expect.anything() + ); + expect(mockAnalytics.track).not.toHaveBeenCalledWith( + 'Application Updated', + expect.anything() + ); + }); + + it('should still fire Application Opened without persistent storage', async () => { + mockStorageManager.hasPersistentStorage.mockReturnValue(false); + + await manager.start({ version: '1.0.0', build: '1' }); + + expect(mockAnalytics.track).toHaveBeenCalledWith('Application Opened', { + version: '1.0.0', + build: '1', + from_background: false, + }); + }); + }); + + describe('cleanup', () => { + it('should remove AppState listener on cleanup', async () => { + const mockRemove = jest.fn(); + (AppState.addEventListener as jest.Mock).mockReturnValue({ remove: mockRemove }); + + await manager.start({ version: '1.0.0', build: '1' }); + manager.cleanup(); + + expect(mockRemove).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/constants/storage.ts b/src/constants/storage.ts index df1ead0..7f53a5b 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -3,6 +3,8 @@ export const STORAGE_PREFIX = "formo_rn_"; // Local storage keys (persistent) export const LOCAL_ANONYMOUS_ID_KEY = "anonymous_id"; +export const LOCAL_APP_VERSION_KEY = "app_version"; +export const LOCAL_APP_BUILD_KEY = "app_build"; // Session storage keys (cleared on app restart) export const SESSION_USER_ID_KEY = "user_id"; diff --git a/src/lib/event/EventFactory.ts b/src/lib/event/EventFactory.ts index 987b697..07710af 100644 --- a/src/lib/event/EventFactory.ts +++ b/src/lib/event/EventFactory.ts @@ -359,7 +359,18 @@ class EventFactory implements IEventFactory { processedEvent.properties = null; } - return toSnakeCase(processedEvent as unknown as Record) as unknown as IFormoEvent; + // Extract function_args before snake_case conversion to preserve ABI parameter names + // (e.g., "tokenId" should not become "token_id" since it's a contract ABI name) + const functionArgs = (processedEvent.properties as Record)?.function_args; + + const converted = toSnakeCase(processedEvent as unknown as Record) as unknown as IFormoEvent; + + // Re-attach function_args with original key casing + if (functionArgs && converted.properties) { + (converted.properties as Record).function_args = functionArgs; + } + + return converted; } /** @@ -367,10 +378,11 @@ class EventFactory implements IEventFactory { */ async generateScreenEvent( name: string, + category?: string, properties?: IFormoEventProperties, context?: IFormoEventContext ): Promise { - const props = { ...(properties ?? {}), name }; + const props = { ...(properties ?? {}), name, ...(category && { category }) }; const screenEvent: Partial = { properties: props, @@ -476,7 +488,7 @@ class EventFactory implements IEventFactory { async generateSignatureEvent( status: SignatureStatus, - chainId: ChainID, + chainId: ChainID | undefined, address: Address, message: string, signatureHash?: string, @@ -486,7 +498,7 @@ class EventFactory implements IEventFactory { const signatureEvent: Partial = { properties: { status, - chainId, + ...(chainId !== undefined && chainId !== null && { chainId }), message, ...(signatureHash && { signatureHash }), ...properties, @@ -502,10 +514,12 @@ class EventFactory implements IEventFactory { status: TransactionStatus, chainId: ChainID, address: Address, - data: string, - to: string, - value: string, + data?: string, + to?: string, + value?: string, transactionHash?: string, + function_name?: string, + function_args?: Record, properties?: IFormoEventProperties, context?: IFormoEventContext ): Promise { @@ -517,6 +531,8 @@ class EventFactory implements IEventFactory { to, value, ...(transactionHash && { transactionHash }), + ...(function_name && { function_name }), + ...(function_args && { function_args }), ...properties, }, address, @@ -569,6 +585,7 @@ class EventFactory implements IEventFactory { case "screen": formoEvent = await this.generateScreenEvent( event.name, + event.category, event.properties, event.context ); @@ -635,6 +652,8 @@ class EventFactory implements IEventFactory { event.to, event.value, event.transactionHash, + event.function_name, + event.function_args, event.properties, event.context ); diff --git a/src/lib/event/types.ts b/src/lib/event/types.ts index b854115..c4c07bf 100644 --- a/src/lib/event/types.ts +++ b/src/lib/event/types.ts @@ -19,6 +19,7 @@ export interface IEventFactory { generateScreenEvent( name: string, + category?: string, properties?: IFormoEventProperties, context?: IFormoEventContext ): Promise; @@ -62,7 +63,7 @@ export interface IEventFactory { generateSignatureEvent( status: SignatureStatus, - chainId: ChainID, + chainId: ChainID | undefined, address: Address, message: string, signatureHash?: string, @@ -74,10 +75,12 @@ export interface IEventFactory { status: TransactionStatus, chainId: ChainID, address: Address, - data: string, - to: string, - value: string, + data?: string, + to?: string, + value?: string, transactionHash?: string, + function_name?: string, + function_args?: Record, properties?: IFormoEventProperties, context?: IFormoEventContext ): Promise; diff --git a/src/lib/lifecycle/index.ts b/src/lib/lifecycle/index.ts new file mode 100644 index 0000000..2a80a69 --- /dev/null +++ b/src/lib/lifecycle/index.ts @@ -0,0 +1,215 @@ +/** + * Application Lifecycle Event Manager + * + * Tracks application lifecycle events following the Segment/RudderStack spec: + * - Application Installed (first launch) + * - Application Updated (version/build changed) + * - Application Opened (every cold start + foreground return) + * - Application Backgrounded (app goes to background) + * + * Detection is JS-side using AsyncStorage (no native modules required). + */ + +import { AppState, AppStateStatus, Linking } from "react-native"; +import { logger } from "../logger"; +import { storage, getStorageManager } from "../storage"; +import { + LOCAL_APP_VERSION_KEY, + LOCAL_APP_BUILD_KEY, +} from "../../constants/storage"; + +/** Interface for the analytics instance to avoid circular deps */ +interface IAnalyticsInstance { + track(event: string, properties?: Record): Promise; +} + +/** App version info resolved from device or config */ +interface AppVersionInfo { + version: string; + build: string; +} + +/** + * Resolves current app version and build from device info modules or options. + * Uses the same fallback chain as EventFactory.getDeviceInfo(). + */ +async function resolveAppVersionInfo( + appOptions?: { version?: string; build?: string } +): Promise { + // Use explicit options first + if (appOptions?.version && appOptions?.build) { + return { version: appOptions.version, build: appOptions.build }; + } + + // Try react-native-device-info + try { + const DeviceInfo = require("react-native-device-info").default; + return { + version: appOptions?.version || DeviceInfo.getVersion(), + build: appOptions?.build || DeviceInfo.getBuildNumber(), + }; + } catch { + // Not available + } + + // Try expo-application + try { + const ExpoApplication = require("expo-application"); + return { + version: appOptions?.version || ExpoApplication.nativeApplicationVersion || "", + build: appOptions?.build || ExpoApplication.nativeBuildVersion || "", + }; + } catch { + // Not available + } + + return { + version: appOptions?.version || "", + build: appOptions?.build || "", + }; +} + +export class AppLifecycleManager { + private analytics: IAnalyticsInstance; + private appStateSubscription: { remove: () => void } | null = null; + private lastAppState: AppStateStatus = AppState.currentState; + private appVersionInfo: AppVersionInfo = { version: "", build: "" }; + + constructor(analytics: IAnalyticsInstance) { + this.analytics = analytics; + } + + /** + * Initialize lifecycle tracking. + * Detects install/update, fires Application Opened, and sets up AppState listener. + */ + async start( + appOptions?: { version?: string; build?: string } + ): Promise { + this.appVersionInfo = await resolveAppVersionInfo(appOptions); + + // Detect install vs update + await this.detectInstallOrUpdate(); + + // Fire Application Opened (cold start) + let initialUrl: string | undefined; + try { + const url = await Linking.getInitialURL(); + if (url) { + initialUrl = url; + } + } catch { + // Linking not available + } + + await this.analytics.track("Application Opened", { + version: this.appVersionInfo.version, + build: this.appVersionInfo.build, + from_background: false, + ...(initialUrl && { url: initialUrl }), + }); + + // Subscribe to AppState changes + this.appStateSubscription = AppState.addEventListener( + "change", + this.handleAppStateChange.bind(this) + ); + + logger.info("AppLifecycleManager: Started"); + } + + /** + * Compare stored version/build with current to detect install or update. + * Requires persistent storage (AsyncStorage) — skips if only MemoryStorage is available, + * since MemoryStorage is empty on every cold start and would false-positive as "installed". + */ + private async detectInstallOrUpdate(): Promise { + const manager = getStorageManager(); + const hasPersistentStorage = manager?.hasPersistentStorage() ?? false; + + if (!hasPersistentStorage) { + logger.warn( + "AppLifecycleManager: AsyncStorage not available, skipping install/update detection. " + + "Provide asyncStorage to FormoAnalyticsProvider for accurate lifecycle tracking." + ); + return; + } + + const previousVersion = storage().get(LOCAL_APP_VERSION_KEY) as string | null; + const previousBuild = storage().get(LOCAL_APP_BUILD_KEY) as string | null; + + const { version, build } = this.appVersionInfo; + + if (previousVersion === null && previousBuild === null) { + // No stored version — first install + logger.info("AppLifecycleManager: Application Installed"); + await this.analytics.track("Application Installed", { + version, + build, + }); + } else if (previousVersion !== version || previousBuild !== build) { + // Version or build changed — update + logger.info("AppLifecycleManager: Application Updated"); + await this.analytics.track("Application Updated", { + version, + build, + previous_version: previousVersion || "", + previous_build: previousBuild || "", + }); + } + + // Persist current version/build for next comparison + // Use setAsync to ensure data is written to AsyncStorage before continuing, + // preventing duplicate install events if the app is terminated before persistence completes + await storage().setAsync(LOCAL_APP_VERSION_KEY, version); + await storage().setAsync(LOCAL_APP_BUILD_KEY, build); + } + + /** + * Handle AppState transitions for foreground/background events. + */ + private handleAppStateChange(nextAppState: AppStateStatus): void { + // Ignore "inactive" (iOS transitional state) and "unknown" + if (nextAppState === "inactive" || nextAppState === "unknown") { + return; + } + + if (nextAppState === "active" && this.lastAppState === "background") { + // Returning from background + this.analytics + .track("Application Opened", { + version: this.appVersionInfo.version, + build: this.appVersionInfo.build, + from_background: true, + }) + .catch((error) => { + logger.error("AppLifecycleManager: Error tracking Application Opened", error); + }); + } + + if (nextAppState === "background" && this.lastAppState === "active") { + // Going to background + this.analytics + .track("Application Backgrounded", { + version: this.appVersionInfo.version, + build: this.appVersionInfo.build, + }) + .catch((error) => { + logger.error("AppLifecycleManager: Error tracking Application Backgrounded", error); + }); + } + + this.lastAppState = nextAppState; + } + + /** + * Clean up AppState listener. + */ + cleanup(): void { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + logger.info("AppLifecycleManager: Cleaned up"); + } +} diff --git a/src/lib/storage/StorageManager.ts b/src/lib/storage/StorageManager.ts index c924bed..d94725c 100644 --- a/src/lib/storage/StorageManager.ts +++ b/src/lib/storage/StorageManager.ts @@ -75,6 +75,15 @@ export class StorageManager { } return this.getStorage("memoryStorage"); } + + /** + * Check if persistent storage (AsyncStorage) has been initialized. + * Returns false if only MemoryStorage is available. + */ + public hasPersistentStorage(): boolean { + const stored = this.storages.get("asyncStorage"); + return stored instanceof AsyncStorageAdapter && stored.isAvailable(); + } } // Global storage manager instance diff --git a/src/types/base.ts b/src/types/base.ts index 4f039de..2828a3a 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -19,6 +19,7 @@ export type ValidInputTypes = Uint8Array | bigint | string | number | boolean; export interface IFormoAnalytics { screen( name: string, + category?: string, properties?: IFormoEventProperties, context?: IFormoEventContext, callback?: (...args: unknown[]) => void @@ -52,7 +53,7 @@ export interface IFormoAnalytics { signature( params: { status: SignatureStatus; - chainId: ChainID; + chainId?: ChainID; address: Address; message: string; signatureHash?: string; @@ -70,6 +71,8 @@ export interface IFormoAnalytics { to?: string; value?: string; transactionHash?: string; + function_name?: string; + function_args?: Record; }, properties?: IFormoEventProperties, context?: IFormoEventContext, @@ -147,6 +150,12 @@ export interface AutocaptureOptions { * @default true */ chain?: boolean; + + /** + * Track application lifecycle events (installed, updated, opened, backgrounded) + * @default true + */ + lifecycle?: boolean; } /** @@ -193,6 +202,21 @@ export interface AppInfo { bundleId?: string; } +/** + * Configuration options for custom referral query parameter parsing + */ +export interface ReferralOptions { + /** + * Custom query parameter names to check for referral codes + * These are checked in addition to the defaults: ref, referral, refcode, referrer_code + */ + queryParams?: string[]; + /** + * Path pattern for extracting referral codes from URL paths + */ + pathPattern?: string; +} + export interface Options { tracking?: boolean | TrackingOptions; /** @@ -226,6 +250,14 @@ export interface Options { * App information for context enrichment */ app?: AppInfo; + /** + * Custom referral query parameter configuration + */ + referral?: ReferralOptions; + /** + * Global error handler for SDK errors + */ + errorHandler?: (err: Error) => void; ready?: (formo: IFormoAnalytics) => void; } @@ -233,5 +265,20 @@ export interface FormoAnalyticsProviderProps { writeKey: string; options?: Options; disabled?: boolean; + /** + * AsyncStorage instance from @react-native-async-storage/async-storage + * Required for persistent storage + */ + asyncStorage?: import("../lib/storage").AsyncStorageInterface; + /** + * Callback when SDK is ready + * Note: Use useCallback to avoid re-initialization on every render + */ + onReady?: (sdk: IFormoAnalytics) => void; + /** + * Callback when SDK initialization fails + * Note: Use useCallback to avoid re-initialization on every render + */ + onError?: (error: Error) => void; children: ReactNode; } diff --git a/src/types/events.ts b/src/types/events.ts index 8fa5c12..ccbd70c 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -43,6 +43,7 @@ export type IFormoEventPayload = IFormoEvent & { export interface ScreenAPIEvent { type: "screen"; name: string; + category?: string; } export interface DetectAPIEvent { @@ -70,16 +71,18 @@ export interface TransactionAPIEvent { status: TransactionStatus; chainId: ChainID; address: Address; - data: string; - to: string; - value: string; + data?: string; + to?: string; + value?: string; transactionHash?: string; + function_name?: string; + function_args?: Record; } export interface SignatureAPIEvent { type: "signature"; status: SignatureStatus; - chainId: ChainID; + chainId?: ChainID; address: Address; message: string; signatureHash?: string; diff --git a/src/utils/trafficSource.ts b/src/utils/trafficSource.ts index 88311e4..9a4377c 100644 --- a/src/utils/trafficSource.ts +++ b/src/utils/trafficSource.ts @@ -12,7 +12,11 @@ import type { ITrafficSource } from "../types"; * Parse UTM parameters and referral info from URL * Supports both web URLs (https://) and deep link URLs (myapp://) */ -export function parseTrafficSource(url: string): Partial { +export function parseTrafficSource( + url: string, + customRefParams?: string[], + pathPattern?: string +): Partial { try { // Handle deep link URLs that may not have standard URL format let urlObj: URL; @@ -40,8 +44,11 @@ export function parseTrafficSource(url: string): Partial { if (params.has("utm_term")) trafficSource.utm_term = params.get("utm_term")!; if (params.has("utm_content")) trafficSource.utm_content = params.get("utm_content")!; - // Extract referral codes (check common parameter names) - const refParams = ["ref", "referral", "refcode", "referrer_code"]; + // Extract referral codes (check common parameter names + custom ones, deduplicated) + const defaultRefParams = ["ref", "referral", "refcode", "referrer_code"]; + const refParams = customRefParams + ? [...new Set([...defaultRefParams, ...customRefParams])] + : defaultRefParams; for (const param of refParams) { if (params.has(param)) { trafficSource.ref = params.get(param)!; @@ -49,6 +56,20 @@ export function parseTrafficSource(url: string): Partial { } } + // Extract referral code from URL path if pathPattern is provided + if (pathPattern && !trafficSource.ref) { + try { + const pathRegex = new RegExp(pathPattern); + const pathname = urlObj.pathname; + const match = pathname.match(pathRegex); + if (match && match[1]) { + trafficSource.ref = match[1]; + } + } catch (e) { + logger.error("Error parsing pathPattern for referral:", e); + } + } + // Store the full URL as referrer trafficSource.referrer = url;