Skip to content

feat(analytics): property-model foundation (cross-platform schema alignment)#936

Merged
piyalbasu merged 16 commits into
mainfrom
feat/analytics-property-model-foundation
Jul 24, 2026
Merged

feat(analytics): property-model foundation (cross-platform schema alignment)#936
piyalbasu merged 16 commits into
mainfrom
feat/analytics-property-model-foundation

Conversation

@piyalbasu

Copy link
Copy Markdown
Contributor

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; and app.opened now carries a one-time per-open snapshot. account_funded is derived from the active account's live balance (accurate per-account), and event surface is tagged mobile_ios/mobile_android.

⚠️ Hard cutover: legacy context fields (publicKey, platform, platformVersion, appVersion, buildVersion, bundleId, connectionType, and top-level effectiveType) are removed from the per-event payload by design — any dashboard/query keyed on them stops receiving them. Connectivity now lives only on app.opened as connection_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.ts in 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 in buildCommonContext.
  • 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 truncated publicKey. Omitted pre-unlock.
  • Four-bucket property model: dropped the hand-sent SDK/device fields (rely on the RN SDK's context enrichment); durable traits → Identify; volatile context stays event-level; connectivity → app.opened snapshot.
  • Identify traits (syncIdentifyTraits, dirty-checked + consent-gated): wallet_count (=allAccounts.length), has_imported_account, plus existing bundle_id. No has_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 from useBalancesStore, gated on a new fetchedPublicKey === activePublicKey signal in the balances store, so it reflects the active account and fails closed (omitted, never false) 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 (from importedFromSecretKey), omitted when the active account isn't resolvable in allAccounts (avoids mislabeling during an auth-store update race). No is_hardware_account.
  • app.opened (legacy name kept) enriched with surface/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:ts clean. A regression test guards against any raw/truncated public key in event payloads.

Follow-ups / out of scope (non-blocking):

  • Assert network on the app.opened payload; prune two stale mock keys in jest.setup.js (flushEvents/flushOnBackground).
  • Pre-existing ducks/auth.ts race: createAccount/importSecretKey update account and allAccounts via un-ordered Promise.all (brief window where they disagree). This analytics code handles it gracefully (omits account_type); the underlying atomicity is broader than this effort.
  • Identity/user_id remains 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.

piyalbasu and others added 10 commits July 15, 2026 13:09
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.
Copilot AI review requested due to automatic review settings July 15, 2026 19:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/services/analytics/core.ts
Comment thread src/services/analytics/core.ts Outdated
Comment thread src/services/analytics/core.ts Outdated
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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)

piyalbasu and others added 2 commits July 16, 2026 11:47
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have an isIOS helper that could be used here

@CassioMG CassioMG left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me, left some minor comments

piyalbasu and others added 4 commits July 21, 2026 11:01
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
@piyalbasu
piyalbasu merged commit 1ce8eb7 into main Jul 24, 2026
33 of 35 checks passed
@piyalbasu
piyalbasu deleted the feat/analytics-property-model-foundation branch July 24, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants