Enhance time-spent tracking

Summary:
Previously, at 1-minute intervals, if the flipper window was focused on, it would report the currently active plugin.
We'd sum all those "ping" events and that would approximate the number of full minutes spent in total across all users.
It's quite coarse grained, if you're focused on the window for 30 seconds, there's a 50% change your ping will get used.
While being reasonable across many users, it doesn't allow analysis like how many plugins do people typically use in a session, because we probably won't see all the plugins they use.

New approach, for every minute flipper is open, report the focused and unfocused time spent in each plugin, as well as the total across all plugins.
This should give us the previous data but with much more precision.

Should be especially helpful for plugins with low numbers of users, you typically interact with emulators while using a plugin, so it's not continually in focus, so you miss a lot of usage events.

enhance_bladerunner

Reviewed By: nikoant

Differential Revision: D19392796

fbshipit-source-id: af9244e993edff9b381144ca587c3a77fdf8c98a
This commit is contained in:
John Knox
2020-01-14 10:25:52 -08:00
committed by Facebook Github Bot
parent 84302f109b
commit a96931c43f
9 changed files with 380 additions and 10 deletions

View File

@@ -0,0 +1,169 @@
/**
* 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 {computeUsageSummary, UsageSummary} from '../tracking';
import {State} from '../../reducers/usageTracking';
test('Never focused', () => {
const state: State = {
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: false}],
};
const result = computeUsageSummary(state, 200);
expect(result).toReportTimeSpent('total', 0, 100);
});
test('Always focused', () => {
const state: State = {
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: true}],
};
const result = computeUsageSummary(state, 200);
expect(result).toReportTimeSpent('total', 100, 0);
});
test('Focused then unfocused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
],
};
const result = computeUsageSummary(state, 350);
expect(result).toReportTimeSpent('total', 50, 200);
});
test('Unfocused then focused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
],
};
const result = computeUsageSummary(state, 350);
expect(result).toReportTimeSpent('total', 200, 50);
});
test('Unfocused then focused then unfocused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
],
};
const result = computeUsageSummary(state, 650);
expect(result).toReportTimeSpent('total', 200, 350);
});
test('Focused then unfocused then focused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: true},
],
};
const result = computeUsageSummary(state, 650);
expect(result).toReportTimeSpent('total', 350, 200);
});
test('Always focused plugin change', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'},
],
};
const result = computeUsageSummary(state, 200);
expect(result).toReportTimeSpent('total', 100, 0);
expect(result).toReportTimeSpent('Layout', 50, 0);
});
test('Focused then plugin change then unfocusd', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
],
};
const result = computeUsageSummary(state, 650);
expect(result).toReportTimeSpent('total', 250, 300);
expect(result).toReportTimeSpent('Layout', 200, 300);
});
test('Multiple plugin changes', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'PLUGIN_SELECTED', time: 150, plugin: 'Layout'},
{type: 'PLUGIN_SELECTED', time: 350, plugin: 'Network'},
{type: 'PLUGIN_SELECTED', time: 650, plugin: 'Layout'},
{type: 'PLUGIN_SELECTED', time: 1050, plugin: 'Databases'},
],
};
const result = computeUsageSummary(state, 1550);
expect(result).toReportTimeSpent('total', 1450, 0);
expect(result).toReportTimeSpent('Layout', 600, 0);
expect(result).toReportTimeSpent('Network', 300, 0);
expect(result).toReportTimeSpent('Databases', 500, 0);
});
declare global {
namespace jest {
interface Matchers<R> {
toReportTimeSpent(
plugin: string,
focusedTimeSpent: number,
unfocusedTimeSpent: number,
): R;
}
}
}
expect.extend({
toReportTimeSpent(
received: UsageSummary,
plugin: string,
focusedTimeSpent: number,
unfocusedTimeSpent: number,
) {
const focusedPass = received[plugin].focusedTime === focusedTimeSpent;
const unfocusedPass = received[plugin].unfocusedTime === unfocusedTimeSpent;
if (!focusedPass) {
return {
message: () =>
`expected ${JSON.stringify(
received,
)} to have focused time spent: ${focusedTimeSpent} for plugin ${plugin}, but was ${
received[plugin]?.focusedTime
}`,
pass: false,
};
}
if (!unfocusedPass) {
return {
message: () =>
`expected ${JSON.stringify(
received,
)} to have unfocused time spent: ${unfocusedTimeSpent} for plugin ${plugin}, but was ${
received[plugin]?.unfocusedTime
}`,
pass: false,
};
}
return {
message: () =>
`expected ${JSON.stringify(
received,
)} not to have focused time spent: ${focusedTimeSpent} and unfocused: ${unfocusedTimeSpent}`,
pass: true,
};
},
});

View File

@@ -46,23 +46,23 @@ export default (store: Store, logger: Logger) => {
currentWindow.on('focus', () => {
store.dispatch({
type: 'windowIsFocused',
payload: true,
payload: {isFocused: true, time: Date.now()},
});
});
currentWindow.on('blur', () => {
store.dispatch({
type: 'windowIsFocused',
payload: false,
payload: {isFocused: false, time: Date.now()},
});
});
// windowIsFocussed is initialized in the store before the app is fully ready.
// So wait until everything is up and running and then check and set the isFocussed state.
window.addEventListener('flipper-store-ready', () => {
const isFocussed = currentWindow.isFocused();
const isFocused = currentWindow.isFocused();
store.dispatch({
type: 'windowIsFocused',
payload: isFocussed,
payload: {isFocused: isFocused, time: Date.now()},
});
});

View File

@@ -14,6 +14,25 @@ import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import Client from '../Client';
import {getPluginBackgroundStats} from '../utils/messageQueue';
import {
clearTimeline,
TrackingEvent,
State as UsageTrackingState,
} from '../reducers/usageTracking';
import produce from 'immer';
const TIME_SPENT_EVENT = 'time-spent';
type UsageInterval = {
plugin: string | null;
length: number;
focused: boolean;
};
export type UsageSummary = {
total: {focusedTime: number; unfocusedTime: number};
[pluginName: string]: {focusedTime: number; unfocusedTime: number};
};
export default (store: Store, logger: Logger) => {
let droppedFrames: number = 0;
@@ -49,7 +68,27 @@ export default (store: Store, logger: Logger) => {
clients,
} = store.getState().connections;
if (!selectedDevice || !selectedPlugin) {
const currentTime = Date.now();
const usageSummary = computeUsageSummary(
store.getState().usageTracking,
currentTime,
);
store.dispatch(clearTimeline(currentTime));
logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
for (const key of Object.keys(usageSummary)) {
if (key === 'total') {
logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
}
logger.track('usage', TIME_SPENT_EVENT, usageSummary[key], key);
}
if (
!store.getState().application.windowIsFocused ||
!selectedDevice ||
!selectedPlugin
) {
return;
}
@@ -82,3 +121,62 @@ export default (store: Store, logger: Logger) => {
logger.track('usage', 'ping', info);
});
};
export function computeUsageSummary(
state: UsageTrackingState,
currentTime: number,
) {
const intervals: UsageInterval[] = [];
let intervalStart = 0;
let isFocused = false;
let selectedPlugin: string | null = null;
function startInterval(event: TrackingEvent) {
intervalStart = event.time;
if (
event.type === 'TIMELINE_START' ||
event.type === 'WINDOW_FOCUS_CHANGE'
) {
isFocused = event.isFocused;
}
if (event.type === 'PLUGIN_SELECTED') {
selectedPlugin = event.plugin;
}
}
function endInterval(time: number) {
const length = time - intervalStart;
intervals.push({length, plugin: selectedPlugin, focused: isFocused});
}
for (const event of state.timeline) {
if (
event.type === 'TIMELINE_START' ||
event.type === 'WINDOW_FOCUS_CHANGE' ||
event.type === 'PLUGIN_SELECTED'
) {
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 pluginName = x.plugin ?? 'none';
draft[pluginName] = draft[pluginName] ?? {
focusedTime: 0,
unfocusedTime: 0,
};
draft[pluginName].focusedTime += x.focused ? x.length : 0;
draft[pluginName].unfocusedTime += x.focused ? 0 : x.length;
}),
{
total: {focusedTime: 0, unfocusedTime: 0},
},
);
}

View File

@@ -90,7 +90,6 @@ type BooleanActionType =
| 'leftSidebarVisible'
| 'rightSidebarVisible'
| 'rightSidebarAvailable'
| 'windowIsFocused'
| 'downloadingImportData';
export type Action =
@@ -98,6 +97,10 @@ export type Action =
type: BooleanActionType;
payload?: boolean;
}
| {
type: 'windowIsFocused';
payload: {isFocused: boolean; time: number};
}
| {
type: 'SET_ACTIVE_SHEET';
payload: ActiveSheet;
@@ -169,6 +172,7 @@ export const initialState: () => State = () => ({
},
statusMessages: [],
xcodeCommandLineToolsDetected: false,
trackingTimeline: [],
});
function statusMessage(sender: string, msg: string): string {
@@ -191,7 +195,6 @@ export default function reducer(
action.type === 'leftSidebarVisible' ||
action.type === 'rightSidebarVisible' ||
action.type === 'rightSidebarAvailable' ||
action.type === 'windowIsFocused' ||
action.type === 'downloadingImportData'
) {
const newValue =
@@ -208,6 +211,11 @@ export default function reducer(
[action.type]: newValue,
};
}
} else if (action.type === 'windowIsFocused') {
return {
...state,
windowIsFocused: action.payload.isFocused,
};
} else if (action.type === 'SET_ACTIVE_SHEET') {
return {
...state,

View File

@@ -88,6 +88,7 @@ export type Action =
selectedApp?: null | string;
deepLinkPayload: null | string;
selectedDevice?: null | BaseDevice;
time: number;
};
}
| {
@@ -461,9 +462,10 @@ export const selectPlugin = (payload: {
selectedApp?: null | string;
selectedDevice?: BaseDevice | null;
deepLinkPayload: null | string;
time?: number;
}): Action => ({
type: 'SELECT_PLUGIN',
payload,
payload: {...payload, time: payload.time ?? Date.now()},
});
export const starPlugin = (payload: {

View File

@@ -52,6 +52,10 @@ import healthchecks, {
Action as HealthcheckAction,
State as HealthcheckState,
} from './healthchecks';
import usageTracking, {
Action as TrackingAction,
State as TrackingState,
} from './usageTracking';
import user, {State as UserState, Action as UserAction} from './user';
import JsonFileStorage from '../utils/jsonFileReduxPersistStorage';
import LauncherSettingsStorage from '../utils/launcherSettingsStorage';
@@ -78,6 +82,7 @@ export type Actions =
| SupportFormAction
| PluginManagerAction
| HealthcheckAction
| TrackingAction
| {type: 'INIT'};
export type State = {
@@ -93,6 +98,7 @@ export type State = {
supportForm: SupportFormState;
pluginManager: PluginManagerState;
healthchecks: HealthcheckState & PersistPartial;
usageTracking: TrackingState;
};
export type Store = ReduxStore<State, Actions>;
@@ -167,4 +173,5 @@ export default combineReducers<State, Actions>({
},
healthchecks,
),
usageTracking,
});

View File

@@ -0,0 +1,86 @@
/**
* 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 {produce} from 'immer';
import {remote} from 'electron';
import {Actions} from './';
export type TrackingEvent =
| {
type: 'WINDOW_FOCUS_CHANGE';
time: number;
isFocused: boolean;
}
| {type: 'PLUGIN_SELECTED'; time: number; plugin: string | null}
| {type: 'TIMELINE_START'; time: number; isFocused: boolean};
export type State = {
timeline: TrackingEvent[];
};
const INITAL_STATE: State = {
timeline: [
{
type: 'TIMELINE_START',
time: Date.now(),
isFocused: remote.getCurrentWindow().isFocused(),
},
],
};
export type Action =
| {
type: 'windowIsFocused';
payload: {isFocused: boolean; time: number};
}
| {type: 'CLEAR_TIMELINE'; payload: {time: number; isFocused: boolean}};
export default function reducer(
state: State = INITAL_STATE,
action: Actions,
): State {
if (action.type === 'CLEAR_TIMELINE') {
return {
...state,
timeline: [
{
type: 'TIMELINE_START',
time: action.payload.time,
isFocused: action.payload.isFocused,
},
],
};
} else if (action.type === 'windowIsFocused') {
return produce(state, draft => {
draft.timeline.push({
type: 'WINDOW_FOCUS_CHANGE',
time: action.payload.time,
isFocused: action.payload.isFocused,
});
});
} else if (action.type === 'SELECT_PLUGIN') {
return produce(state, draft => {
draft.timeline.push({
type: 'PLUGIN_SELECTED',
time: action.payload.time,
plugin: action.payload.selectedPlugin || null,
});
});
}
return state;
}
export function clearTimeline(time: number): Action {
return {
type: 'CLEAR_TIMELINE',
payload: {
time,
isFocused: remote.getCurrentWindow().isFocused(),
},
};
}

View File

@@ -319,4 +319,4 @@
"card-person": [
12
]
}
}

View File

@@ -97,7 +97,7 @@ let filePath = argv.file;
// tracking
setInterval(() => {
if (win && win.isFocused()) {
if (win) {
win.webContents.send('trackUsage');
}
}, 60 * 1000);