blob: 5d417b3195c10597f739ade33293979561509e35 [file] [log] [blame]
package main
import (
"encoding/csv"
"flag"
"fmt"
"os"
"sort"
"strconv"
"time"
"github.com/buildkite/go-buildkite/buildkite"
)
type durationPercentiles struct {
Max time.Duration
Min time.Duration
Pct90 time.Duration
Pct70 time.Duration
Pct50 time.Duration
Pct30 time.Duration
}
type dayBuildStats struct {
Day time.Time
NumBuilds int
NumFailed int
NumPassed int
BuildTimes durationPercentiles
JobWaitTimes map[string]durationPercentiles
}
func optionsForPage(page int) *buildkite.BuildsListOptions {
return &buildkite.BuildsListOptions{
ListOptions: buildkite.ListOptions{
Page: page,
PerPage: 100,
},
}
}
func fetchAllBuilds(org string, pipeline string, client *buildkite.Client) ([]buildkite.Build, error) {
page := 0
var builds []buildkite.Build
for {
buildsOnPage, _, err := client.Builds.ListByPipeline(org, pipeline, optionsForPage(page))
if err != nil {
return nil, err
}
if len(buildsOnPage) == 0 {
break
}
builds = append(builds, buildsOnPage...)
page++
}
return builds, nil
}
func computeDurationPercentiles(durations []time.Duration) durationPercentiles {
if len(durations) == 0 {
return durationPercentiles{}
}
sort.Slice(durations, func(i, j int) bool {
return durations[i] < durations[j]
})
return durationPercentiles{
Max: durations[len(durations)-1],
Min: durations[0],
Pct90: durations[9*len(durations)/10],
Pct70: durations[7*len(durations)/10],
Pct50: durations[5*len(durations)/10],
Pct30: durations[3*len(durations)/10],
}
}
func computeDayBuildStats(builds []buildkite.Build) *dayBuildStats {
if len(builds) == 0 {
return nil
}
var stats dayBuildStats
createdAt := builds[0].CreatedAt
stats.Day = time.Date(createdAt.Year(), createdAt.Month(), createdAt.Day(), 0, 0, 0, 0,
createdAt.Location())
stats.NumBuilds = len(builds)
var buildTimes []time.Duration
jobWaitTimes := make(map[string][]time.Duration)
for _, build := range builds {
if build.FinishedAt != nil && build.CreatedAt != nil {
buildTime := time.Duration(0)
for _, job := range build.Jobs {
if job.Name == nil || job.StartedAt == nil || job.CreatedAt == nil ||
job.FinishedAt == nil {
continue
}
waitTime := job.StartedAt.Time.Sub(job.CreatedAt.Time)
jobBuildTime := job.FinishedAt.Time.Sub(job.StartedAt.Time)
if jobBuildTime > buildTime {
// Use the maximum job build time as the duration, instead of the build's
// start and finish time. This is because retries of jobs also count towards
// the build's time.
buildTime = jobBuildTime
}
jobWaitTimes[*job.Name] = append(jobWaitTimes[*job.Name], waitTime)
}
buildTimes = append(buildTimes, buildTime)
}
if *build.State == "passed" {
stats.NumPassed++
}
if *build.State == "failed" {
stats.NumFailed++
}
}
stats.BuildTimes = computeDurationPercentiles(buildTimes)
stats.JobWaitTimes = make(map[string]durationPercentiles, len(jobWaitTimes))
for jobName, durations := range jobWaitTimes {
stats.JobWaitTimes[jobName] = computeDurationPercentiles(durations)
}
return &stats
}
func buildStatsPerDay(builds []buildkite.Build) ([]*dayBuildStats, []string) {
if len(builds) == 0 {
return []*dayBuildStats{}, []string{}
}
sort.Slice(builds, func(i, j int) bool {
return builds[i].CreatedAt.Time.Before(builds[j].CreatedAt.Time)
})
var stats []*dayBuildStats
day := 0
var dayBuilds []buildkite.Build
jobNamesMap := make(map[string]bool)
for _, build := range builds {
if day != build.CreatedAt.Day() {
if len(dayBuilds) > 0 {
dayStats := computeDayBuildStats(dayBuilds)
for jobName := range dayStats.JobWaitTimes {
jobNamesMap[jobName] = true
}
stats = append(stats, dayStats)
}
dayBuilds = nil
day = build.CreatedAt.Day()
}
dayBuilds = append(dayBuilds, build)
}
if len(dayBuilds) > 0 {
dayStats := computeDayBuildStats(dayBuilds)
for jobName := range dayStats.JobWaitTimes {
jobNamesMap[jobName] = true
}
stats = append(stats, dayStats)
}
var jobNames []string
for jobName := range jobNamesMap {
jobNames = append(jobNames, jobName)
}
sort.Strings(jobNames)
return stats, jobNames
}
func durationPercentilesFieldNames(prefix string) []string {
fieldNames := []string{"Max", "Min", "Pct90", "Pc70", "Pct50", "Pct30"}
for i, value := range fieldNames {
fieldNames[i] = prefix + "." + value
}
return fieldNames
}
func appendDurationPercentiles(line []string, stats *durationPercentiles) []string {
line = append(line, strconv.Itoa(int(stats.Max.Seconds())))
line = append(line, strconv.Itoa(int(stats.Min.Seconds())))
line = append(line, strconv.Itoa(int(stats.Pct90.Seconds())))
line = append(line, strconv.Itoa(int(stats.Pct70.Seconds())))
line = append(line, strconv.Itoa(int(stats.Pct50.Seconds())))
line = append(line, strconv.Itoa(int(stats.Pct30.Seconds())))
return line
}
func main() {
apiToken := os.Getenv("BUILDKITE_API_TOKEN")
if apiToken == "" {
fmt.Println("BUILDKITE_API_TOKEN environment variable not set.")
os.Exit(1)
}
pipelineSlug := flag.String("pipeline_slug", "", "Slug of the Buildkite pipeline")
flag.Parse()
if *pipelineSlug == "" {
flag.Usage()
os.Exit(1)
}
config, _ := buildkite.NewTokenConfig(apiToken, false)
client := buildkite.NewClient(config.Client())
builds, _ := fetchAllBuilds("bazel", *pipelineSlug, client)
stats, jobNames := buildStatsPerDay(builds)
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
csvHeaders := []string{"Day", "NumBuilds", "NumFailed", "NumPassed"}
csvHeaders = append(csvHeaders, durationPercentilesFieldNames("BuildTimes")...)
for _, jobName := range jobNames {
csvHeaders = append(csvHeaders, durationPercentilesFieldNames("WaitTimes["+jobName+"]")...)
}
writer.Write(csvHeaders)
for _, dayStats := range stats {
var line []string
line = append(line, dayStats.Day.Format("2006-01-02"))
line = append(line, strconv.Itoa(dayStats.NumBuilds))
line = append(line, strconv.Itoa(dayStats.NumFailed))
line = append(line, strconv.Itoa(dayStats.NumPassed))
line = appendDurationPercentiles(line, &dayStats.BuildTimes)
for _, jobName := range jobNames {
if waitTimes, ok := dayStats.JobWaitTimes[jobName]; ok {
line = appendDurationPercentiles(line, &waitTimes)
} else {
line = appendDurationPercentiles(line, &durationPercentiles{})
}
}
writer.Write(line)
}
}