feat(analytics): property-model foundation (cross-platform schema alignment)#936
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… signal Adds fetchedPublicKey to the balances store, set on every successful fetchAccountBalances call. Lets downstream analytics (account_funded) verify a balance snapshot belongs to the currently active account before emitting, avoiding stale/other-account values.
Rewrites buildCommonContext to emit only schema_version, surface, and uppercased network, plus account_id_hash/account_type/account_funded when an account is active. Drops publicKey, platform, platformVersion, appVersion, buildVersion, bundleId, connectionType, and effectiveType from the event-level context; never emits is_hardware_account (mobile has no hardware wallets). account_type falls back to allAccounts lookup since ActiveAccount does not carry importedFromSecretKey. account_funded is only set when the balances store's fetchedPublicKey matches the active account, avoiding stale/mismatched funded state. Removes now-dead imports (truncateAddress, getVersion, getBuildNumber, useNetworkStore) no longer referenced after the reshape.
buildCommonContext derived account_type from an allAccounts lookup that fell back to "freighter" whenever the active account wasn't found. During the auth-store update race (createAccount/importSecretKey) and the drift-recovery path, that silently mislabels a possibly-imported account - the same misleading-value failure we already avoid for account_id_hash and account_funded. Gate account_type on the active account actually being found in allAccounts; omit it otherwise.
Add deriveIdentifyTraits (wallet_count, has_imported_account - no has_hardware_wallet, mobile has no hardware wallets) and syncIdentifyTraits, which dirty-checks via a fingerprint and gates on both init and analytics consent. The fingerprint is only cached once the Identify call can actually be sent, so traits re-sync correctly once consent/init hydrate later (mirrors the extension's fix). initAnalytics now calls syncIdentifyTraits instead of the old setAmplitudeUserProperties (removed - it only set bundle_id, now folded into syncIdentifyTraits). Add an auth-store subscription so traits stay in sync as accounts are added/imported/removed.
The in-init syncIdentifyTraits call ran BEFORE `hasInitialised = true`, so its own `!hasInitialised` gate always short-circuited and the initial Identify never sent. Since the auth-store subscription only fires on LATER changes, an already-logged-in user whose allAccounts never changed again got no Identify traits for the whole session - a regression from the old unconditional setAmplitudeUserProperties. - Move the in-init syncIdentifyTraits call to after `hasInitialised = true` (last step of the successful-init path). - Also call syncIdentifyTraits from the existing analytics-store subscription so traits send once consent hydrates/enables after init (dirty-check + consent-gate keep it a safe no-op otherwise). Add two regression tests (both fail on the pre-fix code): initial Identify fires during initAnalytics when enabled, and Identify fires when consent enables after init via the analytics-store subscriber.
Re-adds useNetworkStore to core.ts (removed from buildCommonContext in M4) so trackAppOpened can attach a point-in-time connection_type/ effective_type snapshot plus surface. buildCommonContext still emits no connectivity; network/schema_version continue to arrive via the common-context bucket inside the dispatcher.
There was a problem hiding this comment.
Pull request overview
Aligns mobile analytics properties with the shared cross-platform Amplitude schema without renaming events.
Changes:
- Adds schema, surface, hashed account, and funded-account context.
- Moves durable wallet traits to consent-gated Identify calls.
- Adds connectivity snapshots to
app.opened.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/services/analytics/core.ts |
Implements the revised analytics property model. |
src/services/analytics/core.test.ts |
Tests schema, privacy, Identify, and connectivity behavior. |
src/ducks/balances.ts |
Tracks which account produced the balance snapshot. |
__tests__/ducks/balances.test.ts |
Verifies balance snapshot account tracking. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-fde19bc17a346b982f8c (SDF collaborators only — install instructions in the release description) |
Three fixes from the #936 review: - Gate syncIdentifyTraits on persisted-consent hydration. isEnabled is persisted to AsyncStorage and hydrates asynchronously; the pre-hydration in-memory default is `true` on Android, so a returning opt-out user could emit wallet traits before hydration flips consent to false. Skip until useAnalyticsStore.persist.hasHydrated(), and retry via onFinishHydration so enabled users' traits still sync. - Cache the Identify fingerprint only after amplitude.identify() succeeds. Previously it was cached before the try block, so a single throw left the dirty-check short-circuiting every later sync with the same traits — they would never retry. Matches the function's own docstring. - Require both public key AND network to match for account_funded. Balances are keyed by (publicKey, network); the same G-address stays active across a selectNetwork() switch, so key-only matching emitted the old network's funded status against the new network until the async refetch landed. Record fetchedNetwork on the balances store and compare it too, failing closed on a network switch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complements the network-aware account_funded guard: clearAccountData now resets fetchedPublicKey and fetchedNetwork alongside isFunded so the account_funded property fails closed across an account switch. Without the fetchedPublicKey reset, the stale key still matches the briefly-still-active previous account during the switch window, emitting a wrong funded status. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| @@ -0,0 +1,523 @@ | |||
| // Mobile analytics couples to Zustand stores, the RN SDK, and device-info at | |||
There was a problem hiding this comment.
could we move this test file under the main __tests__ folder?
| export type Surface = "mobile_ios" | "mobile_android"; | ||
|
|
||
| export const getSurface = (): Surface => | ||
| Platform.OS === "ios" ? "mobile_ios" : "mobile_android"; |
There was a problem hiding this comment.
we have an isIOS helper that could be used here
CassioMG
left a comment
There was a problem hiding this comment.
This looks good to me, left some minor comments
APP_OPENED was "event: App Opened" while the extension emits "app.opened", so the single most basic cross-platform funnel (app opens) couldn't merge. Align to "app.opened". app.opened is foundation-owned (the domain.action_past renames for other events live in the later screen.viewed / domain-event slices), so the alignment belongs on this foundation slice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDXpJqihDLf2yE3Ur7UaUT * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDXpJqihDLf2yE3Ur7UaUT * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDXpJqihDLf2yE3Ur7UaUT * docs(analytics): remove internal slice-name references from comments * 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. * refactor(analytics): declare screen_name explicitly for named screens 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * analytics(screen.viewed): emit send_payment_success on SENT TransactionProcessingScreen renders both the in-flight and terminal success states, so the success funnel stage was previously unobservable. Add VIEW_SEND_SUCCESS ("send_payment_success", flow:"send", step:"success") and emit it when the submission settles into the SENT status, guarded to fire at most once per mount. This completes confirm -> processing -> success for the send flow on mobile and pairs with a matching send-success emission on the extension (stellar/freighter#2907) so the step funnel is symmetric cross-platform. Extends the processing-screen test to cover the SENT path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): domain-event consolidation (#2883) (#938) * refactor(analytics): consolidate domain events to shared catalog (#2883) Rename the remaining action/outcome analytics events to the shared cross-platform `domain.action_past` grammar and consolidate events that describe the same action behind a call-site discriminator. - Rewrite the AnalyticsEvent wire strings (payment/swap/signing/asset/ account/onboarding/discovery/history/onramp/platform events). - Collapse the four Blockaid scan events into `blockaid.scan_completed` (scan_target + result) and add `blockaid.scan_failed` at scan-failure paths; skip the expected non-mainnet short-circuit and cancellations. - Collapse the swap pickers into `swap.picker_opened` (side), the add/remove prompt responses into `asset_add.responded` / `asset_remove.responded` (decision), the store-open events into `app_update.store_opened` (source), the payment-type selections, the unsafe-asset add, and the trustline-removal failures. - Route path/routed payment outcomes to swap events and collectible send failures to `collectible_send.failed`. - Align touched property keys to the grammar (origin, reason_code, from_asset_code/to_asset_code, operation) and add asset_code. - Remove the redundant, unreferenced "account screen: copied public key". schema_version/surface/network/account_id_hash still come from buildCommonContext; screen.viewed and the property-model foundation are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFcohvcYZtftSU5Kcypg5P * test(analytics): cover renamed and consolidated domain events (#2883) - Extend core.test.ts with a domain-event catalog block: verifies the renamed wire strings, the scan_target/side/decision/source discriminators, blockaid.scan_failed, and a grammar guard asserting every non-screen domain event stays on domain.action_past. - Extend blockaid/api.test.ts to assert scan_completed (asset/asset_bulk) and the new scan_failed emission. - Lock a representative slice of the catalog in Analytics.test.ts. - Update the swap picker assertions to the consolidated swap.picker_opened event with side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFcohvcYZtftSU5Kcypg5P * analytics: cross-platform property-shape parity + deferred fixes Reconcile the mobile analytics catalog with the freighter extension against the shared cross-platform analytics schema, plus deferred signing / trustline items. - Property shapes: asset_code+asset_issuer (split combined asset); payment. completed asset_code (drop operationType); swap.* {from,to}_asset_code (path-payment branch gains to_asset_code); reason_code-only failures (drop errorCode/operationType/isSwap); collectible snake_case; public_key_copied / recovery_phrase.copied carry no context/action; account.renamed source (drop names); history.item_opened/full_history_opened source; discover protocol_id; reauth drops constant context/method. - Re-point mnemonic-restore to account_recovery.* (was mislabeled account.import*); secret-key path keeps account.import* with import_method. - Blockaid result normalized to safe|warn|block|unknown. - Wire user-reject events (signing.message_rejected/auth_entry_rejected) in the WalletKit reject path. - Emit trustline_remove.failed with has_balance/buying_liabilities/low_reserve derived from Horizon op result codes + balances store. Contract-breaking wire/payload changes (hard cutover per #2883) — notify dashboard owners. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: address cross-platform drift review (history event, snake_case props) - The "View on Stellar Expert" tap in the transaction-detail sheet now emits history.item_opened {source:"transaction_detail"} (was history.full_history_opened) — it opens a specific operation, matching the extension's mapping for the same gesture. - snake_case the blockaid scan_completed extras (token_code, address_count). - Fix stale catalog/JSDoc comments (asset.added carries asset_issuer; asset_add/asset_remove.responded carry `asset`; relocate the scan-failure doc block onto trackScanFailed). - Test: use the lowercase wire value ("safe") for the blockaid result pass-through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: wire transaction-reject + split dapp_access blocked/rejected + source - signing.transaction_rejected now emitted on the dApp tx-reject path (SIGN_XDR / SIGN_AND_SUBMIT_XDR), mirroring the approve side and the extension. - dapp_access.rejected now carries origin only (genuine user cancel); the not-authenticated auto-reject moves to a new dapp_access.blocked {origin, reason_code:"not_authenticated"} — a system block, not a user decision. - asset_add/asset_remove.responded carry source:"manage_assets" (manual in-app path), distinguishing from the extension's dApp source:"dapp_api". - Doc: signing.transaction_blocked (memo_required) not emitted on mobile — the memo-required state is a passive UI gate with no reachable block point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: normalize origin to hostname; fix stale comments; asset_issuer - `origin` on all signing / dApp-access events is normalized to the bare dApp hostname (was the raw full URL from WalletConnect metadata), matching the extension's hostname-based origin so cross-platform funnels merge. Centralized via an originProps() helper in transactions.ts (getDisplayHost). - asset_issuer on the remove paths uses the real `tokenIssuer` variable instead of a colon-split that yielded undefined for colon-less contract identifiers. - Correct stale comments: the message/auth-entry/transaction reject paths ARE emitted (WalletKitProvider); `origin` matches the extension. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: drift-review decisions (drop tx hash/type, snake_case, asset_code, count) - Drop transactionHash/transactionType from signing.transaction_approved and transaction.submitted (N1) — parity with the extension (which emits neither); transactionType was never actually populated, and transaction_hash is a high-cardinality, identity-linking dimension. - payment.simulation_failed: transactionType -> transaction_type (N2, snake_case). - asset_add.responded / asset_remove.responded: asset -> asset_code (N3). - account.created (ACCOUNT_SCREEN_ADD_ACCOUNT): carries number_of_accounts (N4); see the call-site comment re: tap-time vs creation-success semantics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: round-4 drift fixes (reason_code result codes, count timing, reauth/onramp parity) - transaction.failed / swap.failed: reason_code now prefers the machine-readable Horizon result code, falling back to a StrKey-scrubbed message, so it buckets with the extension's SubmitFail derivation. Threaded resultCodes through the payment, collectible, and swap submit sites. - account.created: emit on the creation-success path with the real post-creation account count instead of at tap time (dropped the over-counting ManageAccounts tap emit). - onboarding.password_created: add the success side on signUp success (was fail-only on mobile). - reauth.failed: carry a scrubbed reason_code, matching the extension. - onramp.coinbase_opened: carry { asset }. - Catalog annotations for reserved / platform-specific events. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: D8 — consolidate onboarding.completed to a single terminal point Mobile emitted onboarding.completed from four UI sites (ValidateRecoveryPhrase, RecoveryPhrase, and both BiometricsEnableScreen branches), risking double-counts and, on the import/recover path, firing alongside account_recovery.completed. Consolidate to one deterministic emit in the signUp store action's success path (the create-account terminal point, mirroring the extension's confirmMnemonicPhrase.fulfilled). The import/recover flow keeps account_recovery.completed only (module importWallet), matching the extension's create-vs-recover split — so import no longer double-signals as onboarding. This intentionally drops onboarding.completed from the import flow and shifts the create completion point to wallet creation; historical continuity of the series is deprioritized in favor of cross-platform parity (per product direction). Removed the now-unused analytics imports from BiometricsEnableScreen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: end the D1/D2 recurrence (reason_code + blockaid result fallbacks) Both drift findings kept recurring because prior rounds aligned the primary value each platform emits but left the fallback/default divergent. Fix the edges so there is nothing left to re-flag: - D1 (reason_code): trackTransactionError now emits data.errorCode ?? "unknown", byte-for-byte identical to the extension's SubmitFail derivation. Dropped the scrubbed free-text fallback that produced unbounded reason_code cardinality the extension never emits (full message still logged to Sentry). Regression test asserts the free-text never leaks into reason_code. - D2 (blockaid result): the asset / asset_bulk analytics `result` is now derived DIRECTLY from the raw Blockaid result_type (new resultFromResultType), not the UI SecurityLevel — so an unclassifiable token is `unknown` on both platforms, not silently `safe`. UI security assessment is untouched. Regression tests cover missing and unrecognized result_type. Security-hygiene (defense-in-depth, third-party sink): scrub StrKeys on the remaining free-text reason_code paths the PR had missed — payment.simulation_failed and asset.operation_failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: scrub signing-failure reason_code + bound asset.operation_failed (D2/D3/D5) - D2 (security, high): trackSignedMessageError / trackSignedAuthEntryError now scrub StrKeys from reason_code before it reaches Amplitude, matching the extension's signBlob/signEntry.rejected handlers. A signing exception's message can embed a G…/S… key; mobile was shipping it verbatim. Regression test asserts the key is redacted to G***. - D3: asset.operation_failed.reason_code is now the bounded Horizon op result code (opResultCodes[0] ?? "unknown"), not free-text — same discipline already applied to payment.failed/swap.failed, and identical to the extension's `opCodes[0] || "unknown"`. Reuses the op-code extraction the remove path already had (now a shared opResultCodesOf helper); drops the free-text scrubReasonCode path entirely. - D5: swap.trustline_added now uses snake_case asset_code/asset_issuer (was camelCase tokenCode/tokenIssuer), matching sibling asset.added. Kept in lockstep with the extension's identical rename. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: transaction-scan result from raw result_type + scrub stragglers (D1/D4) - D1 (high, data correctness): blockaid.scan_completed{scan_target:"transaction"} now derives `result` from the raw validation.result_type (guarded like the extension's `"result_type" in validation` check; missing/unrecognized -> "unknown"), instead of assessTransactionSecurity().level. The UI model defaulted unclassifiable results to SAFE and mapped simulation.error to SUSPICIOUS/warn — both diverged from the extension and inflated mobile's safe/warn buckets. This mirrors the token path (resultFromResultType) fixed last round; transaction was the remaining straggler. Regression tests cover recognized, unclassifiable, and simulation.error cases. - Scrub stragglers (defense-in-depth, third-party sink): trackAccountScreen- ImportAccountFail (the secret-key import path — likeliest S… leak) and trackQRScanError (QR payloads carry G…/S… keys) now scrub inside the helper, matching the other track*Error helpers. Found via a full sweep of every free-text reason_code assignment, not just the flagged one. - D4 (catalog hygiene): reserve blockaid.warning_reported in mobile's catalog (extension-only emit) so the wire string can't be reinvented/drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * analytics: address PR review — scan_failed scrub, StrKey C/M, swap key parity, reject guard Review comments from #938 (paired with the extension #2909 changes): - blockaid.scan_failed reason_code now scrubbed (trackScanFailed) — the last raw error path; matches the other track*Error helpers. - scrubStrKeys now covers contract (C…, 56) and muxed (M…, 69) StrKeys, not just G…/S…; widened to tolerate null. (Reviewer noted C/M; corrected the length — muxed is 69 chars, so it's an alternation, not [GSCM]{55}.) - swap.source_selected / destination_selected use snake_case asset_code / asset_issuer / requires_trustline; swap.quote_expired drops the raw amounts (privacy parity with completed/failed) and emits from_asset_code / to_asset_code (bare codes) + result_code. Kept in lockstep with the extension. - signing.*_rejected: extracted resolveDappRejectionEvent (pure, unit-tested) so an approved/completed request — or an approve attempt that threw — is never miscounted as a user reject. Added approvalInFlightRef to separate the WC fallback rejection from the analytics emit. - Completed the jest.setup.js StellarRpcMethods mock (was missing SIGN_MESSAGE / SIGN_AUTH_ENTRY) and moved core.test.ts into __tests__/ (requireActual path updated to keep bypassing the services/* mapper + global mock). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts: # __tests__/config/analyticsConfig.test.ts # src/config/analyticsConfig.ts
TL;DR
The mobile side of the cross-platform Amplitude refactor — the counterpart to the extension foundation (stellar/freighter#2903). Brings mobile's analytics property model in line with the shared cross-platform schema, without renaming any events yet. Every event now carries a
schema_version; a privacy-safe hashed account id replaces the truncated public key that events used to carry; device/app metadata is no longer hand-duplicated onto each event; durable wallet traits move to Amplitude Identify; andapp.openednow carries a one-time per-open snapshot.account_fundedis derived from the active account's live balance (accurate per-account), and event surface is taggedmobile_ios/mobile_android.publicKey,platform,platformVersion,appVersion,buildVersion,bundleId,connectionType, and top-leveleffectiveType) are removed from the per-event payload by design — any dashboard/query keyed on them stops receiving them. Connectivity now lives only onapp.openedasconnection_type/effective_type. Event names are unchanged in this PR (rename slices come later). No new data is collected — this net removes a truncated key from payloads and behavioral autocapture stays off.Implementation details (for agents/reviewers)
Evolves
src/services/analytics/core.tsin place (no re-architecture). Cross-platform contracts are pinned in the RFC (stellar/wallet-eng-monorepo#10, addendum #23).What changed:
schema_version("2") stamped centrally inbuildCommonContext.account_id_hash— memoized SHA-256 hex of the full G-address (hash(Buffer.from(publicKey,"utf8"))), matching the extension's committed vector (G…AWHF → f56f6f2c…44ef) so account-level joins work cross-platform. Replaces the truncatedpublicKey. Omitted pre-unlock.app.openedsnapshot.syncIdentifyTraits, dirty-checked + consent-gated):wallet_count(=allAccounts.length),has_imported_account, plus existingbundle_id. Nohas_hardware_wallet(mobile has no hardware wallets). Synced after init and re-synced when consent enables or the account list changes — so an already-consented returning user still gets traits.account_funded— derived fromuseBalancesStore, gated on a newfetchedPublicKey === activePublicKeysignal in the balances store, so it reflects the active account and fails closed (omitted, neverfalse) on account switch / stale / unfetched. This is more accurate than the extension's prior sticky-per-type flag (the extension was updated to match in #2903).account_type=freighter|imported_secret_key(fromimportedFromSecretKey), omitted when the active account isn't resolvable inallAccounts(avoids mislabeling during an auth-store update race). Nois_hardware_account.app.opened(legacy name kept) enriched withsurface/connection_type/effective_type;surface=mobile_ios/mobile_android.Verification: built via TDD, one commit per task with an independent spec+quality review per task and a whole-branch review at the end (verdict: ready to merge, no Critical/Important).
yarn jest src/services/analytics/core.test.ts→ 17/17; balances store test added; full suite green (~2693);lint:tsclean. A regression test guards against any raw/truncated public key in event payloads.Follow-ups / out of scope (non-blocking):
networkon theapp.openedpayload; prune two stale mock keys injest.setup.js(flushEvents/flushOnBackground).ducks/auth.tsrace:createAccount/importSecretKeyupdateaccountandallAccountsvia un-orderedPromise.all(brief window where they disagree). This analytics code handles it gracefully (omitsaccount_type); the underlying atomicity is broader than this effort.user_idremains as-is — the unified cross-platform id is owned separately (mobile [Mobile] Derive auth keypair from seed for backend authentication #864).Companion: extension #2903, RFC #23.