| 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) | |
| } | |
| } |