Convert crash reporter plugin to Sandy (non UI only)

Summary:
This diff converts the CrashReporter plugin to Sandy. The main driver is that it allows us to fix the connection management of logs in a next diff.

There are few changes to highlight:
* A bunch of the old unit tests are removed, as they primarily verified that persistedState abstraction works, a concept that doesn't exist anymore with Sandy (as a result all the logic in error handling and crash reporter plugin has become a lot more trivial as well)
* Added additional unit tests to verify that the integration with notifications from Sandy, and the integration of crashes in combination with CrashReporter plugin works (this wasn't the case before)
* Plugin errors were always suppressed in production builds of Flipper. However, that makes error reporting pretty pointless in the first place, so enabled it by default, but made it a setting in case this results in too many errors suddenly.
* The integration with clicking OS crash notification -> bringing the user to a sensible place _doesn't_ work, but it didn't work before this diff either, so will address that later
* This doesn't upgrade the Crash reporter UI to sandy yet, will do that later in a separate diff

Changelog: Crash reporter will now report errors triggered from the device / client plugins by default. This can be disabled in settings.

Reviewed By: priteshrnandgaonkar

Differential Revision: D27044507

fbshipit-source-id: 8233798f5cce668d61460c948c24bdf92ed7c834
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 7093a932f8
commit 87c5fab607
11 changed files with 553 additions and 595 deletions

View File

@@ -65,26 +65,17 @@ type Params = {
type RequestMetadata = {method: string; id: number; params: Params | undefined};
const handleError = (store: Store, device: BaseDevice, error: ErrorType) => {
if (isProduction()) {
if (store.getState().settingsState.suppressPluginErrors) {
return;
}
const crashReporterPlugin: typeof FlipperDevicePlugin = store
.getState()
.plugins.devicePlugins.get('CrashReporter') as any;
const crashReporterPlugin = device.sandyPluginStates.get('CrashReporter');
if (!crashReporterPlugin) {
return;
}
if (!crashReporterPlugin.persistedStateReducer) {
console.error('CrashReporterPlugin persistedStateReducer broken'); // Make sure we update this code if we ever convert it to Sandy
if (!crashReporterPlugin.instanceApi.reportCrash) {
console.error('CrashReporterPlugin persistedStateReducer broken');
return;
}
const pluginKey = getPluginKey(null, device, 'CrashReporter');
const persistedState = {
...crashReporterPlugin.defaultPersistedState,
...store.getState().pluginStates[pluginKey],
};
const isCrashReport: boolean = Boolean(error.name || error.message);
const payload = isCrashReport
? {
@@ -96,23 +87,7 @@ const handleError = (store: Store, device: BaseDevice, error: ErrorType) => {
name: 'Plugin Error',
reason: JSON.stringify(error),
};
const newPluginState =
crashReporterPlugin.persistedStateReducer == null
? persistedState
: crashReporterPlugin.persistedStateReducer(
persistedState,
'flipper-crash-report',
payload,
);
if (persistedState !== newPluginState) {
store.dispatch(
setPluginState({
pluginKey,
state: newPluginState,
}),
);
}
crashReporterPlugin.instanceApi.reportCrash(payload);
};
export interface FlipperClientConnection<D, M> {

View File

@@ -112,6 +112,7 @@ class SettingsSheet extends Component<Props, State> {
idbPath,
reactNative,
darkMode,
suppressPluginErrors,
} = this.state.updatedSettings;
const settingsPristine =
@@ -232,6 +233,18 @@ class SettingsSheet extends Component<Props, State> {
});
}}
/>
<ToggledSection
label="Suppress error notifications send from client plugins"
toggled={suppressPluginErrors}
onChange={(enabled) => {
this.setState((prevState) => ({
updatedSettings: {
...prevState.updatedSettings,
suppressPluginErrors: enabled,
},
}));
}}
/>
<ToggledSection
label="Enable dark theme (experimental)"
toggled={darkMode}

View File

@@ -7,6 +7,14 @@
* @format
*/
import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {
_SandyPluginDefinition,
TestUtils,
PluginClient,
Notification,
DevicePluginClient,
} from 'flipper-plugin';
import {State, addNotification, removeNotification} from '../notifications';
import {
@@ -17,8 +25,6 @@ import {
updateCategoryBlocklist,
} from '../notifications';
import {Notification} from 'flipper-plugin';
const notification: Notification = {
id: 'id',
title: 'title',
@@ -219,3 +225,173 @@ test('reduce removeNotification', () => {
}
`);
});
test('notifications from plugins arrive in the notifications reducer', async () => {
const TestPlugin = TestUtils.createTestPlugin({
plugin(client: PluginClient) {
client.onUnhandledMessage(() => {
client.showNotification({
id: 'test',
message: 'test message',
severity: 'error',
title: 'hi',
action: 'dosomething',
});
});
return {};
},
});
const {store, client, sendMessage} = await createMockFlipperWithPlugin(
TestPlugin,
);
sendMessage('testMessage', {});
client.flushMessageBuffer();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": "TestApp#Android#MockAndroidDevice#serial",
"notification": Object {
"action": "dosomething",
"id": "test",
"message": "test message",
"severity": "error",
"title": "hi",
},
"pluginId": "TestPlugin",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('notifications from a device plugin arrive in the notifications reducer', async () => {
let trigger: any;
const TestPlugin = TestUtils.createTestDevicePlugin({
devicePlugin(client: DevicePluginClient) {
trigger = () => {
client.showNotification({
id: 'test',
message: 'test message',
severity: 'error',
title: 'hi',
action: 'dosomething',
});
};
return {};
},
});
const {store} = await createMockFlipperWithPlugin(TestPlugin);
trigger();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": "serial",
"notification": Object {
"action": "dosomething",
"id": "test",
"message": "test message",
"severity": "error",
"title": "hi",
},
"pluginId": "TestPlugin",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('errors end up as notifications if crash reporter is active', async () => {
const TestPlugin = TestUtils.createTestPlugin({
plugin() {
return {};
},
});
// eslint-disable-next-line
const CrashReporterImpl = require('../../../../plugins/crash_reporter/index');
const CrashPlugin = TestUtils.createTestDevicePlugin(CrashReporterImpl, {
id: 'CrashReporter',
});
const {store, client, sendError} = await createMockFlipperWithPlugin(
TestPlugin,
{
additionalPlugins: [CrashPlugin],
},
);
sendError('gone wrong');
client.flushMessageBuffer();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [
Object {
"client": "serial",
"notification": Object {
"action": "0",
"category": "\\"gone wrong\\"",
"id": "0",
"message": "Callstack: No callstack available",
"severity": "error",
"title": "CRASH: Plugin ErrorReason: \\"gone wrong\\"",
},
"pluginId": "CrashReporter",
},
],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});
test('errors end NOT up as notifications if crash reporter is active but suppressPluginErrors is disabled', async () => {
const TestPlugin = TestUtils.createTestPlugin({
plugin() {
return {};
},
});
// eslint-disable-next-line
const CrashReporterImpl = require('../../../../plugins/crash_reporter/index');
const CrashPlugin = TestUtils.createTestDevicePlugin(CrashReporterImpl, {
id: 'CrashReporter',
});
const {store, client, sendError} = await createMockFlipperWithPlugin(
TestPlugin,
{
additionalPlugins: [CrashPlugin],
},
);
store.dispatch({
type: 'UPDATE_SETTINGS',
payload: {
...store.getState().settingsState,
suppressPluginErrors: true,
},
});
sendError('gone wrong');
client.flushMessageBuffer();
expect(store.getState().notifications).toMatchInlineSnapshot(`
Object {
"activeNotifications": Array [],
"blocklistedCategories": Array [],
"blocklistedPlugins": Array [],
"clearedNotifications": Set {},
"invalidatedNotifications": Array [],
}
`);
});

View File

@@ -45,6 +45,7 @@ export type Settings = {
};
darkMode: boolean;
showWelcomeAtStartup: boolean;
suppressPluginErrors: boolean;
};
export type Action =
@@ -79,6 +80,7 @@ const initialState: Settings = {
},
darkMode: false,
showWelcomeAtStartup: true,
suppressPluginErrors: false,
};
export default function reducer(

View File

@@ -38,6 +38,7 @@ export type MockFlipperResult = {
device: BaseDevice;
store: Store;
pluginKey: string;
sendError(error: any, client?: Client): void;
sendMessage(method: string, params: any, client?: Client): void;
createDevice(serial: string): BaseDevice;
createClient(
@@ -146,6 +147,13 @@ export async function createMockFlipperWithPlugin(
client,
device: device as any,
store,
sendError(error: any, actualClient = client) {
actualClient.onMessage(
JSON.stringify({
error,
}),
);
},
sendMessage(method, params, actualClient = client) {
actualClient.onMessage(
JSON.stringify({

View File

@@ -39,6 +39,7 @@ export interface Device {
readonly isArchived: boolean;
readonly isConnected: boolean;
readonly os: string;
readonly serial: string;
readonly deviceType: DeviceType;
onLogEntry(cb: DeviceLogListener): () => void;
}

View File

@@ -175,6 +175,7 @@ export abstract class BasePluginInstance {
realDevice, // TODO: temporarily, clean up T70688226
// N.B. we model OS as string, not as enum, to make custom device types possible in the future
os: realDevice.os,
serial: realDevice.serial,
get isArchived() {
return realDevice.isArchived;
},

View File

@@ -20,6 +20,7 @@ import {
RealFlipperClient,
SandyPluginInstance,
PluginClient,
PluginFactory,
} from '../plugin/Plugin';
import {
SandyPluginDefinition,
@@ -418,6 +419,47 @@ export function createMockPluginDetails(
};
}
export function createTestPlugin<T extends PluginFactory<any, any>>(
implementation: Pick<FlipperPluginModule<T>, 'plugin'> &
Partial<FlipperPluginModule<T>>,
details?: Partial<InstalledPluginDetails>,
) {
return new SandyPluginDefinition(
createMockPluginDetails({
pluginType: 'client',
...details,
}),
{
Component() {
return null;
},
...implementation,
},
);
}
export function createTestDevicePlugin(
implementation: Pick<FlipperDevicePluginModule, 'devicePlugin'> &
Partial<FlipperDevicePluginModule>,
details?: Partial<InstalledPluginDetails>,
) {
return new SandyPluginDefinition(
createMockPluginDetails({
pluginType: 'device',
...details,
}),
{
supportsDevice() {
return true;
},
Component() {
return null;
},
...implementation,
},
);
}
export function createMockBundledPluginDetails(
details?: Partial<BundledPluginDetails>,
): BundledPluginDetails {

View File

@@ -8,27 +8,11 @@
*/
import {BaseDevice} from 'flipper';
import CrashReporterPlugin from '../index';
import type {PersistedState, Crash} from '../index';
import {
parseCrashLog,
getNewPersistedStateFromCrashLog,
parsePath,
shouldShowCrashNotification,
} from '../index';
import {getPluginKey, getPersistedState} from 'flipper';
function setDefaultPersistedState(defaultState: PersistedState) {
CrashReporterPlugin.defaultPersistedState = defaultState;
}
function setNotificationID(notificationID: number) {
CrashReporterPlugin.notificationID = notificationID;
}
function setCrashReporterPluginID(id: string) {
CrashReporterPlugin.id = id;
}
import {Crash, shouldShowiOSCrashNotification} from '../index';
import {parseCrashLog, parsePath} from '../index';
import {TestUtils} from 'flipper-plugin';
import {getPluginKey} from 'flipper';
import * as CrashReporterPlugin from '../index';
function getCrash(
id: number,
@@ -54,19 +38,6 @@ function assertCrash(crash: Crash, expectedCrash: Crash) {
expect(date.toDateString()).toEqual(expectedCrash.date.toDateString());
}
beforeEach(() => {
setNotificationID(0); // Resets notificationID to 0
setDefaultPersistedState({crashes: []}); // Resets defaultpersistedstate
setCrashReporterPluginID('CrashReporter');
});
afterAll(() => {
// Reset values
setNotificationID(0);
setDefaultPersistedState({crashes: []});
setCrashReporterPluginID('');
});
test('test the parsing of the date and crash info for the log which matches the predefined regex', () => {
const log =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa Date/Time: 2019-03-21 12:07:00.861 +0000 \n Blaa balaaa';
@@ -166,125 +137,35 @@ test('test the getter of pluginKey with defined selected app and defined base de
expect(pluginKey).toEqual('selectedApp#CrashReporter');
});
test('test defaultPersistedState of CrashReporterPlugin', () => {
expect(CrashReporterPlugin.defaultPersistedState).toEqual({crashes: []});
expect(
TestUtils.startDevicePlugin(CrashReporterPlugin).exportState(),
).toEqual({crashes: []});
});
test('test helper setdefaultPersistedState function', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
setDefaultPersistedState({crashes: [crash]});
expect(CrashReporterPlugin.defaultPersistedState).toEqual({crashes: [crash]});
});
test('test getPersistedState for non-empty defaultPersistedState and undefined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
setDefaultPersistedState({crashes: [crash]});
const pluginStates = {};
const persistedState = getPersistedState(
getPluginKey(null, null, CrashReporterPlugin.id),
CrashReporterPlugin,
pluginStates,
);
expect(persistedState).toEqual({crashes: [crash]});
});
test('test getPersistedState for non-empty defaultPersistedState and defined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const persistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
expect(persistedState).toEqual({crashes: [pluginStateCrash]});
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
plugin.instance.reportCrash(crash);
expect(plugin.exportState()).toEqual({crashes: [crash]});
});
test('test getNewPersistedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
plugin.instance.reportCrash(crash);
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const persistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const content =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa';
expect(persistedState).toBeDefined();
const definedState = persistedState as PersistedState;
const {crashes} = definedState;
expect(crashes).toBeDefined();
expect(crashes.length).toEqual(1);
expect(crashes[0]).toEqual(pluginStateCrash);
const newPersistedState = getNewPersistedStateFromCrashLog(
definedState,
CrashReporterPlugin,
content,
'iOS',
null,
);
expect(newPersistedState).toBeDefined();
const newDefinedState = newPersistedState as PersistedState;
const newPersistedStateCrashes = newDefinedState.crashes;
expect(newPersistedStateCrashes).toBeDefined();
expect(newPersistedStateCrashes.length).toEqual(2);
assertCrash(newPersistedStateCrashes[0], pluginStateCrash);
assertCrash(
newPersistedStateCrashes[1],
getCrash(1, content, 'SIGSEGV', 'SIGSEGV'),
);
});
test('test getNewPersistedStateFromCrashLog for non-empty defaultPersistedState and undefined pluginState', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStates = {};
const persistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const content = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV';
expect(persistedState).toEqual({crashes: [crash]});
const newPersistedState = getNewPersistedStateFromCrashLog(
persistedState as PersistedState,
CrashReporterPlugin,
content,
'iOS',
null,
);
expect(newPersistedState).toBeDefined();
const {crashes} = newPersistedState as PersistedState;
plugin.instance.reportCrash(pluginStateCrash);
const crashes = plugin.instance.crashes.get();
expect(crashes).toBeDefined();
expect(crashes.length).toEqual(2);
assertCrash(crashes[0], crash);
assertCrash(crashes[1], getCrash(1, content, 'SIGSEGV', 'SIGSEGV'));
expect(crashes[1]).toEqual(pluginStateCrash);
});
test('test getNewPersistedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState and improper crash log', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
const pluginStateCrash = getCrash(0, 'callstack', 'crash1', 'crash1');
plugin.instance.reportCrash(pluginStateCrash);
const content = 'Blaa Blaaa \n Blaa Blaaa';
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
const newPersistedState = getNewPersistedStateFromCrashLog(
perisistedState as PersistedState,
CrashReporterPlugin,
content,
'iOS',
null,
);
expect(newPersistedState).toBeDefined();
const {crashes} = newPersistedState as PersistedState;
expect(crashes).toBeDefined();
plugin.instance.reportCrash(parseCrashLog(content, 'iOS', null));
const crashes = plugin.instance.crashes.get();
expect(crashes.length).toEqual(2);
assertCrash(crashes[0], pluginStateCrash);
assertCrash(
@@ -297,28 +178,17 @@ test('test getNewPersistedStateFromCrashLog for non-empty defaultPersistedState
),
);
});
test('test getNewPersistedStateFromCrashLog when os is undefined', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const persistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const plugin = TestUtils.startDevicePlugin(CrashReporterPlugin);
const content = 'Blaa Blaaa \n Blaa Blaaa';
const newPersistedState = getNewPersistedStateFromCrashLog(
persistedState as PersistedState,
CrashReporterPlugin,
content,
undefined,
null,
);
expect(newPersistedState).toEqual(null);
expect(() => {
plugin.instance.reportCrash(parseCrashLog(content, undefined as any, null));
}).toThrowErrorMatchingInlineSnapshot(`"Unsupported OS"`);
const crashes = plugin.instance.crashes.get();
expect(crashes.length).toEqual(0);
});
test('test parsing of path when inputs are correct', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/AppName.app/AppName \n Blaa Blaa \n Blaa Blaa';
@@ -350,6 +220,7 @@ test('test parsing of path when a regex is not present', () => {
const id = parsePath(content);
expect(id).toEqual(null);
});
test('test shouldShowCrashNotification function for all correct inputs', () => {
const device = new BaseDevice(
'TH1S-15DEV1CE-1D',
@@ -359,14 +230,13 @@ test('test shouldShowCrashNotification function for all correct inputs', () => {
);
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowCrashNotification(
device,
const shouldShowNotification = shouldShowiOSCrashNotification(
device.serial,
content,
'iOS',
);
expect(shouldShowNotification).toEqual(true);
});
test('test shouldShowCrashNotification function for all correct inputs but incorrect id', () => {
test('test shouldShowiOSCrashNotification function for all correct inputs but incorrect id', () => {
const device = new BaseDevice(
'TH1S-15DEV1CE-1D',
'emulator',
@@ -375,20 +245,39 @@ test('test shouldShowCrashNotification function for all correct inputs but incor
);
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowCrashNotification(
device,
const shouldShowNotification = shouldShowiOSCrashNotification(
device.serial,
content,
'iOS',
);
expect(shouldShowNotification).toEqual(false);
});
test('test shouldShowCrashNotification function for undefined device', () => {
test('test shouldShowiOSCrashNotification function for undefined device', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowCrashNotification(
null,
const shouldShowNotification = shouldShowiOSCrashNotification(
null as any,
content,
'iOS',
);
expect(shouldShowNotification).toEqual(false);
});
test('only crashes from the correct device are picked up', () => {
const serial = 'AC9482A2-26A4-404F-A179-A9FB60B077F6';
const crash = `Process: Sample [87361]
Path: /Users/USER/Library/Developer/CoreSimulator/Devices/AC9482A2-26A4-404F-A179-A9FB60B077F6/data/Containers/Bundle/Application/9BF91EF9-F915-4745-BE91-EBA397451850/Sample.app/Sample
Identifier: Sample
Version: 1.0 (1)
Code Type: X86-64 (Native)
Parent Process: launchd_sim [70150]
Responsible: SimulatorTrampoline [1246]
User ID: 501`;
expect(shouldShowiOSCrashNotification(serial, crash)).toBe(true);
// wrong serial
expect(
shouldShowiOSCrashNotification(
'XC9482A2-26A4-404F-A179-A9FB60B077F6',
crash,
),
).toBe(false);
});

View File

@@ -8,9 +8,6 @@
*/
import {
FlipperBasePlugin,
FlipperDevicePlugin,
Device,
View,
styled,
FlexColumn,
@@ -18,9 +15,6 @@ import {
ContextMenu,
clipboard,
Button,
getPluginKey,
getPersistedState,
BaseDevice,
shouldParseAndroidLog,
Text,
colors,
@@ -31,13 +25,17 @@ import {
import unicodeSubstring from 'unicode-substring';
import fs from 'fs';
import os from 'os';
import util from 'util';
import path from 'path';
import {promisify} from 'util';
import type {Notification} from 'flipper';
import type {Store, DeviceLogEntry, OS, Props} from 'flipper';
import type {DeviceLogEntry} from 'flipper';
import React from 'react';
import {Component} from 'react';
import {
createState,
DevicePluginClient,
usePlugin,
useValue,
} from 'flipper-plugin';
import type {FSWatcher} from 'fs';
type Maybe<T> = T | null | undefined;
@@ -74,14 +72,6 @@ export type CrashLog = {
date: Maybe<Date>;
};
export type PersistedState = {
crashes: Array<Crash>;
};
type State = {
crash?: Crash;
};
const Padder = styled.div<{
paddingLeft?: number;
paddingRight?: number;
@@ -188,102 +178,9 @@ const StackTraceContainer = styled(FlexColumn)({
const UNKNOWN_CRASH_REASON = 'Cannot figure out the cause';
export function getNewPersistedStateFromCrashLog(
persistedState: Maybe<PersistedState>,
persistingPlugin: typeof FlipperBasePlugin,
content: string,
os: Maybe<OS>,
logDate: Maybe<Date>,
): Maybe<PersistedState> {
const persistedStateReducer = persistingPlugin.persistedStateReducer;
if (!os || !persistedStateReducer) {
return null;
}
const crash = parseCrashLog(content, os, logDate);
const newPluginState = persistedStateReducer(
persistedState,
'crash-report',
crash,
);
return newPluginState;
}
export function parseCrashLogAndUpdateState(
store: Store,
content: string,
setPersistedState: (
pluginKey: string,
newPluginState: Maybe<PersistedState>,
) => void,
logDate: Maybe<Date>,
) {
const os = store.getState().connections.selectedDevice?.os;
if (
!shouldShowCrashNotification(
store.getState().connections.selectedDevice,
content,
os,
)
) {
return;
}
const pluginID = CrashReporterPlugin.id;
const pluginKey = getPluginKey(
null,
store.getState().connections.selectedDevice,
pluginID,
);
const persistingPlugin:
| typeof FlipperBasePlugin
| undefined = store
.getState()
.plugins.devicePlugins.get(CrashReporterPlugin.id) as any;
if (!persistingPlugin) {
return;
}
if (!persistingPlugin.persistedStateReducer) {
console.error('CrashReporterPlugin is incompatible');
return;
}
const pluginStates = store.getState().pluginStates;
const persistedState = getPersistedState(
pluginKey,
persistingPlugin,
pluginStates,
);
if (!persistedState) {
return;
}
const newPluginState = getNewPersistedStateFromCrashLog(
persistedState as PersistedState,
persistingPlugin,
content,
os,
logDate,
);
setPersistedState(pluginKey, newPluginState);
}
export function shouldShowCrashNotification(
baseDevice: Maybe<BaseDevice>,
content: string,
os: Maybe<OS>,
): boolean {
if (os && os === 'Android') {
return true;
}
const appPath = parsePath(content);
const serial: string = baseDevice?.serial || 'unknown';
if (!appPath || !appPath.includes(serial)) {
// Do not show notifications for the app which are not the selected one
return false;
}
return true;
}
export function parseCrashLog(
content: string,
os: OS,
os: string,
logDate: Maybe<Date>,
): CrashLog {
const fallbackReason = UNKNOWN_CRASH_REASON;
@@ -369,18 +266,16 @@ export function parsePath(content: string): Maybe<string> {
}
function addFileWatcherForiOSCrashLogs(
store: Store,
setPersistedState: (
pluginKey: string,
newPluginState: Maybe<PersistedState>,
) => void,
deviceOs: string,
serial: string,
reportCrash: (payload: CrashLog | Crash) => void,
) {
const dir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
if (!fs.existsSync(dir)) {
// Directory doesn't exist
return;
}
fs.watch(dir, (_eventType, filename) => {
return fs.watch(dir, (_eventType, filename) => {
// We just parse the crash logs with extension `.crash`
const checkFileExtension = /.crash$/.exec(filename);
if (!filename || !checkFileExtension) {
@@ -392,26 +287,18 @@ function addFileWatcherForiOSCrashLogs(
return;
}
fs.readFile(filepath, 'utf8', function (err, data) {
if (store.getState().connections.selectedDevice?.os != 'iOS') {
// If the selected device is not iOS don't show crash notifications
return;
}
if (err) {
console.error(err);
return;
}
parseCrashLogAndUpdateState(
store,
util.format(data),
setPersistedState,
null,
);
if (shouldShowiOSCrashNotification(serial, data))
reportCrash(parseCrashLog(data, deviceOs, null));
});
});
});
}
class CrashSelector extends Component<CrashSelectorProps> {
class CrashSelector extends React.Component<CrashSelectorProps> {
render() {
const {crashes, selectedCrashID, orderedIDs, onCrashChange} = this.props;
return (
@@ -471,7 +358,7 @@ class CrashSelector extends Component<CrashSelectorProps> {
}
}
class CrashReporterBar extends Component<CrashReporterBarProps> {
class CrashReporterBar extends React.Component<CrashReporterBarProps> {
render() {
const {openLogsCallback, crashSelector} = this.props;
return (
@@ -488,7 +375,7 @@ class CrashReporterBar extends Component<CrashReporterBarProps> {
}
}
class HeaderRow extends Component<HeaderRowProps> {
class HeaderRow extends React.Component<HeaderRowProps> {
render() {
const {title, value} = this.props;
return (
@@ -519,7 +406,7 @@ type StackTraceComponentProps = {
stacktrace: string;
};
class StackTraceComponent extends Component<StackTraceComponentProps> {
class StackTraceComponent extends React.Component<StackTraceComponentProps> {
render() {
const {stacktrace} = this.props;
return (
@@ -533,280 +420,232 @@ class StackTraceComponent extends Component<StackTraceComponentProps> {
}
}
export default class CrashReporterPlugin extends FlipperDevicePlugin<
State,
any,
PersistedState
> {
static defaultPersistedState: PersistedState = {
crashes: [],
};
export function devicePlugin(client: DevicePluginClient) {
let notificationID = -1;
let watcher: FSWatcher | undefined;
static supportsDevice(device: Device) {
return (
(device.os === 'iOS' && device.deviceType !== 'physical') ||
device.os === 'Android'
);
const crashes = createState<Crash[]>([], {persist: 'crashes'});
const selectedCrash = createState<string | undefined>();
client.onDeepLink((crashId) => {
selectedCrash.set(crashId as string);
});
function reportCrash(payload: CrashLog | Crash) {
notificationID++;
const crash = {
notificationID: notificationID.toString(),
callstack: payload.callstack,
name: payload.name,
reason: payload.reason,
date: payload.date || new Date(),
};
crashes.update((draft) => {
draft.push(crash);
});
// show notification?
const ignore = !crash.name && !crash.reason;
const unknownCrashCause = crash.reason === UNKNOWN_CRASH_REASON;
if (ignore || unknownCrashCause) {
console.error('Ignored the notification for the crash', crash);
return;
}
let title: string = 'CRASH: ' + truncate(crash.name || crash.reason, 50);
title = `${
crash.name == crash.reason
? title
: title + 'Reason: ' + truncate(crash.reason, 50)
}`;
const callstack = crash.callstack
? trimCallStackIfPossible(crash.callstack)
: 'No callstack available';
const msg = `Callstack: ${truncate(callstack, 200)}`;
client.showNotification({
id: crash.notificationID,
message: msg,
severity: 'error',
title: title,
action: crash.notificationID,
category: crash.reason || 'Unknown reason',
});
}
static notificationID: number = 0;
/*
* Reducer to process incoming "send" messages from the mobile counterpart.
*/
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
payload: CrashLog | Crash,
): PersistedState => {
if (method === 'crash-report' || method === 'flipper-crash-report') {
CrashReporterPlugin.notificationID++;
const mergedState: PersistedState = {
crashes: persistedState.crashes.concat([
{
notificationID: CrashReporterPlugin.notificationID.toString(), // All notifications are unique
callstack: payload.callstack,
name: payload.name,
reason: payload.reason,
date: payload.date || new Date(),
},
]),
};
return mergedState;
}
return persistedState;
};
static trimCallStackIfPossible = (callstack: string): string => {
const regex = /Application Specific Information:/;
const query = regex.exec(callstack);
return query ? callstack.substring(0, query.index) : callstack;
};
/*
* Callback to provide the currently active notifications.
*/
static getActiveNotifications = (
persistedState: PersistedState,
): Array<Notification> => {
const filteredCrashes = persistedState.crashes.filter((crash) => {
const ignore = !crash.name && !crash.reason;
const unknownCrashCause = crash.reason === UNKNOWN_CRASH_REASON;
if (ignore || unknownCrashCause) {
console.error('Ignored the notification for the crash', crash);
}
return !ignore && !unknownCrashCause;
});
return filteredCrashes.map((crash: Crash) => {
const id = crash.notificationID;
const name: string = crash.name || crash.reason;
let title: string = 'CRASH: ' + truncate(name, 50);
title = `${
name == crash.reason
? title
: title + 'Reason: ' + truncate(crash.reason, 50)
}`;
const callstack = crash.callstack
? CrashReporterPlugin.trimCallStackIfPossible(crash.callstack)
: 'No callstack available';
const msg = `Callstack: ${truncate(callstack, 200)}`;
return {
id,
message: msg,
severity: 'error',
title: title,
action: id,
category: crash.reason || 'Unknown reason',
};
});
};
/*
* This function gets called whenever the device is registered
*/
static onRegisterDevice = (
store: Store,
baseDevice: BaseDevice,
setPersistedState: (
pluginKey: string,
newPluginState: Maybe<PersistedState>,
) => void,
): void => {
if (baseDevice.os.includes('iOS')) {
addFileWatcherForiOSCrashLogs(store, setPersistedState);
// Startup logic to establish log monitoring
if (client.device.isConnected) {
if (client.device.os.includes('iOS')) {
watcher = addFileWatcherForiOSCrashLogs(
client.device.os,
client.device.serial,
reportCrash,
);
} else {
const referenceDate = new Date();
(function (
store: Store,
_date: Date,
setPersistedState: (
pluginKey: string,
newPluginState: Maybe<PersistedState>,
) => void,
) {
let androidLog: string = '';
let androidLogUnderProcess = false;
let timer: Maybe<NodeJS.Timeout> = null;
baseDevice.addLogListener((entry: DeviceLogEntry) => {
if (shouldParseAndroidLog(entry, referenceDate)) {
if (androidLogUnderProcess) {
androidLog += '\n' + entry.message;
androidLog = androidLog.trim();
if (timer) {
clearTimeout(timer);
}
} else {
androidLog = entry.message;
androidLogUnderProcess = true;
let androidLog: string = '';
let androidLogUnderProcess = false;
let timer: Maybe<NodeJS.Timeout> = null;
client.device.onLogEntry((entry: DeviceLogEntry) => {
if (shouldParseAndroidLog(entry, referenceDate)) {
if (androidLogUnderProcess) {
androidLog += '\n' + entry.message;
androidLog = androidLog.trim();
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
if (androidLog.length > 0) {
parseCrashLogAndUpdateState(
store,
androidLog,
setPersistedState,
entry.date,
);
}
androidLogUnderProcess = false;
androidLog = '';
}, 50);
} else {
androidLog = entry.message;
androidLogUnderProcess = true;
}
});
})(store, referenceDate, setPersistedState);
}
};
openInLogs = (callstack: string) => {
this.props.selectPlugin('DeviceLogs', callstack);
};
constructor(props: Props<PersistedState>) {
// Required step: always call the parent class' constructor
super(props);
let crash: Crash | undefined = undefined;
if (
this.props.persistedState.crashes &&
this.props.persistedState.crashes.length > 0
) {
crash = this.props.persistedState.crashes[
this.props.persistedState.crashes.length - 1
];
}
let deeplinkedCrash: Crash | undefined = undefined;
if (this.props.deepLinkPayload) {
const id = this.props.deepLinkPayload;
const index = this.props.persistedState.crashes.findIndex((elem) => {
return elem.notificationID === id;
timer = setTimeout(() => {
if (androidLog.length > 0) {
reportCrash(
parseCrashLog(androidLog, client.device.os, entry.date),
);
}
androidLogUnderProcess = false;
androidLog = '';
}, 50);
}
});
if (index >= 0) {
deeplinkedCrash = this.props.persistedState.crashes[index];
}
}
// Set the state directly. Use props if necessary.
this.state = {
crash: deeplinkedCrash || crash,
};
}
render() {
let crashToBeInspected = this.state.crash;
client.onDestroy(() => {
watcher?.close();
});
if (!crashToBeInspected && this.props.persistedState.crashes.length > 0) {
crashToBeInspected = this.props.persistedState.crashes[
this.props.persistedState.crashes.length - 1
];
}
const crash = crashToBeInspected;
if (crash) {
const {crashes} = this.props.persistedState;
const crashMap = crashes.reduce(
(acc: {[key: string]: string}, persistedCrash: Crash) => {
const {notificationID, date} = persistedCrash;
const name = 'Crash at ' + date.toLocaleString();
acc[notificationID] = name;
return acc;
},
{},
);
return {
reportCrash,
crashes,
selectedCrash,
openInLogs(callstack: string) {
client.selectPlugin('DeviceLogs', callstack);
},
os: client.device.os,
copyCrashToClipboard(callstack: string) {
client.writeTextToClipboard(callstack);
},
};
}
const orderedIDs = crashes.map(
(persistedCrash) => persistedCrash.notificationID,
);
const selectedCrashID = crash.notificationID;
const onCrashChange = (id: Maybe<string>) => {
const newSelectedCrash = crashes.find(
(element) => element.notificationID === id,
);
this.setState({crash: newSelectedCrash});
};
export function Component() {
const plugin = usePlugin(devicePlugin);
const selectedCrash = useValue(plugin.selectedCrash);
const crashes = useValue(plugin.crashes);
const crash =
crashes.find((c) => c.notificationID === selectedCrash) ??
crashes[crashes.length - 1] ??
undefined;
const callstackString = crash.callstack || '';
const children = callstackString.split('\n').map((str) => {
return {message: str};
});
const crashSelector: CrashSelectorProps = {
crashes: crashMap,
orderedIDs,
selectedCrashID,
onCrashChange,
};
const showReason = crash.reason !== UNKNOWN_CRASH_REASON;
return (
<PluginRootContainer>
{this.device.os == 'Android' ? (
<CrashReporterBar
crashSelector={crashSelector}
openLogsCallback={() => {
if (crash.callstack) {
this.openInLogs(crash.callstack);
}
}}
/>
) : (
<CrashReporterBar crashSelector={crashSelector} />
)}
<ScrollableColumn>
<HeaderRow title="Name" value={crash.name} />
{showReason ? (
<HeaderRow title="Reason" value={crash.reason} />
) : null}
<Padder paddingLeft={8} paddingTop={4} paddingBottom={2}>
<Title> Stacktrace </Title>
</Padder>
<ContextMenu
items={[
{
label: 'copy',
click: () => {
clipboard.writeText(callstackString);
},
},
]}>
<Line />
{children.map((child, index) => {
return (
<StackTraceComponent key={index} stacktrace={child.message} />
);
})}
</ContextMenu>
</ScrollableColumn>
</PluginRootContainer>
);
}
const crashSelector = {
crashes: undefined,
orderedIDs: undefined,
selectedCrashID: undefined,
onCrashChange: () => void {},
if (crash) {
const crashMap = crashes.reduce(
(acc: {[key: string]: string}, persistedCrash: Crash) => {
const {notificationID, date} = persistedCrash;
const name = 'Crash at ' + date.toLocaleString();
acc[notificationID] = name;
return acc;
},
{},
);
const orderedIDs = crashes.map(
(persistedCrash) => persistedCrash.notificationID,
);
const selectedCrashID = crash.notificationID;
const onCrashChange = (id: Maybe<string>) => {
if (id) {
plugin.selectedCrash.set(id);
}
};
const callstackString = crash.callstack || '';
const children = callstackString.split('\n').map((str) => {
return {message: str};
});
const crashSelector: CrashSelectorProps = {
crashes: crashMap,
orderedIDs,
selectedCrashID,
onCrashChange,
};
const showReason = crash.reason !== UNKNOWN_CRASH_REASON;
return (
<StyledFlexGrowColumn>
<CrashReporterBar crashSelector={crashSelector} />
<StyledFlexColumn>
<Padder paddingBottom={8}>
<Title>No Crashes Logged</Title>
<PluginRootContainer>
{plugin.os == 'Android' ? (
<CrashReporterBar
crashSelector={crashSelector}
openLogsCallback={() => {
if (crash.callstack) {
plugin.openInLogs(crash.callstack);
}
}}
/>
) : (
<CrashReporterBar crashSelector={crashSelector} />
)}
<ScrollableColumn>
<HeaderRow title="Name" value={crash.name} />
{showReason ? (
<HeaderRow title="Reason" value={crash.reason} />
) : null}
<Padder paddingLeft={8} paddingTop={4} paddingBottom={2}>
<Title> Stacktrace </Title>
</Padder>
</StyledFlexColumn>
</StyledFlexGrowColumn>
<ContextMenu
items={[
{
label: 'copy',
click: () => {
plugin.copyCrashToClipboard(callstackString);
},
},
]}>
<Line />
{children.map((child, index) => {
return (
<StackTraceComponent key={index} stacktrace={child.message} />
);
})}
</ContextMenu>
</ScrollableColumn>
</PluginRootContainer>
);
}
const crashSelector = {
crashes: undefined,
orderedIDs: undefined,
selectedCrashID: undefined,
onCrashChange: () => void {},
};
return (
<StyledFlexGrowColumn>
<CrashReporterBar crashSelector={crashSelector} />
<StyledFlexColumn>
<Padder paddingBottom={8}>
<Title>No Crashes Logged</Title>
</Padder>
</StyledFlexColumn>
</StyledFlexGrowColumn>
);
}
function trimCallStackIfPossible(callstack: string): string {
const regex = /Application Specific Information:/;
const query = regex.exec(callstack);
return query ? callstack.substring(0, query.index) : callstack;
}
export function shouldShowiOSCrashNotification(
serial: string,
content: string,
): boolean {
const appPath = parsePath(content);
if (!appPath || !appPath.includes(serial)) {
// Do not show notifications for the app which are not running on this device
return false;
}
return true;
}

View File

@@ -4,9 +4,18 @@
"id": "CrashReporter",
"pluginType": "device",
"supportedDevices": [
{"os": "Android", "type": "emulator"},
{"os": "Android", "type": "physical"},
{"os": "iOS", "type": "emulator"}
{
"os": "Android",
"type": "emulator"
},
{
"os": "Android",
"type": "physical"
},
{
"os": "iOS",
"type": "emulator"
}
],
"version": "0.0.0",
"description": "A plugin which will display a crash",
@@ -24,5 +33,8 @@
},
"dependencies": {
"unicode-substring": "^1.0.0"
},
"peerDependencies": {
"flipper-plugin": "0.0.0"
}
}