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
224 changes: 34 additions & 190 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ trustPolicyExclude:
# Dependency overrides
# Pin transitive deps to patched versions for advisories not yet fixed upstream.
overrides:
'@babel/plugin-transform-modules-systemjs': '>=7.29.4'
'@babel/plugin-transform-modules-systemjs': '>=7.29.4 <8'
'@react-native-community/cli': '>=17.0.1'
'@react-native-community/cli-server-api': '>=17.0.1'
'@ungap/structured-clone': '>=1.3.1'
Expand All @@ -46,6 +46,7 @@ overrides:
'brace-expansion@2': '>=2.0.3 <3'
'brace-expansion@5': '>=5.0.6 <6'
'fast-xml-parser': '>=4.5.5'
'js-yaml@3': '>=3.15.0'
'minimatch@3': '>=3.1.5 <4'
'minimatch@5': '>=5.1.9 <6'
'minimatch@9': '>=9.0.9 <10'
Expand All @@ -57,9 +58,27 @@ overrides:
'postcss': '>=8.5.10'
'semver@5': '>=7.7.4'
'semver@6': '>=7.7.4'
'shell-quote': '>=1.8.4'
'tar': '>=7.5.13'
'tmp': '>=0.2.6'
'undici': '>=6.24.1 <7'
'uuid': '>=11.0.0'
'ws@8': '>=8.20.1'
'ws@6': '>=6.2.4'
'ws@7': '>=7.5.11'
'ws@8': '>=8.21.0'
'yaml@1': '>=1.10.3 <2'
'yaml@2': '>=2.8.3 <3'

# Audit policy: ignore advisories that only affect react-native's / wagmi's BUILD
# tooling (never shipped in the SDK's runtime) and that cannot be cleanly patched.
# The overrides above already bump the fixable instances (shell-quote, tmp, ws@6/7,
# most ws@8, js-yaml@3); these residuals have no non-breaking fix available:
# GHSA-4x5r-pxfx-6jf8 @babel/core (LOW) no patched version published (>=7.29.1 absent)
# GHSA-96hv-2xvq-fx4p ws (HIGH) one build-tooling ws@8.20.1 resists the >=8.21.0 override
# GHSA-h67p-54hq-rp68 js-yaml (MODERATE) the only 4.x fix is a breaking major bump (5.x)
# Revisit as upstream (react-native / metro / viem) updates these transitive deps.
auditConfig:
ignoreGhsas:
- GHSA-4x5r-pxfx-6jf8
- GHSA-96hv-2xvq-fx4p
- GHSA-h67p-54hq-rp68
4 changes: 4 additions & 0 deletions src/FormoAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
EVENTS_API_HOST,
EventType,
LOCAL_ANONYMOUS_ID_KEY,
LOCAL_SESSION_ID_KEY,
LOCAL_SESSION_LAST_ACTIVITY_KEY,
SESSION_USER_ID_KEY,
CONSENT_OPT_OUT_KEY,
TEventType,
Expand Down Expand Up @@ -256,6 +258,8 @@ export class FormoAnalytics implements IFormoAnalytics {
public reset(): void {
this.currentUserId = undefined;
storage().remove(LOCAL_ANONYMOUS_ID_KEY);
storage().remove(LOCAL_SESSION_ID_KEY);
storage().remove(LOCAL_SESSION_LAST_ACTIVITY_KEY);
storage().remove(SESSION_USER_ID_KEY);
this.session.clear();
}
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,36 @@ describe('hash utilities', () => {
const uuid = generateUUID();
expect(uuid).toHaveLength(36);
});

// The secure path uses Web Crypto (present in jest). These tests force the
// no-Web-Crypto fallback to confirm it still yields valid, unique UUIDs
// without crashing (and without Math.random).
describe('fallback when Web Crypto is unavailable', () => {
const realCrypto = (globalThis as { crypto?: unknown }).crypto;
beforeEach(() => {
Object.defineProperty(globalThis, 'crypto', {
value: undefined,
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(globalThis, 'crypto', {
value: realCrypto,
configurable: true,
});
});

it('still produces a valid UUID v4', () => {
expect(generateUUID()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});

it('produces unique ids even within the same millisecond (counter)', () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) ids.add(generateUUID());
expect(ids.size).toBe(100);
});
});
});
});
42 changes: 42 additions & 0 deletions src/__tests__/screenEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { EventFactory } from "../lib/event/EventFactory";
import { initStorageManager } from "../lib/storage";

