Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/build-tools/src/builders/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,16 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await parseAndReportXcactivitylog({
const { skipped } = await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
if (skipped) {
ctx.markBuildPhaseSkipped();
}
} catch (err: any) {
Sentry.capture('Failed to parse xcactivitylog', err);
ctx.markBuildPhaseSkipped();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,21 @@ const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
buildLogEntries = ['12345-abc.xcactivitylog'] as string[] | Error,
}: {
tempDir?: string;
jsonOutput?: string;
buildLogEntries?: string[] | Error;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'readdir').mockImplementation((async () => {
if (buildLogEntries instanceof Error) {
throw buildLogEntries;
}
return buildLogEntries;
}) as any);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);

return {
Expand Down Expand Up @@ -234,13 +242,14 @@ describe('parseAndReportXcactivitylog', () => {
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);

await parseAndReportXcactivitylog({
const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});

expect(result).toEqual({ skipped: false });
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
stdio: 'pipe',
env: TEST_ENV,
Expand Down Expand Up @@ -378,7 +387,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });

expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
Expand Down Expand Up @@ -409,7 +418,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });

expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
Expand Down Expand Up @@ -437,7 +446,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });

expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
Expand Down Expand Up @@ -486,6 +495,108 @@ describe('parseAndReportXcactivitylog', () => {
);
});

it('skips with actionable guidance when Logs/Build has no .xcactivitylog files', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: [] });

const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});

expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(
`Build performance analysis skipped: no .xcactivitylog files found at ${path.join(
'/tmp/derived-data',
'Logs',
'Build'
)}.`
)
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('derived_data_path("./build")')
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('result_bundle(true)'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('result_bundle_path("./build/result-bundle.xcresult")')
);
});

it('skips when Logs/Build directory does not exist', async () => {
const logger = createMockLogger();
const enoent: any = new Error('ENOENT');
enoent.code = 'ENOENT';
mockFilesystem({ buildLogEntries: enoent });

const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});

expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
);
});

it('captures to Sentry and skips when readdir fails with a non-ENOENT error', async () => {
const logger = createMockLogger();
const eacces: any = new Error('EACCES: permission denied');
eacces.code = 'EACCES';
mockFilesystem({ buildLogEntries: eacces });

const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});

expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "checking_xcactivitylog_existence"',
expect.objectContaining({ code: 'EACCES' }),
expect.objectContaining({
tags: { phase: 'checking_xcactivitylog_existence' },
})
);
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('custom ios/Gymfile'));
});

it('ignores non-xcactivitylog entries in Logs/Build when deciding to skip', async () => {
const logger = createMockLogger();
mockFilesystem({ buildLogEntries: ['LogStoreManifest.plist', 'some-other-file.txt'] });

const result = await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});

expect(result).toEqual({ skipped: true });
expect(mockedSpawn).not.toHaveBeenCalled();
expect(Sentry.capture).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Build performance analysis skipped: no .xcactivitylog files found')
);
});

it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
const logger = createMockLogger();
mockFilesystem();
Expand All @@ -501,7 +612,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: true });

expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
expect(logger.warn).not.toHaveBeenCalled();
Expand Down Expand Up @@ -530,7 +641,7 @@ describe('parseAndReportXcactivitylog', () => {
logger,
env: TEST_ENV,
})
).resolves.toBeUndefined();
).resolves.toEqual({ skipped: false });

expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
Expand Down
40 changes: 35 additions & 5 deletions packages/build-tools/src/steps/utils/ios/xcactivitylog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';

/**
* Never throws — best-effort observability that does not affect build status.
* Failures route to Sentry via `Sentry.capture` for engineering triage;
* users see only a generic skip message.
* Catches all internal failures (logged + routed to Sentry); returns
* `{ skipped: true }` when analysis did not produce a report so callers can
* mark the build phase skipped.
Comment thread
hSATAC marked this conversation as resolved.
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
Expand All @@ -34,10 +34,38 @@ export async function parseAndReportXcactivitylog({
logger: bunyan;
proxyBaseUrl?: string;
env: BuildStepEnv;
}): Promise<void> {
}): Promise<{ skipped: boolean }> {
let tempDir: string | undefined;
let phase = 'creating_temp_directory';
let phase = 'checking_xcactivitylog_existence';
try {
const logsBuildDir = path.join(derivedDataPath, 'Logs', 'Build');
let buildLogEntries: string[];
try {
buildLogEntries = await fs.readdir(logsBuildDir);
} catch (err: any) {
if (err?.code !== 'ENOENT') {
throw err;
}
buildLogEntries = [];
}
const hasActivityLog = buildLogEntries.some(entry => entry.endsWith('.xcactivitylog'));
if (!hasActivityLog) {
logger.info(
[
`Build performance analysis skipped: no .xcactivitylog files found at ${logsBuildDir}.`,
'',
'This typically happens when your project has a custom ios/Gymfile. To enable',
'build performance analysis, add the following lines to your Gymfile:',
'',
' derived_data_path("./build")',
' result_bundle(true)',
' result_bundle_path("./build/result-bundle.xcresult")',
Comment thread
hSATAC marked this conversation as resolved.
].join('\n')
);
return { skipped: true };
}

phase = 'creating_temp_directory';
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));

phase = 'resolving_xclogparser';
Expand Down Expand Up @@ -79,6 +107,7 @@ export async function parseAndReportXcactivitylog({
);

logger.info(formatReport(data));
return { skipped: false };
} catch (err: any) {
logger.info('Build performance analysis skipped.');
const msg = `Build performance analysis failed during "${phase}"`;
Expand All @@ -91,6 +120,7 @@ export async function parseAndReportXcactivitylog({
stdout: err?.stdout?.slice(-4000),
},
});
return { skipped: true };
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
Expand Down
Loading