mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
chore: add session perf instrumentation
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
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 { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||||
|
import {
|
||||||
|
buildAgentNavigationKey,
|
||||||
|
endNavigationTiming,
|
||||||
|
HOME_NAVIGATION_KEY,
|
||||||
|
startNavigationTiming,
|
||||||
|
} from "@/utils/navigation-timing";
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
|
|
||||||
@@ -89,6 +96,7 @@ export default function AgentScreen() {
|
|||||||
suppressUnavailableAlert: true,
|
suppressUnavailableAlert: true,
|
||||||
allowUnavailable: true,
|
allowUnavailable: true,
|
||||||
});
|
});
|
||||||
|
const sessionServerId = session?.serverId ?? null;
|
||||||
|
|
||||||
const connectionServerId = resolvedServerId ?? null;
|
const connectionServerId = resolvedServerId ?? null;
|
||||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||||
@@ -99,8 +107,43 @@ export default function AgentScreen() {
|
|||||||
const lastConnectionError = connection?.lastError ?? null;
|
const lastConnectionError = connection?.lastError ?? null;
|
||||||
|
|
||||||
const handleBackToHome = useCallback(() => {
|
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.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) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { View, ActivityIndicator } from "react-native";
|
import { View, ActivityIndicator } from "react-native";
|
||||||
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
|
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 { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
|
||||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { endNavigationTiming, HOME_NAVIGATION_KEY } from "@/utils/navigation-timing";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -96,6 +98,12 @@ export default function HomeScreen() {
|
|||||||
openImportModal(deepLinkServerId);
|
openImportModal(deepLinkServerId);
|
||||||
}, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]);
|
}, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
endNavigationTiming(HOME_NAVIGATION_KEY, { screen: "home" });
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
|||||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||||
|
import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
agents: AggregatedAgent[];
|
agents: AggregatedAgent[];
|
||||||
@@ -29,6 +30,12 @@ export function AgentList({ agents }: AgentListProps) {
|
|||||||
if (isActionSheetVisible) {
|
if (isActionSheetVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||||
|
startNavigationTiming(navigationKey, {
|
||||||
|
from: "home",
|
||||||
|
to: "agent",
|
||||||
|
params: { serverId, agentId },
|
||||||
|
});
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/agent/[serverId]/[agentId]",
|
pathname: "/agent/[serverId]/[agentId]",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import { ScrollView } from "react-native";
|
|||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { useDaemonConnections } from "./daemon-connections-context";
|
import { useDaemonConnections } from "./daemon-connections-context";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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 derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
|
||||||
const fallbackId =
|
const fallbackId =
|
||||||
@@ -1870,7 +1873,20 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const payload = { ...value };
|
const payload = { ...value };
|
||||||
|
const shouldLog = isPerfLoggingEnabled();
|
||||||
|
const start = shouldLog ? getNowMs() : null;
|
||||||
updateSession(serverId, payload);
|
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]);
|
}, [serverId, updateSession, value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import type { SessionContextValue } from "@/contexts/session-context";
|
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.
|
// SessionData mirrors SessionContextValue so consumers can subscribe to a single source of truth.
|
||||||
export type SessionData = SessionContextValue;
|
export type SessionData = SessionContextValue;
|
||||||
@@ -19,6 +20,8 @@ type SessionListener = () => void;
|
|||||||
|
|
||||||
let storeState: SessionStoreState = { sessions: {} };
|
let storeState: SessionStoreState = { sessions: {} };
|
||||||
const listeners = new Set<SessionListener>();
|
const listeners = new Set<SessionListener>();
|
||||||
|
const SESSION_STORE_LOG_TAG = "[SessionStore]";
|
||||||
|
let sessionStoreUpdateCount = 0;
|
||||||
|
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
for (const listener of listeners) {
|
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 updateStoreState = (updater: (prev: SessionStoreState) => SessionStoreState) => {
|
||||||
const next = updater(storeState);
|
const next = updater(storeState);
|
||||||
if (next === storeState) {
|
if (next === storeState) {
|
||||||
@@ -40,6 +63,7 @@ const setSession: SessionStore["setSession"] = (serverId, data) => {
|
|||||||
if (prev.sessions[serverId] === data) {
|
if (prev.sessions[serverId] === data) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
logSessionStoreUpdate("setSession", serverId, data);
|
||||||
return {
|
return {
|
||||||
sessions: {
|
sessions: {
|
||||||
...prev.sessions,
|
...prev.sessions,
|
||||||
@@ -66,6 +90,7 @@ const updateSession: SessionStore["updateSession"] = (serverId, partial) => {
|
|||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logSessionStoreUpdate("updateSession", serverId, partial);
|
||||||
return {
|
return {
|
||||||
sessions: {
|
sessions: {
|
||||||
...prev.sessions,
|
...prev.sessions,
|
||||||
@@ -80,6 +105,7 @@ const clearSession: SessionStore["clearSession"] = (serverId) => {
|
|||||||
if (!(serverId in prev.sessions)) {
|
if (!(serverId in prev.sessions)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
logSessionStoreUpdate("clearSession", serverId);
|
||||||
const nextSessions = { ...prev.sessions };
|
const nextSessions = { ...prev.sessions };
|
||||||
delete nextSessions[serverId];
|
delete nextSessions[serverId];
|
||||||
return { sessions: nextSessions };
|
return { sessions: nextSessions };
|
||||||
|
|||||||
80
packages/app/src/utils/navigation-timing.ts
Normal file
80
packages/app/src/utils/navigation-timing.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||||
|
|
||||||
|
type NavigationTimingDetails = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
targetMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavigationTimingEntry = NavigationTimingDetails & {
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAVIGATION_TAG = "[NavigationTiming]";
|
||||||
|
const pendingNavigations = new Map<string, NavigationTimingEntry>();
|
||||||
|
|
||||||
|
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<string, unknown>): 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
122
packages/app/src/utils/perf.ts
Normal file
122
packages/app/src/utils/perf.ts
Normal file
@@ -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<string, unknown>): 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<string, unknown>).length;
|
||||||
|
const approxBytes = estimateSize(payload, MEASUREMENT_DEPTH, new WeakSet());
|
||||||
|
|
||||||
|
return {
|
||||||
|
approxBytes,
|
||||||
|
fieldCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const estimateSize = (value: unknown, depth: number, seen: WeakSet<object>): 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<string, unknown>)) {
|
||||||
|
total += key.length;
|
||||||
|
total += estimateSize(child, depth - 1, seen);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user