diff --git a/desktop/app/src/dispatcher/__tests__/tracking.node.tsx b/desktop/app/src/dispatcher/__tests__/tracking.node.tsx index d99a2fe67..c1fcb6939 100644 --- a/desktop/app/src/dispatcher/__tests__/tracking.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/tracking.node.tsx @@ -8,16 +8,13 @@ */ import {computeUsageSummary} from '../tracking'; -import {SelectedPluginData, State} from '../../reducers/usageTracking'; -import BaseDevice from '../../devices/BaseDevice'; -import {getPluginKey} from '../../utils/pluginUtils'; +import type {State} from '../../reducers/usageTracking'; +import type {SelectionInfo} from '../../utils/info'; -const device = new BaseDevice('serial', 'emulator', 'test device', 'iOS'); -const layoutPluginKey = getPluginKey('Facebook', device, 'Layout'); -const networkPluginKey = getPluginKey('Facebook', device, 'Network'); -const databasesPluginKey = getPluginKey('Facebook', device, 'Databases'); -const pluginData: SelectedPluginData = { +const layoutSelection: SelectionInfo = { plugin: 'Layout', + pluginName: 'flipper-plugin-layout', + pluginVersion: '0.0.0', app: 'Facebook', device: 'test device', deviceName: 'test device', @@ -26,8 +23,12 @@ const pluginData: SelectedPluginData = { os: 'iOS', archived: false, }; -const pluginData2 = {...pluginData, plugin: 'Network'}; -const pluginData3 = {...pluginData, plugin: 'Databases'}; +const networkSelection = {...layoutSelection, plugin: 'Network'}; +const databasesSelection = {...layoutSelection, plugin: 'Databases'}; + +const layoutPluginKey = JSON.stringify(layoutSelection); +const networkPluginKey = JSON.stringify(networkSelection); +const databasesPluginKey = JSON.stringify(databasesSelection); test('Never focused', () => { const state: State = { @@ -96,10 +97,10 @@ test('Always focused plugin change', () => { timeline: [ {type: 'TIMELINE_START', time: 100, isFocused: true}, { - type: 'PLUGIN_SELECTED', + type: 'SELECTION_CHANGED', time: 150, - pluginKey: layoutPluginKey, - pluginData, + selectionKey: layoutPluginKey, + selection: layoutSelection, }, ], }; @@ -113,10 +114,10 @@ test('Focused then plugin change then unfocusd', () => { timeline: [ {type: 'TIMELINE_START', time: 100, isFocused: true}, { - type: 'PLUGIN_SELECTED', + type: 'SELECTION_CHANGED', time: 150, - pluginKey: layoutPluginKey, - pluginData, + selectionKey: layoutPluginKey, + selection: layoutSelection, }, {type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false}, ], @@ -131,28 +132,28 @@ test('Multiple plugin changes', () => { timeline: [ {type: 'TIMELINE_START', time: 100, isFocused: true}, { - type: 'PLUGIN_SELECTED', + type: 'SELECTION_CHANGED', time: 150, - pluginKey: layoutPluginKey, - pluginData, + selectionKey: layoutPluginKey, + selection: layoutSelection, }, { - type: 'PLUGIN_SELECTED', + type: 'SELECTION_CHANGED', time: 350, - pluginKey: networkPluginKey, - pluginData: pluginData2, + selectionKey: networkPluginKey, + selection: networkSelection, }, { - type: 'PLUGIN_SELECTED', + type: 'SELECTION_CHANGED', time: 650, - pluginKey: layoutPluginKey, - pluginData, + selectionKey: layoutPluginKey, + selection: layoutSelection, }, { - type: 'PLUGIN_SELECTED', + type: 'SELECTION_CHANGED', time: 1050, - pluginKey: databasesPluginKey, - pluginData: pluginData3, + selectionKey: databasesPluginKey, + selection: databasesSelection, }, ], }; diff --git a/desktop/app/src/dispatcher/index.tsx b/desktop/app/src/dispatcher/index.tsx index e0d4f77aa..652b0191d 100644 --- a/desktop/app/src/dispatcher/index.tsx +++ b/desktop/app/src/dispatcher/index.tsx @@ -22,6 +22,7 @@ import pluginManager from './pluginManager'; import reactNative from './reactNative'; import pluginMarketplace from './fb-stubs/pluginMarketplace'; import pluginDownloads from './pluginDownloads'; +import info from '../utils/info'; import {Logger} from '../fb-interfaces/Logger'; import {Store} from '../reducers/index'; @@ -49,6 +50,7 @@ export default function (store: Store, logger: Logger): () => Promise { reactNative, pluginMarketplace, pluginDownloads, + info, ].filter(notNull); const globalCleanup = dispatchers .map((dispatcher) => dispatcher(store, logger)) diff --git a/desktop/app/src/dispatcher/tracking.tsx b/desktop/app/src/dispatcher/tracking.tsx index bc194b8a5..9057fed03 100644 --- a/desktop/app/src/dispatcher/tracking.tsx +++ b/desktop/app/src/dispatcher/tracking.tsx @@ -22,19 +22,21 @@ import { clearTimeline, TrackingEvent, State as UsageTrackingState, - SelectedPluginData, + selectionChanged, } from '../reducers/usageTracking'; import produce from 'immer'; import BaseDevice from '../devices/BaseDevice'; -import {deconstructClientId, deconstructPluginKey} from '../utils/clientUtils'; +import {deconstructClientId} from '../utils/clientUtils'; import {getCPUUsage} from 'process'; -import {getPluginKey} from '../utils/pluginUtils'; +import {sideEffect} from '../utils/sideEffect'; +import {getSelectionInfo} from '../utils/info'; +import type {SelectionInfo} from '../utils/info'; const TIME_SPENT_EVENT = 'time-spent'; type UsageInterval = { - pluginKey: string | null; - pluginData: SelectedPluginData | null; + selectionKey: string | null; + selection: SelectionInfo | null; length: number; focused: boolean; }; @@ -45,7 +47,7 @@ export type UsageSummary = { [pluginKey: string]: { focusedTime: number; unfocusedTime: number; - } & SelectedPluginData; + } & SelectionInfo; }; }; @@ -74,6 +76,28 @@ export function emitBytesReceived(plugin: string, bytes: number) { } export default (store: Store, logger: Logger) => { + sideEffect( + store, + { + name: 'pluginUsageTracking', + throttleMs: 0, + noTimeBudgetWarns: true, + runSynchronously: true, + }, + (state) => ({ + connections: state.connections, + loadedPlugins: state.plugins.loadedPlugins, + }), + (state, store) => { + const selection = getSelectionInfo( + state.connections, + state.loadedPlugins, + ); + const time = Date.now(); + store.dispatch(selectionChanged({selection, time})); + }, + ); + let droppedFrames: number = 0; let largeFrameDrops: number = 0; @@ -154,12 +178,11 @@ export default (store: Store, logger: Logger) => { logger.track('usage', TIME_SPENT_EVENT, usageSummary.total); for (const key of Object.keys(usageSummary.plugin)) { - const keyParts = deconstructPluginKey(key); logger.track( 'usage', TIME_SPENT_EVENT, usageSummary.plugin[key], - keyParts.pluginName, + usageSummary.plugin[key]?.plugin ?? 'none', ); } @@ -242,8 +265,8 @@ export function computeUsageSummary( const intervals: UsageInterval[] = []; let intervalStart = 0; let isFocused = false; - let pluginData: SelectedPluginData | null = null; - let pluginKey: string | null; + let selection: SelectionInfo | null = null; + let selectionKey: string | null; function startInterval(event: TrackingEvent) { intervalStart = event.time; @@ -253,21 +276,26 @@ export function computeUsageSummary( ) { isFocused = event.isFocused; } - if (event.type === 'PLUGIN_SELECTED') { - pluginKey = event.pluginKey; - pluginData = event.pluginData; + if (event.type === 'SELECTION_CHANGED') { + selectionKey = event.selectionKey; + selection = event.selection; } } function endInterval(time: number) { const length = time - intervalStart; - intervals.push({length, focused: isFocused, pluginKey, pluginData}); + 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 === 'PLUGIN_SELECTED' + event.type === 'SELECTION_CHANGED' ) { if (event.type !== 'TIMELINE_START') { endInterval(event.time); @@ -282,14 +310,14 @@ export function computeUsageSummary( produce(acc, (draft) => { draft.total.focusedTime += x.focused ? x.length : 0; draft.total.unfocusedTime += x.focused ? 0 : x.length; - const pluginKey = x.pluginKey ?? getPluginKey(null, null, 'none'); - draft.plugin[pluginKey] = draft.plugin[pluginKey] ?? { + const selectionKey = x.selectionKey ?? 'none'; + draft.plugin[selectionKey] = draft.plugin[selectionKey] ?? { focusedTime: 0, unfocusedTime: 0, - ...x.pluginData, + ...x.selection, }; - draft.plugin[pluginKey].focusedTime += x.focused ? x.length : 0; - draft.plugin[pluginKey].unfocusedTime += x.focused ? 0 : x.length; + draft.plugin[selectionKey].focusedTime += x.focused ? x.length : 0; + draft.plugin[selectionKey].unfocusedTime += x.focused ? 0 : x.length; }), { total: {focusedTime: 0, unfocusedTime: 0}, diff --git a/desktop/app/src/reducers/usageTracking.tsx b/desktop/app/src/reducers/usageTracking.tsx index d2b8a855f..456aceb6c 100644 --- a/desktop/app/src/reducers/usageTracking.tsx +++ b/desktop/app/src/reducers/usageTracking.tsx @@ -10,19 +10,7 @@ import {produce} from 'immer'; import {remote} from 'electron'; import {Actions} from './'; -import {getPluginKey} from '../utils/pluginUtils'; -import {deconstructClientId} from '../utils/clientUtils'; - -export type SelectedPluginData = { - plugin: string | null; - app: string | null; - os: string | null; - device: string | null; - deviceName: string | null; - deviceSerial: string | null; - deviceType: string | null; - archived: boolean | null; -}; +import {SelectionInfo} from '../utils/info'; export type TrackingEvent = | { @@ -31,9 +19,9 @@ export type TrackingEvent = isFocused: boolean; } | { - type: 'PLUGIN_SELECTED'; - pluginKey: string | null; - pluginData: SelectedPluginData | null; + type: 'SELECTION_CHANGED'; + selectionKey: string | null; + selection: SelectionInfo | null; time: number; } | {type: 'TIMELINE_START'; time: number; isFocused: boolean}; @@ -56,7 +44,11 @@ export type Action = type: 'windowIsFocused'; payload: {isFocused: boolean; time: number}; } - | {type: 'CLEAR_TIMELINE'; payload: {time: number; isFocused: boolean}}; + | {type: 'CLEAR_TIMELINE'; payload: {time: number; isFocused: boolean}} + | { + type: 'SELECTION_CHANGED'; + payload: {selection: SelectionInfo; time: number}; + }; export default function reducer( state: State = INITAL_STATE, @@ -81,32 +73,14 @@ export default function reducer( isFocused: action.payload.isFocused, }); }); - } else if (action.type === 'SELECT_PLUGIN') { + } else if (action.type === 'SELECTION_CHANGED') { + const {selection, time} = action.payload; return produce(state, (draft) => { - const selectedApp = action.payload.selectedApp; - const clientIdParts = selectedApp - ? deconstructClientId(selectedApp) - : null; draft.timeline.push({ - type: 'PLUGIN_SELECTED', - time: action.payload.time, - pluginKey: action.payload.selectedPlugin - ? getPluginKey( - action.payload.selectedApp, - action.payload.selectedDevice, - action.payload.selectedPlugin, - ) - : null, - pluginData: { - plugin: action.payload.selectedPlugin || null, - app: clientIdParts?.app || null, - device: action.payload.selectedDevice?.title || null, - deviceName: clientIdParts?.device || null, - deviceSerial: action.payload.selectedDevice?.serial || null, - deviceType: action.payload.selectedDevice?.deviceType || null, - os: action.payload.selectedDevice?.os || null, - archived: action.payload.selectedDevice?.isArchived || false, - }, + type: 'SELECTION_CHANGED', + time, + selectionKey: selection?.plugin ? JSON.stringify(selection) : null, + selection, }); }); } @@ -122,3 +96,13 @@ export function clearTimeline(time: number): Action { }, }; } + +export function selectionChanged(payload: { + selection: SelectionInfo; + time: number; +}): Action { + return { + type: 'SELECTION_CHANGED', + payload, + }; +} diff --git a/desktop/app/src/utils/__tests__/info.node.tsx b/desktop/app/src/utils/__tests__/info.node.tsx new file mode 100644 index 000000000..e27b8fd91 --- /dev/null +++ b/desktop/app/src/utils/__tests__/info.node.tsx @@ -0,0 +1,110 @@ +/** + * 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 + */ + +import {Store} from '../../reducers/index'; +import {createStore} from 'redux'; +import {rootReducer} from '../../store'; +import initialize, {getInfo} from '../info'; +import {registerLoadedPlugins} from '../../reducers/plugins'; +import {TestUtils} from 'flipper-plugin'; +import {getInstance} from '../../fb-stubs/Logger'; +import {selectPlugin} from '../../reducers/connections'; +import {renderMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin'; + +const networkPluginDetails = TestUtils.createMockPluginDetails({ + id: 'Network', + name: 'flipper-plugin-network', + version: '0.78.0', + dir: '/plugins/public/network', + pluginType: 'client', +}); + +const inspectorPluginDetails = TestUtils.createMockPluginDetails({ + id: 'Inspector', + name: 'flipper-plugin-inspector', + version: '0.59.0', + dir: '/plugins/public/flipper-plugin-inspector', + pluginType: 'client', +}); + +describe('info', () => { + let mockStore: Store; + + beforeEach(() => { + mockStore = createStore(rootReducer); + mockStore.dispatch({type: 'INIT'}); + }); + + test('retrieve selection info', async () => { + const networkPlugin = TestUtils.createTestPlugin( + { + plugin() { + return {}; + }, + }, + networkPluginDetails, + ); + const inspectorPlugin = TestUtils.createTestPlugin( + { + plugin() { + return {}; + }, + }, + inspectorPluginDetails, + ); + const {client, device, store} = await renderMockFlipperWithPlugin( + networkPlugin, + { + additionalPlugins: [inspectorPlugin], + }, + ); + initialize(store, getInstance()); + store.dispatch( + registerLoadedPlugins([networkPluginDetails, inspectorPluginDetails]), + ); + const networkPluginSelectionInfo = getInfo(); + store.dispatch( + selectPlugin({ + selectedPlugin: inspectorPlugin.id, + selectedApp: client.query.app, + selectedDevice: device, + deepLinkPayload: null, + }), + ); + const inspectorPluginSelectionInfo = getInfo(); + expect(networkPluginSelectionInfo.selection).toMatchInlineSnapshot(` + Object { + "app": "TestApp", + "archived": false, + "device": "MockAndroidDevice", + "deviceName": "MockAndroidDevice", + "deviceSerial": "serial", + "deviceType": "physical", + "os": "Android", + "plugin": "Network", + "pluginName": "flipper-plugin-network", + "pluginVersion": "0.78.0", + } + `); + expect(inspectorPluginSelectionInfo.selection).toMatchInlineSnapshot(` + Object { + "app": "TestApp", + "archived": false, + "device": "MockAndroidDevice", + "deviceName": "MockAndroidDevice", + "deviceSerial": "serial", + "deviceType": "physical", + "os": "Android", + "plugin": "Inspector", + "pluginName": "flipper-plugin-inspector", + "pluginVersion": "0.59.0", + } + `); + }); +}); diff --git a/desktop/app/src/utils/info.tsx b/desktop/app/src/utils/info.tsx index 7420d45aa..0e85cabb2 100644 --- a/desktop/app/src/utils/info.tsx +++ b/desktop/app/src/utils/info.tsx @@ -10,10 +10,13 @@ import os from 'os'; import isProduction, {isTest} from './isProduction'; import fs from 'fs-extra'; -import path from 'path'; import {getStaticPath} from './pathUtils'; +import type {State, Store} from '../reducers/index'; +import {deconstructClientId} from './clientUtils'; +import {sideEffect} from './sideEffect'; +import {Logger} from '../fb-interfaces/Logger'; -export type Info = { +type PlatformInfo = { arch: string; platform: string; unixname: string; @@ -22,20 +25,77 @@ export type Info = { }; }; +export type SelectionInfo = { + plugin: string | null; + pluginName: string | null; + pluginVersion: string | null; + app: string | null; + os: string | null; + device: string | null; + deviceName: string | null; + deviceSerial: string | null; + deviceType: string | null; + archived: boolean | null; +}; + +export type Info = PlatformInfo & { + selection: SelectionInfo; +}; + +let platformInfo: PlatformInfo | undefined; +let selection: SelectionInfo = { + plugin: null, + pluginName: null, + pluginVersion: null, + app: null, + os: null, + device: null, + deviceName: null, + deviceSerial: null, + deviceType: null, + archived: null, +}; + +export default (store: Store, _logger: Logger) => { + return sideEffect( + store, + { + name: 'recomputeSelectionInfo', + throttleMs: 0, + noTimeBudgetWarns: true, + runSynchronously: true, + fireImmediately: true, + }, + (state) => ({ + connections: state.connections, + loadedPlugins: state.plugins.loadedPlugins, + }), + (state, _store) => { + selection = getSelectionInfo(state.connections, state.loadedPlugins); + }, + ); +}; + /** * This method builds up some metadata about the users environment that we send * on bug reports, analytic events, errors etc. */ export function getInfo(): Info { + if (!platformInfo) { + platformInfo = { + arch: process.arch, + platform: process.platform, + unixname: os.userInfo().username, + versions: { + electron: process.versions.electron, + node: process.versions.node, + platform: os.release(), + }, + }; + } return { - arch: process.arch, - platform: process.platform, - unixname: os.userInfo().username, - versions: { - electron: process.versions.electron, - node: process.versions.node, - platform: os.release(), - }, + ...platformInfo, + selection, }; } @@ -66,3 +126,26 @@ export function stringifyInfo(info: Info): string { return lines.join('\n'); } + +export function getSelectionInfo( + connections: State['connections'], + loadedPlugins: State['plugins']['loadedPlugins'], +): SelectionInfo { + const selectedApp = connections.selectedApp; + const clientIdParts = selectedApp ? deconstructClientId(selectedApp) : null; + const loadedPlugin = connections.selectedPlugin + ? loadedPlugins.get(connections.selectedPlugin) + : null; + return { + plugin: connections.selectedPlugin || null, + pluginName: loadedPlugin?.name || null, + pluginVersion: loadedPlugin?.version || null, + app: clientIdParts?.app || null, + device: connections.selectedDevice?.title || null, + deviceName: clientIdParts?.device || null, + deviceSerial: connections.selectedDevice?.serial || null, + deviceType: connections.selectedDevice?.deviceType || null, + os: connections.selectedDevice?.os || null, + archived: connections.selectedDevice?.isArchived || false, + }; +}