blob: 5cf5f7664174f976ef7b792da88cddbaaf4fca18 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {execSync} from 'node:child_process';
import {parseArgs} from 'node:util';
export function getLuciToken(): string {
try {
return execSync('luci-auth token', {encoding: 'utf-8'}).trim();
} catch (err) {
throw new Error('Could not get LUCI auth token. Did you run \'luci-auth login\'?', {cause: err});
}
}
export interface BuilderId {
project: string;
bucket: string;
builder: string;
}
export interface Build {
id: string;
builder: BuilderId;
number?: number;
createdBy?: string;
createTime?: string;
startTime?: string;
endTime?: string;
updateTime?: string;
status: string;
[key: string]: unknown;
}
export interface DailyBuildStats {
date: string;
count: number;
averageRuntimeMs: number;
minRuntimeMs: number;
maxRuntimeMs: number;
medianRuntimeMs: number;
percentChangeFromPreviousDay: number|null;
durationsMs: number[];
}
export interface BuilderReport {
builder: string;
project: string;
bucket: string;
totalBuilds: number;
overallAverageRuntimeMs: number;
overallMedianRuntimeMs: number;
overallMinRuntimeMs: number;
overallMaxRuntimeMs: number;
changeFromFirstDay: number|null;
changeFromSecondDay: number|null;
dailyStats: DailyBuildStats[];
}
export interface FetchBuildsOptions {
project?: string;
bucket?: string;
builder?: string;
days?: number;
period?: string;
since?: string|Date;
until?: string|Date;
status?: string;
}
interface SearchBuildsPayload {
predicate: {
builder: BuilderId,
status: string,
createTime: {
startTime: string,
endTime: string,
},
};
pageSize: number;
pageToken?: string;
}
export function parseTimeRange(options: {
days?: number,
period?: string,
since?: string|Date,
until?: string|Date,
}): {startTime: Date, endTime: Date} {
const endTime = options.until ? (typeof options.until === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(options.until) ?
new Date(`${options.until}T23:59:59.999Z`) :
new Date(options.until)) :
new Date();
if (isNaN(endTime.getTime())) {
throw new Error(`Invalid date format for until: "${options.until}"`);
}
if (options.since) {
const startTime = typeof options.since === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(options.since) ?
new Date(`${options.since}T00:00:00.000Z`) :
new Date(options.since);
if (isNaN(startTime.getTime())) {
throw new Error(`Invalid date format for since: "${options.since}"`);
}
// Always start at midnight UTC of the start day so all builds on the first day are fetched.
startTime.setUTCHours(0, 0, 0, 0);
if (startTime.getTime() > endTime.getTime()) {
throw new Error(`Start time (${startTime.toISOString()}) cannot be after end time (${endTime.toISOString()})`);
}
return {startTime, endTime};
}
let days = options.days;
if (options.period) {
const match = options.period.trim().match(/^(\d+(?:\.\d+)?)\s*([dwmyh])?$/i);
if (match) {
const val = parseFloat(match[1]);
const unit = (match[2] || 'd').toLowerCase();
if (unit === 'd') {
days = val;
} else if (unit === 'w') {
days = val * 7;
} else if (unit === 'm') {
days = val * 30;
} else if (unit === 'y') {
days = val * 365;
} else if (unit === 'h') {
days = val / 24;
}
} else {
const num = parseFloat(options.period);
if (!isNaN(num) && num > 0) {
days = num;
}
}
}
if (!days || days <= 0 || isNaN(days)) {
days = 14;
}
const startTime = new Date(endTime.getTime() - (days * 24 * 60 * 60 * 1000));
// Always start at midnight UTC of the start day so all builds on the first day are fetched.
startTime.setUTCHours(0, 0, 0, 0);
if (startTime.getTime() > endTime.getTime()) {
throw new Error(`Start time (${startTime.toISOString()}) cannot be after end time (${endTime.toISOString()})`);
}
return {startTime, endTime};
}
export async function fetchSuccessfulBuilds(options: FetchBuildsOptions = {}): Promise<Build[]> {
const project = options.project ?? 'devtools-frontend';
const bucket = options.bucket ?? 'try';
const builder = options.builder ?? 'dtf_linux_rel';
const status = options.status ?? 'SUCCESS';
const url = 'https://cr-buildbucket.appspot.com/prpc/buildbucket.v2.Builds/SearchBuilds';
const headers = {
Authorization: `Bearer ${getLuciToken()}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
const {startTime, endTime} = parseTimeRange(options);
const allBuilds: Build[] = [];
let pageToken: string|undefined;
do {
const payload: SearchBuildsPayload = {
predicate: {
builder: {
project,
bucket,
builder,
},
status,
createTime: {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
},
},
pageSize: 1000,
};
if (pageToken) {
payload.pageToken = pageToken;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text();
console.error(`HTTP Error ${response.status}: ${response.statusText}`);
console.error(text);
throw new Error(`Network error querying Buildbucket SearchBuilds: ${response.statusText}`);
}
let rawData = await response.text();
if (rawData.startsWith(')]}\'\n')) {
rawData = rawData.substring(5);
}
const data = JSON.parse(rawData);
const builds: Build[] = data.builds || [];
allBuilds.push(...builds);
if (!data.nextPageToken || data.nextPageToken === pageToken) {
pageToken = undefined;
} else {
pageToken = data.nextPageToken;
}
} while (pageToken);
return allBuilds;
}
export function computeDailyStats(builds: Build[]): DailyBuildStats[] {
const byDate = new Map<string, number[]>();
for (const build of builds) {
if (!build.startTime || !build.endTime) {
continue;
}
const start = new Date(build.startTime).getTime();
const end = new Date(build.endTime).getTime();
if (isNaN(start) || isNaN(end)) {
continue;
}
const durationMs = end - start;
if (durationMs < 0) {
continue;
}
// Group by UTC date of build start (or createTime)
const dateKey = new Date(start).toISOString().slice(0, 10);
if (!dateKey) {
continue;
}
let list = byDate.get(dateKey);
if (!list) {
list = [];
byDate.set(dateKey, list);
}
list.push(durationMs);
}
const sortedDates = [...byDate.keys()].sort();
const dailyStats: DailyBuildStats[] = [];
let prevAverageMs: number|null = null;
for (const date of sortedDates) {
const durations = byDate.get(date);
if (!durations || durations.length === 0) {
continue;
}
durations.sort((a, b) => a - b);
const sum = durations.reduce((acc, curr) => acc + curr, 0);
const count = durations.length;
const averageRuntimeMs = sum / count;
const minRuntimeMs = durations[0];
const maxRuntimeMs = durations[durations.length - 1];
const mid = Math.floor(durations.length / 2);
const medianRuntimeMs = durations.length % 2 !== 0 ? durations[mid] : (durations[mid - 1] + durations[mid]) / 2;
let percentChangeFromPreviousDay: number|null = null;
if (prevAverageMs !== null && prevAverageMs > 0) {
percentChangeFromPreviousDay = ((averageRuntimeMs - prevAverageMs) / prevAverageMs) * 100;
}
prevAverageMs = averageRuntimeMs;
dailyStats.push({
date,
count,
averageRuntimeMs,
minRuntimeMs,
maxRuntimeMs,
medianRuntimeMs,
percentChangeFromPreviousDay,
durationsMs: durations,
});
}
return dailyStats;
}
export function generateReport(builder: string, project: string, bucket: string,
dailyStats: DailyBuildStats[]): BuilderReport {
let totalBuilds = 0;
const allDurations: number[] = [];
const sortedDailyStats = [...dailyStats].sort((a, b) => a.date.localeCompare(b.date));
for (const day of sortedDailyStats) {
totalBuilds += day.count;
allDurations.push(...day.durationsMs);
}
allDurations.sort((a, b) => a - b);
const totalSum = allDurations.reduce((acc, curr) => acc + curr, 0);
const overallAverageRuntimeMs = allDurations.length > 0 ? totalSum / allDurations.length : 0;
const mid = Math.floor(allDurations.length / 2);
const overallMedianRuntimeMs = allDurations.length === 0 ?
0 :
(allDurations.length % 2 !== 0 ? allDurations[mid] : (allDurations[mid - 1] + allDurations[mid]) / 2);
const overallMinRuntimeMs = allDurations.length > 0 ? allDurations[0] : 0;
const overallMaxRuntimeMs = allDurations.length > 0 ? allDurations[allDurations.length - 1] : 0;
const firstDay = sortedDailyStats.length > 0 ? sortedDailyStats[0] : null;
const secondDay = sortedDailyStats.length > 1 ? sortedDailyStats[1] : null;
const lastDay = sortedDailyStats.length > 0 ? sortedDailyStats[sortedDailyStats.length - 1] : null;
const changeFromFirstDay = (firstDay && lastDay && firstDay.averageRuntimeMs > 0 && sortedDailyStats.length > 1) ?
((lastDay.averageRuntimeMs - firstDay.averageRuntimeMs) / firstDay.averageRuntimeMs) * 100 :
null;
const changeFromSecondDay = (secondDay && lastDay && secondDay.averageRuntimeMs > 0 && sortedDailyStats.length > 1) ?
((lastDay.averageRuntimeMs - secondDay.averageRuntimeMs) / secondDay.averageRuntimeMs) * 100 :
null;
return {
builder,
project,
bucket,
totalBuilds,
overallAverageRuntimeMs,
overallMedianRuntimeMs,
overallMinRuntimeMs,
overallMaxRuntimeMs,
changeFromFirstDay,
changeFromSecondDay,
dailyStats: sortedDailyStats,
};
}
export function formatDuration(ms: number): string {
if (isNaN(ms) || ms < 0) {
return '-';
}
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
}
export function formatPercentDiff(pct: number|null|undefined): string {
if (pct === null || pct === undefined || isNaN(pct)) {
return '-';
}
let formatted = pct.toFixed(1);
if (formatted === '-0.0') {
formatted = '0.0';
}
if (pct > 0 && formatted !== '0.0') {
return `+${formatted}%`;
}
return `${formatted}%`;
}
export function resolveBuilderName(name: string): string {
const normalized = name.trim().toLowerCase();
if (normalized === 'linux' || normalized === 'linux-rel' || normalized === 'dtf_linux_rel') {
return 'dtf_linux_rel';
}
if (normalized === 'mac' || normalized === 'mac_arm64' || normalized === 'mac-arm64' ||
normalized === 'dtf_mac_arm64_rel') {
return 'dtf_mac_arm64_rel';
}
if (normalized === 'win' || normalized === 'windows' || normalized === 'win64' || normalized === 'dtf_win64_rel') {
return 'dtf_win64_rel';
}
return name.trim();
}
export const DEFAULT_BUILDERS = ['dtf_linux_rel', 'dtf_mac_arm64_rel', 'dtf_win64_rel'];
export async function main(): Promise<void> {
const {values} = parseArgs({
options: {
days: {
type: 'string',
},
period: {
type: 'string',
},
since: {
type: 'string',
},
until: {
type: 'string',
},
builder: {
type: 'string',
},
status: {
type: 'string',
default: 'SUCCESS',
},
bucket: {
type: 'string',
default: 'try',
},
project: {
type: 'string',
default: 'devtools-frontend',
},
format: {
type: 'string',
default: 'table',
},
help: {
type: 'boolean',
short: 'h',
},
},
});
if (values.help) {
console.log(`
Usage: node scripts/fetch_luci_build_runtimes.ts [options]
Fetches runtimes of successful builds from LUCI/Buildbucket and prints daily average times and daily +- % changes.
Defaults to devtools-frontend try builders: linux-rel, mac arm64, and windows (dtf_linux_rel, dtf_mac_arm64_rel, dtf_win64_rel).
Options:
--period <period> Time period (e.g. 14d, 2w, 1m, 1y) (default: 14d)
--days <number> Number of days to look back (e.g. 14)
--since <date> Start date / timestamp (e.g. 2026-08-01)
--until <date> End date / timestamp (e.g. 2026-08-20)
--builder <name[,name]> Builder name(s), comma-separated (default: dtf_linux_rel,dtf_mac_arm64_rel,dtf_win64_rel)
--status <status> Build status (default: SUCCESS)
--bucket <name> Bucket name (default: try)
--project <name> Project name (default: devtools-frontend)
--format <format> Output format: 'table' or 'json' (default: table)
-h, --help Show this help message
`);
return;
}
const days = values.days ? parseInt(values.days, 10) : undefined;
const period = values.period;
const since = values.since;
const until = values.until;
const bucket = values.bucket || 'try';
const project = values.project || 'devtools-frontend';
const isJson = values.format === 'json';
const status = values.status || 'SUCCESS';
const timeRange = parseTimeRange({days, period, since, until});
const startStr = timeRange.startTime.toISOString().slice(0, 10);
const endStr = timeRange.endTime.toISOString().slice(0, 10);
const parsedBuilders = values.builder ? values.builder.split(',').map(resolveBuilderName).filter(Boolean) : [];
const builderList = parsedBuilders.length > 0 ? parsedBuilders : DEFAULT_BUILDERS;
if (!isJson) {
console.log(`Fetching ${status} builds for ${project}/${bucket} [${builderList.join(', ')}] from ${startStr} to ${
endStr}...`);
}
const reports: BuilderReport[] = [];
for (const builder of builderList) {
const builds = await fetchSuccessfulBuilds({
project,
bucket,
builder,
days,
period,
since,
until,
status,
});
const dailyStats = computeDailyStats(builds);
const report = generateReport(builder, project, bucket, dailyStats);
reports.push(report);
}
if (isJson) {
console.log(JSON.stringify(reports, null, 2));
return;
}
const allDates = new Set<string>();
for (const r of reports) {
for (const d of r.dailyStats) {
allDates.add(d.date);
}
}
const sortedDates = [...allDates].sort();
console.log(`\nDaily Average Build Runtimes (${startStr} to ${endStr} - ${status} runs only):`);
const colWidth = Math.max(27, ...reports.map(r => r.builder.length));
const headerTop = reports.map(r => r.builder.padEnd(colWidth)).join(' ');
const subCols =
reports.map(() => `${'Avg Time'.padStart(9)} ${'+- %'.padStart(7)} ${'(Builds)'.padStart(9)}`.padEnd(colWidth))
.join(' ');
const totalLineWidth = 12 + 2 + (colWidth + 2) * reports.length - 2;
console.log('='.repeat(totalLineWidth));
console.log(`${'Date'.padEnd(12)} ${headerTop}`);
console.log(`${''.padEnd(12)} ${subCols}`);
console.log('-'.repeat(totalLineWidth));
if (sortedDates.length === 0) {
console.log('No builds found for the specified criteria.');
} else {
for (const date of sortedDates) {
const rowCols = reports
.map(r => {
const d = r.dailyStats.find(s => s.date === date);
if (!d) {
return `${'-'.padStart(9)} ${'-'.padStart(7)} ${'-'.padStart(9)}`.padEnd(colWidth);
}
const avg = formatDuration(d.averageRuntimeMs).padStart(9);
const diff = formatPercentDiff(d.percentChangeFromPreviousDay).padStart(7);
const count = `(${d.count})`.padStart(9);
return `${avg} ${diff} ${count}`.padEnd(colWidth);
})
.join(' ');
console.log(`${date.padEnd(12)} ${rowCols}`);
}
}
console.log('='.repeat(totalLineWidth));
const changeCols = reports
.map(r => {
const avg = ''.padStart(9);
const diff = formatPercentDiff(r.changeFromSecondDay).padStart(7);
const count = `(${r.totalBuilds})`.padStart(9);
return `${avg} ${diff} ${count}`.padEnd(colWidth);
})
.join(' ');
console.log(`${'Change*'.padEnd(12)} ${changeCols}`);
console.log('\n* Change compares the last day to the second day of the period.');
}
if (import.meta.main) {
main().catch(err => {
console.error(err);
process.exit(1);
});
}