Summary: Usage tracking comes from Electron's `main.tsx`. There's a timer that dispatches an IPC event every 60 seconds. This is all good for Electron builds. For non-electron builds, there's no such thing as IPC. So, react to the IPC event whenever necessary but also handle the interval internally such that usage is tracked independently of explicit callers. Reviewed By: antonk52 Differential Revision: D47053404 fbshipit-source-id: f17694e65eed18678b45a2e815813bafab69c3f1
380 lines
9.9 KiB
TypeScript
380 lines
9.9 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import EventEmitter from 'eventemitter3';
|
|
|
|
import {State, Store} from '../reducers/index';
|
|
import {Logger} from 'flipper-common';
|
|
import {
|
|
getPluginBackgroundStats,
|
|
resetPluginBackgroundStatsDelta,
|
|
} from '../utils/pluginStats';
|
|
import {
|
|
clearTimeline,
|
|
TrackingEvent,
|
|
State as UsageTrackingState,
|
|
selectionChanged,
|
|
} from '../reducers/usageTracking';
|
|
import produce from 'immer';
|
|
import {BaseDevice} from 'flipper-frontend-core';
|
|
import {deconstructClientId} from 'flipper-common';
|
|
import {sideEffect} from '../utils/sideEffect';
|
|
import {getSelectionInfo} from '../utils/info';
|
|
import type {SelectionInfo} from '../utils/info';
|
|
import {getRenderHostInstance} from 'flipper-frontend-core';
|
|
|
|
const TIME_SPENT_EVENT = 'time-spent';
|
|
|
|
type UsageInterval = {
|
|
selectionKey: string | null;
|
|
selection: SelectionInfo | null;
|
|
length: number;
|
|
focused: boolean;
|
|
};
|
|
|
|
export type UsageSummary = {
|
|
total: {focusedTime: number; unfocusedTime: number};
|
|
plugin: {
|
|
[pluginKey: string]: {
|
|
focusedTime: number;
|
|
unfocusedTime: number;
|
|
} & SelectionInfo;
|
|
};
|
|
};
|
|
|
|
export const fpsEmitter = new EventEmitter();
|
|
|
|
// var is fine, let doesn't have the correct hoisting semantics
|
|
// eslint-disable-next-line no-var
|
|
var bytesReceivedEmitter: EventEmitter;
|
|
|
|
export function onBytesReceived(
|
|
callback: (plugin: string, bytes: number) => void,
|
|
): () => void {
|
|
if (!bytesReceivedEmitter) {
|
|
bytesReceivedEmitter = new EventEmitter();
|
|
}
|
|
bytesReceivedEmitter.on('bytesReceived', callback);
|
|
return () => {
|
|
bytesReceivedEmitter.off('bytesReceived', callback);
|
|
};
|
|
}
|
|
|
|
export function emitBytesReceived(plugin: string, bytes: number) {
|
|
if (bytesReceivedEmitter) {
|
|
bytesReceivedEmitter.emit('bytesReceived', plugin, bytes);
|
|
}
|
|
}
|
|
export default (store: Store, logger: Logger) => {
|
|
const renderHost = getRenderHostInstance();
|
|
sideEffect(
|
|
store,
|
|
{
|
|
name: 'pluginUsageTracking',
|
|
throttleMs: 0,
|
|
noTimeBudgetWarns: true,
|
|
runSynchronously: true,
|
|
},
|
|
getSelectionInfo,
|
|
(selection, store) => {
|
|
const time = Date.now();
|
|
store.dispatch(selectionChanged({selection, time}));
|
|
},
|
|
);
|
|
|
|
let droppedFrames: number = 0;
|
|
let largeFrameDrops: number = 0;
|
|
|
|
const oldExitData = loadExitData();
|
|
if (oldExitData) {
|
|
const isReload =
|
|
renderHost.serverConfig.environmentInfo.processId === oldExitData.pid;
|
|
const timeSinceLastStartup =
|
|
Date.now() - parseInt(oldExitData.lastSeen, 10);
|
|
|
|
logger.track('usage', isReload ? 'reload' : 'restart', {
|
|
...oldExitData,
|
|
pid: undefined,
|
|
timeSinceLastStartup,
|
|
});
|
|
// create fresh exit data
|
|
const {selectedDevice, selectedAppId, selectedPlugin} =
|
|
store.getState().connections;
|
|
persistExitData(
|
|
{
|
|
selectedDevice,
|
|
selectedAppId,
|
|
selectedPlugin,
|
|
},
|
|
false,
|
|
);
|
|
}
|
|
|
|
function droppedFrameDetection(
|
|
past: DOMHighResTimeStamp,
|
|
isWindowFocused: () => boolean,
|
|
) {
|
|
const now = performance.now();
|
|
requestAnimationFrame(() => droppedFrameDetection(now, isWindowFocused));
|
|
const delta = now - past;
|
|
const dropped = Math.round(delta / (1000 / 60) - 1);
|
|
fpsEmitter.emit('fps', delta > 1000 ? 0 : Math.round(1000 / (now - past)));
|
|
if (!isWindowFocused() || dropped < 1) {
|
|
return;
|
|
}
|
|
droppedFrames += dropped;
|
|
if (dropped > 3) {
|
|
largeFrameDrops++;
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
droppedFrameDetection(
|
|
performance.now(),
|
|
() => store.getState().application.windowIsFocused,
|
|
);
|
|
}
|
|
|
|
const trackUsage = (...args: any[]) => {
|
|
let state: State;
|
|
try {
|
|
state = store.getState();
|
|
} catch (e) {
|
|
// If trackUsage is called (indirectly) through a reducer,
|
|
// this will utterly kill Flipper.
|
|
// Let's prevent that and log an error instead.
|
|
console.error(
|
|
'trackUsage triggered indirectly as side effect of a reducer',
|
|
e,
|
|
);
|
|
return;
|
|
}
|
|
const {selectedDevice, selectedPlugin, selectedAppId, clients} =
|
|
state.connections;
|
|
|
|
persistExitData(
|
|
{selectedDevice, selectedPlugin, selectedAppId},
|
|
args[0] === 'exit',
|
|
);
|
|
|
|
const currentTime = Date.now();
|
|
const usageSummary = computeUsageSummary(state.usageTracking, currentTime);
|
|
|
|
store.dispatch(clearTimeline(currentTime));
|
|
|
|
logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
|
|
for (const key of Object.keys(usageSummary.plugin)) {
|
|
logger.track(
|
|
'usage',
|
|
TIME_SPENT_EVENT,
|
|
usageSummary.plugin[key],
|
|
usageSummary.plugin[key]?.plugin ?? 'none',
|
|
);
|
|
}
|
|
|
|
Object.entries(state.connections.enabledPlugins).forEach(
|
|
([app, plugins]) => {
|
|
logger.track('usage', 'enabled-plugins', {
|
|
app,
|
|
enabledPugins: plugins,
|
|
});
|
|
},
|
|
);
|
|
|
|
const bgStats = getPluginBackgroundStats();
|
|
logger.track('usage', 'plugin-stats', {
|
|
cpuTime: bgStats.cpuTime,
|
|
bytesReceived: bgStats.bytesReceived,
|
|
});
|
|
for (const key of Object.keys(bgStats.byPlugin)) {
|
|
const {
|
|
cpuTimeTotal: _a,
|
|
messageCountTotal: _b,
|
|
bytesReceivedTotal: _c,
|
|
...dataWithoutTotal
|
|
} = bgStats.byPlugin[key];
|
|
if (Object.values(dataWithoutTotal).some((v) => v > 0)) {
|
|
logger.track('usage', 'plugin-stats-plugin', dataWithoutTotal, key);
|
|
}
|
|
}
|
|
resetPluginBackgroundStatsDelta();
|
|
|
|
if (
|
|
!state.application.windowIsFocused ||
|
|
!selectedDevice ||
|
|
!selectedPlugin
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let app: string | null = null;
|
|
let sdkVersion: number | null = null;
|
|
|
|
if (selectedAppId) {
|
|
const client = clients.get(selectedAppId);
|
|
if (client) {
|
|
app = client.query.app;
|
|
sdkVersion = client.query.sdk_version || 0;
|
|
}
|
|
}
|
|
|
|
const info = {
|
|
droppedFrames,
|
|
largeFrameDrops,
|
|
os: selectedDevice.os,
|
|
device: selectedDevice.title,
|
|
plugin: selectedPlugin,
|
|
app,
|
|
sdkVersion,
|
|
isForeground: state.application.windowIsFocused,
|
|
usedJSHeapSize: (window.performance as any).memory.usedJSHeapSize,
|
|
cpuLoad: renderHost.getPercentCPUUsage?.() ?? 0,
|
|
};
|
|
|
|
// reset dropped frames counter
|
|
droppedFrames = 0;
|
|
largeFrameDrops = 0;
|
|
|
|
logger.track('usage', 'ping', info);
|
|
};
|
|
|
|
renderHost.onIpcEvent('trackUsage', trackUsage);
|
|
|
|
setInterval(trackUsage, 60 * 1000);
|
|
};
|
|
|
|
export function computeUsageSummary(
|
|
state: UsageTrackingState,
|
|
currentTime: number,
|
|
) {
|
|
const intervals: UsageInterval[] = [];
|
|
let intervalStart = 0;
|
|
let isFocused = false;
|
|
let selection: SelectionInfo | null = null;
|
|
let selectionKey: string | null;
|
|
|
|
function startInterval(event: TrackingEvent) {
|
|
intervalStart = event.time;
|
|
if (
|
|
event.type === 'TIMELINE_START' ||
|
|
event.type === 'WINDOW_FOCUS_CHANGE'
|
|
) {
|
|
isFocused = event.isFocused;
|
|
}
|
|
if (event.type === 'SELECTION_CHANGED') {
|
|
selectionKey = event.selectionKey;
|
|
selection = event.selection;
|
|
}
|
|
}
|
|
function endInterval(time: number) {
|
|
const length = time - intervalStart;
|
|
intervals.push({
|
|
length,
|
|
focused: isFocused,
|
|
selectionKey,
|
|
selection,
|
|
});
|
|
}
|
|
|
|
for (const event of state.timeline) {
|
|
if (
|
|
event.type === 'TIMELINE_START' ||
|
|
event.type === 'WINDOW_FOCUS_CHANGE' ||
|
|
event.type === 'SELECTION_CHANGED'
|
|
) {
|
|
if (event.type !== 'TIMELINE_START') {
|
|
endInterval(event.time);
|
|
}
|
|
startInterval(event);
|
|
}
|
|
}
|
|
endInterval(currentTime);
|
|
|
|
return intervals.reduce<UsageSummary>(
|
|
(acc: UsageSummary, x: UsageInterval) =>
|
|
produce(acc, (draft) => {
|
|
draft.total.focusedTime += x.focused ? x.length : 0;
|
|
draft.total.unfocusedTime += x.focused ? 0 : x.length;
|
|
const selectionKey = x.selectionKey ?? 'none';
|
|
draft.plugin[selectionKey] = draft.plugin[selectionKey] ?? {
|
|
focusedTime: 0,
|
|
unfocusedTime: 0,
|
|
...x.selection,
|
|
};
|
|
draft.plugin[selectionKey].focusedTime += x.focused ? x.length : 0;
|
|
draft.plugin[selectionKey].unfocusedTime += x.focused ? 0 : x.length;
|
|
}),
|
|
{
|
|
total: {focusedTime: 0, unfocusedTime: 0},
|
|
plugin: {},
|
|
},
|
|
);
|
|
}
|
|
|
|
const flipperExitDataKey = 'FlipperExitData';
|
|
|
|
interface ExitData {
|
|
lastSeen: string;
|
|
deviceOs: string;
|
|
deviceType: string;
|
|
deviceTitle: string;
|
|
plugin: string;
|
|
app: string;
|
|
cleanExit: boolean;
|
|
pid: number;
|
|
}
|
|
|
|
function loadExitData(): ExitData | undefined {
|
|
if (!window.localStorage) {
|
|
return undefined;
|
|
}
|
|
const data = window.localStorage.getItem(flipperExitDataKey);
|
|
if (data) {
|
|
try {
|
|
const res = JSON.parse(data);
|
|
if (res.cleanExit === undefined) {
|
|
res.cleanExit = true; // avoid skewing results for historical data where this info isn't present
|
|
}
|
|
return res;
|
|
} catch (e) {
|
|
console.warn('Failed to parse flipperExitData', e);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function persistExitData(
|
|
state: {
|
|
selectedDevice: BaseDevice | null;
|
|
selectedPlugin: string | null;
|
|
selectedAppId: string | null;
|
|
},
|
|
cleanExit: boolean,
|
|
) {
|
|
if (!window.localStorage) {
|
|
return;
|
|
}
|
|
const exitData: ExitData = {
|
|
lastSeen: '' + Date.now(),
|
|
deviceOs: state.selectedDevice ? state.selectedDevice.os : '',
|
|
deviceType: state.selectedDevice ? state.selectedDevice.deviceType : '',
|
|
deviceTitle: state.selectedDevice ? state.selectedDevice.title : '',
|
|
plugin: state.selectedPlugin || '',
|
|
app: state.selectedAppId
|
|
? deconstructClientId(state.selectedAppId).app
|
|
: '',
|
|
cleanExit,
|
|
pid: getRenderHostInstance().serverConfig.environmentInfo.processId,
|
|
};
|
|
window.localStorage.setItem(
|
|
flipperExitDataKey,
|
|
JSON.stringify(exitData, null, 2),
|
|
);
|
|
}
|