From e506d96034000febcfd43c055a2fb3ca6251a87e Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 19 Aug 2026 02:01:45 +0800 Subject: [PATCH 1/2] test(perps): add reusable visible-pipeline probe --- .../utils/homepagePerformanceProbe.test.ts | 148 +++++ .../Perps/utils/homepagePerformanceProbe.ts | 581 ++++++++++++++++++ 2 files changed, 729 insertions(+) create mode 100644 app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts create mode 100644 app/components/UI/Perps/utils/homepagePerformanceProbe.ts diff --git a/app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts b/app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts new file mode 100644 index 00000000000..8978982cd62 --- /dev/null +++ b/app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts @@ -0,0 +1,148 @@ +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import { + activateHomepagePerformanceProbe, + createHomepagePerformanceDemand, + createHomepagePerpsDelivery, + createHomepagePerpsResidentDelivery, + isHomepagePerpsDeliveryFreshForDemand, + markHomepagePerpsAccountSwitch, + recordHomepagePerpsVisibleFrame, + resetHomepagePerformanceProbeForTests, +} from './homepagePerformanceProbe'; + +jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ + DevLogger: { log: jest.fn() }, +})); + +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'demand-1'), +})); + +jest.mock('react-native-performance', () => ({ + __esModule: true, + default: { + now: jest.fn(() => 1000), + }, +})); + +const stages = () => + jest + .mocked(DevLogger.log) + .mock.calls.map(([message]) => String(message)) + .filter((message) => message.includes('[PerpsPerf]')) + .map( + (message) => + JSON.parse(message.replace('[PerpsPerf] ', '')) as { + stage: string; + }, + ); + +describe('homepagePerformanceProbe', () => { + const devGlobal = global as typeof global & { __DEV__: boolean }; + const previousDev = devGlobal.__DEV__; + + beforeAll(() => { + devGlobal.__DEV__ = true; + }); + + afterAll(() => { + devGlobal.__DEV__ = previousDev; + }); + + beforeEach(() => { + jest.clearAllMocks(); + resetHomepagePerformanceProbeForTests(); + activateHomepagePerformanceProbe(); + }); + + it('emits the canonical surface_demand stage', () => { + createHomepagePerformanceDemand(); + + expect(stages()).toEqual([ + expect.objectContaining({ stage: 'surface_demand' }), + ]); + }); + + it('records resolved and live surface stages from a fresh demand', () => { + const demand = createHomepagePerformanceDemand(); + const positions = createHomepagePerpsDelivery({ + stream: 'positions', + source: 'fresh_socket', + itemCount: 1, + }); + const orders = createHomepagePerpsDelivery({ + stream: 'orders', + source: 'fresh_socket', + itemCount: 0, + }); + + recordHomepagePerpsVisibleFrame({ + demand, + deliveries: [positions, orders], + contentVariant: 'positions', + reactCommitAtMonotonicMs: 1100, + frameCheckpointAtMonotonicMs: 1200, + }); + + expect(stages().map(({ stage }) => stage)).toEqual([ + 'surface_demand', + 'surface_resolved_recorded', + 'surface_live_recorded', + ]); + }); + + it('does not satisfy a demand with a prior-account delivery', () => { + const stale = createHomepagePerpsDelivery({ + stream: 'positions', + source: 'fresh_socket', + itemCount: 1, + }); + markHomepagePerpsAccountSwitch(); + const demand = createHomepagePerformanceDemand(); + + recordHomepagePerpsVisibleFrame({ + demand, + deliveries: [stale], + contentVariant: 'positions', + reactCommitAtMonotonicMs: 1100, + frameCheckpointAtMonotonicMs: 1200, + }); + + expect(stages().map(({ stage }) => stage)).toEqual(['surface_demand']); + }); + + it('treats resident Terminal-origin markets as fresh in the current lifecycle', () => { + const cached = createHomepagePerpsDelivery({ + stream: 'markets', + source: 'memory_cache', + originSource: 'terminal_global_snapshot_v2', + itemCount: 2, + }); + const resident = createHomepagePerpsResidentDelivery({ + stream: 'markets', + itemCount: 2, + previousDelivery: cached, + }); + const demand = createHomepagePerformanceDemand(); + + expect(resident.originSource).toBe('terminal_global_snapshot_v2'); + expect(isHomepagePerpsDeliveryFreshForDemand(resident, demand)).toBe(true); + }); + + it('does not treat resident markets without a fresh origin as fresh', () => { + const cached = createHomepagePerpsDelivery({ + stream: 'markets', + source: 'memory_cache', + itemCount: 2, + }); + const resident = createHomepagePerpsResidentDelivery({ + stream: 'markets', + itemCount: 2, + previousDelivery: cached, + }); + const demand = createHomepagePerformanceDemand(); + + expect(resident.originSource).toBe('memory_cache'); + expect(isHomepagePerpsDeliveryFreshForDemand(resident, demand)).toBe(false); + }); +}); diff --git a/app/components/UI/Perps/utils/homepagePerformanceProbe.ts b/app/components/UI/Perps/utils/homepagePerformanceProbe.ts new file mode 100644 index 00000000000..8e8568851a5 --- /dev/null +++ b/app/components/UI/Perps/utils/homepagePerformanceProbe.ts @@ -0,0 +1,581 @@ +import type { AppStateStatus } from 'react-native'; +import performance from 'react-native-performance'; +import { PERPS_CONSTANTS } from '@metamask/perps-controller'; +import { v4 as uuidv4 } from 'uuid'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; + +export type HomepagePerpsStream = 'positions' | 'orders' | 'markets' | 'prices'; +export type HomepagePerpsDeliverySource = + | 'memory_cache' + | 'disk_cache' + | 'provider_snapshot' + | 'terminal_global_snapshot_v2' + | 'provider' + | 'resident_state' + | 'fresh_socket' + | 'unknown'; +export type HomepagePerformanceLifecycle = + | 'cold_no_cache' + | 'cold_disk_cache' + | 'warm_foreground' + | 'navigate_return' + | 'background_short' + | 'background_reconnect' + | 'network_recovery' + | 'account_switch' + | 'perps_network_switch' + | 'provider_switch'; +export type HomepagePerpsContentVariant = + | 'empty' + | 'positions' + | 'orders' + | 'positions_and_orders' + | 'trending' + | 'pills' + | 'error'; + +export interface HomepagePerpsDeliveryMetadata { + deliveryId: string; + stream: HomepagePerpsStream; + source: HomepagePerpsDeliverySource; + originSource?: HomepagePerpsDeliverySource; + itemCount: number; + receivedAtMonotonicMs: number; + subscriberDeliveredAtMonotonicMs?: number; + dataAgeMs: number; + lifecycle: HomepagePerformanceLifecycle; + accountGeneration: number; + contextGeneration: number; +} + +export interface HomepagePerformanceDemand { + demandId: string; + startedAtMonotonicMs: number; + lifecycleStartedAtMonotonicMs: number; + lifecycle: HomepagePerformanceLifecycle; + accountGeneration: number; + contextGeneration: number; + firstVisibleRecorded: boolean; + firstFreshVisibleRecorded: boolean; + cachedVisibleAtMonotonicMs?: number; + cachedVisibleSource?: string; + recordedFreshPipelineStreams: Set; + recordedSocketPipelineStreams: Set; +} + +type HomepagePerformanceStage = + | 'disk_cache_hydrated' + | 'surface_demand' + | 'connection_attempt_started' + | 'provider_initialized' + | 'health_check_completed' + | 'connection_established' + | 'subscriptions_preloaded' + | 'connection_with_preload_completed' + | 'account_bundle_request_started' + | 'account_bundle_cache_accepted' + | 'account_bundle_accepted' + | 'account_bundle_discarded' + | 'market_snapshot_request_started' + | 'market_snapshot_resolved' + | 'market_snapshot_error' + | 'socket_received' + | 'cache_write' + | 'subscriber_delivery' + | 'react_commit' + | 'values_visible' + | 'first_frame_checkpoint' + | 'next_frame_checkpoint' + | 'surface_resolved_recorded' + | 'surface_live_recorded'; + +let diskCacheTimestampMs: number | null = null; +let firstDemand = true; +let accountGeneration = 0; +let contextGeneration = 0; +let lifecycle: HomepagePerformanceLifecycle = 'cold_no_cache'; +let lifecycleStartedAtMonotonicMs = performance.now(); +let backgroundStartedAt: number | null = null; +let activeObservationCount = 0; +const lifecycleListeners = new Set<() => void>(); +export const isHomepagePerformanceProbeActive = () => + activeObservationCount > 0; + +export const activateHomepagePerformanceProbe = () => { + activeObservationCount += 1; + let released = false; + + return () => { + if (released) return; + released = true; + activeObservationCount = Math.max(0, activeObservationCount - 1); + }; +}; + +export const logHomepagePerformanceStage = ( + stage: HomepagePerformanceStage, + delivery?: HomepagePerpsDeliveryMetadata, + detail: Record = {}, +) => { + if (!__DEV__ || !isHomepagePerformanceProbeActive()) return; + + DevLogger.log( + `[PerpsPerf] ${JSON.stringify({ + stage, + monotonic_ms: Number(performance.now().toFixed(3)), + ...(delivery && { + delivery_id: delivery.deliveryId, + stream: delivery.stream, + source: delivery.source, + ...(delivery.originSource && { origin_source: delivery.originSource }), + item_count: delivery.itemCount, + data_age_ms: Math.round(delivery.dataAgeMs), + lifecycle: delivery.lifecycle, + account_generation: delivery.accountGeneration, + context_generation: delivery.contextGeneration, + }), + ...detail, + })}`, + ); +}; + +export const logHomepageConnectionStage = ( + stage: Extract< + HomepagePerformanceStage, + | 'connection_attempt_started' + | 'provider_initialized' + | 'health_check_completed' + | 'connection_established' + | 'subscriptions_preloaded' + | 'connection_with_preload_completed' + >, + detail: Record, +) => { + logHomepagePerformanceStage(stage, undefined, detail); +}; + +export const logHomepageAccountStage = ( + stage: Extract< + HomepagePerformanceStage, + | 'account_bundle_request_started' + | 'account_bundle_cache_accepted' + | 'account_bundle_accepted' + | 'account_bundle_discarded' + >, + detail: Record, +) => { + logHomepagePerformanceStage(stage, undefined, detail); +}; + +const notifyLifecycleChange = () => + lifecycleListeners.forEach((listener) => listener()); + +export const subscribeHomepagePerformanceLifecycleChange = ( + listener: () => void, +) => { + lifecycleListeners.add(listener); + return () => { + lifecycleListeners.delete(listener); + }; +}; + +export const handleHomepagePerformanceAppStateChange = ( + nextState: AppStateStatus, +) => { + if (nextState === 'background' || nextState === 'inactive') { + backgroundStartedAt ??= performance.now(); + return; + } + if (nextState !== 'active' || backgroundStartedAt === null) return; + + const foregroundedAt = performance.now(); + lifecycle = + foregroundedAt - backgroundStartedAt < + PERPS_CONSTANTS.ConnectionGracePeriodMs + ? 'background_short' + : 'background_reconnect'; + lifecycleStartedAtMonotonicMs = foregroundedAt; + backgroundStartedAt = null; + queueMicrotask(notifyLifecycleChange); +}; + +export const wasHomepagePerpsDiskCacheHydrated = () => + diskCacheTimestampMs !== null; + +export const getHomepagePerpsDiskCacheAgeMs = () => + diskCacheTimestampMs === null + ? 0 + : Math.max(0, Date.now() - diskCacheTimestampMs); + +export const markHomepagePerpsDiskCacheHydrated = (rawValue: string) => { + try { + const parsed = JSON.parse(rawValue) as { + timestamp?: number; + entries?: { timestamp?: number }[]; + }; + const timestamps = [ + parsed.timestamp, + ...(parsed.entries?.map((entry) => entry.timestamp) ?? []), + ].filter((value): value is number => typeof value === 'number'); + diskCacheTimestampMs = + timestamps.length > 0 ? Math.min(...timestamps) : null; + } catch { + diskCacheTimestampMs = null; + } + if (firstDemand && diskCacheTimestampMs !== null) { + lifecycle = 'cold_disk_cache'; + lifecycleStartedAtMonotonicMs = performance.now(); + } + logHomepagePerformanceStage('disk_cache_hydrated', undefined, { + cache_age_ms: getHomepagePerpsDiskCacheAgeMs(), + }); +}; + +const setLifecycle = ( + next: HomepagePerformanceLifecycle, + { advancesContext = false }: { advancesContext?: boolean } = {}, +) => { + if (lifecycle === next && !advancesContext) return; + if (advancesContext) contextGeneration += 1; + lifecycle = next; + lifecycleStartedAtMonotonicMs = performance.now(); + notifyLifecycleChange(); +}; + +export const markHomepagePerpsNavigateReturn = () => { + if ( + lifecycle === 'cold_no_cache' || + lifecycle === 'cold_disk_cache' || + lifecycle === 'warm_foreground' || + lifecycle === 'navigate_return' + ) { + setLifecycle('navigate_return'); + } +}; + +export const markHomepagePerpsNetworkRecovery = () => + setLifecycle('network_recovery'); + +export const markHomepagePerpsNetworkSwitch = () => + setLifecycle('perps_network_switch', { advancesContext: true }); + +export const markHomepagePerpsProviderSwitch = () => + setLifecycle('provider_switch', { advancesContext: true }); + +export const markHomepagePerpsAccountSwitch = () => { + accountGeneration += 1; + setLifecycle('account_switch', { advancesContext: true }); +}; + +export const createHomepagePerpsDelivery = ({ + stream, + source, + itemCount, + dataAgeMs = 0, + originSource, +}: { + stream: HomepagePerpsStream; + source: HomepagePerpsDeliverySource; + itemCount: number; + dataAgeMs?: number; + originSource?: HomepagePerpsDeliverySource; +}): HomepagePerpsDeliveryMetadata => ({ + deliveryId: uuidv4(), + stream, + source, + ...(originSource && { originSource }), + itemCount, + receivedAtMonotonicMs: performance.now(), + dataAgeMs, + lifecycle, + accountGeneration, + contextGeneration, +}); + +export const createHomepagePerpsResidentDelivery = ({ + stream, + itemCount, + previousDelivery, +}: { + stream: HomepagePerpsStream; + itemCount: number; + previousDelivery?: HomepagePerpsDeliveryMetadata; +}): HomepagePerpsDeliveryMetadata => { + const now = performance.now(); + return { + deliveryId: uuidv4(), + stream, + source: 'resident_state', + originSource: previousDelivery?.originSource ?? previousDelivery?.source, + itemCount, + receivedAtMonotonicMs: previousDelivery?.receivedAtMonotonicMs ?? now, + dataAgeMs: previousDelivery + ? previousDelivery.dataAgeMs + + now - + previousDelivery.receivedAtMonotonicMs + : 0, + lifecycle, + accountGeneration: previousDelivery?.accountGeneration ?? accountGeneration, + contextGeneration: previousDelivery?.contextGeneration ?? contextGeneration, + }; +}; + +export const createHomepagePerformanceDemand = + (): HomepagePerformanceDemand => { + const demand: HomepagePerformanceDemand = { + demandId: uuidv4(), + startedAtMonotonicMs: performance.now(), + lifecycleStartedAtMonotonicMs, + lifecycle, + accountGeneration, + contextGeneration, + firstVisibleRecorded: false, + firstFreshVisibleRecorded: false, + recordedFreshPipelineStreams: new Set(), + recordedSocketPipelineStreams: new Set(), + }; + firstDemand = false; + logHomepagePerformanceStage('surface_demand', undefined, { + demand_id: demand.demandId, + lifecycle: demand.lifecycle, + account_generation: demand.accountGeneration, + context_generation: demand.contextGeneration, + }); + return demand; + }; + +export const isHomepagePerpsDeliveryFreshForDemand = ( + delivery: HomepagePerpsDeliveryMetadata, + demand: HomepagePerformanceDemand, + isConnectionLive = false, +) => + (delivery.receivedAtMonotonicMs >= demand.lifecycleStartedAtMonotonicMs || + (demand.lifecycle === 'navigate_return' && + isConnectionLive && + delivery.source === 'resident_state' && + delivery.originSource === 'fresh_socket')) && + delivery.accountGeneration === demand.accountGeneration && + delivery.contextGeneration === demand.contextGeneration && + (delivery.source === 'fresh_socket' || + delivery.source === 'provider_snapshot' || + delivery.source === 'terminal_global_snapshot_v2' || + delivery.source === 'provider' || + ((delivery.source === 'resident_state' || + delivery.source === 'memory_cache') && + (delivery.originSource === 'fresh_socket' || + delivery.originSource === 'provider_snapshot' || + delivery.originSource === 'terminal_global_snapshot_v2' || + delivery.originSource === 'provider'))); + +const hasRequiredStreams = ( + deliveries: HomepagePerpsDeliveryMetadata[], + contentVariant: HomepagePerpsContentVariant, +) => { + const streams = new Set(deliveries.map(({ stream }) => stream)); + const hasAccountResolution = + streams.has('positions') && streams.has('orders'); + return contentVariant === 'trending' || contentVariant === 'pills' + ? hasAccountResolution && streams.has('markets') + : hasAccountResolution; +}; + +const getRequiredDeliveries = ( + deliveries: HomepagePerpsDeliveryMetadata[], + contentVariant: HomepagePerpsContentVariant, +) => { + const requiresMarkets = + contentVariant === 'trending' || contentVariant === 'pills'; + return deliveries.filter( + ({ stream }) => + stream === 'positions' || stream === 'orders' || requiresMarkets, + ); +}; + +const getVisibleDeliveries = ( + deliveries: HomepagePerpsDeliveryMetadata[], + contentVariant: HomepagePerpsContentVariant, +) => { + if (contentVariant === 'positions') { + return deliveries.filter(({ stream }) => stream === 'positions'); + } + if (contentVariant === 'orders') { + return deliveries.filter(({ stream }) => stream === 'orders'); + } + return getRequiredDeliveries(deliveries, contentVariant); +}; + +const hasVisibleStreams = ( + deliveries: HomepagePerpsDeliveryMetadata[], + contentVariant: HomepagePerpsContentVariant, +) => { + const streams = new Set(deliveries.map(({ stream }) => stream)); + if (contentVariant === 'positions') return streams.has('positions'); + if (contentVariant === 'orders') return streams.has('orders'); + return hasRequiredStreams(deliveries, contentVariant); +}; + +const getEffectiveSource = (delivery: HomepagePerpsDeliveryMetadata) => + delivery.source === 'resident_state' || delivery.source === 'memory_cache' + ? (delivery.originSource ?? delivery.source) + : delivery.source; + +export const recordHomepagePerpsVisibleFrame = ({ + demand, + deliveries, + contentVariant, + isConnectionLive = false, + frameCheckpointAtMonotonicMs, +}: { + demand: HomepagePerformanceDemand; + deliveries: HomepagePerpsDeliveryMetadata[]; + contentVariant: HomepagePerpsContentVariant; + isConnectionLive?: boolean; + reactCommitAtMonotonicMs: number; + frameCheckpointAtMonotonicMs: number; +}) => { + const visibleDeliveries = getVisibleDeliveries(deliveries, contentVariant); + if (!hasVisibleStreams(visibleDeliveries, contentVariant)) { + return; + } + if ( + visibleDeliveries.some( + (delivery) => + delivery.accountGeneration !== demand.accountGeneration || + delivery.contextGeneration !== demand.contextGeneration, + ) + ) { + return; + } + + const tagsFor = (tagDeliveries: HomepagePerpsDeliveryMetadata[]) => { + const sources = new Set(tagDeliveries.map(({ source }) => source)); + const deliverySource = + sources.size === 1 ? tagDeliveries[0].source : 'mixed'; + const sourceFor = (stream: HomepagePerpsStream) => { + const delivery = tagDeliveries.find( + (candidate) => candidate.stream === stream, + ); + return delivery ? getEffectiveSource(delivery) : 'not_required'; + }; + return { + instrumentation_schema: 'homepage_perps_visible_v4', + lifecycle: demand.lifecycle, + account_generation: String(demand.accountGeneration), + context_generation: String(demand.contextGeneration), + content_variant: contentVariant, + delivery_source: deliverySource, + positions_source: sourceFor('positions'), + orders_source: sourceFor('orders'), + markets_source: sourceFor('markets'), + data_ready_at_demand: tagDeliveries.every( + ({ receivedAtMonotonicMs }) => + receivedAtMonotonicMs <= demand.startedAtMonotonicMs, + ), + frame_boundary: 'next_frame_checkpoint', + success: true, + }; + }; + const visibleTags = tagsFor(visibleDeliveries); + + if (!demand.firstVisibleRecorded) { + demand.firstVisibleRecorded = true; + logHomepagePerformanceStage('surface_resolved_recorded', undefined, { + demand_id: demand.demandId, + frame_checkpoint_monotonic_ms: frameCheckpointAtMonotonicMs, + duration_ms: Number( + (frameCheckpointAtMonotonicMs - demand.startedAtMonotonicMs).toFixed(3), + ), + ...visibleTags, + }); + if ( + visibleDeliveries.some( + (delivery) => + !isHomepagePerpsDeliveryFreshForDemand( + delivery, + demand, + isConnectionLive, + ), + ) + ) { + demand.cachedVisibleAtMonotonicMs = frameCheckpointAtMonotonicMs; + demand.cachedVisibleSource = visibleTags.delivery_source; + } + } + + const requiredDeliveries = getRequiredDeliveries(deliveries, contentVariant); + const hasCompleteRequiredStreams = hasRequiredStreams( + requiredDeliveries, + contentVariant, + ); + + if ( + !hasCompleteRequiredStreams || + requiredDeliveries.some( + (delivery) => + delivery.accountGeneration !== demand.accountGeneration || + delivery.contextGeneration !== demand.contextGeneration, + ) + ) { + return; + } + + const allFresh = requiredDeliveries.every((delivery) => + isHomepagePerpsDeliveryFreshForDemand(delivery, demand, isConnectionLive), + ); + if (!allFresh || demand.firstFreshVisibleRecorded) return; + + const freshnessSources = new Set(requiredDeliveries.map(getEffectiveSource)); + const freshnessSource = + freshnessSources.size === 1 + ? (freshnessSources.values().next().value ?? 'mixed') + : 'mixed'; + const freshTags = tagsFor(requiredDeliveries); + + demand.firstFreshVisibleRecorded = true; + logHomepagePerformanceStage('surface_live_recorded', undefined, { + demand_id: demand.demandId, + frame_checkpoint_monotonic_ms: frameCheckpointAtMonotonicMs, + duration_ms: Number( + (frameCheckpointAtMonotonicMs - demand.startedAtMonotonicMs).toFixed(3), + ), + ...freshTags, + freshness_source: freshnessSource, + }); +}; + +export const recordHomepagePerpsErrorFrame = ({ + demand, + frameCheckpointAtMonotonicMs, +}: { + demand: HomepagePerformanceDemand; + frameCheckpointAtMonotonicMs: number; +}) => { + if (demand.firstVisibleRecorded) return; + demand.firstVisibleRecorded = true; + logHomepagePerformanceStage('surface_resolved_recorded', undefined, { + demand_id: demand.demandId, + frame_checkpoint_monotonic_ms: frameCheckpointAtMonotonicMs, + duration_ms: Number( + (frameCheckpointAtMonotonicMs - demand.startedAtMonotonicMs).toFixed(3), + ), + content_variant: 'error', + success: false, + }); +}; + +export const markHomepagePerformanceDemandComplete = () => { + lifecycle = 'warm_foreground'; + lifecycleStartedAtMonotonicMs = performance.now(); +}; + +export const resetHomepagePerformanceProbeForTests = () => { + diskCacheTimestampMs = null; + firstDemand = true; + accountGeneration = 0; + contextGeneration = 0; + lifecycle = 'cold_no_cache'; + lifecycleStartedAtMonotonicMs = performance.now(); + backgroundStartedAt = null; + activeObservationCount = 0; + lifecycleListeners.clear(); +}; From 1c68b432153cd2d9d99f28d744dce75d1a5fff06 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 19 Aug 2026 06:26:13 +0800 Subject: [PATCH 2/2] fix(perps): align visible probe contract --- .../utils/homepagePerformanceProbe.test.ts | 127 ++++++++++++- .../Perps/utils/homepagePerformanceProbe.ts | 179 ++++++++++-------- 2 files changed, 226 insertions(+), 80 deletions(-) diff --git a/app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts b/app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts index 8978982cd62..1ebe28dd0f4 100644 --- a/app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts +++ b/app/components/UI/Perps/utils/homepagePerformanceProbe.test.ts @@ -1,11 +1,15 @@ import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import performance from 'react-native-performance'; import { activateHomepagePerformanceProbe, createHomepagePerformanceDemand, createHomepagePerpsDelivery, createHomepagePerpsResidentDelivery, isHomepagePerpsDeliveryFreshForDemand, + handleHomepagePerformanceAppStateChange, + logHomepagePerformanceStage, markHomepagePerpsAccountSwitch, + markHomepagePerformanceDemandComplete, recordHomepagePerpsVisibleFrame, resetHomepagePerformanceProbeForTests, } from './homepagePerformanceProbe'; @@ -51,6 +55,7 @@ describe('homepagePerformanceProbe', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(performance.now).mockReturnValue(1000); resetHomepagePerformanceProbeForTests(); activateHomepagePerformanceProbe(); }); @@ -75,10 +80,15 @@ describe('homepagePerformanceProbe', () => { source: 'fresh_socket', itemCount: 0, }); + const account = createHomepagePerpsDelivery({ + stream: 'account', + source: 'fresh_socket', + itemCount: 1, + }); recordHomepagePerpsVisibleFrame({ demand, - deliveries: [positions, orders], + deliveries: [positions, orders, account], contentVariant: 'positions', reactCommitAtMonotonicMs: 1100, frameCheckpointAtMonotonicMs: 1200, @@ -145,4 +155,119 @@ describe('homepagePerformanceProbe', () => { expect(resident.originSource).toBe('memory_cache'); expect(isHomepagePerpsDeliveryFreshForDemand(resident, demand)).toBe(false); }); + + it('does not require an unrelated price delivery for trending', () => { + const stalePrice = createHomepagePerpsDelivery({ + stream: 'prices', + source: 'fresh_socket', + itemCount: 1, + }); + markHomepagePerpsAccountSwitch(); + const demand = createHomepagePerformanceDemand(); + const deliveries = [ + createHomepagePerpsDelivery({ + stream: 'positions', + source: 'fresh_socket', + itemCount: 0, + }), + createHomepagePerpsDelivery({ + stream: 'orders', + source: 'fresh_socket', + itemCount: 0, + }), + createHomepagePerpsDelivery({ + stream: 'account', + source: 'fresh_socket', + itemCount: 1, + }), + createHomepagePerpsDelivery({ + stream: 'markets', + source: 'provider', + itemCount: 2, + }), + stalePrice, + ]; + + recordHomepagePerpsVisibleFrame({ + demand, + deliveries, + contentVariant: 'trending', + reactCommitAtMonotonicMs: 1100, + frameCheckpointAtMonotonicMs: 1200, + }); + + expect(stages().map(({ stage }) => stage)).toEqual([ + 'surface_demand', + 'surface_resolved_recorded', + 'surface_live_recorded', + ]); + }); + + it('does not let an old demand complete a new account generation', () => { + const demand = createHomepagePerformanceDemand(); + markHomepagePerpsAccountSwitch(); + const deliveries = ['positions', 'orders', 'account'].map((stream) => + createHomepagePerpsDelivery({ + stream: stream as 'positions' | 'orders' | 'account', + source: 'fresh_socket', + itemCount: 1, + }), + ); + + recordHomepagePerpsVisibleFrame({ + demand, + deliveries, + contentVariant: 'positions', + reactCommitAtMonotonicMs: 1100, + frameCheckpointAtMonotonicMs: 1200, + }); + markHomepagePerformanceDemandComplete(demand); + + expect(stages().map(({ stage }) => stage)).toEqual(['surface_demand']); + expect(createHomepagePerformanceDemand().lifecycle).toBe('account_switch'); + }); + + it('ages chained resident deliveries without double counting', () => { + const origin = createHomepagePerpsDelivery({ + stream: 'markets', + source: 'memory_cache', + itemCount: 2, + dataAgeMs: 50, + }); + jest.mocked(performance.now).mockReturnValue(1010); + const first = createHomepagePerpsResidentDelivery({ + stream: 'markets', + itemCount: 2, + previousDelivery: origin, + }); + jest.mocked(performance.now).mockReturnValue(1020); + const second = createHomepagePerpsResidentDelivery({ + stream: 'markets', + itemCount: 2, + previousDelivery: first, + }); + + expect(first.dataAgeMs).toBe(60); + expect(second.dataAgeMs).toBe(70); + }); + + it('ignores iOS inactive when classifying a background lifecycle', () => { + handleHomepagePerformanceAppStateChange('inactive'); + jest.mocked(performance.now).mockReturnValue(30_000); + handleHomepagePerformanceAppStateChange('active'); + + expect(createHomepagePerformanceDemand().lifecycle).toBe('cold_no_cache'); + }); + + it('allowlists detail fields and preserves the canonical stage', () => { + logHomepagePerformanceStage('react_commit', undefined, { + stage: 'overridden', + wallet_address: '0xprivate', + duration_ms: 12, + }); + + const record = stages()[0] as Record; + expect(record).toMatchObject({ stage: 'react_commit', duration_ms: 12 }); + expect(record).not.toHaveProperty('wallet_address'); + }); }); diff --git a/app/components/UI/Perps/utils/homepagePerformanceProbe.ts b/app/components/UI/Perps/utils/homepagePerformanceProbe.ts index 8e8568851a5..72c3bd73b10 100644 --- a/app/components/UI/Perps/utils/homepagePerformanceProbe.ts +++ b/app/components/UI/Perps/utils/homepagePerformanceProbe.ts @@ -3,28 +3,15 @@ import performance from 'react-native-performance'; import { PERPS_CONSTANTS } from '@metamask/perps-controller'; import { v4 as uuidv4 } from 'uuid'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; - -export type HomepagePerpsStream = 'positions' | 'orders' | 'markets' | 'prices'; -export type HomepagePerpsDeliverySource = - | 'memory_cache' - | 'disk_cache' - | 'provider_snapshot' - | 'terminal_global_snapshot_v2' - | 'provider' - | 'resident_state' - | 'fresh_socket' - | 'unknown'; -export type HomepagePerformanceLifecycle = - | 'cold_no_cache' - | 'cold_disk_cache' - | 'warm_foreground' - | 'navigate_return' - | 'background_short' - | 'background_reconnect' - | 'network_recovery' - | 'account_switch' - | 'perps_network_switch' - | 'provider_switch'; +import type { + PerpsLoadingLifecycle, + PerpsLoadingSource, + PerpsLoadingStream, +} from './perpsLoadingSession'; + +export type HomepagePerpsStream = PerpsLoadingStream; +export type HomepagePerpsDeliverySource = PerpsLoadingSource | 'resident_state'; +export type HomepagePerformanceLifecycle = PerpsLoadingLifecycle; export type HomepagePerpsContentVariant = | 'empty' | 'positions' @@ -41,7 +28,7 @@ export interface HomepagePerpsDeliveryMetadata { originSource?: HomepagePerpsDeliverySource; itemCount: number; receivedAtMonotonicMs: number; - subscriberDeliveredAtMonotonicMs?: number; + residentWrappedAtMonotonicMs?: number; dataAgeMs: number; lifecycle: HomepagePerformanceLifecycle; accountGeneration: number; @@ -57,10 +44,6 @@ export interface HomepagePerformanceDemand { contextGeneration: number; firstVisibleRecorded: boolean; firstFreshVisibleRecorded: boolean; - cachedVisibleAtMonotonicMs?: number; - cachedVisibleSource?: string; - recordedFreshPipelineStreams: Set; - recordedSocketPipelineStreams: Set; } type HomepagePerformanceStage = @@ -83,12 +66,47 @@ type HomepagePerformanceStage = | 'cache_write' | 'subscriber_delivery' | 'react_commit' - | 'values_visible' - | 'first_frame_checkpoint' + | 'surface_initial_ui_recorded' | 'next_frame_checkpoint' | 'surface_resolved_recorded' | 'surface_live_recorded'; +const SAFE_DETAIL_KEYS = new Set([ + 'account_generation', + 'cache_age_ms', + 'connection_id', + 'content_variant', + 'context_generation', + 'data_ready_at_demand', + 'demand_id', + 'duration_ms', + 'elapsed_ms', + 'frame_checkpoint_monotonic_ms', + 'frame_boundary', + 'fresh_for_lifecycle', + 'freshness_source', + 'instrumentation_schema', + 'lifecycle', + 'markets_source', + 'orders_source', + 'positions_source', + 'delivery_source', + 'source', + 'success', + 'visible_item_count', +]); + +const sanitizeDetail = (detail: Record) => + Object.fromEntries( + Object.entries(detail).filter( + ([key, value]) => + SAFE_DETAIL_KEYS.has(key) && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean'), + ), + ); + let diskCacheTimestampMs: number | null = null; let firstDemand = true; let accountGeneration = 0; @@ -102,6 +120,7 @@ export const isHomepagePerformanceProbeActive = () => activeObservationCount > 0; export const activateHomepagePerformanceProbe = () => { + if (!__DEV__) return () => undefined; activeObservationCount += 1; let released = false; @@ -121,6 +140,7 @@ export const logHomepagePerformanceStage = ( DevLogger.log( `[PerpsPerf] ${JSON.stringify({ + ...sanitizeDetail(detail), stage, monotonic_ms: Number(performance.now().toFixed(3)), ...(delivery && { @@ -134,7 +154,6 @@ export const logHomepagePerformanceStage = ( account_generation: delivery.accountGeneration, context_generation: delivery.contextGeneration, }), - ...detail, })}`, ); }; @@ -173,6 +192,7 @@ const notifyLifecycleChange = () => export const subscribeHomepagePerformanceLifecycleChange = ( listener: () => void, ) => { + if (!__DEV__) return () => undefined; lifecycleListeners.add(listener); return () => { lifecycleListeners.delete(listener); @@ -182,7 +202,8 @@ export const subscribeHomepagePerformanceLifecycleChange = ( export const handleHomepagePerformanceAppStateChange = ( nextState: AppStateStatus, ) => { - if (nextState === 'background' || nextState === 'inactive') { + if (!__DEV__) return; + if (nextState === 'background') { backgroundStartedAt ??= performance.now(); return; } @@ -208,6 +229,7 @@ export const getHomepagePerpsDiskCacheAgeMs = () => : Math.max(0, Date.now() - diskCacheTimestampMs); export const markHomepagePerpsDiskCacheHydrated = (rawValue: string) => { + if (!__DEV__) return; try { const parsed = JSON.parse(rawValue) as { timestamp?: number; @@ -243,26 +265,23 @@ const setLifecycle = ( }; export const markHomepagePerpsNavigateReturn = () => { + if (!__DEV__) return; if ( lifecycle === 'cold_no_cache' || lifecycle === 'cold_disk_cache' || - lifecycle === 'warm_foreground' || lifecycle === 'navigate_return' ) { setLifecycle('navigate_return'); } }; -export const markHomepagePerpsNetworkRecovery = () => - setLifecycle('network_recovery'); - -export const markHomepagePerpsNetworkSwitch = () => - setLifecycle('perps_network_switch', { advancesContext: true }); - -export const markHomepagePerpsProviderSwitch = () => - setLifecycle('provider_switch', { advancesContext: true }); +export const markHomepagePerpsNetworkSwitch = () => { + if (!__DEV__) return; + setLifecycle('network_switch', { advancesContext: true }); +}; export const markHomepagePerpsAccountSwitch = () => { + if (!__DEV__) return; accountGeneration += 1; setLifecycle('account_switch', { advancesContext: true }); }; @@ -309,10 +328,12 @@ export const createHomepagePerpsResidentDelivery = ({ originSource: previousDelivery?.originSource ?? previousDelivery?.source, itemCount, receivedAtMonotonicMs: previousDelivery?.receivedAtMonotonicMs ?? now, + residentWrappedAtMonotonicMs: now, dataAgeMs: previousDelivery ? previousDelivery.dataAgeMs + now - - previousDelivery.receivedAtMonotonicMs + (previousDelivery.residentWrappedAtMonotonicMs ?? + previousDelivery.receivedAtMonotonicMs) : 0, lifecycle, accountGeneration: previousDelivery?.accountGeneration ?? accountGeneration, @@ -331,8 +352,6 @@ export const createHomepagePerformanceDemand = contextGeneration, firstVisibleRecorded: false, firstFreshVisibleRecorded: false, - recordedFreshPipelineStreams: new Set(), - recordedSocketPipelineStreams: new Set(), }; firstDemand = false; logHomepagePerformanceStage('surface_demand', undefined, { @@ -344,6 +363,11 @@ export const createHomepagePerformanceDemand = return demand; }; +const isDemandCurrent = (demand: HomepagePerformanceDemand) => + demand.lifecycle === lifecycle && + demand.accountGeneration === accountGeneration && + demand.contextGeneration === contextGeneration; + export const isHomepagePerpsDeliveryFreshForDemand = ( delivery: HomepagePerpsDeliveryMetadata, demand: HomepagePerformanceDemand, @@ -373,7 +397,7 @@ const hasRequiredStreams = ( ) => { const streams = new Set(deliveries.map(({ stream }) => stream)); const hasAccountResolution = - streams.has('positions') && streams.has('orders'); + streams.has('positions') && streams.has('orders') && streams.has('account'); return contentVariant === 'trending' || contentVariant === 'pills' ? hasAccountResolution && streams.has('markets') : hasAccountResolution; @@ -385,34 +409,24 @@ const getRequiredDeliveries = ( ) => { const requiresMarkets = contentVariant === 'trending' || contentVariant === 'pills'; - return deliveries.filter( - ({ stream }) => - stream === 'positions' || stream === 'orders' || requiresMarkets, - ); + const requiredStreams = new Set([ + 'positions', + 'orders', + 'account', + ]); + if (requiresMarkets) requiredStreams.add('markets'); + return deliveries.filter(({ stream }) => requiredStreams.has(stream)); }; const getVisibleDeliveries = ( deliveries: HomepagePerpsDeliveryMetadata[], contentVariant: HomepagePerpsContentVariant, -) => { - if (contentVariant === 'positions') { - return deliveries.filter(({ stream }) => stream === 'positions'); - } - if (contentVariant === 'orders') { - return deliveries.filter(({ stream }) => stream === 'orders'); - } - return getRequiredDeliveries(deliveries, contentVariant); -}; +) => getRequiredDeliveries(deliveries, contentVariant); const hasVisibleStreams = ( deliveries: HomepagePerpsDeliveryMetadata[], contentVariant: HomepagePerpsContentVariant, -) => { - const streams = new Set(deliveries.map(({ stream }) => stream)); - if (contentVariant === 'positions') return streams.has('positions'); - if (contentVariant === 'orders') return streams.has('orders'); - return hasRequiredStreams(deliveries, contentVariant); -}; +) => hasRequiredStreams(deliveries, contentVariant); const getEffectiveSource = (delivery: HomepagePerpsDeliveryMetadata) => delivery.source === 'resident_state' || delivery.source === 'memory_cache' @@ -424,6 +438,7 @@ export const recordHomepagePerpsVisibleFrame = ({ deliveries, contentVariant, isConnectionLive = false, + reactCommitAtMonotonicMs, frameCheckpointAtMonotonicMs, }: { demand: HomepagePerformanceDemand; @@ -433,6 +448,15 @@ export const recordHomepagePerpsVisibleFrame = ({ reactCommitAtMonotonicMs: number; frameCheckpointAtMonotonicMs: number; }) => { + if ( + !isDemandCurrent(demand) || + !Number.isFinite(reactCommitAtMonotonicMs) || + !Number.isFinite(frameCheckpointAtMonotonicMs) || + reactCommitAtMonotonicMs < demand.startedAtMonotonicMs || + frameCheckpointAtMonotonicMs < reactCommitAtMonotonicMs + ) { + return; + } const visibleDeliveries = getVisibleDeliveries(deliveries, contentVariant); if (!hasVisibleStreams(visibleDeliveries, contentVariant)) { return; @@ -487,19 +511,6 @@ export const recordHomepagePerpsVisibleFrame = ({ ), ...visibleTags, }); - if ( - visibleDeliveries.some( - (delivery) => - !isHomepagePerpsDeliveryFreshForDemand( - delivery, - demand, - isConnectionLive, - ), - ) - ) { - demand.cachedVisibleAtMonotonicMs = frameCheckpointAtMonotonicMs; - demand.cachedVisibleSource = visibleTags.delivery_source; - } } const requiredDeliveries = getRequiredDeliveries(deliveries, contentVariant); @@ -550,6 +561,13 @@ export const recordHomepagePerpsErrorFrame = ({ demand: HomepagePerformanceDemand; frameCheckpointAtMonotonicMs: number; }) => { + if ( + !isDemandCurrent(demand) || + !Number.isFinite(frameCheckpointAtMonotonicMs) || + frameCheckpointAtMonotonicMs < demand.startedAtMonotonicMs + ) { + return; + } if (demand.firstVisibleRecorded) return; demand.firstVisibleRecorded = true; logHomepagePerformanceStage('surface_resolved_recorded', undefined, { @@ -563,9 +581,12 @@ export const recordHomepagePerpsErrorFrame = ({ }); }; -export const markHomepagePerformanceDemandComplete = () => { - lifecycle = 'warm_foreground'; - lifecycleStartedAtMonotonicMs = performance.now(); +export const markHomepagePerformanceDemandComplete = ( + demand: HomepagePerformanceDemand, +) => { + if (!__DEV__) return; + if (!isDemandCurrent(demand)) return; + setLifecycle('navigate_return'); }; export const resetHomepagePerformanceProbeForTests = () => {