Support physical device logging

Summary:
Changelog: Logs plugin now supports physical iOS devices

As reported in https://github.com/facebook/flipper/issues/262 and linked papercut.

This diff adds support for iOS device logs through idb. Since idb doesn't respect `--json` flag at the moment, we perform the parsing in Flipper itself.

Reviewed By: passy

Differential Revision: D27346262

fbshipit-source-id: 3b314716f48bb9a7fe709370303396a51893359c
This commit is contained in:
Michel Weststrate
2021-03-29 06:59:15 -07:00
committed by Facebook GitHub Bot
parent d22e893169
commit d5fbe9a5b9
9 changed files with 170 additions and 45 deletions

View File

@@ -20,6 +20,7 @@ import {promisify} from 'util';
import {exec} from 'child_process';
import {default as promiseTimeout} from '../utils/promiseTimeout';
import {IOSBridge} from '../utils/IOSBridge';
import split2 from 'split2';
type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault';
@@ -40,6 +41,10 @@ type RawLogEntry = {
traceID: string;
};
// https://regex101.com/r/rrl03T/1
// Mar 25 17:06:38 iPhone symptomsd(SymptomEvaluator)[125] <Notice>: Stuff
const logRegex = /(^.{15}) ([^ ]+?) ([^\[]+?)\[(\d+?)\] <(\w+?)>: (.*)$/s;
export default class IOSDevice extends BaseDevice {
log?: child_process.ChildProcessWithoutNullStreams;
buffer: string;
@@ -97,8 +102,13 @@ export default class IOSDevice extends BaseDevice {
}
const logListener = iOSBridge.startLogListener;
if (!this.log && logListener) {
this.log = logListener(this.serial);
if (
!this.log &&
logListener &&
(this.deviceType === 'emulator' ||
(this.deviceType === 'physical' && iOSBridge.idbAvailable))
) {
this.log = logListener(this.serial, this.deviceType);
this.log.on('error', (err: Error) => {
console.error('iOS log tailer error', err);
});
@@ -112,13 +122,24 @@ export default class IOSDevice extends BaseDevice {
});
try {
this.log.stdout
.pipe(new StripLogPrefix())
.pipe(JSONStream.parse('*'))
.on('data', (data: RawLogEntry) => {
const entry = IOSDevice.parseLogEntry(data);
this.addLogEntry(entry);
if (this.deviceType === 'physical') {
this.log.stdout.pipe(split2('\0')).on('data', (line: string) => {
const parsed = IOSDevice.parseLogLine(line);
if (parsed) {
this.addLogEntry(parsed);
} else {
console.warn('Failed to parse iOS log line: ', line);
}
});
} else {
this.log.stdout
.pipe(new StripLogPrefix())
.pipe(JSONStream.parse('*'))
.on('data', (data: RawLogEntry) => {
const entry = IOSDevice.parseJsonLogEntry(data);
this.addLogEntry(entry);
});
}
} catch (e) {
console.error('Could not parse iOS log stream.', e);
// restart log stream
@@ -129,15 +150,42 @@ export default class IOSDevice extends BaseDevice {
}
}
static parseLogEntry(entry: RawLogEntry): DeviceLogEntry {
const LOG_MAPPING: Map<IOSLogLevel, LogLevel> = new Map([
['Default' as IOSLogLevel, 'debug' as LogLevel],
['Info' as IOSLogLevel, 'info' as LogLevel],
['Debug' as IOSLogLevel, 'debug' as LogLevel],
['Error' as IOSLogLevel, 'error' as LogLevel],
['Fault' as IOSLogLevel, 'fatal' as LogLevel],
]);
let type: LogLevel = LOG_MAPPING.get(entry.messageType) || 'unknown';
static getLogLevel(level: string): LogLevel {
switch (level) {
case 'Default':
return 'debug';
case 'Info':
return 'info';
case 'Debug':
return 'debug';
case 'Error':
return 'error';
case 'Notice':
return 'verbose';
case 'Fault':
return 'fatal';
default:
return 'unknown';
}
}
static parseLogLine(line: string): DeviceLogEntry | undefined {
const matches = line.match(logRegex);
if (matches) {
return {
date: new Date(Date.parse(matches[1])),
tag: matches[3],
tid: 0,
pid: parseInt(matches[4], 10),
type: IOSDevice.getLogLevel(matches[5]),
message: matches[6],
};
}
return undefined;
}
static parseJsonLogEntry(entry: RawLogEntry): DeviceLogEntry {
let type: LogLevel = IOSDevice.getLogLevel(entry.messageType);
// when Apple log levels are not used, log messages can be prefixed with
// their loglevel.

Binary file not shown.

View File

@@ -64,7 +64,9 @@ test('test getAllPromisesForQueryingDevices when xcode detected', () => {
const promises = getAllPromisesForQueryingDevices(
mockStore,
logger,
{},
{
idbAvailable: false,
},
true,
);
expect(promises.length).toEqual(3);
@@ -74,7 +76,9 @@ test('test getAllPromisesForQueryingDevices when xcode is not detected', () => {
const promises = getAllPromisesForQueryingDevices(
mockStore,
logger,
{},
{
idbAvailable: true,
},
false,
);
expect(promises.length).toEqual(1);

View File

@@ -9,10 +9,13 @@
import fs from 'fs';
import child_process from 'child_process';
import {DeviceType} from 'flipper-plugin-lib';
export interface IOSBridge {
idbAvailable: boolean;
startLogListener?: (
udid: string,
deviceType: DeviceType,
) => child_process.ChildProcessWithoutNullStreams;
}
@@ -26,27 +29,36 @@ async function isAvailable(idbPath: string): Promise<boolean> {
.catch((_) => false);
}
const LOG_EXTRA_ARGS = [
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
];
function getLogExtraArgs(deviceType: DeviceType) {
if (deviceType === 'physical') {
return [
// idb has a --json option, but that doesn't actually work!
];
} else {
return [
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
];
}
}
export function idbStartLogListener(
idbPath: string,
udid: string,
deviceType: DeviceType,
): child_process.ChildProcessWithoutNullStreams {
return child_process.spawn(
idbPath,
['log', '--udid', udid, '--', ...LOG_EXTRA_ARGS],
['log', '--udid', udid, '--', ...getLogExtraArgs(deviceType)],
{},
);
}
export function xcrunStartLogListener(udid: string) {
export function xcrunStartLogListener(udid: string, deviceType: DeviceType) {
const deviceSetPath = process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
@@ -59,7 +71,7 @@ export function xcrunStartLogListener(udid: string) {
udid,
'log',
'stream',
...LOG_EXTRA_ARGS,
...getLogExtraArgs(deviceType),
],
{},
);
@@ -70,18 +82,23 @@ export async function makeIOSBridge(
isXcodeDetected: boolean,
isAvailableFn: (idbPath: string) => Promise<boolean> = isAvailable,
): Promise<IOSBridge> {
if (!isXcodeDetected) {
// iOS Physical Device can still get detected without Xcode. In this case there is no way to setup log listener yet.
// This will not be the case, idb team is working on making idb log work without XCode atleast for physical device.
return {};
}
// prefer idb
if (await isAvailableFn(idbPath)) {
return {
idbAvailable: true,
startLogListener: idbStartLogListener.bind(null, idbPath),
};
}
// no idb, if it's a simulator and xcode is available, we can use xcrun
if (isXcodeDetected) {
return {
idbAvailable: false,
startLogListener: xcrunStartLogListener,
};
}
// no idb, and not a simulator, we can't log this device
return {
startLogListener: xcrunStartLogListener,
idbAvailable: false,
};
}

View File

@@ -18,7 +18,7 @@ test('uses xcrun with no idb when xcode is detected', async () => {
const ib = await makeIOSBridge('', true);
expect(ib.startLogListener).toBeDefined();
ib.startLogListener!('deadbeef');
ib.startLogListener!('deadbeef', 'emulator');
expect(spawn).toHaveBeenCalledWith(
'xcrun',
@@ -43,7 +43,7 @@ test('uses idb when present and xcode detected', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
expect(ib.startLogListener).toBeDefined();
ib.startLogListener!('deadbeef');
ib.startLogListener!('deadbeef', 'emulator');
expect(spawn).toHaveBeenCalledWith(
'/usr/local/bin/idb',
@@ -63,6 +63,31 @@ test('uses idb when present and xcode detected', async () => {
);
});
test('uses idb when present and xcode detected and physical device connected', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb', true, async (_) => true);
expect(ib.startLogListener).toBeDefined();
ib.startLogListener!('deadbeef', 'physical');
expect(spawn).toHaveBeenCalledWith(
'/usr/local/bin/idb',
[
'log',
'--udid',
'deadbeef',
'--',
// no further args; not supported by idb atm
],
{},
);
});
test("without idb physical devices can't log", async () => {
const ib = await makeIOSBridge('', true);
expect(ib.idbAvailable).toBeFalsy();
expect(ib.startLogListener).toBeDefined(); // since we have xcode
});
test('uses no log listener when xcode is not detected', async () => {
const ib = await makeIOSBridge('', false);
expect(ib.startLogListener).toBeUndefined();