/**
* Mobile screen views are emitted as type="page" with page_url `app://<name>`.
* The ingestion pipeline (P-2070) owns the interpretation: it derives `origin`
* from the app identifier in context (app_name / app_bundle_id) and `page_path`
* by stripping the app:// scheme. So the SDK emits the screen name as-is and
* must NOT encode an app host in the URL — doing so would leak the host into the
* backend-derived page_path.
*/
describe("generateScreenEvent page_url", () => {
beforeEach(() => initStorageManager("screen-test-key"));

it("emits the screen name as app://<name>", async () => {
const factory = new EventFactory();
const evt = await factory.generateScreenEvent("Home");
expect(evt.context?.page_url).toBe("app://Home");
expect(evt.context?.page_title).toBe("Home");
});

it("passes router-style paths through unchanged", async () => {
const factory = new EventFactory();
const evt = await factory.generateScreenEvent("/tabs/leaderboard");
expect(evt.context?.page_url).toBe("app:///tabs/leaderboard");
});

it("does NOT encode the app bundle id into the URL (backend owns origin)", async () => {
const factory = new EventFactory({ app: { bundleId: "com.formo.test" } });
const evt = await factory.generateScreenEvent("Wallet");
expect(evt.context?.page_url).toBe("app://Wallet");
expect(String(evt.context?.page_url)).not.toContain("com.formo.test");
});

it("keeps user-supplied context (spread last)", async () => {
const factory = new EventFactory();
const evt = await factory.generateScreenEvent("Home", undefined, undefined, {
page_url: "app://Custom",
});
expect(evt.context?.page_url).toBe("app://Custom");
});
});
55 changes: 55 additions & 0 deletions src/__tests__/sessionId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getSessionId } from "../lib/event/EventFactory";
import { initStorageManager, storage } from "../lib/storage";
import {
LOCAL_SESSION_ID_KEY,
LOCAL_SESSION_LAST_ACTIVITY_KEY,
SESSION_TIMEOUT_MS,
} from "../constants";

/**
* These tests exist because a missing/empty session_id collapses every mobile
* user of a project into a single downstream session (bounce/engagement/duration
* all break). They pin the intended contract: one stable id per active session,
* a fresh id after the inactivity timeout.
*/
describe("getSessionId", () => {
beforeEach(() => {
initStorageManager("session-test-key");
storage().remove(LOCAL_SESSION_ID_KEY);
storage().remove(LOCAL_SESSION_LAST_ACTIVITY_KEY);
});

it("mints and persists a UUID session id on first call", () => {
const id = getSessionId();
expect(id).toMatch(/^[0-9a-f-]{36}$/);
expect(storage().get(LOCAL_SESSION_ID_KEY)).toBe(id);
// Every event must carry an id — the whole point of the fix.
expect(id).not.toBe("");
});

it("reuses the same id for events within the inactivity window", () => {
expect(getSessionId()).toBe(getSessionId());
});

it("mints a new id once the inactivity timeout has elapsed", () => {
const first = getSessionId();
// Simulate the previous event happening longer ago than the timeout.
storage().set(
LOCAL_SESSION_LAST_ACTIVITY_KEY,
String(Date.now() - (SESSION_TIMEOUT_MS + 1))
);
const second = getSessionId();
expect(second).not.toBe(first);
expect(storage().get(LOCAL_SESSION_ID_KEY)).toBe(second);
});

it("refreshes the last-activity marker on every call", () => {
getSessionId();
const firstMarker = Number(storage().get(LOCAL_SESSION_LAST_ACTIVITY_KEY));
// Backdate the marker, then confirm the next call moves it forward.
storage().set(LOCAL_SESSION_LAST_ACTIVITY_KEY, String(firstMarker - 1000));
getSessionId();
const secondMarker = Number(storage().get(LOCAL_SESSION_LAST_ACTIVITY_KEY));
expect(secondMarker).toBeGreaterThan(firstMarker - 1000);
});
});
57 changes: 57 additions & 0 deletions src/__tests__/userAgent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { synthesizeUserAgent } from "../lib/event/EventFactory";

