Track plugin selection steps

Summary: Sets up some logging for the various drop-out points when going through the deeplink flow.

Reviewed By: lblasa

Differential Revision: D31345623

fbshipit-source-id: a06ca97c1e687e39ea97a1f47fd8bb614149056f
This commit is contained in:
Pascal Hartig
2021-10-05 11:37:28 -07:00
committed by Facebook GitHub Bot
parent bce2cdc316
commit 37529af074
6 changed files with 238 additions and 58 deletions

View File

@@ -99,11 +99,16 @@ test('Will throw error on invalid deeplinks', async () => {
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unknown deeplink"`); ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unknown deeplink"`);
expect(logger.track).toHaveBeenCalledTimes(2); expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenLastCalledWith('usage', 'deeplink', { expect(logger.track).toHaveBeenLastCalledWith(
'usage',
'deeplink',
{
query: 'flipper://test', query: 'flipper://test',
state: 'ERROR', state: 'ERROR',
errorMessage: 'Unknown deeplink', errorMessage: 'Unknown deeplink',
}); },
undefined,
);
}); });
test('Will throw error on invalid protocol', async () => { test('Will throw error on invalid protocol', async () => {
@@ -116,11 +121,16 @@ test('Will throw error on invalid protocol', async () => {
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unknown deeplink"`); ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unknown deeplink"`);
expect(logger.track).toHaveBeenCalledTimes(2); expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenLastCalledWith('usage', 'deeplink', { expect(logger.track).toHaveBeenLastCalledWith(
'usage',
'deeplink',
{
query: 'notflipper://test', query: 'notflipper://test',
state: 'ERROR', state: 'ERROR',
errorMessage: 'Unknown deeplink', errorMessage: 'Unknown deeplink',
}); },
undefined,
);
}); });
test('Will track deeplinks', async () => { test('Will track deeplinks', async () => {
@@ -142,9 +152,14 @@ test('Will track deeplinks', async () => {
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe', 'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
); );
expect(logger.track).toHaveBeenCalledWith('usage', 'deeplink', { expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query: query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe', 'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'INIT', state: 'INIT',
}); },
undefined,
);
}); });

View File

