Skip to content

Commit ecf58ab

Browse files
authored
[build-tools] - Gradle build profile report with task execution breakdown (#3629)
* [build] gradle parse profiler * gradleProfileTasks * individual tasks nested underneath with tree characters * filter out whats less than 1s * parseGradleProfile * profile flag * enable by default * gradleProfile utils * parse profile HTML with fast-xml-parser * parse profile HTML with fast-xml-parser * error handling, send to sentry * sentry capture
1 parent 10c7ac6 commit ecf58ab

5 files changed

Lines changed: 300 additions & 25 deletions

File tree

packages/build-tools/src/android/gradle.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,27 @@ export async function runGradleCommand(
3232
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
3333
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
3434

35-
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
36-
cwd: androidDir,
37-
logger,
38-
lineTransformer: (line?: string) => {
39-
if (!line || /^\.+$/.exec(line)) {
40-
return null;
41-
} else {
42-
return line;
43-
}
44-
},
45-
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
46-
});
35+
const spawnPromise = spawn(
36+
'bash',
37+
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
38+
{
39+
cwd: androidDir,
40+
logger,
41+
lineTransformer: (line?: string) => {
42+
if (!line || /^\.+$/.exec(line)) {
43+
return null;
44+
} else {
45+
return line;
46+
}
47+
},
48+
env: {
49+
...ctx.env,
50+
...extraEnv,
51+
...resolveVersionOverridesEnvs(ctx),
52+
LC_ALL: 'C.UTF-8',
53+
},
54+
}
55+
);
4756
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
4857
adjustOOMScore(spawnPromise, logger);
4958
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { XMLParser } from 'fast-xml-parser';
2+
import fs from 'fs-extra';
3+
import path from 'path';
4+
5+
export interface GradleProfileTask {
6+
path: string;
7+
durationMs: number;
8+
result: string;
9+
}
10+
11+
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
12+
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
13+
if (!(await fs.pathExists(profileDir))) {
14+
throw new Error(`Gradle profile directory not found at ${profileDir}`);
15+
}
16+
17+
const files = await fs.readdir(profileDir);
18+
const htmlFile = files
19+
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
20+
.sort()
21+
.pop();
22+
23+
if (!htmlFile) {
24+
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
25+
}
26+
27+
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
28+
29+
// Locate the <h2>Task Execution</h2> heading and extract its <table>
30+
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
31+
if (!headingMatch || headingMatch.index === undefined) {
32+
throw new Error('Could not find Task Execution section in Gradle profile');
33+
}
34+
35+
const sectionStart = headingMatch.index;
36+
const tableStart = html.indexOf('<table', sectionStart);
37+
const tableEnd = html.indexOf('</table>', tableStart);
38+
if (tableStart === -1 || tableEnd === -1) {
39+
throw new Error('Could not find task execution table in Gradle profile');
40+
}
41+
42+
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
43+
const parser = new XMLParser({
44+
ignoreAttributes: true,
45+
isArray: name => name === 'tr' || name === 'td',
46+
trimValues: true,
47+
});
48+
49+
const parsed = parser.parse(tableHtml);
50+
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
51+
52+
const tasks: GradleProfileTask[] = [];
53+
for (const row of rows) {
54+
const cells: unknown[] = row?.td;
55+
if (!cells || cells.length < 2) {
56+
continue;
57+
}
58+
59+
const taskPath = String(cells[0] ?? '').trim();
60+
const durationStr = String(cells[1] ?? '').trim();
61+
const result = String(cells[2] ?? '').trim() || 'executed';
62+
63+
if (!taskPath || !durationStr) {
64+
continue;
65+
}
66+
67+
const durationMs = parseDurationToMs(durationStr);
68+
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
69+
}
70+
71+
return tasks;
72+
}
73+
74+
function parseDurationToMs(duration: string): number {
75+
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
76+
const secondsMatch = duration.match(/^([\d.]+)s$/);
77+
if (secondsMatch) {
78+
return Math.round(parseFloat(secondsMatch[1]) * 1000);
79+
}
80+
return 0;
81+
}
82+
83+
function formatSeconds(ms: number): string {
84+
const s = ms / 1000;
85+
if (s < 0.1) {
86+
return `${ms}ms`;
87+
}
88+
return `${s.toFixed(1)}s`;
89+
}
90+
91+
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
92+
// Filter out tasks under 1 second
93+
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
94+
95+
// Separate module totals from individual tasks
96+
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
97+
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
98+
99+
// Group individual tasks by their module prefix
100+
const moduleChildren = new Map<string, GradleProfileTask[]>();
101+
const orphanTasks: GradleProfileTask[] = [];
102+
103+
for (const task of individualTasks) {
104+
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
105+
if (parent) {
106+
const children = moduleChildren.get(parent.path) ?? [];
107+
children.push(task);
108+
moduleChildren.set(parent.path, children);
109+
} else {
110+
orphanTasks.push(task);
111+
}
112+
}
113+
114+
// Sort module totals by duration, sort children within each module
115+
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
116+
for (const children of moduleChildren.values()) {
117+
children.sort((a, b) => b.durationMs - a.durationMs);
118+
}
119+
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
120+
121+
// Build display rows: [displayName, task]
122+
const rows: { displayName: string; task: GradleProfileTask }[] = [];
123+
124+
for (const mod of sortedModules) {
125+
rows.push({ displayName: mod.path, task: mod });
126+
const children = moduleChildren.get(mod.path) ?? [];
127+
for (let i = 0; i < children.length; i++) {
128+
const isLast = i === children.length - 1;
129+
const prefix = isLast ? ' └─ ' : ' ├─ ';
130+
const shortName = children[i].path.slice(mod.path.length + 1);
131+
rows.push({ displayName: prefix + shortName, task: children[i] });
132+
}
133+
}
134+
135+
for (const task of orphanTasks) {
136+
rows.push({ displayName: task.path, task });
137+
}
138+
139+
// Compute totals from individual tasks only (avoid double-counting)
140+
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
141+
const maxMs = totalMs || 1;
142+
143+
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
144+
const barMaxWidth = 20;
145+
146+
const header =
147+
'┌─' +
148+
'─'.repeat(nameWidth) +
149+
'─┬────────────┬──────────┬────────────┬─' +
150+
'─'.repeat(barMaxWidth) +
151+
'─┐';
152+
const divider =
153+
'├─' +
154+
'─'.repeat(nameWidth) +
155+
'─┼────────────┼──────────┼────────────┼─' +
156+
'─'.repeat(barMaxWidth) +
157+
'─┤';
158+
const footer =
159+
'└─' +
160+
'─'.repeat(nameWidth) +
161+
'─┴────────────┴──────────┴────────────┴─' +
162+
'─'.repeat(barMaxWidth) +
163+
'─┘';
164+
165+
const taskCount = individualTasks.length;
166+
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
167+
const lines: string[] = [];
168+
169+
lines.push('Gradle Build — Task Execution Profile');
170+
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
171+
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
172+
lines.push('% Time = share of total task execution time');
173+
lines.push('');
174+
lines.push(header);
175+
lines.push(
176+
'│ ' +
177+
'Task'.padEnd(nameWidth) +
178+
' │ ' +
179+
'Duration'.padStart(10) +
180+
' │ ' +
181+
'% Time'.padStart(8) +
182+
' │ ' +
183+
'Result'.padEnd(10) +
184+
' │ ' +
185+
' '.repeat(barMaxWidth) +
186+
' │'
187+
);
188+
lines.push(divider);
189+
190+
for (const row of rows) {
191+
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
192+
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
193+
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
194+
const result = row.task.result === '(total)' ? 'total' : row.task.result;
195+
196+
lines.push(
197+
'│ ' +
198+
row.displayName.padEnd(nameWidth) +
199+
' │ ' +
200+
formatSeconds(row.task.durationMs).padStart(10) +
201+
' │ ' +
202+
`${pct.toFixed(1)}%`.padStart(8) +
203+
' │ ' +
204+
result.padEnd(10) +
205+
' │ ' +
206+
bar +
207+
' │'
208+
);
209+
}
210+
211+
lines.push(divider);
212+
lines.push(
213+
'│ ' +
214+
'TOTAL'.padEnd(nameWidth) +
215+
' │ ' +
216+
formatSeconds(totalMs).padStart(10) +
217+
' │ ' +
218+
'100.0%'.padStart(8) +
219+
' │ ' +
220+
' '.repeat(10) +
221+
' │ ' +
222+
' '.repeat(barMaxWidth) +
223+
' │'
224+
);
225+
lines.push(footer);
226+
lines.push('');
227+
228+
return lines.join('\n');
229+
}

