From 25ae4a053514e23e5d1b6f77ff6b89abf67f490d Mon Sep 17 00:00:00 2001 From: Anton Nikolaev Date: Wed, 19 May 2021 05:16:35 -0700 Subject: [PATCH] Include information about selected device, app and plugin into analytics events and error reports Summary: This diff generalises computation of the currently selected plugin, app, device etc. and adds this information to all the analytics events and error reports. Slicing of events by os, device, app or selected plugin can be very useful. This is especially true for errors which often affects only certain types of devices, e.g. android only or physical devices only. Having such information can help to narrow down such issues. Reviewed By: passy Differential Revision: D28511441 fbshipit-source-id: ed9dc57927c70ed8cc6fe093e21604eae54c2f60 --- .../dispatcher/__tests__/tracking.node.tsx | 57 ++++----- desktop/app/src/dispatcher/index.tsx | 2 + desktop/app/src/dispatcher/tracking.tsx | 68 +++++++---- desktop/app/src/reducers/usageTracking.tsx | 66 ++++------- desktop/app/src/utils/__tests__/info.node.tsx | 110 ++++++++++++++++++ desktop/app/src/utils/info.tsx | 103 ++++++++++++++-- 6 files changed, 307 insertions(+), 99 deletions(-) create mode 100644 desktop/app/src/utils/__tests__/info.node.tsx 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, + }; +}