/**
* The ingestion pipeline (Tinybird dedup_raw_events) classifies device + os
* SOLELY from the lowercased user_agent string. A mobile event with an empty UA
* is bucketed device=os='unknown'. These tests pin the synthesized UA to the
* exact tokens that classifier matches, so mobile device/os resolve correctly:
* device: 'iphone|ipod'->mobile-ios, 'ipad'->tablet,
* 'android'+'mobile'->mobile-android, 'android'+tablet/no-mobile->tablet
* os: 'iphone|ipad|ipod'->ios, 'android[ /]<ver>'->android
*/
describe("synthesizeUserAgent", () => {
const ua = (o: Partial<Parameters<typeof synthesizeUserAgent>[0]>) =>
synthesizeUserAgent({
os_name: "",
os_version: "",
device_model: "",
device_type: "mobile",
...o,
}).toLowerCase();

it("iOS phone → classifies as mobile-ios (has 'iphone', no 'ipad')", () => {
const s = ua({ os_name: "iOS", os_version: "17.4.1", device_type: "mobile" });
expect(s).toContain("iphone");
expect(s).not.toContain("ipad");
// os regex expects 'iphone os <digits>_<digits>'
expect(/iphone os [\d_]+ like mac os x/.test(s)).toBe(true);
});

it("iOS tablet → classifies as tablet (has 'ipad')", () => {
const s = ua({ os_name: "iOS", os_version: "17.4", device_type: "tablet" });
expect(s).toContain("ipad");
});

it("Android phone → mobile-android (has 'android' + 'mobile', versioned)", () => {
const s = ua({ os_name: "Android", os_version: "14", device_model: "Pixel 8", device_type: "mobile" });
expect(s).toContain("android");
expect(s).toContain("mobile");
expect(/android[/ ][\d.]+/.test(s)).toBe(true);
});

it("Android tablet → tablet (has 'android' + tablet token, NOT 'mobile')", () => {
const s = ua({ os_name: "Android", os_version: "13", device_model: "Tab S9", device_type: "tablet" });
expect(s).toContain("android");
expect(s).toContain("tablet");
expect(s).not.toContain("mobile");
});

it("handles Expo's capitalized osName and lowercase Platform.OS alike", () => {
expect(ua({ os_name: "ios" })).toContain("iphone");
expect(ua({ os_name: "iOS" })).toContain("iphone");
});

it("returns empty string for unknown platforms (no false classification)", () => {
expect(synthesizeUserAgent({ os_name: "windows", os_version: "11", device_model: "PC", device_type: "mobile" })).toBe("");
});
});
4 changes: 4 additions & 0 deletions src/constants/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ export type TEventChannel = Lowercase<EventChannel>;
// React Native SDK uses mobile channel
export const CHANNEL: TEventChannel = "mobile";
export const VERSION = "0";

// Session inactivity timeout (30 min), matching the GA4/Segment default. A new
// session_id is minted once the gap since the last tracked event exceeds this.
export const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
4 changes: 4 additions & 0 deletions src/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const STORAGE_PREFIX = "formo_rn_";
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 identifier + last-activity marker. Persisted so a session survives an
// app restart, but expires after SESSION_TIMEOUT_MS of inactivity (see EventFactory).
export const LOCAL_SESSION_ID_KEY = "session_id";
export const LOCAL_SESSION_LAST_ACTIVITY_KEY = "session_last_activity";
// One-shot flag: set once the Install Referrer (Android) or AdServices (iOS)
// attribution has been fetched, so we never call the native API again.
export const LOCAL_INSTALL_REFERRER_RESOLVED_KEY = "install_referrer_resolved";
Expand Down
Loading