diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 774a044d4..653bd7086 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -9,6 +9,7 @@ import { LayoutChangeEvent, } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; @@ -26,6 +27,12 @@ import { formatConnectionStatus } from "@/utils/daemons"; import { useDaemonSession } from "@/hooks/use-daemon-session"; import { useDaemonRequest } from "@/hooks/use-daemon-request"; import type { SessionOutboundMessage } from "@server/server/messages"; +import { + buildAgentNavigationKey, + endNavigationTiming, + HOME_NAVIGATION_KEY, + startNavigationTiming, +} from "@/utils/navigation-timing"; const DROPDOWN_WIDTH = 220; @@ -89,6 +96,7 @@ export default function AgentScreen() { suppressUnavailableAlert: true, allowUnavailable: true, }); + const sessionServerId = session?.serverId ?? null; const connectionServerId = resolvedServerId ?? null; const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; @@ -99,8 +107,43 @@ export default function AgentScreen() { const lastConnectionError = connection?.lastError ?? null; const handleBackToHome = useCallback(() => { + const targetServerId = resolvedServerId ?? sessionServerId; + const targetAgentId = resolvedAgentId ?? null; + if (targetServerId && targetAgentId) { + startNavigationTiming(HOME_NAVIGATION_KEY, { + from: "agent", + to: "home", + targetMs: 300, + params: { + serverId: targetServerId, + agentId: targetAgentId, + }, + }); + } else { + startNavigationTiming(HOME_NAVIGATION_KEY, { + from: "agent", + to: "home", + targetMs: 300, + }); + } router.replace("/"); - }, [router]); + }, [resolvedAgentId, resolvedServerId, router, sessionServerId]); + + const focusServerId = resolvedServerId ?? sessionServerId; + const navigationStatus = session ? "ready" : "session_unavailable"; + + useFocusEffect( + useCallback(() => { + if (!resolvedAgentId || !focusServerId) { + return; + } + const navigationKey = buildAgentNavigationKey(focusServerId, resolvedAgentId); + endNavigationTiming(navigationKey, { + screen: "agent", + status: navigationStatus, + }); + }, [focusServerId, navigationStatus, resolvedAgentId]) + ); if (!session) { return ( diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 73bcf6639..7c022f155 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,5 +1,6 @@ import { View, ActivityIndicator } from "react-native"; import { useState, useCallback, useMemo, useRef, useEffect } from "react"; +import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; @@ -10,6 +11,7 @@ import { AgentList } from "@/components/agent-list"; import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useLocalSearchParams } from "expo-router"; +import { endNavigationTiming, HOME_NAVIGATION_KEY } from "@/utils/navigation-timing"; export default function HomeScreen() { const insets = useSafeAreaInsets(); @@ -96,6 +98,12 @@ export default function HomeScreen() { openImportModal(deepLinkServerId); }, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]); + useFocusEffect( + useCallback(() => { + endNavigationTiming(HOME_NAVIGATION_KEY, { screen: "home" }); + }, []) + ); + return ( {/* Header */} diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 489187368..580464d06 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -7,6 +7,7 @@ import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status"; import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useDaemonSession } from "@/hooks/use-daemon-session"; +import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing"; interface AgentListProps { agents: AggregatedAgent[]; @@ -29,6 +30,12 @@ export function AgentList({ agents }: AgentListProps) { if (isActionSheetVisible) { return; } + const navigationKey = buildAgentNavigationKey(serverId, agentId); + startNavigationTiming(navigationKey, { + from: "home", + to: "agent", + params: { serverId, agentId }, + }); router.push({ pathname: "/agent/[serverId]/[agentId]", params: { diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 17bfd4fbd..8660eb321 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -32,6 +32,9 @@ import { ScrollView } from "react-native"; import * as FileSystem from 'expo-file-system'; import { useDaemonConnections } from "./daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; +import { getNowMs, isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; + +const SESSION_CONTEXT_LOG_TAG = "[SessionContext]"; const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => { const fallbackId = @@ -1870,7 +1873,20 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid useEffect(() => { const payload = { ...value }; + const shouldLog = isPerfLoggingEnabled(); + const start = shouldLog ? getNowMs() : null; updateSession(serverId, payload); + if (shouldLog && start !== null) { + const durationMs = getNowMs() - start; + const metrics = measurePayload(payload); + perfLog(SESSION_CONTEXT_LOG_TAG, { + event: "updateSession", + serverId, + durationMs: Number(durationMs.toFixed(2)), + payloadApproxBytes: metrics.approxBytes, + payloadFieldCount: metrics.fieldCount, + }); + } }, [serverId, updateSession, value]); useEffect(() => { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 55fa21eef..e5432fd4a 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from "react"; import type { SessionContextValue } from "@/contexts/session-context"; +import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; // SessionData mirrors SessionContextValue so consumers can subscribe to a single source of truth. export type SessionData = SessionContextValue; @@ -19,6 +20,8 @@ type SessionListener = () => void; let storeState: SessionStoreState = { sessions: {} }; const listeners = new Set(); +const SESSION_STORE_LOG_TAG = "[SessionStore]"; +let sessionStoreUpdateCount = 0; const emit = () => { for (const listener of listeners) { @@ -26,6 +29,26 @@ const emit = () => { } }; +const logSessionStoreUpdate = ( + type: "setSession" | "updateSession" | "clearSession", + serverId: string, + payload?: unknown +) => { + if (!isPerfLoggingEnabled()) { + return; + } + sessionStoreUpdateCount += 1; + const metrics = payload ? measurePayload(payload) : null; + perfLog(SESSION_STORE_LOG_TAG, { + event: type, + serverId, + updateCount: sessionStoreUpdateCount, + payloadApproxBytes: metrics?.approxBytes ?? 0, + payloadFieldCount: metrics?.fieldCount ?? 0, + timestamp: Date.now(), + }); +}; + const updateStoreState = (updater: (prev: SessionStoreState) => SessionStoreState) => { const next = updater(storeState); if (next === storeState) { @@ -40,6 +63,7 @@ const setSession: SessionStore["setSession"] = (serverId, data) => { if (prev.sessions[serverId] === data) { return prev; } + logSessionStoreUpdate("setSession", serverId, data); return { sessions: { ...prev.sessions, @@ -66,6 +90,7 @@ const updateSession: SessionStore["updateSession"] = (serverId, partial) => { return prev; } + logSessionStoreUpdate("updateSession", serverId, partial); return { sessions: { ...prev.sessions, @@ -80,6 +105,7 @@ const clearSession: SessionStore["clearSession"] = (serverId) => { if (!(serverId in prev.sessions)) { return prev; } + logSessionStoreUpdate("clearSession", serverId); const nextSessions = { ...prev.sessions }; delete nextSessions[serverId]; return { sessions: nextSessions }; diff --git a/packages/app/src/utils/navigation-timing.ts b/packages/app/src/utils/navigation-timing.ts new file mode 100644 index 000000000..c651b5a99 --- /dev/null +++ b/packages/app/src/utils/navigation-timing.ts @@ -0,0 +1,80 @@ +import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; + +type NavigationTimingDetails = { + from: string; + to: string; + params?: Record; + targetMs?: number; +}; + +type NavigationTimingEntry = NavigationTimingDetails & { + startedAt: number; +}; + +const NAVIGATION_TAG = "[NavigationTiming]"; +const pendingNavigations = new Map(); + +export const HOME_NAVIGATION_KEY = "home"; + +export const buildAgentNavigationKey = (serverId: string, agentId: string) => + `agent:${serverId}:${agentId}`; + +export const startNavigationTiming = (key: string, details: NavigationTimingDetails): void => { + if (!isPerfLoggingEnabled()) { + return; + } + + pendingNavigations.set(key, { + ...details, + startedAt: getNowMs(), + }); + + perfLog(NAVIGATION_TAG, { + phase: "start", + key, + from: details.from, + to: details.to, + targetMs: details.targetMs ?? null, + params: details.params ?? null, + }); +}; + +export const endNavigationTiming = (key: string, extra?: Record): void => { + if (!isPerfLoggingEnabled()) { + return; + } + + const entry = pendingNavigations.get(key); + if (!entry) { + return; + } + pendingNavigations.delete(key); + + const durationMs = getNowMs() - entry.startedAt; + perfLog(NAVIGATION_TAG, { + phase: "complete", + key, + from: entry.from, + to: entry.to, + durationMs: Number(durationMs.toFixed(2)), + targetMs: entry.targetMs ?? null, + params: entry.params ?? null, + extra: extra ?? null, + }); +}; + +export const cancelNavigationTiming = (key: string, reason?: string): void => { + if (!isPerfLoggingEnabled()) { + return; + } + + if (!pendingNavigations.has(key)) { + return; + } + pendingNavigations.delete(key); + perfLog(NAVIGATION_TAG, { + phase: "cancelled", + key, + reason: reason ?? null, + }); +}; diff --git a/packages/app/src/utils/perf.ts b/packages/app/src/utils/perf.ts new file mode 100644 index 000000000..326e58811 --- /dev/null +++ b/packages/app/src/utils/perf.ts @@ -0,0 +1,122 @@ +const getIsDevEnvironment = (): boolean => { + const globalDev = (globalThis as { __DEV__?: boolean } | undefined)?.__DEV__; + if (typeof globalDev === "boolean") { + return globalDev; + } + if (typeof process !== "undefined" && process.env?.NODE_ENV) { + return process.env.NODE_ENV !== "production"; + } + return false; +}; + +const shouldDisablePerfLogging = + typeof process !== "undefined" && process.env?.EXPO_PUBLIC_DISABLE_PERF_LOGGING === "1"; +const shouldForcePerfLogging = + typeof process !== "undefined" && process.env?.EXPO_PUBLIC_ENABLE_PERF_LOGGING === "1"; + +const PERF_LOGGING_ENABLED = + (shouldForcePerfLogging || getIsDevEnvironment()) && !shouldDisablePerfLogging; + +export const isPerfLoggingEnabled = (): boolean => PERF_LOGGING_ENABLED; + +export const perfLog = (tag: string, details: Record): void => { + if (!PERF_LOGGING_ENABLED) { + return; + } + console.info(tag, details); +}; + +export const getNowMs = (): number => { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +}; + +export interface PayloadMetrics { + approxBytes: number; + fieldCount: number; +} + +const MEASUREMENT_DEPTH = 2; + +export const measurePayload = (payload: unknown): PayloadMetrics => { + if (!payload || typeof payload !== "object") { + return { + approxBytes: 0, + fieldCount: 0, + }; + } + + const fieldCount = Object.keys(payload as Record).length; + const approxBytes = estimateSize(payload, MEASUREMENT_DEPTH, new WeakSet()); + + return { + approxBytes, + fieldCount, + }; +}; + +const estimateSize = (value: unknown, depth: number, seen: WeakSet): number => { + if (depth <= 0 || value === null || value === undefined) { + return 0; + } + + if (typeof value === "string") { + return value.length; + } + + if (typeof value === "number") { + return 8; + } + + if (typeof value === "boolean") { + return 4; + } + + if (typeof value === "bigint") { + return value.toString().length; + } + + if (typeof value === "symbol" || typeof value === "function") { + return 0; + } + + if (typeof value !== "object") { + return 0; + } + + const objectValue = value as object; + if (seen.has(objectValue)) { + return 0; + } + seen.add(objectValue); + + if (Array.isArray(value)) { + return value.reduce((total, item) => total + estimateSize(item, depth - 1, seen), 0); + } + + if (value instanceof Map) { + let total = 0; + for (const [mapKey, mapValue] of value.entries()) { + total += estimateSize(mapKey, depth - 1, seen); + total += estimateSize(mapValue, depth - 1, seen); + } + return total; + } + + if (value instanceof Set) { + let total = 0; + for (const setValue of value.values()) { + total += estimateSize(setValue, depth - 1, seen); + } + return total; + } + + let total = 0; + for (const [key, child] of Object.entries(value as Record)) { + total += key.length; + total += estimateSize(child, depth - 1, seen); + } + return total; +};