diff --git a/packages/build-tools/src/android/gradle.ts b/packages/build-tools/src/android/gradle.ts index 3cc363ac0a..c4fa881b0a 100644 --- a/packages/build-tools/src/android/gradle.ts +++ b/packages/build-tools/src/android/gradle.ts @@ -32,7 +32,7 @@ export async function runGradleCommand( await fs.chmod(path.join(androidDir, 'gradlew'), 0o755); const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : ''; - const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], { + const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`], { cwd: androidDir, logger, lineTransformer: (line?: string) => { @@ -103,6 +103,78 @@ function resolveVersionOverridesEnvs(ctx: BuildContext): Env { return extraEnvs; } +export interface GradleProfileTask { + path: string; + durationMs: number; + result: string; +} + +export async function parseGradleProfile(androidDir: string): Promise { + const profileDir = path.join(androidDir, 'build', 'reports', 'profile'); + if (!(await fs.pathExists(profileDir))) { + return null; + } + + const files = await fs.readdir(profileDir); + const profileFile = files.filter((f) => f.endsWith('.html')).sort().pop(); + if (!profileFile) { + return null; + } + + const html = await fs.readFile(path.join(profileDir, profileFile), 'utf8'); + + const tab4Match = html.match(/id="tab4"[\s\S]*?<\/table>/); + if (!tab4Match) { + return null; + } + + const taskSection = tab4Match[0]; + const tasks: GradleProfileTask[] = []; + const rowRegex = /\s*(.*?)<\/td>\s*(.*?)<\/td>\s*(.*?)<\/td>\s*<\/tr>/g; + + let match; + while ((match = rowRegex.exec(taskSection)) !== null) { + tasks.push({ + path: match[1], + durationMs: parseDurationToMs(match[2]), + result: match[3] || 'executed', + }); + } + + return tasks; +} + +function parseDurationToMs(duration: string): number { + let totalMs = 0; + + const daysMatch = duration.match(/(\d+)d/); + if (daysMatch) { + totalMs += parseInt(daysMatch[1], 10) * 86400000; + } + + const hoursMatch = duration.match(/(\d+)h/); + if (hoursMatch) { + totalMs += parseInt(hoursMatch[1], 10) * 3600000; + } + + const minsMatch = duration.match(/(\d+)m(?!\s*s)/); + if (minsMatch) { + totalMs += parseInt(minsMatch[1], 10) * 60000; + } + + const secsMatch = duration.match(/([\d.]+)s$/); + if (secsMatch) { + totalMs += Math.round(parseFloat(secsMatch[1]) * 1000); + } + + const msMatch = duration.match(/([\d.]+)ms$/); + if (msMatch) { + totalMs += Math.round(parseFloat(msMatch[1])); + } + + return totalMs; +} + export function resolveGradleCommand(job: Android.Job): string { if (job.gradleCommand) { return job.gradleCommand; diff --git a/packages/build-tools/src/index.ts b/packages/build-tools/src/index.ts index e6d88b4507..62955075c4 100644 --- a/packages/build-tools/src/index.ts +++ b/packages/build-tools/src/index.ts @@ -20,4 +20,7 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs'; export { Hook, runHookIfPresent } from './utils/hooks'; +export { parseGradleProfile } from './android/gradle'; +export type { GradleProfileTask } from './android/gradle'; + export * from './generic'; diff --git a/packages/worker/src/service.ts b/packages/worker/src/service.ts index 4e1d35131c..64f0eae01a 100644 --- a/packages/worker/src/service.ts +++ b/packages/worker/src/service.ts @@ -3,6 +3,7 @@ import { BuildContext, Hook, findAndUploadXcodeBuildLogsAsync, + parseGradleProfile, runHookIfPresent, } from '@expo/build-tools'; import { @@ -360,6 +361,45 @@ export default class BuildService { } await this.finishError(err, maybeArtifacts); + } finally { + if (job.platform === Platform.ANDROID) { + await this.maybeUploadGradleProfile(job); + } + } + } + + private async maybeUploadGradleProfile(job: Job): Promise { + try { + const robotAccessToken = job.secrets?.robotAccessToken; + if (!robotAccessToken || !this.buildContext) { + return; + } + + const androidDir = path.join( + this.buildContext.getReactNativeProjectDirectory(), + 'android' + ); + const tasks = await parseGradleProfile(androidDir); + if (!tasks || tasks.length === 0) { + return; + } + + await turtleFetch( + new URL('turtle-builds/gradle-profile', config.wwwApiV2BaseUrl).toString(), + 'POST', + { + json: { + buildId: this.buildId, + tasks, + }, + headers: { + Authorization: `Bearer ${robotAccessToken}`, + }, + shouldThrowOnNotOk: false, + } + ); + } catch (err: any) { + logger.debug({ err }, 'Failed to upload Gradle profile'); } }