diff --git a/app/components/Views/Homepage/hooks/useSectionPerformance.test.ts b/app/components/Views/Homepage/hooks/useSectionPerformance.test.ts index b46f4d1e800e..4314ed68640a 100644 --- a/app/components/Views/Homepage/hooks/useSectionPerformance.test.ts +++ b/app/components/Views/Homepage/hooks/useSectionPerformance.test.ts @@ -7,6 +7,8 @@ import { useSectionPerformance } from './useSectionPerformance'; jest.mock('../../../../util/trace', () => ({ trace: jest.fn(), endTrace: jest.fn(), + getTraceContext: jest.fn(), + annotateTrace: jest.fn(), TraceName: { HomepageSectionTimeToContent: 'Homepage Section Time To Content', HomepageSectionDataFetch: 'Homepage Section Data Fetch', @@ -21,9 +23,12 @@ jest.mock('react-native-performance', () => ({ now: jest.fn(() => mockPerfNowValue), })); -const { trace: mockTrace, endTrace: mockEndTrace } = jest.requireMock( - '../../../../util/trace', -); +const { + trace: mockTrace, + endTrace: mockEndTrace, + getTraceContext: mockGetTraceContext, + annotateTrace: mockAnnotateTrace, +} = jest.requireMock('../../../../util/trace'); const mockAddBreadcrumb = addBreadcrumb as jest.MockedFunction< typeof addBreadcrumb >; @@ -38,6 +43,7 @@ describe('useSectionPerformance', () => { beforeEach(() => { jest.clearAllMocks(); mockPerfNowValue = 0; + mockGetTraceContext.mockReturnValue({ spanId: 'section-span' }); }); describe('Time to Content', () => { @@ -154,6 +160,41 @@ describe('useSectionPerformance', () => { ); }); + it('keeps the latest TTC cohort metadata when unmounted', () => { + const { rerender, unmount } = renderHook( + ({ lifecycle, sessionId }: { lifecycle: string; sessionId: string }) => + useSectionPerformance({ + ...defaultConfig, + tags: { lifecycle }, + data: { perps_session_id: sessionId }, + }), + { + initialProps: { + lifecycle: 'cold_no_cache', + sessionId: 'session-id-1', + }, + }, + ); + + rerender({ + lifecycle: 'background_reconnect', + sessionId: 'session-id-2', + }); + + unmount(); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.HomepageSectionTimeToContent, + data: expect.objectContaining({ + lifecycle: 'background_reconnect', + perps_session_id: 'session-id-2', + success: false, + }), + }), + ); + }); + it('does not end with failure on unmount if content was already ready', () => { const { rerender, unmount } = renderHook( ({ contentReady }) => @@ -268,6 +309,42 @@ describe('useSectionPerformance', () => { }), ); }); + + it('keeps the latest DFD cohort metadata when unmounted', () => { + const { rerender, unmount } = renderHook( + ({ lifecycle, sessionId }: { lifecycle: string; sessionId: string }) => + useSectionPerformance({ + ...defaultConfig, + isLoading: true, + tags: { lifecycle }, + data: { perps_session_id: sessionId }, + }), + { + initialProps: { + lifecycle: 'cold_no_cache', + sessionId: 'session-id-1', + }, + }, + ); + + rerender({ + lifecycle: 'background_reconnect', + sessionId: 'session-id-2', + }); + + unmount(); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.HomepageSectionDataFetch, + data: expect.objectContaining({ + lifecycle: 'background_reconnect', + perps_session_id: 'session-id-2', + success: false, + }), + }), + ); + }); }); describe('Re-render Monitoring', () => { @@ -426,6 +503,195 @@ describe('useSectionPerformance', () => { expect(mockEndTrace).not.toHaveBeenCalled(); }); + it.each([ + TraceName.HomepageSectionTimeToContent, + TraceName.HomepageSectionDataFetch, + ])('applies bounded metadata to %s', (traceName) => { + const tags = { + content_variant: 'trending', + market_source: 'provider', + account_source: 'memory_cache', + lifecycle: 'cold_no_cache', + }; + const { rerender } = renderHook( + ({ isLoading, contentReady }) => + useSectionPerformance({ + ...defaultConfig, + contentReady, + isLoading, + tags, + data: { perps_session_id: 'session-id-1' }, + }), + { initialProps: { isLoading: true, contentReady: false } }, + ); + + rerender({ isLoading: false, contentReady: true }); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: traceName, + tags: expect.objectContaining({ + ...tags, + section_id: HomeSectionNames.TOKENS, + }), + }), + ); + const start = (mockTrace as jest.Mock).mock.calls.find( + (call: [{ name: TraceName }]) => call[0].name === traceName, + )?.[0] as { tags: Record }; + expect(start.tags).not.toHaveProperty('success'); + expect(start.tags).not.toHaveProperty('content_state'); + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: traceName, + data: expect.objectContaining({ + success: true, + content_state: 'filled', + perps_session_id: 'session-id-1', + }), + }), + ); + }); + + it.each([ + TraceName.HomepageSectionTimeToContent, + TraceName.HomepageSectionDataFetch, + ])('keeps hook-owned fields authoritative on %s', (traceName) => { + const { rerender } = renderHook( + ({ contentReady, isLoading }) => + useSectionPerformance({ + ...defaultConfig, + contentReady, + isLoading, + tags: { + section_id: 'wrong-section', + success: false, + content_state: 'error', + }, + data: { + section_id: 'wrong-section', + success: false, + content_state: 'error', + }, + }), + { initialProps: { contentReady: false, isLoading: true } }, + ); + + rerender({ contentReady: true, isLoading: false }); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: traceName, + tags: expect.objectContaining({ + section_id: HomeSectionNames.TOKENS, + }), + }), + ); + const start = (mockTrace as jest.Mock).mock.calls.find( + (call: [{ name: TraceName }]) => call[0].name === traceName, + )?.[0] as { tags: Record }; + expect(start.tags).not.toHaveProperty('success'); + expect(start.tags).not.toHaveProperty('content_state'); + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: traceName, + data: expect.objectContaining({ + section_id: HomeSectionNames.TOKENS, + success: true, + content_state: 'filled', + }), + }), + ); + }); + + it('updates later-resolved tags on the existing span', () => { + const initialTags = { + content_variant: 'trending', + market_source: 'memory_cache', + account_source: 'memory_cache', + lifecycle: 'cold_no_cache', + }; + const resolvedTags = { + content_variant: 'positions', + market_source: 'terminal_v2', + account_source: 'fresh_socket', + lifecycle: 'cold_no_cache', + }; + const span = { spanId: 'ttc-span' }; + mockGetTraceContext.mockReturnValue(span); + + const { rerender } = renderHook( + ({ contentReady, tags }) => + useSectionPerformance({ + ...defaultConfig, + contentReady, + tags, + data: { perps_session_id: 'session-id-1' }, + }), + { initialProps: { contentReady: false, tags: initialTags } }, + ); + + const ttcStart = (mockTrace as jest.Mock).mock.calls.find( + (call: [{ name: string }]) => + call[0].name === TraceName.HomepageSectionTimeToContent, + )?.[0] as { id: string }; + + rerender({ contentReady: true, tags: resolvedTags }); + + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mockGetTraceContext).toHaveBeenCalledWith({ + name: TraceName.HomepageSectionTimeToContent, + id: ttcStart.id, + }); + expect(mockAnnotateTrace).toHaveBeenCalledWith(span, resolvedTags); + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.HomepageSectionTimeToContent, + id: ttcStart.id, + data: expect.objectContaining(resolvedTags), + }), + ); + }); + + it('keeps existing error success and content_state when bounded tags are present', () => { + const { rerender } = renderHook( + ({ contentReady }) => + useSectionPerformance({ + ...defaultConfig, + contentReady, + contentStateForTrace: 'error', + tags: { + content_variant: 'error', + market_source: 'provider', + account_source: 'fresh_socket', + lifecycle: 'cold_no_cache', + }, + data: { perps_session_id: 'session-id-1' }, + }), + { initialProps: { contentReady: false } }, + ); + + rerender({ contentReady: true }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.HomepageSectionTimeToContent, + data: expect.objectContaining({ + success: true, + content_state: 'error', + perps_session_id: 'session-id-1', + }), + }), + ); + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.not.objectContaining({ + perps_session_id: 'session-id-1', + }), + }), + ); + }); + it('ends TTC with success when enabled becomes true while contentReady is already true', () => { const { rerender } = renderHook( ({ enabled }) => diff --git a/app/components/Views/Homepage/hooks/useSectionPerformance.ts b/app/components/Views/Homepage/hooks/useSectionPerformance.ts index d604d7b8cf50..dd37f9a3c243 100644 --- a/app/components/Views/Homepage/hooks/useSectionPerformance.ts +++ b/app/components/Views/Homepage/hooks/useSectionPerformance.ts @@ -1,7 +1,9 @@ import { useEffect, useRef } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { + annotateTrace, endTrace, + getTraceContext, trace, TraceName, TraceOperation, @@ -37,10 +39,31 @@ interface UseSectionPerformanceConfig { reRenderThreshold?: number; /** Sliding window in ms for re-render detection. @default 500 */ reRenderWindowMs?: number; + /** Bounded cohort tags applied to the existing TTC and DFD starts. */ + tags?: Record; + /** Trace data/context applied to the existing TTC and DFD ends. */ + data?: Record; } const DEFAULT_RE_RENDER_THRESHOLD = 3; const DEFAULT_RE_RENDER_WINDOW_MS = 500; +const RESERVED_METADATA_KEYS = new Set([ + 'section_id', + 'success', + 'content_state', + 'reason', +]); + +const sanitizeMetadata = ( + metadata?: Record, +) => + metadata + ? Object.fromEntries( + Object.entries(metadata).filter( + ([key]) => !RESERVED_METADATA_KEYS.has(key), + ), + ) + : undefined; /** * Reusable performance telemetry for homepage sections. @@ -61,7 +84,21 @@ export const useSectionPerformance = ({ enabled = true, reRenderThreshold = DEFAULT_RE_RENDER_THRESHOLD, reRenderWindowMs = DEFAULT_RE_RENDER_WINDOW_MS, + tags, + data, }: UseSectionPerformanceConfig) => { + const tagsRef = useRef(sanitizeMetadata(tags)); + tagsRef.current = sanitizeMetadata(tags); + const dataRef = useRef(sanitizeMetadata(data)); + dataRef.current = sanitizeMetadata(data); + + const annotateLatestTags = (name: TraceName, id: string) => { + if (!tagsRef.current) { + return; + } + annotateTrace(getTraceContext({ name, id }), tagsRef.current); + }; + // --- Time to Content refs --- const ttcTraceId = useRef(uuidv4()); const ttcStarted = useRef(false); @@ -98,24 +135,45 @@ export const useSectionPerformance = ({ name: TraceName.HomepageSectionTimeToContent, op: TraceOperation.HomepageSectionPerformance, id: ttcTraceId.current, - tags: { section_id: sectionId }, + tags: { ...tagsRef.current, section_id: sectionId }, + data: { ...tagsRef.current, section_id: sectionId }, }); ttcStarted.current = true; return () => { if (ttcStarted.current && !ttcEnded.current) { + annotateLatestTags( + TraceName.HomepageSectionTimeToContent, + ttcTraceId.current, + ); endTrace({ name: TraceName.HomepageSectionTimeToContent, id: ttcTraceId.current, - data: { success: false, reason: 'unmounted', section_id: sectionId }, + data: { + ...tagsRef.current, + ...dataRef.current, + success: false, + reason: 'unmounted', + section_id: sectionId, + }, }); ttcStarted.current = false; } if (fetchStarted.current && !fetchEnded.current) { + annotateLatestTags( + TraceName.HomepageSectionDataFetch, + fetchTraceId.current, + ); endTrace({ name: TraceName.HomepageSectionDataFetch, id: fetchTraceId.current, - data: { success: false, reason: 'unmounted', section_id: sectionId }, + data: { + ...tagsRef.current, + ...dataRef.current, + success: false, + reason: 'unmounted', + section_id: sectionId, + }, }); fetchStarted.current = false; } @@ -125,10 +183,16 @@ export const useSectionPerformance = ({ // Time to Content — end span when content is ready useEffect(() => { if (enabled && contentReady && ttcStarted.current && !ttcEnded.current) { + annotateLatestTags( + TraceName.HomepageSectionTimeToContent, + ttcTraceId.current, + ); endTrace({ name: TraceName.HomepageSectionTimeToContent, id: ttcTraceId.current, data: { + ...tagsRef.current, + ...dataRef.current, success: true, section_id: sectionId, content_state: traceContentState, @@ -136,7 +200,7 @@ export const useSectionPerformance = ({ }); ttcEnded.current = true; } - }, [enabled, contentReady, sectionId, traceContentState]); + }, [enabled, contentReady, sectionId, traceContentState, data]); // ────────────────────────────────────────────── // 2. Data Fetch Latency — track isLoading transitions @@ -154,7 +218,8 @@ export const useSectionPerformance = ({ name: TraceName.HomepageSectionDataFetch, op: TraceOperation.HomepageSectionPerformance, id: fetchTraceId.current, - tags: { section_id: sectionId }, + tags: { ...tagsRef.current, section_id: sectionId }, + data: { ...tagsRef.current, section_id: sectionId }, }); fetchStarted.current = true; } @@ -166,10 +231,16 @@ export const useSectionPerformance = ({ fetchStarted.current && !fetchEnded.current ) { + annotateLatestTags( + TraceName.HomepageSectionDataFetch, + fetchTraceId.current, + ); endTrace({ name: TraceName.HomepageSectionDataFetch, id: fetchTraceId.current, data: { + ...tagsRef.current, + ...dataRef.current, success: true, section_id: sectionId, content_state: traceContentState, @@ -178,5 +249,5 @@ export const useSectionPerformance = ({ fetchStarted.current = false; fetchEnded.current = true; } - }, [enabled, isLoading, sectionId, traceContentState]); + }, [enabled, isLoading, sectionId, traceContentState, data]); }; diff --git a/app/components/Views/Login/index.test.tsx b/app/components/Views/Login/index.test.tsx index 4cd0e17e351a..b609d0a04be9 100644 --- a/app/components/Views/Login/index.test.tsx +++ b/app/components/Views/Login/index.test.tsx @@ -284,11 +284,14 @@ const mockStartHomepageReadyTrace = jest.fn( (..._args: unknown[]) => HOMEPAGE_READY_TRACE_TOKEN, ); const mockCancelHomepageReadyTrace = jest.fn(); +const mockMarkHomepageAuthenticationEnd = jest.fn(); jest.mock('../../../core/Performance/HomepageReady', () => ({ startHomepageReadyTrace: (...args: unknown[]) => mockStartHomepageReadyTrace(...args), cancelHomepageReadyTrace: (...args: unknown[]) => mockCancelHomepageReadyTrace(...args), + markHomepageAuthenticationEnd: (...args: unknown[]) => + mockMarkHomepageAuthenticationEnd(...args), })); jest.mock('@react-native-community/netinfo', () => ({ @@ -1862,6 +1865,9 @@ describe('Login', () => { }, expect.any(Function), ); + expect(mockMarkHomepageAuthenticationEnd).toHaveBeenCalledWith( + HOMEPAGE_READY_TRACE_TOKEN, + ); }); it('ends LoginUserInteraction on device authentication unlock', async () => { @@ -1910,6 +1916,9 @@ describe('Login', () => { }, expect.any(Function), ); + expect(mockMarkHomepageAuthenticationEnd).toHaveBeenCalledWith( + HOMEPAGE_READY_TRACE_TOKEN, + ); }); it('cancels Homepage Ready when password unlock fails', async () => { @@ -1926,6 +1935,7 @@ describe('Login', () => { reason: 'unlock_failed', traceToken: HOMEPAGE_READY_TRACE_TOKEN, }); + expect(mockMarkHomepageAuthenticationEnd).not.toHaveBeenCalled(); }); it('cancels Homepage Ready when device authentication fails', async () => { @@ -1953,6 +1963,7 @@ describe('Login', () => { reason: 'unlock_failed', traceToken: HOMEPAGE_READY_TRACE_TOKEN, }); + expect(mockMarkHomepageAuthenticationEnd).not.toHaveBeenCalled(); }); }); diff --git a/app/components/Views/Login/index.tsx b/app/components/Views/Login/index.tsx index d3dc7b45500d..5fdcec26ea7b 100644 --- a/app/components/Views/Login/index.tsx +++ b/app/components/Views/Login/index.tsx @@ -101,6 +101,7 @@ import { } from './loginPerformanceTags'; import { cancelHomepageReadyTrace, + markHomepageAuthenticationEnd, startHomepageReadyTrace, type HomepageReadyTraceToken, } from '../../../core/Performance/HomepageReady'; @@ -359,6 +360,7 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { } }, ); + markHomepageAuthenticationEnd(homepageReadyTraceToken); } catch (loginErr) { cancelHomepageReadyTrace({ reason: 'unlock_failed', @@ -407,6 +409,7 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { await unlockWallet(); }, ); + markHomepageAuthenticationEnd(homepageReadyTraceToken); } catch (loginerror) { cancelHomepageReadyTrace({ reason: 'unlock_failed', diff --git a/app/components/Views/OAuthRehydration/index.test.tsx b/app/components/Views/OAuthRehydration/index.test.tsx index 32407e061cf7..242e2bba3dda 100644 --- a/app/components/Views/OAuthRehydration/index.test.tsx +++ b/app/components/Views/OAuthRehydration/index.test.tsx @@ -48,6 +48,7 @@ const mockStartHomepageReadyTrace = jest.fn( (..._args: unknown[]) => HOMEPAGE_READY_TRACE_TOKEN, ); const mockCancelHomepageReadyTrace = jest.fn(); +const mockMarkHomepageAuthenticationEnd = jest.fn(); jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({ __esModule: true, @@ -70,6 +71,8 @@ jest.mock('../../../core/Performance/HomepageReady', () => ({ mockStartHomepageReadyTrace(...args), cancelHomepageReadyTrace: (...args: unknown[]) => mockCancelHomepageReadyTrace(...args), + markHomepageAuthenticationEnd: (...args: unknown[]) => + mockMarkHomepageAuthenticationEnd(...args), })); jest.mock('../../../util/Logger'); @@ -284,6 +287,9 @@ describe('OAuthRehydration', () => { await waitFor(() => { expect(mockGetMarketingOptInStatus).toHaveBeenCalled(); }); + expect(mockMarkHomepageAuthenticationEnd).toHaveBeenCalledWith( + HOMEPAGE_READY_TRACE_TOKEN, + ); expect(mockUnlockWallet.mock.invocationCallOrder[0]).toBeLessThan( mockRequestBiometricsAccessControlForIOS.mock.invocationCallOrder[0], ); @@ -1154,6 +1160,9 @@ describe('OAuthRehydration', () => { ); }); expect(mockGetMarketingOptInStatus).not.toHaveBeenCalled(); + expect(mockMarkHomepageAuthenticationEnd).toHaveBeenCalledWith( + HOMEPAGE_READY_TRACE_TOKEN, + ); expect(mockUnlockWallet.mock.invocationCallOrder[0]).toBeLessThan( mockRequestBiometricsAccessControlForIOS.mock.invocationCallOrder[0], ); diff --git a/app/components/Views/OAuthRehydration/index.tsx b/app/components/Views/OAuthRehydration/index.tsx index f66226914204..bd58ba9ef0b3 100644 --- a/app/components/Views/OAuthRehydration/index.tsx +++ b/app/components/Views/OAuthRehydration/index.tsx @@ -63,6 +63,7 @@ import { } from '../../../core/Engine/controllers/seedless-onboarding-controller/error'; import { cancelHomepageReadyTrace, + markHomepageAuthenticationEnd, startHomepageReadyTrace, type HomepageReadyTraceToken, } from '../../../core/Performance/HomepageReady'; @@ -629,6 +630,7 @@ const OAuthRehydration: React.FC = ({ }); }, ); + markHomepageAuthenticationEnd(homepageReadyTraceToken); // run syncMarketingOptInAfterUnlock in the background syncMarketingOptInAfterUnlock(); @@ -709,6 +711,7 @@ const OAuthRehydration: React.FC = ({ }); }, ); + markHomepageAuthenticationEnd(homepageReadyTraceToken); // Best-effort post-unlock UX: show biometric cancelled alert if needed. // Failure here must not be treated as a login error — unlock already succeeded. diff --git a/app/core/Performance/HomepageReady.test.ts b/app/core/Performance/HomepageReady.test.ts index fc20a9ba996c..6fb80db8a270 100644 --- a/app/core/Performance/HomepageReady.test.ts +++ b/app/core/Performance/HomepageReady.test.ts @@ -1,17 +1,29 @@ -import { endTrace, trace, TraceName, TraceOperation } from '../../util/trace'; +import { setMeasurement } from '@sentry/react-native'; +import performance from 'react-native-performance'; import { + endTrace, + getTraceContext, + trace, + TraceName, + TraceOperation, +} from '../../util/trace'; +import { + AUTHENTICATION_END_TO_HOMEPAGE_READY_MS, cancelHomepageReadyTrace, endHomepageReadyTrace, isHomepageReadyTraceActive, + markHomepageAuthenticationEnd, queueColdHomepageReadyTrace, resetHomepageReadyTraceForTesting, resolveColdHomepageReadyTrace, startHomepageReadyTrace, + subscribeHomepageReadyCompletion, } from './HomepageReady'; jest.mock('../../util/trace', () => ({ trace: jest.fn(), endTrace: jest.fn(), + getTraceContext: jest.fn(), TraceName: { HomepageReady: 'Homepage Ready', }, @@ -21,13 +33,29 @@ jest.mock('../../util/trace', () => ({ TRACES_CLEANUP_INTERVAL: 5 * 60 * 1000, })); +jest.mock('@sentry/react-native', () => ({ + setMeasurement: jest.fn(), +})); + +jest.mock('react-native-performance', () => ({ + __esModule: true, + default: { + now: jest.fn(() => 1000), + }, +})); + const mockTrace = jest.mocked(trace); const mockEndTrace = jest.mocked(endTrace); +const mockGetTraceContext = jest.mocked(getTraceContext); +const mockSetMeasurement = jest.mocked(setMeasurement); +const homepageReadySpan = { spanId: 'homepage-ready-span' }; describe('HomepageReady', () => { beforeEach(() => { jest.clearAllMocks(); resetHomepageReadyTraceForTesting(); + jest.mocked(performance.now).mockReturnValue(1000); + mockGetTraceContext.mockReturnValue(homepageReadySpan as never); }); it('starts the cold app-open trace from the native launch timestamp', () => { @@ -196,4 +224,127 @@ describe('HomepageReady', () => { }, }); }); + + it('measures authentication-end to Homepage Ready on a successful unlock', () => { + const token = startHomepageReadyTrace({ + source: 'unlock', + appStartType: 'warm', + }); + jest.mocked(performance.now).mockReturnValue(400); + markHomepageAuthenticationEnd(token); + jest.mocked(performance.now).mockReturnValue(650); + + endHomepageReadyTrace({ contentState: 'filled' }); + + expect(mockGetTraceContext).toHaveBeenCalledWith({ + name: TraceName.HomepageReady, + }); + expect(mockSetMeasurement).toHaveBeenCalledWith( + AUTHENTICATION_END_TO_HOMEPAGE_READY_MS, + 250, + 'millisecond', + homepageReadySpan, + ); + expect(mockEndTrace).toHaveBeenCalledWith({ + name: TraceName.HomepageReady, + data: { + success: true, + content_state: 'filled', + }, + }); + }); + + it('clears authentication-end on unlock failure so a retry does not inherit it', () => { + const token = startHomepageReadyTrace({ + source: 'unlock', + appStartType: 'warm', + }); + jest.mocked(performance.now).mockReturnValue(400); + markHomepageAuthenticationEnd(token); + cancelHomepageReadyTrace({ reason: 'unlock_failed', traceToken: token }); + + const retryToken = startHomepageReadyTrace({ + source: 'unlock', + appStartType: 'warm', + }); + jest.mocked(performance.now).mockReturnValue(900); + endHomepageReadyTrace({ contentState: 'filled' }); + + expect(retryToken).not.toBeNull(); + expect(mockSetMeasurement).not.toHaveBeenCalled(); + }); + + it('omits the authentication-end measurement on already-unlocked cold app-open', () => { + startHomepageReadyTrace({ + source: 'app_open', + appStartType: 'cold', + }); + markHomepageAuthenticationEnd(1); + + endHomepageReadyTrace({ contentState: 'filled' }); + + expect(mockSetMeasurement).not.toHaveBeenCalled(); + }); + + it('replays the latest Homepage Ready completion to a late subscriber', () => { + const listener = jest.fn(); + startHomepageReadyTrace({ + source: 'app_open', + appStartType: 'cold', + }); + jest.mocked(performance.now).mockReturnValue(880); + endHomepageReadyTrace({ contentState: 'filled' }); + + const unsubscribe = subscribeHomepageReadyCompletion(listener); + + expect(listener).toHaveBeenCalledWith(880); + unsubscribe(); + }); + + it('notifies subscribers of a later Homepage Ready completion', () => { + const listener = jest.fn(); + const unsubscribe = subscribeHomepageReadyCompletion(listener); + + startHomepageReadyTrace({ + source: 'unlock', + appStartType: 'warm', + }); + jest.mocked(performance.now).mockReturnValue(720); + endHomepageReadyTrace({ contentState: 'empty' }); + + expect(listener).toHaveBeenCalledWith(720); + unsubscribe(); + }); + + it('does not replay a completion from an earlier lifecycle generation', () => { + startHomepageReadyTrace({ source: 'app_open', appStartType: 'cold' }); + jest.mocked(performance.now).mockReturnValue(500); + endHomepageReadyTrace({ contentState: 'filled' }); + + startHomepageReadyTrace({ source: 'unlock', appStartType: 'warm' }); + const listener = jest.fn(); + const unsubscribe = subscribeHomepageReadyCompletion(listener); + + expect(listener).not.toHaveBeenCalled(); + + jest.mocked(performance.now).mockReturnValue(700); + endHomepageReadyTrace({ contentState: 'filled' }); + expect(listener).toHaveBeenCalledWith(700); + unsubscribe(); + }); + + it('does not notify after unsubscribe', () => { + const listener = jest.fn(); + const unsubscribe = subscribeHomepageReadyCompletion(listener); + unsubscribe(); + + startHomepageReadyTrace({ + source: 'unlock', + appStartType: 'warm', + }); + jest.mocked(performance.now).mockReturnValue(640); + endHomepageReadyTrace({ contentState: 'filled' }); + + expect(listener).not.toHaveBeenCalled(); + }); }); diff --git a/app/core/Performance/HomepageReady.ts b/app/core/Performance/HomepageReady.ts index 623e7010b6d6..233e19f99f32 100644 --- a/app/core/Performance/HomepageReady.ts +++ b/app/core/Performance/HomepageReady.ts @@ -1,11 +1,17 @@ +import performance from 'react-native-performance'; +import { setMeasurement } from '@sentry/react-native'; import { endTrace, + getTraceContext, trace, TraceName, TraceOperation, TRACES_CLEANUP_INTERVAL, } from '../../util/trace'; +export const AUTHENTICATION_END_TO_HOMEPAGE_READY_MS = + 'authentication_end_to_homepage_ready_ms'; + export type HomepageReadyContentState = 'filled' | 'empty' | 'error'; export type HomepageReadyStartSource = 'app_open' | 'unlock'; export type HomepageReadyAppStartType = 'cold' | 'warm'; @@ -27,8 +33,13 @@ interface CancelHomepageReadyTraceOptions { let startedAt: number | null = null; let activeTraceToken: HomepageReadyTraceToken | null = null; +let activeStartSource: HomepageReadyStartSource | null = null; +let authenticationEndedAtMs: number | null = null; +let authenticationEndToken: HomepageReadyTraceToken | null = null; let nextTraceToken = 0; let queuedColdTrace: { startTime?: number } | null = null; +let latestHomepageReadyAtMs: number | null = null; +const homepageReadyListeners = new Set<(monotonicMs: number) => void>(); export type HomepageReadyTraceToken = number; @@ -37,6 +48,54 @@ export type HomepageReadyTraceToken = number; */ export const isHomepageReadyTraceActive = () => startedAt !== null; +const clearAuthenticationEnd = () => { + authenticationEndedAtMs = null; + authenticationEndToken = null; +}; + +/** + * Records authentication-end on the current unlock Homepage Ready token. + * Cleared on failure, cancel, or a new token. + */ +export const markHomepageAuthenticationEnd = ( + traceToken: HomepageReadyTraceToken | null, +) => { + if ( + traceToken === null || + traceToken !== activeTraceToken || + activeStartSource !== 'unlock' + ) { + return; + } + const now = performance.now(); + if (!Number.isFinite(now)) { + return; + } + authenticationEndedAtMs = now; + authenticationEndToken = traceToken; +}; + +/** + * Subscribe to Homepage Ready completion. Replays the latest completed + * monotonic timestamp immediately, then notifies on later completions. + */ +export const subscribeHomepageReadyCompletion = ( + listener: (monotonicMs: number) => void, +): (() => void) => { + if (latestHomepageReadyAtMs !== null) { + listener(latestHomepageReadyAtMs); + } + homepageReadyListeners.add(listener); + return () => { + homepageReadyListeners.delete(listener); + }; +}; + +const notifyHomepageReadyCompletion = (monotonicMs: number) => { + latestHomepageReadyAtMs = monotonicMs; + homepageReadyListeners.forEach((listener) => listener(monotonicMs)); +}; + /** * Starts the app-open/unlock to usable homepage CUF. * @@ -56,6 +115,11 @@ export const startHomepageReadyTrace = ({ startedAt = now; nextTraceToken += 1; activeTraceToken = nextTraceToken; + activeStartSource = source; + // A completion from the previous lifecycle must never be replayed into the + // new Homepage/Perps generation. + latestHomepageReadyAtMs = null; + clearAuthenticationEnd(); trace({ name: TraceName.HomepageReady, op: TraceOperation.HomepagePerformance, @@ -117,6 +181,28 @@ export const endHomepageReadyTrace = ({ return; } + const homepageReadyAtMs = performance.now(); + if ( + activeStartSource === 'unlock' && + authenticationEndedAtMs !== null && + authenticationEndToken === activeTraceToken && + Number.isFinite(homepageReadyAtMs) && + homepageReadyAtMs >= authenticationEndedAtMs + ) { + const span = getTraceContext({ name: TraceName.HomepageReady }); + if (span) { + setMeasurement( + AUTHENTICATION_END_TO_HOMEPAGE_READY_MS, + homepageReadyAtMs - authenticationEndedAtMs, + 'millisecond', + span, + ); + } + } + if (Number.isFinite(homepageReadyAtMs)) { + notifyHomepageReadyCompletion(homepageReadyAtMs); + } + endTrace({ name: TraceName.HomepageReady, data: { @@ -126,6 +212,8 @@ export const endHomepageReadyTrace = ({ }); startedAt = null; activeTraceToken = null; + activeStartSource = null; + clearAuthenticationEnd(); }; /** @@ -155,11 +243,17 @@ export const cancelHomepageReadyTrace = ({ }); startedAt = null; activeTraceToken = null; + activeStartSource = null; + clearAuthenticationEnd(); }; export const resetHomepageReadyTraceForTesting = () => { startedAt = null; activeTraceToken = null; + activeStartSource = null; nextTraceToken = 0; queuedColdTrace = null; + latestHomepageReadyAtMs = null; + homepageReadyListeners.clear(); + clearAuthenticationEnd(); }; diff --git a/docs/perps/performance/ARCHITECTURE.md b/docs/perps/performance/ARCHITECTURE.md new file mode 100644 index 000000000000..e450dfb3148c --- /dev/null +++ b/docs/perps/performance/ARCHITECTURE.md @@ -0,0 +1,203 @@ +# Perps performance measurement architecture + +This is the source of truth for measurement semantics. It intentionally contains no measured values. One `perps.performance` recipe runs unchanged on Android and iOS; only the harness device target changes. + +## Delivery status + +This document is the target cross-PR contract. The Homepage/Sentry foundation +implements Homepage Ready and existing section TTC/DFD reuse. The +`perps_bootstrap_start` session and Mobile milestone producers are delivered by +the next loading-session PR in the declared stack; dashboard rows for them stay +`recipe pending` until that layer has runtime proof, then `release pending` +until the proven instrumentation and its Core dependency ship. + +## Two clocks, no assumed ordering + +App startup and Perps bootstrap are related but not sequential by definition: + +```mermaid +flowchart LR + P[Process launch] --> LS[Load Scripts] + P --> UI[UI Startup] + P --> HR[Homepage Ready] + AUTH[Authenticate User when required] --> HR + PB[Perps bootstrap start] --> GLOBAL[Global market lane] + PB --> CTRL[Controller and connection lane] + PB --> USER[User-data lane] + HR <-. absolute offset + ordering .-> PB +``` + +`perps_bootstrap_start` is emitted when `PerpsAlwaysOnProvider` requests global, controller, and user bootstrap. It is not controller construction, Perps Home navigation, login completion, wallet readiness, or Homepage readiness. The controller may already exist from Engine setup, and global market preload may safely begin before authentication because it has no account dependency. The report preserves the actual ordering of construction, bootstrap request, Homepage readiness, and surface demand so moving safe work earlier remains measurable. + +### Homepage Ready anchors + +| `app_start_type` | `start_source` | Homepage Ready start | Valid interpretation | +| ---------------- | -------------- | ------------------------------ | ----------------------------------------- | +| `cold` | `app_open` | Native app launch | Full process-to-usable-Homepage duration | +| `cold` or `warm` | `unlock` | Unlock submission | Unlock-submit-to-usable-Homepage duration | +| `warm` | `app_open` | AppState foreground transition | Resume-to-usable-Homepage duration | + +Only the cold cohort may label Homepage Ready as full app startup. Authentication is absent for an already-unlocked cold start and must remain missing rather than synthesized. + +## Parallel Perps lanes + +```mermaid +flowchart TD + PB[Perps bootstrap start] + PB --> G1[Global preload] + G1 --> G2[Terminal request] + G2 --> G3[Parse and validate] + G3 --> G4[Core market cache accepted] + G4 --> G5[Mobile market channel delivered] + + PB --> C1[PerpsController init] + C1 --> C2[Provider and DEX mapping] + C2 --> C3[Provider ready] + C3 --> C4[WebSocket healthy] + C4 --> C5[Persistent subscriptions ready] + + PB --> U1[User identity available] + U1 --> U2[Memory or disk identity checked] + U2 --> U3[Atomic user snapshot] + U3 --> U4[Mobile account channels delivered] + + C5 --> L1[Account live] + C5 --> L2[Positions live] + C5 --> L3[Orders live] + C5 --> L4[Prices live] +``` + +The global lane does not require an unlocked account. It may start before controller initialization or Homepage readiness. User-data and WebSocket timing are separate from the global-market optimization and must not be claimed as improved without their own evidence. + +## Cached and resume paths + +```mermaid +flowchart LR + D[Surface demand] --> K{Identity-correct data available?} + K -->|Resident memory| M[Resident content] + K -->|Persisted cache| DC[Disk-hydrated content] + K -->|No| N[Network bootstrap] + M --> R[Resolved frame] + DC --> R + N --> R + R --> T[Optional lifecycle-fresh takeover] +``` + +Resident return, cold disk hydration, short background continuity, and reconnect are distinct cohorts. Market and account cache sources are attributes within those cohorts, not separate lifecycle names. + +## Canonical stage vocabulary + +These names are shared by Mobile emitters, the harness parser, recipe requirements, evidence, runbook, and report: + +| Stage | Meaning | +| ----------------------------- | --------------------------------------------------------- | +| `perps_bootstrap_start` | Perps bootstrap requested | +| `surface_demand` | A visible surface requests Perps state | +| `surface_initial_ui_recorded` | Perps shell/header first committed | +| `surface_resolved_recorded` | Valid content, resolved-empty, or visible error committed | +| `surface_live_recorded` | Lifecycle-fresh data consumed by that surface committed | + +`react_commit`, `next_frame_checkpoint`, `socket_received`, and `subscriber_delivery` are lower-level recipe diagnostics, not public funnel stages. + +## Authoritative producer table + +Each boundary has exactly one producer. + +| Boundary or measurement | Authoritative producer | Destination | +| --------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Load Scripts | Existing Mobile startup instrumentation | `Load Scripts` trace | +| UI Startup | Existing Mobile startup instrumentation | `UI Startup` trace | +| Authentication duration | Existing Mobile login instrumentation | `Authenticate User` trace | +| Authentication end to Homepage ready | Mobile Homepage Ready flow, unlock cohort only | `Homepage Ready` measurement | +| Homepage ready | Existing Mobile Homepage instrumentation | `Homepage Ready` trace | +| Perps bootstrap start | Mobile `PerpsAlwaysOnProvider` | slim `Perps Loading Session` anchor and recipe marker | +| Controller constructed | Core constructor, using Mobile-supplied monotonic clock | Buffer construction/hydration timestamps in Core; after `perps_bootstrap_start`, write the derived offset to the explicit loading-session span handle | +| Market/user disk hydration identity and age | Core cache code | corresponding preload trace attributes plus recipe event | +| Terminal request, parse/validation, adoption | Core Terminal market service | `Perps Market Data Preload` measurements only | +| Global market preload | Core controller | `Perps Market Data Preload` trace | +| User preload | Core controller | `Perps User Data Preload` trace | +| Provider init, health, socket, subscriptions | Existing Mobile connection manager | `Perps Connection Establishment` measurements | +| First live price/positions/orders/account | Existing Mobile stream manager | existing `Perps WebSocket First *` traces | +| Homepage Perps TTC/DFD | Existing `useSectionPerformance` | existing Homepage section traces, extended with source/lifecycle/content variant | +| Surface initial UI and live-visible | Mobile surface instrumentation | measurements on the existing surface trace; recipe stages above | +| Bootstrap-relative markets/cache/live offsets | Mobile loading-session coordinator | slim `Perps Loading Session` only | + +Core-to-Mobile measurements must target an explicit trace/span handle. Ambient-span measurement writes are not accepted. The Core user-preload trace must not contain a wallet address or any other user identifier. + +## Slim loading session + +`Perps Loading Session` exists because Sentry dashboards cannot join independent root transactions. It contains only: + +- the `perps_bootstrap_start` anchor; +- non-negative bootstrap-relative readiness offsets for markets, one coherent atomic cached account state, and required live streams; +- the non-negative distance to Homepage Ready plus `bootstrap_before_homepage_ready`; +- lifecycle, surface, content variant, source, release, and outcome attributes. + +It must not copy startup, Terminal request/parse, preload, connection, first-data, TTC, or DFD durations. Existing traces remain authoritative for those values. + +`perps_session_id` is generated once per lifecycle/context generation and attached as trace data/context to reused traces for drill-down. It is never used as a dashboard group-by tag. Dashboard cohorts use bounded attributes such as release, platform, lifecycle, surface, content variant, and source. + +## Derived metrics + +| Metric | Formula / source | +| ------------------------------------ | ----------------------------------------------------------------------------- | +| Cold app startup | Existing cold `Homepage Ready` duration | +| Authentication | Existing `Authenticate User` duration | +| Auth-to-home | Homepage Ready measurement, unlock cohort only | +| Homepage/Perps distance | `abs(homepage_ready - perps_bootstrap_start)` plus ordering boolean | +| Controller init | Existing connection trace measurement | +| Terminal network / parse / adoption | Existing market-preload trace measurements | +| Markets ready | Bootstrap-relative loading-session milestone | +| Account cache ready | Atomic user-snapshot acceptance/delivery bootstrap-relative milestone | +| Complete live account | `max(account_live, positions_live, orders_live)` bootstrap-relative milestone | +| Live prices | `prices_live` bootstrap-relative milestone | +| Homepage TTC/DFD | Existing Homepage section traces | +| Initial UI / live-visible | Existing surface trace measurements plus recipe frame evidence | +| Cache-to-visible / socket-to-visible | Recipe evidence; promote only after semantics are proven | + +All offsets are non-negative. Ordering is represented by a separate bounded boolean attribute. + +## TTC and DFD + +- Homepage TTC is the existing `Homepage Section Time To Content`: surface mount/demand to valid content, resolved-empty, or visible error. Skeleton-only is not complete. +- Homepage DFD is the existing `Homepage Section Data Fetch`: loading interval for that section. +- Preserve the current visible-error event contract for compatibility, but production percentile widgets must filter `content_state != error`. Error rate is a separate widget filtered to `content_state = error`. +- Initial UI and live-visible are separate measurements. Complete resolved content must never be labelled “first visible”. +- Recipe-only socket → subscriber → commit → frame splits diagnose rendering; Sentry stores only the useful aggregate `socket_to_visible_ms` if device proof shows it is needed. + +## Surface readiness + +| Surface/content | Resolved requirement | Live requirement | +| ------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------ | +| Homepage trending | Positions and orders resolved empty, plus active markets with snapshot prices/trends | None unless the cards begin consuming live prices | +| Homepage positions/orders | Coherent positions, orders, and account state; expected item visible | Current-context account/positions/orders | +| Market list | Active markets with snapshot prices | First live prices for subscribed symbols | +| Market detail | Selected market metadata and snapshot price | Selected-symbol price; order book/candles are separate | +| Order form | Market details and account state | Current price/account state required by calculations | +| Error state | Retryable error visibly committed | Not applicable | + +## Executable lifecycle v1 + +| Lifecycle | Start condition | Required proof | +| ---------------------- | ------------------------------------------- | ------------------------------------------------------------- | +| `cold_no_cache` | New process, Perps caches cleared | Resolved surface plus bounded required-stream completion | +| `cold_disk_cache` | New process, valid persisted cache retained | Identity-correct cached frame, then fresh takeover | +| `navigate_return` | Surface remounted in same process | Resident frame; fresh tick optional | +| `background_short` | Background below documented grace period | Resident frame and connection continuity; fresh tick optional | +| `background_reconnect` | Background beyond grace period | Cached/LKG frame allowed, then fresh takeover | +| `account_switch` | Selected account changes | No prior-account frame; new identity accepted | +| `network_switch` | Perps network changes | No prior-network frame; new network/DEX identity accepted | + +`provider_switch` and `network_recovery` are deferred until the recipe has explicit target-provider/offline controls. They are not v1 claims. + +## Recipe and evidence rules + +- One public recipe: `perps.performance`. +- There is no platform parameter. `--device` selects Android or iOS. +- Setup proves unlocked wallet, selected account, provider, network, and content precondition in the same run. There is no boolean that bypasses those proofs. +- Setup nodes remain in `trace.json` but are excluded from performance durations. +- Cold live-stream completion is state-driven and bounded by 90 seconds; it is not an arbitrary sleep. +- `--hud show` is normal. `--hud hide` is permitted only for a labelled matched timing cohort and must match across arms. +- Every report value has exactly one status: `validated`, `recipe pending`, `release pending`, or `excluded`. +- Missing events remain missing. No derived value is fabricated. +- Dashboard 3948326 remains unchanged; corrections are proposals until its owner approves them. diff --git a/docs/perps/performance/RUNBOOK.md b/docs/perps/performance/RUNBOOK.md new file mode 100644 index 000000000000..c4c69109320d --- /dev/null +++ b/docs/perps/performance/RUNBOOK.md @@ -0,0 +1,74 @@ +# Perps performance validation runbook + +## Single source + +Perps loading and lifecycle measurements use exactly one recipe: + +`perps.performance` + +Source: + +`/library/recipes/mobile/perps/performance.recipe.json` + +Do not create Android, iOS, lifecycle, account, or source-strategy copies. The recipe parameters are identical across platforms. The only platform selection is the harness device target. + +## Comparable command + +```bash +mm-harness run perps.performance \ + account=dev1 \ + content_variant=trending \ + provider=hyperliquid \ + initial_network=mainnet \ + lifecycle=cold_no_cache \ + source_strategy=current_main \ + sample_id= \ + snapshot_endpoint_mode=deployed \ + --device \ + --heal off +``` + +Local candidate shortcut: + +```bash +mm-harness run perps.performance \ + account=dev1 \ + source_strategy=full_bootstrap \ + lifecycle=cold_no_cache \ + --device \ + --hud show +``` + +For a matched optimized arm, change only `source_strategy=full_bootstrap`, `sample_id`, and checkout. Keep the same physical device or simulator. If different devices are unavoidable, label the result hardware-unmatched and do not present it as a controlled code-performance comparison. Android and iOS use the same recipe parameters. + +## Timing boundaries + +The canonical recipe vocabulary below is the target stack contract. Mobile +emission of `perps_bootstrap_start` and its loading-session milestones lands in +the next stacked PR; runs against this foundation PR alone must mark those rows +`recipe pending` rather than synthesizing them. They become `release pending` +only after the instrumentation is implemented and validated on-device. + +The recipe captures both clocks without mixing them: + +- Existing app startup traces retain process, UI, authentication, and Homepage Ready timing. +- Perps bootstrap-relative milestones anchor at recipe-proven `perps_bootstrap_start`. +- Surface TTC/DFD anchor at `surface_demand`. + +Metro compilation and fixture/account setup remain visible in `trace.json` but are excluded from product durations. The relative ordering of Homepage Ready and Perps bootstrap is measured rather than assumed. + +Unlock readiness is state-driven through `metamask.wallet.ensure_unlocked`; the measured graph contains no fixed post-unlock stabilization delay. + +The normal operator mode is `--hud show`. Use `--hud hide` only for an explicitly labeled matched timing cohort, and apply the same HUD setting to both arms. + +## Report lanes + +The final report combines: + +1. Homepage markets, HIP-3 coverage, prices, account resolution, cached visibility, and live takeover. +2. Critical Perps CUFs already instrumented in the app: market list/detail, open position, limit order, close, and cancel. +3. Executable lifecycle cohorts: cold no-cache, cold disk cache, navigation return, short resume, reconnect, account switch, and network switch. + +`provider_switch` and `network_recovery` are deferred until the recipe exposes deterministic controls for them. + +Do not combine setup/build duration with these measurements, and do not compare Android device timing directly with an iOS simulator as a code-performance claim. diff --git a/docs/perps/performance/SENTRY.md b/docs/perps/performance/SENTRY.md new file mode 100644 index 000000000000..bfe347f9fcee --- /dev/null +++ b/docs/perps/performance/SENTRY.md @@ -0,0 +1,204 @@ +# Perps performance Sentry contract + +This maps the approved event model to Sentry. It contains no performance values. + +## Rule + +Reuse existing startup, preload, connection, first-data, Homepage-section, and critical-user-flow traces. Add only missing attributes, targeted child measurements, and one slim bootstrap-relative loading session. No boundary may have two producers. + +## Trace topology + +```mermaid +flowchart TD + LS[Load Scripts] + UI[UI Startup] + AUTH[Authenticate User when required] + HOME[Homepage Ready] + SESSION[Perps Loading Session: offsets only] + MARKET[Perps Market Data Preload] + USER[Perps User Data Preload] + CONN[Perps Connection Establishment] + LIVE[Perps WebSocket First streams] + SECTION[Homepage Section TTC and DFD] + SESSION -. perps_session_id context .- MARKET + SESSION -. perps_session_id context .- USER + SESSION -. perps_session_id context .- CONN + SESSION -. perps_session_id context .- LIVE + SESSION -. perps_session_id context .- SECTION + HOME <-. absolute distance + ordering .-> SESSION +``` + +These remain independent root transactions. The session is not a synthetic parent and does not copy their durations. + +## Existing authoritative traces + +| Stage | Existing trace | Minimal change | +| ---------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| Script load | `Load Scripts` | Query by release/platform/app-start type | +| UI mount | `UI Startup` | Query by release/platform/app-start type | +| Authentication | `Authenticate User` | Query only where authentication occurs | +| Homepage readiness | `Homepage Ready` | Add unlock-only auth-end-to-ready measurement | +| Global market preload | `Perps Market Data Preload` | Add source, counts, snapshot status, and targeted Terminal child measurements | +| User preload | `Perps User Data Preload` | Remove raw address; add bounded cache identity/source attributes | +| Connection | `Perps Connection Establishment` | Reuse provider-init, health, socket, and subscription measurements | +| First live streams | `Perps WebSocket First Price/Positions/Orders/Account` | Add lifecycle/source/session context to all four | +| Homepage Perps TTC/DFD | `Homepage Section Time To Content` / `Homepage Section Data Fetch` | Add lifecycle, market source, account source, content variant | +| Critical flows | Existing Perps CUF traces | Query by release/platform/network/provider | + +## Measurements by authoritative trace + +### `Homepage Ready` + +- `authentication_end_to_homepage_ready_ms`, unlock cohort only. + +Cold app-open, unlock, and warm app-open Homepage Ready traces keep their existing distinct anchors. Missing authentication on an already-unlocked cold start remains missing. + +### `Perps Market Data Preload` + +- `terminal_request_duration_ms` +- `terminal_parse_validate_duration_ms` +- `terminal_mobile_adoption_duration_ms` + +These measurements are emitted once by Core and written to the explicit market-preload span handle through the Mobile tracing adapter. They do not appear on the loading session. + +### `Perps Loading Session` + +All measurements are non-negative bootstrap-relative offsets: + +- `process_to_perps_bootstrap_start_ms` +- `homepage_ready_distance_from_perps_bootstrap_start_ms` +- `process_to_perps_controller_constructed_ms` +- `markets_ready_ms` +- `account_cache_ready_ms` +- `account_live_ms` +- `positions_live_ms` +- `orders_live_ms` +- `prices_live_ms` + +`bootstrap_before_homepage_ready` records ordering. Percentiles must be split by this boolean; a signed offset is forbidden. + +The session starts at `perps_bootstrap_start`. It ends when the surface-specific resolved requirement and any lifecycle-required live streams are recorded, or at a bounded error/timeout. Homepage trending does not wait for live prices. + +### Existing Homepage section traces + +Do not add another Homepage TTC or DFD transaction. + +- Existing TTC remains the resolved-content duration. +- Existing DFD remains the loading duration. +- Add `surface_initial_ui_ms` and `surface_live_content_ms` as measurements on that existing surface trace only where applicable. +- Keep `socket_to_visible_ms` recipe-only until on-device evidence justifies promotion. +- Keep socket→subscriber→commit→frame components recipe-only. + +Visible errors retain the existing event shape for compatibility. Successful-latency widgets must filter `content_state != error`; error-rate widgets use `content_state = error`. + +Dashboard cohort queries use span attributes. The section hook writes bounded +cohort values as start attributes and refreshes them on the same span before +completion; Sentry event tags are compatibility metadata, not the dashboard +source of truth. + +## Attributes + +### Indexed, bounded cohort attributes + +- `release` +- `environment` +- `platform` +- `app_start_type` +- `surface` +- `content_variant` +- `lifecycle` +- `provider` +- `network` +- `market_source`: `terminal_v2`, `provider`, `memory_cache`, `disk_cache` +- `account_source`: `provider_snapshot`, `memory_cache`, `disk_cache`, `fresh_socket` +- `terminal_snapshot_status`: `accepted`, `stale`, `invalid`, `http_error`, `not_attempted` +- `cache_identity_valid` +- `cache_age_bucket` +- `data_ready_at_demand` +- `required_live_streams_complete` +- `bootstrap_before_homepage_ready` +- `content_state` +- `success` +- `failure_stage` + +### Trace data/context, never dashboard group-by tags + +- `perps_session_id` +- `account_generation` +- `context_generation` +- `enabled_dex_fingerprint` +- market coverage counts + +`hip3_config_version` may be indexed only if its production value space is demonstrably bounded. + +Never attach wallet addresses, account names, order IDs, position IDs, balances, or raw upstream error bodies. The existing raw `userAddress` on `Perps User Data Preload` must be removed before this contract ships. + +## Correlation + +Mobile creates one random `perps_session_id` per lifecycle/context generation at `perps_bootstrap_start`. It passes the identifier through the existing tracing infrastructure to Core and attaches it as trace data/context to reused Perps and Homepage-section traces. It is for individual-run drill-down, not aggregation. + +Dashboards aggregate each authoritative transaction independently using the same bounded release/platform/lifecycle/source attributes. Bootstrap-relative cross-stage widgets query the slim loading session, which is why those offsets exist there. + +## Dashboard proposal + +### Startup + +- Load Scripts p50/p75/p95. +- UI Startup p50/p75/p95. +- Authenticate User p50/p75/p95 when present. +- Auth-end → Homepage Ready for unlock. +- Homepage Ready ↔ Perps bootstrap distance, split by ordering. + +### Cold Perps + +- Controller construction and existing controller-init measurements. +- Terminal network versus parse/adoption from market-preload trace. +- Bootstrap-relative markets/account-cache/live readiness from loading session. +- Existing Homepage TTC/DFD split by `content_variant` and filtered to `content_state != error`. +- Visible error rate in its own widget. +- Market coverage and Terminal acceptance rate. + +### Cached/resume + +- Resident return TTC. +- Disk-cache hydration and cache-to-visible recipe result. +- Short resume TTC and continuity. +- Reconnect cached visibility then live takeover. +- Cache identity rejection rate. + +### Critical Perps flows + +- Market-list entry. +- Market detail live. +- Open position rendered. +- Limit order rendered. +- Close confirmation. +- Cancel confirmation. + +Every latency widget splits by platform and identifiable release. Android and iOS are never pooled for a performance conclusion. + +## Implementation gates + +1. Fix the Core→Mobile tracing bridge so measurements target their intended trace/span. Core buffers constructor/hydration monotonic timestamps; the Mobile loading-session coordinator writes their derived offsets only after the explicit session span exists. +2. Remove the wallet address from Core user-preload trace data. +3. Use the canonical recipe stage names from `ARCHITECTURE.md`. +4. Correlate one Android and one iOS recipe run with development Sentry events. +5. Create or update a separate development validation dashboard. +6. Populate production widgets only from an identifiable release containing the instrumentation. +7. Keep dashboard 3948326 unchanged until its owner approves a separate proposed diff. + +## Delivery status + +- Core snapshot and atomic user-data behavior is released in `@metamask/perps-controller` 12.0.0. +- Explicit preload span targeting, controller-construction timing, and user-address removal are implemented in [MetaMask/core#9906](https://github.com/MetaMask/core/pull/9906) and remain release pending. +- Terminal snapshot availability hardening is implemented in [terminal-backend#49](https://github.com/consensys-vertical-apps/terminal-backend/pull/49) and remains deployment pending. +- Dashboard widgets may be created before those releases, but empty widgets must remain labelled `release pending`; absence of data is not a zero-duration result. + +## Status vocabulary + +Every report/dashboard value is labelled exactly one of: + +- `validated`: correlated device/recipe evidence exists; +- `recipe pending`: instrumentation or on-device reproduction remains; +- `release pending`: device proof exists but no identifiable production release contains it; +- `excluded`: the value is invalid, incomparable, or outside the claim.