From 00e6b99e6e83c829cb784889dd871ef71bfbbcb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:13:15 +0000 Subject: [PATCH 01/10] feat(analytics): add screen.viewed event, flow catalog, and derivation helpers Introduce the single canonical screen-view event for Slice B (#2883): - AnalyticsEvent.SCREEN_VIEWED = "screen.viewed" - AnalyticsFlow enum + ScreenViewedProps type - deriveScreenName(): deterministic legacy-string -> slug - SCREEN_METADATA: per-screen flow/step catalog keyed by legacy string - buildScreenViewedProps() / getScreenViewedProps() / isScreenViewEvent() The VIEW_* members are retained as the legacy-string catalog that screen_name derives from; their values become catalog keys only and are no longer emitted. Route-mapping logic (transformRouteToEventName / CUSTOM_ROUTE_MAPPINGS / processRouteForAnalytics) is unchanged and still resolves the legacy string. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CDXpJqihDLf2yE3Ur7UaUT --- src/config/analyticsConfig.ts | 216 ++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index e78ac1406..955c31ff7 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -7,6 +7,18 @@ import { ALL_ROUTES_OBJECT } from "config/routes"; * Events are organized by category for better maintainability. */ export enum AnalyticsEvent { + // Canonical screen-view event (Slice B, #2883). + // + // Every screen load emits THIS single event carrying { screen_name, flow, + // surface, step? } instead of a distinct "loaded screen: X" event. The + // VIEW_* members below are retained as the canonical legacy-screen-string + // catalog: `screen_name` is derived deterministically from their string + // values (see deriveScreenName), and they remain the keys that components + // firing screen views manually (bottom sheets / detail sheets) reference. + // After the Slice B cutover their "loaded screen: X" string values are used + // only as catalog keys and are NEVER emitted to Amplitude. + SCREEN_VIEWED = "screen.viewed", + // Screen Navigation Events (Auto-generated from routes) VIEW_WELCOME = "loaded screen: welcome", VIEW_CHOOSE_PASSWORD = "loaded screen: account creator", @@ -208,6 +220,210 @@ export enum SwapSelectionSource { TRENDING = "trending", } +/** + * Cross-platform "flow" dimension for the canonical `screen.viewed` event. + * + * Each screen is assigned its best-fit flow so screen views can be funneled by + * user journey in shared analytics dashboards. Values are the RFC's low- + * cardinality flow vocabulary; a screen may omit `flow` when none fits. + */ +export enum AnalyticsFlow { + ONBOARDING = "onboarding", + SEND = "send", + SWAP = "swap", + SIGNING = "signing", + ASSETS = "assets", + SETTINGS = "settings", + DISCOVERY = "discovery", + SECURITY = "security", + HISTORY = "history", +} + +/** + * Property bag carried by the single canonical `screen.viewed` event. + * + * - `screen_name`: deterministic, cross-platform slug (see deriveScreenName). + * - `flow`: best-fit user journey (see AnalyticsFlow); omitted when none fits. + * - `step`: sub-step marker for screens that are a stage within a flow + * (e.g. a confirmation or processing screen) rather than a distinct + * destination. Collapses completion/success screens into `screen.viewed` + * instead of a bespoke event. + * + * `surface` is intentionally NOT included here: it is added to every event by + * the Slice-A common context (buildCommonContext -> getSurface()). + */ +// A `type` alias (not an `interface`) so it stays assignable to the +// `Record`-based `AnalyticsProps` at the track() call sites - +// interfaces are not assignable to an index signature, type aliases are. +export type ScreenViewedProps = { + screen_name: string; + flow?: AnalyticsFlow; + step?: string; +}; + +const LEGACY_SCREEN_PREFIX = "loaded screen: "; + +/** + * Per-screen flow (and optional step) assignment, keyed by the legacy + * "loaded screen: X" string. `screen_name` is NOT stored here - it is derived + * mechanically from the key so both platforms align automatically. Screens + * absent from this map still emit `screen.viewed` (with a derived + * `screen_name`) but carry no `flow`. + */ +const SCREEN_METADATA: Record = + { + // Onboarding / account creation + [AnalyticsEvent.VIEW_WELCOME]: { flow: AnalyticsFlow.ONBOARDING }, + [AnalyticsEvent.VIEW_CHOOSE_PASSWORD]: { flow: AnalyticsFlow.ONBOARDING }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE_ALERT]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE]: { flow: AnalyticsFlow.ONBOARDING }, + [AnalyticsEvent.VIEW_VALIDATE_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_IMPORT_WALLET]: { flow: AnalyticsFlow.ONBOARDING }, + + // Security / re-auth / secret material + [AnalyticsEvent.VIEW_LOCK_SCREEN]: { flow: AnalyticsFlow.SECURITY }, + [AnalyticsEvent.VIEW_SECURITY]: { flow: AnalyticsFlow.SECURITY }, + [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { flow: AnalyticsFlow.SECURITY }, + + // Home / assets + [AnalyticsEvent.VIEW_HOME]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_TOKEN_DETAILS]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_ACCOUNT_QR_CODE]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_MANAGE_TOKENS]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_ADD_TOKEN]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_REMOVE_TOKEN]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_SEARCH_TOKEN]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_ADD_TOKEN_MANUALLY]: { flow: AnalyticsFlow.ASSETS }, + [AnalyticsEvent.VIEW_BUY_XLM]: { flow: AnalyticsFlow.ASSETS }, + + // History + [AnalyticsEvent.VIEW_HISTORY]: { flow: AnalyticsFlow.HISTORY }, + + // Discovery + [AnalyticsEvent.VIEW_DISCOVERY]: { flow: AnalyticsFlow.DISCOVERY }, + + // Signing / dApp + [AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS]: { flow: AnalyticsFlow.SIGNING }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS]: { + flow: AnalyticsFlow.SIGNING, + }, + + // Send payment + [AnalyticsEvent.VIEW_SEND_SEARCH_CONTACTS]: { flow: AnalyticsFlow.SEND }, + [AnalyticsEvent.VIEW_SEND_AMOUNT]: { flow: AnalyticsFlow.SEND }, + [AnalyticsEvent.VIEW_SEND_MEMO]: { flow: AnalyticsFlow.SEND }, + [AnalyticsEvent.VIEW_SEND_FEE]: { flow: AnalyticsFlow.SEND }, + [AnalyticsEvent.VIEW_SEND_TIMEOUT]: { flow: AnalyticsFlow.SEND }, + [AnalyticsEvent.VIEW_SEND_CONFIRM]: { + flow: AnalyticsFlow.SEND, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_PROCESSING]: { + flow: AnalyticsFlow.SEND, + step: "processing", + }, + + // Swap + [AnalyticsEvent.VIEW_SWAP]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_AMOUNT]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_FEE]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_SLIPPAGE]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_TIMEOUT]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_SETTINGS]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_CONFIRM]: { + flow: AnalyticsFlow.SWAP, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SWAP_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SWAP, + }, + + // Settings + [AnalyticsEvent.VIEW_SETTINGS]: { flow: AnalyticsFlow.SETTINGS }, + [AnalyticsEvent.VIEW_PREFERENCES]: { flow: AnalyticsFlow.SETTINGS }, + [AnalyticsEvent.VIEW_CHANGE_NETWORK]: { flow: AnalyticsFlow.SETTINGS }, + [AnalyticsEvent.VIEW_NETWORK_SETTINGS]: { flow: AnalyticsFlow.SETTINGS }, + [AnalyticsEvent.VIEW_SHARE_FEEDBACK]: { flow: AnalyticsFlow.SETTINGS }, + [AnalyticsEvent.VIEW_ABOUT]: { flow: AnalyticsFlow.SETTINGS }, + [AnalyticsEvent.VIEW_MANAGE_CONNECTED_APPS]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { flow: AnalyticsFlow.SETTINGS }, + }; + +/** + * Derives the canonical, cross-platform `screen_name` from a legacy + * "loaded screen: X" string, deterministically: + * 1. strip the "loaded screen: " prefix + * 2. trim + * 3. lowercase + * 4. replace each run of non-alphanumeric chars with a single "_" + * + * e.g. "loaded screen: send payment amount" -> "send_payment_amount" + * "loaded screen: account" -> "account" + * + * Both platforms use identical legacy strings, so `screen_name` aligns + * cross-platform automatically. + */ +export const deriveScreenName = (legacyEvent: string): string => + legacyEvent + .replace(/^loaded screen:\s*/i, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +/** + * True when `event` is a legacy screen-load event (its value should be + * retargeted to the canonical `screen.viewed` event rather than emitted). + */ +export const isScreenViewEvent = (event: string): boolean => + event.startsWith(LEGACY_SCREEN_PREFIX); + +/** + * Builds the `screen.viewed` property bag from a legacy "loaded screen: X" + * string: a deterministic `screen_name` plus the screen's `flow`/`step` from + * the catalog (omitted when unknown/none fits). + */ +export const buildScreenViewedProps = ( + legacyEvent: string, +): ScreenViewedProps => { + const meta = SCREEN_METADATA[legacyEvent] ?? {}; + const props: ScreenViewedProps = { + screen_name: deriveScreenName(legacyEvent), + }; + if (meta.flow) props.flow = meta.flow; + if (meta.step) props.step = meta.step; + return props; +}; + +/** + * Retargeting helper for manual screen-view emission sites (e.g. bottom + * sheets that present a "screen"). Returns the `screen.viewed` props for a + * legacy screen-load event, or null for any non-screen event (which should be + * tracked unchanged). + */ +export const getScreenViewedProps = ( + event: string, +): ScreenViewedProps | null => + isScreenViewEvent(event) ? buildScreenViewedProps(event) : null; + /** * Route-to-Analytics Mapping Configuration * From 594ba0579c0d2d3323520e72f05d7c3ef297200a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:13:22 +0000 Subject: [PATCH 02/10] refactor(analytics): emit screen.viewed from the screen-view choke points Hard cutover for Slice B (#2883): every screen load now emits the single canonical screen.viewed event instead of a distinct "loaded screen: X" event. - useNavigationAnalytics: route -> buildScreenViewedProps -> SCREEN_VIEWED - BottomSheet: retarget legacy screen analyticsEvent props to SCREEN_VIEWED (all 12 manual screen-view call sites flow through this one component, two via SignTransactionDetails which forwards here). Non-screen events pass through unchanged. surface is supplied by the Slice-A common context (getSurface()). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CDXpJqihDLf2yE3Ur7UaUT --- src/components/BottomSheet.tsx | 16 ++++++++++++++-- src/hooks/useNavigationAnalytics.ts | 21 ++++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx index 86837c0f1..aa1471ac3 100644 --- a/src/components/BottomSheet.tsx +++ b/src/components/BottomSheet.tsx @@ -9,7 +9,7 @@ import { import { BottomSheetViewProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetView/types"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import { AnalyticsEvent } from "config/analyticsConfig"; +import { AnalyticsEvent, getScreenViewedProps } from "config/analyticsConfig"; import { DEFAULT_PADDING } from "config/constants"; import { pxValue } from "helpers/dimensions"; import useColors from "hooks/useColors"; @@ -150,7 +150,19 @@ const BottomSheet: React.FC = ({ (index: number) => { // index >= 0 means sheet is visible if (analyticsEvent && index >= 0 && !hasTrackedRef.current) { - track(analyticsEvent, analyticsProps); + // Screens presented via a bottom sheet are screen loads too: retarget + // any legacy "loaded screen: X" analyticsEvent to the single canonical + // `screen.viewed` event (Slice B). Non-screen events pass through + // unchanged. + const screenViewedProps = getScreenViewedProps(analyticsEvent); + if (screenViewedProps) { + track(AnalyticsEvent.SCREEN_VIEWED, { + ...screenViewedProps, + ...analyticsProps, + }); + } else { + track(analyticsEvent, analyticsProps); + } hasTrackedRef.current = true; } diff --git a/src/hooks/useNavigationAnalytics.ts b/src/hooks/useNavigationAnalytics.ts index 1ba8bfc24..04011b86f 100644 --- a/src/hooks/useNavigationAnalytics.ts +++ b/src/hooks/useNavigationAnalytics.ts @@ -1,5 +1,9 @@ import { NavigationState, PartialState } from "@react-navigation/native"; -import { processRouteForAnalytics } from "config/analyticsConfig"; +import { + AnalyticsEvent, + buildScreenViewedProps, + processRouteForAnalytics, +} from "config/analyticsConfig"; import { useRef } from "react"; import { track } from "services/analytics/core"; @@ -35,10 +39,17 @@ export const useNavigationAnalytics = () => { const currentRouteName = getActiveRouteName(state); if (previousRouteName !== currentRouteName) { - const event = processRouteForAnalytics(currentRouteName); - - if (event) { - track(event); + // processRouteForAnalytics still resolves the route to its legacy + // "loaded screen: X" string; Slice B retargets that into a single + // canonical `screen.viewed` event carrying { screen_name, flow } + // (surface is added by the Slice-A common context). + const legacyScreenEvent = processRouteForAnalytics(currentRouteName); + + if (legacyScreenEvent) { + track( + AnalyticsEvent.SCREEN_VIEWED, + buildScreenViewedProps(legacyScreenEvent), + ); } } From ba7d54bede29e5000d493d4649c7fd7f99e9081a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:13:28 +0000 Subject: [PATCH 03/10] test(analytics): cover screen.viewed consolidation - analyticsConfig.test.ts: deriveScreenName determinism, isScreenViewEvent, buildScreenViewedProps (screen_name + flow + step), getScreenViewedProps retarget/passthrough, and the route path feeding screen.viewed. - core.test.ts: emission asserts name=screen.viewed with screen_name/flow/ surface (+ step for completion screens), and that NO legacy "loaded screen:" event is emitted for any catalog screen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CDXpJqihDLf2yE3Ur7UaUT --- __tests__/config/analyticsConfig.test.ts | 154 +++++++++++++++++++++++ src/services/analytics/core.test.ts | 68 +++++++++- 2 files changed, 221 insertions(+), 1 deletion(-) diff --git a/__tests__/config/analyticsConfig.test.ts b/__tests__/config/analyticsConfig.test.ts index 0969aa772..d18e11d3b 100644 --- a/__tests__/config/analyticsConfig.test.ts +++ b/__tests__/config/analyticsConfig.test.ts @@ -1,9 +1,14 @@ import { AnalyticsEvent, + AnalyticsFlow, ROUTE_TO_ANALYTICS_EVENT_MAP, ROUTES_WITHOUT_ANALYTICS, transformRouteToEventName, processRouteForAnalytics, + deriveScreenName, + buildScreenViewedProps, + getScreenViewedProps, + isScreenViewEvent, } from "config/analyticsConfig"; describe("Analytics Configuration", () => { @@ -77,4 +82,153 @@ describe("Analytics Configuration", () => { expect(ROUTE_TO_ANALYTICS_EVENT_MAP.AuthStack).toBeNull(); }); }); + + describe("screen.viewed consolidation (Slice B, #2883)", () => { + it("exposes the single canonical screen event", () => { + expect(AnalyticsEvent.SCREEN_VIEWED).toBe("screen.viewed"); + }); + + describe("deriveScreenName", () => { + it("derives a deterministic slug from the legacy screen string", () => { + expect(deriveScreenName("loaded screen: send payment amount")).toBe( + "send_payment_amount", + ); + expect(deriveScreenName("loaded screen: account")).toBe("account"); + expect( + deriveScreenName("loaded screen: view public key generator"), + ).toBe("view_public_key_generator"); + }); + + it("collapses each run of non-alphanumeric chars into a single underscore", () => { + expect(deriveScreenName("loaded screen: swap amount ")).toBe( + "swap_amount", + ); + expect(deriveScreenName("loaded screen: re-auth / details")).toBe( + "re_auth_details", + ); + }); + + it("is stable across the whole VIEW_* catalog (idempotent, no leading/trailing underscores)", () => { + Object.entries(AnalyticsEvent) + .filter(([key]) => key.startsWith("VIEW_")) + .forEach(([, legacy]) => { + const name = deriveScreenName(legacy); + expect(name).toMatch(/^[a-z0-9]+(?:_[a-z0-9]+)*$/); + }); + }); + }); + + describe("isScreenViewEvent", () => { + it("recognises legacy screen-load strings and rejects the rest", () => { + expect(isScreenViewEvent(AnalyticsEvent.VIEW_HOME)).toBe(true); + expect(isScreenViewEvent(AnalyticsEvent.SCREEN_VIEWED)).toBe(false); + expect(isScreenViewEvent(AnalyticsEvent.SEND_PAYMENT_SUCCESS)).toBe( + false, + ); + }); + }); + + describe("buildScreenViewedProps", () => { + it("derives screen_name and assigns the screen's flow", () => { + expect(buildScreenViewedProps(AnalyticsEvent.VIEW_SEND_AMOUNT)).toEqual( + { + screen_name: "send_payment_amount", + flow: AnalyticsFlow.SEND, + }, + ); + expect(buildScreenViewedProps(AnalyticsEvent.VIEW_DISCOVERY)).toEqual({ + screen_name: "discover", + flow: AnalyticsFlow.DISCOVERY, + }); + }); + + it("adds a step for completion/sub-step screens", () => { + expect( + buildScreenViewedProps(AnalyticsEvent.VIEW_SEND_CONFIRM), + ).toEqual({ + screen_name: "send_payment_confirm", + flow: AnalyticsFlow.SEND, + step: "confirm", + }); + expect( + buildScreenViewedProps(AnalyticsEvent.VIEW_SEND_PROCESSING), + ).toEqual({ + screen_name: "send_payment_processing", + flow: AnalyticsFlow.SEND, + step: "processing", + }); + expect( + buildScreenViewedProps(AnalyticsEvent.VIEW_SWAP_CONFIRM), + ).toEqual({ + screen_name: "swap_confirm", + flow: AnalyticsFlow.SWAP, + step: "confirm", + }); + }); + + it("omits flow (and step) for a screen with no catalogued flow", () => { + // An unmapped legacy string still emits screen.viewed with a derived + // name but no flow. + expect( + buildScreenViewedProps("loaded screen: some future screen"), + ).toEqual({ screen_name: "some_future_screen" }); + }); + + it("produces a valid screen_name for every VIEW_* catalog entry", () => { + Object.entries(AnalyticsEvent) + .filter(([key]) => key.startsWith("VIEW_")) + .forEach(([, legacy]) => { + const props = buildScreenViewedProps(legacy); + expect(props.screen_name).toMatch(/^[a-z0-9]+(?:_[a-z0-9]+)*$/); + if (props.flow) { + expect(Object.values(AnalyticsFlow)).toContain(props.flow); + } + }); + }); + }); + + describe("getScreenViewedProps (manual retarget helper)", () => { + it("retargets legacy screen events (incl. bottom-sheet ones) to screen.viewed props", () => { + expect(getScreenViewedProps(AnalyticsEvent.VIEW_SEND_CONFIRM)).toEqual({ + screen_name: "send_payment_confirm", + flow: AnalyticsFlow.SEND, + step: "confirm", + }); + expect( + getScreenViewedProps( + AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS, + ), + ).toEqual({ + screen_name: "sign_transaction_details", + flow: AnalyticsFlow.SIGNING, + }); + }); + + it("returns null for non-screen events so they pass through unchanged", () => { + expect( + getScreenViewedProps(AnalyticsEvent.SEND_PAYMENT_SUCCESS), + ).toBeNull(); + expect(getScreenViewedProps(AnalyticsEvent.SCREEN_VIEWED)).toBeNull(); + }); + }); + + describe("route path feeds screen.viewed (hard cutover)", () => { + it("still resolves routes to a legacy string that maps to screen.viewed props", () => { + // processRouteForAnalytics keeps returning the legacy string; the + // navigation hook feeds it through buildScreenViewedProps. + const welcome = processRouteForAnalytics("WelcomeScreen"); + expect(welcome).toBe(AnalyticsEvent.VIEW_WELCOME); + expect(buildScreenViewedProps(welcome!)).toEqual({ + screen_name: "welcome", + flow: AnalyticsFlow.ONBOARDING, + }); + + const settings = processRouteForAnalytics("SettingsScreen"); + expect(buildScreenViewedProps(settings!)).toEqual({ + screen_name: "settings", + flow: AnalyticsFlow.SETTINGS, + }); + }); + }); + }); }); diff --git a/src/services/analytics/core.test.ts b/src/services/analytics/core.test.ts index a0b349aa5..209bd9be5 100644 --- a/src/services/analytics/core.test.ts +++ b/src/services/analytics/core.test.ts @@ -18,7 +18,7 @@ // (`PlatformLocalStorage`). The factory stubs only what core.ts touches. // • `helpers/stellar` is mocked because core.ts imports `truncateAddress` // from it and there is no `__mocks__/helpers/stellar` stub. -import { AnalyticsEvent } from "config/analyticsConfig"; +import { AnalyticsEvent, buildScreenViewedProps } from "config/analyticsConfig"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; @@ -89,6 +89,7 @@ const { deriveIdentifyTraits, initAnalytics, trackAppOpened, + track, } = jest.requireActual("./core"); describe("getAccountIdHash", () => { @@ -282,6 +283,71 @@ describe("trackAppOpened (one-time connectivity snapshot)", () => { }); }); +describe("screen.viewed emission (Slice B hard cutover)", () => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const amplitudeMock = require("@amplitude/analytics-react-native"); + + beforeEach(() => { + initAnalytics(); + (amplitudeMock.track as jest.Mock).mockClear(); + }); + + it("emits the single canonical event with screen_name, flow, and surface", () => { + track( + AnalyticsEvent.SCREEN_VIEWED, + buildScreenViewedProps(AnalyticsEvent.VIEW_SEND_AMOUNT), + ); + + expect(amplitudeMock.track).toHaveBeenCalledTimes(1); + expect(amplitudeMock.track).toHaveBeenCalledWith( + "screen.viewed", + expect.objectContaining({ + screen_name: "send_payment_amount", + flow: "send", + // surface comes from the Slice-A common context (getSurface()). + surface: "mobile_ios", + schema_version: "2", + }), + ); + }); + + it("carries a step for completion/sub-step screens", () => { + track( + AnalyticsEvent.SCREEN_VIEWED, + buildScreenViewedProps(AnalyticsEvent.VIEW_SEND_PROCESSING), + ); + + expect(amplitudeMock.track).toHaveBeenCalledWith( + "screen.viewed", + expect.objectContaining({ + screen_name: "send_payment_processing", + flow: "send", + step: "processing", + }), + ); + }); + + it("never emits a legacy 'loaded screen: X' event for ANY screen in the catalog", () => { + // Drive every VIEW_* screen through the navigation retargeting path and + // assert the emitted event name is always the canonical one. + Object.entries(AnalyticsEvent) + .filter(([key]) => key.startsWith("VIEW_")) + .forEach(([, legacy]) => { + track(AnalyticsEvent.SCREEN_VIEWED, buildScreenViewedProps(legacy)); + }); + + const emittedEventNames = (amplitudeMock.track as jest.Mock).mock.calls.map( + (call) => call[0] as string, + ); + + expect(emittedEventNames.length).toBeGreaterThan(0); + emittedEventNames.forEach((name) => { + expect(name).toBe(AnalyticsEvent.SCREEN_VIEWED); + expect(name.startsWith("loaded screen:")).toBe(false); + }); + }); +}); + describe("syncIdentifyTraits (consent gating)", () => { it("does not cache or send Identify while opted out; sends once opted in with the same traits", () => { // syncIdentifyTraits guards on module-level `hasInitialised`/fingerprint From a18e386be99dfebd84ee2bc4b3f33bd470c7b4f0 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 16 Jul 2026 12:09:29 -0400 Subject: [PATCH 04/10] docs(analytics): remove internal slice-name references from comments --- .claude/settings.local.json | 14 + ...ile-analytics-property-model-foundation.md | 612 ++++++++++++++++++ ...ile-analytics-property-model-foundation.md | 143 ++++ src/components/BottomSheet.tsx | 2 +- src/config/analyticsConfig.ts | 4 +- src/hooks/useNavigationAnalytics.ts | 2 +- src/services/analytics/core.test.ts | 2 +- 7 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md create mode 100644 docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..73027a0b3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(git:*)", + "Bash(npm install:*)", + "Bash(node:*)", + "Bash(yarn add:*)", + "WebFetch(domain:www.figma.com)", + "mcp__figma__get_design_context", + "Bash(npx tsc:*)", + "Bash(npx jest:*)" + ] + } +} diff --git a/docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md b/docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md new file mode 100644 index 000000000..4cd741e06 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md @@ -0,0 +1,612 @@ +# Mobile Analytics Property-Model Foundation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax. + +**Goal:** Bring freighter-mobile's Amplitude property model to the RFC +four-bucket schema (mirroring extension #2903), without renaming events. + +**Architecture:** Evolve `freighter-mobile/src/services/analytics/core.ts` in +place. Add `schema_version`, `account_id_hash`, `getSurface` +(`mobile_ios`/`mobile_android`), Identify traits, and enrich `app.opened`; +derive `account_funded` from the balances store (new `fetchedPublicKey` gate). +Two cross-platform touch-points ride along: an extension #2903 follow-up +(sticky→balances) and an RFC #23 note. + +**Tech Stack:** TypeScript, React Native, `@amplitude/analytics-react-native`, +Zustand stores, `@stellar/stellar-sdk` (`hash`), Jest (`react-native` preset). + +**Spec:** +`docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md` + +## Global Constraints + +- **No event renames** (legacy names stay; `app.opened` keeps its legacy string + `"event: App Opened"`). +- **Hard cutover, no dual-write.** `schema_version` value is the string `"2"`. +- **Never emit a raw or truncated public key.** Account identity = + `account_id_hash` = `hash(Buffer.from(publicKey,"utf8")).toString("hex")` + (lowercase hex SHA-256 of the full G-address). Must match the committed vector + `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF` → + `f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef`. +- **Omit both** `is_hardware_account` (event) and `has_hardware_wallet` + (Identify) — mobile has no hardware wallets. +- **`account_funded`**: derive from the balances store; emit only when + `fetchedPublicKey === activePublicKey`, else **omit** (never emit a misleading + `false`). +- **`wallet_count` = `allAccounts.length`.** +- **Do not enable behavioral autocapture.** Keep the existing throttle/dedupe + dispatcher and opt-out subscription. +- **Identity/`user_id`** is out of scope (owned by mobile #864). +- Conventional Commit prefixes. + +## File Structure + +| File | Change | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `src/services/analytics/core.ts` | Modify — buildCommonContext, init/Identify, trackAppOpened; add getAccountIdHash/getSurface/syncIdentifyTraits + auth-store subscription | +| `src/ducks/balances.ts` | Modify — add `fetchedPublicKey` field | +| `src/services/analytics/core.test.ts` | **Create** — no analytics tests exist yet | + +Cross-repo touch-points (separate tasks, other repos): extension `freighter` and +monorepo `wallet-eng-monorepo`. + +--- + +## Task M1: `getAccountIdHash` + test scaffolding + +**Files:** Modify `src/services/analytics/core.ts`; Create +`src/services/analytics/core.test.ts` + +**Interfaces:** Produces `getAccountIdHash(publicKey: string): string` — +memoized lowercase-hex SHA-256 of the G-address; `""` on error. + +- [ ] **Step 1: Create the test file with the shared mock header + failing + test** + +Create `src/services/analytics/core.test.ts`: + +```ts +import { getAccountIdHash } from "services/analytics/core"; + +// Mobile analytics couples to Zustand stores, the RN SDK, and device-info at +// module load — mock all of them here; individual tests set behavior. +jest.mock("@amplitude/analytics-react-native"); +jest.mock("@amplitude/experiment-react-native-client", () => ({ + Experiment: { initializeWithAmplitudeAnalytics: jest.fn(() => ({})) }, +})); +jest.mock("react-native", () => ({ Platform: { OS: "ios", Version: "17.0" } })); +jest.mock("react-native-device-info", () => ({ + getVersion: jest.fn(() => "9.9.9"), + getBuildNumber: jest.fn(() => "42"), + getBundleId: jest.fn(() => "org.stellar.freighter"), +})); +jest.mock("config/logger", () => ({ + logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn() }, +})); +jest.mock("helpers/isEnv", () => ({ isE2ETest: false })); +jest.mock("services/analytics/constants", () => ({ + AMPLITUDE_API_KEY: "test-key", + AMPLITUDE_EXPERIMENT_DEPLOYMENT_KEY: "test-exp", + DEBUG_CONFIG: { LOG_PREFIX: "Analytics" }, + TIMING: { THROTTLE_DELAY_MS: 500 }, + ANALYTICS_CONFIG: { + INCLUDE_COMMON_CONTEXT: true, + THROTTLE_DUPLICATE_EVENTS: false, // disable throttle in tests for determinism + BUNDLE_ID_KEY: "Bundle Id", + }, +})); +jest.mock("ducks/analytics", () => ({ + useAnalyticsStore: { + getState: jest.fn(() => ({ isEnabled: true })), + subscribe: jest.fn(), + }, +})); +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: { + getState: jest.fn(() => ({ + network: "testnet", + account: null, + allAccounts: [], + })), + subscribe: jest.fn(), + }, +})); +jest.mock("ducks/networkInfo", () => ({ + useNetworkStore: { + getState: jest.fn(() => ({ connectionType: "wifi", effectiveType: "4g" })), + }, +})); +jest.mock("ducks/balances", () => ({ + useBalancesStore: { + getState: jest.fn(() => ({ isFunded: false, fetchedPublicKey: null })), + }, +})); + +describe("getAccountIdHash", () => { + const PK = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const EXPECTED = + "f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef"; + + it("returns the lowercase hex SHA-256 of the G-address (cross-platform vector)", () => { + expect(getAccountIdHash(PK)).toBe(EXPECTED); + }); + it("is deterministic, 64 hex chars, and differs per key", () => { + expect(getAccountIdHash(PK)).toBe(getAccountIdHash(PK)); + expect(getAccountIdHash(PK)).toMatch(/^[0-9a-f]{64}$/); + expect(getAccountIdHash("GABC")).not.toBe(getAccountIdHash("GXYZ")); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn jest src/services/analytics/core.test.ts -t getAccountIdHash` +Expected: FAIL — `getAccountIdHash is not a function`. + +- [ ] **Step 3: Implement in `core.ts`** + +Add import near the top: `import { hash } from "@stellar/stellar-sdk";` + +Add in the CORE TRACKING region (before `buildCommonContext`): + +```ts +/** + * Cross-platform account identifier: lowercase-hex SHA-256 of the full + * G-address string. Memoized; never emits a raw/truncated key. Must match the + * extension's value for the same address. + */ +const accountIdHashCache = new Map(); +export const getAccountIdHash = (publicKey: string): string => { + const cached = accountIdHashCache.get(publicKey); + if (cached) return cached; + try { + const digest = hash(Buffer.from(publicKey, "utf8")).toString("hex"); + accountIdHashCache.set(publicKey, digest); + return digest; + } catch { + return ""; + } +}; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn jest src/services/analytics/core.test.ts -t getAccountIdHash` → PASS +(2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/services/analytics/core.ts src/services/analytics/core.test.ts +git commit -m "feat(analytics): add memoized account_id_hash helper" +``` + +--- + +## Task M2: `getSurface()` + +**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** Produces +`type Surface = "mobile_ios" | "mobile_android"`; `getSurface(): Surface`. + +- [ ] **Step 1: Failing test** — add to `core.test.ts`: + +```ts +import { getSurface } from "services/analytics/core"; + +describe("getSurface", () => { + it("maps Platform.OS to the RFC surface value", () => { + // react-native mock has Platform.OS = "ios" + expect(getSurface()).toBe("mobile_ios"); + }); +}); +``` + +- [ ] **Step 2: Run** — + `yarn jest src/services/analytics/core.test.ts -t getSurface` → FAIL. + +- [ ] **Step 3: Implement** — `core.ts` already imports + `{ Platform } from "react-native"`. Add: + +```ts +export type Surface = "mobile_ios" | "mobile_android"; +export const getSurface = (): Surface => + Platform.OS === "ios" ? "mobile_ios" : "mobile_android"; +``` + +- [ ] **Step 4: Run** → PASS. + +- [ ] **Step 5: Commit** — `feat(analytics): add mobile surface helper` + +--- + +## Task M3: balances store `fetchedPublicKey` + +**Files:** Modify `src/ducks/balances.ts`; Test `src/ducks/balances.test.ts` +(extend if present, else create a focused test) **Interfaces:** Produces +`BalancesState.fetchedPublicKey: string | null`, set to the fetched key when +`fetchAccountBalances` succeeds. + +- [ ] **Step 1: Failing test** — assert that after a successful + `fetchAccountBalances({publicKey})`, the store's `fetchedPublicKey` equals + that publicKey. (Mock `fetchBalances` to resolve + `{balances:{}, isFunded:false, subentryCount:0}`.) If mocking the fetch + chain is heavy, instead assert the reducer-level `set` includes + `fetchedPublicKey` by unit-testing a small extracted setter — but prefer + the store-level test. + +```ts +// src/ducks/balances.test.ts (illustrative; adapt mocks to existing patterns) +it("records fetchedPublicKey after a successful fetch", async () => { + // arrange: mock fetchBalances to resolve empty/funded=false + await useBalancesStore + .getState() + .fetchAccountBalances({ publicKey: "G_TEST", network: NETWORKS.TESTNET }); + expect(useBalancesStore.getState().fetchedPublicKey).toBe("G_TEST"); +}); +``` + +- [ ] **Step 2: Run** → FAIL (`fetchedPublicKey` undefined). + +- [ ] **Step 3: Implement** — in `src/ducks/balances.ts`: + + - Add to `interface BalancesState`: `fetchedPublicKey: string | null;` + - Add to the store's initial state: `fetchedPublicKey: null,` + - In `fetchAccountBalances`, in the + `set({ balances, isFunded, subentryCount })` call after a successful fetch, + add `fetchedPublicKey: params.publicKey`. + +- [ ] **Step 4: Run** → PASS. + +- [ ] **Step 5: Commit** — + `feat(balances): record fetchedPublicKey for the active-account funded signal` + +--- + +## Task M4: reshape `buildCommonContext` (four buckets) + +**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** Consumes +`getAccountIdHash` (M1), `getSurface` (M2), `fetchedPublicKey` (M3). Produces +exported `buildCommonContext(): Record`. + +**Verify at impl:** confirm `ActiveAccount` exposes `publicKey` (it does — used +today) and `importedFromSecretKey`; if the active `account` object lacks +`importedFromSecretKey`, derive `account_type` from +`allAccounts.find(a => a.publicKey === activePublicKey)?.importedFromSecretKey`. + +- [ ] **Step 1: Failing tests** — add to `core.test.ts`: + +```ts +import { useAuthenticationStore } from "ducks/auth"; +import { useBalancesStore } from "ducks/balances"; +import { buildCommonContext } from "services/analytics/core"; + +describe("buildCommonContext (four-bucket model)", () => { + const PK = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + beforeEach(() => { + (useAuthenticationStore.getState as jest.Mock).mockReturnValue({ + network: "testnet", + account: { publicKey: PK, importedFromSecretKey: true }, + allAccounts: [{ publicKey: PK, importedFromSecretKey: true }], + }); + (useBalancesStore.getState as jest.Mock).mockReturnValue({ + isFunded: true, + fetchedPublicKey: PK, + }); + }); + + it("emits the reshaped bucket", () => { + expect(buildCommonContext()).toMatchObject({ + schema_version: "2", + surface: "mobile_ios", + network: "TESTNET", + account_type: "imported_secret_key", + account_funded: true, + account_id_hash: + "f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef", + }); + }); + + it("drops SDK/legacy fields and never emits is_hardware_account or a public key", () => { + const ctx = buildCommonContext(); + [ + "publicKey", + "platform", + "platformVersion", + "appVersion", + "buildVersion", + "bundleId", + "connectionType", + "effectiveType", + "is_hardware_account", + ].forEach((k) => expect(ctx).not.toHaveProperty(k)); + expect(JSON.stringify(ctx)).not.toContain(PK); + }); + + it("omits account fields pre-unlock (no active account)", () => { + (useAuthenticationStore.getState as jest.Mock).mockReturnValue({ + network: "testnet", + account: null, + allAccounts: [], + }); + const ctx = buildCommonContext(); + ["account_id_hash", "account_type", "account_funded"].forEach((k) => + expect(ctx).not.toHaveProperty(k), + ); + expect(ctx).toMatchObject({ schema_version: "2", network: "TESTNET" }); + }); + + it("omits account_funded when balances are for a different/unfetched account", () => { + (useBalancesStore.getState as jest.Mock).mockReturnValue({ + isFunded: true, + fetchedPublicKey: "G_OTHER", + }); + expect(buildCommonContext()).not.toHaveProperty("account_funded"); + }); +}); +``` + +- [ ] **Step 2: Run** → FAIL (shape mismatch / not exported). + +- [ ] **Step 3: Implement** — replace `buildCommonContext` in `core.ts`: + +```ts +export const SCHEMA_VERSION = "2"; + +/** + * Event-level volatile bucket + schema_version. Durable traits live in Identify; + * device/app metadata comes from the RN SDK; connectivity is on app.opened. + */ +export const buildCommonContext = (): Record => { + const { network, account, allAccounts } = useAuthenticationStore.getState(); + const activePublicKey = account?.publicKey; + + const context: Record = { + schema_version: SCHEMA_VERSION, + surface: getSurface(), + network: network.toUpperCase(), + }; + + if (activePublicKey) { + const idHash = getAccountIdHash(activePublicKey); + if (idHash) context.account_id_hash = idHash; + + const isImported = + account?.importedFromSecretKey ?? + allAccounts.find((a) => a.publicKey === activePublicKey) + ?.importedFromSecretKey ?? + false; + context.account_type = isImported ? "imported_secret_key" : "freighter"; + + const { isFunded, fetchedPublicKey } = useBalancesStore.getState(); + if (fetchedPublicKey === activePublicKey) { + context.account_funded = isFunded; + } + } + + return context; +}; +``` + +Add `import { useBalancesStore } from "ducks/balances";`. Remove now-unused +imports if any (`truncateAddress`, `getVersion`, `getBuildNumber`, `getBundleId` +may still be used by trackAppOpened/Identify — keep those actually used; remove +`truncateAddress` if buildCommonContext was its only user — verify with grep). + +- [ ] **Step 3b: Dead-import check** — + `grep -n "truncateAddress\|getBuildNumber\|Platform.Version" src/services/analytics/core.ts`; + remove imports with no remaining references. + +- [ ] **Step 4: Run** → PASS (4 tests). + +- [ ] **Step 5: Commit** — + `refactor(analytics): reshape common context into RFC four-bucket model` + +--- + +## Task M5: Identify traits (consent-gated) + auth-store subscription + +**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** Produces +`deriveIdentifyTraits(allAccounts)`, `syncIdentifyTraits(allAccounts)`. + +- [ ] **Step 1: Failing tests** — add to `core.test.ts`: + +```ts +import { deriveIdentifyTraits } from "services/analytics/core"; + +describe("deriveIdentifyTraits", () => { + it("counts accounts and detects imported presence; no hardware trait", () => { + const accounts = [ + { publicKey: "G1", importedFromSecretKey: false }, + { publicKey: "G2", importedFromSecretKey: true }, + ] as never; + const t = deriveIdentifyTraits(accounts); + expect(t).toEqual({ wallet_count: 2, has_imported_account: true }); + expect(t).not.toHaveProperty("has_hardware_wallet"); + }); +}); +``` + +(Consent-gating regression — mirror the extension: a call while +`isEnabled=false` must not send Identify or cache; after enabling, the same +traits send. Use `jest.isolateModules` + toggling the +`useAnalyticsStore.getState` mock, asserting `amplitude.identify`.) + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** — in `core.ts`, replace `setAmplitudeUserProperties` + usage with trait-syncing: + +```ts +export const deriveIdentifyTraits = ( + allAccounts: { importedFromSecretKey?: boolean }[], +) => ({ + wallet_count: allAccounts.length, + has_imported_account: allAccounts.some((a) => a.importedFromSecretKey), +}); + +let lastIdentifiedTraits: string | null = null; + +export const syncIdentifyTraits = ( + allAccounts: { importedFromSecretKey?: boolean }[], +): void => { + const traits = deriveIdentifyTraits(allAccounts); + const fingerprint = JSON.stringify(traits); + if (fingerprint === lastIdentifiedTraits) return; + if (!AMPLITUDE_API_KEY || !hasInitialised) return; + // Consent gate: don't cache unless the Identify can actually be sent, so + // traits re-sync after consent hydrates (mirrors extension). + if (!useAnalyticsStore.getState().isEnabled) return; + lastIdentifiedTraits = fingerprint; + + const identify = new amplitude.Identify(); + identify.set(ANALYTICS_CONFIG.BUNDLE_ID_KEY, getBundleId()); + identify.set("wallet_count", traits.wallet_count); + identify.set("has_imported_account", traits.has_imported_account); + amplitude.identify(identify); +}; +``` + +- In `initAnalytics`, replace the `setAmplitudeUserProperties();` call with + `syncIdentifyTraits(useAuthenticationStore.getState().allAccounts);` (and + delete the old `setAmplitudeUserProperties` function, which only set + bundle_id). Add `import { useAuthenticationStore } from "ducks/auth";` if not + already present. +- Add an auth-store subscription (near the existing analytics-store subscription + at the bottom of the file): + +```ts +useAuthenticationStore.subscribe((state) => { + syncIdentifyTraits(state.allAccounts); +}); +``` + +- [ ] **Step 4: Run** → PASS. + +- [ ] **Step 5: Commit** — + `feat(analytics): send durable wallet traits via Identify (consent-gated)` + +--- + +## Task M6: enrich `app.opened` snapshot + +**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** +`trackAppOpened(props?)` now attaches the one-time snapshot. + +- [ ] **Step 1: Failing test** — assert + `trackAppOpened({previousState:"background"})` results in an + `amplitude.track("event: App Opened", ...)` call whose payload includes + `connection_type`, `effective_type`, `surface`, and (via common context) + `schema_version`. (Set `hasInitialised` true via `initAnalytics`, + `isEnabled` true; use `AnalyticsEvent.APP_OPENED`.) + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** — replace `trackAppOpened`: + +```ts +export const trackAppOpened = (props?: { previousState: string }): void => { + const { connectionType, effectiveType } = useNetworkStore.getState(); + track(AnalyticsEvent.APP_OPENED, { + ...props, + surface: getSurface(), + connection_type: connectionType ?? "unknown", + ...(effectiveType ? { effective_type: effectiveType } : {}), + }); +}; +``` + +(`network`, `schema_version`, and account fields arrive via `buildCommonContext` +inside `dispatchUnthrottled`.) No change needed at the +`useAnalyticsPermissions.ts` call sites. + +- [ ] **Step 4: Run** → PASS. + +- [ ] **Step 5: Full-file test + typecheck**: + `yarn jest src/services/analytics/core.test.ts` (all pass); + `yarn tsc --noEmit` (or repo's type-check) clean. + +- [ ] **Step 6: Commit** — + `feat(analytics): enrich app.opened with one-time connectivity snapshot` + +--- + +## Task M7: privacy guard test + +**Files:** Test `core.test.ts` + +- [ ] **Step 1** — add a guard test: with an active account, + `buildCommonContext()` serialized contains no raw/truncated public key and + no `publicKey` property. Run → PASS (behavior from M4). +- [ ] **Step 2: Commit** — + `test(analytics): guard against raw public keys in payloads` + +--- + +## Task X1: extension #2903 follow-up — `account_funded` from balances + +**Repo:** `/Users/piyal/Stellar/freighter` — branch +`feat/analytics-schema-foundation-spec` (PR #2903). **Files:** Modify +`extension/src/helpers/metrics.ts`; `extension/src/helpers/metrics.test.ts`. + +- [ ] **Step 1: Failing test** — in `metrics.test.ts`, mock `balancesSelector` + from `popup/ducks/cache` to return `{ [PK]: { isFunded: true } }`; assert + `buildCommonContext` emits `account_funded: true` from the balances cache + (not the sticky flag), and omits `account_funded` when there's no cached + entry for the active key. + +- [ ] **Step 2: Run** → FAIL. + +- [ ] **Step 3: Implement** — in `buildCommonContext`, replace the sticky + `accountFundedByType[...]` derivation with a read from the balances cache: + `const cached = balancesSelector(state)[activePublicKey]; if (cached) context.account_funded = cached.isFunded;` + (omit when no cached entry). Add the `balancesSelector` import from + `popup/ducks/cache`. Leave the `freighterAccountFunded` milestone event + and `storeBalanceMetricData` intact (they still power that one-time + event); only `account_funded`'s source changes. + +- [ ] **Step 4: Run** — `yarn jest extension/src/helpers/metrics.test.ts` (all + pass) + tsc clean. + +- [ ] **Step 5: Commit + push** — + `fix(analytics): derive account_funded from balances, not the sticky flag` + → push (updates PR #2903). + +--- + +## Task X2: RFC #23 note — pin the `account_funded` contract + +**Repo:** `/Users/piyal/Stellar/wallet-eng-monorepo` — branch +`piyal/analytics-rfc-addendum` (PR #23). **Files:** Modify +`analytics-refactor-report.md`. + +- [ ] **Step 1** — amend the `account_funded` row (Part 3 core properties) to: + "Active-account funded state derived from the account's live/cached + balance; omitted when the balance is not yet loaded for the active + account. Not a sticky per-type flag." Commit + push (updates PR #23). No + test. + +--- + +## Verification (end of plan) + +- [ ] `yarn jest src/services/analytics/core.test.ts` (mobile) — all pass; + typecheck clean. +- [ ] Extension `metrics.test.ts` still green after X1. +- [ ] Manual (mobile): with analytics enabled on a funded testnet account, + confirm events carry `schema_version:"2"`, `surface:"mobile_ios/android"`, + `network`, `account_id_hash`, `account_type`, `account_funded`; never + `platform`/`publicKey`/`is_hardware_account`; `app.opened` carries the + connectivity snapshot; Identify shows + `wallet_count`/`has_imported_account` (no `has_hardware_wallet`). + +## Open items + +- [ ] Confirm which of `platform`/`os`/`app_version` the RN SDK auto-attaches; + if `app_version` is missing, pass it in `amplitude.init`. +- [ ] Confirm `ActiveAccount` exposes `importedFromSecretKey` (else derive + `account_type` from `allAccounts`). +- [ ] Mobile `account_id_hash` reproduces the committed vector (shared with + extension). diff --git a/docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md b/docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md new file mode 100644 index 000000000..eeae42477 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md @@ -0,0 +1,143 @@ +# Mobile Analytics Property-Model Foundation (Design Spec) + +**Status:** Approved design, pre-implementation. **Local working artifact — not +committed** (the canonical schema lives in the RFC; product repos carry code, +not process docs). **Date:** 2026-07-14 **Owner:** piyal **Canonical schema +(RFC):** stellar/wallet-eng-monorepo#10 (+ addendum PR #23) **Extension +counterpart (shipped):** stellar/freighter#2903 **Identity primitive +(dependency, separate):** mobile `#864` (auth-keypair derivation; companion to +extension #2876) + +--- + +## 1. Context + +The mobile side of the cross-platform Amplitude refactor. Mirrors the extension +property-model foundation (#2903): align mobile's Amplitude property model to +the RFC four-bucket schema **without renaming events**. +`freighter-mobile/src/services/analytics/core.ts` is a near-1:1 analog of the +extension's `metrics.ts` — same over-chatty `buildCommonContext`, same opt-out +store subscription, `app.opened` + Experiment client already present. Evolve it +in place (no rewrite). + +Mobile uses the **same single-mnemonic HD model** as the extension (flat +`Account = { id, name, publicKey, importedFromSecretKey? }`, one recovery +phrase, no per-seed-phrase grouping; "wallets" in the UI = accounts). It has +**no hardware wallets** and **no sticky funding model**. + +## 2. Scope + +**In scope** (global property-model changes in `core.ts`): + +1. `schema_version: "2"` on every event. +2. `account_id_hash` (SHA-256 hex of the full G-address, memoized) replacing the + truncated `publicKey`. +3. Drop hand-sent SDK-supplied fields (`platform`, `platformVersion`, + `appVersion`, `buildVersion`, `bundleId`); rely on the RN SDK's built-in + enrichment. +4. Durable traits → Amplitude Identify; volatile context stays event-level. +5. `getSurface()` (`mobile_ios`/`mobile_android`) + enrich `app.opened` with the + one-time snapshot. + +**Out of scope:** event renames (deferred to mobile's later slices); +identity/`user_id` (owned by #864). + +**Cross-platform touch-points carried by this effort** (from the +`account_funded` decision): + +- **Extension follow-up** — a commit on PR #2903 swapping `account_funded` from + the sticky `metricsData` flag to the balances selector. +- **RFC addendum** — a note on PR #23 pinning the `account_funded` contract. + +## 3. Key decisions + +| Decision | Choice | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Migration | Hard cutover, no dual-write (per RFC addendum). Names stay legacy this slice. | +| `account_id_hash` | `hash(Buffer.from(publicKey,"utf8")).toString("hex")` (stellar-sdk), memoized. Must match the committed vector `G…AWHF → f56f6f2c…44ef`. | +| `surface` | `mobile_ios` / `mobile_android` from `Platform.OS` (synchronous). | +| `account_funded` | Derived from the active account's **cached balance** (`useBalancesStore.getState().isFunded`); tri-state — **omit when balance not yet loaded**. Never a sticky flag. | +| `account_type` | `freighter` \| `imported_secret_key` (from `importedFromSecretKey`); emitted only with an active account. | +| Hardware wallet | **Omit both** `is_hardware_account` (event) and `has_hardware_wallet` (Identify) — mobile has no hardware-wallet concept. | +| `wallet_count` | `allAccounts.length` (consistent with extension). | +| Identity | Out of scope; `user_id` stays as-is (owned by #864). | + +## 4. The four buckets (`buildCommonContext`) + +**Bucket 1 — SDK-supplied (stop hand-sending):** delete `platform`, +`platformVersion`, `appVersion`, `buildVersion`, `bundleId`. Rely on the +`@amplitude/analytics-react-native` context enrichment; pass `appVersion` in +`amplitude.init` if the RN SDK doesn't attach it automatically (confirm at +impl). Do **not** enable behavioral autocapture. + +**Bucket 2 — event-level volatile:** `network` (kept), `surface` (new, +`getSurface()`), `schema_version: "2"`, and — only with an active account — +`account_id_hash`, `account_type`, `account_funded` (omit when balance unknown). +**No** `is_hardware_account`. **Removed:** `publicKey`, `connectionType`, +`effectiveType`. + +**Bucket 3 — Identify user traits** (see §5). + +**Bucket 4 — one-time `app.opened` snapshot** (see §6): `surface`, `network`, +`connection_type`, `effective_type`, `schema_version`. + +## 5. Identity traits + +Extend `setAmplitudeUserProperties`: + +- `bundle_id` (existing), `wallet_count` = `allAccounts.length`, + `has_imported_account` = any account `importedFromSecretKey`. +- **Omit** `has_hardware_wallet`. +- Dirty-checked (only when a trait changes) **and consent-gated** — do not cache + the fingerprint unless `isEnabled` (mirrors the extension fix so traits + re-sync after consent hydrates). +- Re-sync (`syncIdentifyTraits`) when the account list changes — wire to the + auth store (`allAccounts`). + +## 6. `app.opened` + +Enrich the existing foreground-triggered `trackAppOpened` (legacy name +`"event: App Opened"` — unchanged) with the snapshot: `surface`, `network`, +`connection_type`, `effective_type`, `schema_version` (keep `previousState`). +Move `connection_type`/`effective_type` off `buildCommonContext` onto this +event. Fires on foreground (post store-hydration), so no init-timing consent +bug. + +## 7. `account_id_hash` + +`getAccountIdHash(publicKey)` — synchronous SHA-256 hex of the full G-address +via stellar-sdk `hash`, memoized per key; omit when no active key. +Cross-platform contract: identical to extension (committed vector). Never emit a +raw/truncated key. + +## 8. Error handling & edge cases + +- Pre-unlock / no active key → omit `account_id_hash`, `account_type`, + `account_funded`. +- Balance not loaded → omit `account_funded` (not `false`). +- Consent off → no `track`, no `identify`, no fingerprint cache. +- Missing API key / not initialized → existing guarded no-op path preserved. +- Keep the existing throttle/dedupe dispatcher. + +## 9. Testing + +Mirror the extension's `metrics.test.ts`: + +- `buildCommonContext` drops SDK/legacy fields; emits `schema_version`, + `surface`, `network`, and (with active key) + `account_id_hash`/`account_type`/`account_funded`; never + `is_hardware_account`, `has_hardware_wallet`, `publicKey`. +- `account_id_hash` matches the committed cross-platform vector. +- `account_funded` omitted when balance unknown; true/false when loaded. +- Identify emits `wallet_count`/`has_imported_account`/`bundle_id`, not + `has_hardware_wallet`; consent-gated (no send/cache while opted out; re-sync + after opt-in). +- `app.opened` carries the snapshot; connectivity fields absent from other + events. +- Privacy guard: no raw/truncated public key in any payload. + +## 10. Open items to confirm + +- Which of `platform`/`os`/`app_version` the RN SDK auto-attaches (drop vs pass + in `init`) — resolve at impl. +- Mobile reproduces the `account_id_hash` vector (shared with extension). diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx index aa1471ac3..3f2d27202 100644 --- a/src/components/BottomSheet.tsx +++ b/src/components/BottomSheet.tsx @@ -152,7 +152,7 @@ const BottomSheet: React.FC = ({ if (analyticsEvent && index >= 0 && !hasTrackedRef.current) { // Screens presented via a bottom sheet are screen loads too: retarget // any legacy "loaded screen: X" analyticsEvent to the single canonical - // `screen.viewed` event (Slice B). Non-screen events pass through + // `screen.viewed` event. Non-screen events pass through // unchanged. const screenViewedProps = getScreenViewedProps(analyticsEvent); if (screenViewedProps) { diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 955c31ff7..80ead9bd1 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -7,7 +7,7 @@ import { ALL_ROUTES_OBJECT } from "config/routes"; * Events are organized by category for better maintainability. */ export enum AnalyticsEvent { - // Canonical screen-view event (Slice B, #2883). + // Canonical screen-view event (#2883). // // Every screen load emits THIS single event carrying { screen_name, flow, // surface, step? } instead of a distinct "loaded screen: X" event. The @@ -15,7 +15,7 @@ export enum AnalyticsEvent { // catalog: `screen_name` is derived deterministically from their string // values (see deriveScreenName), and they remain the keys that components // firing screen views manually (bottom sheets / detail sheets) reference. - // After the Slice B cutover their "loaded screen: X" string values are used + // After the cutover their "loaded screen: X" string values are used // only as catalog keys and are NEVER emitted to Amplitude. SCREEN_VIEWED = "screen.viewed", diff --git a/src/hooks/useNavigationAnalytics.ts b/src/hooks/useNavigationAnalytics.ts index 04011b86f..1dd609ba0 100644 --- a/src/hooks/useNavigationAnalytics.ts +++ b/src/hooks/useNavigationAnalytics.ts @@ -40,7 +40,7 @@ export const useNavigationAnalytics = () => { if (previousRouteName !== currentRouteName) { // processRouteForAnalytics still resolves the route to its legacy - // "loaded screen: X" string; Slice B retargets that into a single + // "loaded screen: X" string; this change retargets that into a single // canonical `screen.viewed` event carrying { screen_name, flow } // (surface is added by the Slice-A common context). const legacyScreenEvent = processRouteForAnalytics(currentRouteName); diff --git a/src/services/analytics/core.test.ts b/src/services/analytics/core.test.ts index 209bd9be5..efc605d1d 100644 --- a/src/services/analytics/core.test.ts +++ b/src/services/analytics/core.test.ts @@ -283,7 +283,7 @@ describe("trackAppOpened (one-time connectivity snapshot)", () => { }); }); -describe("screen.viewed emission (Slice B hard cutover)", () => { +describe("screen.viewed emission (hard cutover)", () => { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires const amplitudeMock = require("@amplitude/analytics-react-native"); From 50f0d4c2800dd45e6238070ad9aa3825c3e5a0f4 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 16 Jul 2026 13:58:27 -0400 Subject: [PATCH 05/10] chore(analytics): drop unrelated files accidentally swept into this branch Removes local .claude settings and superpowers spec/plan working docs that an errant 'git add -A' committed. Untracked here only; files remain on disk. --- .claude/settings.local.json | 14 - ...ile-analytics-property-model-foundation.md | 612 ------------------ ...ile-analytics-property-model-foundation.md | 143 ---- 3 files changed, 769 deletions(-) delete mode 100644 .claude/settings.local.json delete mode 100644 docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md delete mode 100644 docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 73027a0b3..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git:*)", - "Bash(npm install:*)", - "Bash(node:*)", - "Bash(yarn add:*)", - "WebFetch(domain:www.figma.com)", - "mcp__figma__get_design_context", - "Bash(npx tsc:*)", - "Bash(npx jest:*)" - ] - } -} diff --git a/docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md b/docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md deleted file mode 100644 index 4cd741e06..000000000 --- a/docs/superpowers/plans/2026-07-14-mobile-analytics-property-model-foundation.md +++ /dev/null @@ -1,612 +0,0 @@ -# Mobile Analytics Property-Model Foundation — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps use -> checkbox (`- [ ]`) syntax. - -**Goal:** Bring freighter-mobile's Amplitude property model to the RFC -four-bucket schema (mirroring extension #2903), without renaming events. - -**Architecture:** Evolve `freighter-mobile/src/services/analytics/core.ts` in -place. Add `schema_version`, `account_id_hash`, `getSurface` -(`mobile_ios`/`mobile_android`), Identify traits, and enrich `app.opened`; -derive `account_funded` from the balances store (new `fetchedPublicKey` gate). -Two cross-platform touch-points ride along: an extension #2903 follow-up -(sticky→balances) and an RFC #23 note. - -**Tech Stack:** TypeScript, React Native, `@amplitude/analytics-react-native`, -Zustand stores, `@stellar/stellar-sdk` (`hash`), Jest (`react-native` preset). - -**Spec:** -`docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md` - -## Global Constraints - -- **No event renames** (legacy names stay; `app.opened` keeps its legacy string - `"event: App Opened"`). -- **Hard cutover, no dual-write.** `schema_version` value is the string `"2"`. -- **Never emit a raw or truncated public key.** Account identity = - `account_id_hash` = `hash(Buffer.from(publicKey,"utf8")).toString("hex")` - (lowercase hex SHA-256 of the full G-address). Must match the committed vector - `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF` → - `f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef`. -- **Omit both** `is_hardware_account` (event) and `has_hardware_wallet` - (Identify) — mobile has no hardware wallets. -- **`account_funded`**: derive from the balances store; emit only when - `fetchedPublicKey === activePublicKey`, else **omit** (never emit a misleading - `false`). -- **`wallet_count` = `allAccounts.length`.** -- **Do not enable behavioral autocapture.** Keep the existing throttle/dedupe - dispatcher and opt-out subscription. -- **Identity/`user_id`** is out of scope (owned by mobile #864). -- Conventional Commit prefixes. - -## File Structure - -| File | Change | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `src/services/analytics/core.ts` | Modify — buildCommonContext, init/Identify, trackAppOpened; add getAccountIdHash/getSurface/syncIdentifyTraits + auth-store subscription | -| `src/ducks/balances.ts` | Modify — add `fetchedPublicKey` field | -| `src/services/analytics/core.test.ts` | **Create** — no analytics tests exist yet | - -Cross-repo touch-points (separate tasks, other repos): extension `freighter` and -monorepo `wallet-eng-monorepo`. - ---- - -## Task M1: `getAccountIdHash` + test scaffolding - -**Files:** Modify `src/services/analytics/core.ts`; Create -`src/services/analytics/core.test.ts` - -**Interfaces:** Produces `getAccountIdHash(publicKey: string): string` — -memoized lowercase-hex SHA-256 of the G-address; `""` on error. - -- [ ] **Step 1: Create the test file with the shared mock header + failing - test** - -Create `src/services/analytics/core.test.ts`: - -```ts -import { getAccountIdHash } from "services/analytics/core"; - -// Mobile analytics couples to Zustand stores, the RN SDK, and device-info at -// module load — mock all of them here; individual tests set behavior. -jest.mock("@amplitude/analytics-react-native"); -jest.mock("@amplitude/experiment-react-native-client", () => ({ - Experiment: { initializeWithAmplitudeAnalytics: jest.fn(() => ({})) }, -})); -jest.mock("react-native", () => ({ Platform: { OS: "ios", Version: "17.0" } })); -jest.mock("react-native-device-info", () => ({ - getVersion: jest.fn(() => "9.9.9"), - getBuildNumber: jest.fn(() => "42"), - getBundleId: jest.fn(() => "org.stellar.freighter"), -})); -jest.mock("config/logger", () => ({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn() }, -})); -jest.mock("helpers/isEnv", () => ({ isE2ETest: false })); -jest.mock("services/analytics/constants", () => ({ - AMPLITUDE_API_KEY: "test-key", - AMPLITUDE_EXPERIMENT_DEPLOYMENT_KEY: "test-exp", - DEBUG_CONFIG: { LOG_PREFIX: "Analytics" }, - TIMING: { THROTTLE_DELAY_MS: 500 }, - ANALYTICS_CONFIG: { - INCLUDE_COMMON_CONTEXT: true, - THROTTLE_DUPLICATE_EVENTS: false, // disable throttle in tests for determinism - BUNDLE_ID_KEY: "Bundle Id", - }, -})); -jest.mock("ducks/analytics", () => ({ - useAnalyticsStore: { - getState: jest.fn(() => ({ isEnabled: true })), - subscribe: jest.fn(), - }, -})); -jest.mock("ducks/auth", () => ({ - useAuthenticationStore: { - getState: jest.fn(() => ({ - network: "testnet", - account: null, - allAccounts: [], - })), - subscribe: jest.fn(), - }, -})); -jest.mock("ducks/networkInfo", () => ({ - useNetworkStore: { - getState: jest.fn(() => ({ connectionType: "wifi", effectiveType: "4g" })), - }, -})); -jest.mock("ducks/balances", () => ({ - useBalancesStore: { - getState: jest.fn(() => ({ isFunded: false, fetchedPublicKey: null })), - }, -})); - -describe("getAccountIdHash", () => { - const PK = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - const EXPECTED = - "f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef"; - - it("returns the lowercase hex SHA-256 of the G-address (cross-platform vector)", () => { - expect(getAccountIdHash(PK)).toBe(EXPECTED); - }); - it("is deterministic, 64 hex chars, and differs per key", () => { - expect(getAccountIdHash(PK)).toBe(getAccountIdHash(PK)); - expect(getAccountIdHash(PK)).toMatch(/^[0-9a-f]{64}$/); - expect(getAccountIdHash("GABC")).not.toBe(getAccountIdHash("GXYZ")); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `yarn jest src/services/analytics/core.test.ts -t getAccountIdHash` -Expected: FAIL — `getAccountIdHash is not a function`. - -- [ ] **Step 3: Implement in `core.ts`** - -Add import near the top: `import { hash } from "@stellar/stellar-sdk";` - -Add in the CORE TRACKING region (before `buildCommonContext`): - -```ts -/** - * Cross-platform account identifier: lowercase-hex SHA-256 of the full - * G-address string. Memoized; never emits a raw/truncated key. Must match the - * extension's value for the same address. - */ -const accountIdHashCache = new Map(); -export const getAccountIdHash = (publicKey: string): string => { - const cached = accountIdHashCache.get(publicKey); - if (cached) return cached; - try { - const digest = hash(Buffer.from(publicKey, "utf8")).toString("hex"); - accountIdHashCache.set(publicKey, digest); - return digest; - } catch { - return ""; - } -}; -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `yarn jest src/services/analytics/core.test.ts -t getAccountIdHash` → PASS -(2 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/services/analytics/core.ts src/services/analytics/core.test.ts -git commit -m "feat(analytics): add memoized account_id_hash helper" -``` - ---- - -## Task M2: `getSurface()` - -**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** Produces -`type Surface = "mobile_ios" | "mobile_android"`; `getSurface(): Surface`. - -- [ ] **Step 1: Failing test** — add to `core.test.ts`: - -```ts -import { getSurface } from "services/analytics/core"; - -describe("getSurface", () => { - it("maps Platform.OS to the RFC surface value", () => { - // react-native mock has Platform.OS = "ios" - expect(getSurface()).toBe("mobile_ios"); - }); -}); -``` - -- [ ] **Step 2: Run** — - `yarn jest src/services/analytics/core.test.ts -t getSurface` → FAIL. - -- [ ] **Step 3: Implement** — `core.ts` already imports - `{ Platform } from "react-native"`. Add: - -```ts -export type Surface = "mobile_ios" | "mobile_android"; -export const getSurface = (): Surface => - Platform.OS === "ios" ? "mobile_ios" : "mobile_android"; -``` - -- [ ] **Step 4: Run** → PASS. - -- [ ] **Step 5: Commit** — `feat(analytics): add mobile surface helper` - ---- - -## Task M3: balances store `fetchedPublicKey` - -**Files:** Modify `src/ducks/balances.ts`; Test `src/ducks/balances.test.ts` -(extend if present, else create a focused test) **Interfaces:** Produces -`BalancesState.fetchedPublicKey: string | null`, set to the fetched key when -`fetchAccountBalances` succeeds. - -- [ ] **Step 1: Failing test** — assert that after a successful - `fetchAccountBalances({publicKey})`, the store's `fetchedPublicKey` equals - that publicKey. (Mock `fetchBalances` to resolve - `{balances:{}, isFunded:false, subentryCount:0}`.) If mocking the fetch - chain is heavy, instead assert the reducer-level `set` includes - `fetchedPublicKey` by unit-testing a small extracted setter — but prefer - the store-level test. - -```ts -// src/ducks/balances.test.ts (illustrative; adapt mocks to existing patterns) -it("records fetchedPublicKey after a successful fetch", async () => { - // arrange: mock fetchBalances to resolve empty/funded=false - await useBalancesStore - .getState() - .fetchAccountBalances({ publicKey: "G_TEST", network: NETWORKS.TESTNET }); - expect(useBalancesStore.getState().fetchedPublicKey).toBe("G_TEST"); -}); -``` - -- [ ] **Step 2: Run** → FAIL (`fetchedPublicKey` undefined). - -- [ ] **Step 3: Implement** — in `src/ducks/balances.ts`: - - - Add to `interface BalancesState`: `fetchedPublicKey: string | null;` - - Add to the store's initial state: `fetchedPublicKey: null,` - - In `fetchAccountBalances`, in the - `set({ balances, isFunded, subentryCount })` call after a successful fetch, - add `fetchedPublicKey: params.publicKey`. - -- [ ] **Step 4: Run** → PASS. - -- [ ] **Step 5: Commit** — - `feat(balances): record fetchedPublicKey for the active-account funded signal` - ---- - -## Task M4: reshape `buildCommonContext` (four buckets) - -**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** Consumes -`getAccountIdHash` (M1), `getSurface` (M2), `fetchedPublicKey` (M3). Produces -exported `buildCommonContext(): Record`. - -**Verify at impl:** confirm `ActiveAccount` exposes `publicKey` (it does — used -today) and `importedFromSecretKey`; if the active `account` object lacks -`importedFromSecretKey`, derive `account_type` from -`allAccounts.find(a => a.publicKey === activePublicKey)?.importedFromSecretKey`. - -- [ ] **Step 1: Failing tests** — add to `core.test.ts`: - -```ts -import { useAuthenticationStore } from "ducks/auth"; -import { useBalancesStore } from "ducks/balances"; -import { buildCommonContext } from "services/analytics/core"; - -describe("buildCommonContext (four-bucket model)", () => { - const PK = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - beforeEach(() => { - (useAuthenticationStore.getState as jest.Mock).mockReturnValue({ - network: "testnet", - account: { publicKey: PK, importedFromSecretKey: true }, - allAccounts: [{ publicKey: PK, importedFromSecretKey: true }], - }); - (useBalancesStore.getState as jest.Mock).mockReturnValue({ - isFunded: true, - fetchedPublicKey: PK, - }); - }); - - it("emits the reshaped bucket", () => { - expect(buildCommonContext()).toMatchObject({ - schema_version: "2", - surface: "mobile_ios", - network: "TESTNET", - account_type: "imported_secret_key", - account_funded: true, - account_id_hash: - "f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef", - }); - }); - - it("drops SDK/legacy fields and never emits is_hardware_account or a public key", () => { - const ctx = buildCommonContext(); - [ - "publicKey", - "platform", - "platformVersion", - "appVersion", - "buildVersion", - "bundleId", - "connectionType", - "effectiveType", - "is_hardware_account", - ].forEach((k) => expect(ctx).not.toHaveProperty(k)); - expect(JSON.stringify(ctx)).not.toContain(PK); - }); - - it("omits account fields pre-unlock (no active account)", () => { - (useAuthenticationStore.getState as jest.Mock).mockReturnValue({ - network: "testnet", - account: null, - allAccounts: [], - }); - const ctx = buildCommonContext(); - ["account_id_hash", "account_type", "account_funded"].forEach((k) => - expect(ctx).not.toHaveProperty(k), - ); - expect(ctx).toMatchObject({ schema_version: "2", network: "TESTNET" }); - }); - - it("omits account_funded when balances are for a different/unfetched account", () => { - (useBalancesStore.getState as jest.Mock).mockReturnValue({ - isFunded: true, - fetchedPublicKey: "G_OTHER", - }); - expect(buildCommonContext()).not.toHaveProperty("account_funded"); - }); -}); -``` - -- [ ] **Step 2: Run** → FAIL (shape mismatch / not exported). - -- [ ] **Step 3: Implement** — replace `buildCommonContext` in `core.ts`: - -```ts -export const SCHEMA_VERSION = "2"; - -/** - * Event-level volatile bucket + schema_version. Durable traits live in Identify; - * device/app metadata comes from the RN SDK; connectivity is on app.opened. - */ -export const buildCommonContext = (): Record => { - const { network, account, allAccounts } = useAuthenticationStore.getState(); - const activePublicKey = account?.publicKey; - - const context: Record = { - schema_version: SCHEMA_VERSION, - surface: getSurface(), - network: network.toUpperCase(), - }; - - if (activePublicKey) { - const idHash = getAccountIdHash(activePublicKey); - if (idHash) context.account_id_hash = idHash; - - const isImported = - account?.importedFromSecretKey ?? - allAccounts.find((a) => a.publicKey === activePublicKey) - ?.importedFromSecretKey ?? - false; - context.account_type = isImported ? "imported_secret_key" : "freighter"; - - const { isFunded, fetchedPublicKey } = useBalancesStore.getState(); - if (fetchedPublicKey === activePublicKey) { - context.account_funded = isFunded; - } - } - - return context; -}; -``` - -Add `import { useBalancesStore } from "ducks/balances";`. Remove now-unused -imports if any (`truncateAddress`, `getVersion`, `getBuildNumber`, `getBundleId` -may still be used by trackAppOpened/Identify — keep those actually used; remove -`truncateAddress` if buildCommonContext was its only user — verify with grep). - -- [ ] **Step 3b: Dead-import check** — - `grep -n "truncateAddress\|getBuildNumber\|Platform.Version" src/services/analytics/core.ts`; - remove imports with no remaining references. - -- [ ] **Step 4: Run** → PASS (4 tests). - -- [ ] **Step 5: Commit** — - `refactor(analytics): reshape common context into RFC four-bucket model` - ---- - -## Task M5: Identify traits (consent-gated) + auth-store subscription - -**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** Produces -`deriveIdentifyTraits(allAccounts)`, `syncIdentifyTraits(allAccounts)`. - -- [ ] **Step 1: Failing tests** — add to `core.test.ts`: - -```ts -import { deriveIdentifyTraits } from "services/analytics/core"; - -describe("deriveIdentifyTraits", () => { - it("counts accounts and detects imported presence; no hardware trait", () => { - const accounts = [ - { publicKey: "G1", importedFromSecretKey: false }, - { publicKey: "G2", importedFromSecretKey: true }, - ] as never; - const t = deriveIdentifyTraits(accounts); - expect(t).toEqual({ wallet_count: 2, has_imported_account: true }); - expect(t).not.toHaveProperty("has_hardware_wallet"); - }); -}); -``` - -(Consent-gating regression — mirror the extension: a call while -`isEnabled=false` must not send Identify or cache; after enabling, the same -traits send. Use `jest.isolateModules` + toggling the -`useAnalyticsStore.getState` mock, asserting `amplitude.identify`.) - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** — in `core.ts`, replace `setAmplitudeUserProperties` - usage with trait-syncing: - -```ts -export const deriveIdentifyTraits = ( - allAccounts: { importedFromSecretKey?: boolean }[], -) => ({ - wallet_count: allAccounts.length, - has_imported_account: allAccounts.some((a) => a.importedFromSecretKey), -}); - -let lastIdentifiedTraits: string | null = null; - -export const syncIdentifyTraits = ( - allAccounts: { importedFromSecretKey?: boolean }[], -): void => { - const traits = deriveIdentifyTraits(allAccounts); - const fingerprint = JSON.stringify(traits); - if (fingerprint === lastIdentifiedTraits) return; - if (!AMPLITUDE_API_KEY || !hasInitialised) return; - // Consent gate: don't cache unless the Identify can actually be sent, so - // traits re-sync after consent hydrates (mirrors extension). - if (!useAnalyticsStore.getState().isEnabled) return; - lastIdentifiedTraits = fingerprint; - - const identify = new amplitude.Identify(); - identify.set(ANALYTICS_CONFIG.BUNDLE_ID_KEY, getBundleId()); - identify.set("wallet_count", traits.wallet_count); - identify.set("has_imported_account", traits.has_imported_account); - amplitude.identify(identify); -}; -``` - -- In `initAnalytics`, replace the `setAmplitudeUserProperties();` call with - `syncIdentifyTraits(useAuthenticationStore.getState().allAccounts);` (and - delete the old `setAmplitudeUserProperties` function, which only set - bundle_id). Add `import { useAuthenticationStore } from "ducks/auth";` if not - already present. -- Add an auth-store subscription (near the existing analytics-store subscription - at the bottom of the file): - -```ts -useAuthenticationStore.subscribe((state) => { - syncIdentifyTraits(state.allAccounts); -}); -``` - -- [ ] **Step 4: Run** → PASS. - -- [ ] **Step 5: Commit** — - `feat(analytics): send durable wallet traits via Identify (consent-gated)` - ---- - -## Task M6: enrich `app.opened` snapshot - -**Files:** Modify `core.ts`; Test `core.test.ts` **Interfaces:** -`trackAppOpened(props?)` now attaches the one-time snapshot. - -- [ ] **Step 1: Failing test** — assert - `trackAppOpened({previousState:"background"})` results in an - `amplitude.track("event: App Opened", ...)` call whose payload includes - `connection_type`, `effective_type`, `surface`, and (via common context) - `schema_version`. (Set `hasInitialised` true via `initAnalytics`, - `isEnabled` true; use `AnalyticsEvent.APP_OPENED`.) - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** — replace `trackAppOpened`: - -```ts -export const trackAppOpened = (props?: { previousState: string }): void => { - const { connectionType, effectiveType } = useNetworkStore.getState(); - track(AnalyticsEvent.APP_OPENED, { - ...props, - surface: getSurface(), - connection_type: connectionType ?? "unknown", - ...(effectiveType ? { effective_type: effectiveType } : {}), - }); -}; -``` - -(`network`, `schema_version`, and account fields arrive via `buildCommonContext` -inside `dispatchUnthrottled`.) No change needed at the -`useAnalyticsPermissions.ts` call sites. - -- [ ] **Step 4: Run** → PASS. - -- [ ] **Step 5: Full-file test + typecheck**: - `yarn jest src/services/analytics/core.test.ts` (all pass); - `yarn tsc --noEmit` (or repo's type-check) clean. - -- [ ] **Step 6: Commit** — - `feat(analytics): enrich app.opened with one-time connectivity snapshot` - ---- - -## Task M7: privacy guard test - -**Files:** Test `core.test.ts` - -- [ ] **Step 1** — add a guard test: with an active account, - `buildCommonContext()` serialized contains no raw/truncated public key and - no `publicKey` property. Run → PASS (behavior from M4). -- [ ] **Step 2: Commit** — - `test(analytics): guard against raw public keys in payloads` - ---- - -## Task X1: extension #2903 follow-up — `account_funded` from balances - -**Repo:** `/Users/piyal/Stellar/freighter` — branch -`feat/analytics-schema-foundation-spec` (PR #2903). **Files:** Modify -`extension/src/helpers/metrics.ts`; `extension/src/helpers/metrics.test.ts`. - -- [ ] **Step 1: Failing test** — in `metrics.test.ts`, mock `balancesSelector` - from `popup/ducks/cache` to return `{ [PK]: { isFunded: true } }`; assert - `buildCommonContext` emits `account_funded: true` from the balances cache - (not the sticky flag), and omits `account_funded` when there's no cached - entry for the active key. - -- [ ] **Step 2: Run** → FAIL. - -- [ ] **Step 3: Implement** — in `buildCommonContext`, replace the sticky - `accountFundedByType[...]` derivation with a read from the balances cache: - `const cached = balancesSelector(state)[activePublicKey]; if (cached) context.account_funded = cached.isFunded;` - (omit when no cached entry). Add the `balancesSelector` import from - `popup/ducks/cache`. Leave the `freighterAccountFunded` milestone event - and `storeBalanceMetricData` intact (they still power that one-time - event); only `account_funded`'s source changes. - -- [ ] **Step 4: Run** — `yarn jest extension/src/helpers/metrics.test.ts` (all - pass) + tsc clean. - -- [ ] **Step 5: Commit + push** — - `fix(analytics): derive account_funded from balances, not the sticky flag` - → push (updates PR #2903). - ---- - -## Task X2: RFC #23 note — pin the `account_funded` contract - -**Repo:** `/Users/piyal/Stellar/wallet-eng-monorepo` — branch -`piyal/analytics-rfc-addendum` (PR #23). **Files:** Modify -`analytics-refactor-report.md`. - -- [ ] **Step 1** — amend the `account_funded` row (Part 3 core properties) to: - "Active-account funded state derived from the account's live/cached - balance; omitted when the balance is not yet loaded for the active - account. Not a sticky per-type flag." Commit + push (updates PR #23). No - test. - ---- - -## Verification (end of plan) - -- [ ] `yarn jest src/services/analytics/core.test.ts` (mobile) — all pass; - typecheck clean. -- [ ] Extension `metrics.test.ts` still green after X1. -- [ ] Manual (mobile): with analytics enabled on a funded testnet account, - confirm events carry `schema_version:"2"`, `surface:"mobile_ios/android"`, - `network`, `account_id_hash`, `account_type`, `account_funded`; never - `platform`/`publicKey`/`is_hardware_account`; `app.opened` carries the - connectivity snapshot; Identify shows - `wallet_count`/`has_imported_account` (no `has_hardware_wallet`). - -## Open items - -- [ ] Confirm which of `platform`/`os`/`app_version` the RN SDK auto-attaches; - if `app_version` is missing, pass it in `amplitude.init`. -- [ ] Confirm `ActiveAccount` exposes `importedFromSecretKey` (else derive - `account_type` from `allAccounts`). -- [ ] Mobile `account_id_hash` reproduces the committed vector (shared with - extension). diff --git a/docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md b/docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md deleted file mode 100644 index eeae42477..000000000 --- a/docs/superpowers/specs/2026-07-14-mobile-analytics-property-model-foundation.md +++ /dev/null @@ -1,143 +0,0 @@ -# Mobile Analytics Property-Model Foundation (Design Spec) - -**Status:** Approved design, pre-implementation. **Local working artifact — not -committed** (the canonical schema lives in the RFC; product repos carry code, -not process docs). **Date:** 2026-07-14 **Owner:** piyal **Canonical schema -(RFC):** stellar/wallet-eng-monorepo#10 (+ addendum PR #23) **Extension -counterpart (shipped):** stellar/freighter#2903 **Identity primitive -(dependency, separate):** mobile `#864` (auth-keypair derivation; companion to -extension #2876) - ---- - -## 1. Context - -The mobile side of the cross-platform Amplitude refactor. Mirrors the extension -property-model foundation (#2903): align mobile's Amplitude property model to -the RFC four-bucket schema **without renaming events**. -`freighter-mobile/src/services/analytics/core.ts` is a near-1:1 analog of the -extension's `metrics.ts` — same over-chatty `buildCommonContext`, same opt-out -store subscription, `app.opened` + Experiment client already present. Evolve it -in place (no rewrite). - -Mobile uses the **same single-mnemonic HD model** as the extension (flat -`Account = { id, name, publicKey, importedFromSecretKey? }`, one recovery -phrase, no per-seed-phrase grouping; "wallets" in the UI = accounts). It has -**no hardware wallets** and **no sticky funding model**. - -## 2. Scope - -**In scope** (global property-model changes in `core.ts`): - -1. `schema_version: "2"` on every event. -2. `account_id_hash` (SHA-256 hex of the full G-address, memoized) replacing the - truncated `publicKey`. -3. Drop hand-sent SDK-supplied fields (`platform`, `platformVersion`, - `appVersion`, `buildVersion`, `bundleId`); rely on the RN SDK's built-in - enrichment. -4. Durable traits → Amplitude Identify; volatile context stays event-level. -5. `getSurface()` (`mobile_ios`/`mobile_android`) + enrich `app.opened` with the - one-time snapshot. - -**Out of scope:** event renames (deferred to mobile's later slices); -identity/`user_id` (owned by #864). - -**Cross-platform touch-points carried by this effort** (from the -`account_funded` decision): - -- **Extension follow-up** — a commit on PR #2903 swapping `account_funded` from - the sticky `metricsData` flag to the balances selector. -- **RFC addendum** — a note on PR #23 pinning the `account_funded` contract. - -## 3. Key decisions - -| Decision | Choice | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Migration | Hard cutover, no dual-write (per RFC addendum). Names stay legacy this slice. | -| `account_id_hash` | `hash(Buffer.from(publicKey,"utf8")).toString("hex")` (stellar-sdk), memoized. Must match the committed vector `G…AWHF → f56f6f2c…44ef`. | -| `surface` | `mobile_ios` / `mobile_android` from `Platform.OS` (synchronous). | -| `account_funded` | Derived from the active account's **cached balance** (`useBalancesStore.getState().isFunded`); tri-state — **omit when balance not yet loaded**. Never a sticky flag. | -| `account_type` | `freighter` \| `imported_secret_key` (from `importedFromSecretKey`); emitted only with an active account. | -| Hardware wallet | **Omit both** `is_hardware_account` (event) and `has_hardware_wallet` (Identify) — mobile has no hardware-wallet concept. | -| `wallet_count` | `allAccounts.length` (consistent with extension). | -| Identity | Out of scope; `user_id` stays as-is (owned by #864). | - -## 4. The four buckets (`buildCommonContext`) - -**Bucket 1 — SDK-supplied (stop hand-sending):** delete `platform`, -`platformVersion`, `appVersion`, `buildVersion`, `bundleId`. Rely on the -`@amplitude/analytics-react-native` context enrichment; pass `appVersion` in -`amplitude.init` if the RN SDK doesn't attach it automatically (confirm at -impl). Do **not** enable behavioral autocapture. - -**Bucket 2 — event-level volatile:** `network` (kept), `surface` (new, -`getSurface()`), `schema_version: "2"`, and — only with an active account — -`account_id_hash`, `account_type`, `account_funded` (omit when balance unknown). -**No** `is_hardware_account`. **Removed:** `publicKey`, `connectionType`, -`effectiveType`. - -**Bucket 3 — Identify user traits** (see §5). - -**Bucket 4 — one-time `app.opened` snapshot** (see §6): `surface`, `network`, -`connection_type`, `effective_type`, `schema_version`. - -## 5. Identity traits - -Extend `setAmplitudeUserProperties`: - -- `bundle_id` (existing), `wallet_count` = `allAccounts.length`, - `has_imported_account` = any account `importedFromSecretKey`. -- **Omit** `has_hardware_wallet`. -- Dirty-checked (only when a trait changes) **and consent-gated** — do not cache - the fingerprint unless `isEnabled` (mirrors the extension fix so traits - re-sync after consent hydrates). -- Re-sync (`syncIdentifyTraits`) when the account list changes — wire to the - auth store (`allAccounts`). - -## 6. `app.opened` - -Enrich the existing foreground-triggered `trackAppOpened` (legacy name -`"event: App Opened"` — unchanged) with the snapshot: `surface`, `network`, -`connection_type`, `effective_type`, `schema_version` (keep `previousState`). -Move `connection_type`/`effective_type` off `buildCommonContext` onto this -event. Fires on foreground (post store-hydration), so no init-timing consent -bug. - -## 7. `account_id_hash` - -`getAccountIdHash(publicKey)` — synchronous SHA-256 hex of the full G-address -via stellar-sdk `hash`, memoized per key; omit when no active key. -Cross-platform contract: identical to extension (committed vector). Never emit a -raw/truncated key. - -## 8. Error handling & edge cases - -- Pre-unlock / no active key → omit `account_id_hash`, `account_type`, - `account_funded`. -- Balance not loaded → omit `account_funded` (not `false`). -- Consent off → no `track`, no `identify`, no fingerprint cache. -- Missing API key / not initialized → existing guarded no-op path preserved. -- Keep the existing throttle/dedupe dispatcher. - -## 9. Testing - -Mirror the extension's `metrics.test.ts`: - -- `buildCommonContext` drops SDK/legacy fields; emits `schema_version`, - `surface`, `network`, and (with active key) - `account_id_hash`/`account_type`/`account_funded`; never - `is_hardware_account`, `has_hardware_wallet`, `publicKey`. -- `account_id_hash` matches the committed cross-platform vector. -- `account_funded` omitted when balance unknown; true/false when loaded. -- Identify emits `wallet_count`/`has_imported_account`/`bundle_id`, not - `has_hardware_wallet`; consent-gated (no send/cache while opted out; re-sync - after opt-in). -- `app.opened` carries the snapshot; connectivity fields absent from other - events. -- Privacy guard: no raw/truncated public key in any payload. - -## 10. Open items to confirm - -- Which of `platform`/`os`/`app_version` the RN SDK auto-attaches (drop vs pass - in `init`) — resolve at impl. -- Mobile reproduces the `account_id_hash` vector (shared with extension). From b20a05206bfd44313794a8d58efd9c49f9ec0218 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 17 Jul 2026 13:23:12 -0400 Subject: [PATCH 06/10] refactor(analytics): declare screen_name explicitly for named screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named VIEW_* screens now declare their screen_name in SCREEN_CATALOG (renamed from SCREEN_METADATA) instead of deriving it from the mutable display string at emit time — decoupling the analytics id from the UI copy. deriveScreenName stays only as the fallback for auto-mapped routes (transformRouteToEventName) not in the catalog, so the long tail still emits a non-empty screen_name. Values are unchanged; pure refactor. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/config/analyticsConfig.test.ts | 8 +- src/config/analyticsConfig.ts | 344 +++++++++++++++-------- 2 files changed, 235 insertions(+), 117 deletions(-) diff --git a/__tests__/config/analyticsConfig.test.ts b/__tests__/config/analyticsConfig.test.ts index d18e11d3b..4445e95a3 100644 --- a/__tests__/config/analyticsConfig.test.ts +++ b/__tests__/config/analyticsConfig.test.ts @@ -129,7 +129,7 @@ describe("Analytics Configuration", () => { }); describe("buildScreenViewedProps", () => { - it("derives screen_name and assigns the screen's flow", () => { + it("uses the catalogued screen_name and assigns the screen's flow", () => { expect(buildScreenViewedProps(AnalyticsEvent.VIEW_SEND_AMOUNT)).toEqual( { screen_name: "send_payment_amount", @@ -166,9 +166,9 @@ describe("Analytics Configuration", () => { }); }); - it("omits flow (and step) for a screen with no catalogued flow", () => { - // An unmapped legacy string still emits screen.viewed with a derived - // name but no flow. + it("falls back to a derived name (no flow) for an uncatalogued route", () => { + // Auto-mapped routes (transformRouteToEventName) not in SCREEN_CATALOG + // still emit screen.viewed with a derived name but carry no flow. expect( buildScreenViewedProps("loaded screen: some future screen"), ).toEqual({ screen_name: "some_future_screen" }); diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 80ead9bd1..0686d931e 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -242,7 +242,8 @@ export enum AnalyticsFlow { /** * Property bag carried by the single canonical `screen.viewed` event. * - * - `screen_name`: deterministic, cross-platform slug (see deriveScreenName). + * - `screen_name`: canonical, cross-platform id — declared in SCREEN_CATALOG + * for named screens, derived (deriveScreenName) for auto-mapped routes. * - `flow`: best-fit user journey (see AnalyticsFlow); omitted when none fits. * - `step`: sub-step marker for screens that are a stage within a flow * (e.g. a confirmation or processing screen) rather than a distinct @@ -264,112 +265,229 @@ export type ScreenViewedProps = { const LEGACY_SCREEN_PREFIX = "loaded screen: "; /** - * Per-screen flow (and optional step) assignment, keyed by the legacy - * "loaded screen: X" string. `screen_name` is NOT stored here - it is derived - * mechanically from the key so both platforms align automatically. Screens - * absent from this map still emit `screen.viewed` (with a derived - * `screen_name`) but carry no `flow`. + * Per-screen catalog for named screens: the canonical `screen_name` + * (declared explicitly so the analytics id is decoupled from the mutable + * display string) plus its `flow` and optional `step`. Keyed by the legacy + * "loaded screen: X" string. Routes NOT listed here (auto-mapped via + * transformRouteToEventName) fall back to a derived `screen_name` and carry + * no `flow` — see buildScreenViewedProps / deriveScreenName. */ -const SCREEN_METADATA: Record = - { - // Onboarding / account creation - [AnalyticsEvent.VIEW_WELCOME]: { flow: AnalyticsFlow.ONBOARDING }, - [AnalyticsEvent.VIEW_CHOOSE_PASSWORD]: { flow: AnalyticsFlow.ONBOARDING }, - [AnalyticsEvent.VIEW_RECOVERY_PHRASE_ALERT]: { - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_RECOVERY_PHRASE]: { flow: AnalyticsFlow.ONBOARDING }, - [AnalyticsEvent.VIEW_VALIDATE_RECOVERY_PHRASE]: { - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_IMPORT_WALLET]: { flow: AnalyticsFlow.ONBOARDING }, - - // Security / re-auth / secret material - [AnalyticsEvent.VIEW_LOCK_SCREEN]: { flow: AnalyticsFlow.SECURITY }, - [AnalyticsEvent.VIEW_SECURITY]: { flow: AnalyticsFlow.SECURITY }, - [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { - flow: AnalyticsFlow.SECURITY, - }, - [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { flow: AnalyticsFlow.SECURITY }, - - // Home / assets - [AnalyticsEvent.VIEW_HOME]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_TOKEN_DETAILS]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_ACCOUNT_QR_CODE]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_MANAGE_TOKENS]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_ADD_TOKEN]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_REMOVE_TOKEN]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_SEARCH_TOKEN]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_ADD_TOKEN_MANUALLY]: { flow: AnalyticsFlow.ASSETS }, - [AnalyticsEvent.VIEW_BUY_XLM]: { flow: AnalyticsFlow.ASSETS }, - - // History - [AnalyticsEvent.VIEW_HISTORY]: { flow: AnalyticsFlow.HISTORY }, - - // Discovery - [AnalyticsEvent.VIEW_DISCOVERY]: { flow: AnalyticsFlow.DISCOVERY }, - - // Signing / dApp - [AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS]: { flow: AnalyticsFlow.SIGNING }, - [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION]: { - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS]: { - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS]: { - flow: AnalyticsFlow.SIGNING, - }, - - // Send payment - [AnalyticsEvent.VIEW_SEND_SEARCH_CONTACTS]: { flow: AnalyticsFlow.SEND }, - [AnalyticsEvent.VIEW_SEND_AMOUNT]: { flow: AnalyticsFlow.SEND }, - [AnalyticsEvent.VIEW_SEND_MEMO]: { flow: AnalyticsFlow.SEND }, - [AnalyticsEvent.VIEW_SEND_FEE]: { flow: AnalyticsFlow.SEND }, - [AnalyticsEvent.VIEW_SEND_TIMEOUT]: { flow: AnalyticsFlow.SEND }, - [AnalyticsEvent.VIEW_SEND_CONFIRM]: { - flow: AnalyticsFlow.SEND, - step: "confirm", - }, - [AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS]: { - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_PROCESSING]: { - flow: AnalyticsFlow.SEND, - step: "processing", - }, - - // Swap - [AnalyticsEvent.VIEW_SWAP]: { flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_AMOUNT]: { flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_FEE]: { flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_SLIPPAGE]: { flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_TIMEOUT]: { flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_SETTINGS]: { flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_CONFIRM]: { - flow: AnalyticsFlow.SWAP, - step: "confirm", - }, - [AnalyticsEvent.VIEW_SWAP_TRANSACTION_DETAILS]: { - flow: AnalyticsFlow.SWAP, - }, - - // Settings - [AnalyticsEvent.VIEW_SETTINGS]: { flow: AnalyticsFlow.SETTINGS }, - [AnalyticsEvent.VIEW_PREFERENCES]: { flow: AnalyticsFlow.SETTINGS }, - [AnalyticsEvent.VIEW_CHANGE_NETWORK]: { flow: AnalyticsFlow.SETTINGS }, - [AnalyticsEvent.VIEW_NETWORK_SETTINGS]: { flow: AnalyticsFlow.SETTINGS }, - [AnalyticsEvent.VIEW_SHARE_FEEDBACK]: { flow: AnalyticsFlow.SETTINGS }, - [AnalyticsEvent.VIEW_ABOUT]: { flow: AnalyticsFlow.SETTINGS }, - [AnalyticsEvent.VIEW_MANAGE_CONNECTED_APPS]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { flow: AnalyticsFlow.SETTINGS }, - }; +const SCREEN_CATALOG: Record< + string, + { screen_name: string; flow?: AnalyticsFlow; step?: string } +> = { + // Onboarding / account creation + [AnalyticsEvent.VIEW_WELCOME]: { + screen_name: "welcome", + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_CHOOSE_PASSWORD]: { + screen_name: "account_creator", + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE_ALERT]: { + screen_name: "mnemonic_phrase_alert", + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE]: { + screen_name: "mnemonic_phrase", + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_VALIDATE_RECOVERY_PHRASE]: { + screen_name: "confirm_mnemonic_phrase", + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_IMPORT_WALLET]: { + screen_name: "recover_account", + flow: AnalyticsFlow.ONBOARDING, + }, + // Security / re-auth / secret material + [AnalyticsEvent.VIEW_LOCK_SCREEN]: { + screen_name: "unlock_account", + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_SECURITY]: { + screen_name: "security", + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { + screen_name: "show_recovery_phrase", + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { + screen_name: "import_secret_key", + flow: AnalyticsFlow.SECURITY, + }, + // Home / assets + [AnalyticsEvent.VIEW_HOME]: { + screen_name: "account", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_TOKEN_DETAILS]: { + screen_name: "asset_detail", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ACCOUNT_QR_CODE]: { + screen_name: "view_public_key_generator", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_MANAGE_TOKENS]: { + screen_name: "manage_assets", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ADD_TOKEN]: { + screen_name: "add_asset", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_REMOVE_TOKEN]: { + screen_name: "remove_asset", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_SEARCH_TOKEN]: { + screen_name: "search_asset", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ADD_TOKEN_MANUALLY]: { + screen_name: "add_asset_manually", + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_BUY_XLM]: { + screen_name: "add_fund", + flow: AnalyticsFlow.ASSETS, + }, + // History + [AnalyticsEvent.VIEW_HISTORY]: { + screen_name: "account_history", + flow: AnalyticsFlow.HISTORY, + }, + // Discovery + [AnalyticsEvent.VIEW_DISCOVERY]: { + screen_name: "discover", + flow: AnalyticsFlow.DISCOVERY, + }, + // Signing / dApp + [AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS]: { + screen_name: "grant_access", + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION]: { + screen_name: "sign_transaction", + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS]: { + screen_name: "sign_transaction_details", + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS]: { + screen_name: "sign_auth_entry_details", + flow: AnalyticsFlow.SIGNING, + }, + // Send payment + [AnalyticsEvent.VIEW_SEND_SEARCH_CONTACTS]: { + screen_name: "send_payment_to", + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_AMOUNT]: { + screen_name: "send_payment_amount", + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_MEMO]: { + screen_name: "send_payment_settings", + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_FEE]: { + screen_name: "send_payment_fee", + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_TIMEOUT]: { + screen_name: "send_payment_timeout", + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_CONFIRM]: { + screen_name: "send_payment_confirm", + flow: AnalyticsFlow.SEND, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS]: { + screen_name: "send_transaction_details", + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_PROCESSING]: { + screen_name: "send_payment_processing", + flow: AnalyticsFlow.SEND, + step: "processing", + }, + // Swap + [AnalyticsEvent.VIEW_SWAP]: { screen_name: "swap", flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_AMOUNT]: { + screen_name: "swap_amount", + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_FEE]: { + screen_name: "swap_fee", + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_SLIPPAGE]: { + screen_name: "swap_slippage", + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_TIMEOUT]: { + screen_name: "swap_timeout", + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_SETTINGS]: { + screen_name: "swap_settings", + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_CONFIRM]: { + screen_name: "swap_confirm", + flow: AnalyticsFlow.SWAP, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SWAP_TRANSACTION_DETAILS]: { + screen_name: "swap_transaction_details", + flow: AnalyticsFlow.SWAP, + }, + // Settings + [AnalyticsEvent.VIEW_SETTINGS]: { + screen_name: "settings", + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_PREFERENCES]: { + screen_name: "preferences", + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_CHANGE_NETWORK]: { + screen_name: "manage_network", + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_NETWORK_SETTINGS]: { + screen_name: "network_settings", + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_SHARE_FEEDBACK]: { + screen_name: "leave_feedback", + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_ABOUT]: { + screen_name: "about", + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_MANAGE_CONNECTED_APPS]: { + screen_name: "manage_connected_apps", + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { + screen_name: "manage_wallets", + flow: AnalyticsFlow.SETTINGS, + }, +}; /** - * Derives the canonical, cross-platform `screen_name` from a legacy - * "loaded screen: X" string, deterministically: + * Fallback `screen_name` derivation for routes NOT declared in SCREEN_CATALOG + * (auto-mapped via transformRouteToEventName). Named screens declare their + * `screen_name` explicitly in the catalog; this only runs for the auto-mapped + * long tail. Deterministic transform of a legacy "loaded screen: X" string: * 1. strip the "loaded screen: " prefix * 2. trim * 3. lowercase @@ -377,9 +495,6 @@ const SCREEN_METADATA: Record = * * e.g. "loaded screen: send payment amount" -> "send_payment_amount" * "loaded screen: account" -> "account" - * - * Both platforms use identical legacy strings, so `screen_name` aligns - * cross-platform automatically. */ export const deriveScreenName = (legacyEvent: string): string => legacyEvent @@ -398,18 +513,21 @@ export const isScreenViewEvent = (event: string): boolean => /** * Builds the `screen.viewed` property bag from a legacy "loaded screen: X" - * string: a deterministic `screen_name` plus the screen's `flow`/`step` from - * the catalog (omitted when unknown/none fits). + * string: the catalogued `screen_name` (or a derived one for auto-mapped + * routes) plus the screen's `flow`/`step` from the catalog (omitted when none). */ export const buildScreenViewedProps = ( legacyEvent: string, ): ScreenViewedProps => { - const meta = SCREEN_METADATA[legacyEvent] ?? {}; + const entry = SCREEN_CATALOG[legacyEvent]; const props: ScreenViewedProps = { - screen_name: deriveScreenName(legacyEvent), + // Catalogued screens declare their `screen_name` explicitly; auto-mapped + // routes (transformRouteToEventName) that aren't in the catalog fall back + // to a derived name so they still emit a non-empty `screen_name`. + screen_name: entry?.screen_name ?? deriveScreenName(legacyEvent), }; - if (meta.flow) props.flow = meta.flow; - if (meta.step) props.step = meta.step; + if (entry?.flow) props.flow = entry.flow; + if (entry?.step) props.step = entry.step; return props; }; From 9bb6500086fd61066a52d2d2267ae7ca1bd46b32 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Fri, 17 Jul 2026 13:56:55 -0400 Subject: [PATCH 07/10] refactor(analytics): remove the legacy "loaded screen: X" string layer Screens are now identified directly by their canonical screen_name: each VIEW_* enum member holds its screen_name as its value, the route transform (routeToScreenName, was transformRouteToEventName) yields a screen_name directly, and SCREEN_CATALOG is keyed by screen_name (holding only flow/step). Deletes deriveScreenName + LEGACY_SCREEN_PREFIX; isScreenViewEvent is now a catalog-membership check instead of a "loaded screen: " prefix sniff. No "loaded screen: X" string is emitted or used as a key anymore. Emitted screen_name values are unchanged; the 15 screen call-sites are untouched (they reference the enum members, whose values changed). Net -116 lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/config/analyticsConfig.test.ts | 75 +-- src/components/BottomSheet.tsx | 5 +- src/config/analyticsConfig.ts | 603 ++++++++++------------- src/hooks/useNavigationAnalytics.ts | 19 +- 4 files changed, 293 insertions(+), 409 deletions(-) diff --git a/__tests__/config/analyticsConfig.test.ts b/__tests__/config/analyticsConfig.test.ts index 4445e95a3..a6033c1f0 100644 --- a/__tests__/config/analyticsConfig.test.ts +++ b/__tests__/config/analyticsConfig.test.ts @@ -3,9 +3,8 @@ import { AnalyticsFlow, ROUTE_TO_ANALYTICS_EVENT_MAP, ROUTES_WITHOUT_ANALYTICS, - transformRouteToEventName, + routeToScreenName, processRouteForAnalytics, - deriveScreenName, buildScreenViewedProps, getScreenViewedProps, isScreenViewEvent, @@ -13,23 +12,15 @@ import { describe("Analytics Configuration", () => { describe("Route Transformation", () => { - it("should transform route names correctly", () => { - expect(transformRouteToEventName("WelcomeScreen")).toBe( - "loaded screen: welcome", - ); - expect(transformRouteToEventName("SettingsScreen")).toBe( - "loaded screen: settings", - ); - expect(transformRouteToEventName("SwapAmountScreen")).toBe( - "loaded screen: swap amount", - ); + it("should derive canonical screen_names from route names", () => { + expect(routeToScreenName("WelcomeScreen")).toBe("welcome"); + expect(routeToScreenName("SettingsScreen")).toBe("settings"); + expect(routeToScreenName("SwapAmountScreen")).toBe("swap_amount"); }); it("should handle routes without Screen suffix", () => { - expect(transformRouteToEventName("Home")).toBe("loaded screen: home"); - expect(transformRouteToEventName("History")).toBe( - "loaded screen: history", - ); + expect(routeToScreenName("Home")).toBe("home"); + expect(routeToScreenName("History")).toBe("history"); }); }); @@ -88,38 +79,24 @@ describe("Analytics Configuration", () => { expect(AnalyticsEvent.SCREEN_VIEWED).toBe("screen.viewed"); }); - describe("deriveScreenName", () => { - it("derives a deterministic slug from the legacy screen string", () => { - expect(deriveScreenName("loaded screen: send payment amount")).toBe( - "send_payment_amount", - ); - expect(deriveScreenName("loaded screen: account")).toBe("account"); - expect( - deriveScreenName("loaded screen: view public key generator"), - ).toBe("view_public_key_generator"); - }); - + describe("routeToScreenName", () => { it("collapses each run of non-alphanumeric chars into a single underscore", () => { - expect(deriveScreenName("loaded screen: swap amount ")).toBe( - "swap_amount", - ); - expect(deriveScreenName("loaded screen: re-auth / details")).toBe( + expect(routeToScreenName("ReAuthDetailsScreen")).toBe( "re_auth_details", ); }); - it("is stable across the whole VIEW_* catalog (idempotent, no leading/trailing underscores)", () => { - Object.entries(AnalyticsEvent) - .filter(([key]) => key.startsWith("VIEW_")) - .forEach(([, legacy]) => { - const name = deriveScreenName(legacy); + it("yields a valid slug for every route in the map", () => { + Object.values(ROUTE_TO_ANALYTICS_EVENT_MAP) + .filter((name): name is string => name !== null) + .forEach((name) => { expect(name).toMatch(/^[a-z0-9]+(?:_[a-z0-9]+)*$/); }); }); }); describe("isScreenViewEvent", () => { - it("recognises legacy screen-load strings and rejects the rest", () => { + it("recognises catalogued screen_names and rejects the rest", () => { expect(isScreenViewEvent(AnalyticsEvent.VIEW_HOME)).toBe(true); expect(isScreenViewEvent(AnalyticsEvent.SCREEN_VIEWED)).toBe(false); expect(isScreenViewEvent(AnalyticsEvent.SEND_PAYMENT_SUCCESS)).toBe( @@ -166,19 +143,19 @@ describe("Analytics Configuration", () => { }); }); - it("falls back to a derived name (no flow) for an uncatalogued route", () => { - // Auto-mapped routes (transformRouteToEventName) not in SCREEN_CATALOG - // still emit screen.viewed with a derived name but carry no flow. + it("carries no flow for an uncatalogued (auto-mapped) route", () => { + // Auto-mapped routes not in SCREEN_CATALOG still emit screen.viewed + // with their route-derived screen_name but carry no flow. expect( - buildScreenViewedProps("loaded screen: some future screen"), - ).toEqual({ screen_name: "some_future_screen" }); + buildScreenViewedProps(routeToScreenName("SomeFutureScreen")), + ).toEqual({ screen_name: "some_future" }); }); - it("produces a valid screen_name for every VIEW_* catalog entry", () => { - Object.entries(AnalyticsEvent) - .filter(([key]) => key.startsWith("VIEW_")) - .forEach(([, legacy]) => { - const props = buildScreenViewedProps(legacy); + it("produces a valid screen_name for every catalogued screen", () => { + Object.values(AnalyticsEvent) + .filter((value) => isScreenViewEvent(value)) + .forEach((screenName) => { + const props = buildScreenViewedProps(screenName); expect(props.screen_name).toMatch(/^[a-z0-9]+(?:_[a-z0-9]+)*$/); if (props.flow) { expect(Object.values(AnalyticsFlow)).toContain(props.flow); @@ -213,8 +190,8 @@ describe("Analytics Configuration", () => { }); describe("route path feeds screen.viewed (hard cutover)", () => { - it("still resolves routes to a legacy string that maps to screen.viewed props", () => { - // processRouteForAnalytics keeps returning the legacy string; the + it("resolves routes to a screen_name that maps to screen.viewed props", () => { + // processRouteForAnalytics returns the canonical screen_name; the // navigation hook feeds it through buildScreenViewedProps. const welcome = processRouteForAnalytics("WelcomeScreen"); expect(welcome).toBe(AnalyticsEvent.VIEW_WELCOME); diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx index 3f2d27202..4df52c9d5 100644 --- a/src/components/BottomSheet.tsx +++ b/src/components/BottomSheet.tsx @@ -151,9 +151,8 @@ const BottomSheet: React.FC = ({ // index >= 0 means sheet is visible if (analyticsEvent && index >= 0 && !hasTrackedRef.current) { // Screens presented via a bottom sheet are screen loads too: retarget - // any legacy "loaded screen: X" analyticsEvent to the single canonical - // `screen.viewed` event. Non-screen events pass through - // unchanged. + // any catalogued screen-view analyticsEvent to the single canonical + // `screen.viewed` event. Non-screen events pass through unchanged. const screenViewedProps = getScreenViewedProps(analyticsEvent); if (screenViewedProps) { track(AnalyticsEvent.SCREEN_VIEWED, { diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 0686d931e..1adac7af6 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -10,65 +10,63 @@ export enum AnalyticsEvent { // Canonical screen-view event (#2883). // // Every screen load emits THIS single event carrying { screen_name, flow, - // surface, step? } instead of a distinct "loaded screen: X" event. The - // VIEW_* members below are retained as the canonical legacy-screen-string - // catalog: `screen_name` is derived deterministically from their string - // values (see deriveScreenName), and they remain the keys that components - // firing screen views manually (bottom sheets / detail sheets) reference. - // After the cutover their "loaded screen: X" string values are used - // only as catalog keys and are NEVER emitted to Amplitude. + // surface, step? }. The VIEW_* members below hold each screen's canonical + // `screen_name` directly as their value; they are the identifiers that + // components firing screen views manually (bottom sheets / detail sheets) + // reference, and are NEVER emitted as event names themselves — they only + // populate the `screen_name` prop on `screen.viewed`. SCREEN_VIEWED = "screen.viewed", // Screen Navigation Events (Auto-generated from routes) - VIEW_WELCOME = "loaded screen: welcome", - VIEW_CHOOSE_PASSWORD = "loaded screen: account creator", - VIEW_RECOVERY_PHRASE_ALERT = "loaded screen: mnemonic phrase alert", - VIEW_RECOVERY_PHRASE = "loaded screen: mnemonic phrase", - VIEW_VALIDATE_RECOVERY_PHRASE = "loaded screen: confirm mnemonic phrase", - VIEW_IMPORT_WALLET = "loaded screen: recover account", - VIEW_LOCK_SCREEN = "loaded screen: unlock account", - VIEW_HOME = "loaded screen: account", - VIEW_HISTORY = "loaded screen: account history", - VIEW_DISCOVERY = "loaded screen: discover", - VIEW_TOKEN_DETAILS = "loaded screen: asset detail", - VIEW_ACCOUNT_QR_CODE = "loaded screen: view public key generator", - VIEW_GRANT_DAPP_ACCESS = "loaded screen: grant access", - VIEW_SIGN_DAPP_TRANSACTION = "loaded screen: sign transaction", - VIEW_SIGN_DAPP_TRANSACTION_DETAILS = "loaded screen: sign transaction details", - VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS = "loaded screen: sign auth entry details", - VIEW_SEND_SEARCH_CONTACTS = "loaded screen: send payment to", - VIEW_SEND_AMOUNT = "loaded screen: send payment amount", - VIEW_SEND_MEMO = "loaded screen: send payment settings", - VIEW_SEND_FEE = "loaded screen: send payment fee", - VIEW_SEND_TIMEOUT = "loaded screen: send payment timeout", - VIEW_SEND_CONFIRM = "loaded screen: send payment confirm", - VIEW_SEND_TRANSACTION_DETAILS = "loaded screen: send transaction details", - VIEW_SEND_PROCESSING = "loaded screen: send payment processing", - VIEW_SWAP = "loaded screen: swap", - VIEW_SWAP_AMOUNT = "loaded screen: swap amount", - VIEW_SWAP_FEE = "loaded screen: swap fee", - VIEW_SWAP_SLIPPAGE = "loaded screen: swap slippage", - VIEW_SWAP_TIMEOUT = "loaded screen: swap timeout", - VIEW_SWAP_SETTINGS = "loaded screen: swap settings", - VIEW_SWAP_CONFIRM = "loaded screen: swap confirm", - VIEW_SWAP_TRANSACTION_DETAILS = "loaded screen: swap transaction details", - VIEW_SETTINGS = "loaded screen: settings", - VIEW_PREFERENCES = "loaded screen: preferences", - VIEW_CHANGE_NETWORK = "loaded screen: manage network", - VIEW_NETWORK_SETTINGS = "loaded screen: network settings", - VIEW_SHARE_FEEDBACK = "loaded screen: leave feedback", - VIEW_ABOUT = "loaded screen: about", - VIEW_SECURITY = "loaded screen: security", - VIEW_SHOW_RECOVERY_PHRASE = "loaded screen: show recovery phrase", - VIEW_MANAGE_CONNECTED_APPS = "loaded screen: manage connected apps", - VIEW_MANAGE_TOKENS = "loaded screen: manage assets", - VIEW_ADD_TOKEN = "loaded screen: add asset", - VIEW_REMOVE_TOKEN = "loaded screen: remove asset", - VIEW_MANAGE_WALLETS = "loaded screen: manage wallets", - VIEW_IMPORT_SECRET_KEY = "loaded screen: import secret key", - VIEW_BUY_XLM = "loaded screen: add fund", - VIEW_SEARCH_TOKEN = "loaded screen: search asset", - VIEW_ADD_TOKEN_MANUALLY = "loaded screen: add asset manually", + VIEW_WELCOME = "welcome", + VIEW_CHOOSE_PASSWORD = "account_creator", + VIEW_RECOVERY_PHRASE_ALERT = "mnemonic_phrase_alert", + VIEW_RECOVERY_PHRASE = "mnemonic_phrase", + VIEW_VALIDATE_RECOVERY_PHRASE = "confirm_mnemonic_phrase", + VIEW_IMPORT_WALLET = "recover_account", + VIEW_LOCK_SCREEN = "unlock_account", + VIEW_HOME = "account", + VIEW_HISTORY = "account_history", + VIEW_DISCOVERY = "discover", + VIEW_TOKEN_DETAILS = "asset_detail", + VIEW_ACCOUNT_QR_CODE = "view_public_key_generator", + VIEW_GRANT_DAPP_ACCESS = "grant_access", + VIEW_SIGN_DAPP_TRANSACTION = "sign_transaction", + VIEW_SIGN_DAPP_TRANSACTION_DETAILS = "sign_transaction_details", + VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS = "sign_auth_entry_details", + VIEW_SEND_SEARCH_CONTACTS = "send_payment_to", + VIEW_SEND_AMOUNT = "send_payment_amount", + VIEW_SEND_MEMO = "send_payment_settings", + VIEW_SEND_FEE = "send_payment_fee", + VIEW_SEND_TIMEOUT = "send_payment_timeout", + VIEW_SEND_CONFIRM = "send_payment_confirm", + VIEW_SEND_TRANSACTION_DETAILS = "send_transaction_details", + VIEW_SEND_PROCESSING = "send_payment_processing", + VIEW_SWAP = "swap", + VIEW_SWAP_AMOUNT = "swap_amount", + VIEW_SWAP_FEE = "swap_fee", + VIEW_SWAP_SLIPPAGE = "swap_slippage", + VIEW_SWAP_TIMEOUT = "swap_timeout", + VIEW_SWAP_SETTINGS = "swap_settings", + VIEW_SWAP_CONFIRM = "swap_confirm", + VIEW_SWAP_TRANSACTION_DETAILS = "swap_transaction_details", + VIEW_SETTINGS = "settings", + VIEW_PREFERENCES = "preferences", + VIEW_CHANGE_NETWORK = "manage_network", + VIEW_NETWORK_SETTINGS = "network_settings", + VIEW_SHARE_FEEDBACK = "leave_feedback", + VIEW_ABOUT = "about", + VIEW_SECURITY = "security", + VIEW_SHOW_RECOVERY_PHRASE = "show_recovery_phrase", + VIEW_MANAGE_CONNECTED_APPS = "manage_connected_apps", + VIEW_MANAGE_TOKENS = "manage_assets", + VIEW_ADD_TOKEN = "add_asset", + VIEW_REMOVE_TOKEN = "remove_asset", + VIEW_MANAGE_WALLETS = "manage_wallets", + VIEW_IMPORT_SECRET_KEY = "import_secret_key", + VIEW_BUY_XLM = "add_fund", + VIEW_SEARCH_TOKEN = "search_asset", + VIEW_ADD_TOKEN_MANUALLY = "add_asset_manually", // User Action Events (Manual tracking) CREATE_PASSWORD_SUCCESS = "account creator: create password: success", @@ -242,8 +240,8 @@ export enum AnalyticsFlow { /** * Property bag carried by the single canonical `screen.viewed` event. * - * - `screen_name`: canonical, cross-platform id — declared in SCREEN_CATALOG - * for named screens, derived (deriveScreenName) for auto-mapped routes. + * - `screen_name`: canonical, cross-platform id — a named screen's VIEW_* + * enum value; auto-mapped routes derive it from the route name. * - `flow`: best-fit user journey (see AnalyticsFlow); omitted when none fits. * - `step`: sub-step marker for screens that are a stage within a flow * (e.g. a confirmation or processing screen) rather than a distinct @@ -262,280 +260,204 @@ export type ScreenViewedProps = { step?: string; }; -const LEGACY_SCREEN_PREFIX = "loaded screen: "; - /** - * Per-screen catalog for named screens: the canonical `screen_name` - * (declared explicitly so the analytics id is decoupled from the mutable - * display string) plus its `flow` and optional `step`. Keyed by the legacy - * "loaded screen: X" string. Routes NOT listed here (auto-mapped via - * transformRouteToEventName) fall back to a derived `screen_name` and carry - * no `flow` — see buildScreenViewedProps / deriveScreenName. + * Per-screen `flow`/`step` for named screens, keyed by the screen's canonical + * `screen_name` (its VIEW_* enum value). The `screen_name` itself is the key, + * so it isn't repeated inside. Routes NOT listed here (auto-mapped via + * routeToScreenName) still emit `screen.viewed` with their route-derived + * `screen_name` but carry no `flow` — see buildScreenViewedProps. */ -const SCREEN_CATALOG: Record< - string, - { screen_name: string; flow?: AnalyticsFlow; step?: string } -> = { - // Onboarding / account creation - [AnalyticsEvent.VIEW_WELCOME]: { - screen_name: "welcome", - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_CHOOSE_PASSWORD]: { - screen_name: "account_creator", - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_RECOVERY_PHRASE_ALERT]: { - screen_name: "mnemonic_phrase_alert", - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_RECOVERY_PHRASE]: { - screen_name: "mnemonic_phrase", - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_VALIDATE_RECOVERY_PHRASE]: { - screen_name: "confirm_mnemonic_phrase", - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_IMPORT_WALLET]: { - screen_name: "recover_account", - flow: AnalyticsFlow.ONBOARDING, - }, - // Security / re-auth / secret material - [AnalyticsEvent.VIEW_LOCK_SCREEN]: { - screen_name: "unlock_account", - flow: AnalyticsFlow.SECURITY, - }, - [AnalyticsEvent.VIEW_SECURITY]: { - screen_name: "security", - flow: AnalyticsFlow.SECURITY, - }, - [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { - screen_name: "show_recovery_phrase", - flow: AnalyticsFlow.SECURITY, - }, - [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { - screen_name: "import_secret_key", - flow: AnalyticsFlow.SECURITY, - }, - // Home / assets - [AnalyticsEvent.VIEW_HOME]: { - screen_name: "account", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_TOKEN_DETAILS]: { - screen_name: "asset_detail", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_ACCOUNT_QR_CODE]: { - screen_name: "view_public_key_generator", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_MANAGE_TOKENS]: { - screen_name: "manage_assets", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_ADD_TOKEN]: { - screen_name: "add_asset", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_REMOVE_TOKEN]: { - screen_name: "remove_asset", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_SEARCH_TOKEN]: { - screen_name: "search_asset", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_ADD_TOKEN_MANUALLY]: { - screen_name: "add_asset_manually", - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_BUY_XLM]: { - screen_name: "add_fund", - flow: AnalyticsFlow.ASSETS, - }, - // History - [AnalyticsEvent.VIEW_HISTORY]: { - screen_name: "account_history", - flow: AnalyticsFlow.HISTORY, - }, - // Discovery - [AnalyticsEvent.VIEW_DISCOVERY]: { - screen_name: "discover", - flow: AnalyticsFlow.DISCOVERY, - }, - // Signing / dApp - [AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS]: { - screen_name: "grant_access", - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION]: { - screen_name: "sign_transaction", - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS]: { - screen_name: "sign_transaction_details", - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS]: { - screen_name: "sign_auth_entry_details", - flow: AnalyticsFlow.SIGNING, - }, - // Send payment - [AnalyticsEvent.VIEW_SEND_SEARCH_CONTACTS]: { - screen_name: "send_payment_to", - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_AMOUNT]: { - screen_name: "send_payment_amount", - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_MEMO]: { - screen_name: "send_payment_settings", - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_FEE]: { - screen_name: "send_payment_fee", - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_TIMEOUT]: { - screen_name: "send_payment_timeout", - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_CONFIRM]: { - screen_name: "send_payment_confirm", - flow: AnalyticsFlow.SEND, - step: "confirm", - }, - [AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS]: { - screen_name: "send_transaction_details", - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_PROCESSING]: { - screen_name: "send_payment_processing", - flow: AnalyticsFlow.SEND, - step: "processing", - }, - // Swap - [AnalyticsEvent.VIEW_SWAP]: { screen_name: "swap", flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_AMOUNT]: { - screen_name: "swap_amount", - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_FEE]: { - screen_name: "swap_fee", - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_SLIPPAGE]: { - screen_name: "swap_slippage", - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_TIMEOUT]: { - screen_name: "swap_timeout", - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_SETTINGS]: { - screen_name: "swap_settings", - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_CONFIRM]: { - screen_name: "swap_confirm", - flow: AnalyticsFlow.SWAP, - step: "confirm", - }, - [AnalyticsEvent.VIEW_SWAP_TRANSACTION_DETAILS]: { - screen_name: "swap_transaction_details", - flow: AnalyticsFlow.SWAP, - }, - // Settings - [AnalyticsEvent.VIEW_SETTINGS]: { - screen_name: "settings", - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_PREFERENCES]: { - screen_name: "preferences", - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_CHANGE_NETWORK]: { - screen_name: "manage_network", - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_NETWORK_SETTINGS]: { - screen_name: "network_settings", - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_SHARE_FEEDBACK]: { - screen_name: "leave_feedback", - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_ABOUT]: { - screen_name: "about", - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_MANAGE_CONNECTED_APPS]: { - screen_name: "manage_connected_apps", - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { - screen_name: "manage_wallets", - flow: AnalyticsFlow.SETTINGS, - }, -}; - -/** - * Fallback `screen_name` derivation for routes NOT declared in SCREEN_CATALOG - * (auto-mapped via transformRouteToEventName). Named screens declare their - * `screen_name` explicitly in the catalog; this only runs for the auto-mapped - * long tail. Deterministic transform of a legacy "loaded screen: X" string: - * 1. strip the "loaded screen: " prefix - * 2. trim - * 3. lowercase - * 4. replace each run of non-alphanumeric chars with a single "_" - * - * e.g. "loaded screen: send payment amount" -> "send_payment_amount" - * "loaded screen: account" -> "account" - */ -export const deriveScreenName = (legacyEvent: string): string => - legacyEvent - .replace(/^loaded screen:\s*/i, "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); +const SCREEN_CATALOG: Record = + { + // Onboarding / account creation + [AnalyticsEvent.VIEW_WELCOME]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_CHOOSE_PASSWORD]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE_ALERT]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_VALIDATE_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_IMPORT_WALLET]: { + flow: AnalyticsFlow.ONBOARDING, + }, + // Security / re-auth / secret material + [AnalyticsEvent.VIEW_LOCK_SCREEN]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_SECURITY]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { + flow: AnalyticsFlow.SECURITY, + }, + // Home / assets + [AnalyticsEvent.VIEW_HOME]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_TOKEN_DETAILS]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ACCOUNT_QR_CODE]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_MANAGE_TOKENS]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ADD_TOKEN]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_REMOVE_TOKEN]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_SEARCH_TOKEN]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ADD_TOKEN_MANUALLY]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_BUY_XLM]: { + flow: AnalyticsFlow.ASSETS, + }, + // History + [AnalyticsEvent.VIEW_HISTORY]: { + flow: AnalyticsFlow.HISTORY, + }, + // Discovery + [AnalyticsEvent.VIEW_DISCOVERY]: { + flow: AnalyticsFlow.DISCOVERY, + }, + // Signing / dApp + [AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS]: { + flow: AnalyticsFlow.SIGNING, + }, + // Send payment + [AnalyticsEvent.VIEW_SEND_SEARCH_CONTACTS]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_AMOUNT]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_MEMO]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_FEE]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_TIMEOUT]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_CONFIRM]: { + flow: AnalyticsFlow.SEND, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_PROCESSING]: { + flow: AnalyticsFlow.SEND, + step: "processing", + }, + // Swap + [AnalyticsEvent.VIEW_SWAP]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_AMOUNT]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_FEE]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_SLIPPAGE]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_TIMEOUT]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_SETTINGS]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_CONFIRM]: { + flow: AnalyticsFlow.SWAP, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SWAP_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SWAP, + }, + // Settings + [AnalyticsEvent.VIEW_SETTINGS]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_PREFERENCES]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_CHANGE_NETWORK]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_NETWORK_SETTINGS]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_SHARE_FEEDBACK]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_ABOUT]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_MANAGE_CONNECTED_APPS]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { + flow: AnalyticsFlow.SETTINGS, + }, + }; /** - * True when `event` is a legacy screen-load event (its value should be - * retargeted to the canonical `screen.viewed` event rather than emitted). + * True when `event` is a known screen-view — i.e. a canonical `screen_name` + * present in SCREEN_CATALOG. Such events are retargeted to the single + * `screen.viewed` event; everything else (action/domain events) is tracked + * unchanged. */ export const isScreenViewEvent = (event: string): boolean => - event.startsWith(LEGACY_SCREEN_PREFIX); + event in SCREEN_CATALOG; /** - * Builds the `screen.viewed` property bag from a legacy "loaded screen: X" - * string: the catalogued `screen_name` (or a derived one for auto-mapped - * routes) plus the screen's `flow`/`step` from the catalog (omitted when none). + * Builds the `screen.viewed` property bag for a screen: the `screen_name` + * itself (a named screen passes its VIEW_* enum value; an auto-mapped route + * passes its route-derived name) plus the catalogued `flow`/`step` (omitted + * when the screen is not catalogued or carries none). */ export const buildScreenViewedProps = ( - legacyEvent: string, + screenName: string, ): ScreenViewedProps => { - const entry = SCREEN_CATALOG[legacyEvent]; - const props: ScreenViewedProps = { - // Catalogued screens declare their `screen_name` explicitly; auto-mapped - // routes (transformRouteToEventName) that aren't in the catalog fall back - // to a derived name so they still emit a non-empty `screen_name`. - screen_name: entry?.screen_name ?? deriveScreenName(legacyEvent), - }; - if (entry?.flow) props.flow = entry.flow; - if (entry?.step) props.step = entry.step; + const meta = SCREEN_CATALOG[screenName]; + const props: ScreenViewedProps = { screen_name: screenName }; + if (meta?.flow) props.flow = meta.flow; + if (meta?.step) props.step = meta.step; return props; }; /** * Retargeting helper for manual screen-view emission sites (e.g. bottom * sheets that present a "screen"). Returns the `screen.viewed` props for a - * legacy screen-load event, or null for any non-screen event (which should be - * tracked unchanged). + * catalogued screen-view event, or null for any non-screen event (which + * should be tracked unchanged). */ export const getScreenViewedProps = ( event: string, @@ -612,37 +534,29 @@ export const CUSTOM_ROUTE_MAPPINGS: Record = { }; /** - * Transform route name to analytics event name automatically. - * - * This function implements the core transformation logic that converts - * React Navigation route names to analytics event names. + * Derives a canonical `screen_name` directly from a React Navigation route + * name: drop the "Screen" suffix, split PascalCase, lowercase, and join runs + * of non-alphanumeric chars with a single "_". * * Examples: - * - "WelcomeScreen" → "loaded screen: welcome" - * - "SettingsScreen" → "loaded screen: settings" - * - "SwapAmountScreen" → "loaded screen: swap amount" + * - "WelcomeScreen" → "welcome" + * - "SettingsScreen" → "settings" + * - "SwapAmountScreen" → "swap_amount" */ -export const transformRouteToEventName = (routeName: string): string => { - // Remove "Screen" suffix if present - const baseName = routeName.replace(/Screen$/, ""); - - // Convert PascalCase to lowercase with spaces - // "SwapAmount" → "swap amount" - const withSpaces = baseName +export const routeToScreenName = (routeName: string): string => + routeName + .replace(/Screen$/, "") .replace(/([A-Z])/g, " $1") // Add space before capitals .toLowerCase() - .trim(); - - return `loaded screen: ${withSpaces}`; -}; + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); /** - * Processes a single route for analytics mapping. - * Uses automatic transformation unless there's a manual override. + * Resolves a route to its canonical `screen_name`, or null when the route + * carries no analytics. A manual override (CUSTOM_ROUTE_MAPPINGS) wins; + * otherwise the name is derived from the route. */ -export const processRouteForAnalytics = ( - routeName: string, -): AnalyticsEvent | null => { +export const processRouteForAnalytics = (routeName: string): string | null => { // Check exclusion list first if (ROUTES_WITHOUT_ANALYTICS.has(routeName)) { return null; @@ -653,26 +567,23 @@ export const processRouteForAnalytics = ( return CUSTOM_ROUTE_MAPPINGS[routeName]; } - // Use automatic transformation for all other routes - const autoEvent = transformRouteToEventName(routeName); - - return autoEvent as AnalyticsEvent; + // Derive the screen_name directly for all other routes + return routeToScreenName(routeName); }; /** - * Generates the complete route-to-analytics mapping using ALL_ROUTE_OBJECTS. + * Generates the complete route-to-screen_name mapping using ALL_ROUTE_OBJECTS. * * This function automatically discovers all routes and creates analytics mappings * without requiring manual maintenance of route lists. */ export const generateRouteToAnalyticsMapping = () => { - const mapping: Record = {}; + const mapping: Record = {}; ALL_ROUTES_OBJECT.forEach((routeObject) => { Object.values(routeObject).forEach((routeName) => { if (typeof routeName === "string") { - const analyticsEvent = processRouteForAnalytics(routeName); - mapping[routeName] = analyticsEvent; + mapping[routeName] = processRouteForAnalytics(routeName); } }); }); diff --git a/src/hooks/useNavigationAnalytics.ts b/src/hooks/useNavigationAnalytics.ts index 1dd609ba0..1cb587aa7 100644 --- a/src/hooks/useNavigationAnalytics.ts +++ b/src/hooks/useNavigationAnalytics.ts @@ -39,17 +39,14 @@ export const useNavigationAnalytics = () => { const currentRouteName = getActiveRouteName(state); if (previousRouteName !== currentRouteName) { - // processRouteForAnalytics still resolves the route to its legacy - // "loaded screen: X" string; this change retargets that into a single - // canonical `screen.viewed` event carrying { screen_name, flow } - // (surface is added by the Slice-A common context). - const legacyScreenEvent = processRouteForAnalytics(currentRouteName); - - if (legacyScreenEvent) { - track( - AnalyticsEvent.SCREEN_VIEWED, - buildScreenViewedProps(legacyScreenEvent), - ); + // processRouteForAnalytics resolves the route to its canonical + // screen_name; we emit a single `screen.viewed` event carrying + // { screen_name, flow } (surface is added by the Slice-A common + // context). + const screenName = processRouteForAnalytics(currentRouteName); + + if (screenName) { + track(AnalyticsEvent.SCREEN_VIEWED, buildScreenViewedProps(screenName)); } } From 7ab58d969c382eea9ec79d42e1b60b28c1aa2451 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 20 Jul 2026 11:58:10 -0400 Subject: [PATCH 08/10] analytics(screen.viewed): fix throttle collapse + add guards (D1, D2, D7) From the cross-platform drift review of RFC #2883 slice B: - D1: make the screen.viewed throttle screen-aware so a burst of navigations (fast tap-through, or synchronous programmatic nav like popToTop()+navigate()) no longer collapses distinct screens down to the last one. Screen views are already deduped upstream, so partitioning the throttle per screen_name is safe. Adds a throttle-ENABLED regression test (the suite otherwise disables the throttle, which is why this was CI-invisible). - D2: type `step` as the canonical Step enum {confirm, processing, success} (applied identically on the extension). - D7: guard track() so a catalogued screen slug passed directly as an event name is dropped + reported, instead of leaking a bare slug (the VIEW_* enum members keep their slug values, so there is no compile-time guard). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/analyticsConfig.ts | 337 ++++++++++++++-------------- src/services/analytics/core.test.ts | 99 ++++++++ src/services/analytics/core.ts | 49 +++- 3 files changed, 315 insertions(+), 170 deletions(-) diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 1adac7af6..226e6e4ba 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -243,21 +243,29 @@ export enum AnalyticsFlow { * - `screen_name`: canonical, cross-platform id — a named screen's VIEW_* * enum value; auto-mapped routes derive it from the route name. * - `flow`: best-fit user journey (see AnalyticsFlow); omitted when none fits. - * - `step`: sub-step marker for screens that are a stage within a flow - * (e.g. a confirmation or processing screen) rather than a distinct - * destination. Collapses completion/success screens into `screen.viewed` - * instead of a bespoke event. + * - `step`: a screen's position within a multi-step flow (see Step) rather than + * a distinct destination. Omitted for standalone screens. * * `surface` is intentionally NOT included here: it is added to every event by * the Slice-A common context (buildCommonContext -> getSurface()). */ +/** + * Canonical cross-platform `step` vocabulary (RFC #2883): a screen's position + * within a multi-step flow. Closed set, applied identically on the extension — + * a screen present on both platforms MUST carry the same `step`. + * - `confirm`: the review/confirm stage before submitting (send/swap). + * - `processing`: the in-flight submission stage. + * - `success`: the terminal completion stage of a flow. + */ +export type Step = "confirm" | "processing" | "success"; + // A `type` alias (not an `interface`) so it stays assignable to the // `Record`-based `AnalyticsProps` at the track() call sites - // interfaces are not assignable to an index signature, type aliases are. export type ScreenViewedProps = { screen_name: string; flow?: AnalyticsFlow; - step?: string; + step?: Step; }; /** @@ -267,166 +275,165 @@ export type ScreenViewedProps = { * routeToScreenName) still emit `screen.viewed` with their route-derived * `screen_name` but carry no `flow` — see buildScreenViewedProps. */ -const SCREEN_CATALOG: Record = - { - // Onboarding / account creation - [AnalyticsEvent.VIEW_WELCOME]: { - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_CHOOSE_PASSWORD]: { - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_RECOVERY_PHRASE_ALERT]: { - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_RECOVERY_PHRASE]: { - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_VALIDATE_RECOVERY_PHRASE]: { - flow: AnalyticsFlow.ONBOARDING, - }, - [AnalyticsEvent.VIEW_IMPORT_WALLET]: { - flow: AnalyticsFlow.ONBOARDING, - }, - // Security / re-auth / secret material - [AnalyticsEvent.VIEW_LOCK_SCREEN]: { - flow: AnalyticsFlow.SECURITY, - }, - [AnalyticsEvent.VIEW_SECURITY]: { - flow: AnalyticsFlow.SECURITY, - }, - [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { - flow: AnalyticsFlow.SECURITY, - }, - [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { - flow: AnalyticsFlow.SECURITY, - }, - // Home / assets - [AnalyticsEvent.VIEW_HOME]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_TOKEN_DETAILS]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_ACCOUNT_QR_CODE]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_MANAGE_TOKENS]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_ADD_TOKEN]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_REMOVE_TOKEN]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_SEARCH_TOKEN]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_ADD_TOKEN_MANUALLY]: { - flow: AnalyticsFlow.ASSETS, - }, - [AnalyticsEvent.VIEW_BUY_XLM]: { - flow: AnalyticsFlow.ASSETS, - }, - // History - [AnalyticsEvent.VIEW_HISTORY]: { - flow: AnalyticsFlow.HISTORY, - }, - // Discovery - [AnalyticsEvent.VIEW_DISCOVERY]: { - flow: AnalyticsFlow.DISCOVERY, - }, - // Signing / dApp - [AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS]: { - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION]: { - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS]: { - flow: AnalyticsFlow.SIGNING, - }, - [AnalyticsEvent.VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS]: { - flow: AnalyticsFlow.SIGNING, - }, - // Send payment - [AnalyticsEvent.VIEW_SEND_SEARCH_CONTACTS]: { - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_AMOUNT]: { - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_MEMO]: { - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_FEE]: { - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_TIMEOUT]: { - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_CONFIRM]: { - flow: AnalyticsFlow.SEND, - step: "confirm", - }, - [AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS]: { - flow: AnalyticsFlow.SEND, - }, - [AnalyticsEvent.VIEW_SEND_PROCESSING]: { - flow: AnalyticsFlow.SEND, - step: "processing", - }, - // Swap - [AnalyticsEvent.VIEW_SWAP]: { flow: AnalyticsFlow.SWAP }, - [AnalyticsEvent.VIEW_SWAP_AMOUNT]: { - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_FEE]: { - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_SLIPPAGE]: { - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_TIMEOUT]: { - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_SETTINGS]: { - flow: AnalyticsFlow.SWAP, - }, - [AnalyticsEvent.VIEW_SWAP_CONFIRM]: { - flow: AnalyticsFlow.SWAP, - step: "confirm", - }, - [AnalyticsEvent.VIEW_SWAP_TRANSACTION_DETAILS]: { - flow: AnalyticsFlow.SWAP, - }, - // Settings - [AnalyticsEvent.VIEW_SETTINGS]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_PREFERENCES]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_CHANGE_NETWORK]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_NETWORK_SETTINGS]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_SHARE_FEEDBACK]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_ABOUT]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_MANAGE_CONNECTED_APPS]: { - flow: AnalyticsFlow.SETTINGS, - }, - [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { - flow: AnalyticsFlow.SETTINGS, - }, - }; +const SCREEN_CATALOG: Record = { + // Onboarding / account creation + [AnalyticsEvent.VIEW_WELCOME]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_CHOOSE_PASSWORD]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE_ALERT]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_VALIDATE_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.ONBOARDING, + }, + [AnalyticsEvent.VIEW_IMPORT_WALLET]: { + flow: AnalyticsFlow.ONBOARDING, + }, + // Security / re-auth / secret material + [AnalyticsEvent.VIEW_LOCK_SCREEN]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_SECURITY]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { + flow: AnalyticsFlow.SECURITY, + }, + // Home / assets + [AnalyticsEvent.VIEW_HOME]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_TOKEN_DETAILS]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ACCOUNT_QR_CODE]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_MANAGE_TOKENS]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ADD_TOKEN]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_REMOVE_TOKEN]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_SEARCH_TOKEN]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_ADD_TOKEN_MANUALLY]: { + flow: AnalyticsFlow.ASSETS, + }, + [AnalyticsEvent.VIEW_BUY_XLM]: { + flow: AnalyticsFlow.ASSETS, + }, + // History + [AnalyticsEvent.VIEW_HISTORY]: { + flow: AnalyticsFlow.HISTORY, + }, + // Discovery + [AnalyticsEvent.VIEW_DISCOVERY]: { + flow: AnalyticsFlow.DISCOVERY, + }, + // Signing / dApp + [AnalyticsEvent.VIEW_GRANT_DAPP_ACCESS]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SIGNING, + }, + [AnalyticsEvent.VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS]: { + flow: AnalyticsFlow.SIGNING, + }, + // Send payment + [AnalyticsEvent.VIEW_SEND_SEARCH_CONTACTS]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_AMOUNT]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_MEMO]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_FEE]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_TIMEOUT]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_CONFIRM]: { + flow: AnalyticsFlow.SEND, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SEND, + }, + [AnalyticsEvent.VIEW_SEND_PROCESSING]: { + flow: AnalyticsFlow.SEND, + step: "processing", + }, + // Swap + [AnalyticsEvent.VIEW_SWAP]: { flow: AnalyticsFlow.SWAP }, + [AnalyticsEvent.VIEW_SWAP_AMOUNT]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_FEE]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_SLIPPAGE]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_TIMEOUT]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_SETTINGS]: { + flow: AnalyticsFlow.SWAP, + }, + [AnalyticsEvent.VIEW_SWAP_CONFIRM]: { + flow: AnalyticsFlow.SWAP, + step: "confirm", + }, + [AnalyticsEvent.VIEW_SWAP_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.SWAP, + }, + // Settings + [AnalyticsEvent.VIEW_SETTINGS]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_PREFERENCES]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_CHANGE_NETWORK]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_NETWORK_SETTINGS]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_SHARE_FEEDBACK]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_ABOUT]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_MANAGE_CONNECTED_APPS]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { + flow: AnalyticsFlow.SETTINGS, + }, +}; /** * True when `event` is a known screen-view — i.e. a canonical `screen_name` diff --git a/src/services/analytics/core.test.ts b/src/services/analytics/core.test.ts index 5e993f757..d78d2f99c 100644 --- a/src/services/analytics/core.test.ts +++ b/src/services/analytics/core.test.ts @@ -363,6 +363,105 @@ describe("screen.viewed emission (hard cutover)", () => { }); }); +describe("screen.viewed throttling (D1: cross-screen collapse regression)", () => { + // The rest of the suite disables the throttle for determinism + // (THROTTLE_DUPLICATE_EVENTS: false). These tests instead exercise the + // PRODUCTION throttle path, because that is where a burst of navigations + // (fast tap-through, or synchronous programmatic nav like popToTop() + + // navigate()) could silently drop screen views: every screen shares the + // single "screen.viewed" event name, so a name-keyed throttle would collapse + // distinct screens into one. `core.ts` reads THROTTLE_DUPLICATE_EVENTS off + // the (mutable) mocked ANALYTICS_CONFIG at call time, so we flip it on the + // shared reference here and restore it afterwards. + const THROTTLE_DELAY_MS = 500; // must match the TIMING constants mock + + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const constantsMock = require("services/analytics/constants"); + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const amplitudeMock = require("@amplitude/analytics-react-native"); + + beforeEach(() => { + jest.useFakeTimers(); + constantsMock.ANALYTICS_CONFIG.THROTTLE_DUPLICATE_EVENTS = true; + initAnalytics(); + (amplitudeMock.track as jest.Mock).mockClear(); + }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + constantsMock.ANALYTICS_CONFIG.THROTTLE_DUPLICATE_EVENTS = false; // restore + }); + + it("emits every distinct screen in a rapid navigation burst", () => { + // Three distinct screens tracked within one THROTTLE_DELAY_MS window. + track(AnalyticsEvent.SCREEN_VIEWED, { screen_name: "send_payment_to" }); + track(AnalyticsEvent.SCREEN_VIEWED, { screen_name: "send_payment_amount" }); + track(AnalyticsEvent.SCREEN_VIEWED, { + screen_name: "send_payment_confirm", + }); + + // Proves the throttle is genuinely engaged: with leading:false nothing has + // dispatched yet (a disabled throttle would have fired 3 immediate calls). + expect(amplitudeMock.track).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(THROTTLE_DELAY_MS + 1); + + // Before the screen-aware throttle key this collapsed to a single trailing + // emit carrying only "send_payment_confirm". + expect(amplitudeMock.track).toHaveBeenCalledTimes(3); + const names = (amplitudeMock.track as jest.Mock).mock.calls.map( + (call) => (call[1] as { screen_name: string }).screen_name, + ); + // Order is preserved (trailing timers fire in scheduling order), so the + // funnel stays intact. + expect(names).toEqual([ + "send_payment_to", + "send_payment_amount", + "send_payment_confirm", + ]); + }); + + it("still dedups rapid re-emits of the SAME screen (throttle intent preserved)", () => { + track(AnalyticsEvent.SCREEN_VIEWED, { screen_name: "account" }); + track(AnalyticsEvent.SCREEN_VIEWED, { screen_name: "account" }); + + jest.advanceTimersByTime(THROTTLE_DELAY_MS + 1); + + expect(amplitudeMock.track).toHaveBeenCalledTimes(1); + expect(amplitudeMock.track).toHaveBeenCalledWith( + "screen.viewed", + expect.objectContaining({ screen_name: "account" }), + ); + }); +}); + +describe("screen-view bypass guard (D7)", () => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const amplitudeMock = require("@amplitude/analytics-react-native"); + + beforeEach(() => { + initAnalytics(); + (amplitudeMock.track as jest.Mock).mockClear(); + }); + + it("drops a catalogued screen slug passed directly to track (never leaks a bare slug as event name)", () => { + // Simulate a future call site bypassing the navigation/BottomSheet choke + // points by passing a VIEW_* screen member straight to track(). Its value + // ("send_payment_amount") is a SCREEN_CATALOG key, so the guard must drop it + // rather than emit it as an Amplitude event name. + track(AnalyticsEvent.VIEW_SEND_AMOUNT); + expect(amplitudeMock.track).not.toHaveBeenCalled(); + }); + + it("still emits the canonical SCREEN_VIEWED event and non-screen action events", () => { + // The choke-point path (event = SCREEN_VIEWED, slug in props) is allowed... + track(AnalyticsEvent.SCREEN_VIEWED, { screen_name: "send_payment_amount" }); + // ...as are action-style VIEW_* members that are NOT catalogued screens. + track(AnalyticsEvent.VIEW_PUBLIC_KEY_CLICKED_STELLAR_EXPERT); + expect(amplitudeMock.track).toHaveBeenCalledTimes(2); + }); +}); + describe("syncIdentifyTraits (consent gating)", () => { it("does not cache or send Identify while opted out; sends once opted in with the same traits", () => { // syncIdentifyTraits guards on module-level `hasInitialised`/fingerprint diff --git a/src/services/analytics/core.ts b/src/services/analytics/core.ts index d25ea8a52..0baaf179c 100644 --- a/src/services/analytics/core.ts +++ b/src/services/analytics/core.ts @@ -1,7 +1,7 @@ import * as amplitude from "@amplitude/analytics-react-native"; import { Experiment } from "@amplitude/experiment-react-native-client"; import { hash } from "@stellar/stellar-sdk"; -import { AnalyticsEvent } from "config/analyticsConfig"; +import { AnalyticsEvent, isScreenViewEvent } from "config/analyticsConfig"; import { logger } from "config/logger"; import { useAnalyticsStore } from "ducks/analytics"; import { useAuthenticationStore } from "ducks/auth"; @@ -395,16 +395,38 @@ const dispatchUnthrottled = ( /** * Throttled event dispatcher to prevent duplicate events. * Uses trailing execution to give time for analytics preferences to load. - * We use memoize to create a separate throttled function for each event name. + * We use memoize to create a separate throttled function for each throttle key. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -const getThrottledDispatcher = memoize((_eventName: AnalyticsEventName) => +const getThrottledDispatcher = memoize((_throttleKey: string) => throttle(dispatchUnthrottled, TIMING.THROTTLE_DELAY_MS, { leading: false, trailing: true, }), ); +/** + * Resolves the throttle bucket for an event. + * + * `screen.viewed` collapses every screen into a single event name, so keying + * the throttle on the event name alone would put all screens in one bucket: a + * burst of navigations within THROTTLE_DELAY_MS (fast tap-through, or + * synchronous programmatic navigation such as popToTop() immediately followed + * by navigate()) would drop all but the last screen. Navigation already dedups + * consecutive same-screen views upstream (useNavigationAnalytics compares + * previousRouteName !== currentRouteName; BottomSheet guards per-mount), so + * partitioning the throttle per screen_name is safe and keeps distinct screens + * from clobbering one another while still deduping same-screen re-emits. + */ +const getThrottleKey = ( + event: AnalyticsEventName, + props?: AnalyticsProps, +): string => + event === AnalyticsEvent.SCREEN_VIEWED && + typeof props?.screen_name === "string" + ? `${event}:${props.screen_name}` + : event; + /** * Main event tracking function. * Uses throttling by default to prevent duplicate events. @@ -413,9 +435,26 @@ export const track = ( event: AnalyticsEventName, props?: AnalyticsProps, ): void => { + // Bypass guard (RFC #2883, D7): screen-view VIEW_* members must be retargeted + // to the single SCREEN_VIEWED event by the navigation/BottomSheet choke + // points. A catalogued screen slug (e.g. "send_payment_amount") arriving here + // as the event name means a screen-view bypassed those choke points and would + // leak the bare slug as an Amplitude event name. The enum members keep their + // slug values (so there is no compile-time guard), hence this runtime one: + // drop the event and log, so a regression is caught in the console/breadcrumbs + // instead of silently corrupting the event stream. + if (event !== AnalyticsEvent.SCREEN_VIEWED && isScreenViewEvent(event)) { + const message = `Screen-view "${event}" passed to track() directly; emit SCREEN_VIEWED via the navigation choke points instead. Event dropped.`; + logger.error(DEBUG_CONFIG.LOG_PREFIX, message, new Error(message)); + return; + } + if (ANALYTICS_CONFIG.THROTTLE_DUPLICATE_EVENTS) { - // Get a dispatcher throttled for this specific event name - const throttledDispatch = getThrottledDispatcher(event); + // Get a dispatcher throttled for this event's bucket (screen-aware for + // screen.viewed; per event name otherwise). + const throttledDispatch = getThrottledDispatcher( + getThrottleKey(event, props), + ); throttledDispatch(event, props); } else { From bc1e7ff293f46eaeedbf291d47cb0989223092f6 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 20 Jul 2026 21:46:13 -0400 Subject: [PATCH 09/10] analytics(screen.viewed): reconcile sign_auth_entry name with extension Mobile emitted screen_name "sign_auth_entry_details", but the extension (stellar/freighter#2907) emits "sign_auth_entry" for the same dApp auth-entry signing screen. Mobile has only the details-sheet variant of this screen (no separate base sign_auth_entry), so the _details suffix was a mobile structural artifact rather than a distinct screen. Align the emitted value to the shared canonical name so cross-platform funnels join. Only the wire value changes; the VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS enum member and its references (component, catalog key) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/analyticsConfig.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 226e6e4ba..07e5ba71e 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -33,7 +33,13 @@ export enum AnalyticsEvent { VIEW_GRANT_DAPP_ACCESS = "grant_access", VIEW_SIGN_DAPP_TRANSACTION = "sign_transaction", VIEW_SIGN_DAPP_TRANSACTION_DETAILS = "sign_transaction_details", - VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS = "sign_auth_entry_details", + // Canonical cross-platform name (RFC #2883): the extension emits + // `sign_auth_entry` for the same dApp auth-entry signing screen. Mobile has + // only this (details bottom-sheet) variant of the screen -- there is no + // separate base `sign_auth_entry` -- so the value is reconciled to the + // shared name to keep cross-platform funnels joined. The `_DETAILS` member + // suffix reflects mobile's component structure and is unrelated to the wire value. + VIEW_SIGN_DAPP_AUTH_ENTRY_DETAILS = "sign_auth_entry", VIEW_SEND_SEARCH_CONTACTS = "send_payment_to", VIEW_SEND_AMOUNT = "send_payment_amount", VIEW_SEND_MEMO = "send_payment_settings", From 6f81a5e299c37c10d82affa0b819207b210bd1ff Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Mon, 20 Jul 2026 21:46:25 -0400 Subject: [PATCH 10/10] analytics(screen.viewed): emit send_payment_processing on mobile The send_payment_processing catalog entry (VIEW_SEND_PROCESSING, step:"processing") was declared but never emitted: no route derives to it and the inline TransactionProcessingScreen only fired the child transaction-details sheet. The extension (stellar/freighter#2907) emits this event from its submission PENDING effect and its comment assumes mobile does the same, so step:"processing" was silently mobile-absent. Emit screen.viewed { screen_name: "send_payment_processing", flow: "send", step: "processing" } once when TransactionProcessingScreen mounts. The screen is mounted only while submitting, making the mount the mobile analog of the extension's PENDING transition. Add a test asserting the single emission with the expected props. Swap is left untouched: neither platform emits a swap processing screen, so it stays symmetric. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TransactionProcessingScreen.test.tsx | 93 +++++++++++++++++++ .../screens/TransactionProcessingScreen.tsx | 15 +++ 2 files changed, 108 insertions(+) create mode 100644 __tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx diff --git a/__tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx new file mode 100644 index 000000000..92adcb36f --- /dev/null +++ b/__tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx @@ -0,0 +1,93 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { SendType } from "components/screens/SendScreen/components/SendReviewBottomSheet"; +import TransactionProcessingScreen from "components/screens/SendScreen/screens/TransactionProcessingScreen"; +import { AnalyticsEvent, AnalyticsFlow } from "config/analyticsConfig"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; +import { track } from "services/analytics/core"; + +import { mockUseColors } from "../../../../../__mocks__/use-colors"; + +mockUseColors(); + +// The store hooks pull in native/persisted state; stub them with the minimal +// shape TransactionProcessingScreen reads so we can mount it in isolation. +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: jest.fn(() => ({ + network: "TESTNET", + allAccounts: [], + })), +})); +jest.mock("ducks/transactionSettings", () => ({ + useTransactionSettingsStore: jest.fn(() => ({ + recipientAddress: + "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF", + federationAddress: "", + recipientName: "", + })), +})); +jest.mock("ducks/transactionBuilder", () => ({ + useTransactionBuilderStore: jest.fn(() => ({ + isSubmitting: true, + transactionHash: null, + error: null, + resetTransaction: jest.fn(), + })), +})); +jest.mock("ducks/sendRecipient", () => ({ + useSendRecipientStore: jest.fn(() => ({ + addRecentAddress: jest.fn(), + })), +})); + +jest.mock("hooks/useAppTranslation", () => () => ({ + t: (key: string) => key, +})); + +// The component only reads navigation.setOptions; the global mock omits it. +jest.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ setOptions: jest.fn() }), +})); + +// Heavy presentational children are irrelevant to the emission under test. +jest.mock("components/BottomSheet", () => () => null); +jest.mock("components/TransactionDetailsBottomSheet", () => () => null); +jest.mock("components/CollectibleImage", () => ({ CollectibleImage: () => null })); +jest.mock("components/TokenIcon", () => ({ TokenIcon: () => null })); +jest.mock("components/Spinner", () => () => null); +jest.mock("components/layout/BaseLayout", () => ({ + BaseLayout: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock("components/sds/Avatar", () => () => null); +jest.mock("components/sds/Button", () => ({ Button: () => null })); +jest.mock("components/sds/Typography", () => ({ + Display: () => null, + Text: () => null, +})); +jest.mock("components/sds/Icon", () => ({ + __esModule: true, + default: new Proxy({}, { get: () => "View" }), +})); + +describe("TransactionProcessingScreen analytics", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("emits a single screen.viewed for the processing step on mount", () => { + renderWithProviders( + , + ); + + const screenViewedCalls = (track as jest.Mock).mock.calls.filter( + ([event]) => event === AnalyticsEvent.SCREEN_VIEWED, + ); + + expect(screenViewedCalls).toHaveLength(1); + expect(screenViewedCalls[0][1]).toEqual({ + screen_name: "send_payment_processing", + flow: AnalyticsFlow.SEND, + step: "processing", + }); + }); +}); diff --git a/src/components/screens/SendScreen/screens/TransactionProcessingScreen.tsx b/src/components/screens/SendScreen/screens/TransactionProcessingScreen.tsx index 86e3512cd..aadeba955 100644 --- a/src/components/screens/SendScreen/screens/TransactionProcessingScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionProcessingScreen.tsx @@ -11,6 +11,7 @@ import Avatar from "components/sds/Avatar"; import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Display, Text } from "components/sds/Typography"; +import { AnalyticsEvent, buildScreenViewedProps } from "config/analyticsConfig"; import { TokenTypeWithCustomToken, PricedBalance } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { Collectible } from "ducks/collectibles"; @@ -24,6 +25,7 @@ import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { View } from "react-native"; +import { track } from "services/analytics/core"; const TransactionStatus = { SENDING: "sending", @@ -87,6 +89,19 @@ const TransactionProcessingScreen: React.FC< const bottomSheetModalRef = useRef(null); const isContractAddress = isContractId(recipientAddress); + // Emit the canonical screen.viewed for the in-flight submission stage. This + // screen is the mobile analog of the extension's PENDING state (stellar/ + // freighter#2907), which emits the same send_payment_processing (flow:"send", + // step:"processing") event -- keeping the processing funnel stage joined + // cross-platform. The component is mounted only while submitting, so a bare + // mount effect fires this exactly once per submission. + useEffect(() => { + track( + AnalyticsEvent.SCREEN_VIEWED, + buildScreenViewedProps(AnalyticsEvent.VIEW_SEND_PROCESSING), + ); + }, []); + useLayoutEffect(() => { navigation.setOptions({ headerShown: false,