mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
feat: add iOS frame perf sampling (#477)
* feat: add iOS frame perf sampling * fix: satisfy fallow for iOS perf sampling * refactor: address iOS perf review feedback * fix: handle optional iOS display info export * fix: retry iOS trace record lock failures * fix: correct iOS perf retry windows
This commit is contained in:
committed by
GitHub
parent
af73a101f3
commit
cff8bd81e4
@@ -12,7 +12,11 @@ import {
|
||||
sampleAndroidFramePerf,
|
||||
sampleAndroidMemoryPerf,
|
||||
} from '../../platforms/android/perf.ts';
|
||||
import { buildAppleSamplingMetadata, sampleApplePerfMetrics } from '../../platforms/ios/perf.ts';
|
||||
import {
|
||||
buildAppleSamplingMetadata,
|
||||
sampleAppleFramePerf,
|
||||
sampleApplePerfMetrics,
|
||||
} from '../../platforms/ios/perf.ts';
|
||||
import {
|
||||
PERF_STARTUP_SAMPLE_LIMIT,
|
||||
PERF_UNAVAILABLE_REASON,
|
||||
@@ -172,6 +176,10 @@ async function applyApplePerfMetrics(
|
||||
const results = await sampleApplePerfResultsForSession(session);
|
||||
response.metrics.memory = buildMetricResult(results.memory);
|
||||
response.metrics.cpu = buildMetricResult(results.cpu);
|
||||
response.metrics.fps = enrichFrameMetricWithSessionContext(
|
||||
buildMetricResult(results.fps),
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
function supportsPlatformPerfMetrics(session: SessionState): boolean {
|
||||
@@ -237,19 +245,34 @@ async function sampleAndroidPerfResults(
|
||||
async function sampleApplePerfResultsForSession(session: SessionState): Promise<{
|
||||
memory: SettledMetricResult;
|
||||
cpu: SettledMetricResult;
|
||||
fps: SettledMetricResult;
|
||||
}> {
|
||||
const appBundleId = session.appBundleId as string;
|
||||
const fps = await settleMetric(sampleAppleFramePerf(session.device, appBundleId));
|
||||
const processSample = await settleMetric(sampleApplePerfMetrics(session.device, appBundleId));
|
||||
if (processSample.status === 'fulfilled') {
|
||||
const processMetrics = processSample.value as {
|
||||
memory: Record<string, unknown>;
|
||||
cpu: Record<string, unknown>;
|
||||
};
|
||||
return {
|
||||
memory: { status: 'fulfilled', value: processMetrics.memory },
|
||||
cpu: { status: 'fulfilled', value: processMetrics.cpu },
|
||||
fps,
|
||||
};
|
||||
}
|
||||
return {
|
||||
memory: { status: 'rejected', reason: processSample.reason },
|
||||
cpu: { status: 'rejected', reason: processSample.reason },
|
||||
fps,
|
||||
};
|
||||
}
|
||||
|
||||
async function settleMetric<T extends object>(promise: Promise<T>): Promise<SettledMetricResult> {
|
||||
try {
|
||||
const sample = await sampleApplePerfMetrics(session.device, appBundleId);
|
||||
return {
|
||||
memory: { status: 'fulfilled', value: sample.memory },
|
||||
cpu: { status: 'fulfilled', value: sample.cpu },
|
||||
};
|
||||
return { status: 'fulfilled', value: (await promise) as Record<string, unknown> };
|
||||
} catch (reason) {
|
||||
return {
|
||||
memory: { status: 'rejected', reason },
|
||||
cpu: { status: 'rejected', reason },
|
||||
};
|
||||
return { status: 'rejected', reason };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,14 @@ vi.mock('../../../utils/exec.ts', async (importOriginal) => {
|
||||
return { ...actual, runCmd: vi.fn(actual.runCmd) };
|
||||
});
|
||||
|
||||
import { parseApplePsOutput, sampleApplePerfMetrics } from '../perf.ts';
|
||||
import { parseApplePsOutput, sampleAppleFramePerf, sampleApplePerfMetrics } from '../perf.ts';
|
||||
import { parseAppleFramePerfSample } from '../perf-frame.ts';
|
||||
import { runCmd } from '../../../utils/exec.ts';
|
||||
import type { DeviceInfo } from '../../../utils/device.ts';
|
||||
|
||||
const mockRunCmd = vi.mocked(runCmd);
|
||||
type MockRunCmdResult = Awaited<ReturnType<typeof runCmd>>;
|
||||
type XcrunMockHandler = (args: string[]) => Promise<MockRunCmdResult | null>;
|
||||
|
||||
const IOS_SIMULATOR: DeviceInfo = {
|
||||
platform: 'ios',
|
||||
@@ -68,6 +71,37 @@ test('parseApplePsOutput reads pid cpu rss and command columns', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseAppleFramePerfSample summarizes app hitches and worst windows', () => {
|
||||
const sample = parseAppleFramePerfSample({
|
||||
hitchesXml: makeAppleHitchesXml(),
|
||||
frameLifetimesXml: makeAppleFrameLifetimesXml(4),
|
||||
displayInfoXml: makeAppleDisplayInfoXml(120),
|
||||
processIds: [4001],
|
||||
processNames: ['ExampleDeviceApp'],
|
||||
windowStartedAt: '2026-04-01T10:00:00.000Z',
|
||||
windowEndedAt: '2026-04-01T10:00:02.000Z',
|
||||
measuredAt: '2026-04-01T10:00:02.000Z',
|
||||
});
|
||||
|
||||
assert.equal(sample.droppedFrameCount, 2);
|
||||
assert.equal(sample.totalFrameCount, 4);
|
||||
assert.equal(sample.droppedFramePercent, 50);
|
||||
assert.equal(sample.sampleWindowMs, 2000);
|
||||
assert.equal(sample.refreshRateHz, 120);
|
||||
assert.equal(sample.frameDeadlineMs, 8.3);
|
||||
assert.deepEqual(sample.matchedProcesses, ['ExampleDeviceApp']);
|
||||
assert.deepEqual(sample.worstWindows, [
|
||||
{
|
||||
startOffsetMs: 100,
|
||||
endOffsetMs: 238,
|
||||
startAt: '2026-04-01T10:00:00.100Z',
|
||||
endAt: '2026-04-01T10:00:00.238Z',
|
||||
missedDeadlineFrameCount: 2,
|
||||
worstFrameMs: 37.5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('sampleApplePerfMetrics aggregates host ps metrics for macOS app bundle', async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-macos-perf-'));
|
||||
const bundlePath = path.join(tmpDir, 'Example.app');
|
||||
@@ -124,7 +158,7 @@ test('sampleApplePerfMetrics uses simctl spawn ps for iOS simulators', async ()
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0"><dict>',
|
||||
'<key>CFBundleExecutable</key><string>ExampleSimExec</string>',
|
||||
'<key>CFBundleExecutable</key><string>Example Sim Exec</string>',
|
||||
'</dict></plist>',
|
||||
].join(''),
|
||||
'utf8',
|
||||
@@ -139,7 +173,10 @@ test('sampleApplePerfMetrics uses simctl spawn ps for iOS simulators', async ()
|
||||
}
|
||||
if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) {
|
||||
return {
|
||||
stdout: ['111 12.0 8192 ExampleSimExec', '222 4.0 1024 SpringBoard'].join('\n'),
|
||||
stdout: [
|
||||
`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`,
|
||||
'222 4.0 1024 SpringBoard',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
@@ -151,7 +188,7 @@ test('sampleApplePerfMetrics uses simctl spawn ps for iOS simulators', async ()
|
||||
const metrics = await sampleApplePerfMetrics(IOS_SIMULATOR, 'com.example.sim');
|
||||
assert.equal(metrics.cpu.usagePercent, 12);
|
||||
assert.equal(metrics.memory.residentMemoryKb, 8192);
|
||||
assert.deepEqual(metrics.cpu.matchedProcesses, ['ExampleSimExec']);
|
||||
assert.deepEqual(metrics.cpu.matchedProcesses, ['Example Sim Exec']);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -161,35 +198,218 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'));
|
||||
|
||||
const firstCaptureXml = [
|
||||
'<?xml version="1.0"?>',
|
||||
'<trace-query-result>',
|
||||
'<node xpath="//trace-toc[1]/run[1]/data[1]/table[7]">',
|
||||
'<schema name="activity-monitor-process-live">',
|
||||
'<col><mnemonic>start</mnemonic></col>',
|
||||
'<col><mnemonic>process</mnemonic></col>',
|
||||
'<col><mnemonic>cpu-total</mnemonic></col>',
|
||||
'<col><mnemonic>memory-real</mnemonic></col>',
|
||||
'<col><mnemonic>pid</mnemonic></col>',
|
||||
'</schema>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.123">123</start-time>',
|
||||
'<process fmt="ExampleDeviceApp (4001)"><pid fmt="4001">4001</pid></process>',
|
||||
'<duration-on-core fmt="100.00 ms">100000000</duration-on-core>',
|
||||
'<size-in-bytes fmt="8.00 MiB">8388608</size-in-bytes>',
|
||||
'<pid fmt="4001">4001</pid>',
|
||||
'<process ref="background-process"/>',
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.124">124</start-time>',
|
||||
'<process fmt="OtherApp (5001)"><pid fmt="5001">5001</pid></process>',
|
||||
'<duration-on-core fmt="75.00 ms">75000000</duration-on-core>',
|
||||
'<size-in-bytes fmt="4.00 MiB">4194304</size-in-bytes>',
|
||||
'<pid fmt="5001">5001</pid>',
|
||||
'</row>',
|
||||
'</node>',
|
||||
'</trace-query-result>',
|
||||
].join('');
|
||||
const captures = makeActivityMonitorCaptureXmls();
|
||||
mockXcrunCommands([
|
||||
mockIosDeviceApps,
|
||||
mockIosDeviceProcesses,
|
||||
mockXctraceRecord(() => vi.setSystemTime(new Date(Date.now() + 1000))),
|
||||
mockSequentialExports(captures),
|
||||
]);
|
||||
|
||||
const metrics = await sampleApplePerfMetrics(IOS_DEVICE, 'com.example.device');
|
||||
assert.equal(metrics.cpu.usagePercent, 25);
|
||||
assert.equal(metrics.memory.residentMemoryKb, 8192);
|
||||
assert.equal(metrics.cpu.method, 'xctrace-activity-monitor');
|
||||
assert.deepEqual(metrics.cpu.matchedProcesses, ['ExampleDeviceApp']);
|
||||
assert.equal(metrics.cpu.measuredAt, '2026-04-01T10:00:02.000Z');
|
||||
assert.equal(metrics.memory.measuredAt, '2026-04-01T10:00:02.000Z');
|
||||
});
|
||||
|
||||
test('sampleAppleFramePerf records Animation Hitches for connected iOS devices', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'));
|
||||
|
||||
mockXcrunCommands([
|
||||
mockIosDeviceApps,
|
||||
mockIosDeviceProcesses,
|
||||
mockAnimationHitchesRecord,
|
||||
mockFrameTableExports,
|
||||
]);
|
||||
|
||||
const sample = await sampleAppleFramePerf(IOS_DEVICE, 'com.example.device');
|
||||
assert.equal(sample.droppedFramePercent, 50);
|
||||
assert.equal(sample.windowStartedAt, '2026-04-01T10:00:00.000Z');
|
||||
assert.equal(sample.windowEndedAt, '2026-04-01T10:00:02.000Z');
|
||||
assert.equal(sample.method, 'xctrace-animation-hitches');
|
||||
});
|
||||
|
||||
test('sampleAppleFramePerf keeps core metrics when display info export fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'));
|
||||
|
||||
mockXcrunCommands([
|
||||
mockIosDeviceApps,
|
||||
mockIosDeviceProcesses,
|
||||
mockAnimationHitchesRecord,
|
||||
mockFrameTableExportsWithoutDisplayInfo,
|
||||
]);
|
||||
|
||||
const sample = await sampleAppleFramePerf(IOS_DEVICE, 'com.example.device');
|
||||
assert.equal(sample.droppedFramePercent, 50);
|
||||
assert.equal(sample.refreshRateHz, undefined);
|
||||
assert.equal(sample.frameDeadlineMs, undefined);
|
||||
});
|
||||
|
||||
test('sampleAppleFramePerf retries transient kperf lock failures', async () => {
|
||||
mockXcrunCommands([
|
||||
mockIosDeviceApps,
|
||||
mockIosDeviceProcesses,
|
||||
mockKperfLockThenAnimationHitchesRecord(),
|
||||
mockFrameTableExports,
|
||||
]);
|
||||
|
||||
const sample = await sampleAppleFramePerf(IOS_DEVICE, 'com.example.device');
|
||||
assert.equal(sample.droppedFramePercent, 50);
|
||||
assert.ok(sample.sampleWindowMs < 1000);
|
||||
}, 10_000);
|
||||
|
||||
function mockXcrunCommands(handlers: XcrunMockHandler[]): void {
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd !== 'xcrun') throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(args);
|
||||
if (result) return result;
|
||||
}
|
||||
throw new Error(`unexpected xcrun args: ${args.join(' ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function mockIosDeviceApps(args: string[]): Promise<MockRunCmdResult | null> {
|
||||
if (!matchesDevicectlInfo(args, 'apps')) return null;
|
||||
await writeJsonOutput(args, {
|
||||
result: {
|
||||
apps: [
|
||||
{
|
||||
bundleIdentifier: 'com.example.device',
|
||||
name: 'Example Device App',
|
||||
url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return emptyRunResult();
|
||||
}
|
||||
|
||||
async function mockIosDeviceProcesses(args: string[]): Promise<MockRunCmdResult | null> {
|
||||
if (!matchesDevicectlInfo(args, 'processes')) return null;
|
||||
await writeJsonOutput(args, {
|
||||
result: {
|
||||
runningProcesses: [
|
||||
{
|
||||
executable:
|
||||
'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp',
|
||||
processIdentifier: 4001,
|
||||
},
|
||||
{
|
||||
executable:
|
||||
'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceHelper',
|
||||
processIdentifier: 4002,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return emptyRunResult();
|
||||
}
|
||||
|
||||
function mockXctraceRecord(onRecord: () => void): XcrunMockHandler {
|
||||
return async (args) => {
|
||||
if (args[0] !== 'xctrace' || args[1] !== 'record') return null;
|
||||
onRecord();
|
||||
await fs.writeFile(readOutputPath(args), 'trace', 'utf8');
|
||||
return emptyRunResult();
|
||||
};
|
||||
}
|
||||
|
||||
async function mockAnimationHitchesRecord(args: string[]): Promise<MockRunCmdResult | null> {
|
||||
if (args[0] !== 'xctrace' || args[1] !== 'record') return null;
|
||||
assert.deepEqual(args.slice(2, 10), [
|
||||
'--template',
|
||||
'Animation Hitches',
|
||||
'--device',
|
||||
'ios-device-1',
|
||||
'--attach',
|
||||
'4001',
|
||||
'--attach',
|
||||
'4002',
|
||||
]);
|
||||
assert.deepEqual(args.slice(10, 12), ['--time-limit', '2s']);
|
||||
vi.setSystemTime(new Date('2026-04-01T10:00:02.000Z'));
|
||||
await fs.writeFile(readOutputPath(args), 'trace', 'utf8');
|
||||
return emptyRunResult();
|
||||
}
|
||||
|
||||
function mockKperfLockThenAnimationHitchesRecord(): XcrunMockHandler {
|
||||
let didFail = false;
|
||||
return async (args) => {
|
||||
if (args[0] !== 'xctrace' || args[1] !== 'record') return null;
|
||||
if (!didFail) {
|
||||
didFail = true;
|
||||
return {
|
||||
stdout: '',
|
||||
stderr:
|
||||
'Run issues were detected (trace is still ready to be viewed):\n* [Error] Failed to start the recording: _lockKPerf: could not lock kperf. Likely another session just started.',
|
||||
exitCode: 2,
|
||||
};
|
||||
}
|
||||
await fs.writeFile(readOutputPath(args), 'trace', 'utf8');
|
||||
return emptyRunResult();
|
||||
};
|
||||
}
|
||||
|
||||
function mockSequentialExports(xmlPayloads: string[]): XcrunMockHandler {
|
||||
let exportCount = 0;
|
||||
return async (args) => {
|
||||
if (args[0] !== 'xctrace' || args[1] !== 'export') return null;
|
||||
await fs.writeFile(readOutputPath(args), xmlPayloads[exportCount++] ?? '', 'utf8');
|
||||
return emptyRunResult();
|
||||
};
|
||||
}
|
||||
|
||||
async function mockFrameTableExports(args: string[]): Promise<MockRunCmdResult | null> {
|
||||
if (args[0] !== 'xctrace' || args[1] !== 'export') return null;
|
||||
const xpath = args[args.indexOf('--xpath') + 1] ?? '';
|
||||
await fs.writeFile(readOutputPath(args), readFrameTableXml(xpath), 'utf8');
|
||||
return emptyRunResult();
|
||||
}
|
||||
|
||||
async function mockFrameTableExportsWithoutDisplayInfo(
|
||||
args: string[],
|
||||
): Promise<MockRunCmdResult | null> {
|
||||
if (args[0] !== 'xctrace' || args[1] !== 'export') return null;
|
||||
const xpath = args[args.indexOf('--xpath') + 1] ?? '';
|
||||
if (xpath.includes('device-display-info')) {
|
||||
return { stdout: '', stderr: 'missing display info', exitCode: 1 };
|
||||
}
|
||||
await fs.writeFile(readOutputPath(args), readFrameTableXml(xpath), 'utf8');
|
||||
return emptyRunResult();
|
||||
}
|
||||
|
||||
function readFrameTableXml(xpath: string): string {
|
||||
if (xpath.includes('hitches-frame-lifetimes')) return makeAppleFrameLifetimesXml(4);
|
||||
if (xpath.includes('device-display-info')) return makeAppleDisplayInfoXml(120);
|
||||
return makeAppleHitchesXml();
|
||||
}
|
||||
|
||||
function matchesDevicectlInfo(args: string[], subject: 'apps' | 'processes'): boolean {
|
||||
return (
|
||||
args[0] === 'devicectl' && args[1] === 'device' && args[2] === 'info' && args[3] === subject
|
||||
);
|
||||
}
|
||||
|
||||
async function writeJsonOutput(args: string[], data: unknown): Promise<void> {
|
||||
await fs.writeFile(readOutputPath(args, '--json-output'), JSON.stringify(data), 'utf8');
|
||||
}
|
||||
|
||||
function readOutputPath(args: string[], flag = '--output'): string {
|
||||
return args[args.indexOf(flag) + 1]!;
|
||||
}
|
||||
|
||||
function emptyRunResult(): MockRunCmdResult {
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
|
||||
function makeActivityMonitorCaptureXmls(): string[] {
|
||||
const firstCaptureXml = makeActivityMonitorCaptureXml();
|
||||
const secondCaptureXml = firstCaptureXml
|
||||
.replace(
|
||||
'<duration-on-core fmt="100.00 ms">100000000</duration-on-core>',
|
||||
@@ -204,98 +424,142 @@ test('sampleApplePerfMetrics uses xctrace Activity Monitor for iOS devices', asy
|
||||
'<process fmt="ExampleDeviceApp (4001)"><pid fmt="4001">4001</pid></process>',
|
||||
'<process id="proc-ref" fmt="ExampleDeviceApp (4001)"><pid fmt="4001">4001</pid></process>',
|
||||
)
|
||||
.replace(
|
||||
'</row><row><start-time fmt="00:00.124">124</start-time>',
|
||||
[
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.123">123</start-time>',
|
||||
'<process ref="proc-ref"/>',
|
||||
'<duration-on-core ref="cpu-ref"/>',
|
||||
'<size-in-bytes ref="mem-ref"/>',
|
||||
'<pid ref="pid-ref"/>',
|
||||
'<process ref="background-process"/>',
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.124">124</start-time>',
|
||||
].join(''),
|
||||
);
|
||||
let exportCount = 0;
|
||||
.replace('</row><row><start-time fmt="00:00.124">124</start-time>', makeReferenceRow());
|
||||
return [firstCaptureXml, secondCaptureXml];
|
||||
}
|
||||
|
||||
mockRunCmd.mockImplementation(async (cmd, args) => {
|
||||
if (cmd !== 'xcrun') {
|
||||
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
|
||||
}
|
||||
if (
|
||||
args[0] === 'devicectl' &&
|
||||
args[1] === 'device' &&
|
||||
args[2] === 'info' &&
|
||||
args[3] === 'apps'
|
||||
) {
|
||||
const outputIndex = args.indexOf('--json-output');
|
||||
await fs.writeFile(
|
||||
args[outputIndex + 1]!,
|
||||
JSON.stringify({
|
||||
result: {
|
||||
apps: [
|
||||
{
|
||||
bundleIdentifier: 'com.example.device',
|
||||
name: 'Example Device App',
|
||||
url: 'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (
|
||||
args[0] === 'devicectl' &&
|
||||
args[1] === 'device' &&
|
||||
args[2] === 'info' &&
|
||||
args[3] === 'processes'
|
||||
) {
|
||||
const outputIndex = args.indexOf('--json-output');
|
||||
await fs.writeFile(
|
||||
args[outputIndex + 1]!,
|
||||
JSON.stringify({
|
||||
result: {
|
||||
runningProcesses: [
|
||||
{
|
||||
executable:
|
||||
'file:///private/var/containers/Bundle/Application/ABC123/ExampleDevice.app/ExampleDeviceApp',
|
||||
processIdentifier: 4001,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (args[0] === 'xctrace' && args[1] === 'record') {
|
||||
vi.setSystemTime(new Date(Date.now() + 1000));
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (args[0] === 'xctrace' && args[1] === 'export') {
|
||||
const outputIndex = args.indexOf('--output');
|
||||
exportCount += 1;
|
||||
await fs.writeFile(
|
||||
args[outputIndex + 1]!,
|
||||
exportCount === 1 ? firstCaptureXml : secondCaptureXml,
|
||||
'utf8',
|
||||
);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
throw new Error(`unexpected xcrun args: ${args.join(' ')}`);
|
||||
});
|
||||
function makeActivityMonitorCaptureXml(): string {
|
||||
return [
|
||||
'<?xml version="1.0"?>',
|
||||
'<trace-query-result>',
|
||||
'<node xpath="//trace-toc[1]/run[1]/data[1]/table[7]">',
|
||||
'<schema name="activity-monitor-process-live">',
|
||||
'<col><mnemonic>start</mnemonic></col>',
|
||||
'<col><mnemonic>process</mnemonic></col>',
|
||||
'<col><mnemonic>cpu-total</mnemonic></col>',
|
||||
'<col><mnemonic>memory-real</mnemonic></col>',
|
||||
'<col><mnemonic>pid</mnemonic></col>',
|
||||
'</schema>',
|
||||
makeActivityMonitorRow('ExampleDeviceApp', 4001, 100_000_000, 8_388_608),
|
||||
makeActivityMonitorRow('OtherApp', 5001, 75_000_000, 4_194_304),
|
||||
'</node>',
|
||||
'</trace-query-result>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
const metrics = await sampleApplePerfMetrics(IOS_DEVICE, 'com.example.device');
|
||||
assert.equal(metrics.cpu.usagePercent, 25);
|
||||
assert.equal(metrics.memory.residentMemoryKb, 8192);
|
||||
assert.equal(metrics.cpu.method, 'xctrace-activity-monitor');
|
||||
assert.deepEqual(metrics.cpu.matchedProcesses, ['ExampleDeviceApp']);
|
||||
assert.equal(metrics.cpu.measuredAt, '2026-04-01T10:00:02.000Z');
|
||||
assert.equal(metrics.memory.measuredAt, '2026-04-01T10:00:02.000Z');
|
||||
});
|
||||
function makeActivityMonitorRow(
|
||||
processName: string,
|
||||
pid: number,
|
||||
cpuTimeNs: number,
|
||||
memoryBytes: number,
|
||||
): string {
|
||||
return [
|
||||
'<row>',
|
||||
`<start-time fmt="00:00.123">${pid === 4001 ? 123 : 124}</start-time>`,
|
||||
`<process fmt="${processName} (${pid})"><pid fmt="${pid}">${pid}</pid></process>`,
|
||||
`<duration-on-core fmt="100.00 ms">${cpuTimeNs}</duration-on-core>`,
|
||||
`<size-in-bytes fmt="8.00 MiB">${memoryBytes}</size-in-bytes>`,
|
||||
`<pid fmt="${pid}">${pid}</pid>`,
|
||||
pid === 4001 ? '<process ref="background-process"/>' : '',
|
||||
'</row>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function makeReferenceRow(): string {
|
||||
return [
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.123">123</start-time>',
|
||||
'<process ref="proc-ref"/>',
|
||||
'<duration-on-core ref="cpu-ref"/>',
|
||||
'<size-in-bytes ref="mem-ref"/>',
|
||||
'<pid ref="pid-ref"/>',
|
||||
'<process ref="background-process"/>',
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.124">124</start-time>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function makeAppleHitchesXml(): string {
|
||||
return [
|
||||
'<?xml version="1.0"?>',
|
||||
'<trace-query-result><node>',
|
||||
'<schema name="hitches">',
|
||||
'<col><mnemonic>start</mnemonic></col>',
|
||||
'<col><mnemonic>duration</mnemonic></col>',
|
||||
'<col><mnemonic>process</mnemonic></col>',
|
||||
'<col><mnemonic>is-system</mnemonic></col>',
|
||||
'<col><mnemonic>swap-id</mnemonic></col>',
|
||||
'<col><mnemonic>label</mnemonic></col>',
|
||||
'<col><mnemonic>display</mnemonic></col>',
|
||||
'<col><mnemonic>narrative-description</mnemonic></col>',
|
||||
'</schema>',
|
||||
'<row>',
|
||||
'<start-time id="start-1" fmt="00:00.100">100000000</start-time>',
|
||||
'<duration id="duration-1" fmt="16.67 ms">16666583</duration>',
|
||||
'<process id="process-1" fmt="ExampleDeviceApp (4001)"><pid id="pid-1" fmt="4001">4001</pid></process>',
|
||||
'<boolean id="false" fmt="No">0</boolean>',
|
||||
'<uint32>1</uint32><string>0x1</string><display-name>Display 1</display-name><string></string>',
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.200">200000000</start-time>',
|
||||
'<duration fmt="37.50 ms">37500000</duration>',
|
||||
'<process ref="process-1"/>',
|
||||
'<boolean ref="false"/>',
|
||||
'<uint32>2</uint32><string>0x2</string><display-name>Display 1</display-name><string></string>',
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.200">200000000</start-time>',
|
||||
'<duration ref="duration-1"/>',
|
||||
'<sentinel/>',
|
||||
'<boolean fmt="Yes">1</boolean>',
|
||||
'<uint32>2</uint32><string>0x2</string><display-name>Display 1</display-name><string></string>',
|
||||
'</row>',
|
||||
'<row>',
|
||||
'<start-time fmt="00:00.300">300000000</start-time>',
|
||||
'<duration fmt="16.67 ms">16666583</duration>',
|
||||
'<process fmt="OtherApp (5001)"><pid fmt="5001">5001</pid></process>',
|
||||
'<boolean ref="false"/>',
|
||||
'<uint32>3</uint32><string>0x3</string><display-name>Display 1</display-name><string></string>',
|
||||
'</row>',
|
||||
'</node></trace-query-result>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function makeAppleFrameLifetimesXml(count: number): string {
|
||||
return [
|
||||
'<?xml version="1.0"?>',
|
||||
'<trace-query-result><node>',
|
||||
'<schema name="hitches-frame-lifetimes">',
|
||||
'<col><mnemonic>start</mnemonic></col>',
|
||||
'<col><mnemonic>duration</mnemonic></col>',
|
||||
'</schema>',
|
||||
...Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
`<row><start-time>${index * 16_000_000}</start-time><duration>16000000</duration></row>`,
|
||||
),
|
||||
'</node></trace-query-result>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function makeAppleDisplayInfoXml(refreshRateHz: number): string {
|
||||
return [
|
||||
'<?xml version="1.0"?>',
|
||||
'<trace-query-result><node>',
|
||||
'<schema name="device-display-info">',
|
||||
'<col><mnemonic>timestamp</mnemonic></col>',
|
||||
'<col><mnemonic>accelerator-id</mnemonic></col>',
|
||||
'<col><mnemonic>display-id</mnemonic></col>',
|
||||
'<col><mnemonic>device-name</mnemonic></col>',
|
||||
'<col><mnemonic>framebuffer-index</mnemonic></col>',
|
||||
'<col><mnemonic>resolution</mnemonic></col>',
|
||||
'<col><mnemonic>built-in</mnemonic></col>',
|
||||
'<col><mnemonic>max-refresh-rate</mnemonic></col>',
|
||||
'<col><mnemonic>is-main-display</mnemonic></col>',
|
||||
'</schema>',
|
||||
`<row><event-time>0</event-time><uint64>1</uint64><uint64>1</uint64><string>Display</string><uint32>0</uint32><string>390 844</string><boolean>1</boolean><uint32>${refreshRateHz}</uint32><boolean>1</boolean></row>`,
|
||||
'</node></trace-query-result>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { roundOneDecimal, roundPercent } from '../perf-utils.ts';
|
||||
import { parseXmlDocumentSync, type XmlNode } from './xml.ts';
|
||||
import {
|
||||
findAllXmlNodes,
|
||||
findFirstXmlNode,
|
||||
parseDirectXmlNumber,
|
||||
readSchemaColumns,
|
||||
resolveXmlNumber,
|
||||
} from './perf-xml.ts';
|
||||
|
||||
const MAX_WORST_WINDOWS = 3;
|
||||
const JANK_WINDOW_GAP_NS = 500_000_000;
|
||||
|
||||
export const APPLE_FRAME_SAMPLE_METHOD = 'xctrace-animation-hitches';
|
||||
export const APPLE_FRAME_SAMPLE_DESCRIPTION =
|
||||
'Rendered-frame hitch health from xctrace Animation Hitches on connected iOS devices. Dropped frames are counted from native hitch rows for the attached app process, with total frames from the same trace frame-lifetime table.';
|
||||
|
||||
export type AppleFrameDropWindow = {
|
||||
startOffsetMs: number;
|
||||
endOffsetMs: number;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
missedDeadlineFrameCount: number;
|
||||
worstFrameMs: number;
|
||||
};
|
||||
|
||||
export type AppleFramePerfSample = {
|
||||
droppedFramePercent: number;
|
||||
droppedFrameCount: number;
|
||||
totalFrameCount: number;
|
||||
sampleWindowMs: number;
|
||||
windowStartedAt: string;
|
||||
windowEndedAt: string;
|
||||
measuredAt: string;
|
||||
method: typeof APPLE_FRAME_SAMPLE_METHOD;
|
||||
matchedProcesses: string[];
|
||||
frameDeadlineMs?: number;
|
||||
refreshRateHz?: number;
|
||||
worstWindows?: AppleFrameDropWindow[];
|
||||
};
|
||||
|
||||
type AppleFrameHitchRow = {
|
||||
startNs: number;
|
||||
durationNs: number;
|
||||
pid?: number;
|
||||
processName?: string;
|
||||
};
|
||||
|
||||
type AppleHitchSchemaIndexes = {
|
||||
start: number;
|
||||
duration: number;
|
||||
process: number;
|
||||
isSystem: number;
|
||||
};
|
||||
|
||||
type XmlReference = {
|
||||
numberValue?: number | null;
|
||||
process?: { pid?: number; name?: string } | null;
|
||||
};
|
||||
|
||||
export function parseAppleFramePerfSample(options: {
|
||||
hitchesXml: string;
|
||||
frameLifetimesXml: string;
|
||||
displayInfoXml?: string;
|
||||
processIds: number[];
|
||||
processNames: string[];
|
||||
windowStartedAt: string;
|
||||
windowEndedAt: string;
|
||||
measuredAt: string;
|
||||
}): AppleFramePerfSample {
|
||||
const totalFrameCount = parseAppleFrameLifetimeCount(options.frameLifetimesXml);
|
||||
const refreshRateHz = parseAppleDisplayRefreshRate(options.displayInfoXml);
|
||||
const hitches = parseAppleHitchRows(options.hitchesXml).filter((row) =>
|
||||
matchesAppleFrameProcess(row, options.processIds, options.processNames),
|
||||
);
|
||||
const droppedFrameCount = hitches.length;
|
||||
const sampleWindowMs = Math.max(
|
||||
0,
|
||||
Math.round(Date.parse(options.windowEndedAt) - Date.parse(options.windowStartedAt)),
|
||||
);
|
||||
const windowStartedAtMs = Date.parse(options.windowStartedAt);
|
||||
const worstWindows = buildAppleWorstWindows(hitches, windowStartedAtMs);
|
||||
|
||||
return {
|
||||
droppedFramePercent:
|
||||
totalFrameCount > 0 ? roundPercent((droppedFrameCount / totalFrameCount) * 100) : 0,
|
||||
droppedFrameCount,
|
||||
totalFrameCount,
|
||||
sampleWindowMs,
|
||||
windowStartedAt: options.windowStartedAt,
|
||||
windowEndedAt: options.windowEndedAt,
|
||||
measuredAt: options.measuredAt,
|
||||
method: APPLE_FRAME_SAMPLE_METHOD,
|
||||
matchedProcesses: uniqueStrings(
|
||||
hitches
|
||||
.map((row) => row.processName)
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0),
|
||||
),
|
||||
frameDeadlineMs:
|
||||
refreshRateHz === undefined ? undefined : roundOneDecimal(1000 / refreshRateHz),
|
||||
refreshRateHz,
|
||||
worstWindows: worstWindows.length > 0 ? worstWindows : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAppleFrameLifetimeCount(xml: string): number {
|
||||
return parseRows(xml, 'hitches-frame-lifetimes').length;
|
||||
}
|
||||
|
||||
function parseAppleDisplayRefreshRate(xml: string | undefined): number | undefined {
|
||||
if (!xml) return undefined;
|
||||
const { rows, schema } = parseTable(xml, 'device-display-info');
|
||||
const refreshIndex = schema.indexOf('max-refresh-rate');
|
||||
if (refreshIndex < 0) return undefined;
|
||||
const references = new Map<string, XmlReference>();
|
||||
for (const row of rows) {
|
||||
rememberXmlReferences(row.children, references);
|
||||
const refreshRate = resolveXmlNumber(row.children[refreshIndex], references);
|
||||
if (refreshRate !== null && refreshRate > 0) return refreshRate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseAppleHitchRows(xml: string): AppleFrameHitchRow[] {
|
||||
const document = parseXmlDocumentSync(xml);
|
||||
const indexes = readAppleHitchSchemaIndexes(document);
|
||||
if (!indexes) return [];
|
||||
const references = new Map<string, XmlReference>();
|
||||
return findAllXmlNodes(document, (node) => node.name === 'row')
|
||||
.map((row) => readAppleHitchRow(row, indexes, references))
|
||||
.filter((row): row is AppleFrameHitchRow => Boolean(row));
|
||||
}
|
||||
|
||||
function readAppleHitchSchemaIndexes(document: XmlNode[]): AppleHitchSchemaIndexes | null {
|
||||
const schema = readSchemaColumns(document, 'hitches');
|
||||
const indexes = {
|
||||
start: schema.indexOf('start'),
|
||||
duration: schema.indexOf('duration'),
|
||||
process: schema.indexOf('process'),
|
||||
isSystem: schema.indexOf('is-system'),
|
||||
};
|
||||
return Object.values(indexes).every((index) => index >= 0) ? indexes : null;
|
||||
}
|
||||
|
||||
function readAppleHitchRow(
|
||||
row: XmlNode,
|
||||
indexes: AppleHitchSchemaIndexes,
|
||||
references: Map<string, XmlReference>,
|
||||
): AppleFrameHitchRow | null {
|
||||
rememberXmlReferences(row.children, references);
|
||||
if (resolveXmlBoolean(row.children[indexes.isSystem], references) === true) return null;
|
||||
const startNs = resolveXmlNumber(row.children[indexes.start], references);
|
||||
const durationNs = resolveXmlNumber(row.children[indexes.duration], references);
|
||||
if (startNs === null || durationNs === null) return null;
|
||||
const process = resolveXmlProcess(row.children[indexes.process], references);
|
||||
return { startNs, durationNs, pid: process?.pid, processName: process?.name };
|
||||
}
|
||||
|
||||
function matchesAppleFrameProcess(
|
||||
row: AppleFrameHitchRow,
|
||||
processIds: number[],
|
||||
processNames: string[],
|
||||
): boolean {
|
||||
if (row.pid !== undefined && processIds.includes(row.pid)) return true;
|
||||
if (!row.processName) return false;
|
||||
return processNames.includes(row.processName);
|
||||
}
|
||||
|
||||
function buildAppleWorstWindows(
|
||||
hitches: AppleFrameHitchRow[],
|
||||
windowStartedAtMs: number,
|
||||
): AppleFrameDropWindow[] {
|
||||
if (hitches.length === 0) return [];
|
||||
const sorted = [...hitches].sort((left, right) => left.startNs - right.startNs);
|
||||
const windows: AppleFrameHitchRow[][] = [];
|
||||
let current: AppleFrameHitchRow[] = [];
|
||||
for (const hitch of sorted) {
|
||||
const previous = current.at(-1);
|
||||
if (
|
||||
!previous ||
|
||||
hitch.startNs - (previous.startNs + previous.durationNs) <= JANK_WINDOW_GAP_NS
|
||||
) {
|
||||
current.push(hitch);
|
||||
continue;
|
||||
}
|
||||
windows.push(current);
|
||||
current = [hitch];
|
||||
}
|
||||
if (current.length > 0) windows.push(current);
|
||||
|
||||
return windows
|
||||
.map((rows) => buildAppleWorstWindow(rows, windowStartedAtMs))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.missedDeadlineFrameCount - left.missedDeadlineFrameCount ||
|
||||
right.worstFrameMs - left.worstFrameMs,
|
||||
)
|
||||
.slice(0, MAX_WORST_WINDOWS)
|
||||
.sort((left, right) => left.startOffsetMs - right.startOffsetMs);
|
||||
}
|
||||
|
||||
function buildAppleWorstWindow(
|
||||
hitches: AppleFrameHitchRow[],
|
||||
windowStartedAtMs: number,
|
||||
): AppleFrameDropWindow {
|
||||
const startNs = Math.min(...hitches.map((hitch) => hitch.startNs));
|
||||
const endNs = Math.max(...hitches.map((hitch) => hitch.startNs + hitch.durationNs));
|
||||
const startOffsetMs = Math.max(0, Math.round(startNs / 1_000_000));
|
||||
const endOffsetMs = Math.max(startOffsetMs, Math.round(endNs / 1_000_000));
|
||||
return {
|
||||
startOffsetMs,
|
||||
endOffsetMs,
|
||||
startAt: new Date(windowStartedAtMs + startOffsetMs).toISOString(),
|
||||
endAt: new Date(windowStartedAtMs + endOffsetMs).toISOString(),
|
||||
missedDeadlineFrameCount: hitches.length,
|
||||
worstFrameMs: roundOneDecimal(
|
||||
Math.max(...hitches.map((hitch) => hitch.durationNs)) / 1_000_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRows(xml: string, schemaName: string): XmlNode[] {
|
||||
return parseTable(xml, schemaName).rows;
|
||||
}
|
||||
|
||||
function parseTable(xml: string, schemaName: string): { rows: XmlNode[]; schema: string[] } {
|
||||
const document = parseXmlDocumentSync(xml);
|
||||
const schema = readSchemaColumns(document, schemaName);
|
||||
return {
|
||||
rows: schema.length === 0 ? [] : findAllXmlNodes(document, (node) => node.name === 'row'),
|
||||
schema,
|
||||
};
|
||||
}
|
||||
|
||||
function rememberXmlReferences(elements: XmlNode[], references: Map<string, XmlReference>): void {
|
||||
for (const element of elements) {
|
||||
rememberXmlReferences(element.children, references);
|
||||
if (!element.attributes.id) continue;
|
||||
references.set(element.attributes.id, {
|
||||
numberValue: parseDirectXmlNumber(element),
|
||||
process: readDirectProcess(element),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveXmlBoolean(
|
||||
element: XmlNode | undefined,
|
||||
references: Map<string, XmlReference>,
|
||||
): boolean | null {
|
||||
const value = resolveXmlNumber(element, references);
|
||||
if (value === null) return null;
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
function resolveXmlProcess(
|
||||
element: XmlNode | undefined,
|
||||
references: Map<string, XmlReference>,
|
||||
): { pid?: number; name?: string } | null {
|
||||
if (!element) return null;
|
||||
if (element.attributes.ref) return references.get(element.attributes.ref)?.process ?? null;
|
||||
return readDirectProcess(element);
|
||||
}
|
||||
|
||||
function readDirectProcess(element: XmlNode | undefined): { pid?: number; name?: string } | null {
|
||||
if (!element || element.children.some((child) => child.name === 'sentinel')) return null;
|
||||
const pidNode = findFirstXmlNode(element.children, (child) => child.name === 'pid');
|
||||
const pid = parseDirectXmlNumber(pidNode);
|
||||
const name = (element.attributes.fmt ?? '').replace(/\s+\(\d+\)$/, '').trim();
|
||||
if (pid === null && name.length === 0) return null;
|
||||
return {
|
||||
pid: pid ?? undefined,
|
||||
name: name.length > 0 ? name : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { XmlNode } from './xml.ts';
|
||||
|
||||
export function findFirstXmlNode(
|
||||
nodes: XmlNode[],
|
||||
predicate: (node: XmlNode) => boolean,
|
||||
): XmlNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (predicate(node)) return node;
|
||||
const descendant = findFirstXmlNode(node.children, predicate);
|
||||
if (descendant) return descendant;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findAllXmlNodes(
|
||||
nodes: XmlNode[],
|
||||
predicate: (node: XmlNode) => boolean,
|
||||
): XmlNode[] {
|
||||
const matches: XmlNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (predicate(node)) matches.push(node);
|
||||
matches.push(...findAllXmlNodes(node.children, predicate));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function readFirstChildText(node: XmlNode, childName: string): string | null {
|
||||
const child = node.children.find((candidate) => candidate.name === childName);
|
||||
return child?.text ?? null;
|
||||
}
|
||||
|
||||
export function readSchemaColumns(document: XmlNode[], schemaName: string): string[] {
|
||||
const schema = findFirstXmlNode(
|
||||
document,
|
||||
(node) => node.name === 'schema' && node.attributes.name === schemaName,
|
||||
);
|
||||
if (!schema) return [];
|
||||
return schema.children
|
||||
.filter((child) => child.name === 'col')
|
||||
.map((column) => readFirstChildText(column, 'mnemonic') ?? '');
|
||||
}
|
||||
|
||||
export function parseDirectXmlNumber(element: XmlNode | undefined): number | null {
|
||||
if (!element || element.children.some((child) => child.name === 'sentinel')) return null;
|
||||
if (!element.text) return null;
|
||||
const value = Number(element.text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function resolveXmlNumber(
|
||||
element: XmlNode | undefined,
|
||||
references: Map<string, { numberValue?: number | null }>,
|
||||
): number | null {
|
||||
if (!element) return null;
|
||||
if (element.attributes.ref) return references.get(element.attributes.ref)?.numberValue ?? null;
|
||||
return parseDirectXmlNumber(element);
|
||||
}
|
||||
+321
-128
@@ -17,6 +17,19 @@ import {
|
||||
import { readInfoPlistString } from './plist.ts';
|
||||
import { buildSimctlArgsForDevice } from './simctl.ts';
|
||||
import { parseXmlDocumentSync, type XmlNode } from './xml.ts';
|
||||
import {
|
||||
findAllXmlNodes,
|
||||
findFirstXmlNode,
|
||||
parseDirectXmlNumber,
|
||||
readSchemaColumns,
|
||||
resolveXmlNumber,
|
||||
} from './perf-xml.ts';
|
||||
import {
|
||||
APPLE_FRAME_SAMPLE_DESCRIPTION,
|
||||
APPLE_FRAME_SAMPLE_METHOD,
|
||||
parseAppleFramePerfSample,
|
||||
type AppleFramePerfSample,
|
||||
} from './perf-frame.ts';
|
||||
|
||||
const APPLE_CPU_SAMPLE_METHOD = 'ps-process-snapshot';
|
||||
const APPLE_MEMORY_SAMPLE_METHOD = 'ps-process-snapshot';
|
||||
@@ -28,6 +41,9 @@ const APPLE_PERF_TIMEOUT_MS = 15_000;
|
||||
const IOS_DEVICE_PERF_RECORD_TIMEOUT_MS = 60_000;
|
||||
const IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS = 15_000;
|
||||
const IOS_DEVICE_PERF_TRACE_DURATION = '1s';
|
||||
const IOS_DEVICE_FRAME_TRACE_DURATION = '2s';
|
||||
const IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS = 3;
|
||||
const IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS = 1_500;
|
||||
|
||||
export type AppleCpuPerfSample = {
|
||||
usagePercent: number;
|
||||
@@ -62,6 +78,24 @@ type IosDevicePerfCapture = {
|
||||
xml: string;
|
||||
};
|
||||
|
||||
type IosDeviceFramePerfCapture = {
|
||||
windowStartedAt: string;
|
||||
windowEndedAt: string;
|
||||
hitchesXml: string;
|
||||
frameLifetimesXml: string;
|
||||
displayInfoXml?: string;
|
||||
};
|
||||
|
||||
type IosDeviceTraceRecord = {
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
capturedAtMs: number;
|
||||
};
|
||||
|
||||
type IosDeviceTraceRecordAttempt = IosDeviceTraceRecord & {
|
||||
result: Awaited<ReturnType<typeof runCmd>>;
|
||||
};
|
||||
|
||||
export async function sampleApplePerfMetrics(
|
||||
device: DeviceInfo,
|
||||
appBundleId: string,
|
||||
@@ -80,22 +114,69 @@ export async function sampleApplePerfMetrics(
|
||||
}
|
||||
|
||||
const measuredAt = new Date().toISOString();
|
||||
const matchedProcesses = uniqueStrings(
|
||||
processes.map((process) => path.basename(readProcessCommandToken(process.command))),
|
||||
);
|
||||
return buildApplePerfSamples({
|
||||
usagePercent: processes.reduce((total, process) => total + process.cpuPercent, 0),
|
||||
residentMemoryKb: processes.reduce((total, process) => total + process.rssKb, 0),
|
||||
measuredAt,
|
||||
matchedProcesses,
|
||||
matchedProcesses: [executable.executableName],
|
||||
cpuMethod: APPLE_CPU_SAMPLE_METHOD,
|
||||
memoryMethod: APPLE_MEMORY_SAMPLE_METHOD,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sampleAppleFramePerf(
|
||||
device: DeviceInfo,
|
||||
appBundleId: string,
|
||||
): Promise<AppleFramePerfSample> {
|
||||
if (device.platform !== 'ios' || device.kind !== 'device') {
|
||||
throw new AppError(
|
||||
'COMMAND_FAILED',
|
||||
'Apple frame-health sampling is currently available only on connected iOS devices.',
|
||||
{
|
||||
metric: 'fps',
|
||||
platform: device.platform,
|
||||
deviceKind: device.kind,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const processes = await resolveIosDevicePerfTarget(device, appBundleId);
|
||||
const capture = await captureIosDeviceFramePerf(device, appBundleId, processes);
|
||||
return parseAppleFramePerfSample({
|
||||
hitchesXml: capture.hitchesXml,
|
||||
frameLifetimesXml: capture.frameLifetimesXml,
|
||||
displayInfoXml: capture.displayInfoXml,
|
||||
processIds: processes.map((process) => process.pid),
|
||||
processNames: uniqueStrings(
|
||||
processes.map((process) => path.basename(fileURLToPath(process.executable))),
|
||||
),
|
||||
windowStartedAt: capture.windowStartedAt,
|
||||
windowEndedAt: capture.windowEndedAt,
|
||||
measuredAt: capture.windowEndedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAppleSamplingMetadata(device: DeviceInfo): Record<string, unknown> {
|
||||
const fps =
|
||||
device.platform === 'ios' && device.kind === 'device'
|
||||
? {
|
||||
method: APPLE_FRAME_SAMPLE_METHOD,
|
||||
description: APPLE_FRAME_SAMPLE_DESCRIPTION,
|
||||
unit: 'percent',
|
||||
primaryField: 'droppedFramePercent',
|
||||
window: `short ${IOS_DEVICE_FRAME_TRACE_DURATION} xctrace Animation Hitches record of the active app process`,
|
||||
resetsAfterRead: false,
|
||||
}
|
||||
: {
|
||||
method: APPLE_FRAME_SAMPLE_METHOD,
|
||||
description:
|
||||
'Unavailable on iOS simulators and macOS because local Apple tooling does not expose reliable app frame hitches for these targets.',
|
||||
unit: 'percent',
|
||||
primaryField: 'droppedFramePercent',
|
||||
};
|
||||
if (device.platform === 'ios' && device.kind === 'device') {
|
||||
return {
|
||||
fps,
|
||||
memory: {
|
||||
method: IOS_DEVICE_MEMORY_SAMPLE_METHOD,
|
||||
description:
|
||||
@@ -116,6 +197,7 @@ export function buildAppleSamplingMetadata(device: DeviceInfo): Record<string, u
|
||||
? 'host ps for the running macOS app executable resolved from the bundle ID.'
|
||||
: 'xcrun simctl spawn ps for the running iOS simulator app executable resolved from the bundle ID.';
|
||||
return {
|
||||
fps,
|
||||
memory: {
|
||||
method: APPLE_MEMORY_SAMPLE_METHOD,
|
||||
description: `Resident memory snapshot from ${source}`,
|
||||
@@ -129,6 +211,222 @@ export function buildAppleSamplingMetadata(device: DeviceInfo): Record<string, u
|
||||
};
|
||||
}
|
||||
|
||||
async function captureIosDeviceFramePerf(
|
||||
device: DeviceInfo,
|
||||
appBundleId: string,
|
||||
processes: IosDeviceProcessInfo[],
|
||||
): Promise<IosDeviceFramePerfCapture> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-frame-perf-'));
|
||||
const tracePath = path.join(tempDir, 'animation-hitches.trace');
|
||||
const hitchesPath = path.join(tempDir, 'hitches.xml');
|
||||
const frameLifetimesPath = path.join(tempDir, 'frame-lifetimes.xml');
|
||||
const displayInfoPath = path.join(tempDir, 'display-info.xml');
|
||||
try {
|
||||
const record = await recordIosDeviceTrace({
|
||||
device,
|
||||
appBundleId,
|
||||
tracePath,
|
||||
template: 'Animation Hitches',
|
||||
duration: IOS_DEVICE_FRAME_TRACE_DURATION,
|
||||
targetPids: processes.map((process) => process.pid),
|
||||
validateTraceOutput: true,
|
||||
failureMessage: `Failed to record iOS frame-health sample for ${appBundleId}`,
|
||||
});
|
||||
await exportIosDevicePerfTable(device, appBundleId, tracePath, 'hitches', hitchesPath);
|
||||
await exportIosDevicePerfTable(
|
||||
device,
|
||||
appBundleId,
|
||||
tracePath,
|
||||
'hitches-frame-lifetimes',
|
||||
frameLifetimesPath,
|
||||
);
|
||||
const hasDisplayInfo = await exportOptionalIosDevicePerfTable(
|
||||
device,
|
||||
appBundleId,
|
||||
tracePath,
|
||||
'device-display-info',
|
||||
displayInfoPath,
|
||||
);
|
||||
return {
|
||||
windowStartedAt: record.startedAt,
|
||||
windowEndedAt: record.endedAt,
|
||||
hitchesXml: await fs.readFile(hitchesPath, 'utf8'),
|
||||
frameLifetimesXml: await fs.readFile(frameLifetimesPath, 'utf8'),
|
||||
displayInfoXml: hasDisplayInfo ? await fs.readFile(displayInfoPath, 'utf8') : undefined,
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function recordIosDeviceTrace(params: {
|
||||
device: DeviceInfo;
|
||||
appBundleId: string;
|
||||
tracePath: string;
|
||||
template: 'Activity Monitor' | 'Animation Hitches';
|
||||
duration: string;
|
||||
targetPids?: number[];
|
||||
allProcesses?: boolean;
|
||||
validateTraceOutput?: boolean;
|
||||
failureMessage: string;
|
||||
}): Promise<IosDeviceTraceRecord> {
|
||||
const { device, appBundleId, tracePath, template, duration } = params;
|
||||
const targetArgs = params.allProcesses
|
||||
? ['--all-processes']
|
||||
: (params.targetPids ?? []).flatMap((pid) => ['--attach', String(pid)]);
|
||||
const recordArgs = [
|
||||
'xctrace',
|
||||
'record',
|
||||
'--template',
|
||||
template,
|
||||
'--device',
|
||||
device.id,
|
||||
...targetArgs,
|
||||
'--time-limit',
|
||||
duration,
|
||||
'--output',
|
||||
tracePath,
|
||||
'--quiet',
|
||||
'--no-prompt',
|
||||
];
|
||||
const record = await runIosDeviceTraceRecord(recordArgs, params.tracePath);
|
||||
if (record.result.exitCode === 0) {
|
||||
if (params.validateTraceOutput) {
|
||||
await assertUsableTraceOutput(params, record.result.stdout, record.result.stderr);
|
||||
}
|
||||
return {
|
||||
startedAt: record.startedAt,
|
||||
endedAt: record.endedAt,
|
||||
capturedAtMs: record.capturedAtMs,
|
||||
};
|
||||
}
|
||||
throw new AppError('COMMAND_FAILED', params.failureMessage, {
|
||||
cmd: 'xcrun',
|
||||
args: recordArgs,
|
||||
exitCode: record.result.exitCode,
|
||||
stdout: record.result.stdout,
|
||||
stderr: record.result.stderr,
|
||||
appBundleId,
|
||||
deviceId: device.id,
|
||||
hint: resolveIosDevicePerfHint(record.result.stdout, record.result.stderr),
|
||||
});
|
||||
}
|
||||
|
||||
async function runIosDeviceTraceRecord(
|
||||
recordArgs: string[],
|
||||
tracePath: string,
|
||||
): Promise<IosDeviceTraceRecordAttempt> {
|
||||
let lastAttempt: IosDeviceTraceRecordAttempt | undefined;
|
||||
for (let attempt = 1; attempt <= IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (attempt > 1) {
|
||||
await fs.rm(tracePath, { recursive: true, force: true }).catch(() => {});
|
||||
await new Promise((resolve) => setTimeout(resolve, IOS_DEVICE_TRACE_RECORD_RETRY_DELAY_MS));
|
||||
}
|
||||
const startedAt = new Date().toISOString();
|
||||
const result = await runCmd('xcrun', recordArgs, {
|
||||
allowFailure: true,
|
||||
timeoutMs: IOS_DEVICE_PERF_RECORD_TIMEOUT_MS,
|
||||
});
|
||||
lastAttempt = {
|
||||
result,
|
||||
startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
capturedAtMs: Date.now(),
|
||||
};
|
||||
if (result.exitCode === 0 || !isRetryableIosDeviceTraceRecordFailure(result)) {
|
||||
return lastAttempt;
|
||||
}
|
||||
}
|
||||
return lastAttempt as IosDeviceTraceRecordAttempt;
|
||||
}
|
||||
|
||||
function isRetryableIosDeviceTraceRecordFailure(result: {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): boolean {
|
||||
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
||||
return (
|
||||
text.includes('_lockkperf') ||
|
||||
text.includes('could not lock kperf') ||
|
||||
text.includes('likely another session just started')
|
||||
);
|
||||
}
|
||||
|
||||
async function assertUsableTraceOutput(
|
||||
params: {
|
||||
device: DeviceInfo;
|
||||
appBundleId: string;
|
||||
tracePath: string;
|
||||
failureMessage: string;
|
||||
},
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): Promise<void> {
|
||||
const stat = await fs.stat(params.tracePath).catch(() => null);
|
||||
const hasTrace =
|
||||
stat?.isDirectory() === true
|
||||
? (await fs.readdir(params.tracePath).catch(() => [])).length > 0
|
||||
: (stat?.size ?? 0) > 0;
|
||||
if (hasTrace) return;
|
||||
throw new AppError('COMMAND_FAILED', `${params.failureMessage}: xctrace produced no trace data`, {
|
||||
tracePath: params.tracePath,
|
||||
appBundleId: params.appBundleId,
|
||||
deviceId: params.device.id,
|
||||
stdout,
|
||||
stderr,
|
||||
hint: 'Keep the iOS device unlocked and connected by cable, keep the app active, then retry perf.',
|
||||
});
|
||||
}
|
||||
|
||||
async function exportIosDevicePerfTable(
|
||||
device: DeviceInfo,
|
||||
appBundleId: string,
|
||||
tracePath: string,
|
||||
schema: string,
|
||||
outputPath: string,
|
||||
): Promise<void> {
|
||||
const exportArgs = [
|
||||
'xctrace',
|
||||
'export',
|
||||
'--input',
|
||||
tracePath,
|
||||
'--xpath',
|
||||
`/trace-toc/run/data/table[@schema="${schema}"]`,
|
||||
'--output',
|
||||
outputPath,
|
||||
];
|
||||
const exportResult = await runCmd('xcrun', exportArgs, {
|
||||
allowFailure: true,
|
||||
timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS,
|
||||
});
|
||||
if (exportResult.exitCode === 0) return;
|
||||
throw new AppError('COMMAND_FAILED', `Failed to export iOS device ${schema} data`, {
|
||||
cmd: 'xcrun',
|
||||
args: exportArgs,
|
||||
exitCode: exportResult.exitCode,
|
||||
stdout: exportResult.stdout,
|
||||
stderr: exportResult.stderr,
|
||||
appBundleId,
|
||||
deviceId: device.id,
|
||||
hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr),
|
||||
});
|
||||
}
|
||||
|
||||
async function exportOptionalIosDevicePerfTable(
|
||||
device: DeviceInfo,
|
||||
appBundleId: string,
|
||||
tracePath: string,
|
||||
schema: string,
|
||||
outputPath: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await exportIosDevicePerfTable(device, appBundleId, tracePath, schema, outputPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseApplePsOutput(stdout: string): AppleProcessSample[] {
|
||||
const rows: AppleProcessSample[] = [];
|
||||
for (const rawLine of stdout.split('\n')) {
|
||||
@@ -150,19 +448,13 @@ export function parseApplePsOutput(stdout: string): AppleProcessSample[] {
|
||||
|
||||
async function parseIosDevicePerfTable(xml: string): Promise<IosDevicePerfProcessSample[]> {
|
||||
const document = parseXmlDocumentSync(xml);
|
||||
const schema = findFirstXmlNode(
|
||||
document,
|
||||
(node) => node.name === 'schema' && node.attributes.name === 'activity-monitor-process-live',
|
||||
);
|
||||
if (!schema) {
|
||||
const mnemonics = readSchemaColumns(document, 'activity-monitor-process-live');
|
||||
if (mnemonics.length === 0) {
|
||||
throw new AppError(
|
||||
'COMMAND_FAILED',
|
||||
'Failed to parse xctrace activity-monitor-process-live schema',
|
||||
);
|
||||
}
|
||||
const mnemonics = schema.children
|
||||
.filter((child) => child.name === 'col')
|
||||
.map((column) => readFirstChildText(column, 'mnemonic') ?? '');
|
||||
const pidIndex = mnemonics.indexOf('pid');
|
||||
const processIndex = mnemonics.indexOf('process');
|
||||
const cpuTimeIndex = mnemonics.indexOf('cpu-total');
|
||||
@@ -242,7 +534,7 @@ async function resolveAppleExecutable(
|
||||
executablePath:
|
||||
device.platform === 'macos'
|
||||
? path.join(appPath, 'Contents', 'MacOS', executableName)
|
||||
: undefined,
|
||||
: path.join(appPath, executableName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,75 +638,24 @@ async function captureIosDevicePerfTable(
|
||||
const tracePath = path.join(tempDir, 'sample.trace');
|
||||
const exportPath = path.join(tempDir, 'activity-monitor-process-live.xml');
|
||||
try {
|
||||
const recordArgs = [
|
||||
'xctrace',
|
||||
'record',
|
||||
'--template',
|
||||
'Activity Monitor',
|
||||
'--device',
|
||||
device.id,
|
||||
'--all-processes',
|
||||
'--time-limit',
|
||||
IOS_DEVICE_PERF_TRACE_DURATION,
|
||||
'--output',
|
||||
const record = await recordIosDeviceTrace({
|
||||
device,
|
||||
appBundleId,
|
||||
tracePath,
|
||||
'--quiet',
|
||||
'--no-prompt',
|
||||
];
|
||||
const recordResult = await runCmd('xcrun', recordArgs, {
|
||||
allowFailure: true,
|
||||
timeoutMs: IOS_DEVICE_PERF_RECORD_TIMEOUT_MS,
|
||||
template: 'Activity Monitor',
|
||||
duration: IOS_DEVICE_PERF_TRACE_DURATION,
|
||||
allProcesses: true,
|
||||
failureMessage: `Failed to record iOS device Activity Monitor sample for ${appBundleId}`,
|
||||
});
|
||||
const capturedAtMs = Date.now();
|
||||
if (recordResult.exitCode !== 0) {
|
||||
throw new AppError(
|
||||
'COMMAND_FAILED',
|
||||
`Failed to record iOS device Activity Monitor sample for ${appBundleId}`,
|
||||
{
|
||||
cmd: 'xcrun',
|
||||
args: recordArgs,
|
||||
exitCode: recordResult.exitCode,
|
||||
stdout: recordResult.stdout,
|
||||
stderr: recordResult.stderr,
|
||||
appBundleId,
|
||||
deviceId: device.id,
|
||||
hint: resolveIosDevicePerfHint(recordResult.stdout, recordResult.stderr),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const exportArgs = [
|
||||
'xctrace',
|
||||
'export',
|
||||
'--input',
|
||||
await exportIosDevicePerfTable(
|
||||
device,
|
||||
appBundleId,
|
||||
tracePath,
|
||||
'--xpath',
|
||||
'/trace-toc/run/data/table[@schema="activity-monitor-process-live"]',
|
||||
'--output',
|
||||
'activity-monitor-process-live',
|
||||
exportPath,
|
||||
];
|
||||
const exportResult = await runCmd('xcrun', exportArgs, {
|
||||
allowFailure: true,
|
||||
timeoutMs: IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS,
|
||||
});
|
||||
if (exportResult.exitCode !== 0) {
|
||||
throw new AppError(
|
||||
'COMMAND_FAILED',
|
||||
`Failed to export iOS device perf sample for ${appBundleId}`,
|
||||
{
|
||||
cmd: 'xcrun',
|
||||
args: exportArgs,
|
||||
exitCode: exportResult.exitCode,
|
||||
stdout: exportResult.stdout,
|
||||
stderr: exportResult.stderr,
|
||||
appBundleId,
|
||||
deviceId: device.id,
|
||||
hint: resolveIosDevicePerfHint(exportResult.stdout, exportResult.stderr),
|
||||
},
|
||||
);
|
||||
}
|
||||
);
|
||||
return {
|
||||
capturedAtMs,
|
||||
capturedAtMs: record.capturedAtMs,
|
||||
xml: await fs.readFile(exportPath, 'utf8'),
|
||||
};
|
||||
} finally {
|
||||
@@ -579,7 +820,9 @@ function matchesAppleExecutableProcess(
|
||||
const token = readProcessCommandToken(command);
|
||||
if (
|
||||
executable.executablePath &&
|
||||
(token === executable.executablePath || command.startsWith(`${executable.executablePath} `))
|
||||
(command === executable.executablePath ||
|
||||
token === executable.executablePath ||
|
||||
command.startsWith(`${executable.executablePath} `))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -615,56 +858,6 @@ function buildApplePerfSamples(args: {
|
||||
};
|
||||
}
|
||||
|
||||
function findFirstXmlNode(
|
||||
nodes: XmlNode[],
|
||||
predicate: (node: XmlNode) => boolean,
|
||||
): XmlNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (predicate(node)) {
|
||||
return node;
|
||||
}
|
||||
const descendant = findFirstXmlNode(node.children, predicate);
|
||||
if (descendant) {
|
||||
return descendant;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findAllXmlNodes(nodes: XmlNode[], predicate: (node: XmlNode) => boolean): XmlNode[] {
|
||||
const matches: XmlNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (predicate(node)) {
|
||||
matches.push(node);
|
||||
}
|
||||
matches.push(...findAllXmlNodes(node.children, predicate));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function readFirstChildText(node: XmlNode, childName: string): string | null {
|
||||
const child = node.children.find((candidate) => candidate.name === childName);
|
||||
return child?.text ?? null;
|
||||
}
|
||||
|
||||
function parseDirectXmlNumber(element: XmlNode | undefined): number | null {
|
||||
if (!element || element.children.some((child) => child.name === 'sentinel')) return null;
|
||||
if (!element.text) return null;
|
||||
const value = Number(element.text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function resolveXmlNumber(
|
||||
element: XmlNode | undefined,
|
||||
references: Map<string, { numberValue?: number | null }>,
|
||||
): number | null {
|
||||
if (!element) return null;
|
||||
if (element.attributes.ref) {
|
||||
return references.get(element.attributes.ref)?.numberValue ?? null;
|
||||
}
|
||||
return parseDirectXmlNumber(element);
|
||||
}
|
||||
|
||||
function readDirectProcessNameFromXml(element: XmlNode | undefined): string | null {
|
||||
const fmt = element?.attributes.fmt?.trim() ?? '';
|
||||
if (!fmt) return null;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function roundPercent(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function roundOneDecimal(value: number): number {
|
||||
return roundPercent(value);
|
||||
}
|
||||
|
||||
@@ -1612,7 +1612,8 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
|
||||
allowedFlags: [],
|
||||
},
|
||||
perf: {
|
||||
helpDescription: 'Show session performance metrics, including Android frame health',
|
||||
helpDescription:
|
||||
'Show session performance metrics, including frame health on Android and iOS devices',
|
||||
summary: 'Show performance metrics',
|
||||
positionalArgs: [],
|
||||
allowedFlags: [],
|
||||
|
||||
@@ -261,7 +261,7 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti
|
||||
- `client.recording.record()` and `client.recording.trace()`
|
||||
- `client.settings.update()`
|
||||
|
||||
`client.observability.perf()` returns daemon-shaped JSON so local and remote transports expose the same metrics payload. On Android, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. It is derived from the current `adb shell dumpsys gfxinfo <package> framestats` window and represents rendered frames that missed Android's frame deadline, not recording FPS. Android frame samples also include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful read resets Android frame stats; `open <app>` resets the Android frame window too, so agents can call `perf`, perform a transition or gesture, then call `perf` again to inspect that focused window.
|
||||
`client.observability.perf()` returns daemon-shaped JSON so local and remote transports expose the same metrics payload. On Android and supported Apple targets, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. Android derives it from the current `adb shell dumpsys gfxinfo <package> framestats` window; connected iOS devices derive it from `xcrun xctrace` Animation Hitches for the active app process. Frame samples include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful Android read resets Android frame stats; `open <app>` resets the Android frame window too, so agents can call `perf`, perform a transition or gesture, then call `perf` again to inspect that focused window. iOS simulator and macOS app sessions report frame health as unavailable rather than inventing FPS or dropped-frame values.
|
||||
|
||||
`client.recording.record({ action: 'start', path, quality: 5 })` starts a smaller 50% resolution video; omit `quality` to keep native/current resolution.
|
||||
|
||||
|
||||
@@ -549,23 +549,24 @@ agent-device metrics --json
|
||||
```
|
||||
|
||||
- `perf` (alias: `metrics`) returns a session-scoped metrics JSON blob.
|
||||
- Without `--json`, `perf` prints a compact summary: Android frame health when frame data is available, otherwise CPU/memory when those samples are available.
|
||||
- Without `--json`, `perf` prints a compact summary: frame health when reliable frame data is available, otherwise CPU/memory when those samples are available.
|
||||
- `startup` is sampled from `open-command-roundtrip`: elapsed wall-clock time around each `open` command dispatch for the active session app target.
|
||||
- Android app sessions with an active package also sample:
|
||||
- `fps` frame health from `adb shell dumpsys gfxinfo <package> framestats`, with `droppedFramePercent` as the primary value and `worstWindows` for dropped-frame clusters
|
||||
- `memory` from `adb shell dumpsys meminfo <package>` with values reported in kilobytes (`kB`)
|
||||
- `cpu` from `adb shell dumpsys cpuinfo`, aggregated across matching package processes and reported as a recent percentage snapshot
|
||||
- Apple app sessions with an active bundle ID also sample:
|
||||
- `fps` frame health from `xcrun xctrace` Animation Hitches on connected iOS devices, with `droppedFramePercent` as the primary value and `worstWindows` for hitch clusters
|
||||
- `memory` from process RSS snapshots reported in kilobytes (`kB`)
|
||||
- `cpu` from process CPU usage snapshots reported as a recent percentage
|
||||
- Platform support:
|
||||
- `startup`: iOS simulator, iOS physical device, Android emulator/device
|
||||
- `memory` and `cpu`: Android emulator/device, macOS app sessions, iOS simulators with an active app session (`open <app>` first), and iOS physical devices with an active app session
|
||||
- `fps`: Android emulator/device app sessions
|
||||
- `fps`: Android emulator/device app sessions and connected iOS device app sessions. iOS simulator and macOS frame health is reported unavailable because Apple tooling does not expose trustworthy app hitch data there.
|
||||
- If no startup sample exists yet for the session, run `open <app|url>` first and retry `perf`.
|
||||
- Android URL/deep-link opens infer the foreground package after launch when possible, including Expo Go/dev-client shells. If the session still has no app package/bundle ID, package-bound metrics remain unavailable until you `open <app>`.
|
||||
- Android frame health is reset after each successful `perf` read and after `open <app>`, so run `perf`, perform the interaction, then run `perf` again for a focused window.
|
||||
- On physical iOS devices, `perf` records a short `xcrun xctrace` Activity Monitor sample. Keep the device unlocked, connected, and the app active in the foreground while sampling.
|
||||
- On physical iOS devices, `perf` records short `xcrun xctrace` Activity Monitor and Animation Hitches samples. Keep the device unlocked, connected, and the app active in the foreground while sampling.
|
||||
- Interpretation note: this startup metric is command round-trip timing and does not represent true first frame / first interactive app instrumentation.
|
||||
- CPU data is a lightweight process snapshot, so an idle app may legitimately read as `0`.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ For agent-oriented operating guidance, start with `agent-device help` or `agent-
|
||||
- iOS/tvOS simulator-only: `settings`, `push`, `clipboard`.
|
||||
- Apple simulators and macOS desktop app sessions: `alert`, `pinch`.
|
||||
- Session diagnostics: `logs` and `network dump` are available for debugging active app sessions, with network inspection based on recent HTTP(s) entries captured in the session app log.
|
||||
- Session performance metrics: `perf`/`metrics` is available on iOS, macOS, and Android. Startup timing comes from `open` command round-trip duration. Android app sessions expose CPU, memory, and rendered-frame health; use `metrics.fps.droppedFramePercent` as the primary Android frame-smoothness signal, and `metrics.fps.worstWindows` to correlate jank clusters with logs or recent interactions. Apple app sessions on macOS, iOS simulators, or connected iOS devices expose CPU and memory snapshots when an app identifier is available in the session. Android dropped-frame data comes from the current `dumpsys gfxinfo ... framestats` window, is reset after each successful `perf` read, and is not video recording FPS.
|
||||
- Session performance metrics: `perf`/`metrics` is available on iOS, macOS, and Android. Startup timing comes from `open` command round-trip duration. Android app sessions expose CPU, memory, and rendered-frame health; connected iOS device app sessions expose CPU, memory, and `xctrace` Animation Hitches frame health. Use `metrics.fps.droppedFramePercent` as the primary frame-smoothness signal, and `metrics.fps.worstWindows` to correlate jank clusters with logs or recent interactions. Apple app sessions on macOS or iOS simulators expose CPU and memory snapshots when an app identifier is available, but report frame health unavailable. Android dropped-frame data comes from the current `dumpsys gfxinfo ... framestats` window, is reset after each successful `perf` read, and is not video recording FPS.
|
||||
- iOS `record` supports simulators and physical devices.
|
||||
- Simulators use native `simctl io ... recordVideo`.
|
||||
- Physical devices use runner screenshot capture (`XCUIScreen.main.screenshot()` frames) stitched into MP4, so FPS is best-effort (not guaranteed 60 even with `--fps 60`).
|
||||
|
||||
Reference in New Issue
Block a user