packages/build-tools/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
2020

2121
export { Hook, runHookIfPresent } from './utils/hooks';
2222

23+
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
24+
export type { GradleProfileTask } from './android/gradleProfile';
25+
2326
export * from './generic';
2427

2528
export { Datadog } from './datadog';

packages/build-tools/src/steps/utils/android/gradle.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,27 @@ export async function runGradleCommand({
2323

2424
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
2525
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
26-
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
27-
cwd: androidDir,
28-
logger,
29-
lineTransformer: (line?: string) => {
30-
if (!line || /^\.+$/.exec(line)) {
31-
return null;
32-
} else {
33-
return line;
34-
}
35-
},
36-
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
37-
});
26+
27+
const spawnPromise = spawn(
28+
'bash',
29+
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
30+
{
31+
cwd: androidDir,
32+
logger,
33+
lineTransformer: (line?: string) => {
34+
if (!line || /^\.+$/.exec(line)) {
35+
return null;
36+
} else {
37+
return line;
38+
}
39+
},
40+
env: {
41+
...env,
42+
...extraEnv,
43+
LC_ALL: 'C.UTF-8',
44+
},
45+
}
46+
);
3847
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
3948
adjustOOMScore(spawnPromise, logger);
4049
}

packages/worker/src/build.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
1+
import {
2+
Artifacts,
3+
BuildContext,
4+
Builders,
5+
Sentry,
6+
parseGradleProfile,
7+
formatGradleProfileReport,
8+
runGenericJobAsync,
9+
} from '@expo/build-tools';
210
import {
311
Android,
412
BuildJob,
@@ -13,6 +21,7 @@ import {
1321
} from '@expo/eas-build-job';
1422
import { bunyan } from '@expo/logger';
1523
import omit from 'lodash/omit';
24+
import path from 'path';
1625

1726
import config from './config';
1827
import { displayWorkerRuntimeInfo } from './displayRuntimeInfo';
@@ -83,6 +92,22 @@ export async function build({
8392

8493
analytics.logEvent(Event.WORKER_BUILD_SUCCESS, {});
8594

95+
if (job.platform === Platform.ANDROID) {
96+
try {
97+
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
98+
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
99+
const profileTasks = await parseGradleProfile(androidDir);
100+
if (profileTasks.length > 0) {
101+
const report = formatGradleProfileReport(profileTasks);
102+
ctx.logger.info(report);
103+
}
104+
});
105+
} catch (err: any) {
106+
logger.error({ err }, 'Failed to parse Gradle build profile');
107+
Sentry.capture('Failed to parse Gradle build profile', err);
108+
}
109+
}
110+
86111
return artifacts;
87112
} catch (err: any) {
88113
if ('mode' in job && ![BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)) {

0 commit comments

Comments
 (0)