diff --git a/extension/src/helpers/metrics.test.ts b/extension/src/helpers/metrics.test.ts new file mode 100644 index 0000000000..88899b27f4 --- /dev/null +++ b/extension/src/helpers/metrics.test.ts @@ -0,0 +1,505 @@ +// config/jest/setupTests.tsx globally auto-mocks "helpers/metrics" (so +// unrelated component/reducer tests don't have to deal with Amplitude etc.). +// This file tests the real implementation, so opt back out of that stub. +jest.unmock("helpers/metrics"); + +// Shared mocks — metrics.ts couples to the store, the SDK, and selectors at +// module load, so isolate all of them here. Individual tests set behavior. +jest.mock("@amplitude/analytics-browser"); +jest.mock("popup/App", () => ({ + store: { getState: jest.fn(() => ({})), subscribe: jest.fn() }, +})); +jest.mock("popup/ducks/accountServices", () => ({ + publicKeySelector: jest.fn(), + allAccountsSelector: jest.fn(() => []), +})); +jest.mock("popup/ducks/settings", () => ({ + settingsDataSharingSelector: jest.fn(() => true), + settingsNetworkDetailsSelector: jest.fn(() => ({ network: "TESTNET" })), +})); +jest.mock("popup/ducks/cache", () => ({ + balancesSelector: jest.fn(() => ({})), +})); +jest.mock("helpers/experimentClient", () => ({ + initExperimentClient: jest.fn(), +})); +jest.mock("constants/env", () => ({ + AMPLITUDE_KEY: "test-key", + APP_VERSION: "9.9.9", + METRICS_PLATFORM: "WEB", +})); +jest.mock("popup/helpers/isSidebarMode", () => ({ + isSidebarMode: jest.fn(() => false), +})); +jest.mock("webextension-polyfill", () => ({ + tabs: { getCurrent: jest.fn() }, + runtime: { getManifest: jest.fn(() => ({ version: "9.9.9" })) }, +})); + +import * as amplitude from "@amplitude/analytics-browser"; +import { + getAccountIdHash, + getSurface, + resolveSurface, + buildCommonContext, + deriveIdentifyTraits, + storeBalanceMetricData, + initAmplitude, +} from "helpers/metrics"; +import { isSidebarMode } from "popup/helpers/isSidebarMode"; +import browser from "webextension-polyfill"; +import { + publicKeySelector, + allAccountsSelector, +} from "popup/ducks/accountServices"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { balancesSelector } from "popup/ducks/cache"; +import { METRICS_DATA } from "constants/localStorageTypes"; +import { AccountType } from "@shared/api/types"; +import { truncatedPublicKey } from "helpers/stellar"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +describe("getAccountIdHash", () => { + const PUBLIC_KEY = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const EXPECTED = + "f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef"; + + it("returns the lowercase hex SHA-256 of the G-address (cross-platform vector)", () => { + expect(getAccountIdHash(PUBLIC_KEY)).toBe(EXPECTED); + }); + + it("is deterministic and 64 hex chars", () => { + const h = getAccountIdHash(PUBLIC_KEY); + expect(h).toBe(getAccountIdHash(PUBLIC_KEY)); + expect(h).toMatch(/^[0-9a-f]{64}$/); + }); + + it("differs for different keys", () => { + expect(getAccountIdHash("GABC")).not.toBe(getAccountIdHash("GXYZ")); + }); +}); + +describe("getSurface", () => { + beforeEach(() => jest.clearAllMocks()); + + it("returns 'sidebar' when in sidebar mode", async () => { + (isSidebarMode as jest.Mock).mockReturnValue(true); + await resolveSurface(); + expect(getSurface()).toBe("sidebar"); + }); + + it("returns 'fullpage' when opened in a tab", async () => { + (isSidebarMode as jest.Mock).mockReturnValue(false); + (browser.tabs.getCurrent as jest.Mock).mockResolvedValue({ id: 1 }); + await resolveSurface(); + expect(getSurface()).toBe("fullpage"); + }); + + it("returns 'popup' when not a tab", async () => { + (isSidebarMode as jest.Mock).mockReturnValue(false); + (browser.tabs.getCurrent as jest.Mock).mockResolvedValue(undefined); + await resolveSurface(); + expect(getSurface()).toBe("popup"); + }); + + it("defaults to 'popup' if getCurrent throws", async () => { + (isSidebarMode as jest.Mock).mockReturnValue(false); + (browser.tabs.getCurrent as jest.Mock).mockRejectedValue(new Error("x")); + await resolveSurface(); + expect(getSurface()).toBe("popup"); + }); +}); + +describe("buildCommonContext (four-bucket property model)", () => { + const PUBLIC_KEY = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + (settingsNetworkDetailsSelector as unknown as jest.Mock).mockReturnValue({ + network: "TESTNET", + }); + (publicKeySelector as unknown as jest.Mock).mockReturnValue(PUBLIC_KEY); + // account_type/is_hardware_account are resolved LIVE from the Redux + // account list keyed on the active key — not from the localStorage cache. + (allAccountsSelector as unknown as jest.Mock).mockReturnValue([ + { publicKey: PUBLIC_KEY, name: "Imported", imported: true }, + ]); + (balancesSelector as unknown as jest.Mock).mockReturnValue({ + TESTNET: { [PUBLIC_KEY]: { isFunded: true } }, + }); + // A deliberately *stale* metricsData cache (wrong type) — proves the + // account_type resolution no longer trusts it. + localStorage.setItem( + METRICS_DATA, + JSON.stringify({ + accountType: AccountType.FREIGHTER, + hwExists: false, + importedExists: true, + hwFunded: false, + importedFunded: true, + freighterFunded: false, + unfundedFreighterAccounts: [], + }), + ); + }); + + it("stamps schema_version '2'", () => { + expect(buildCommonContext({} as never).schema_version).toBe("2"); + }); + + it("emits the reshaped event-level bucket", () => { + const ctx = buildCommonContext({} as never); + expect(ctx).toMatchObject({ + network: "TESTNET", + account_type: "imported_secret_key", + account_funded: true, + is_hardware_account: false, + account_id_hash: + "f56f6f2c6cf1b9388e3495dfab96f0c55ec5d217f481b2ae45d11b46145c44ef", + }); + expect(ctx.surface).toBeDefined(); + }); + + it("derives account_funded from the active account's cached balance entry, not the sticky metricsData flag", () => { + // metricsData says this account type has never been funded, but the + // balances cache has a funded entry for the *active* key — the cache + // must win. This is the fix for the sticky-per-type inaccuracy. + localStorage.setItem( + METRICS_DATA, + JSON.stringify({ + accountType: AccountType.IMPORTED, + hwExists: false, + importedExists: true, + hwFunded: false, + importedFunded: false, + freighterFunded: false, + unfundedFreighterAccounts: [], + }), + ); + (balancesSelector as unknown as jest.Mock).mockReturnValue({ + TESTNET: { [PUBLIC_KEY]: { isFunded: true } }, + }); + expect(buildCommonContext({} as never)).toMatchObject({ + account_funded: true, + }); + }); + + it("omits account_funded when there is no cached balances entry for the active key", () => { + (balancesSelector as unknown as jest.Mock).mockReturnValue({}); + const ctx = buildCommonContext({} as never); + expect(ctx).not.toHaveProperty("account_funded"); + // Other active-account fields are still present. + expect(ctx).toMatchObject({ account_type: "imported_secret_key" }); + }); + + it("drops SDK-supplied and legacy fields", () => { + const ctx = buildCommonContext({} as never); + expect(ctx).not.toHaveProperty("platform"); + expect(ctx).not.toHaveProperty("platformVersion"); + expect(ctx).not.toHaveProperty("appVersion"); + expect(ctx).not.toHaveProperty("publicKey"); + expect(ctx).not.toHaveProperty("connectionType"); + expect(ctx).not.toHaveProperty("effectiveType"); + }); + + it("omits all account fields when there is no active key (pre-unlock)", () => { + (publicKeySelector as unknown as jest.Mock).mockReturnValue(""); + const ctx = buildCommonContext({} as never); + expect(ctx).not.toHaveProperty("account_id_hash"); + expect(ctx).not.toHaveProperty("account_type"); + expect(ctx).not.toHaveProperty("account_funded"); + expect(ctx).not.toHaveProperty("is_hardware_account"); + // non-account context is still present + expect(ctx).toMatchObject({ schema_version: "2", network: "TESTNET" }); + expect(ctx.surface).toBeDefined(); + }); + + it("marks hardware active account", () => { + (allAccountsSelector as unknown as jest.Mock).mockReturnValue([ + { + publicKey: PUBLIC_KEY, + name: "Ledger", + imported: false, + hardwareWalletType: "Ledger", + }, + ]); + (balancesSelector as unknown as jest.Mock).mockReturnValue({ + TESTNET: { [PUBLIC_KEY]: { isFunded: true } }, + }); + expect(buildCommonContext({} as never)).toMatchObject({ + account_type: "hardware", + is_hardware_account: true, + account_funded: true, + }); + }); + + it("resolves account_type from the live account list, ignoring a stale metricsData cache", () => { + // The cache (set in beforeEach) says FREIGHTER, but the active account is + // resolvable as an imported secret-key account — the live list must win. + // This is the regression guard for the post-import mislabeling bug: an + // account-mutation thunk switches the active account without refreshing + // metricsData, and events emitted before the next full reload must still + // report the correct type. + const ctx = buildCommonContext({} as never); + expect(ctx).toMatchObject({ + account_type: "imported_secret_key", + is_hardware_account: false, + }); + }); + + it("omits account_type/is_hardware_account when the active key is not resolvable in allAccounts", () => { + // Auth-store update race: the active public key is set, but the account + // list hasn't caught up (or doesn't contain it). Fail safe by OMITTING the + // type fields rather than guessing "freighter" — parity with mobile. + (allAccountsSelector as unknown as jest.Mock).mockReturnValue([ + { publicKey: "GSOMEOTHERKEY", name: "Other", imported: false }, + ]); + const ctx = buildCommonContext({} as never); + expect(ctx).not.toHaveProperty("account_type"); + expect(ctx).not.toHaveProperty("is_hardware_account"); + // The active-key-derived fields are still emitted. + expect(ctx).toHaveProperty("account_id_hash"); + expect(ctx).toMatchObject({ account_funded: true }); + }); +}); + +describe("deriveIdentifyTraits", () => { + it("counts accounts and detects hardware/imported presence", () => { + const accounts = [ + { publicKey: "G1", hardwareWalletType: "ledger", imported: false }, + { publicKey: "G2", hardwareWalletType: "", imported: true }, + { publicKey: "G3", hardwareWalletType: "", imported: false }, + ] as never; + expect(deriveIdentifyTraits(accounts)).toEqual({ + wallet_count: 3, + has_hardware_wallet: true, + has_imported_account: true, + }); + }); + + it("reports zero/false for an empty account list", () => { + expect(deriveIdentifyTraits([])).toEqual({ + wallet_count: 0, + has_hardware_wallet: false, + has_imported_account: false, + }); + }); + + it("does not cache the fingerprint when called before Amplitude init, so an identical call after init still sends Identify", async () => { + // syncIdentifyTraits guards on module-level `hasInitialized`/`AMPLITUDE_KEY` + // state, so isolate the module here to get a fresh, uninitialized instance + // independent of init having already run in another describe block. + const accounts = [ + { publicKey: "G1", hardwareWalletType: "ledger", imported: false }, + ] as never; + + let mod: typeof import("helpers/metrics"); + jest.isolateModules(() => { + mod = require("helpers/metrics"); + }); + const identify = ( + require("@amplitude/analytics-browser") as typeof amplitude + ).identify as jest.Mock; + identify.mockClear(); + + // Pre-init call: the init/consent guard short-circuits before any Identify + // is sent. The fingerprint must NOT be cached here (the hardening fix). + mod!.syncIdentifyTraits(accounts); + expect(identify).not.toHaveBeenCalled(); + + await mod!.initAmplitude(); + // initAmplitude sends its own Identify (bundle id property); clear that + // call so it doesn't get confused with the assertion below. + identify.mockClear(); + + // Same accounts as the pre-init call: if the fingerprint had been cached + // pre-init, this would be a no-op dirty-check short-circuit and Identify + // would never fire. Post-fix, it must fire because nothing was cached. + mod!.syncIdentifyTraits(accounts); + expect(identify).toHaveBeenCalled(); + }); +}); + +describe("storeBalanceMetricData (privacy)", () => { + const PUBLIC_KEY = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + (settingsNetworkDetailsSelector as unknown as jest.Mock).mockReturnValue({ + network: "TESTNET", + }); + (publicKeySelector as unknown as jest.Mock).mockReturnValue(PUBLIC_KEY); + localStorage.setItem( + METRICS_DATA, + JSON.stringify({ + accountType: AccountType.FREIGHTER, + hwExists: false, + importedExists: false, + hwFunded: false, + importedFunded: false, + freighterFunded: false, + unfundedFreighterAccounts: [truncatedPublicKey(PUBLIC_KEY)], + }), + ); + }); + + it("emits freighterAccountFunded with account_id_hash and never a raw/truncated publicKey", () => { + // Ensure emitMetric's `!hasInitialized` guard doesn't short-circuit before + // the amplitude.track call this test inspects. + initAmplitude(); + storeBalanceMetricData(PUBLIC_KEY, true); + + expect(amplitude.track).toHaveBeenCalledWith( + METRIC_NAMES.freighterAccountFunded, + expect.objectContaining({ + account_id_hash: getAccountIdHash(PUBLIC_KEY), + }), + ); + + const [, body] = (amplitude.track as jest.Mock).mock.calls[0]; + expect(body).not.toHaveProperty("publicKey"); + }); +}); + +describe("initAmplitude SDK config", () => { + beforeEach(() => jest.clearAllMocks()); + + it("passes appVersion so the SDK attaches app_version, with autocapture off", async () => { + // initAmplitude guards on a module-level `hasInitialized` flag, so isolate + // the module here to ensure this test is independent of init having + // already run in another describe block. + let mod: typeof import("helpers/metrics"); + jest.isolateModules(() => { + mod = require("helpers/metrics"); + }); + await mod!.initAmplitude(); + expect( + (require("@amplitude/analytics-browser") as typeof amplitude).init, + ).toHaveBeenCalledWith( + "test-key", + undefined, + expect.objectContaining({ appVersion: "9.9.9", autocapture: false }), + ); + }); +}); + +describe("privacy guard", () => { + it("buildCommonContext never includes a raw or truncated public key", () => { + (publicKeySelector as unknown as jest.Mock).mockReturnValue( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + const ctx = buildCommonContext({} as never); + const serialized = JSON.stringify(ctx); + expect(serialized).not.toContain("GAAAAAAAAAAAAAAAAAAAAAAAAA"); + expect(ctx).not.toHaveProperty("publicKey"); + }); +}); + +describe("consent hydration (startup)", () => { + it("defers app.opened while data-sharing is disallowed, then emits exactly once when it becomes allowed", async () => { + let mod: typeof import("helpers/metrics"); + let store: { subscribe: jest.Mock }; + let settingsMod: { settingsDataSharingSelector: jest.Mock }; + let track: jest.Mock; + jest.isolateModules(() => { + mod = require("helpers/metrics"); + store = require("popup/App").store; + settingsMod = require("popup/ducks/settings"); + track = (require("@amplitude/analytics-browser") as typeof amplitude) + .track as jest.Mock; + }); + + // Persisted preference hasn't hydrated yet → disallowed at init. + settingsMod!.settingsDataSharingSelector.mockReturnValue(false); + track!.mockClear(); + store!.subscribe.mockClear(); + + await mod!.initAmplitude(); + + // Nothing emitted while consent is disallowed. + expect( + track!.mock.calls.find((c) => c[0] === "app.opened"), + ).toBeUndefined(); + + // Consent now resolves to allowed; the store subscription fires. + settingsMod!.settingsDataSharingSelector.mockReturnValue(true); + const subCb = + store!.subscribe.mock.calls[store!.subscribe.mock.calls.length - 1][0]; + subCb(); + + expect(track!.mock.calls.filter((c) => c[0] === "app.opened")).toHaveLength( + 1, + ); + + // Idempotent: further store changes don't re-emit. + subCb(); + expect(track!.mock.calls.filter((c) => c[0] === "app.opened")).toHaveLength( + 1, + ); + }); + + it("does not send or cache Identify while data-sharing is disallowed, then sends once allowed", async () => { + let mod: typeof import("helpers/metrics"); + let settingsMod: { settingsDataSharingSelector: jest.Mock }; + let identify: jest.Mock; + jest.isolateModules(() => { + mod = require("helpers/metrics"); + settingsMod = require("popup/ducks/settings"); + identify = (require("@amplitude/analytics-browser") as typeof amplitude) + .identify as jest.Mock; + }); + const accounts = [ + { publicKey: "G1", hardwareWalletType: "", imported: false }, + ] as never; + + settingsMod!.settingsDataSharingSelector.mockReturnValue(true); + await mod!.initAmplitude(); + identify!.mockClear(); + + // Opted out: no Identify, and (critically) the fingerprint is NOT cached. + settingsMod!.settingsDataSharingSelector.mockReturnValue(false); + mod!.syncIdentifyTraits(accounts); + expect(identify!).not.toHaveBeenCalled(); + + // Opt in: the same traits must now reach Amplitude (proving nothing was + // cached while opted out — otherwise the dirty-check would suppress it). + settingsMod!.settingsDataSharingSelector.mockReturnValue(true); + mod!.syncIdentifyTraits(accounts); + expect(identify!).toHaveBeenCalled(); + }); +}); + +describe("app.opened", () => { + it("exposes the appOpened event name", () => { + expect(METRIC_NAMES.appOpened).toBe("app.opened"); + }); + + it("emits app.opened once during init with the connectivity snapshot", async () => { + (Object.defineProperty as typeof Object.defineProperty)( + global.navigator, + "connection", + { value: { type: "wifi", effectiveType: "4g" }, configurable: true }, + ); + let mod: typeof import("helpers/metrics"); + jest.isolateModules(() => { + mod = require("helpers/metrics"); + }); + const track = ( + require("@amplitude/analytics-browser") as typeof import("@amplitude/analytics-browser") + ).track as jest.Mock; + track.mockClear(); + + await mod!.initAmplitude(); + + const call = track.mock.calls.find((c) => c[0] === "app.opened"); + expect(call).toBeDefined(); + expect(call![1]).toMatchObject({ + connection_type: "wifi", + effective_type: "4g", + schema_version: "2", + }); + expect(call![1].surface).toBeDefined(); + }); +}); diff --git a/extension/src/helpers/metrics.ts b/extension/src/helpers/metrics.ts index 1b7cfd7068..8a8f228e4a 100644 --- a/extension/src/helpers/metrics.ts +++ b/extension/src/helpers/metrics.ts @@ -2,21 +2,27 @@ import * as amplitude from "@amplitude/analytics-browser"; import { Action, Middleware } from "redux"; import { PayloadAction } from "@reduxjs/toolkit"; import { Location } from "react-router-dom"; +import { hash } from "stellar-sdk"; import browser from "webextension-polyfill"; import { store } from "popup/App"; import { METRICS_DATA, METRICS_USER_ID } from "constants/localStorageTypes"; -import { AMPLITUDE_KEY, METRICS_PLATFORM, APP_VERSION } from "constants/env"; +import { AMPLITUDE_KEY, APP_VERSION } from "constants/env"; import { initExperimentClient } from "helpers/experimentClient"; import { BUNDLE_ID_USER_PROPERTY_KEY, getBundleId } from "helpers/analytics"; import { isDev } from "@shared/helpers/dev"; import { truncatedPublicKey } from "helpers/stellar"; +import { isSidebarMode } from "popup/helpers/isSidebarMode"; import { settingsDataSharingSelector, settingsNetworkDetailsSelector, } from "popup/ducks/settings"; -import { publicKeySelector } from "popup/ducks/accountServices"; +import { + publicKeySelector, + allAccountsSelector, +} from "popup/ducks/accountServices"; +import { balancesSelector } from "popup/ducks/cache"; import { Account, AccountType } from "@shared/api/types"; import { METRIC_NAMES } from "popup/constants/metricsNames"; @@ -105,6 +111,23 @@ let hasInitialized = false; */ const AMPLITUDE_FLUSH_INTERVAL_MS = 500; +/** Schema generation marker for the new cross-platform property model. */ +export const SCHEMA_VERSION = "2"; + +/** Maps the internal account type to the RFC's wire value for `account_type`. */ +const ACCOUNT_TYPE_WIRE: Record = { + [AccountType.FREIGHTER]: "freighter", + [AccountType.HW]: "hardware", + [AccountType.IMPORTED]: "imported_secret_key", +}; + +/** Resolves an account's type from its discriminating fields. */ +const resolveAccountType = (account: Account): AccountType => { + if (account.hardwareWalletType) return AccountType.HW; + if (account.imported) return AccountType.IMPORTED; + return AccountType.FREIGHTER; +}; + // --------------------------------------------------------------------------- // User identity (mirrors mobile's src/services/analytics/user.ts) // --------------------------------------------------------------------------- @@ -152,7 +175,7 @@ export const getUserId = (): string => { * Initializes the Amplitude SDK. Should be called once at app startup. * In development (no AMPLITUDE_KEY), events are logged to console only. */ -export const initAmplitude = () => { +export const initAmplitude = async () => { if (hasInitialized) return; if (!AMPLITUDE_KEY) { @@ -176,6 +199,7 @@ export const initAmplitude = () => { // generate a UUID deviceId and persist it across sessions. identityStorage: "localStorage", autocapture: false, + appVersion: APP_VERSION || undefined, // The extension popup can close at any time; reduce the flush interval // so queued events are sent promptly instead of waiting the default 1 s. flushIntervalMillis: AMPLITUDE_FLUSH_INTERVAL_MS, @@ -190,29 +214,51 @@ export const initAmplitude = () => { identify.set(BUNDLE_ID_USER_PROPERTY_KEY, getBundleId()); amplitude.identify(identify); - // Apply initial opt-out state. Note: settings may not yet be loaded from the - // background at this point (they're fetched async). The store subscription - // below will correct this as soon as the real preference arrives. - const isDataSharingAllowed = settingsDataSharingSelector(store.getState()); - amplitude.setOptOut(!isDataSharingAllowed); - hasInitialized = true; // Initialize Experiment client now that analytics is ready. // initializeWithAmplitudeAnalytics requires the analytics SDK to be started first. initExperimentClient(); - // Keep opt-out in sync whenever the data-sharing setting changes in Redux. - // This is the authoritative source of truth; the initial call above may fire - // before settings are loaded from the background script. + // Resolve the surface once (async) so getSurface() is synchronous afterward. + await resolveSurface(); + + // The persisted data-sharing preference has NOT hydrated yet at init — it + // defaults to `false` and the real value arrives asynchronously. Emitting + // app.opened here would be suppressed (emitMetric's consent gate) or dropped + // by the SDK (opt-out), yet lost forever. So defer it: emit exactly once, the + // moment data-sharing is (or becomes) allowed. + let hasEmittedAppOpened = false; + const emitAppOpenedOnce = () => { + if (hasEmittedAppOpened) return; + hasEmittedAppOpened = true; + + const nav = navigator as Navigator & { + connection?: { type?: string; effectiveType?: string }; + }; + emitMetric(METRIC_NAMES.appOpened, { + connection_type: nav.connection?.type ?? "unknown", + ...(nav.connection?.effectiveType + ? { effective_type: nav.connection.effectiveType } + : {}), + }); + }; + + // Keep Amplitude opt-out synced with the data-sharing preference (the + // authoritative source of truth). Primes immediately for the case where + // settings are already hydrated, and updates on every change. Fires the + // one-shot app.opened the first time consent resolves to allowed. let lastDataSharingAllowed: boolean | null = null; - store.subscribe(() => { + const syncDataSharing = () => { const allowed = settingsDataSharingSelector(store.getState()); if (allowed !== lastDataSharingAllowed) { lastDataSharingAllowed = allowed; amplitude.setOptOut(!allowed); + if (allowed) emitAppOpenedOnce(); } - }); + }; + store.subscribe(syncDataSharing); + syncDataSharing(); // Flush any queued events before the popup window closes so they aren't lost. if (typeof window !== "undefined") { @@ -228,88 +274,152 @@ export const initAmplitude = () => { } }; +/** Derives the durable Identify traits from the full account list. */ +export const deriveIdentifyTraits = (allAccounts: Account[]) => { + let hasHardware = false; + let hasImported = false; + allAccounts.forEach((acc) => { + if (acc.hardwareWalletType) { + hasHardware = true; + } else if (acc.imported) { + hasImported = true; + } + }); + return { + wallet_count: allAccounts.length, + has_hardware_wallet: hasHardware, + has_imported_account: hasImported, + }; +}; + +// Only re-send Identify when a durable trait actually changed. +let lastIdentifiedTraits: string | null = null; + +/** Sends durable wallet-composition traits to Amplitude Identify (dirty-checked). */ +export const syncIdentifyTraits = (allAccounts: Account[]): void => { + const traits = deriveIdentifyTraits(allAccounts); + const fingerprint = JSON.stringify(traits); + if (fingerprint === lastIdentifiedTraits) return; + + if (!AMPLITUDE_KEY || !hasInitialized) return; + + // Don't cache the fingerprint unless the Identify can actually be sent. During + // startup this runs before the data-sharing preference hydrates (SDK still + // opted out), so caching here would record traits as "sent" while the SDK + // drops them — and the unchanged traits would then be suppressed forever. Skip + // without caching so a later call (after consent hydrates) re-syncs. + if (!settingsDataSharingSelector(store.getState())) return; + + lastIdentifiedTraits = fingerprint; + + const identify = new amplitude.Identify(); + identify.set("wallet_count", traits.wallet_count); + identify.set("has_hardware_wallet", traits.has_hardware_wallet); + identify.set("has_imported_account", traits.has_imported_account); + amplitude.identify(identify); +}; + // --------------------------------------------------------------------------- // Common context (mirrors mobile's buildCommonContext) // --------------------------------------------------------------------------- -/** - * Returns the extension version. Prefers the build-time constant (always - * available via DefinePlugin), falling back to the browser extension manifest. - */ -const getAppVersion = (): string => { - if (APP_VERSION) return APP_VERSION; +export type Surface = "popup" | "sidebar" | "fullpage"; +// Resolved once at init: browser.tabs.getCurrent() is async, but the emit path +// must be synchronous, so cache the surface in a module variable. +let cachedSurface: Surface | null = null; + +/** Resolve and cache the surface. Call once during init, before app.opened. */ +export const resolveSurface = async (): Promise => { + if (isSidebarMode()) { + cachedSurface = "sidebar"; + return; + } try { - return browser.runtime.getManifest().version; + const tab = await browser.tabs.getCurrent(); + cachedSurface = tab ? "fullpage" : "popup"; } catch { - return "unknown"; + cachedSurface = "popup"; } }; +/** Synchronous surface accessor for the emit path. */ +export const getSurface = (): Surface => + cachedSurface ?? (isSidebarMode() ? "sidebar" : "popup"); + /** - * Extracts a coarsened browser identifier from the user agent string. - * Returns "BrowserName/MajorVersion" (e.g. "Chrome/120", "Firefox/121"). - * Falls back to "Unknown" if parsing fails. + * Cross-platform account identifier: lowercase hex SHA-256 of the full + * G-address string. Never emit a raw/truncated public key. Memoized per key + * so the hot emit path stays synchronous and does no repeat work. Mobile must + * hash the same G-address string with SHA-256 to produce a matching value. */ -const getCoarsenedUserAgent = (): string => { - const ua = navigator.userAgent; - - // Order matters: check more specific browsers first. - // Edge includes "Chrome" in its UA, so check Edge before Chrome. - const patterns: Array<[RegExp, string]> = [ - [/Edg(?:e|A|iOS)?\/(\d+)/, "Edge"], - [/OPR\/(\d+)/, "Opera"], - [/Firefox\/(\d+)/, "Firefox"], - [/(?:Chrome|CriOS)\/(\d+)/, "Chrome"], - [/Version\/(\d+).*Safari/, "Safari"], - ]; - - for (const [regex, name] of patterns) { - const match = ua.match(regex); - if (match) { - return `${name}/${match[1]}`; - } +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 ""; } - - return "Unknown"; }; /** - * Builds common context data attached to every event. - * Mirrors the mobile app's context properties for consistency across platforms. - * - * | Mobile property | Extension equivalent | - * |-------------------|----------------------------------------------| - * | Platform.OS | METRICS_PLATFORM ("WEB") | - * | Platform.Version | coarsened user agent (e.g. "Chrome/120") | - * | getVersion() | manifest version / APP_VERSION | - * | getBundleId() | "extension." | - * - * @param state Current Redux state (passed in to avoid a redundant getState() call). + * Builds the event-level "volatile context" bucket attached to every event, + * plus schema_version. Durable traits live in Identify (see syncIdentifyTraits); + * device/app metadata comes from the Amplitude SDK; connectivity is on app.opened. */ -const buildCommonContext = ( +export const buildCommonContext = ( state: ReturnType, ): Record => { const activePublicKey = publicKeySelector(state); const networkDetails = settingsNetworkDetailsSelector(state); - // Navigator.connection is not available in all browsers - const nav = navigator as Navigator & { - connection?: { type?: string; effectiveType?: string }; - }; - const context: Record = { - publicKey: activePublicKey ? truncatedPublicKey(activePublicKey) : "N/A", - platform: METRICS_PLATFORM, - platformVersion: getCoarsenedUserAgent(), + schema_version: SCHEMA_VERSION, + surface: getSurface(), network: networkDetails?.network ?? "UNKNOWN", - connectionType: nav.connection?.type ?? "unknown", - appVersion: getAppVersion(), - bundleId: getBundleId(), }; - if (nav.connection?.effectiveType) { - context.effectiveType = nav.connection.effectiveType; + // Active-account fields are meaningful only when there is an active account. + // Pre-unlock (no active key) we omit them rather than emit a default + // "freighter/false" context that misrepresents state. + if (activePublicKey) { + const idHash = getAccountIdHash(activePublicKey); + if (idHash) context.account_id_hash = idHash; + + // Resolve account_type/is_hardware_account LIVE from the Redux account + // list, keyed on the active public key — not from the localStorage + // metricsData cache. Account-mutation thunks (importAccount, + // importHardwareWallet, addAccount, createAccount) switch the active + // account without refreshing metricsData, so the cache can lag a switch + // and mislabel e.g. a freshly-imported secret-key account as "freighter" + // until the next full app-data reload. If the active key isn't resolvable + // in allAccounts (an auth-store update race), OMIT these fields rather + // than guessing — matching mobile's fail-safe behavior. + const activeAccount = (allAccountsSelector(state) ?? []).find( + (acc: Account) => acc.publicKey === activePublicKey, + ); + if (activeAccount) { + const accountType = resolveAccountType(activeAccount); + context.account_type = ACCOUNT_TYPE_WIRE[accountType]; + context.is_hardware_account = accountType === AccountType.HW; + } + + // account_funded reflects the *active account's* cached balance, not a + // sticky per-account-type flag — funding one Freighter account must not + // make every other (unfunded) Freighter account report funded. Balances + // are cached per network, per public key (see popup/ducks/cache + // balanceData); omit the property entirely (rather than defaulting to + // false) when there is no cached entry, or the cached fundedness is + // unknown (`isFunded === null`), for the active key. + const cachedBalances = + balancesSelector(state)[networkDetails?.network ?? ""]?.[activePublicKey]; + if (cachedBalances && cachedBalances.isFunded !== null) { + context.account_funded = cachedBalances.isFunded; + } } return context; @@ -357,15 +467,9 @@ const getMetricsData = (): MetricsData => { export const emitMetric = (name: string, body?: Record) => { const state = store.getState(); - const metricsData = getMetricsData(); - const eventProperties = { ...buildCommonContext(state), ...body, - freighter_account_funded: metricsData.freighterFunded, - hw_connected: metricsData.hwExists, - secret_key_account: metricsData.importedExists, - secret_key_account_funded: metricsData.importedFunded, }; const isDataSharingAllowed = settingsDataSharingSelector(state); @@ -415,7 +519,7 @@ export const storeBalanceMetricData = ( metricsData.freighterFunded = true; if (idx !== -1) { emitMetric(METRIC_NAMES.freighterAccountFunded, { - publicKey: truncated, + account_id_hash: getAccountIdHash(publicKey), }); unfundedFreighterAccounts.splice(idx, 1); } @@ -462,4 +566,6 @@ export const storeAccountMetricsData = ( }); metricsData.accountType = accountType; localStorage.setItem(METRICS_DATA, JSON.stringify(metricsData)); + + syncIdentifyTraits(allAccounts); }; diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 97574adccd..3c177cb5c7 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -199,4 +199,5 @@ export const METRIC_NAMES = { coinbaseOnrampOpened: "coinbase onramp: opened", wallets: "loaded screen: wallets", confirmSidebarRequest: "loaded screen: confirm sidebar request", + appOpened: "app.opened", };