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
This commit is contained in:
Anton Nikolaev
2021-05-19 05:16:35 -07:00
committed by Facebook GitHub Bot
parent b378d8b946
commit 25ae4a0535
6 changed files with 307 additions and 99 deletions

View File

@@ -8,16 +8,13 @@
*/ */
import {computeUsageSummary} from '../tracking'; import {computeUsageSummary} from '../tracking';
import {SelectedPluginData, State} from '../../reducers/usageTracking'; import type {State} from '../../reducers/usageTracking';
import BaseDevice from '../../devices/BaseDevice'; import type {SelectionInfo} from '../../utils/info';
import {getPluginKey} from '../../utils/pluginUtils';
const device = new BaseDevice('serial', 'emulator', 'test device', 'iOS'); const layoutSelection: SelectionInfo = {
const layoutPluginKey = getPluginKey('Facebook', device, 'Layout');
const networkPluginKey = getPluginKey('Facebook', device, 'Network');
const databasesPluginKey = getPluginKey('Facebook', device, 'Databases');
const pluginData: SelectedPluginData = {
plugin: 'Layout', plugin: 'Layout',
pluginName: 'flipper-plugin-layout',
pluginVersion: '0.0.0',
app: 'Facebook', app: 'Facebook',
device: 'test device', device: 'test device',
deviceName: 'test device', deviceName: 'test device',
@@ -26,8 +23,12 @@ const pluginData: SelectedPluginData = {
os: 'iOS', os: 'iOS',
archived: false, archived: false,
}; };
const pluginData2 = {...pluginData, plugin: 'Network'}; const networkSelection = {...layoutSelection, plugin: 'Network'};
const pluginData3 = {...pluginData, plugin: 'Databases'}; const databasesSelection = {...layoutSelection, plugin: 'Databases'};
const layoutPluginKey = JSON.stringify(layoutSelection);
const networkPluginKey = JSON.stringify(networkSelection);
const databasesPluginKey = JSON.stringify(databasesSelection);
test('Never focused', () => { test('Never focused', () => {
const state: State = { const state: State = {
@@ -96,10 +97,10 @@ test('Always focused plugin change', () => {
timeline: [ timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true}, {type: 'TIMELINE_START', time: 100, isFocused: true},
{ {
type: 'PLUGIN_SELECTED', type: 'SELECTION_CHANGED',
time: 150, time: 150,
pluginKey: layoutPluginKey, selectionKey: layoutPluginKey,
pluginData, selection: layoutSelection,
}, },
], ],
}; };
@@ -113,10 +114,10 @@ test('Focused then plugin change then unfocusd', () => {
timeline: [ timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true}, {type: 'TIMELINE_START', time: 100, isFocused: true},
{ {
type: 'PLUGIN_SELECTED', type: 'SELECTION_CHANGED',
time: 150, time: 150,
pluginKey: layoutPluginKey, selectionKey: layoutPluginKey,
pluginData, selection: layoutSelection,
}, },
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false}, {type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
], ],
@@ -131,28 +132,28 @@ test('Multiple plugin changes', () => {
timeline: [ timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true}, {type: 'TIMELINE_START', time: 100, isFocused: true},
{ {
type: 'PLUGIN_SELECTED', type: 'SELECTION_CHANGED',
time: 150, time: 150,
pluginKey: layoutPluginKey, selectionKey: layoutPluginKey,
pluginData, selection: layoutSelection,
}, },
{ {
type: 'PLUGIN_SELECTED', type: 'SELECTION_CHANGED',
time: 350, time: 350,
pluginKey: networkPluginKey, selectionKey: networkPluginKey,
pluginData: pluginData2, selection: networkSelection,
}, },
{ {
type: 'PLUGIN_SELECTED', type: 'SELECTION_CHANGED',
time: 650, time: 650,
pluginKey: layoutPluginKey, selectionKey: layoutPluginKey,
pluginData, selection: layoutSelection,
}, },
{ {
type: 'PLUGIN_SELECTED', type: 'SELECTION_CHANGED',
time: 1050, time: 1050,
pluginKey: databasesPluginKey, selectionKey: databasesPluginKey,
pluginData: pluginData3, selection: databasesSelection,
}, },
], ],
}; };

View File

@@ -22,6 +22,7 @@ import pluginManager from './pluginManager';
import reactNative from './reactNative'; import reactNative from './reactNative';
import pluginMarketplace from './fb-stubs/pluginMarketplace'; import pluginMarketplace from './fb-stubs/pluginMarketplace';
import pluginDownloads from './pluginDownloads'; import pluginDownloads from './pluginDownloads';
import info from '../utils/info';
import {Logger} from '../fb-interfaces/Logger'; import {Logger} from '../fb-interfaces/Logger';
import {Store} from '../reducers/index'; import {Store} from '../reducers/index';
@@ -49,6 +50,7 @@ export default function (store: Store, logger: Logger): () => Promise<void> {
reactNative, reactNative,
pluginMarketplace, pluginMarketplace,
pluginDownloads, pluginDownloads,
info,
].filter(notNull); ].filter(notNull);
const globalCleanup = dispatchers const globalCleanup = dispatchers
.map((dispatcher) => dispatcher(store, logger)) .map((dispatcher) => dispatcher(store, logger))

View File

@@ -22,19 +22,21 @@ import {
clearTimeline, clearTimeline,
TrackingEvent, TrackingEvent,
State as UsageTrackingState, State as UsageTrackingState,
SelectedPluginData, selectionChanged,
} from '../reducers/usageTracking'; } from '../reducers/usageTracking';
import produce from 'immer'; import produce from 'immer';
import BaseDevice from '../devices/BaseDevice'; import BaseDevice from '../devices/BaseDevice';
import {deconstructClientId, deconstructPluginKey} from '../utils/clientUtils'; import {deconstructClientId} from '../utils/clientUtils';
import {getCPUUsage} from 'process'; 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'; const TIME_SPENT_EVENT = 'time-spent';
type UsageInterval = { type UsageInterval = {
pluginKey: string | null; selectionKey: string | null;
pluginData: SelectedPluginData | null; selection: SelectionInfo | null;
length: number; length: number;
focused: boolean; focused: boolean;
}; };
@@ -45,7 +47,7 @@ export type UsageSummary = {
[pluginKey: string]: { [pluginKey: string]: {
focusedTime: number; focusedTime: number;
unfocusedTime: number; unfocusedTime: number;
} & SelectedPluginData; } & SelectionInfo;
}; };
}; };
@@ -74,6 +76,28 @@ export function emitBytesReceived(plugin: string, bytes: number) {
} }
export default (store: Store, logger: Logger) => { 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 droppedFrames: number = 0;
let largeFrameDrops: number = 0; let largeFrameDrops: number = 0;
@@ -154,12 +178,11 @@ export default (store: Store, logger: Logger) => {
logger.track('usage', TIME_SPENT_EVENT, usageSummary.total); logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
for (const key of Object.keys(usageSummary.plugin)) { for (const key of Object.keys(usageSummary.plugin)) {
const keyParts = deconstructPluginKey(key);
logger.track( logger.track(
'usage', 'usage',
TIME_SPENT_EVENT, TIME_SPENT_EVENT,
usageSummary.plugin[key], usageSummary.plugin[key],
keyParts.pluginName, usageSummary.plugin[key]?.plugin ?? 'none',
); );
} }
@@ -242,8 +265,8 @@ export function computeUsageSummary(
const intervals: UsageInterval[] = []; const intervals: UsageInterval[] = [];
let intervalStart = 0; let intervalStart = 0;
let isFocused = false; let isFocused = false;
let pluginData: SelectedPluginData | null = null; let selection: SelectionInfo | null = null;
let pluginKey: string | null; let selectionKey: string | null;
function startInterval(event: TrackingEvent) { function startInterval(event: TrackingEvent) {
intervalStart = event.time; intervalStart = event.time;
@@ -253,21 +276,26 @@ export function computeUsageSummary(
) { ) {
isFocused = event.isFocused; isFocused = event.isFocused;
} }
if (event.type === 'PLUGIN_SELECTED') { if (event.type === 'SELECTION_CHANGED') {
pluginKey = event.pluginKey; selectionKey = event.selectionKey;
pluginData = event.pluginData; selection = event.selection;
} }
} }
function endInterval(time: number) { function endInterval(time: number) {
const length = time - intervalStart; const length = time - intervalStart;
intervals.push({length, focused: isFocused, pluginKey, pluginData}); intervals.push({
length,
focused: isFocused,
selectionKey,
selection,
});
} }
for (const event of state.timeline) { for (const event of state.timeline) {
if ( if (
event.type === 'TIMELINE_START' || event.type === 'TIMELINE_START' ||
event.type === 'WINDOW_FOCUS_CHANGE' || event.type === 'WINDOW_FOCUS_CHANGE' ||
event.type === 'PLUGIN_SELECTED' event.type === 'SELECTION_CHANGED'
) { ) {
if (event.type !== 'TIMELINE_START') { if (event.type !== 'TIMELINE_START') {
endInterval(event.time); endInterval(event.time);
@@ -282,14 +310,14 @@ export function computeUsageSummary(
produce(acc, (draft) => { produce(acc, (draft) => {
draft.total.focusedTime += x.focused ? x.length : 0; draft.total.focusedTime += x.focused ? x.length : 0;
draft.total.unfocusedTime += x.focused ? 0 : x.length; draft.total.unfocusedTime += x.focused ? 0 : x.length;
const pluginKey = x.pluginKey ?? getPluginKey(null, null, 'none'); const selectionKey = x.selectionKey ?? 'none';
draft.plugin[pluginKey] = draft.plugin[pluginKey] ?? { draft.plugin[selectionKey] = draft.plugin[selectionKey] ?? {
focusedTime: 0, focusedTime: 0,
unfocusedTime: 0, unfocusedTime: 0,
...x.pluginData, ...x.selection,
}; };
draft.plugin[pluginKey].focusedTime += x.focused ? x.length : 0; draft.plugin[selectionKey].focusedTime += x.focused ? x.length : 0;
draft.plugin[pluginKey].unfocusedTime += x.focused ? 0 : x.length; draft.plugin[selectionKey].unfocusedTime += x.focused ? 0 : x.length;
}), }),
{ {
total: {focusedTime: 0, unfocusedTime: 0}, total: {focusedTime: 0, unfocusedTime: 0},

View File

@@ -10,19 +10,7 @@
import {produce} from 'immer'; import {produce} from 'immer';
import {remote} from 'electron'; import {remote} from 'electron';
import {Actions} from './'; import {Actions} from './';
import {getPluginKey} from '../utils/pluginUtils'; import {SelectionInfo} from '../utils/info';
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;
};
export type TrackingEvent = export type TrackingEvent =
| { | {
@@ -31,9 +19,9 @@ export type TrackingEvent =
isFocused: boolean; isFocused: boolean;
} }
| { | {
type: 'PLUGIN_SELECTED'; type: 'SELECTION_CHANGED';
pluginKey: string | null; selectionKey: string | null;
pluginData: SelectedPluginData | null; selection: SelectionInfo | null;
time: number; time: number;
} }
| {type: 'TIMELINE_START'; time: number; isFocused: boolean}; | {type: 'TIMELINE_START'; time: number; isFocused: boolean};
@@ -56,7 +44,11 @@ export type Action =
type: 'windowIsFocused'; type: 'windowIsFocused';
payload: {isFocused: boolean; time: number}; 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( export default function reducer(
state: State = INITAL_STATE, state: State = INITAL_STATE,
@@ -81,32 +73,14 @@ export default function reducer(
isFocused: action.payload.isFocused, 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) => { return produce(state, (draft) => {
const selectedApp = action.payload.selectedApp;
const clientIdParts = selectedApp
? deconstructClientId(selectedApp)
: null;
draft.timeline.push({ draft.timeline.push({
type: 'PLUGIN_SELECTED', type: 'SELECTION_CHANGED',
time: action.payload.time, time,
pluginKey: action.payload.selectedPlugin selectionKey: selection?.plugin ? JSON.stringify(selection) : null,
? getPluginKey( selection,
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,
},
}); });
}); });
} }
@@ -122,3 +96,13 @@ export function clearTimeline(time: number): Action {
}, },
}; };
} }
export function selectionChanged(payload: {
selection: SelectionInfo;
time: number;
}): Action {
return {
type: 'SELECTION_CHANGED',
payload,
};
}

View File

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

View File

@@ -10,10 +10,13 @@
import os from 'os'; import os from 'os';
import isProduction, {isTest} from './isProduction'; import isProduction, {isTest} from './isProduction';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path';
import {getStaticPath} from './pathUtils'; 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; arch: string;
platform: string; platform: string;
unixname: 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 * This method builds up some metadata about the users environment that we send
* on bug reports, analytic events, errors etc. * on bug reports, analytic events, errors etc.
*/ */
export function getInfo(): Info { 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 { return {
arch: process.arch, ...platformInfo,
platform: process.platform, selection,
unixname: os.userInfo().username,
versions: {
electron: process.versions.electron,
node: process.versions.node,
platform: os.release(),
},
}; };
} }
@@ -66,3 +126,26 @@ export function stringifyInfo(info: Info): string {
return lines.join('\n'); 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,
};
}