Crashreporter plugin by adding a watchman for crash log

Summary:
This diff adds a watcher on `~/Library/Logs/Diagnostics/` library and fires a notification whenever a crash log is added there. This will only work for iOS crashes. With this change, for iOS we should be able to see all kind of crash notification be it due to uncaught exception or a native crash or signal errors.

For android, it will still show notifications due to uncaught exceptions. In upcoming diffs, I will change the logic for android too by parsing Logcat logs.

This diff doesn't support physical device crash reporting. The crashes for physical devices are synced to other folder and that too they are symbolicated.

Reviewed By: danielbuechele

Differential Revision: D13404648

fbshipit-source-id: 7219855ebc73451af87f77f90cc3ed0f2ab5287c
This commit is contained in:
Pritesh Nandgaonkar
2018-12-18 13:30:50 -08:00
committed by Facebook Github Bot
parent e3fb1e1d84
commit a12768539e
3 changed files with 353 additions and 15 deletions

View File

@@ -9,7 +9,8 @@ import type {ChildProcess} from 'child_process';
import type {Store} from '../reducers/index.js';
import type Logger from '../fb-stubs/Logger.js';
import type {DeviceType} from '../devices/BaseDevice';
import BaseDevice from '../devices/BaseDevice';
import type {PersistedState} from '../plugins/crash_reporter';
import {RecurringError} from '../utils/errors';
import {promisify} from 'util';
import path from 'path';
@@ -19,6 +20,12 @@ import IOSDevice from '../devices/IOSDevice';
import iosUtil from '../fb-stubs/iOSContainerUtility';
import isProduction from '../utils/isProduction.js';
import GK from '../fb-stubs/GK';
import fs from 'fs';
import os from 'os';
import util from 'util';
import {setPluginState} from '../reducers/pluginStates.js';
import {FlipperDevicePlugin, FlipperPlugin} from '../plugin.js';
import type {State as PluginStatesState} from '../reducers/pluginStates.js';
type iOSSimulatorDevice = {|
state: 'Booted' | 'Shutdown' | 'Shutting Down',
@@ -28,6 +35,12 @@ type iOSSimulatorDevice = {|
udid: string,
|};
type Crash = {|
callstack: string,
reason: string,
name: string,
|};
type IOSDeviceParams = {udid: string, type: DeviceType, name: string};
const portforwardingClient = isProduction()
@@ -51,6 +64,120 @@ window.addEventListener('beforeunload', () => {
portForwarders.forEach(process => process.kill());
});
export function parseCrashLog(content: string): Crash {
const regex = /Exception Type: *[aA-zZ0-9]*/;
const arr = regex.exec(content);
const exceptionString = arr ? arr[0] : '';
const exceptionRegex = /[aA-zZ0-9]*$/;
const tmp = exceptionRegex.exec(exceptionString);
const exception =
tmp && tmp[0].length ? tmp[0] : 'Cannot figure out the cause';
const crash = {
callstack: content,
name: exception,
reason: exception,
};
return crash;
}
export function getPersistedState(
pluginKey: string,
persistingPlugin: ?Class<FlipperPlugin<> | FlipperDevicePlugin<>>,
pluginStates: PluginStatesState,
): ?PersistedState {
if (!persistingPlugin) {
return null;
}
const persistedState = {
...persistingPlugin.defaultPersistedState,
...pluginStates[pluginKey],
};
return persistedState;
}
export function getPluginKey(
selectedDevice: ?BaseDevice,
pluginID: string,
): string {
return `${selectedDevice?.serial || 'unknown'}#${pluginID}`;
}
export function getNewPersisitedStateFromCrashLog(
persistedState: ?PersistedState,
persistingPlugin: Class<FlipperDevicePlugin<> | FlipperPlugin<>>,
content: string,
): ?PersistedState {
const crash = parseCrashLog(content);
if (!persistingPlugin.persistedStateReducer) {
return null;
}
const newPluginState = persistingPlugin.persistedStateReducer(
persistedState,
'crash-report',
crash,
);
return newPluginState;
}
function parseCrashLogAndUpdateState(store: Store, content: string) {
const pluginID = 'CrashReporter';
const pluginKey = getPluginKey(
store.getState().connections.selectedDevice,
pluginID,
);
const persistingPlugin: ?Class<
FlipperDevicePlugin<> | FlipperPlugin<>,
> = store.getState().plugins.devicePlugins.get('CrashReporter');
if (!persistingPlugin) {
return;
}
const pluginStates = store.getState().pluginStates;
const persistedState = getPersistedState(
pluginKey,
persistingPlugin,
pluginStates,
);
const newPluginState = getNewPersisitedStateFromCrashLog(
persistedState,
persistingPlugin,
content,
);
if (newPluginState && persistedState !== newPluginState) {
store.dispatch(
setPluginState({
pluginKey,
state: newPluginState,
}),
);
}
}
function addFileWatcherForiOSCrashLogs(store: Store, logger: Logger) {
const dir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
if (!fs.existsSync(dir)) {
// Directory doesn't exist
return;
}
fs.watch(dir, (eventType, filename) => {
// We just parse the crash logs with extension `.crash`
const checkFileExtension = /.crash$/.exec(filename);
if (!filename || !checkFileExtension) {
return;
}
fs.readFile(path.join(dir, filename), '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));
});
});
}
function queryDevices(store: Store, logger: Logger): Promise<void> {
const {connections} = store.getState();
const currentDeviceIDs: Set<string> = new Set(
@@ -132,6 +259,7 @@ export default (store: Store, logger: Logger) => {
if (process.platform !== 'darwin') {
return;
}
addFileWatcherForiOSCrashLogs(store, logger);
queryDevices(store, logger)
.then(() => {
const simulatorUpdateInterval = setInterval(() => {

View File

@@ -0,0 +1,201 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {
parseCrashLog,
getPluginKey,
getPersistedState,
getNewPersisitedStateFromCrashLog,
} from '../../../dispatcher/iOSDevice.js';
import BaseDevice from '../../../devices/BaseDevice';
import CrashReporterPlugin from '../../crash_reporter';
import type {PersistedState, Crash} from '../../crash_reporter';
function setDefaultPersistedState(defaultState: PersistedState) {
CrashReporterPlugin.defaultPersistedState = defaultState;
}
function setNotificationID(notificationID: number) {
CrashReporterPlugin.notificationID = notificationID;
}
function getCrash(
id: number,
callstack: string,
name: string,
reason: string,
): Crash {
return {
notificationID: id.toString(),
callstack: callstack,
reason: reason,
name: name,
};
}
beforeEach(() => {
setNotificationID(0); // Resets notificationID to 0
setDefaultPersistedState({crashes: []}); // Resets defaultpersistedstate
});
test('test the parsing of the reason for crash when log matches the predefined regex', () => {
const log = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV';
const crash = parseCrashLog(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('SIGSEGV');
expect(crash.name).toEqual('SIGSEGV');
});
test('test the parsing of the crash log when log does not match the predefined regex but is alphanumeric', () => {
const log = 'Blaa Blaaa \n Blaa Blaaa \n Blaa Blaaa';
const crash = parseCrashLog(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
});
test('test the parsing of the reason for crash when log does not match the predefined regex contains unicode character', () => {
const log = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: 🍕🐬';
const crash = parseCrashLog(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
});
test('test the parsing of the reason for crash when log is empty', () => {
const log = '';
const crash = parseCrashLog(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Cannot figure out the cause');
expect(crash.name).toEqual('Cannot figure out the cause');
});
test('test the getter of pluginKey with proper input', () => {
const device = new BaseDevice('serial', 'emulator', 'test device');
const pluginKey = getPluginKey(device, 'CrashReporter');
expect(pluginKey).toEqual('serial#CrashReporter');
});
test('test the getter of pluginKey with undefined input', () => {
const pluginKey = getPluginKey(undefined, 'CrashReporter');
expect(pluginKey).toEqual('unknown#CrashReporter');
});
test('test defaultPersistedState of CrashReporterPlugin', () => {
expect(CrashReporterPlugin.defaultPersistedState).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 perisistedState = getPersistedState(
getPluginKey(null, CrashReporterPlugin.id),
CrashReporterPlugin,
pluginStates,
);
expect(perisistedState).toEqual({crashes: [crash]});
});
test('test getPersistedState for non-empty defaultPersistedState and defined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStateCrash = getCrash(1, 'callstack', 'crash1', 'crash1');
//$FlowFixMe
const pluginStates = {'unknown#CrashReporter': {crashes: [pluginStateCrash]}};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
});
test('test getPersistedState for non-empty defaultPersistedState and defined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(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,
);
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
});
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState', () => {
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(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 content = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV';
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
const newPersistedState = getNewPersisitedStateFromCrashLog(
perisistedState,
CrashReporterPlugin,
content,
);
expect(newPersistedState).toEqual({
crashes: [pluginStateCrash, getCrash(1, content, 'SIGSEGV', 'SIGSEGV')],
});
});
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and undefined pluginState', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(null, CrashReporterPlugin.id);
setDefaultPersistedState({crashes: [crash]});
const pluginStates = {};
const perisistedState = getPersistedState(
pluginKey,
CrashReporterPlugin,
pluginStates,
);
const content = 'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV';
expect(perisistedState).toEqual({crashes: [crash]});
const newPersistedState = getNewPersisitedStateFromCrashLog(
perisistedState,
CrashReporterPlugin,
content,
);
expect(newPersistedState).toEqual({
crashes: [crash, getCrash(1, content, 'SIGSEGV', 'SIGSEGV')],
});
});
test('test getNewPersisitedStateFromCrashLog for non-empty defaultPersistedState and defined pluginState and improper crash log', () => {
setNotificationID(0);
const crash = getCrash(0, 'callstack', 'crash0', 'crash0');
const pluginKey = getPluginKey(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 content = 'Blaa Blaaa \n Blaa Blaaa';
expect(perisistedState).toEqual({crashes: [pluginStateCrash]});
const newPersistedState = getNewPersisitedStateFromCrashLog(
perisistedState,
CrashReporterPlugin,
content,
);
expect(newPersistedState).toEqual({
crashes: [
pluginStateCrash,
getCrash(
1,
content,
'Cannot figure out the cause',
'Cannot figure out the cause',
),
],
});
});

View File

@@ -19,13 +19,14 @@ import {
} from 'flipper';
import type {Notification} from '../../plugin';
type Crash = {|
notificationID: number,
callStack: string,
export type Crash = {|
notificationID: string,
callstack: string,
reason: string,
name: string,
|};
type PersistedState = {|
export type PersistedState = {|
crashes: Array<Crash>,
|};
@@ -70,6 +71,8 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin {
static supportsDevice(device: Device) {
return device.os === 'iOS' || device.os === 'Android';
}
static notificationID: number = 0;
/*
* Reducer to process incoming "send" messages from the mobile counterpart.
*/
@@ -79,21 +82,27 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin {
payload: Object,
): PersistedState => {
if (method === 'crash-report') {
return {
...persistedState,
CrashReporterPlugin.notificationID++;
const mergedState: PersistedState = {
crashes: persistedState.crashes.concat([
{
notificationID: Math.random(), // All notifications are unique
callStack: payload.callstack,
notificationID: CrashReporterPlugin.notificationID.toString(), // All notifications are unique
callstack: payload.callstack,
name: payload.name,
reason: payload.reason,
},
]),
};
return mergedState;
}
return persistedState;
};
static trimCallStackIfPossible = (callstack: string): string => {
let regex = /Application Specific Information:/;
const query = regex.exec(callstack);
return query ? callstack.substring(0, query.index) : callstack;
};
/*
* Callback to provide the currently active notifications.
*/
@@ -101,10 +110,10 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin {
persistedState: PersistedState,
): Array<Notification> => {
return persistedState.crashes.map((crash: Crash) => {
const id = 'crash-notification:' + crash.notificationID;
const id = crash.notificationID;
return {
id,
message: crash.callStack,
message: CrashReporterPlugin.trimCallStackIfPossible(crash.callstack),
severity: 'error',
title: 'CRASH: ' + crash.name + ' ' + crash.reason,
action: id,
@@ -124,7 +133,7 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin {
];
if (crash) {
const callStackString = crash.callStack;
const callstackString = crash.callstack;
return (
<RootColumn>
<CrashRow>
@@ -144,16 +153,16 @@ export default class CrashReporterPlugin extends FlipperDevicePlugin {
{
label: 'copy',
click: () => {
clipboard.writeText(callStackString);
clipboard.writeText(callstackString);
},
},
]}>
<CallStack>{callStackString}</CallStack>
<CallStack>{callstackString}</CallStack>
</ContextMenu>
</CrashRow>
{this.device.os == 'Android' && (
<CrashRow>
<Button onClick={() => this.openInLogs(crash.callStack)}>
<Button onClick={() => this.openInLogs(crash.callstack)}>
Open in Logs
</Button>
</CrashRow>