Files
flipper/desktop/flipper-ui-core/src/dispatcher/tracking.tsx
Lorenzo Blasa dac8c5b213 Usage track now internal to tracking
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
2023-06-27 07:17:06 -07:00

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),
);
}