Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 42 additions & 10 deletions src/FormoAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// Call ready callback
if (options?.ready) {
options.ready(analytics);
Expand All @@ -138,14 +152,15 @@ export class FormoAnalytics implements IFormoAnalytics {
*/
public async screen(
name: string,
category?: string,
properties?: IFormoEventProperties,
Comment on lines 153 to 156
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 Keep screen properties as second argument

Adding category before properties changes runtime behavior for existing integrations that call screen(name, properties) (including JS users without type checks). Calls like formo.screen('Profile', { userId: '123' }) now treat the object as category, drop properties, and emit malformed screen metadata, so upgrades silently lose custom screen properties. Please preserve backward compatibility with an overload/runtime argument check (or by appending category after existing optional args).

Useful? React with 👍 / 👎.

Comment on lines 154 to 156
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 Preserve backward-compatible screen() parameter parsing

Changing screen to accept category as the second positional argument breaks existing integrations that call screen(name, properties, ...): the properties object is now treated as category, so the intended screen properties are lost and an object-valued category is emitted instead. This is a silent analytics regression for JavaScript users (and TypeScript users compiled against the old signature), so the method should keep legacy argument handling or add new parameters without shifting existing positions.

Useful? React with 👍 / 👎.

context?: IFormoEventContext,
callback?: (...args: unknown[]) => void
): Promise<void> {
// Note: shouldTrack() is called in trackEvent() - no need to check here
await this.trackEvent(
EventType.SCREEN,
{ name },
{ name, ...(category && { category }) },
properties,
context,
callback
Expand Down Expand Up @@ -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);
}
Expand All @@ -197,6 +215,11 @@ export class FormoAnalytics implements IFormoAnalytics {
public async cleanup(): Promise<void> {
logger.info("FormoAnalytics: Cleaning up resources");

if (this.lifecycleManager) {
this.lifecycleManager.cleanup();
this.lifecycleManager = undefined;
}

if (this.wagmiHandler) {
this.wagmiHandler.cleanup();
this.wagmiHandler = undefined;
Expand Down Expand Up @@ -325,7 +348,7 @@ export class FormoAnalytics implements IFormoAnalytics {
signatureHash,
}: {
status: SignatureStatus;
chainId: ChainID;
chainId?: ChainID;
address: Address;
message: string;
signatureHash?: string;
Expand All @@ -334,10 +357,6 @@ export class FormoAnalytics implements IFormoAnalytics {
context?: IFormoEventContext,
callback?: (...args: unknown[]) => void
): Promise<void> {
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;
Expand All @@ -346,7 +365,7 @@ export class FormoAnalytics implements IFormoAnalytics {
EventType.SIGNATURE,
{
status,
chainId,
...(chainId !== undefined && chainId !== null && { chainId }),
address,
message,
...(signatureHash && { signatureHash }),
Expand All @@ -369,6 +388,8 @@ export class FormoAnalytics implements IFormoAnalytics {
to,
value,
transactionHash,
function_name,
function_args,
}: {
status: TransactionStatus;
chainId: ChainID;
Expand All @@ -377,6 +398,8 @@ export class FormoAnalytics implements IFormoAnalytics {
to?: string;
value?: string;
transactionHash?: string;
function_name?: string;
function_args?: Record<string, unknown>;
},
properties?: IFormoEventProperties,
context?: IFormoEventContext,
Expand All @@ -400,6 +423,8 @@ export class FormoAnalytics implements IFormoAnalytics {
to,
value,
...(transactionHash && { transactionHash }),
...(function_name && { function_name }),
...(function_args && { function_args }),
},
properties,
context,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
}

Expand Down
25 changes: 3 additions & 22 deletions src/FormoAnalyticsProvider.tsx
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 New referral and errorHandler options missing from optionsKey, preventing SDK re-initialization on change

The PR adds two new options to Options: referral (serializable object with queryParams and pathPattern) and errorHandler (function). Neither is included in the optionsKey computation in FormoAnalyticsProvider.tsx. The existing pattern extracts each option individually (lines 126-137) and includes serializable ones in serializableOptions (lines 141-152) and useMemo deps (line 160). For example, app (a similar serializable config object) is included. For functions, ready is tracked as hasReady: !!options?.ready. However, referral is not extracted or serialized, and errorHandler has no hasErrorHandler equivalent. If a consumer changes referral config or toggles errorHandler, the optionsKey won't change, the useEffect (line 162) won't re-run, and the SDK instance will retain stale options—meaning setTrafficSourceFromUrl() at src/FormoAnalytics.ts:180-184 would use outdated referral config.

(Refers to lines 140-160)

Prompt for agents
In src/FormoAnalyticsProvider.tsx, around line 125-160:

1. Extract the new options from the options object (after line 137):
   const referral = options?.referral;
   const hasErrorHandler = !!options?.errorHandler;

2. Add them to the serializableOptions object (around line 141-152):
   Add referral and hasErrorHandler to the object.

3. Add them to the useMemo dependency array (line 160):
   Add referral and hasErrorHandler to the dependency list.

This follows the existing pattern where serializable options (like app) are included directly, and function options (like ready) are tracked as boolean flags (hasReady).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,25 +34,6 @@ const defaultContext: IFormoAnalytics = {
export const FormoAnalyticsContext =
createContext<IFormoAnalytics>(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
*
Expand All @@ -76,7 +57,7 @@ export interface FormoAnalyticsProviderPropsWithStorage
* }
* ```
*/
export const FormoAnalyticsProvider: FC<FormoAnalyticsProviderPropsWithStorage> = (
export const FormoAnalyticsProvider: FC<FormoAnalyticsProviderProps> = (
props
) => {
const { writeKey, disabled = false, children } = props;
Expand All @@ -102,7 +83,7 @@ export const FormoAnalyticsProvider: FC<FormoAnalyticsProviderPropsWithStorage>
return <InitializedAnalytics {...props} />;
};

const InitializedAnalytics: FC<FormoAnalyticsProviderPropsWithStorage> = ({
const InitializedAnalytics: FC<FormoAnalyticsProviderProps> = ({
writeKey,
options,
asyncStorage,
Expand Down
32 changes: 29 additions & 3 deletions src/__tests__/FormoAnalytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -260,15 +276,25 @@ 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,
address: '0x742d35cc6634c0532925a3b844bc9e7595f3f6d2',
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 () => {
Expand Down Expand Up @@ -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();
});
Expand Down
Loading
Loading