diff --git a/__tests__/config/analyticsConfig.test.ts b/__tests__/config/analyticsConfig.test.ts index 0969aa772..a6033c1f0 100644 --- a/__tests__/config/analyticsConfig.test.ts +++ b/__tests__/config/analyticsConfig.test.ts @@ -1,30 +1,26 @@ import { AnalyticsEvent, + AnalyticsFlow, ROUTE_TO_ANALYTICS_EVENT_MAP, ROUTES_WITHOUT_ANALYTICS, - transformRouteToEventName, + routeToScreenName, processRouteForAnalytics, + buildScreenViewedProps, + getScreenViewedProps, + isScreenViewEvent, } from "config/analyticsConfig"; 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"); }); }); @@ -77,4 +73,139 @@ 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("routeToScreenName", () => { + it("collapses each run of non-alphanumeric chars into a single underscore", () => { + expect(routeToScreenName("ReAuthDetailsScreen")).toBe( + "re_auth_details", + ); + }); + + 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 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( + false, + ); + }); + }); + + describe("buildScreenViewedProps", () => { + it("uses the catalogued 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("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(routeToScreenName("SomeFutureScreen")), + ).toEqual({ screen_name: "some_future" }); + }); + + 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); + } + }); + }); + }); + + 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("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); + 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/components/BottomSheet.tsx b/src/components/BottomSheet.tsx index 86837c0f1..4df52c9d5 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,18 @@ 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 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, { + ...screenViewedProps, + ...analyticsProps, + }); + } else { + track(analyticsEvent, analyticsProps); + } hasTrackedRef.current = true; } diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index e78ac1406..1adac7af6 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -7,56 +7,66 @@ import { ALL_ROUTES_OBJECT } from "config/routes"; * Events are organized by category for better maintainability. */ export enum AnalyticsEvent { + // Canonical screen-view event (#2883). + // + // Every screen load emits THIS single event carrying { screen_name, flow, + // 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", @@ -208,6 +218,252 @@ 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`: 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. + * + * `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; +}; + +/** + * 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 = + { + // 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` + * 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 in SCREEN_CATALOG; + +/** + * 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 = ( + screenName: string, +): ScreenViewedProps => { + 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 + * catalogued screen-view 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 * @@ -278,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; @@ -319,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 1ba8bfc24..1cb587aa7 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,14 @@ export const useNavigationAnalytics = () => { const currentRouteName = getActiveRouteName(state); if (previousRouteName !== currentRouteName) { - const event = processRouteForAnalytics(currentRouteName); - - if (event) { - track(event); + // 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)); } } diff --git a/src/services/analytics/core.test.ts b/src/services/analytics/core.test.ts index efae08ab6..5e993f757 100644 --- a/src/services/analytics/core.test.ts +++ b/src/services/analytics/core.test.ts @@ -16,7 +16,7 @@ // not a bare auto-mock. Auto-mocking loads the real RN SDK to introspect // it, which crashes in the Jest env on native async-storage // (`PlatformLocalStorage`). The factory stubs only what core.ts touches. -import { AnalyticsEvent } from "config/analyticsConfig"; +import { AnalyticsEvent, buildScreenViewedProps } from "config/analyticsConfig"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; @@ -90,6 +90,7 @@ const { deriveIdentifyTraits, initAnalytics, trackAppOpened, + track, } = jest.requireActual("./core"); describe("getAccountIdHash", () => { @@ -297,6 +298,71 @@ describe("trackAppOpened (one-time connectivity snapshot)", () => { }); }); +describe("screen.viewed emission (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