From a96931c43f26b2b161d8ec4cebfbfb417f13bd76 Mon Sep 17 00:00:00 2001 From: John Knox Date: Tue, 14 Jan 2020 10:25:52 -0800 Subject: [PATCH] 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 --- src/dispatcher/__tests__/tracking.node.tsx | 169 +++++++++++++++++++++ src/dispatcher/application.tsx | 8 +- src/dispatcher/tracking.tsx | 100 +++++++++++- src/reducers/application.tsx | 12 +- src/reducers/connections.tsx | 4 +- src/reducers/index.tsx | 7 + src/reducers/usageTracking.tsx | 86 +++++++++++ static/icons.json | 2 +- static/index.js | 2 +- 9 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 src/dispatcher/__tests__/tracking.node.tsx create mode 100644 src/reducers/usageTracking.tsx diff --git a/src/dispatcher/__tests__/tracking.node.tsx b/src/dispatcher/__tests__/tracking.node.tsx new file mode 100644 index 000000000..1c485aa94 --- /dev/null +++ b/src/dispatcher/__tests__/tracking.node.tsx @@ -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 { + 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, + }; + }, +}); diff --git a/src/dispatcher/application.tsx b/src/dispatcher/application.tsx index b07ca2438..df8750d63 100644 --- a/src/dispatcher/application.tsx +++ b/src/dispatcher/application.tsx @@ -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()}, }); }); diff --git a/src/dispatcher/tracking.tsx b/src/dispatcher/tracking.tsx index 1c8dd08fb..dd49c6d6d 100644 --- a/src/dispatcher/tracking.tsx +++ b/src/dispatcher/tracking.tsx @@ -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( + (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}, + }, + ); +} diff --git a/src/reducers/application.tsx b/src/reducers/application.tsx index db0ecb2f4..bc0c44316 100644 --- a/src/reducers/application.tsx +++ b/src/reducers/application.tsx @@ -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, diff --git a/src/reducers/connections.tsx b/src/reducers/connections.tsx index 53f684df3..bb2257ab5 100644 --- a/src/reducers/connections.tsx +++ b/src/reducers/connections.tsx @@ -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: { diff --git a/src/reducers/index.tsx b/src/reducers/index.tsx index d0794717f..d1d383a5b 100644 --- a/src/reducers/index.tsx +++ b/src/reducers/index.tsx @@ -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; @@ -167,4 +173,5 @@ export default combineReducers({ }, healthchecks, ), + usageTracking, }); diff --git a/src/reducers/usageTracking.tsx b/src/reducers/usageTracking.tsx new file mode 100644 index 000000000..2924c8410 --- /dev/null +++ b/src/reducers/usageTracking.tsx @@ -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(), + }, + }; +} diff --git a/static/icons.json b/static/icons.json index 3d094b247..2f941851d 100644 --- a/static/icons.json +++ b/static/icons.json @@ -319,4 +319,4 @@ "card-person": [ 12 ] -} +} \ No newline at end of file diff --git a/static/index.js b/static/index.js index 3c8181c64..11632628a 100644 --- a/static/index.js +++ b/static/index.js @@ -97,7 +97,7 @@ let filePath = argv.file; // tracking setInterval(() => { - if (win && win.isFocused()) { + if (win) { win.webContents.send('trackUsage'); } }, 60 * 1000);