Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
10,322 changes: 10,322 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

24 changes: 15 additions & 9 deletions src/FormoAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
TransactionStatus,
} from "./types";
import { toChecksumAddress, getValidAddress } from "./utils";
import { parseTrafficSource, storeTrafficSource } from "./utils/trafficSource";

export class FormoAnalytics implements IFormoAnalytics {
private session: FormoAnalyticsSession;
Expand Down Expand Up @@ -138,14 +139,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() backward-compatible with existing argument order

Changing screen to screen(name, category?, properties?, context?, callback?) shifts every existing positional call by one slot. Calls like screen('Home', { section: 'featured' }) now pass the properties object as category, so custom properties/context are dropped or misaligned and a non-string category can be sent in the payload. This is a silent analytics regression for existing JS consumers and a breaking API change for TS users that previously used screen(name, properties, ...).

Useful? React with 👍 / 👎.

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 +177,7 @@ 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor referral pathPattern when parsing traffic source

The new ReferralOptions type advertises pathPattern, but URL parsing only receives referral.queryParams here. As a result, configuring { referral: { pathPattern: ... } } has no effect and referral codes encoded in path segments are never extracted, which makes the public option misleading and breaks expected attribution behavior for path-based referral links.

Useful? React with 👍 / 👎.

This comment was marked as outdated.

storeTrafficSource(trafficSource);
logger.debug("Traffic source set from URL:", trafficSource);
}
Expand Down Expand Up @@ -325,7 +326,7 @@ export class FormoAnalytics implements IFormoAnalytics {
signatureHash,
}: {
status: SignatureStatus;
chainId: ChainID;
chainId?: ChainID;
address: Address;
message: string;
signatureHash?: string;
Expand All @@ -334,10 +335,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 +343,7 @@ export class FormoAnalytics implements IFormoAnalytics {
EventType.SIGNATURE,
{
status,
chainId,
...(chainId !== undefined && chainId !== null && { chainId }),
address,
message,
...(signatureHash && { signatureHash }),
Expand All @@ -369,6 +366,8 @@ export class FormoAnalytics implements IFormoAnalytics {
to,
value,
transactionHash,
function_name,
function_args,
}: {
status: TransactionStatus;
chainId: ChainID;
Expand All @@ -377,6 +376,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 +401,8 @@ export class FormoAnalytics implements IFormoAnalytics {
to,
value,
...(transactionHash && { transactionHash }),
...(function_name && { function_name }),
...(function_args && { function_args }),
},
properties,
context,
Expand Down Expand Up @@ -596,6 +599,9 @@ export class FormoAnalytics implements IFormoAnalytics {
);
} catch (error) {
logger.error("Error tracking event:", error);
if (this.options.errorHandler) {
this.options.errorHandler(error instanceof Error ? error : new Error(String(error)));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard errorHandler exceptions inside trackEvent

The new global errorHandler is invoked from within the catch block, but it is not wrapped in its own try/catch. If a consumer-provided handler throws (for example due to a bug in their reporter), that exception escapes trackEvent and causes public calls like track()/signature() to reject even though tracking errors are otherwise handled internally. This turns telemetry failures into app-level failures; wrap the handler call and log secondary handler errors instead of letting them bubble.

Useful? React with 👍 / 👎.

}
}
}

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
16 changes: 13 additions & 3 deletions src/__tests__/FormoAnalytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,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 +349,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
20 changes: 14 additions & 6 deletions src/lib/event/EventFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,11 @@ class EventFactory implements IEventFactory {
*/
async generateScreenEvent(
name: string,
category?: string,
properties?: IFormoEventProperties,
context?: IFormoEventContext
): Promise<IFormoEvent> {
const props = { ...(properties ?? {}), name };
const props = { ...(properties ?? {}), name, ...(category && { category }) };

const screenEvent: Partial<IFormoEvent> = {
properties: props,
Expand Down Expand Up @@ -476,7 +477,7 @@ class EventFactory implements IEventFactory {

async generateSignatureEvent(
status: SignatureStatus,
chainId: ChainID,
chainId: ChainID | undefined,
address: Address,
message: string,
signatureHash?: string,
Expand All @@ -486,7 +487,7 @@ class EventFactory implements IEventFactory {
const signatureEvent: Partial<IFormoEvent> = {
properties: {
status,
chainId,
...(chainId !== undefined && chainId !== null && { chainId }),
message,
...(signatureHash && { signatureHash }),
...properties,
Expand All @@ -502,10 +503,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<string, unknown>,
properties?: IFormoEventProperties,
context?: IFormoEventContext
): Promise<IFormoEvent> {
Expand All @@ -517,6 +520,8 @@ class EventFactory implements IEventFactory {
to,
value,
...(transactionHash && { transactionHash }),
...(function_name && { function_name }),
...(function_args && { function_args }),
...properties,
},
address,
Comment on lines 531 to 538

This comment was marked as outdated.

Expand Down Expand Up @@ -569,6 +574,7 @@ class EventFactory implements IEventFactory {
case "screen":
formoEvent = await this.generateScreenEvent(
event.name,
event.category,
event.properties,
event.context
);
Expand Down Expand Up @@ -635,6 +641,8 @@ class EventFactory implements IEventFactory {
event.to,
event.value,
event.transactionHash,
event.function_name,
event.function_args,
event.properties,
event.context
);
Expand Down
11 changes: 7 additions & 4 deletions src/lib/event/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface IEventFactory {

generateScreenEvent(
name: string,
category?: string,
properties?: IFormoEventProperties,
context?: IFormoEventContext
): Promise<IFormoEvent>;
Expand Down Expand Up @@ -62,7 +63,7 @@ export interface IEventFactory {

generateSignatureEvent(
status: SignatureStatus,
chainId: ChainID,
chainId: ChainID | undefined,
address: Address,
message: string,
signatureHash?: string,
Expand All @@ -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<string, unknown>,
properties?: IFormoEventProperties,
context?: IFormoEventContext
): Promise<IFormoEvent>;
Expand Down
43 changes: 42 additions & 1 deletion src/types/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,7 +53,7 @@ export interface IFormoAnalytics {
signature(
params: {
status: SignatureStatus;
chainId: ChainID;
chainId?: ChainID;
address: Address;
message: string;
signatureHash?: string;
Expand All @@ -70,6 +71,8 @@ export interface IFormoAnalytics {
to?: string;
value?: string;
transactionHash?: string;
function_name?: string;
function_args?: Record<string, unknown>;
},
properties?: IFormoEventProperties,
context?: IFormoEventContext,
Expand Down Expand Up @@ -193,6 +196,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;
/**
Expand Down Expand Up @@ -226,12 +244,35 @@ 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;
}

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;
}
Loading
Loading