Files
flipper/desktop/app/src/dispatcher/tracking.tsx
Michel Weststrate c9a34d3cc2 Fix inconsistent handling of app id and name
Summary:
Changelog: Improved plugin / device / app selection handing.

During some refactorings I discovered that the `connetions.selectedApp` field contained sometimes an application id, and sometimes just the name. This caused inconsistent behavior especially in unit tests.

I've cleaned that up, and renamed it to `selectedAppId` where applicable, to make the distinction more clear.

And, in contrast, userPreferredApp now always has a name, not an id.

During refactoring our existing selection update logic was quite in the way, which was overcomplicated still, since during the sandy chrome migration, the reducers needed to be able to handle both the old UI, and the new application selection UI. That logic has been simplified now, and a lot of tests were added.

As a further simplification the preferredApp/Device/Plugin are now only read and used when updating selection, but not when running selectors.

Reviewed By: timur-valiev

Differential Revision: D31305180

fbshipit-source-id: 2dbd9f9c33950227cc63aa29cc4a98bdd0db8e7a
2021-10-04 07:27:24 -07:00

384 lines
10 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
// Used for PID tracking.
// eslint-disable-next-line flipper/no-electron-remote-imports
import {ipcRenderer, remote} from 'electron';
import {performance} from 'perf_hooks';
import {EventEmitter} from 'events';
import {State, Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import Client from '../Client';
import {
getPluginBackgroundStats,
resetPluginBackgroundStatsDelta,
} from '../utils/pluginStats';
import {
clearTimeline,
TrackingEvent,
State as UsageTrackingState,
selectionChanged,
} from '../reducers/usageTracking';
import produce from 'immer';
import BaseDevice from '../devices/BaseDevice';
import {deconstructClientId} from '../utils/clientUtils';
import {getCPUUsage} from 'process';
import {sideEffect} from '../utils/sideEffect';
import {getSelectionInfo} from '../utils/info';
import type {SelectionInfo} from '../utils/info';
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) => {
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 = remote.process.pid === oldExitData.pid;
const timeSinceLastStartup =
Date.now() - parseInt(oldExitData.lastSeen, 10);
// console.log(isReload ? 'reload' : 'restart', oldExitData);
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,
);
}
ipcRenderer.on('trackUsage', (event, ...args: any[]) => {
let state: State;
try {
state = store.getState();
} catch (e) {
// if trackUsage is called (indirectly) through a reducer, this will utterly die Flipper. Let's prevent that and log an error instead
console.error(
'trackUsage triggered indirectly as side effect of a reducer. Event: ',
event.type,
event,
);
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]) => {
// TODO: remove "starred-plugns" event in favor of "enabled-plugins" after some transition period
logger.track('usage', 'starred-plugins', {
app,
starredPlugins: 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.find((c: Client) => c.id === 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: getCPUUsage().percentCPUUsage,
};
// reset dropped frames counter
droppedFrames = 0;
largeFrameDrops = 0;
logger.track('usage', 'ping', info);
});
};
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: remote.process.pid,
};
window.localStorage.setItem(
flipperExitDataKey,
JSON.stringify(exitData, null, 2),
);
}