@@ -20,22 +20,7 @@ import {selectPlugin, getAllClients} from './reducers/connections';
import {Dialog} from 'flipper-plugin'; import {Dialog} from 'flipper-plugin';
import {handleOpenPluginDeeplink} from './dispatcher/handleOpenPluginDeeplink'; import {handleOpenPluginDeeplink} from './dispatcher/handleOpenPluginDeeplink';
import {message} from 'antd'; import {message} from 'antd';
import {track} from './deeplinkTracking';
type DeeplinkInteraction = {
state: 'INIT' | 'ERROR';
errorMessage?: string;
};
function track(
logger: Logger,
query: string,
interaction: DeeplinkInteraction,
) {
logger.track('usage', 'deeplink', {
...interaction,
query,
});
}
const UNKNOWN = 'Unknown deeplink'; const UNKNOWN = 'Unknown deeplink';
/** /**
@@ -63,7 +48,7 @@ export async function handleDeeplink(
throw unknownError(); throw unknownError();
} }
if (uri.href.startsWith('flipper://open-plugin')) { if (uri.href.startsWith('flipper://open-plugin')) {
return handleOpenPluginDeeplink(store, query); return handleOpenPluginDeeplink(store, query, trackInteraction);
} }
if (uri.pathname.match(/^\/*import\/*$/)) { if (uri.pathname.match(/^\/*import\/*$/)) {
const url = uri.searchParams.get('url'); const url = uri.searchParams.get('url');

View File

@@ -0,0 +1,48 @@
/**
* 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 {Logger} from './fb-interfaces/Logger';
export type OpenPluginParams = {
pluginId: string;
client: string | undefined;
devices: string[];
payload: string | undefined;
};
export type DeeplinkInteraction = {
state:
| 'INIT'
| 'ERROR'
| 'PLUGIN_LIGHTHOUSE_BAIL'
| 'PLUGIN_STATUS_BAIL'
| 'PLUGIN_DEVICE_BAIL'
| 'PLUGIN_DEVICE_UNSUPPORTED'
| 'PLUGIN_CLIENT_UNSUPPORTED'
| 'PLUGIN_OPEN_SUCCESS';
errorMessage?: string;
plugin?: OpenPluginParams;
extra?: object;
};
export function track(
logger: Logger,
query: string,
interaction: DeeplinkInteraction,
) {
logger.track(
'usage',
'deeplink',
{
...interaction,
query,
},
interaction.plugin?.pluginId,
);
}

View File

@@ -19,11 +19,25 @@ import {
createState, createState,
useValue, useValue,
DevicePluginClient, DevicePluginClient,
Dialog,
} from 'flipper-plugin'; } from 'flipper-plugin';
import {parseOpenPluginParams} from '../handleOpenPluginDeeplink'; import {parseOpenPluginParams} from '../handleOpenPluginDeeplink';
import {handleDeeplink} from '../../deeplink'; import {handleDeeplink} from '../../deeplink';
import {selectPlugin} from '../../reducers/connections'; import {selectPlugin} from '../../reducers/connections';
let origAlertImpl: any;
let origConfirmImpl: any;
beforeEach(() => {
origAlertImpl = Dialog.alert;
origConfirmImpl = Dialog.confirm;
});
afterEach(() => {
Dialog.alert = origAlertImpl;
Dialog.confirm = origConfirmImpl;
});
test('open-plugin deeplink parsing', () => { test('open-plugin deeplink parsing', () => {
const testpayload = 'http://www.google/?test=c o%20o+l'; const testpayload = 'http://www.google/?test=c o%20o+l';
const testLink = const testLink =
@@ -83,6 +97,7 @@ test('Triggering a deeplink will work', async () => {
const {renderer, client, store, logger} = await renderMockFlipperWithPlugin( const {renderer, client, store, logger} = await renderMockFlipperWithPlugin(
definition, definition,
); );
logger.track = jest.fn();
expect(linksSeen).toEqual([]); expect(linksSeen).toEqual([]);
@@ -120,6 +135,33 @@ test('Triggering a deeplink will work', async () => {
</div> </div>
</body> </body>
`); `);
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'INIT',
},
undefined,
);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'PLUGIN_OPEN_SUCCESS',
plugin: {
client: 'TestApp',
devices: [],
payload: 'universe',
pluginId: 'TestPlugin',
},
},
'TestPlugin',
);
}); });
test('triggering a deeplink without applicable device can wait for a device', async () => { test('triggering a deeplink without applicable device can wait for a device', async () => {
@@ -283,3 +325,63 @@ test('triggering a deeplink without applicable client can wait for a device', as
</body> </body>
`); `);
}); });
test('triggering a deeplink with incompatible device will cause bail', async () => {
const definition = TestUtils.createTestDevicePlugin(
{
Component() {
return <p>Hello</p>;
},
devicePlugin() {
return {};
},
},
{
id: 'DevicePlugin',
supportedDevices: [{os: 'iOS'}],
},
);
const {store, logger, createDevice} = await renderMockFlipperWithPlugin(
definition,
);
logger.track = jest.fn();
// Skipping user interactions.
Dialog.alert = (async () => {}) as any;
Dialog.confirm = (async () => {}) as any;
store.dispatch(
selectPlugin({selectedPlugin: 'nonexisting', deepLinkPayload: null}),
);
const handlePromise = handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&devices=iOS`,
);
jest.runAllTimers();
// create a new device that doesn't match spec
createDevice({serial: 'device2', os: 'Android'});
// wait for dialogues
await handlePromise;
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
plugin: {
client: undefined,
devices: ['iOS'],
payload: undefined,
pluginId: 'DevicePlugin',
},
query: 'flipper://open-plugin?plugin-id=DevicePlugin&devices=iOS',
state: 'PLUGIN_DEVICE_BAIL',
},
'DevicePlugin',
);
});

View File

@@ -18,7 +18,7 @@ import {UserNotSignedInError} from '../utils/errors';
import {selectPlugin, setPluginEnabled} from '../reducers/connections'; import {selectPlugin, setPluginEnabled} from '../reducers/connections';
import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator'; import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator';
import {Typography} from 'antd'; import {Typography} from 'antd';
import {getPluginStatus} from '../utils/pluginUtils'; import {getPluginStatus, PluginStatus} from '../utils/pluginUtils';
import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace'; import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace';
import {loadPlugin, switchPlugin} from '../reducers/pluginManager'; import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
import {startPluginDownload} from '../reducers/pluginDownloads'; import {startPluginDownload} from '../reducers/pluginDownloads';
@@ -29,13 +29,7 @@ import Client from '../Client';
import {RocketOutlined} from '@ant-design/icons'; import {RocketOutlined} from '@ant-design/icons';
import {showEmulatorLauncher} from '../sandy-chrome/appinspect/LaunchEmulator'; import {showEmulatorLauncher} from '../sandy-chrome/appinspect/LaunchEmulator';
import {getAllClients} from '../reducers/connections'; import {getAllClients} from '../reducers/connections';
import {DeeplinkInteraction, OpenPluginParams} from '../deeplinkTracking';
type OpenPluginParams = {
pluginId: string;
client: string | undefined;
devices: string[];
payload: string | undefined;
};
export function parseOpenPluginParams(query: string): OpenPluginParams { export function parseOpenPluginParams(query: string): OpenPluginParams {
// 'flipper://open-plugin?plugin-id=graphql&client=facebook&devices=android,ios&chrome=1&payload=' // 'flipper://open-plugin?plugin-id=graphql&client=facebook&devices=android,ios&chrome=1&payload='
@@ -54,15 +48,33 @@ export function parseOpenPluginParams(query: string): OpenPluginParams {
}; };
} }
export async function handleOpenPluginDeeplink(store: Store, query: string) { export async function handleOpenPluginDeeplink(
store: Store,
query: string,
trackInteraction: (interaction: DeeplinkInteraction) => void,
) {
const params = parseOpenPluginParams(query); const params = parseOpenPluginParams(query);
const title = `Opening plugin ${params.pluginId}`; const title = `Opening plugin ${params.pluginId}`;
if (!(await verifyLighthouseAndUserLoggedIn(store, title))) { if (!(await verifyLighthouseAndUserLoggedIn(store, title))) {
trackInteraction({
state: 'PLUGIN_LIGHTHOUSE_BAIL',
plugin: params,
});
return; return;
} }
await verifyFlipperIsUpToDate(title); await verifyFlipperIsUpToDate(title);
if (!(await verifyPluginStatus(store, params.pluginId, title))) { const [pluginStatusResult, pluginStatus] = await verifyPluginStatus(
store,
params.pluginId,
title,
);
if (!pluginStatusResult) {
trackInteraction({
state: 'PLUGIN_STATUS_BAIL',
plugin: params,
extra: {pluginStatus},
});
return; return;
} }
@@ -79,6 +91,10 @@ export async function handleOpenPluginDeeplink(store: Store, query: string) {
isDevicePlugin, isDevicePlugin,
); );
if (deviceOrClient === false) { if (deviceOrClient === false) {
trackInteraction({
state: 'PLUGIN_DEVICE_BAIL',
plugin: params,
});
return; return;
} }
const client: Client | undefined = isDevicePlugin const client: Client | undefined = isDevicePlugin
@@ -95,6 +111,11 @@ export async function handleOpenPluginDeeplink(store: Store, query: string) {
type: 'error', type: 'error',
message: `This plugin is not supported by device ${device.displayTitle()}`, message: `This plugin is not supported by device ${device.displayTitle()}`,
}); });
trackInteraction({
state: 'PLUGIN_DEVICE_UNSUPPORTED',
plugin: params,
extra: {device: device.displayTitle()},
});
return; return;
} }
if (!isDevicePlugin && !client!.plugins.has(params.pluginId)) { if (!isDevicePlugin && !client!.plugins.has(params.pluginId)) {
@@ -103,6 +124,12 @@ export async function handleOpenPluginDeeplink(store: Store, query: string) {
type: 'error', type: 'error',
message: `This plugin is not supported by client ${client!.query.app}`, message: `This plugin is not supported by client ${client!.query.app}`,
}); });
trackInteraction({
state: 'PLUGIN_CLIENT_UNSUPPORTED',
plugin: params,
extra: {client: client!.query.app},
});
return;
} }
// verify plugin enabled // verify plugin enabled
@@ -137,6 +164,10 @@ export async function handleOpenPluginDeeplink(store: Store, query: string) {
}), }),
); );
} }
trackInteraction({
state: 'PLUGIN_OPEN_SUCCESS',
plugin: params,
});
} }
// check if user is connected to VPN and logged in. Returns true if OK, or false if aborted // check if user is connected to VPN and logged in. Returns true if OK, or false if aborted
@@ -269,7 +300,7 @@ async function verifyPluginStatus(
store: Store, store: Store,
pluginId: string, pluginId: string,
title: string, title: string,
): Promise<boolean> { ): Promise<[boolean, PluginStatus]> {
// make sure we have marketplace plugin data present // make sure we have marketplace plugin data present
if (!isTest() && !store.getState().plugins.marketplacePlugins.length) { if (!isTest() && !store.getState().plugins.marketplacePlugins.length) {
// plugins not yet fetched // plugins not yet fetched
@@ -281,21 +312,21 @@ async function verifyPluginStatus(
const [status, reason] = getPluginStatus(store, pluginId); const [status, reason] = getPluginStatus(store, pluginId);
switch (status) { switch (status) {
case 'ready': case 'ready':
return true; return [true, status];
case 'unknown': case 'unknown':
await Dialog.alert({ await Dialog.alert({
type: 'warning', type: 'warning',
title, title,
message: `No plugin with id '${pluginId}' is known to Flipper. Please correct the deeplink, or install the plugin from NPM using the plugin manager.`, message: `No plugin with id '${pluginId}' is known to Flipper. Please correct the deeplink, or install the plugin from NPM using the plugin manager.`,
}); });
return false; return [false, status];
case 'failed': case 'failed':
await Dialog.alert({ await Dialog.alert({
type: 'error', type: 'error',
title, title,
message: `We found plugin '${pluginId}', but failed to load it: ${reason}. Please check the logs for more details`, message: `We found plugin '${pluginId}', but failed to load it: ${reason}. Please check the logs for more details`,
}); });
return false; return [false, status];
case 'gatekeeped': case 'gatekeeped':
if ( if (
!(await Dialog.confirm({ !(await Dialog.confirm({
@@ -318,7 +349,7 @@ async function verifyPluginStatus(
}, },
})) }))
) { ) {
return false; return [false, status];
} }
break; break;
case 'bundle_installable': { case 'bundle_installable': {
@@ -328,7 +359,7 @@ async function verifyPluginStatus(
} }
case 'marketplace_installable': { case 'marketplace_installable': {
if (!(await installMarketPlacePlugin(store, pluginId, title))) { if (!(await installMarketPlacePlugin(store, pluginId, title))) {
return false; return [false, status];
} }
break; break;
} }

View File

@@ -402,19 +402,18 @@ export function computeActivePluginList({
return pluginList; return pluginList;
} }
export function getPluginStatus( export type PluginStatus =
store: Store,
id: string,
): [
state:
| 'ready' | 'ready'
| 'unknown' | 'unknown'
| 'failed' | 'failed'
| 'gatekeeped' | 'gatekeeped'
| 'bundle_installable' | 'bundle_installable'
| 'marketplace_installable', | 'marketplace_installable';
reason?: string,
] { export function getPluginStatus(
store: Store,
id: string,
): [state: PluginStatus, reason?: string] {
const state: PluginsState = store.getState().plugins; const state: PluginsState = store.getState().plugins;
if (state.devicePlugins.has(id) || state.clientPlugins.has(id)) { if (state.devicePlugins.has(id) || state.clientPlugins.has(id)) {
return ['ready']; return ['ready'];