├── cli ├── api.go ├── config.go ├── constants_vars.go ├── discovery.go ├── tui.go ├── selections.go └── commands.go ├── engine ├── collector │ ├── utils.go │ ├── api.go │ └── helpers.go ├── tracker │ ├── types.go │ ├── utils.go │ ├── function_change_result.go │ ├── api.go │ ├── helpers.go │ └── profile_change_report.go ├── benchmark │ ├── constants.go │ ├── discovery.go │ ├── pipeline.go │ ├── api.go │ ├── files.go │ ├── directories.go │ ├── execution.go │ └── profiles.go └── tools │ ├── qcachegrind │ └── api.go │ └── benchstats │ └── api.go ├── parser ├── types.go ├── apiv2.go └── helpersv2.go ├── internal ├── const.go ├── types.go └── api.go ├── go.mod ├── agentic_support.yaml ├── go.sum └── .golangci.yml /cli/api.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | // Execute runs the CLI application 4 | func Execute() error { 5 | return CreateRootCmd().Execute() 6 | } 7 | -------------------------------------------------------------------------------- /cli/config.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/AlexsanderHamir/prof/engine/tracker" 5 | ) 6 | 7 | // setGlobalTrackingVariables sets the global CLI variables for tracking 8 | func setGlobalTrackingVariables(selections *tracker.Selections) { 9 | Baseline = selections.Baseline 10 | Current = selections.Current 11 | benchmarkName = selections.BenchmarkName 12 | profileType = selections.ProfileType 13 | outputFormat = selections.OutputFormat 14 | failOnRegression = selections.UseThreshold 15 | regressionThreshold = selections.RegressionThreshold 16 | } 17 | -------------------------------------------------------------------------------- /engine/collector/utils.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "path" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func getPprofTextParams() []string { 10 | return []string{ 11 | "-cum", 12 | "-edgefraction=0", 13 | "-nodefraction=0", 14 | "-top", 15 | } 16 | } 17 | 18 | func getFileName(fullPath string) string { 19 | file := filepath.Base(fullPath) 20 | fileName := strings.TrimSuffix(file, filepath.Ext(file)) 21 | 22 | return fileName 23 | } 24 | 25 | func createProfileDirectory(tagDir, fileName string) (string, error) { 26 | profileDirPath := path.Join(tagDir, fileName) 27 | if err := ensureDirExists(profileDirPath); err != nil { 28 | return "", err 29 | } 30 | 31 | return profileDirPath, nil 32 | } 33 | -------------------------------------------------------------------------------- /cli/constants_vars.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | var ( 4 | // Root command flags. 5 | benchmarks []string 6 | profiles []string 7 | tag string 8 | count int 9 | 10 | // Track command flags. 11 | Baseline string 12 | Current string 13 | benchmarkName string 14 | profileType string 15 | outputFormat string 16 | failOnRegression bool 17 | regressionThreshold float64 18 | 19 | // Profile organization flags. 20 | groupByPackage bool 21 | ) 22 | 23 | const ( 24 | tuiPageSize = 20 25 | minTagsForComparison = 2 26 | 27 | // 3 occurrences requires a const 28 | baseTagFlag = "base" 29 | currentTagFlag = "current" 30 | benchNameFlag = "bench-name" 31 | tagFlag = "tag" 32 | ) 33 | -------------------------------------------------------------------------------- /engine/tracker/types.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ProfileChangeReport struct { 8 | FunctionChanges []*FunctionChangeResult 9 | } 10 | 11 | type AbsoluteChange struct { 12 | Before float64 `json:"before"` 13 | After float64 `json:"after"` 14 | Delta float64 `json:"delta"` 15 | } 16 | 17 | type FunctionChangeResult struct { 18 | FunctionName string `json:"function_name"` 19 | ChangeType string `json:"change_type"` 20 | FlatChangePercent float64 `json:"flat_change_percent"` 21 | CumChangePercent float64 `json:"cum_change_percent"` 22 | FlatAbsolute AbsoluteChange `json:"flat_absolute"` 23 | CumAbsolute AbsoluteChange `json:"cum_absolute"` 24 | Timestamp time.Time `json:"timestamp"` 25 | } 26 | -------------------------------------------------------------------------------- /parser/types.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | type LineObj struct { 4 | FnName string 5 | Flat float64 6 | FlatPercentage float64 7 | SumPercentage float64 8 | Cum float64 9 | CumPercentage float64 10 | } 11 | 12 | // PackageGroup represents a group of functions from the same package 13 | type PackageGroup struct { 14 | Name string 15 | Functions []*FunctionInfo 16 | TotalFlat float64 17 | TotalCum float64 18 | FlatPercentage float64 19 | CumPercentage float64 20 | } 21 | 22 | // FunctionInfo represents a function with its performance metrics 23 | type FunctionInfo struct { 24 | Name string 25 | FullName string 26 | Flat float64 27 | FlatPercentage float64 28 | Cum float64 29 | CumPercentage float64 30 | SumPercentage float64 31 | } 32 | -------------------------------------------------------------------------------- /engine/benchmark/constants.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | var SupportedProfiles = []string{"cpu", "memory", "mutex", "block"} 4 | 5 | var ProfileFlags = map[string]string{ 6 | "cpu": "-cpuprofile=cpu.out", 7 | "memory": "-memprofile=memory.out", 8 | "mutex": "-mutexprofile=mutex.out", 9 | "block": "-blockprofile=block.out", 10 | } 11 | 12 | var ExpectedFiles = map[string]string{ 13 | "cpu": "cpu.out", 14 | "memory": "memory.out", 15 | "mutex": "mutex.out", 16 | "block": "block.out", 17 | } 18 | 19 | const ( 20 | binExtension = "out" 21 | descriptionFileName = "description.txt" 22 | moduleNotFoundMsg = "go: cannot find main module" 23 | waitForFiles = 100 24 | descritpionFileMessage = "The explanation for this profilling session goes here" 25 | 26 | // Minimum number of regex capture groups expected for benchmark function 27 | minCaptureGroups = 2 28 | ) 29 | -------------------------------------------------------------------------------- /internal/const.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | // The point of this file was to eliminate magic values on the codebase 4 | 5 | const ( 6 | AUTOCMD = "auto" 7 | MANUALCMD = "manual" 8 | TrackAutoCMD = AUTOCMD 9 | TrackManualCMD = MANUALCMD 10 | ) 11 | 12 | const ( 13 | InfoCollectionSuccess = "All benchmarks and profile processing completed successfully!" 14 | IMPROVEMENT = "IMPROVEMENT" 15 | REGRESSION = "REGRESSION" 16 | STABLE = "STABLE" 17 | ) 18 | 19 | const ( 20 | MainDirOutput = "bench" 21 | ProfileTextDir = "text" 22 | ToolDir = "tools" 23 | ProfileBinDir = "bin" 24 | PermDir = 0o755 25 | PermFile = 0o644 26 | FunctionsDirSuffix = "_functions" 27 | ToolsResultsSuffix = "_results.txt" 28 | TextExtension = "txt" 29 | ConfigFilename = "config_template.json" 30 | GlobalSign = "*" 31 | ExpectedTestSuffix = ".test" 32 | ) 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AlexsanderHamir/prof 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/microcosm-cc/bluemonday v1.0.27 7 | github.com/spf13/cobra v1.9.1 8 | ) 9 | 10 | require ( 11 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 12 | github.com/mattn/go-colorable v0.1.2 // indirect 13 | github.com/mattn/go-isatty v0.0.8 // indirect 14 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect 15 | golang.org/x/sys v0.32.0 // indirect 16 | golang.org/x/term v0.21.0 // indirect 17 | golang.org/x/text v0.16.0 // indirect 18 | ) 19 | 20 | require ( 21 | github.com/AlecAivazis/survey/v2 v2.3.7 22 | github.com/aymerick/douceur v0.2.0 // indirect 23 | github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 24 | github.com/gorilla/css v1.0.1 // indirect 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 | github.com/spf13/pflag v1.0.7 // indirect 27 | golang.org/x/net v0.26.0 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /agentic_support.yaml: -------------------------------------------------------------------------------- 1 | # AtomOS Block Specification Example 2 | # This file defines a piece of software as a block that is supported by our runtime. 3 | 4 | name: go-profiler 5 | description: > 6 | A Go-based profiler that can run, report, and generate flamegraphs. 7 | Provides deterministic CLI entries for AI agents to execute workflows. 8 | version: v1.8.1 9 | source: 10 | type: github 11 | repo: AlexsanderHamir/prof 12 | 13 | # Platform-specific binaries (release-only, mandatory) 14 | binary: 15 | from: release 16 | assets: 17 | darwin-arm64: prof-darwin-arm64 18 | 19 | # CLI entries exposed by this block 20 | entries: 21 | - name: run 22 | command: prof run 23 | description: Run profiling on the target binary 24 | inputs: 25 | - name: target 26 | type: path 27 | outputs: 28 | - name: profile 29 | type: file 30 | 31 | - name: report 32 | command: prof report 33 | description: Generate a profiling report from a saved profile 34 | inputs: 35 | - name: profile 36 | type: file 37 | outputs: 38 | - name: summary 39 | type: string 40 | 41 | - name: flamegraph 42 | command: prof flamegraph 43 | description: Generate a flamegraph from a profile file 44 | inputs: 45 | - name: profile 46 | type: file 47 | outputs: 48 | - name: flamegraph 49 | type: svg 50 | -------------------------------------------------------------------------------- /engine/tracker/utils.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/AlexsanderHamir/prof/internal" 7 | ) 8 | 9 | const ( 10 | significantThreshold = 25.0 11 | notableThreshold = 10.0 12 | criticalThreshold = 50.0 13 | moderateThreshold = 10.0 14 | ) 15 | 16 | func signPrefix(val float64) string { 17 | if val > 0 { 18 | return "+" 19 | } 20 | return "" 21 | } 22 | 23 | func (cr *FunctionChangeResult) recommendation() string { 24 | switch cr.ChangeType { 25 | case internal.IMPROVEMENT: 26 | absChange := math.Abs(cr.FlatChangePercent) 27 | switch { 28 | case absChange > significantThreshold: 29 | return "Significant performance gain! Consider documenting the optimization." 30 | case absChange > notableThreshold: 31 | return "Notable improvement detected. Monitor to ensure consistency." 32 | default: 33 | return "Minor improvement detected. Continue monitoring." 34 | } 35 | case internal.REGRESSION: 36 | switch { 37 | case cr.FlatChangePercent > criticalThreshold: 38 | return "Critical regression! Immediate investigation required." 39 | case cr.FlatChangePercent > significantThreshold: 40 | return "Significant regression detected. Consider rollback or optimization." 41 | case cr.FlatChangePercent > moderateThreshold: 42 | return "Moderate regression. Review recent changes and optimize if needed." 43 | default: 44 | return "Minor regression detected. Monitor for trends." 45 | } 46 | default: 47 | return "No action required. Continue monitoring." 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /engine/tracker/function_change_result.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | ) 8 | 9 | // Helper method to get a summary 10 | func (cr *FunctionChangeResult) summary() string { 11 | sign := "" 12 | if cr.FlatChangePercent > 0 { 13 | sign = "+" 14 | } 15 | 16 | return fmt.Sprintf("%s: %s%.1f%% (%.3fs → %.3fs)", 17 | cr.FunctionName, 18 | sign, 19 | cr.FlatChangePercent, 20 | cr.FlatAbsolute.Before, 21 | cr.FlatAbsolute.After) 22 | } 23 | 24 | // Full detailed report 25 | func (cr *FunctionChangeResult) Report() string { 26 | var report strings.Builder 27 | 28 | cr.writeHeader(&report) 29 | cr.writeFunctionInfo(&report) 30 | cr.writeStatusAssessment(&report) 31 | cr.writeFlatAnalysis(&report) 32 | cr.writeCumulativeAnalysis(&report) 33 | cr.writeImpactAssessment(&report) 34 | 35 | report.WriteString("\n═══════════════════════════════════════════════════════════════\n") 36 | return report.String() 37 | } 38 | 39 | const ( 40 | SeverityNoneThreshold = 0.0 41 | SeverityLowThreshold = 5.0 42 | SeverityModerateThreshold = 15.0 43 | SeverityHighThreshold = 30.0 44 | ) 45 | 46 | func (cr *FunctionChangeResult) calculateSeverity() string { 47 | absChange := math.Abs(cr.FlatChangePercent) 48 | 49 | switch { 50 | case absChange == SeverityNoneThreshold: 51 | return "NONE" 52 | case absChange < SeverityLowThreshold: 53 | return "LOW" 54 | case absChange < SeverityModerateThreshold: 55 | return "MODERATE" 56 | case absChange < SeverityHighThreshold: 57 | return "HIGH" 58 | default: 59 | return "CRITICAL" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /engine/benchmark/discovery.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | // scanForBenchmarks walks the directory tree looking for benchmark functions 11 | func scanForBenchmarks(root string) ([]string, error) { 12 | pattern := regexp.MustCompile(`(?m)^\s*func\s+(Benchmark[\w\d_]+)\s*\(\s*b\s*\*\s*testing\.B\s*\)\s*{`) 13 | seen := make(map[string]struct{}) 14 | var names []string 15 | 16 | err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { 17 | if err != nil { 18 | return err 19 | } 20 | if d.IsDir() { 21 | return handleDirectory(path) 22 | } 23 | if !strings.HasSuffix(path, "_test.go") { 24 | return nil 25 | } 26 | return processTestFile(path, pattern, seen, &names) 27 | }) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return names, nil 33 | } 34 | 35 | // handleDirectory determines if a directory should be traversed or skipped 36 | func handleDirectory(path string) error { 37 | base := filepath.Base(path) 38 | if strings.HasPrefix(base, ".") || base == "vendor" { 39 | return filepath.SkipDir 40 | } 41 | return nil 42 | } 43 | 44 | // processTestFile extracts benchmark function names from a test file 45 | func processTestFile(path string, pattern *regexp.Regexp, seen map[string]struct{}, names *[]string) error { 46 | data, err := os.ReadFile(path) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | matches := pattern.FindAllSubmatch(data, -1) 52 | for _, m := range matches { 53 | if len(m) >= minCaptureGroups { 54 | name := string(m[1]) 55 | if _, ok := seen[name]; !ok { 56 | seen[name] = struct{}{} 57 | *names = append(*names, name) 58 | } 59 | } 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /engine/benchmark/pipeline.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/AlexsanderHamir/prof/internal" 8 | ) 9 | 10 | func runBenchAndGetProfiles(benchArgs *internal.BenchArgs, benchmarkConfigs map[string]internal.FunctionFilter, groupByPackage bool) error { 11 | slog.Info("Starting benchmark pipeline...") 12 | 13 | var functionFilter internal.FunctionFilter 14 | globalFilter, hasGlobalFilter := benchmarkConfigs[internal.GlobalSign] 15 | if hasGlobalFilter { 16 | functionFilter = globalFilter 17 | } 18 | 19 | for _, benchmarkName := range benchArgs.Benchmarks { 20 | slog.Info("Running benchmark", "Benchmark", benchmarkName) 21 | if err := runBenchmark(benchmarkName, benchArgs.Profiles, benchArgs.Count, benchArgs.Tag); err != nil { 22 | return fmt.Errorf("failed to run %s: %w", benchmarkName, err) 23 | } 24 | 25 | slog.Info("Processing profiles", "Benchmark", benchmarkName) 26 | if err := processProfiles(benchmarkName, benchArgs.Profiles, benchArgs.Tag, groupByPackage); err != nil { 27 | return fmt.Errorf("failed to process profiles for %s: %w", benchmarkName, err) 28 | } 29 | 30 | slog.Info("Analyzing profile functions", "Benchmark", benchmarkName) 31 | 32 | if !hasGlobalFilter { 33 | functionFilter = benchmarkConfigs[benchmarkName] 34 | } 35 | 36 | args := &internal.CollectionArgs{ 37 | Tag: benchArgs.Tag, 38 | Profiles: benchArgs.Profiles, 39 | BenchmarkName: benchmarkName, 40 | BenchmarkConfig: functionFilter, 41 | } 42 | 43 | if err := collectProfileFunctions(args); err != nil { 44 | return fmt.Errorf("failed to analyze profile functions for %s: %w", benchmarkName, err) 45 | } 46 | 47 | slog.Info("Completed pipeline for benchmark", "Benchmark", benchmarkName) 48 | } 49 | 50 | slog.Info(internal.InfoCollectionSuccess) 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /engine/benchmark/api.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/AlexsanderHamir/prof/internal" 9 | ) 10 | 11 | func RunBenchmarks(benchmarks, profiles []string, tag string, count int, groupByPackage bool) error { 12 | if len(benchmarks) == 0 { 13 | return errors.New("benchmarks flag is empty") 14 | } 15 | 16 | if len(profiles) == 0 { 17 | return errors.New("profiles flag is empty") 18 | } 19 | 20 | cfg, err := internal.LoadFromFile(internal.ConfigFilename) 21 | if err != nil { 22 | slog.Info("No config file found at repository root; proceeding without function filters.", "expected", internal.ConfigFilename) 23 | slog.Info("You can generate one with 'prof setup'. It will be placed at the root next to go.mod.") 24 | cfg = &internal.Config{} 25 | } 26 | 27 | if err = setupDirectories(tag, benchmarks, profiles); err != nil { 28 | return fmt.Errorf("failed to setup directories: %w", err) 29 | } 30 | 31 | benchArgs := &internal.BenchArgs{ 32 | Benchmarks: benchmarks, 33 | Profiles: profiles, 34 | Count: count, 35 | Tag: tag, 36 | } 37 | 38 | internal.PrintConfiguration(benchArgs, cfg.FunctionFilter) 39 | 40 | if err = runBenchAndGetProfiles(benchArgs, cfg.FunctionFilter, groupByPackage); err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | // DiscoverBenchmarks scans the Go module for benchmark functions and returns their names. 48 | // A benchmark is identified by functions matching: 49 | // 50 | // func BenchmarkXxx(b *testing.B) { ... } 51 | // 52 | // If scope is provided, only searches within that directory and its subdirectories. 53 | // If scope is empty, searches the entire module from the root. 54 | func DiscoverBenchmarks(scope string) ([]string, error) { 55 | var searchRoot string 56 | var err error 57 | 58 | if scope != "" { 59 | // Use the provided scope directory 60 | searchRoot = scope 61 | } else { 62 | // Fall back to searching from module root 63 | searchRoot, err = internal.FindGoModuleRoot() 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to locate module root: %w", err) 66 | } 67 | } 68 | 69 | return scanForBenchmarks(searchRoot) 70 | } 71 | -------------------------------------------------------------------------------- /engine/tracker/api.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | ) 7 | 8 | // trackAutoSelections holds all the user selections for tracking 9 | type Selections struct { 10 | Baseline string 11 | Current string 12 | BenchmarkName string 13 | ProfileType string 14 | OutputFormat string 15 | UseThreshold bool 16 | RegressionThreshold float64 17 | IsManual bool 18 | } 19 | 20 | var validFormats = map[string]bool{ 21 | "summary": true, 22 | "detailed": true, 23 | "summary-html": true, 24 | "detailed-html": true, 25 | "summary-json": true, 26 | "detailed-json": true, 27 | } 28 | 29 | // runTrack handles the track command execution 30 | func RunTrackAuto(selections *Selections) error { 31 | if !validFormats[selections.OutputFormat] { 32 | return fmt.Errorf("invalid output format '%s'. Valid formats: summary, detailed", selections.OutputFormat) 33 | } 34 | 35 | report, err := CheckPerformanceDifferences(selections) 36 | if err != nil { 37 | return fmt.Errorf("failed to track performance differences: %w", err) 38 | } 39 | 40 | noFunctionChanges := len(report.FunctionChanges) == 0 41 | if noFunctionChanges { 42 | slog.Info("No function changes detected between the two runs") 43 | return nil 44 | } 45 | 46 | report.ChooseOutputFormat(selections.OutputFormat) 47 | 48 | if err = applyCIConfiguration(report, selections); err != nil { 49 | return err 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // RunTrackManual receives the location of the .out / .prof files. 56 | func RunTrackManual(selections *Selections) error { 57 | if !validFormats[selections.OutputFormat] { 58 | return fmt.Errorf("invalid output format '%s'. Valid formats: summary, detailed", selections.OutputFormat) 59 | } 60 | 61 | report, err := CheckPerformanceDifferences(selections) 62 | if err != nil { 63 | return fmt.Errorf("failed to track performance differences: %w", err) 64 | } 65 | 66 | noFunctionChanges := len(report.FunctionChanges) == 0 67 | if noFunctionChanges { 68 | slog.Info("No function changes detected between the two runs") 69 | return nil 70 | } 71 | 72 | report.ChooseOutputFormat(selections.OutputFormat) 73 | 74 | // Apply CI/CD filtering and thresholds 75 | if err = applyCIConfiguration(report, selections); err != nil { 76 | return err 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /engine/collector/api.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/AlexsanderHamir/prof/internal" 10 | ) 11 | 12 | // RunCollector handles data organization without wrapping go test. 13 | func RunCollector(files []string, tag string, groupByPackage bool) error { 14 | if err := ensureDirExists(internal.MainDirOutput); err != nil { 15 | return err 16 | } 17 | 18 | tagDir := filepath.Join(internal.MainDirOutput, tag) 19 | if err := internal.CleanOrCreateTag(tagDir); err != nil { 20 | return fmt.Errorf("CleanOrCreateTag failed: %w", err) 21 | } 22 | 23 | cfg, err := internal.LoadFromFile(internal.ConfigFilename) 24 | if err != nil { 25 | cfg = &internal.Config{} 26 | } 27 | 28 | globalFilter, _ := getGlobalFunctionFilter(cfg) 29 | 30 | for _, fullBinaryPath := range files { 31 | if processErr := processBinaryFile(fullBinaryPath, tagDir, cfg, globalFilter, groupByPackage); processErr != nil { 32 | return processErr 33 | } 34 | } 35 | return nil 36 | } 37 | 38 | func GetProfileTextOutput(binaryFile, outputFile string) error { 39 | pprofTextParams := getPprofTextParams() 40 | cmd := append([]string{"go", "tool", "pprof"}, pprofTextParams...) 41 | cmd = append(cmd, binaryFile) 42 | 43 | // #nosec G204 -- cmd is constructed internally by generateTextProfile(), not from user input 44 | execCmd := exec.Command(cmd[0], cmd[1:]...) 45 | output, err := execCmd.Output() 46 | if err != nil { 47 | return fmt.Errorf("pprof command failed: %w", err) 48 | } 49 | 50 | return os.WriteFile(outputFile, output, internal.PermFile) 51 | } 52 | 53 | func GetPNGOutput(binaryFile, outputFile string) error { 54 | cmd := []string{"go", "tool", "pprof", "-png", binaryFile} 55 | 56 | // #nosec G204 -- cmd is constructed internally by GetPNGOutput(), not from user input 57 | execCmd := exec.Command(cmd[0], cmd[1:]...) 58 | output, err := execCmd.Output() 59 | if err != nil { 60 | return fmt.Errorf("pprof PNG generation failed: %w", err) 61 | } 62 | 63 | return os.WriteFile(outputFile, output, internal.PermFile) 64 | } 65 | 66 | // GetFunctionsOutput calls [GetFunctionPprofContent] sequentially. 67 | func GetFunctionsOutput(functions []string, binaryPath, basePath string) error { 68 | for _, functionName := range functions { 69 | outputFile := filepath.Join(basePath, functionName+"."+internal.TextExtension) 70 | if err := getFunctionPprofContent(functionName, binaryPath, outputFile); err != nil { 71 | return fmt.Errorf("failed to extract function content for %s: %w", functionName, err) 72 | } 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/types.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | // #1 Config Fields 4 | 5 | // Config holds the main configuration for the prof tool. 6 | type Config struct { 7 | FunctionFilter map[string]FunctionFilter `json:"function_collection_filter"` 8 | // CI/CD specific configuration for performance tracking 9 | CIConfig *CIConfig `json:"ci_config,omitempty"` 10 | } 11 | 12 | // FunctionCollectionFilter defines filters for a specific benchmark, 13 | // the filters are used when deciding which functions to collect 14 | // code line level information for. 15 | type FunctionFilter struct { 16 | // Prefixes: only collect functions starting with these prefixes 17 | // Example: []string{"github.com/example/GenPool"} 18 | IncludePrefixes []string `json:"include_prefixes,omitempty"` 19 | 20 | // IgnoreFunctions ignores the function name after the last dot. 21 | // Example: "Get,Set" excludes pool.Get() and cache.Set() 22 | IgnoreFunctions []string `json:"ignore_functions,omitempty"` 23 | } 24 | 25 | // CIConfig holds CI/CD specific configuration for performance tracking 26 | type CIConfig struct { 27 | // Global CI/CD settings that apply to all tracking operations 28 | Global *CITrackingConfig `json:"global,omitempty"` 29 | 30 | // Benchmark-specific CI/CD settings 31 | Benchmarks map[string]CITrackingConfig `json:"benchmarks,omitempty"` 32 | } 33 | 34 | // CITrackingConfig defines CI/CD specific filtering for performance tracking 35 | type CITrackingConfig struct { 36 | // Functions to ignore during performance comparison (reduces noise) 37 | // These functions won't cause CI/CD failures even if they regress 38 | IgnoreFunctions []string `json:"ignore_functions,omitempty"` 39 | 40 | // Function prefixes to ignore during performance comparison 41 | // Example: ["runtime.", "reflect."] ignores all runtime and reflect functions 42 | IgnorePrefixes []string `json:"ignore_prefixes,omitempty"` 43 | 44 | // Minimum change threshold for CI/CD failure 45 | // Only functions with changes >= this threshold will cause failures 46 | MinChangeThreshold float64 `json:"min_change_threshold,omitempty"` 47 | 48 | // Maximum acceptable regression percentage for CI/CD 49 | // Overrides command-line regression threshold if set 50 | MaxRegressionThreshold float64 `json:"max_regression_threshold,omitempty"` 51 | 52 | // Whether to fail on improvements (useful for detecting unexpected optimizations) 53 | FailOnImprovement bool `json:"fail_on_improvement,omitempty"` 54 | } 55 | 56 | // #2 - Function Arguments 57 | 58 | type LineFilterArgs struct { 59 | ProfileFilters map[int]float64 60 | IgnoreFunctionSet map[string]struct{} 61 | IgnorePrefixSet map[string]struct{} 62 | } 63 | 64 | type CollectionArgs struct { 65 | Tag string 66 | Profiles []string 67 | BenchmarkName string 68 | BenchmarkConfig FunctionFilter 69 | } 70 | 71 | type BenchArgs struct { 72 | Benchmarks []string 73 | Profiles []string 74 | Count int 75 | Tag string 76 | } 77 | -------------------------------------------------------------------------------- /engine/benchmark/files.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/AlexsanderHamir/prof/internal" 11 | ) 12 | 13 | // getExpectedProfileFileName gets the expected file name for a profile such as "cpu.out". 14 | func getExpectedProfileFileName(profile string) (string, bool) { 15 | expectedFileName, exists := ExpectedFiles[profile] 16 | if !exists { 17 | return "", false 18 | } 19 | return expectedFileName, true 20 | } 21 | 22 | // findMostRecentFile searches for the most recently modified file named fileName under rootDir. 23 | // In case a user has some pprof files from manual runs, we don't want mix ups. 24 | func findMostRecentFile(rootDir, fileName string) (string, error) { 25 | var latestPath string 26 | var latestMod time.Time 27 | 28 | err := filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { 29 | if err != nil { 30 | return err 31 | } 32 | if d.IsDir() { 33 | return nil 34 | } 35 | if filepath.Base(path) != fileName { 36 | return nil 37 | } 38 | info, statErr := d.Info() 39 | if statErr != nil { 40 | return statErr 41 | } 42 | if info.ModTime().After(latestMod) { 43 | latestMod = info.ModTime() 44 | latestPath = path 45 | } 46 | return nil 47 | }) 48 | 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | return latestPath, nil 54 | } 55 | 56 | func moveProfileFiles(benchmarkName string, profiles []string, rootDir string, binDir string) error { 57 | for _, profile := range profiles { 58 | profileFile, ok := getExpectedProfileFileName(profile) 59 | if !ok { 60 | continue 61 | } 62 | 63 | latestPath, err := findMostRecentFile(rootDir, profileFile) 64 | if err != nil { 65 | return fmt.Errorf("failed to search for profile files: %w", err) 66 | } 67 | if latestPath == "" { 68 | continue 69 | } 70 | 71 | destPath := filepath.Join(binDir, fmt.Sprintf("%s_%s.%s", benchmarkName, profile, binExtension)) 72 | if err = os.Rename(latestPath, destPath); err != nil { 73 | return fmt.Errorf("failed to move profile file %s: %w", latestPath, err) 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func moveTestFiles(benchmarkName, rootDir, binDir string) error { 81 | var testFiles []string 82 | err := filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { 83 | if err != nil { 84 | return err 85 | } 86 | 87 | if d.IsDir() { 88 | return nil 89 | } 90 | 91 | if strings.HasSuffix(path, internal.ExpectedTestSuffix) { 92 | testFiles = append(testFiles, path) 93 | } 94 | return nil 95 | }) 96 | 97 | if err != nil { 98 | return fmt.Errorf("WalkDir Failed: %w", err) 99 | } 100 | 101 | for _, file := range testFiles { 102 | newPath := filepath.Join(binDir, fmt.Sprintf("%s_%s", benchmarkName, filepath.Base(file))) 103 | if err = os.Rename(file, newPath); err != nil { 104 | return fmt.Errorf("failed to move test file %s: %w", file, err) 105 | } 106 | } 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /engine/tools/qcachegrind/api.go: -------------------------------------------------------------------------------- 1 | package qcachegrind 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/AlexsanderHamir/prof/internal" 11 | ) 12 | 13 | // go tool pprof -callgrind {benchmarkName}_{profileName}.out > {benchmarkName}_{profileName}.callgrind 14 | // qcachegrind profile.callgrind 15 | 16 | func RunQcacheGrind(tag, benchName, profileName string) error { 17 | // 1. find the binary file given the parameters of the function, it will be located under bench/tag/bin/benchName/{benchmarkName}_{profileName}.out 18 | binaryFilePath := filepath.Join(internal.MainDirOutput, tag, internal.ProfileBinDir, benchName, fmt.Sprintf("%s_%s.out", benchName, profileName)) 19 | 20 | if _, err := os.Stat(binaryFilePath); os.IsNotExist(err) { 21 | return fmt.Errorf("binary file not found: %s", binaryFilePath) 22 | } 23 | 24 | // 2. Create a callgrind file out of the binary by running the following command (EXAMPLE): 25 | // a. go tool pprof -callgrind {benchmarkName}_{profileName}.out > {benchmarkName}_{profileName}.callgrind 26 | // b. save the command under tools/qcachegrind/{benchmarkName}_results.callgrind 27 | 28 | // Create the output directory for qcachegrind results 29 | outputDir := filepath.Join(internal.MainDirOutput, internal.ToolDir, "qcachegrind") 30 | if err := os.MkdirAll(outputDir, internal.PermDir); err != nil { 31 | return fmt.Errorf("failed to create output directory: %w", err) 32 | } 33 | 34 | // Generate the callgrind file 35 | callgrindOutputPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.callgrind", benchName, profileName)) 36 | 37 | cmd := exec.Command("go", "tool", "pprof", "-callgrind", binaryFilePath) 38 | outputFile, err := os.Create(callgrindOutputPath) 39 | if err != nil { 40 | return fmt.Errorf("failed to create callgrind output file: %w", err) 41 | } 42 | defer outputFile.Close() 43 | 44 | cmd.Stdout = outputFile 45 | cmd.Stderr = os.Stderr 46 | 47 | if err = cmd.Run(); err != nil { 48 | return fmt.Errorf("failed to generate callgrind file: %w", err) 49 | } 50 | 51 | fmt.Printf("Generated callgrind file: %s\n", callgrindOutputPath) 52 | 53 | // 3. Use the output to call qcachegrind profile.callgrind and launch it for the user to analyze. 54 | 55 | // Check if qcachegrind is installed 56 | if _, err = exec.LookPath("qcachegrind"); err != nil { 57 | return errors.New("qcachegrind command not found. Please install it first: sudo apt-get install qcachegrind (Ubuntu/Debian) or brew install qcachegrind (macOS)") 58 | } 59 | 60 | // Launch qcachegrind with the generated callgrind file 61 | launchCmd := exec.Command("qcachegrind", callgrindOutputPath) 62 | launchCmd.Stdout = os.Stdout 63 | launchCmd.Stderr = os.Stderr 64 | 65 | fmt.Printf("Launching qcachegrind with file: %s\n", callgrindOutputPath) 66 | 67 | // Run qcachegrind in the background so the user can interact with it 68 | if err = launchCmd.Start(); err != nil { 69 | return fmt.Errorf("failed to launch qcachegrind: %w", err) 70 | } 71 | 72 | fmt.Println("qcachegrind launched successfully. You can now analyze the profile data.") 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /engine/benchmark/directories.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/AlexsanderHamir/prof/internal" 10 | ) 11 | 12 | // createBenchDirectories creates the main structure of the library's output. 13 | func createBenchDirectories(tagDir string, benchmarks []string) error { 14 | binDir := filepath.Join(tagDir, internal.ProfileBinDir) 15 | textDir := filepath.Join(tagDir, internal.ProfileTextDir) 16 | descFile := filepath.Join(tagDir, descriptionFileName) 17 | 18 | // Create main directories 19 | if err := os.Mkdir(binDir, internal.PermDir); err != nil { 20 | return fmt.Errorf("failed to create bin directory: %w", err) 21 | } 22 | if err := os.Mkdir(textDir, internal.PermDir); err != nil { 23 | return fmt.Errorf("failed to create text directory: %w", err) 24 | } 25 | 26 | // Create benchmark subdirectories 27 | for _, benchmark := range benchmarks { 28 | if err := os.Mkdir(filepath.Join(binDir, benchmark), internal.PermDir); err != nil { 29 | return fmt.Errorf("failed to create bin subdirectory for %s: %w", benchmark, err) 30 | } 31 | if err := os.Mkdir(filepath.Join(textDir, benchmark), internal.PermDir); err != nil { 32 | return fmt.Errorf("failed to create text subdirectory for %s: %w", benchmark, err) 33 | } 34 | } 35 | 36 | // Create description file 37 | if err := os.WriteFile(descFile, []byte(descritpionFileMessage), internal.PermFile); err != nil { 38 | return fmt.Errorf("failed to create description file: %w", err) 39 | } 40 | 41 | slog.Info("Created directory structure", "dir", tagDir) 42 | return nil 43 | } 44 | 45 | // createProfileFunctionDirectories creates the structure for the code line level data collection. 46 | func createProfileFunctionDirectories(tagDir string, profiles, benchmarks []string) error { 47 | for _, profileName := range profiles { 48 | profileDirPath := filepath.Join(tagDir, profileName+internal.FunctionsDirSuffix) 49 | if err := os.Mkdir(profileDirPath, internal.PermDir); err != nil { 50 | return fmt.Errorf("failed to create profile directory %s: %w", profileDirPath, err) 51 | } 52 | 53 | for _, benchmark := range benchmarks { 54 | benchmarkDirPath := filepath.Join(profileDirPath, benchmark) 55 | if err := os.Mkdir(benchmarkDirPath, internal.PermDir); err != nil { 56 | return fmt.Errorf("failed to create benchmark directory %s: %w", benchmarkDirPath, err) 57 | } 58 | } 59 | } 60 | 61 | slog.Info("Created profile function directories") 62 | return nil 63 | } 64 | 65 | // SetupDirectories creates the structure of the library's output. 66 | func setupDirectories(tag string, benchmarks, profiles []string) error { 67 | currentDir, err := os.Getwd() 68 | if err != nil { 69 | return err 70 | } 71 | 72 | tagDir := filepath.Join(currentDir, internal.MainDirOutput, tag) 73 | err = internal.CleanOrCreateTag(tagDir) 74 | if err != nil { 75 | return fmt.Errorf("CleanOrCreateTag failed: %w", err) 76 | } 77 | 78 | if err = createBenchDirectories(tagDir, benchmarks); err != nil { 79 | return err 80 | } 81 | 82 | return createProfileFunctionDirectories(tagDir, profiles, benchmarks) 83 | } 84 | -------------------------------------------------------------------------------- /cli/discovery.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/AlexsanderHamir/prof/internal" 10 | ) 11 | 12 | // discoverAvailableTags scans the bench directory for existing tags 13 | func discoverAvailableTags() ([]string, error) { 14 | root, err := internal.FindGoModuleRoot() 15 | if err != nil { 16 | return nil, fmt.Errorf("failed to locate module root: %w", err) 17 | } 18 | 19 | benchDir := filepath.Join(root, internal.MainDirOutput) 20 | entries, err := os.ReadDir(benchDir) 21 | if err != nil { 22 | if os.IsNotExist(err) { 23 | return []string{}, nil 24 | } 25 | return nil, fmt.Errorf("failed to read bench directory: %w", err) 26 | } 27 | 28 | var tags []string 29 | for _, entry := range entries { 30 | if entry.IsDir() { 31 | tags = append(tags, entry.Name()) 32 | } 33 | } 34 | 35 | return tags, nil 36 | } 37 | 38 | // discoverAvailableBenchmarks scans a specific tag directory for available benchmarks 39 | func discoverAvailableBenchmarks(tag string) ([]string, error) { 40 | root, err := internal.FindGoModuleRoot() 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to locate module root: %w", err) 43 | } 44 | 45 | benchDir := filepath.Join(root, internal.MainDirOutput, tag, internal.ProfileTextDir) 46 | entries, err := os.ReadDir(benchDir) 47 | if err != nil { 48 | if os.IsNotExist(err) { 49 | return []string{}, nil 50 | } 51 | return nil, fmt.Errorf("failed to read benchmark directory for tag %s: %w", tag, err) 52 | } 53 | 54 | var availableBenchmarks []string 55 | for _, entry := range entries { 56 | if entry.IsDir() { 57 | availableBenchmarks = append(availableBenchmarks, entry.Name()) 58 | } 59 | } 60 | 61 | return availableBenchmarks, nil 62 | } 63 | 64 | // discoverAvailableProfiles scans a specific tag and benchmark for available profile types 65 | func discoverAvailableProfiles(tag, benchmarkName string) ([]string, error) { 66 | root, err := internal.FindGoModuleRoot() 67 | if err != nil { 68 | return nil, fmt.Errorf("failed to locate module root: %w", err) 69 | } 70 | 71 | benchDir := filepath.Join(root, internal.MainDirOutput, tag, internal.ProfileTextDir, benchmarkName) 72 | entries, err := os.ReadDir(benchDir) 73 | if err != nil { 74 | if os.IsNotExist(err) { 75 | return []string{}, nil 76 | } 77 | return nil, fmt.Errorf("failed to read profile directory for tag %s, benchmark %s: %w", tag, benchmarkName, err) 78 | } 79 | 80 | var availableProfiles []string 81 | for _, entry := range entries { 82 | if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".txt") { 83 | // Extract profile type from filename like "BenchmarkName_cpu.txt" 84 | name := entry.Name() 85 | if strings.HasPrefix(name, benchmarkName+"_") { 86 | profileTypeName := strings.TrimSuffix(strings.TrimPrefix(name, benchmarkName+"_"), ".txt") 87 | if profileTypeName == "cpu" || profileTypeName == "memory" || profileTypeName == "mutex" || profileTypeName == "block" { 88 | availableProfiles = append(availableProfiles, profileTypeName) 89 | } 90 | } 91 | } 92 | } 93 | 94 | return availableProfiles, nil 95 | } 96 | -------------------------------------------------------------------------------- /engine/tools/benchstats/api.go: -------------------------------------------------------------------------------- 1 | package benchstats 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/AlexsanderHamir/prof/internal" 11 | ) 12 | 13 | const ( 14 | benchstatCommand = "benchstat" 15 | ) 16 | 17 | func RunBenchStats(baseTag, currentTag, benchName string) error { 18 | // 1. Look for the bench directory created by our library under the current directory where this command will be run 19 | benchDir := internal.MainDirOutput 20 | if _, err := os.Stat(benchDir); os.IsNotExist(err) { 21 | return errors.New("bench directory not found in current directory") 22 | } 23 | 24 | // 2. Inside the bench directory, look for the passed tags, if one of them don't exist return an error. 25 | baseTagPath := filepath.Join(benchDir, baseTag) 26 | currentTagPath := filepath.Join(benchDir, currentTag) 27 | 28 | if _, err := os.Stat(baseTagPath); os.IsNotExist(err) { 29 | return fmt.Errorf("base tag directory '%s' not found in bench directory", baseTag) 30 | } 31 | 32 | if _, err := os.Stat(currentTagPath); os.IsNotExist(err) { 33 | return fmt.Errorf("current tag directory '%s' not found in bench directory", currentTag) 34 | } 35 | 36 | // 3. Once both directories are found look for the path text/{benchmarkname}/{benchmarkname}.txt, this file contains the data for benchstats 37 | baseTextPath := filepath.Join(baseTagPath, internal.ProfileTextDir, benchName, benchName+"."+internal.TextExtension) 38 | currentTextPath := filepath.Join(currentTagPath, internal.ProfileTextDir, benchName, benchName+"."+internal.TextExtension) 39 | 40 | if _, err := os.Stat(baseTextPath); os.IsNotExist(err) { 41 | return fmt.Errorf("base benchmark text file not found: %s", baseTextPath) 42 | } 43 | 44 | if _, err := os.Stat(currentTextPath); os.IsNotExist(err) { 45 | return fmt.Errorf("current benchmark text file not found: %s", currentTextPath) 46 | } 47 | 48 | // 4. Run benchstats programmatically, if benchstats is not installed on the machine return an error. 49 | // Check if benchstats is installed 50 | if _, err := exec.LookPath(benchstatCommand); err != nil { 51 | return errors.New("benchstat command not found. Please install it first: go install golang.org/x/perf/cmd/benchstat@latest") 52 | } 53 | 54 | // Run benchstat command 55 | cmd := exec.Command(benchstatCommand, baseTextPath, currentTextPath) 56 | output, err := cmd.CombinedOutput() 57 | if err != nil { 58 | return fmt.Errorf("failed to run benchstat: %w, output: %s", err, string(output)) 59 | } 60 | 61 | // 5. Print the output to the terminal and save it under bench/tools/benchstats/{benchmarkname}_results.txt 62 | fmt.Println("Benchmark comparison results:") 63 | fmt.Println(string(output)) 64 | 65 | // Save results to file 66 | resultsDir := filepath.Join(benchDir, internal.ToolDir, benchstatCommand) 67 | if err = os.MkdirAll(resultsDir, internal.PermDir); err != nil { 68 | return fmt.Errorf("failed to create results directory: %w", err) 69 | } 70 | 71 | resultsFile := filepath.Join(resultsDir, benchName+internal.ToolsResultsSuffix) 72 | if err = os.WriteFile(resultsFile, output, internal.PermFile); err != nil { 73 | return fmt.Errorf("failed to save results to file: %w", err) 74 | } 75 | 76 | fmt.Printf("Results saved to: %s\n", resultsFile) 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /cli/tui.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/AlecAivazis/survey/v2" 10 | "github.com/AlexsanderHamir/prof/engine/benchmark" 11 | "github.com/AlexsanderHamir/prof/engine/tracker" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func runTUI(_ *cobra.Command, _ []string) error { 16 | // Get current working directory for scope-aware benchmark discovery 17 | currentDir, err := os.Getwd() 18 | if err != nil { 19 | return fmt.Errorf("failed to get current working directory: %w", err) 20 | } 21 | 22 | benchNames, err := benchmark.DiscoverBenchmarks(currentDir) 23 | if err != nil { 24 | return fmt.Errorf("failed to discover benchmarks: %w", err) 25 | } 26 | 27 | if len(benchNames) == 0 { 28 | return errors.New("no benchmarks found in this directory or its subdirectories (look for func BenchmarkXxx(b *testing.B) in *_test.go)") 29 | } 30 | 31 | var selectedBenches []string 32 | benchPrompt := &survey.MultiSelect{ 33 | Message: "Select benchmarks to run:", 34 | Options: benchNames, 35 | PageSize: tuiPageSize, 36 | } 37 | if err = survey.AskOne(benchPrompt, &selectedBenches, survey.WithValidator(survey.Required)); err != nil { 38 | return err 39 | } 40 | 41 | profilesOptions := benchmark.SupportedProfiles 42 | var selectedProfiles []string 43 | profilesPrompt := &survey.MultiSelect{ 44 | Message: "Select profiles:", 45 | Options: profilesOptions, 46 | Default: []string{"cpu"}, 47 | } 48 | 49 | if err = survey.AskOne(profilesPrompt, &selectedProfiles, survey.WithValidator(survey.Required)); err != nil { 50 | return err 51 | } 52 | 53 | var countStr string 54 | countPrompt := &survey.Input{Message: "Number of runs (count):", Default: "1"} 55 | if err = survey.AskOne(countPrompt, &countStr, survey.WithValidator(survey.Required)); err != nil { 56 | return err 57 | } 58 | runCount, convErr := strconv.Atoi(countStr) 59 | if convErr != nil || runCount < 1 { 60 | return fmt.Errorf("invalid count: %s", countStr) 61 | } 62 | 63 | var tagStr string 64 | tagPrompt := &survey.Input{Message: "Tag name (used to group results under bench/):"} 65 | if err = survey.AskOne(tagPrompt, &tagStr, survey.WithValidator(survey.Required)); err != nil { 66 | return err 67 | } 68 | 69 | if err = benchmark.RunBenchmarks(selectedBenches, selectedProfiles, tagStr, runCount, false); err != nil { 70 | return err 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func runTUITrackAuto(_ *cobra.Command, _ []string) error { 77 | // Discover available tags 78 | tags, err := discoverAvailableTags() 79 | if err != nil { 80 | return fmt.Errorf("failed to discover available tags: %w", err) 81 | } 82 | if len(tags) < minTagsForComparison { 83 | return errors.New("need at least 2 tags to compare (run 'prof tui' first to collect some data)") 84 | } 85 | 86 | // Get user selections 87 | selections, err := getTrackSelections(tags) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // Set global variables for the existing tracking logic 93 | setGlobalTrackingVariables(selections) 94 | 95 | // Now run the actual tracking command 96 | fmt.Printf("\n🚀 Running: prof track auto --base %s --current %s --bench-name %s --profile-type %s --output-format %s", 97 | selections.Baseline, selections.Current, selections.BenchmarkName, selections.ProfileType, selections.OutputFormat) 98 | if selections.UseThreshold { 99 | fmt.Printf(" --fail-on-regression --regression-threshold %.1f", selections.RegressionThreshold) 100 | } 101 | fmt.Println() 102 | 103 | return tracker.RunTrackAuto(selections) 104 | } 105 | -------------------------------------------------------------------------------- /parser/apiv2.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/AlexsanderHamir/prof/internal" 5 | ) 6 | 7 | // TurnLinesIntoObjectsV2 turn profile data from a .pprof file into line objects. 8 | func TurnLinesIntoObjectsV2(profilePath string) ([]*LineObj, error) { 9 | profileData, err := extractProfileData(profilePath) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | var lineObjs []*LineObj 15 | for _, entry := range profileData.SortedEntries { 16 | fn := entry.Name 17 | lineObj := &LineObj{ 18 | FnName: fn, 19 | Flat: float64(entry.Flat), 20 | FlatPercentage: profileData.FlatPercentages[fn], 21 | SumPercentage: profileData.SumPercentages[fn], 22 | Cum: float64(profileData.Cum[fn]), 23 | CumPercentage: profileData.CumPercentages[fn], 24 | } 25 | lineObjs = append(lineObjs, lineObj) 26 | } 27 | 28 | return lineObjs, nil 29 | } 30 | 31 | // GetAllFunctionNamesV2 extracts all function names from a pprof file, the function name is the name after the last dot. 32 | func GetAllFunctionNamesV2(profilePath string, filter internal.FunctionFilter) (names []string, err error) { 33 | profileData, err := extractProfileData(profilePath) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | ignoreSet := getFilterSets(filter.IgnoreFunctions) 39 | for _, entry := range profileData.SortedEntries { 40 | fn := entry.Name 41 | 42 | // Extract the function name from the full function path 43 | funcName := extractSimpleFunctionName(fn) 44 | if funcName == "" { 45 | continue 46 | } 47 | 48 | // Check if function should be ignored 49 | if _, ignored := ignoreSet[funcName]; ignored { 50 | continue 51 | } 52 | 53 | // Check if function matches include prefixes 54 | if len(filter.IncludePrefixes) > 0 && !matchPrefix(fn, filter.IncludePrefixes) { 55 | continue 56 | } 57 | 58 | names = append(names, funcName) 59 | } 60 | 61 | return names, nil 62 | } 63 | 64 | // OrganizeProfileByPackageV2 organizes profile data by package/module and returns a formatted string 65 | // that groups functions by their package/module with subtotals and percentages. 66 | func OrganizeProfileByPackageV2(profilePath string, filter internal.FunctionFilter) (string, error) { 67 | profileData, err := extractProfileData(profilePath) 68 | if err != nil { 69 | return "", err 70 | } 71 | 72 | // Group functions by package/module 73 | packageGroups := make(map[string]*PackageGroup) 74 | ignoreSet := getFilterSets(filter.IgnoreFunctions) 75 | 76 | for _, entry := range profileData.SortedEntries { 77 | fn := entry.Name 78 | 79 | // Extract the function name from the full function path 80 | funcName := extractSimpleFunctionName(fn) 81 | if funcName == "" { 82 | continue 83 | } 84 | 85 | // Check if function should be ignored 86 | if _, ignored := ignoreSet[funcName]; ignored { 87 | continue 88 | } 89 | 90 | // Check if function matches include prefixes 91 | if len(filter.IncludePrefixes) > 0 && !matchPrefix(fn, filter.IncludePrefixes) { 92 | continue 93 | } 94 | 95 | // Extract package name 96 | packageName := extractPackageName(fn) 97 | if packageName == "" { 98 | packageName = "unknown" 99 | } 100 | 101 | // Initialize package group if it doesn't exist 102 | if packageGroups[packageName] == nil { 103 | packageGroups[packageName] = &PackageGroup{ 104 | Name: packageName, 105 | Functions: make([]*FunctionInfo, 0), 106 | TotalFlat: 0, 107 | TotalCum: 0, 108 | } 109 | } 110 | 111 | // Add function to package group 112 | funcInfo := &FunctionInfo{ 113 | Name: funcName, 114 | FullName: fn, 115 | Flat: float64(entry.Flat), 116 | FlatPercentage: profileData.FlatPercentages[fn], 117 | Cum: float64(profileData.Cum[fn]), 118 | CumPercentage: profileData.CumPercentages[fn], 119 | SumPercentage: profileData.SumPercentages[fn], 120 | } 121 | 122 | packageGroups[packageName].Functions = append(packageGroups[packageName].Functions, funcInfo) 123 | packageGroups[packageName].TotalFlat += funcInfo.Flat 124 | packageGroups[packageName].TotalCum += funcInfo.Cum 125 | } 126 | 127 | // Calculate package percentages and sort 128 | totalFlat := float64(profileData.Total) 129 | for _, pkg := range packageGroups { 130 | pkg.FlatPercentage = pkg.TotalFlat / totalFlat * 100 131 | pkg.CumPercentage = pkg.TotalCum / totalFlat * 100 132 | } 133 | 134 | // Sort packages by flat percentage (descending) 135 | sortedPackages := sortPackagesByFlatPercentage(packageGroups) 136 | 137 | // Generate formatted output 138 | return formatPackageReport(sortedPackages), nil 139 | } 140 | -------------------------------------------------------------------------------- /engine/benchmark/execution.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/AlexsanderHamir/prof/internal" 12 | ) 13 | 14 | // findBenchmarkPackageDir walks the module root to locate the package directory 15 | // that defines the uniquely named benchmark function. 16 | func findBenchmarkPackageDir(moduleRoot, benchmarkName string) (string, error) { 17 | pattern := regexp.MustCompile(`(?m)^\s*func\s+` + regexp.QuoteMeta(benchmarkName) + `\s*\(\s*b\s*\*\s*testing\.B\s*\)\s*{`) 18 | 19 | var foundDir string 20 | err := filepath.WalkDir(moduleRoot, func(path string, d os.DirEntry, err error) error { 21 | if err != nil { 22 | return err 23 | } 24 | if d.IsDir() { 25 | base := filepath.Base(path) 26 | if strings.HasPrefix(base, ".") || base == "vendor" { 27 | return filepath.SkipDir 28 | } 29 | return nil 30 | } 31 | 32 | if !strings.HasSuffix(path, "_test.go") { 33 | return nil 34 | } 35 | 36 | data, readErr := os.ReadFile(path) 37 | if readErr != nil { 38 | return readErr 39 | } 40 | 41 | if pattern.Find(data) != nil { 42 | foundDir = filepath.Dir(path) 43 | return nil 44 | } 45 | 46 | return nil 47 | }) 48 | 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | if foundDir == "" { 54 | return "", fmt.Errorf("benchmark %s not found in module", benchmarkName) 55 | } 56 | 57 | return foundDir, nil 58 | } 59 | 60 | // buildBenchmarkCommand builds the command to run the benchmark. 61 | func buildBenchmarkCommand(benchmarkName string, profiles []string, count int) ([]string, error) { 62 | cmd := []string{ 63 | "go", "test", "-run=^$", 64 | fmt.Sprintf("-bench=^%s$", benchmarkName), 65 | "-benchmem", 66 | fmt.Sprintf("-count=%d", count), 67 | } 68 | 69 | for _, profile := range profiles { 70 | flag, exists := ProfileFlags[profile] 71 | if !exists { 72 | return nil, fmt.Errorf("profile %s is not supported", profile) 73 | } 74 | 75 | cmd = append(cmd, flag) 76 | } 77 | 78 | return cmd, nil 79 | } 80 | 81 | // getOutputDirectoriesPath returns the paths of the necessary output directories. 82 | func getOutputDirectoriesPath(benchmarkName, tag string) (textDir string, binDir string) { 83 | tagDir := filepath.Join(internal.MainDirOutput, tag) 84 | textDir = filepath.Join(tagDir, internal.ProfileTextDir, benchmarkName) 85 | binDir = filepath.Join(tagDir, internal.ProfileBinDir, benchmarkName) 86 | 87 | return textDir, binDir 88 | } 89 | 90 | func runBenchmarkCommand(cmd []string, outputFile string, rootDir string) error { 91 | // cmd[0] = executable program (e.g., "go") 92 | // cmd[1:] = arguments to pass to the program (e.g., ["test", "-run=^$", "-bench=..."]) 93 | // #nosec G204 -- cmd is constructed internally by buildBenchmarkCommand(), not from user input 94 | execCmd := exec.Command(cmd[0], cmd[1:]...) 95 | if rootDir != "" { 96 | execCmd.Dir = rootDir 97 | } 98 | 99 | output, err := execCmd.CombinedOutput() 100 | 101 | // Always print the output, even if there was an error - it may contain meaningful information 102 | fmt.Println("🚀 ==================== BENCHMARK OUTPUT ==================== 🚀") 103 | fmt.Println(string(output)) 104 | fmt.Println("📊 ========================================================== 📊") 105 | 106 | if err != nil { 107 | if strings.Contains(string(output), moduleNotFoundMsg) { 108 | return fmt.Errorf("❌ %s - ensure you're in a Go project directory 📁", moduleNotFoundMsg) 109 | } 110 | return fmt.Errorf("💥 BENCHMARK COMMAND FAILED 💥\n%s", string(output)) 111 | } 112 | 113 | return os.WriteFile(outputFile, output, internal.PermFile) 114 | } 115 | 116 | // RunBenchmark runs a specific benchmark and collects all of its information. 117 | func runBenchmark(benchmarkName string, profiles []string, count int, tag string) error { 118 | cmd, err := buildBenchmarkCommand(benchmarkName, profiles, count) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | textDir, binDir := getOutputDirectoriesPath(benchmarkName, tag) 124 | 125 | moduleRoot, err := internal.FindGoModuleRoot() 126 | if err != nil { 127 | return fmt.Errorf("failed to find Go module root: %w", err) 128 | } 129 | 130 | pkgDir, err := findBenchmarkPackageDir(moduleRoot, benchmarkName) 131 | if err != nil { 132 | return fmt.Errorf("failed to locate benchmark %s: %w", benchmarkName, err) 133 | } 134 | 135 | outputFile := filepath.Join(textDir, fmt.Sprintf("%s.%s", benchmarkName, internal.TextExtension)) 136 | if err = runBenchmarkCommand(cmd, outputFile, pkgDir); err != nil { 137 | return err 138 | } 139 | 140 | if err = moveProfileFiles(benchmarkName, profiles, pkgDir, binDir); err != nil { 141 | return err 142 | } 143 | 144 | return moveTestFiles(benchmarkName, pkgDir, binDir) 145 | } 146 | -------------------------------------------------------------------------------- /cli/selections.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/AlecAivazis/survey/v2" 8 | 9 | "github.com/AlexsanderHamir/prof/engine/tracker" 10 | ) 11 | 12 | // getTrackSelections collects all user selections interactively 13 | func getTrackSelections(tags []string) (*tracker.Selections, error) { 14 | selections := &tracker.Selections{} 15 | 16 | // Select baseline tag 17 | baselinePrompt := &survey.Select{ 18 | Message: "Select baseline tag (the 'before' version) [Press Enter to select]:", 19 | Options: tags, 20 | PageSize: tuiPageSize, 21 | } 22 | if err := survey.AskOne(baselinePrompt, &selections.Baseline, survey.WithValidator(survey.Required)); err != nil { 23 | return nil, err 24 | } 25 | 26 | // Select current tag (filter out baseline) 27 | var currentOptions []string 28 | for _, tag := range tags { 29 | if tag != selections.Baseline { 30 | currentOptions = append(currentOptions, tag) 31 | } 32 | } 33 | 34 | currentPrompt := &survey.Select{ 35 | Message: "Select current tag (the 'after' version) [Press Enter to select]:", 36 | Options: currentOptions, 37 | PageSize: tuiPageSize, 38 | } 39 | if err := survey.AskOne(currentPrompt, &selections.Current, survey.WithValidator(survey.Required)); err != nil { 40 | return nil, err 41 | } 42 | 43 | // Discover and select benchmark 44 | if err := selectBenchmark(selections); err != nil { 45 | return nil, err 46 | } 47 | 48 | // Discover and select profile type 49 | if err := selectProfileType(selections); err != nil { 50 | return nil, err 51 | } 52 | 53 | // Select output format 54 | if err := selectOutputFormat(selections); err != nil { 55 | return nil, err 56 | } 57 | 58 | // Ask about regression threshold 59 | if err := selectRegressionThreshold(selections); err != nil { 60 | return nil, err 61 | } 62 | 63 | return selections, nil 64 | } 65 | 66 | // selectBenchmark discovers and selects a benchmark 67 | func selectBenchmark(selections *tracker.Selections) error { 68 | availableBenchmarks, err := discoverAvailableBenchmarks(selections.Baseline) 69 | if err != nil { 70 | return fmt.Errorf("failed to discover benchmarks for tag %s: %w", selections.Baseline, err) 71 | } 72 | if len(availableBenchmarks) == 0 { 73 | return fmt.Errorf("no benchmarks found for tag %s", selections.Baseline) 74 | } 75 | 76 | benchPrompt := &survey.Select{ 77 | Message: "Select benchmark to compare [Press Enter to select]:", 78 | Options: availableBenchmarks, 79 | PageSize: tuiPageSize, 80 | } 81 | return survey.AskOne(benchPrompt, &selections.BenchmarkName, survey.WithValidator(survey.Required)) 82 | } 83 | 84 | // selectProfileType discovers and selects a profile type 85 | func selectProfileType(selections *tracker.Selections) error { 86 | availableProfiles, err := discoverAvailableProfiles(selections.Baseline, selections.BenchmarkName) 87 | if err != nil { 88 | return fmt.Errorf("failed to discover profiles for tag %s, benchmark %s: %w", selections.Baseline, selections.BenchmarkName, err) 89 | } 90 | if len(availableProfiles) == 0 { 91 | return fmt.Errorf("no profiles found for tag %s, benchmark %s", selections.Baseline, selections.BenchmarkName) 92 | } 93 | 94 | profilePrompt := &survey.Select{ 95 | Message: "Select profile type to compare [Press Enter to select]:", 96 | Options: availableProfiles, 97 | PageSize: tuiPageSize, 98 | } 99 | return survey.AskOne(profilePrompt, &selections.ProfileType, survey.WithValidator(survey.Required)) 100 | } 101 | 102 | // selectOutputFormat selects the output format 103 | func selectOutputFormat(selections *tracker.Selections) error { 104 | outputFormats := []string{"summary", "detailed", "summary-html", "detailed-html", "summary-json", "detailed-json"} 105 | formatPrompt := &survey.Select{ 106 | Message: "Select output format [Press Enter to select]:", 107 | Options: outputFormats, 108 | Default: "detailed", 109 | PageSize: tuiPageSize, 110 | } 111 | return survey.AskOne(formatPrompt, &selections.OutputFormat, survey.WithValidator(survey.Required)) 112 | } 113 | 114 | // selectRegressionThreshold handles regression threshold selection 115 | func selectRegressionThreshold(selections *tracker.Selections) error { 116 | thresholdPrompt := &survey.Confirm{ 117 | Message: "Do you want to fail on performance regressions?", 118 | Default: false, 119 | } 120 | if err := survey.AskOne(thresholdPrompt, &selections.UseThreshold); err != nil { 121 | return err 122 | } 123 | 124 | if selections.UseThreshold { 125 | var thresholdStr string 126 | thresholdInputPrompt := &survey.Input{ 127 | Message: "Enter regression threshold percentage (e.g., 5.0 for 5%):", 128 | Default: "5.0", 129 | } 130 | if err := survey.AskOne(thresholdInputPrompt, &thresholdStr, survey.WithValidator(survey.Required)); err != nil { 131 | return err 132 | } 133 | threshold, convErr := strconv.ParseFloat(thresholdStr, 64) 134 | if convErr != nil || threshold <= 0 { 135 | return fmt.Errorf("invalid threshold: %s", thresholdStr) 136 | } 137 | selections.RegressionThreshold = threshold 138 | } 139 | 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /engine/collector/helpers.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "os/exec" 8 | "path" 9 | 10 | "github.com/AlexsanderHamir/prof/internal" 11 | "github.com/AlexsanderHamir/prof/parser" 12 | ) 13 | 14 | func ensureDirExists(basePath string) error { 15 | _, err := os.Stat(basePath) 16 | if err != nil { 17 | if os.IsNotExist(err) { 18 | return os.MkdirAll(basePath, internal.PermDir) 19 | } 20 | return err 21 | } 22 | 23 | return nil 24 | } 25 | 26 | // getFunctionPprofContent gets code line level mapping of specified function 27 | // and writes the data to a file named after the function. 28 | func getFunctionPprofContent(function, binaryFile, outputFile string) error { 29 | cmd := []string{"go", "tool", "pprof", fmt.Sprintf("-list=%s", function), binaryFile} 30 | 31 | // #nosec ProfileTextDir04 -- cmd is constructed internally by getFunctionPprofContent(), not from user input 32 | execCmd := exec.Command(cmd[0], cmd[1:]...) 33 | output, err := execCmd.Output() 34 | if err != nil { 35 | return fmt.Errorf("pprof list command failed: %w", err) 36 | } 37 | 38 | if err = os.WriteFile(outputFile, output, internal.PermFile); err != nil { 39 | return fmt.Errorf("failed to write function content: %w", err) 40 | } 41 | 42 | slog.Info("Collected function", "function", function) 43 | return nil 44 | } 45 | 46 | func collectFunctions(profileDirPath, fullBinaryPath string, functionFilter internal.FunctionFilter) error { 47 | var functions []string 48 | functions, err := parser.GetAllFunctionNamesV2(fullBinaryPath, functionFilter) 49 | if err != nil { 50 | return fmt.Errorf("failed to extract function names: %w", err) 51 | } 52 | 53 | functionDir := path.Join(profileDirPath, "functions") 54 | if err = ensureDirExists(functionDir); err != nil { 55 | return err 56 | } 57 | 58 | if err = GetFunctionsOutput(functions, fullBinaryPath, functionDir); err != nil { 59 | return fmt.Errorf("getAllFunctionsPprofContents failed: %w", err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // getGlobalFunctionFilter extracts the global function filter from config 66 | func getGlobalFunctionFilter(cfg *internal.Config) (internal.FunctionFilter, bool) { 67 | globalFilter, hasGlobalFilter := cfg.FunctionFilter[internal.GlobalSign] 68 | return globalFilter, hasGlobalFilter 69 | } 70 | 71 | // processBinaryFile handles the processing of a single binary file 72 | func processBinaryFile(fullBinaryPath, tagDir string, cfg *internal.Config, globalFilter internal.FunctionFilter, groupByPackage bool) error { 73 | fileName := getFileName(fullBinaryPath) 74 | 75 | profileDirPath, createErr := createProfileDirectory(tagDir, fileName) 76 | if createErr != nil { 77 | return fmt.Errorf("createProfileDirectory failed: %w", createErr) 78 | } 79 | 80 | functionFilter := determineFunctionFilter(cfg, fileName, globalFilter) 81 | 82 | if genErr := generateProfileOutputs(fullBinaryPath, profileDirPath, fileName, functionFilter, groupByPackage); genErr != nil { 83 | return genErr 84 | } 85 | 86 | if collectErr := collectFunctions(profileDirPath, fullBinaryPath, functionFilter); collectErr != nil { 87 | return fmt.Errorf("collectFunctions failed: %w", collectErr) 88 | } 89 | 90 | return nil 91 | } 92 | 93 | // determineFunctionFilter determines which function filter to use for a given file 94 | func determineFunctionFilter(cfg *internal.Config, fileName string, globalFilter internal.FunctionFilter) internal.FunctionFilter { 95 | _, hasGlobalFilter := cfg.FunctionFilter[internal.GlobalSign] 96 | if hasGlobalFilter { 97 | return globalFilter 98 | } 99 | 100 | localFilter, hasLocalFilter := cfg.FunctionFilter[fileName] 101 | if hasLocalFilter { 102 | return localFilter 103 | } 104 | 105 | return internal.FunctionFilter{} 106 | } 107 | 108 | // generateProfileOutputs generates all profile outputs for a binary file 109 | func generateProfileOutputs(fullBinaryPath, profileDirPath, fileName string, functionFilter internal.FunctionFilter, groupByPackage bool) error { 110 | outputTextFilePath := path.Join(profileDirPath, fileName+"."+internal.TextExtension) 111 | if err := GetProfileTextOutput(fullBinaryPath, outputTextFilePath); err != nil { 112 | return err 113 | } 114 | 115 | if groupByPackage { 116 | groupedOutputPath := path.Join(profileDirPath, fileName+"_grouped."+internal.TextExtension) 117 | if err := generateGroupedProfileData(fullBinaryPath, groupedOutputPath, functionFilter); err != nil { 118 | return fmt.Errorf("generateGroupedProfileData failed: %w", err) 119 | } 120 | } 121 | 122 | return nil 123 | } 124 | 125 | // generateGroupedProfileData generates profile data organized by package/module using the new parser function 126 | func generateGroupedProfileData(binaryFile, outputFile string, functionFilter internal.FunctionFilter) error { 127 | // Import the parser package to use OrganizeProfileByPackageV2 128 | groupedData, err := parser.OrganizeProfileByPackageV2(binaryFile, functionFilter) 129 | if err != nil { 130 | return fmt.Errorf("failed to organize profile by package: %w", err) 131 | } 132 | 133 | // Write the grouped data to the output file 134 | return os.WriteFile(outputFile, []byte(groupedData), internal.PermFile) 135 | } 136 | -------------------------------------------------------------------------------- /engine/benchmark/profiles.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/AlexsanderHamir/prof/engine/collector" 11 | "github.com/AlexsanderHamir/prof/internal" 12 | "github.com/AlexsanderHamir/prof/parser" 13 | ) 14 | 15 | // ProfilePaths holds paths for profile text, binary, and output directories. 16 | type ProfilePaths struct { 17 | // Desired file path for specified profile 18 | ProfileTextFile string 19 | 20 | // Desired bin path for specified profile 21 | ProfileBinaryFile string 22 | 23 | // Desired benchmark directory for function data collection 24 | FunctionDirectory string 25 | } 26 | 27 | // getProfilePaths constructs file paths for benchmark profile data organized by tag and benchmark. 28 | // 29 | // Returns paths for: 30 | // - ProfileTextFile: bench/{tag}/text/{benchmarkName}/{benchmarkName}_{profile}.txt 31 | // - ProfileBinaryFile: bench/{tag}/bin/{benchmarkName}/{benchmarkName}_{profile}.out 32 | // - FunctionDirectory: bench/{tag}/{profile}_functions/{benchmarkName}/ 33 | // 34 | // Example with tag="v1.0", benchmarkName="BenchmarkPool", profile="cpu": 35 | // - bench/v1.0/text/BenchmarkPool/BenchmarkPool_cpu.txt 36 | // - bench/v1.0/bin/BenchmarkPool/BenchmarkPool_cpu.out 37 | // - bench/v1.0/cpu_functions/BenchmarkPool/function1.txt 38 | func getProfilePaths(tag, benchmarkName, profile string) ProfilePaths { 39 | tagDir := filepath.Join(internal.MainDirOutput, tag) 40 | profileTextFile := fmt.Sprintf("%s_%s.%s", benchmarkName, profile, internal.TextExtension) 41 | profileBinFile := fmt.Sprintf("%s_%s.%s", benchmarkName, profile, binExtension) 42 | 43 | return ProfilePaths{ 44 | ProfileTextFile: filepath.Join(tagDir, internal.ProfileTextDir, benchmarkName, profileTextFile), 45 | ProfileBinaryFile: filepath.Join(tagDir, internal.ProfileBinDir, benchmarkName, profileBinFile), 46 | FunctionDirectory: filepath.Join(tagDir, profile+internal.FunctionsDirSuffix, benchmarkName), 47 | } 48 | } 49 | 50 | // processProfiles collects all pprof info for a specific benchmark and its specified profiles. 51 | func processProfiles(benchmarkName string, profiles []string, tag string, groupByPackage bool) error { 52 | tagDir := filepath.Join(internal.MainDirOutput, tag) 53 | binDir := filepath.Join(tagDir, internal.ProfileBinDir, benchmarkName) 54 | textDir := filepath.Join(tagDir, internal.ProfileTextDir, benchmarkName) 55 | 56 | for _, profile := range profiles { 57 | profileFile := filepath.Join(binDir, fmt.Sprintf("%s_%s.%s", benchmarkName, profile, binExtension)) 58 | if _, err := os.Stat(profileFile); err != nil { 59 | if errors.Is(err, os.ErrNotExist) { 60 | slog.Warn("Profile file not found", "file", profileFile) 61 | continue 62 | } 63 | return fmt.Errorf("failed to stat profile file %s: %w", profileFile, err) 64 | } 65 | 66 | outputFile := filepath.Join(textDir, fmt.Sprintf("%s_%s.%s", benchmarkName, profile, internal.TextExtension)) 67 | profileFunctionsDir := filepath.Join(tagDir, profile+internal.FunctionsDirSuffix, benchmarkName) 68 | 69 | if err := collector.GetProfileTextOutput(profileFile, outputFile); err != nil { 70 | return fmt.Errorf("failed to generate text profile for %s: %w", profile, err) 71 | } 72 | 73 | // Generate grouped profile data if requested 74 | if groupByPackage { 75 | groupedOutputFile := filepath.Join(textDir, fmt.Sprintf("%s_%s_grouped.%s", benchmarkName, profile, internal.TextExtension)) 76 | if err := generateGroupedProfileData(profileFile, groupedOutputFile, internal.FunctionFilter{}); err != nil { 77 | return fmt.Errorf("failed to generate grouped profile for %s: %w", profile, err) 78 | } 79 | } 80 | 81 | pngDesiredFilePath := filepath.Join(profileFunctionsDir, fmt.Sprintf("%s_%s.png", benchmarkName, profile)) 82 | if err := collector.GetPNGOutput(profileFile, pngDesiredFilePath); err != nil { 83 | return fmt.Errorf("failed to generate PNG visualization for %s: %w", profile, err) 84 | } 85 | 86 | slog.Info("Processed profile", "profile", profile, "benchmark", benchmarkName) 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // generateGroupedProfileData generates profile data organized by package/module using the new parser function 93 | func generateGroupedProfileData(binaryFile, outputFile string, functionFilter internal.FunctionFilter) error { 94 | // Import the parser package to use OrganizeProfileByPackageV2 95 | groupedData, err := parser.OrganizeProfileByPackageV2(binaryFile, functionFilter) 96 | if err != nil { 97 | return fmt.Errorf("failed to organize profile by package: %w", err) 98 | } 99 | 100 | // Write the grouped data to the output file 101 | return os.WriteFile(outputFile, []byte(groupedData), internal.PermFile) 102 | } 103 | 104 | // CollectProfileFunctions collects all pprof information for each function, according to configurations. 105 | func collectProfileFunctions(args *internal.CollectionArgs) error { 106 | for _, profile := range args.Profiles { 107 | paths := getProfilePaths(args.Tag, args.BenchmarkName, profile) 108 | if err := os.MkdirAll(paths.FunctionDirectory, internal.PermDir); err != nil { 109 | return fmt.Errorf("failed to create output directory: %w", err) 110 | } 111 | 112 | functions, err := parser.GetAllFunctionNamesV2(paths.ProfileBinaryFile, args.BenchmarkConfig) 113 | if err != nil { 114 | return fmt.Errorf("failed to extract function names: %w", err) 115 | } 116 | 117 | if err = collector.GetFunctionsOutput(functions, paths.ProfileBinaryFile, paths.FunctionDirectory); err != nil { 118 | return fmt.Errorf("getAllFunctionsPprofContents failed: %w", err) 119 | } 120 | } 121 | 122 | return nil 123 | } 124 | -------------------------------------------------------------------------------- /internal/api.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | ) 12 | 13 | func GetScanner(filePath string) (*bufio.Scanner, *os.File, error) { 14 | file, err := os.Open(filePath) 15 | if err != nil { 16 | return nil, nil, fmt.Errorf("cannot read profile file %s: %w", filePath, err) 17 | } 18 | 19 | scanner := bufio.NewScanner(file) 20 | 21 | return scanner, file, nil 22 | } 23 | 24 | // CleanOrCreateTag cleans the tag directory if it exists, or creates one. 25 | func CleanOrCreateTag(dir string) error { 26 | info, err := os.Stat(dir) 27 | if err != nil { 28 | if os.IsNotExist(err) { 29 | if err = os.MkdirAll(dir, PermDir); err != nil { 30 | return fmt.Errorf("failed to create %s directory: %w", dir, err) 31 | } 32 | return nil 33 | } 34 | return err 35 | } 36 | 37 | if !info.IsDir() { 38 | return fmt.Errorf("path is not a directory: %s", dir) 39 | } 40 | 41 | entries, err := os.ReadDir(dir) 42 | if err != nil { 43 | return fmt.Errorf("failed to read directory: %w", err) 44 | } 45 | 46 | for _, entry := range entries { 47 | path := filepath.Join(dir, entry.Name()) 48 | if err = os.RemoveAll(path); err != nil { 49 | return fmt.Errorf("failed to remove %s: %w", path, err) 50 | } 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // FindGoModuleRoot searches upwards from the current working directory for a directory 57 | // containing a go.mod file and returns its absolute path. If none is found, an error is returned. 58 | func FindGoModuleRoot() (string, error) { 59 | dir, err := os.Getwd() 60 | if err != nil { 61 | return "", err 62 | } 63 | 64 | for { 65 | if _, err = os.Stat(filepath.Join(dir, "go.mod")); err == nil { 66 | return dir, nil 67 | } 68 | 69 | parent := filepath.Dir(dir) 70 | if parent == dir { 71 | return "", errors.New("go.mod not found from current directory upwards") 72 | } 73 | dir = parent 74 | } 75 | } 76 | 77 | func PrintConfiguration(benchArgs *BenchArgs, functionFilterPerBench map[string]FunctionFilter) { 78 | slog.Info( 79 | "Parsed arguments", 80 | "Benchmarks", benchArgs.Benchmarks, 81 | "Profiles", benchArgs.Profiles, 82 | "Tag", benchArgs.Tag, 83 | "Count", benchArgs.Count, 84 | ) 85 | 86 | hasBenchFunctionFilters := len(functionFilterPerBench) > 0 87 | if hasBenchFunctionFilters { 88 | slog.Info("Benchmark Function Filter Configurations:") 89 | for benchmark, cfg := range functionFilterPerBench { 90 | slog.Info("Benchmark Config", "Benchmark", benchmark, "Prefixes", cfg.IncludePrefixes, "Ignore", cfg.IgnoreFunctions) 91 | } 92 | } else { 93 | slog.Info("No benchmark configuration found in config file - analyzing all functions") 94 | } 95 | } 96 | 97 | // LoadFromFile loads and validates config from a JSON file. 98 | func LoadFromFile(filename string) (*Config, error) { 99 | root, err := FindGoModuleRoot() 100 | if err != nil { 101 | return nil, fmt.Errorf("failed to locate module root for config: %w", err) 102 | } 103 | 104 | configPath := filepath.Join(root, filename) 105 | data, err := os.ReadFile(configPath) 106 | if err != nil { 107 | return nil, fmt.Errorf("failed to read config file: %w", err) 108 | } 109 | 110 | var config Config 111 | if err = json.Unmarshal(data, &config); err != nil { 112 | return nil, fmt.Errorf("failed to parse config file: %w", err) 113 | } 114 | 115 | return &config, nil 116 | } 117 | 118 | // CreateTemplate creates a template configuration file from the actual Config struct 119 | // with pre-built examples. 120 | func CreateTemplate() error { 121 | root, err := FindGoModuleRoot() 122 | if err != nil { 123 | return fmt.Errorf("failed to locate module root for template: %w", err) 124 | } 125 | 126 | outputPath := filepath.Join(root, "config_template.json") 127 | 128 | template := Config{ 129 | FunctionFilter: map[string]FunctionFilter{ 130 | "BenchmarkGenPool": { 131 | IncludePrefixes: []string{ 132 | "github.com/example/GenPool", 133 | "github.com/example/GenPool/internal", 134 | "github.com/example/GenPool/pkg", 135 | }, 136 | IgnoreFunctions: []string{"init", "TestMain", "BenchmarkMain"}, 137 | }, 138 | }, 139 | CIConfig: &CIConfig{ 140 | Global: &CITrackingConfig{ 141 | // Ignore common noisy functions that shouldn't cause CI/CD failures 142 | IgnoreFunctions: []string{ 143 | "runtime.gcBgMarkWorker", 144 | "runtime.systemstack", 145 | "runtime.mallocgc", 146 | "reflect.ValueOf", 147 | "testing.(*B).launch", 148 | }, 149 | // Ignore runtime and reflect functions that are often noisy 150 | IgnorePrefixes: []string{ 151 | "runtime.", 152 | "reflect.", 153 | "testing.", 154 | }, 155 | // Only fail CI/CD for changes >= 5% 156 | MinChangeThreshold: 5.0, 157 | // Maximum acceptable regression is 15% 158 | MaxRegressionThreshold: 15.0, 159 | // Don't fail on improvements 160 | FailOnImprovement: false, 161 | }, 162 | Benchmarks: map[string]CITrackingConfig{ 163 | "BenchmarkGenPool": { 164 | // Specific settings for this benchmark 165 | IgnoreFunctions: []string{ 166 | "BenchmarkGenPool", 167 | "testing.(*B).ResetTimer", 168 | }, 169 | MinChangeThreshold: 3.0, // More sensitive for this benchmark 170 | MaxRegressionThreshold: 10.0, 171 | }, 172 | }, 173 | }, 174 | } 175 | 176 | if err = os.MkdirAll(filepath.Dir(outputPath), PermDir); err != nil { 177 | return fmt.Errorf("failed to create directory: %w", err) 178 | } 179 | 180 | data, err := json.MarshalIndent(template, "", " ") 181 | if err != nil { 182 | return fmt.Errorf("failed to marshal template file: %w", err) 183 | } 184 | 185 | if err = os.WriteFile(outputPath, data, PermFile); err != nil { 186 | return fmt.Errorf("failed to write template file: %w", err) 187 | } 188 | 189 | slog.Info("Template configuration file created", "path", outputPath) 190 | slog.Info("Please edit this file with your configuration") 191 | slog.Info("The new CI/CD configuration section allows you to:") 192 | slog.Info(" - Filter out noisy functions from CI/CD failures") 193 | slog.Info(" - Set different thresholds for different benchmarks") 194 | slog.Info(" - Configure severity levels for performance changes") 195 | slog.Info(" - Override command-line regression thresholds") 196 | 197 | return nil 198 | } 199 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= 2 | github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= 3 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 4 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 5 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 6 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 8 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 9 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= 14 | github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= 15 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 16 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 17 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 18 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 19 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 20 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 21 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 22 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 23 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 24 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 25 | github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= 26 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 27 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= 28 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 29 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 30 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 34 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 35 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 36 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 37 | github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= 38 | github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 39 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 40 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 41 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 42 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 43 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 44 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 45 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 46 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 47 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 48 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 49 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 50 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 51 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 52 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 54 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 55 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 60 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 62 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 63 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 64 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 65 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 66 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 67 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 68 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 69 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 70 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 71 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 72 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 73 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 74 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 75 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 76 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 77 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 78 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 79 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 80 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 81 | -------------------------------------------------------------------------------- /parser/helpersv2.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | 9 | pprofprofile "github.com/google/pprof/profile" 10 | ) 11 | 12 | // ProfileData contains the extracted flat and cumulative data from a pprof profile 13 | type ProfileData struct { 14 | Flat map[string]int64 15 | Cum map[string]int64 16 | Total int64 17 | 18 | FlatPercentages map[string]float64 19 | CumPercentages map[string]float64 20 | SumPercentages map[string]float64 21 | SortedEntries []FuncEntry 22 | } 23 | 24 | // FuncEntry represents a function with its flat value, sorted by flat value (descending) 25 | type FuncEntry struct { 26 | Name string 27 | Flat int64 28 | } 29 | 30 | // extractProfileData extracts flat and cumulative data from a pprof profile file 31 | func extractProfileData(profilePath string) (*ProfileData, error) { 32 | // Open and parse profile file 33 | p, total, err := parseProfileFile(profilePath) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | // Extract flat and cumulative data 39 | flat, cum := extractFlatAndCumulativeData(p) 40 | 41 | // Calculate percentages and sort entries 42 | flatPercentages, cumPercentages, sumPercentages, sortedEntries := calculatePercentagesAndSort(flat, cum, total) 43 | 44 | return &ProfileData{ 45 | Flat: flat, 46 | Cum: cum, 47 | Total: total, 48 | FlatPercentages: flatPercentages, 49 | CumPercentages: cumPercentages, 50 | SumPercentages: sumPercentages, 51 | SortedEntries: sortedEntries, 52 | }, nil 53 | } 54 | 55 | // parseProfileFile opens and parses a pprof profile file, returning the profile and total samples 56 | func parseProfileFile(profilePath string) (*pprofprofile.Profile, int64, error) { 57 | f, err := os.Open(profilePath) 58 | if err != nil { 59 | return nil, 0, fmt.Errorf("failed to open profile file: %w", err) 60 | } 61 | defer f.Close() 62 | 63 | p, err := pprofprofile.Parse(f) 64 | if err != nil { 65 | return nil, 0, fmt.Errorf("failed to parse pprof profile: %w", err) 66 | } 67 | 68 | // Calculate total samples 69 | var total int64 70 | for _, s := range p.Sample { 71 | total += s.Value[0] 72 | } 73 | 74 | return p, total, nil 75 | } 76 | 77 | // extractFlatAndCumulativeData processes profile samples to extract flat and cumulative function data 78 | func extractFlatAndCumulativeData(p *pprofprofile.Profile) (map[string]int64, map[string]int64) { 79 | flat := make(map[string]int64) 80 | cum := make(map[string]int64) 81 | 82 | // Process each sample 83 | for _, s := range p.Sample { 84 | value := s.Value[0] 85 | extractSampleData(s, value, flat, cum) 86 | } 87 | 88 | return flat, cum 89 | } 90 | 91 | // extractSampleData processes a single sample to update flat and cumulative maps 92 | func extractSampleData(s *pprofprofile.Sample, value int64, flat, cum map[string]int64) { 93 | // Cumulative: add to all stack frames 94 | seenFuncs := make(map[string]bool) 95 | for _, loc := range s.Location { 96 | for _, line := range loc.Line { 97 | if line.Function == nil { 98 | continue 99 | } 100 | fn := line.Function.Name 101 | if !seenFuncs[fn] { 102 | cum[fn] += value 103 | seenFuncs[fn] = true 104 | } 105 | } 106 | } 107 | 108 | // Flat: top of stack only 109 | if len(s.Location) > 0 { 110 | topLoc := s.Location[0] 111 | if len(topLoc.Line) > 0 && topLoc.Line[0].Function != nil { 112 | fn := topLoc.Line[0].Function.Name 113 | flat[fn] += value 114 | } 115 | } 116 | } 117 | 118 | // calculatePercentagesAndSort calculates all percentages and sorts entries by flat value 119 | func calculatePercentagesAndSort(flat, cum map[string]int64, total int64) (map[string]float64, map[string]float64, map[string]float64, []FuncEntry) { 120 | flatPercentages := make(map[string]float64) 121 | cumPercentages := make(map[string]float64) 122 | sumPercentages := make(map[string]float64) 123 | 124 | // Sort by flat value (descending) for sum percentage calculation 125 | entries := createSortedEntries(flat) 126 | 127 | // Calculate percentages 128 | calculateAllPercentages(entries, cum, total, flatPercentages, cumPercentages, sumPercentages) 129 | 130 | return flatPercentages, cumPercentages, sumPercentages, entries 131 | } 132 | 133 | // createSortedEntries creates a sorted slice of function entries by flat value 134 | func createSortedEntries(flat map[string]int64) []FuncEntry { 135 | var entries []FuncEntry 136 | for fn, flatVal := range flat { 137 | entries = append(entries, FuncEntry{ 138 | Name: fn, 139 | Flat: flatVal, 140 | }) 141 | } 142 | 143 | sort.Slice(entries, func(i, j int) bool { 144 | return entries[i].Flat > entries[j].Flat 145 | }) 146 | 147 | return entries 148 | } 149 | 150 | // calculateAllPercentages calculates flat, cumulative, and sum percentages for all functions 151 | func calculateAllPercentages(entries []FuncEntry, cum map[string]int64, total int64, flatPercentages, cumPercentages, sumPercentages map[string]float64) { 152 | percentageMultiplier := 100.0 153 | var running float64 154 | 155 | for _, entry := range entries { 156 | fn := entry.Name 157 | flatVal := entry.Flat 158 | cumVal := cum[fn] 159 | 160 | flatPct := float64(flatVal) / float64(total) * percentageMultiplier 161 | cumPct := float64(cumVal) / float64(total) * percentageMultiplier 162 | running += flatPct 163 | 164 | flatPercentages[fn] = flatPct 165 | cumPercentages[fn] = cumPct 166 | sumPercentages[fn] = running 167 | } 168 | } 169 | 170 | // extractSimpleFunctionName extracts just the function name from a full function path 171 | func extractSimpleFunctionName(fullPath string) string { 172 | // Handle cases like "github.com/user/pkg.(*Type).Method" => Method 173 | 174 | // Split by dots and get the last part 175 | parts := strings.Split(fullPath, ".") 176 | if len(parts) == 0 { 177 | return "" 178 | } 179 | 180 | lastPart := parts[len(parts)-1] 181 | 182 | // Handle method calls like "(*Type).Method" 183 | if strings.Contains(lastPart, ").") { 184 | methodParts := strings.Split(lastPart, ").") 185 | if len(methodParts) > 1 { 186 | return methodParts[1] 187 | } 188 | } 189 | 190 | // Handle generic types like "Type[Param].Method" 191 | if strings.Contains(lastPart, "].)") { 192 | methodParts := strings.Split(lastPart, "].)") 193 | if len(methodParts) > 1 { 194 | return methodParts[1] 195 | } 196 | } 197 | 198 | // Remove any trailing parentheses and parameters 199 | if idx := strings.Index(lastPart, "("); idx != -1 { 200 | lastPart = lastPart[:idx] 201 | } 202 | 203 | return lastPart 204 | } 205 | 206 | func matchPrefix(funcName string, functionPrefixes []string) bool { 207 | var hasPrefix bool 208 | for _, prefix := range functionPrefixes { 209 | if strings.Contains(funcName, prefix) { 210 | hasPrefix = true 211 | break 212 | } 213 | } 214 | 215 | return hasPrefix 216 | } 217 | 218 | func getFilterSets(ignoreFunctions []string) map[string]struct{} { 219 | ignoreSet := make(map[string]struct{}) 220 | for _, f := range ignoreFunctions { 221 | ignoreSet[f] = struct{}{} 222 | } 223 | 224 | return ignoreSet 225 | } 226 | 227 | // extractPackageName extracts the package name from a full function path 228 | func extractPackageName(fullPath string) string { 229 | // Handle cases like "github.com/user/pkg.(*Type).Method" => "github.com/user/pkg" 230 | // or "sync/atomic.CompareAndSwapPointer" => "sync/atomic" 231 | 232 | // Split by dots 233 | parts := strings.Split(fullPath, ".") 234 | if len(parts) < 2 { 235 | return "" 236 | } 237 | 238 | // Check if it's a standard library package (like "sync/atomic") 239 | if !strings.Contains(parts[0], "/") && len(parts) >= 2 { 240 | // Standard library package 241 | if len(parts) >= 3 && strings.Contains(parts[1], "/") { 242 | return parts[0] + "." + parts[1] 243 | } 244 | return parts[0] 245 | } 246 | 247 | // Check if it's a GitHub-style package 248 | if strings.Contains(parts[0], "github.com") || strings.Contains(parts[0], "golang.org") { 249 | // For GitHub packages, take up to the third part (github.com/user/pkg) 250 | if len(parts) >= 3 { 251 | return strings.Join(parts[:3], ".") 252 | } 253 | return strings.Join(parts[:2], ".") 254 | } 255 | 256 | // For other cases, take the first part 257 | return parts[0] 258 | } 259 | 260 | // sortPackagesByFlatPercentage sorts packages by their flat percentage in descending order 261 | func sortPackagesByFlatPercentage(packageGroups map[string]*PackageGroup) []*PackageGroup { 262 | var packages []*PackageGroup 263 | for _, pkg := range packageGroups { 264 | packages = append(packages, pkg) 265 | } 266 | 267 | sort.Slice(packages, func(i, j int) bool { 268 | return packages[i].FlatPercentage > packages[j].FlatPercentage 269 | }) 270 | 271 | return packages 272 | } 273 | 274 | // formatFunctionOutput formats a single function's output based on package type 275 | func formatFunctionOutput(fn *FunctionInfo, isUnknownPackage bool) string { 276 | if isUnknownPackage { 277 | // For unknown package, show full prof-style output 278 | return fmt.Sprintf("- `%s` → flat: %.2f, flat%%: %.2f%%, sum%%: %.2f%%, cum: %.2f, cum%%: %.2f%%\n", 279 | fn.Name, fn.Flat, fn.FlatPercentage, fn.SumPercentage, fn.Cum, fn.CumPercentage) 280 | } 281 | 282 | // For known packages, show simplified format 283 | return fmt.Sprintf("- `%s` → %.2f%%\n", fn.Name, fn.FlatPercentage) 284 | } 285 | 286 | // formatPackageReport formats the package groups into a readable report 287 | func formatPackageReport(packages []*PackageGroup) string { 288 | var result strings.Builder 289 | 290 | for i, pkg := range packages { 291 | if i > 0 { 292 | result.WriteString("\n\n") 293 | } 294 | 295 | // Package header 296 | result.WriteString(fmt.Sprintf("#### **%s**\n", pkg.Name)) 297 | 298 | // Sort functions by flat percentage (descending) 299 | sort.Slice(pkg.Functions, func(i, j int) bool { 300 | return pkg.Functions[i].FlatPercentage > pkg.Functions[j].FlatPercentage 301 | }) 302 | 303 | // List functions 304 | isUnknownPackage := pkg.Name == "unknown" 305 | for _, fn := range pkg.Functions { 306 | result.WriteString(formatFunctionOutput(fn, isUnknownPackage)) 307 | } 308 | 309 | // Package subtotal 310 | result.WriteString(fmt.Sprintf("\n**Subtotal (%s)**: ≈%.1f%%", 311 | extractShortPackageName(pkg.Name), pkg.FlatPercentage)) 312 | } 313 | 314 | return result.String() 315 | } 316 | 317 | // extractShortPackageName extracts a shorter version of the package name for display 318 | func extractShortPackageName(fullPackageName string) string { 319 | parts := strings.Split(fullPackageName, ".") 320 | if len(parts) == 0 { 321 | return fullPackageName 322 | } 323 | 324 | // For GitHub packages, show just the last part 325 | if strings.Contains(fullPackageName, "github.com") { 326 | return parts[len(parts)-1] 327 | } 328 | 329 | // For standard library, show the full name 330 | return fullPackageName 331 | } 332 | -------------------------------------------------------------------------------- /cli/commands.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/AlexsanderHamir/prof/engine/benchmark" 7 | "github.com/AlexsanderHamir/prof/engine/collector" 8 | "github.com/AlexsanderHamir/prof/engine/tools/benchstats" 9 | "github.com/AlexsanderHamir/prof/engine/tools/qcachegrind" 10 | "github.com/AlexsanderHamir/prof/engine/tracker" 11 | "github.com/AlexsanderHamir/prof/internal" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // CreateRootCmd creates and returns the root cobra command. 16 | func CreateRootCmd() *cobra.Command { 17 | rootCmd := &cobra.Command{ 18 | Use: "prof", 19 | Short: "CLI tool for organizing pprof generated data, and analyzing performance differences at the profile level.", 20 | } 21 | 22 | rootCmd.AddCommand(createProfManual()) 23 | rootCmd.AddCommand(createProfAuto()) 24 | rootCmd.AddCommand(createTuiCmd()) 25 | rootCmd.AddCommand(createSetupCmd()) 26 | rootCmd.AddCommand(createTrackCmd()) 27 | rootCmd.AddCommand(createToolsCmd()) 28 | 29 | return rootCmd 30 | } 31 | 32 | func createToolsCmd() *cobra.Command { 33 | shortExplanation := "Offers many tools that can easily operate on the collected data." 34 | cmd := &cobra.Command{ 35 | Use: "tools", 36 | Short: shortExplanation, 37 | } 38 | 39 | cmd.AddCommand(createBenchStatCmd()) 40 | cmd.AddCommand(createQCacheGrindCmd()) 41 | 42 | return cmd 43 | } 44 | 45 | func createQCacheGrindCmd() *cobra.Command { 46 | profilesFlag := "profiles" 47 | shortExplanation := "runs benchstat on txt collected data." 48 | 49 | cmd := &cobra.Command{ 50 | Use: "qcachegrind", 51 | Short: shortExplanation, 52 | Example: "prof tools qcachegrind --tag `current` --profiles `cpu` --bench-name `BenchmarkGenPool`", 53 | RunE: func(_ *cobra.Command, _ []string) error { 54 | return qcachegrind.RunQcacheGrind(tag, benchmarkName, profiles[0]) 55 | }, 56 | } 57 | 58 | cmd.Flags().StringVar(&benchmarkName, benchNameFlag, "", "Name of the benchmark") 59 | cmd.Flags().StringSliceVar(&profiles, profilesFlag, []string{}, `Profiles to use (e.g., "cpu,memory,mutex")`) 60 | cmd.Flags().StringVar(&tag, tagFlag, "", "The tag is used to organize the results") 61 | 62 | _ = cmd.MarkFlagRequired(benchNameFlag) 63 | _ = cmd.MarkFlagRequired(profilesFlag) 64 | _ = cmd.MarkFlagRequired(tagFlag) 65 | 66 | return cmd 67 | } 68 | 69 | func createBenchStatCmd() *cobra.Command { 70 | shortExplanation := "runs benchstat on txt collected data." 71 | 72 | cmd := &cobra.Command{ 73 | Use: "benchstat", 74 | Short: shortExplanation, 75 | Example: "prof tools benchstat --base `baseline` --current `current` --bench-name `BenchmarkGenPool`", 76 | RunE: func(_ *cobra.Command, _ []string) error { 77 | return benchstats.RunBenchStats(Baseline, Current, benchmarkName) 78 | }, 79 | } 80 | 81 | cmd.Flags().StringVar(&Baseline, baseTagFlag, "", "Name of the baseline tag") 82 | cmd.Flags().StringVar(&Current, currentTagFlag, "", "Name of the current tag") 83 | cmd.Flags().StringVar(&benchmarkName, benchNameFlag, "", "Name of the benchmark") 84 | 85 | _ = cmd.MarkFlagRequired(baseTagFlag) 86 | _ = cmd.MarkFlagRequired(currentTagFlag) 87 | _ = cmd.MarkFlagRequired(benchNameFlag) 88 | 89 | return cmd 90 | } 91 | 92 | func createProfManual() *cobra.Command { 93 | manualCmd := &cobra.Command{ 94 | Use: internal.MANUALCMD, 95 | Short: "Receives profile files and performs data collection and organization. (doesn't wrap go test)", 96 | Args: cobra.MinimumNArgs(1), 97 | Example: fmt.Sprintf("prof %s --tag tagName cpu.prof memory.prof block.prof mutex.prof", internal.MANUALCMD), 98 | RunE: func(_ *cobra.Command, args []string) error { 99 | return collector.RunCollector(args, tag, groupByPackage) 100 | }, 101 | } 102 | 103 | manualCmd.Flags().StringVar(&tag, tagFlag, "", "The tag is used to organize the results") 104 | manualCmd.Flags().BoolVar(&groupByPackage, "group-by-package", false, "Group profile data by package/module and save as organized text file") 105 | _ = manualCmd.MarkFlagRequired(tagFlag) 106 | 107 | return manualCmd 108 | } 109 | 110 | func createProfAuto() *cobra.Command { 111 | benchFlag := "benchmarks" 112 | profileFlag := "profiles" 113 | countFlag := "count" 114 | example := fmt.Sprintf(`prof %s --%s "BenchmarkGenPool" --%s "cpu,memory" --%s 10 --%s "tag1"`, internal.AUTOCMD, benchFlag, profileFlag, countFlag, tagFlag) 115 | 116 | cmd := &cobra.Command{ 117 | Use: internal.AUTOCMD, 118 | Short: "Wraps `go test` and `pprof` to benchmark code and gather profiling data for performance investigations.", 119 | RunE: func(_ *cobra.Command, _ []string) error { 120 | return benchmark.RunBenchmarks(benchmarks, profiles, tag, count, groupByPackage) 121 | }, 122 | Example: example, 123 | } 124 | 125 | cmd.Flags().StringSliceVar(&benchmarks, benchFlag, []string{}, `Benchmarks to run (e.g., "BenchmarkGenPool")"`) 126 | cmd.Flags().StringSliceVar(&profiles, profileFlag, []string{}, `Profiles to use (e.g., "cpu,memory,mutex")`) 127 | cmd.Flags().StringVar(&tag, tagFlag, "", "The tag is used to organize the results") 128 | cmd.Flags().IntVar(&count, countFlag, 0, "Number of runs") 129 | cmd.Flags().BoolVar(&groupByPackage, "group-by-package", false, "Group profile data by package/module and save as organized text file") 130 | 131 | _ = cmd.MarkFlagRequired(benchFlag) 132 | _ = cmd.MarkFlagRequired(profileFlag) 133 | _ = cmd.MarkFlagRequired(tagFlag) 134 | _ = cmd.MarkFlagRequired(countFlag) 135 | 136 | return cmd 137 | } 138 | 139 | func createTrackCmd() *cobra.Command { 140 | shortExplanation := "Compare performance between two benchmark runs to detect regressions and improvements" 141 | cmd := &cobra.Command{ 142 | Use: "track", 143 | Short: shortExplanation, 144 | } 145 | 146 | cmd.AddCommand(createTrackAutoCmd()) 147 | cmd.AddCommand(createTrackManualCmd()) 148 | 149 | return cmd 150 | } 151 | 152 | func createTrackAutoCmd() *cobra.Command { 153 | profileTypeFlag := "profile-type" 154 | outputFormatFlag := "output-format" 155 | failFlag := "fail-on-regression" 156 | thresholdFlag := "regression-threshold" 157 | example := fmt.Sprintf(`prof track auto --%s "tag1" --%s "tag2" --%s "cpu" --%s "BenchmarkGenPool" --%s "summary"`, baseTagFlag, currentTagFlag, profileTypeFlag, benchNameFlag, outputFormatFlag) 158 | longExplanation := fmt.Sprintf("This command only works if the %s command was used to collect and organize the benchmark and profile data, as it expects a specific directory structure generated by that process.", internal.AUTOCMD) 159 | shortExplanation := "If prof auto was used to collect the data, track auto can be used to analyze it, you just have to pass the tag name." 160 | 161 | cmd := &cobra.Command{ 162 | Use: internal.TrackAutoCMD, 163 | Short: shortExplanation, 164 | Long: longExplanation, 165 | RunE: func(_ *cobra.Command, _ []string) error { 166 | selections := &tracker.Selections{ 167 | OutputFormat: outputFormat, 168 | Baseline: Baseline, 169 | Current: Current, 170 | ProfileType: profileType, 171 | BenchmarkName: benchmarkName, 172 | RegressionThreshold: regressionThreshold, 173 | UseThreshold: failOnRegression, 174 | } 175 | return tracker.RunTrackAuto(selections) 176 | }, 177 | Example: example, 178 | } 179 | 180 | cmd.Flags().StringVar(&Baseline, baseTagFlag, "", "Name of the baseline tag") 181 | cmd.Flags().StringVar(&Current, currentTagFlag, "", "Name of the current tag") 182 | cmd.Flags().StringVar(&benchmarkName, benchNameFlag, "", "Name of the benchmark") 183 | cmd.Flags().StringVar(&profileType, profileTypeFlag, "", "Profile type (cpu, memory, mutex, block)") 184 | cmd.Flags().StringVar(&outputFormat, outputFormatFlag, "detailed", `Output format: "summary" or "detailed"`) 185 | cmd.Flags().BoolVar(&failOnRegression, failFlag, false, "Exit with non-zero code if regression exceeds threshold (optional when using CI/CD config)") 186 | cmd.Flags().Float64Var(®ressionThreshold, thresholdFlag, 0.0, "Fail when worst flat regression exceeds this percent (optional when using CI/CD config)") 187 | 188 | _ = cmd.MarkFlagRequired(baseTagFlag) 189 | _ = cmd.MarkFlagRequired(currentTagFlag) 190 | _ = cmd.MarkFlagRequired(benchNameFlag) 191 | _ = cmd.MarkFlagRequired(profileTypeFlag) 192 | 193 | return cmd 194 | } 195 | 196 | func createTrackManualCmd() *cobra.Command { 197 | outputFormatFlag := "output-format" 198 | failFlag := "fail-on-regression" 199 | thresholdFlag := "regression-threshold" 200 | example := fmt.Sprintf(`prof track %s --%s "path/to/profile_file.txt" --%s "path/to/profile_file.txt" --%s "summary"`, internal.TrackManualCMD, baseTagFlag, currentTagFlag, outputFormatFlag) 201 | 202 | cmd := &cobra.Command{ 203 | Use: internal.TrackManualCMD, 204 | Short: "Manually specify the paths to the profile text files you want to compare.", 205 | RunE: func(_ *cobra.Command, _ []string) error { 206 | selections := &tracker.Selections{ 207 | OutputFormat: outputFormat, 208 | Baseline: Baseline, 209 | Current: Current, 210 | ProfileType: profileType, 211 | BenchmarkName: benchmarkName, 212 | RegressionThreshold: regressionThreshold, 213 | UseThreshold: failOnRegression, 214 | IsManual: true, 215 | } 216 | return tracker.RunTrackManual(selections) 217 | }, 218 | Example: example, 219 | } 220 | 221 | cmd.Flags().StringVar(&Baseline, baseTagFlag, "", "Name of the baseline tag") 222 | cmd.Flags().StringVar(&Current, currentTagFlag, "", "Name of the current tag") 223 | cmd.Flags().StringVar(&outputFormat, outputFormatFlag, "", "Output format choice choice") 224 | cmd.Flags().BoolVar(&failOnRegression, failFlag, false, "Exit with non-zero code if regression exceeds threshold (optional when using CI/CD config)") 225 | cmd.Flags().Float64Var(®ressionThreshold, thresholdFlag, 0.0, "Fail when worst flat regression exceeds this percent (optional when using CI/CD config)") 226 | 227 | _ = cmd.MarkFlagRequired(baseTagFlag) 228 | _ = cmd.MarkFlagRequired(currentTagFlag) 229 | _ = cmd.MarkFlagRequired(outputFormatFlag) 230 | 231 | return cmd 232 | } 233 | 234 | func createSetupCmd() *cobra.Command { 235 | cmd := &cobra.Command{ 236 | Use: "setup", 237 | Short: "Generates the template configuration file.", 238 | RunE: func(_ *cobra.Command, _ []string) error { 239 | return internal.CreateTemplate() 240 | }, 241 | DisableFlagsInUseLine: true, 242 | } 243 | 244 | return cmd 245 | } 246 | 247 | func createTuiCmd() *cobra.Command { 248 | cmd := &cobra.Command{ 249 | Use: "tui", 250 | Short: "Interactive selection of benchmarks and profiles, then runs prof auto", 251 | RunE: runTUI, 252 | } 253 | 254 | cmd.AddCommand(createTuiTrackAutoCmd()) 255 | 256 | return cmd 257 | } 258 | 259 | func createTuiTrackAutoCmd() *cobra.Command { 260 | cmd := &cobra.Command{ 261 | Use: "track", 262 | Short: "Interactive tracking with existing benchmark data", 263 | RunE: runTUITrackAuto, 264 | } 265 | 266 | return cmd 267 | } 268 | -------------------------------------------------------------------------------- /engine/tracker/helpers.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "math" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/AlexsanderHamir/prof/internal" 13 | "github.com/AlexsanderHamir/prof/parser" 14 | ) 15 | 16 | func createMapFromLineObjects(lineobjects []*parser.LineObj) map[string]*parser.LineObj { 17 | matchingMap := make(map[string]*parser.LineObj) 18 | for _, lineObj := range lineobjects { 19 | matchingMap[lineObj.FnName] = lineObj 20 | } 21 | 22 | return matchingMap 23 | } 24 | 25 | func detectChangeBetweenTwoObjects(baseline, current *parser.LineObj) (*FunctionChangeResult, error) { 26 | if current == nil { 27 | return nil, errors.New("current obj is nil") 28 | } 29 | if baseline == nil { 30 | return nil, errors.New("baseLine obj is nil") 31 | } 32 | 33 | const percentMultiplier = 100 34 | 35 | var flatChange float64 36 | if baseline.Flat != 0 { 37 | flatChange = ((current.Flat - baseline.Flat) / baseline.Flat) * percentMultiplier 38 | } 39 | 40 | var cumChange float64 41 | if baseline.Cum != 0 { 42 | cumChange = ((current.Cum - baseline.Cum) / baseline.Cum) * percentMultiplier 43 | } 44 | 45 | changeType := internal.STABLE 46 | if flatChange > 0 { 47 | changeType = internal.REGRESSION 48 | } else if flatChange < 0 { 49 | changeType = internal.IMPROVEMENT 50 | } 51 | 52 | return &FunctionChangeResult{ 53 | FunctionName: current.FnName, 54 | ChangeType: changeType, 55 | FlatChangePercent: flatChange, 56 | CumChangePercent: cumChange, 57 | FlatAbsolute: AbsoluteChange{ 58 | Before: baseline.Flat, 59 | After: current.Flat, 60 | Delta: current.Flat - baseline.Flat, 61 | }, 62 | CumAbsolute: AbsoluteChange{ 63 | Before: baseline.Cum, 64 | After: current.Cum, 65 | Delta: current.Cum - baseline.Cum, 66 | }, 67 | Timestamp: time.Now(), 68 | }, nil 69 | } 70 | 71 | func (cr *FunctionChangeResult) writeHeader(report *strings.Builder) { 72 | report.WriteString("═══════════════════════════════════════════════════════════════\n") 73 | report.WriteString(" PERFORMANCE CHANGE REPORT\n") 74 | report.WriteString("═══════════════════════════════════════════════════════════════\n") 75 | } 76 | 77 | func (cr *FunctionChangeResult) writeFunctionInfo(report *strings.Builder) { 78 | fmt.Fprintf(report, "Function: %s\n", cr.FunctionName) 79 | fmt.Fprintf(report, "Analysis Time: %s\n", cr.Timestamp.Format("2006-01-02 15:04:05 MST")) 80 | fmt.Fprintf(report, "Change Type: %s\n\n", cr.ChangeType) 81 | } 82 | 83 | func (cr *FunctionChangeResult) writeStatusAssessment(report *strings.Builder) { 84 | statusIcon := map[string]string{ 85 | internal.IMPROVEMENT: "✅", 86 | internal.REGRESSION: "⚠️", 87 | }[cr.ChangeType] 88 | 89 | if statusIcon == "" { 90 | statusIcon = "🔄" 91 | } 92 | 93 | assessment := map[string]string{ 94 | internal.IMPROVEMENT: "Performance improvement detected", 95 | internal.REGRESSION: "Performance regression detected", 96 | }[cr.ChangeType] 97 | 98 | if assessment == "" { 99 | assessment = "No significant change detected" 100 | } 101 | 102 | fmt.Fprintf(report, "%s %s\n\n", statusIcon, assessment) 103 | } 104 | 105 | func (cr *FunctionChangeResult) writeFlatAnalysis(report *strings.Builder) { 106 | report.WriteString("───────────────────────────────────────────────────────────────\n") 107 | report.WriteString(" FLAT TIME ANALYSIS\n") 108 | report.WriteString("───────────────────────────────────────────────────────────────\n") 109 | 110 | sign := signPrefix(cr.FlatChangePercent) 111 | 112 | fmt.Fprintf(report, "Before: %.6fs\n", cr.FlatAbsolute.Before) 113 | fmt.Fprintf(report, "After: %.6fs\n", cr.FlatAbsolute.After) 114 | fmt.Fprintf(report, "Delta: %s%.6fs\n", sign, cr.FlatAbsolute.Delta) 115 | fmt.Fprintf(report, "Change: %s%.2f%%\n", sign, cr.FlatChangePercent) 116 | 117 | switch { 118 | case cr.FlatChangePercent > 0: 119 | fmt.Fprintf(report, "Impact: Function is %.2f%% SLOWER\n\n", cr.FlatChangePercent) 120 | case cr.FlatChangePercent < 0: 121 | fmt.Fprintf(report, "Impact: Function is %.2f%% FASTER\n\n", math.Abs(cr.FlatChangePercent)) 122 | default: 123 | report.WriteString("Impact: No change in execution time\n\n") 124 | } 125 | } 126 | 127 | func (cr *FunctionChangeResult) writeCumulativeAnalysis(report *strings.Builder) { 128 | report.WriteString("───────────────────────────────────────────────────────────────\n") 129 | report.WriteString(" CUMULATIVE TIME ANALYSIS\n") 130 | report.WriteString("───────────────────────────────────────────────────────────────\n") 131 | 132 | sign := signPrefix(cr.CumChangePercent) 133 | 134 | fmt.Fprintf(report, "Before: %.3fs\n", cr.CumAbsolute.Before) 135 | fmt.Fprintf(report, "After: %.3fs\n", cr.CumAbsolute.After) 136 | fmt.Fprintf(report, "Delta: %s%.3fs\n", sign, cr.CumAbsolute.Delta) 137 | fmt.Fprintf(report, "Change: %s%.2f%%\n\n", sign, cr.CumChangePercent) 138 | } 139 | 140 | func (cr *FunctionChangeResult) writeImpactAssessment(report *strings.Builder) { 141 | report.WriteString("───────────────────────────────────────────────────────────────\n") 142 | report.WriteString(" IMPACT ASSESSMENT\n") 143 | report.WriteString("───────────────────────────────────────────────────────────────\n") 144 | 145 | fmt.Fprintf(report, "Severity: %s\n", cr.calculateSeverity()) 146 | report.WriteString("Recommendation: ") 147 | report.WriteString(cr.recommendation()) 148 | report.WriteString("\n") 149 | } 150 | 151 | func getBinFilesLocations(selections *Selections) (string, string) { 152 | fileName := fmt.Sprintf("%s_%s.out", selections.BenchmarkName, selections.ProfileType) 153 | binFilePath1BaseLine := filepath.Join(internal.MainDirOutput, selections.Baseline, internal.ProfileBinDir, selections.BenchmarkName, fileName) 154 | binFilePath2Current := filepath.Join(internal.MainDirOutput, selections.Current, internal.ProfileBinDir, selections.BenchmarkName, fileName) 155 | 156 | return binFilePath1BaseLine, binFilePath2Current 157 | } 158 | 159 | func chooseFileLocations(selections *Selections) (string, string) { 160 | var textFilePathBaseLine, textFilePathCurrent string 161 | 162 | if selections.IsManual { 163 | textFilePathBaseLine = selections.Baseline 164 | textFilePathCurrent = selections.Current 165 | } else { 166 | textFilePathBaseLine, textFilePathCurrent = getBinFilesLocations(selections) 167 | } 168 | 169 | return textFilePathBaseLine, textFilePathCurrent 170 | } 171 | 172 | // applyCIConfiguration applies CI/CD configuration to the performance report 173 | func applyCIConfiguration(report *ProfileChangeReport, selections *Selections) error { 174 | // Load CI/CD configuration 175 | cfg, err := internal.LoadFromFile(internal.ConfigFilename) 176 | if err != nil { 177 | slog.Info("No CI/CD configuration found, using command-line settings only") 178 | // Fall back to command-line threshold logic 179 | return applyCommandLineThresholds(report, selections) 180 | } 181 | 182 | // Apply CI/CD filtering 183 | report.ApplyCIConfiguration(cfg.CIConfig, selections.BenchmarkName) 184 | 185 | // Check if CLI flags were provided for regression checking 186 | cliFlagsProvided := selections.UseThreshold || selections.RegressionThreshold > 0.0 187 | 188 | if cliFlagsProvided { 189 | // User provided CLI flags, use them (with CI/CD config as fallback) 190 | return applyCommandLineThresholds(report, selections) 191 | } 192 | 193 | // No CLI flags provided, use CI/CD config only 194 | slog.Info("No CLI regression flags provided, using CI/CD configuration settings") 195 | return applyCICDThresholdsOnly(report, selections, cfg.CIConfig) 196 | } 197 | 198 | // applyCommandLineThresholds applies the legacy command-line threshold logic 199 | func applyCommandLineThresholds(report *ProfileChangeReport, selections *Selections) error { 200 | if selections.UseThreshold && selections.RegressionThreshold > 0.0 { 201 | worst := report.WorstRegression() 202 | if worst != nil && worst.FlatChangePercent >= selections.RegressionThreshold { 203 | return fmt.Errorf("performance regression %.2f%% in %s exceeds threshold %.2f%%", worst.FlatChangePercent, worst.FunctionName, selections.RegressionThreshold) 204 | } 205 | } 206 | return nil 207 | } 208 | 209 | // applyCICDThresholdsOnly applies CI/CD specific threshold logic only, without CLI flags 210 | func applyCICDThresholdsOnly(report *ProfileChangeReport, selections *Selections, cicdConfig *internal.CIConfig) error { 211 | benchmarkName := selections.BenchmarkName 212 | if benchmarkName == "" { 213 | benchmarkName = "unknown" 214 | } 215 | 216 | // Get effective regression threshold 217 | effectiveThreshold := getEffectiveRegressionThreshold(cicdConfig, benchmarkName, 0.0) // Use 0.0 for no CLI threshold 218 | 219 | // Get minimum change threshold 220 | minChangeThreshold := getMinChangeThreshold(cicdConfig, benchmarkName) 221 | 222 | // Check if we should fail on improvements 223 | failOnImprovement := shouldFailOnImprovement(cicdConfig, benchmarkName) 224 | 225 | // Apply thresholds 226 | if effectiveThreshold > 0.0 { 227 | worst := report.WorstRegression() 228 | if worst != nil && worst.FlatChangePercent <= effectiveThreshold { 229 | // Check if function should be ignored by CI/CD config 230 | if !shouldIgnoreFunction(cicdConfig, worst.FunctionName, benchmarkName) { 231 | return fmt.Errorf("performance regression %.2f%% in %s exceeds CI/CD threshold %.2f%%", 232 | worst.FlatChangePercent, worst.FunctionName, effectiveThreshold) 233 | } 234 | } 235 | } 236 | 237 | // Check for improvements if configured to fail on them 238 | if failOnImprovement { 239 | best := report.BestImprovement() 240 | if best != nil && math.Abs(best.FlatChangePercent) >= minChangeThreshold { 241 | if !shouldIgnoreFunction(cicdConfig, best.FunctionName, benchmarkName) { 242 | return fmt.Errorf("unexpected performance improvement %.2f%% in %s (configured to fail on improvements)", 243 | math.Abs(best.FlatChangePercent), best.FunctionName) 244 | } 245 | } 246 | } 247 | 248 | // Check minimum change threshold 249 | if minChangeThreshold > 0.0 { 250 | worst := report.WorstRegression() 251 | if worst != nil && worst.FlatChangePercent < minChangeThreshold { 252 | slog.Info("Performance regression below minimum threshold, not failing CI/CD", 253 | "function", worst.FunctionName, 254 | "change", worst.FlatChangePercent, 255 | "threshold", minChangeThreshold) 256 | } 257 | } 258 | 259 | return nil 260 | } 261 | 262 | // Helper functions for CI/CD configuration 263 | func getEffectiveRegressionThreshold(cicdConfig *internal.CIConfig, benchmarkName string, commandLineThreshold float64) float64 { 264 | if cicdConfig == nil { 265 | return commandLineThreshold 266 | } 267 | 268 | // Check benchmark-specific config first 269 | if benchmarkConfig, exists := cicdConfig.Benchmarks[benchmarkName]; exists && benchmarkConfig.MaxRegressionThreshold > 0 { 270 | if commandLineThreshold > 0 { 271 | if commandLineThreshold < benchmarkConfig.MaxRegressionThreshold { 272 | return commandLineThreshold 273 | } 274 | return benchmarkConfig.MaxRegressionThreshold 275 | } 276 | return benchmarkConfig.MaxRegressionThreshold 277 | } 278 | 279 | // Fall back to global config 280 | if cicdConfig.Global != nil && cicdConfig.Global.MaxRegressionThreshold > 0 { 281 | if commandLineThreshold > 0 { 282 | if commandLineThreshold < cicdConfig.Global.MaxRegressionThreshold { 283 | return commandLineThreshold 284 | } 285 | return cicdConfig.Global.MaxRegressionThreshold 286 | } 287 | return cicdConfig.Global.MaxRegressionThreshold 288 | } 289 | 290 | return commandLineThreshold 291 | } 292 | 293 | func getMinChangeThreshold(cicdConfig *internal.CIConfig, benchmarkName string) float64 { 294 | if cicdConfig == nil { 295 | return 0.0 296 | } 297 | 298 | // Check benchmark-specific config first 299 | if benchmarkConfig, exists := cicdConfig.Benchmarks[benchmarkName]; exists && benchmarkConfig.MinChangeThreshold > 0 { 300 | return benchmarkConfig.MinChangeThreshold 301 | } 302 | 303 | // Fall back to global config 304 | if cicdConfig.Global != nil && cicdConfig.Global.MinChangeThreshold > 0 { 305 | return cicdConfig.Global.MinChangeThreshold 306 | } 307 | 308 | return 0.0 309 | } 310 | 311 | func shouldFailOnImprovement(cicdConfig *internal.CIConfig, benchmarkName string) bool { 312 | if cicdConfig == nil { 313 | return false 314 | } 315 | 316 | // Check benchmark-specific config first 317 | if benchmarkConfig, exists := cicdConfig.Benchmarks[benchmarkName]; exists { 318 | return benchmarkConfig.FailOnImprovement 319 | } 320 | 321 | // Fall back to global config 322 | if cicdConfig.Global != nil { 323 | return cicdConfig.Global.FailOnImprovement 324 | } 325 | 326 | return false 327 | } 328 | 329 | func shouldIgnoreFunction(cicdConfig *internal.CIConfig, functionName string, benchmarkName string) bool { 330 | if cicdConfig == nil { 331 | return false 332 | } 333 | 334 | // Check benchmark-specific config first 335 | if benchmarkConfig, exists := cicdConfig.Benchmarks[benchmarkName]; exists { 336 | if shouldIgnoreFunctionByConfig(&benchmarkConfig, functionName) { 337 | return true 338 | } 339 | } 340 | 341 | // Fall back to global config 342 | if cicdConfig.Global != nil { 343 | return shouldIgnoreFunctionByConfig(cicdConfig.Global, functionName) 344 | } 345 | 346 | return false 347 | } 348 | 349 | func shouldIgnoreFunctionByConfig(config *internal.CITrackingConfig, functionName string) bool { 350 | if config == nil { 351 | return false 352 | } 353 | 354 | // Check exact function name matches 355 | for _, ignoredFunc := range config.IgnoreFunctions { 356 | if functionName == ignoredFunc { 357 | return true 358 | } 359 | } 360 | 361 | // Check prefix matches 362 | for _, ignoredPrefix := range config.IgnorePrefixes { 363 | if strings.HasPrefix(functionName, ignoredPrefix) { 364 | return true 365 | } 366 | } 367 | 368 | return false 369 | } 370 | 371 | // CheckPerformanceDifferences creates the profile report by comparing data from prof's auto run. 372 | func CheckPerformanceDifferences(selections *Selections) (*ProfileChangeReport, error) { 373 | binFilePathBaseLine, binFilePathCurrent := chooseFileLocations(selections) 374 | 375 | lineObjsBaseline, err := parser.TurnLinesIntoObjectsV2(binFilePathBaseLine) 376 | if err != nil { 377 | return nil, fmt.Errorf("couldn't get objs for path: %s, error: %w", binFilePathBaseLine, err) 378 | } 379 | 380 | lineObjsCurrent, err := parser.TurnLinesIntoObjectsV2(binFilePathCurrent) 381 | if err != nil { 382 | return nil, fmt.Errorf("couldn't get objs for path: %s, error: %w", binFilePathCurrent, err) 383 | } 384 | 385 | matchingMap := createMapFromLineObjects(lineObjsBaseline) 386 | 387 | pgp := &ProfileChangeReport{} 388 | for _, currentObj := range lineObjsCurrent { 389 | baseLineObj, matchNotFound := matchingMap[currentObj.FnName] 390 | if !matchNotFound { 391 | continue 392 | } 393 | 394 | var changeResult *FunctionChangeResult 395 | changeResult, err = detectChangeBetweenTwoObjects(baseLineObj, currentObj) 396 | if err != nil { 397 | return nil, fmt.Errorf("detectChangeBetweenTwoObjects failed: %w", err) 398 | } 399 | 400 | pgp.FunctionChanges = append(pgp.FunctionChanges, changeResult) 401 | } 402 | 403 | return pgp, nil 404 | } 405 | -------------------------------------------------------------------------------- /engine/tracker/profile_change_report.go: -------------------------------------------------------------------------------- 1 | package tracker 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html/template" 7 | "log/slog" 8 | "math" 9 | "os" 10 | "sort" 11 | 12 | "github.com/AlexsanderHamir/prof/internal" 13 | "github.com/microcosm-cc/bluemonday" 14 | ) 15 | 16 | func (r *ProfileChangeReport) printSummary() { 17 | fmt.Println("\n=== Performance Tracking Summary ===") 18 | 19 | var regressionList, improvementList []*FunctionChangeResult 20 | var stable int 21 | 22 | // Separate changes by type 23 | for _, change := range r.FunctionChanges { 24 | switch change.ChangeType { 25 | case internal.REGRESSION: 26 | regressionList = append(regressionList, change) 27 | case internal.IMPROVEMENT: 28 | improvementList = append(improvementList, change) 29 | default: 30 | stable++ 31 | } 32 | } 33 | 34 | // Sort regressions by percentage (biggest regression first) 35 | sort.Slice(regressionList, func(i, j int) bool { 36 | return regressionList[i].FlatChangePercent > regressionList[j].FlatChangePercent 37 | }) 38 | 39 | // Sort improvements by absolute percentage (biggest improvement first) 40 | sort.Slice(improvementList, func(i, j int) bool { 41 | return math.Abs(improvementList[i].FlatChangePercent) > math.Abs(improvementList[j].FlatChangePercent) 42 | }) 43 | 44 | fmt.Printf("Total Functions Analyzed: %d\n", len(r.FunctionChanges)) 45 | fmt.Printf("Regressions: %d\n", len(regressionList)) 46 | fmt.Printf("Improvements: %d\n", len(improvementList)) 47 | fmt.Printf("Stable: %d\n", stable) 48 | 49 | if len(regressionList) > 0 { 50 | fmt.Println("\n⚠️ Top Regressions (worst first):") 51 | for _, change := range regressionList { 52 | fmt.Printf(" • %s\n", change.summary()) 53 | } 54 | } 55 | 56 | if len(improvementList) > 0 { 57 | fmt.Println("\n✅ Top Improvements (best first):") 58 | for _, change := range improvementList { 59 | fmt.Printf(" • %s\n", change.summary()) 60 | } 61 | } 62 | } 63 | 64 | const ( 65 | regressionPriority = 1 66 | improvementPriority = 2 67 | stablePriority = 3 68 | ) 69 | 70 | func (r *ProfileChangeReport) printDetailedReport() { 71 | changes := r.FunctionChanges 72 | 73 | // Count each type 74 | var regressions, improvements, stable int 75 | for _, change := range changes { 76 | switch change.ChangeType { 77 | case internal.REGRESSION: 78 | regressions++ 79 | case internal.IMPROVEMENT: 80 | improvements++ 81 | default: 82 | stable++ 83 | } 84 | } 85 | 86 | // Print header with statistics and sorting info 87 | fmt.Println("╔══════════════════════════════════════════════════════════════════╗") 88 | fmt.Println("║ Detailed Performance Report ║") 89 | fmt.Println("╚══════════════════════════════════════════════════════════════════╝") 90 | fmt.Printf("\n📊 Summary: %d total functions | 🔴 %d regressions | 🟢 %d improvements | ⚪ %d stable\n", 91 | len(changes), regressions, improvements, stable) 92 | fmt.Println("\n📋 Report Order: Regressions first (worst → best), then Improvements (best → worst), then Stable") 93 | fmt.Println("═══════════════════════════════════════════════════════════════════════════════════════════════════") 94 | 95 | // Sort by change type first (REGRESSION, IMPROVEMENT, STABLE), 96 | // then by absolute percentage change (biggest changes first) 97 | sort.Slice(changes, func(i, j int) bool { 98 | // Primary sort: by change type priority 99 | typePriority := map[string]int{ 100 | internal.REGRESSION: regressionPriority, 101 | internal.IMPROVEMENT: improvementPriority, 102 | internal.STABLE: stablePriority, 103 | } 104 | 105 | if typePriority[changes[i].ChangeType] != typePriority[changes[j].ChangeType] { 106 | return typePriority[changes[i].ChangeType] < typePriority[changes[j].ChangeType] 107 | } 108 | 109 | return math.Abs(changes[i].FlatChangePercent) > math.Abs(changes[j].FlatChangePercent) 110 | }) 111 | 112 | for i, change := range changes { 113 | if i > 0 { 114 | fmt.Println() 115 | fmt.Println() 116 | fmt.Println() 117 | } 118 | fmt.Print(change.Report()) 119 | } 120 | } 121 | 122 | type htmlData struct { 123 | TotalFunctions int 124 | Regressions []*FunctionChangeResult 125 | Improvements []*FunctionChangeResult 126 | Stable int 127 | } 128 | 129 | func (r *ProfileChangeReport) generateHTMLSummary(outputPath string) error { 130 | var regressionList, improvementList []*FunctionChangeResult 131 | var stable int 132 | 133 | for _, change := range r.FunctionChanges { 134 | switch change.ChangeType { 135 | case internal.REGRESSION: 136 | regressionList = append(regressionList, change) 137 | case internal.IMPROVEMENT: 138 | improvementList = append(improvementList, change) 139 | default: 140 | stable++ 141 | } 142 | } 143 | 144 | sort.Slice(regressionList, func(i, j int) bool { 145 | return regressionList[i].FlatChangePercent > regressionList[j].FlatChangePercent 146 | }) 147 | sort.Slice(improvementList, func(i, j int) bool { 148 | return math.Abs(improvementList[i].FlatChangePercent) > math.Abs(improvementList[j].FlatChangePercent) 149 | }) 150 | 151 | data := htmlData{ 152 | TotalFunctions: len(r.FunctionChanges), 153 | Regressions: regressionList, 154 | Improvements: improvementList, 155 | Stable: stable, 156 | } 157 | 158 | tmpl := ` 159 | 160 | 161 | 162 | 163 | Performance Tracking Summary 164 | 181 | 182 | 183 |

Performance Tracking Summary

184 |

Total Functions Analyzed: {{.TotalFunctions}}

185 |

Regressions: {{len .Regressions}}

186 |

Improvements: {{len .Improvements}}

187 |

Stable: {{.Stable}}

188 | 189 | {{if .Regressions}} 190 |

⚠️ Top Regressions (worst first):

191 | 194 | {{end}} 195 | 196 | {{if .Improvements}} 197 |

✅ Top Improvements (best first):

198 | 201 | {{end}} 202 | 203 | 204 | ` 205 | 206 | funcMap := template.FuncMap{ 207 | "summary": func(fc *FunctionChangeResult) string { 208 | return fc.summary() 209 | }, 210 | } 211 | 212 | t, err := template.New("report").Funcs(funcMap).Parse(tmpl) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | file, err := os.Create(outputPath) 218 | if err != nil { 219 | return err 220 | } 221 | defer file.Close() 222 | 223 | return t.Execute(file, data) 224 | } 225 | 226 | type detailedHTMLData struct { 227 | Total int 228 | Regressions int 229 | Improvements int 230 | Stable int 231 | Changes []*FunctionChangeResult 232 | } 233 | 234 | func (r *ProfileChangeReport) generateDetailedHTMLReport(outputPath string) error { 235 | changes := r.FunctionChanges 236 | 237 | // Count types 238 | var regressions, improvements, stable int 239 | for _, change := range changes { 240 | switch change.ChangeType { 241 | case internal.REGRESSION: 242 | regressions++ 243 | case internal.IMPROVEMENT: 244 | improvements++ 245 | default: 246 | stable++ 247 | } 248 | } 249 | 250 | // Sort: regressions → improvements → stable, each by magnitude 251 | typePriority := map[string]int{ 252 | internal.REGRESSION: regressionPriority, 253 | internal.IMPROVEMENT: improvementPriority, 254 | internal.STABLE: stablePriority, 255 | } 256 | 257 | sort.Slice(changes, func(i, j int) bool { 258 | if typePriority[changes[i].ChangeType] != typePriority[changes[j].ChangeType] { 259 | return typePriority[changes[i].ChangeType] < typePriority[changes[j].ChangeType] 260 | } 261 | return math.Abs(changes[i].FlatChangePercent) > math.Abs(changes[j].FlatChangePercent) 262 | }) 263 | 264 | data := detailedHTMLData{ 265 | Total: len(changes), 266 | Regressions: regressions, 267 | Improvements: improvements, 268 | Stable: stable, 269 | Changes: changes, 270 | } 271 | 272 | tmpl := ` 273 | 274 | 275 | 276 | 277 | Detailed Performance Report 278 | 286 | 287 | 288 |

📈 Detailed Performance Report

289 | 290 |
291 |

Total functions: {{.Total}}

292 |

🔴 Regressions: {{.Regressions}} | 🟢 Improvements: {{.Improvements}} | ⚪ Stable: {{.Stable}}

293 |

Report Order: Regressions (worst → best), then Improvements (best → worst), then Stable

294 |
295 | 296 | {{range .Changes}} 297 |
298 |
{{report .}}
299 |
300 | {{end}} 301 | 302 | 303 | ` 304 | 305 | sanitizer := bluemonday.StrictPolicy() 306 | funcMap := template.FuncMap{ 307 | "report": func(fc *FunctionChangeResult) template.HTML { 308 | safe := sanitizer.Sanitize(fc.Report()) 309 | return template.HTML(safe) //nolint:gosec // input is being sanatized 310 | }, 311 | } 312 | 313 | t, err := template.New("detailed").Funcs(funcMap).Parse(tmpl) 314 | if err != nil { 315 | return err 316 | } 317 | 318 | file, err := os.Create(outputPath) 319 | if err != nil { 320 | return err 321 | } 322 | defer file.Close() 323 | 324 | return t.Execute(file, data) 325 | } 326 | 327 | // JSON data structures 328 | type jsonSummaryData struct { 329 | TotalFunctions int `json:"total_functions"` 330 | Statistics jsonStatistics `json:"statistics"` 331 | Regressions []*FunctionChangeResult `json:"regressions"` 332 | Improvements []*FunctionChangeResult `json:"improvements"` 333 | } 334 | 335 | type jsonStatistics struct { 336 | Regressions int `json:"regressions"` 337 | Improvements int `json:"improvements"` 338 | Stable int `json:"stable"` 339 | } 340 | 341 | type jsonDetailedData struct { 342 | TotalFunctions int `json:"total_functions"` 343 | Statistics jsonStatistics `json:"statistics"` 344 | Changes []*FunctionChangeResult `json:"changes"` 345 | SortOrder string `json:"sort_order"` 346 | } 347 | 348 | func (r *ProfileChangeReport) generateJSONSummary(outputPath string) error { 349 | var regressionList, improvementList []*FunctionChangeResult 350 | var stable int 351 | 352 | for _, change := range r.FunctionChanges { 353 | switch change.ChangeType { 354 | case internal.REGRESSION: 355 | regressionList = append(regressionList, change) 356 | case internal.IMPROVEMENT: 357 | improvementList = append(improvementList, change) 358 | default: 359 | stable++ 360 | } 361 | } 362 | 363 | // Sort regressions by percentage (biggest regression first) 364 | sort.Slice(regressionList, func(i, j int) bool { 365 | return regressionList[i].FlatChangePercent > regressionList[j].FlatChangePercent 366 | }) 367 | 368 | // Sort improvements by absolute percentage (biggest improvement first) 369 | sort.Slice(improvementList, func(i, j int) bool { 370 | return math.Abs(improvementList[i].FlatChangePercent) > math.Abs(improvementList[j].FlatChangePercent) 371 | }) 372 | 373 | data := jsonSummaryData{ 374 | TotalFunctions: len(r.FunctionChanges), 375 | Statistics: jsonStatistics{ 376 | Regressions: len(regressionList), 377 | Improvements: len(improvementList), 378 | Stable: stable, 379 | }, 380 | Regressions: regressionList, 381 | Improvements: improvementList, 382 | } 383 | 384 | file, err := os.Create(outputPath) 385 | if err != nil { 386 | return err 387 | } 388 | defer file.Close() 389 | 390 | encoder := json.NewEncoder(file) 391 | encoder.SetIndent("", " ") 392 | return encoder.Encode(data) 393 | } 394 | 395 | func (r *ProfileChangeReport) generateDetailedJSONReport(outputPath string) error { 396 | changes := r.FunctionChanges 397 | 398 | // Count types 399 | var regressions, improvements, stable int 400 | for _, change := range changes { 401 | switch change.ChangeType { 402 | case internal.REGRESSION: 403 | regressions++ 404 | case internal.IMPROVEMENT: 405 | improvements++ 406 | default: 407 | stable++ 408 | } 409 | } 410 | 411 | // Sort: regressions → improvements → stable, each by magnitude 412 | typePriority := map[string]int{ 413 | internal.REGRESSION: regressionPriority, 414 | internal.IMPROVEMENT: improvementPriority, 415 | internal.STABLE: stablePriority, 416 | } 417 | 418 | sort.Slice(changes, func(i, j int) bool { 419 | if typePriority[changes[i].ChangeType] != typePriority[changes[j].ChangeType] { 420 | return typePriority[changes[i].ChangeType] < typePriority[changes[j].ChangeType] 421 | } 422 | return math.Abs(changes[i].FlatChangePercent) > math.Abs(changes[j].FlatChangePercent) 423 | }) 424 | 425 | data := jsonDetailedData{ 426 | TotalFunctions: len(changes), 427 | Statistics: jsonStatistics{ 428 | Regressions: regressions, 429 | Improvements: improvements, 430 | Stable: stable, 431 | }, 432 | Changes: changes, 433 | SortOrder: "Regressions (worst → best), then Improvements (best → worst), then Stable", 434 | } 435 | 436 | file, err := os.Create(outputPath) 437 | if err != nil { 438 | return err 439 | } 440 | defer file.Close() 441 | 442 | encoder := json.NewEncoder(file) 443 | encoder.SetIndent("", " ") 444 | return encoder.Encode(data) 445 | } 446 | 447 | func (r *ProfileChangeReport) ChooseOutputFormat(outputFormat string) { 448 | switch outputFormat { 449 | case "summary": 450 | r.printSummary() 451 | case "detailed": 452 | r.printDetailedReport() 453 | case "summary-html": 454 | if err := r.generateHTMLSummary("summary.html"); err != nil { 455 | slog.Info("summary-html failed", "err", err) 456 | } 457 | case "detailed-html": 458 | if err := r.generateDetailedHTMLReport("detailed.html"); err != nil { 459 | slog.Info("detailed-html failed", "err", err) 460 | } 461 | case "summary-json": 462 | if err := r.generateJSONSummary("summary.json"); err != nil { 463 | slog.Info("summary-json failed", "err", err) 464 | } 465 | case "detailed-json": 466 | if err := r.generateDetailedJSONReport("detailed.json"); err != nil { 467 | slog.Info("detailed-json failed", "err", err) 468 | } 469 | } 470 | } 471 | 472 | // WorstRegression returns the single worst regression by flat change percent. 473 | // Returns nil if there are no regressions. 474 | func (r *ProfileChangeReport) WorstRegression() *FunctionChangeResult { 475 | var worst *FunctionChangeResult 476 | for _, change := range r.FunctionChanges { 477 | if change.ChangeType != internal.REGRESSION { 478 | continue 479 | } 480 | if worst == nil || change.FlatChangePercent > worst.FlatChangePercent { 481 | worst = change 482 | } 483 | } 484 | return worst 485 | } 486 | 487 | // BestImprovement returns the single best improvement by absolute flat change percent. 488 | // Returns nil if there are no improvements. 489 | func (r *ProfileChangeReport) BestImprovement() *FunctionChangeResult { 490 | var best *FunctionChangeResult 491 | for _, change := range r.FunctionChanges { 492 | if change.ChangeType != internal.IMPROVEMENT { 493 | continue 494 | } 495 | if best == nil || math.Abs(change.FlatChangePercent) > math.Abs(best.FlatChangePercent) { 496 | best = change 497 | } 498 | } 499 | return best 500 | } 501 | 502 | // ApplyCIConfiguration applies CI/CD configuration filtering to the report 503 | func (r *ProfileChangeReport) ApplyCIConfiguration(cicdConfig *internal.CIConfig, benchmarkName string) { 504 | if cicdConfig == nil { 505 | return 506 | } 507 | 508 | // Get the appropriate CI/CD configuration for this benchmark 509 | var config *internal.CITrackingConfig 510 | if benchmarkConfig, exists := cicdConfig.Benchmarks[benchmarkName]; exists { 511 | config = &benchmarkConfig 512 | } else if cicdConfig.Global != nil { 513 | config = cicdConfig.Global 514 | } else { 515 | return 516 | } 517 | 518 | // Filter out ignored functions 519 | var filteredChanges []*FunctionChangeResult 520 | for _, change := range r.FunctionChanges { 521 | if !shouldIgnoreFunctionByConfig(config, change.FunctionName) { 522 | filteredChanges = append(filteredChanges, change) 523 | } else { 524 | slog.Debug("Function ignored by CI/CD config", 525 | "function", change.FunctionName, 526 | "benchmark", benchmarkName) 527 | } 528 | } 529 | 530 | // Update the report with filtered changes 531 | r.FunctionChanges = filteredChanges 532 | 533 | slog.Info("Applied CI/CD configuration filtering", 534 | "benchmark", benchmarkName, 535 | "original_functions", len(r.FunctionChanges), 536 | "filtered_functions", len(filteredChanges)) 537 | } 538 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # This file is licensed under the terms of the MIT license https://opensource.org/license/mit 2 | # Copyright (c) 2021-2025 Marat Reymers 3 | 4 | ## Golden config for golangci-lint v1.64.7 5 | # 6 | # This is the best config for golangci-lint based on my experience and opinion. 7 | # It is very strict, but not extremely strict. 8 | # Feel free to adapt it to suit your needs. 9 | # If this config helps you, please consider keeping a link to this file (see the next comment). 10 | 11 | # Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 12 | 13 | run: 14 | timeout: 3m 15 | relative-path-mode: gomod 16 | issues: 17 | max-same-issues: 50 18 | exclude-dirs: 19 | - ^tests$ 20 | - /tests/ 21 | - .*/tests/.* 22 | exclude-files: 23 | - "_test\\.go$" 24 | 25 | # The mode used to evaluate relative paths. 26 | # It's used by exclusions, Go plugins, and some linters. 27 | # The value can be: 28 | # - `gomod`: the paths will be relative to the directory of the `go.mod` file. 29 | # - `gitroot`: the paths will be relative to the git root (the parent directory of `.git`). 30 | # - `cfg`: the paths will be relative to the configuration file. 31 | # - `wd` (NOT recommended): the paths will be relative to the place where golangci-lint is run. 32 | # Default: wd 33 | relative-path-mode: gomod 34 | 35 | # This file contains only configs which differ from defaults. 36 | # All possible options can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml 37 | linters-settings: 38 | cyclop: 39 | # The maximal code complexity to report. 40 | # Default: 10 41 | max-complexity: 30 42 | # The maximal average package complexity. 43 | # If it's higher than 0.0 (float) the check is enabled 44 | # Default: 0.0 45 | package-average: 10.0 46 | 47 | depguard: 48 | # Rules to apply. 49 | # 50 | # Variables: 51 | # - File Variables 52 | # Use an exclamation mark `!` to negate a variable. 53 | # Example: `!$test` matches any file that is not a go test file. 54 | # 55 | # `$all` - matches all go files 56 | # `$test` - matches all go test files 57 | # 58 | # - Package Variables 59 | # 60 | # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`) 61 | # 62 | # Default (applies if no custom rules are defined): Only allow $gostd in all files. 63 | rules: 64 | "deprecated": 65 | # List of file globs that will match this list of settings to compare against. 66 | # Default: $all 67 | files: 68 | - "$all" 69 | # List of packages that are not allowed. 70 | # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). 71 | # Default: [] 72 | deny: 73 | - pkg: "github.com/golang/protobuf" 74 | desc: "Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules" 75 | - pkg: "github.com/satori/go.uuid" 76 | desc: "Use github.com/google/uuid instead, satori's package is not maintained" 77 | - pkg: "github.com/gofrs/uuid$" 78 | desc: "Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5" 79 | "non-test files": 80 | files: 81 | - "!$test" 82 | deny: 83 | - pkg: "math/rand$" 84 | desc: "Use math/rand/v2 instead, see https://go.dev/blog/randv2" 85 | "non-main files": 86 | files: 87 | - "!**/main.go" 88 | deny: 89 | - pkg: "log$" 90 | desc: "Use log/slog instead, see https://go.dev/blog/slog" 91 | 92 | errcheck: 93 | # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. 94 | # Such cases aren't reported by default. 95 | # Default: false 96 | check-type-assertions: true 97 | 98 | exhaustive: 99 | # Program elements to check for exhaustiveness. 100 | # Default: [ switch ] 101 | check: 102 | - switch 103 | - map 104 | 105 | exhaustruct: 106 | # List of regular expressions to exclude struct packages and their names from checks. 107 | # Regular expressions must match complete canonical struct package/name/structname. 108 | # Default: [] 109 | exclude: 110 | # std libs 111 | - "^net/http.Client$" 112 | - "^net/http.Cookie$" 113 | - "^net/http.Request$" 114 | - "^net/http.Response$" 115 | - "^net/http.Server$" 116 | - "^net/http.Transport$" 117 | - "^net/url.URL$" 118 | - "^os/exec.Cmd$" 119 | - "^reflect.StructField$" 120 | # public libs 121 | - "^github.com/Shopify/sarama.Config$" 122 | - "^github.com/Shopify/sarama.ProducerMessage$" 123 | - "^github.com/mitchellh/mapstructure.DecoderConfig$" 124 | - "^github.com/prometheus/client_golang/.+Opts$" 125 | - "^github.com/spf13/cobra.Command$" 126 | - "^github.com/spf13/cobra.CompletionOptions$" 127 | - "^github.com/stretchr/testify/mock.Mock$" 128 | - "^github.com/testcontainers/testcontainers-go.+Request$" 129 | - "^github.com/testcontainers/testcontainers-go.FromDockerfile$" 130 | - "^golang.org/x/tools/go/analysis.Analyzer$" 131 | - "^google.golang.org/protobuf/.+Options$" 132 | - "^gopkg.in/yaml.v3.Node$" 133 | 134 | funlen: 135 | # Checks the number of lines in a function. 136 | # If lower than 0, disable the check. 137 | # Default: 60 138 | lines: 100 139 | # Checks the number of statements in a function. 140 | # If lower than 0, disable the check. 141 | # Default: 40 142 | statements: 50 143 | # Ignore comments when counting lines. 144 | # Default false 145 | ignore-comments: true 146 | 147 | gochecksumtype: 148 | # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. 149 | # Default: true 150 | default-signifies-exhaustive: false 151 | 152 | gocognit: 153 | # Minimal code complexity to report. 154 | # Default: 30 (but we recommend 10-20) 155 | min-complexity: 20 156 | 157 | gocritic: 158 | # Settings passed to gocritic. 159 | # The settings key is the name of a supported gocritic checker. 160 | # The list of supported checkers can be find in https://go-critic.github.io/overview. 161 | settings: 162 | captLocal: 163 | # Whether to restrict checker to params only. 164 | # Default: true 165 | paramsOnly: false 166 | underef: 167 | # Whether to skip (*x).method() calls where x is a pointer receiver. 168 | # Default: true 169 | skipRecvDeref: false 170 | 171 | govet: 172 | # Enable all analyzers. 173 | # Default: false 174 | enable-all: true 175 | # Disable analyzers by name. 176 | # Run `go tool vet help` to see all analyzers. 177 | # Default: [] 178 | disable: 179 | - fieldalignment # too strict 180 | # Settings per analyzer. 181 | settings: 182 | shadow: 183 | # Whether to be strict about shadowing; can be noisy. 184 | # Default: false 185 | strict: true 186 | 187 | inamedparam: 188 | # Skips check for interface methods with only a single parameter. 189 | # Default: false 190 | skip-single-param: true 191 | 192 | nakedret: 193 | # Make an issue if func has more lines of code than this setting, and it has naked returns. 194 | # Default: 30 195 | max-func-lines: 0 196 | 197 | nolintlint: 198 | # Exclude following linters from requiring an explanation. 199 | # Default: [] 200 | allow-no-explanation: [funlen, gocognit] 201 | # Enable to require an explanation of nonzero length after each nolint directive. 202 | # Default: false 203 | require-explanation: true 204 | # Enable to require nolint directives to mention the specific linter being suppressed. 205 | # Default: false 206 | require-specific: true 207 | 208 | perfsprint: 209 | # Optimizes into strings concatenation. 210 | # Default: true 211 | strconcat: false 212 | 213 | reassign: 214 | # Patterns for global variable names that are checked for reassignment. 215 | # See https://github.com/curioswitch/go-reassign#usage 216 | # Default: ["EOF", "Err.*"] 217 | patterns: 218 | - ".*" 219 | 220 | rowserrcheck: 221 | # database/sql is always checked 222 | # Default: [] 223 | packages: 224 | - github.com/jmoiron/sqlx 225 | 226 | usetesting: 227 | # Enable/disable `os.TempDir()` detections. 228 | # Default: false 229 | os-temp-dir: true 230 | 231 | linters: 232 | disable-all: true 233 | enable: 234 | ## enabled by default 235 | - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases 236 | - gosimple # specializes in simplifying a code 237 | - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string 238 | - ineffassign # detects when assignments to existing variables are not used 239 | - staticcheck # is a go vet on steroids, applying a ton of static analysis checks 240 | - typecheck # like the front-end of a Go compiler, parses and type-checks Go code 241 | - unused # checks for unused constants, variables, functions and types 242 | ## disabled by default 243 | - asasalint # checks for pass []any as any in variadic func(...any) 244 | - asciicheck # checks that your code does not contain non-ASCII identifiers 245 | - bidichk # checks for dangerous unicode character sequences 246 | - bodyclose # checks whether HTTP response body is closed successfully 247 | - canonicalheader # checks whether net/http.Header uses canonical header 248 | - copyloopvar # detects places where loop variables are copied (Go 1.22+) 249 | - cyclop # checks function and package cyclomatic complexity 250 | - depguard # checks if package imports are in a list of acceptable packages 251 | - dupl # tool for code clone detection 252 | - durationcheck # checks for two durations multiplied together 253 | - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error 254 | - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 255 | - exhaustive # checks exhaustiveness of enum switch statements 256 | - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions 257 | - fatcontext # detects nested contexts in loops 258 | - funlen # tool for detection of long functions 259 | - gocheckcompilerdirectives # validates go compiler directive comments (//go:) 260 | - gochecknoinits # checks that no init functions are present in Go code 261 | - gochecksumtype # checks exhaustiveness on Go "sum types" 262 | - gocognit # computes and checks the cognitive complexity of functions 263 | - goconst # finds repeated strings that could be replaced by a constant 264 | - gocritic # provides diagnostics that check for bugs, performance and style issues 265 | - gocyclo # computes and checks the cyclomatic complexity of functions 266 | - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt 267 | - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod 268 | - goprintffuncname # checks that printf-like functions are named with f at the end 269 | - gosec # inspects source code for security problems 270 | - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution 271 | - intrange # finds places where for loops could make use of an integer range 272 | - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) 273 | - makezero # finds slice declarations with non-zero initial length 274 | - mirror # reports wrong mirror patterns of bytes/strings usage 275 | - musttag # enforces field tags in (un)marshaled structs 276 | - nakedret # finds naked returns in functions greater than a specified function length 277 | - nestif # reports deeply nested if statements 278 | - nilerr # finds the code that returns nil even if it checks that the error is not nil 279 | - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) 280 | - nilnil # checks that there is no simultaneous return of nil error and an invalid value 281 | - noctx # finds sending http request without context.Context 282 | - nolintlint # reports ill-formed or insufficient nolint directives 283 | - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL 284 | - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative 285 | - predeclared # finds code that shadows one of Go's predeclared identifiers 286 | - promlinter # checks Prometheus metrics naming via promlint 287 | - protogetter # reports direct reads from proto message fields when getters should be used 288 | - reassign # checks that package variables are not reassigned 289 | - recvcheck # checks for receiver type consistency 290 | - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint 291 | - rowserrcheck # checks whether Err of rows is checked successfully 292 | - spancheck # checks for mistakes with OpenTelemetry/Census spans 293 | - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed 294 | - stylecheck # is a replacement for golint 295 | - testableexamples # checks if examples are testable (have an expected output) 296 | - testifylint # checks usage of github.com/stretchr/testify 297 | - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes 298 | - unconvert # removes unnecessary type conversions 299 | - unparam # reports unused function parameters 300 | - usestdlibvars # detects the possibility to use variables/constants from the Go standard library 301 | - usetesting # reports uses of functions with replacement inside the testing package 302 | - wastedassign # finds wasted assignment statements 303 | - whitespace # detects leading and trailing whitespace 304 | - godox # detects usage of FIXME, TODO and other keywords inside comments 305 | 306 | 307 | ## you may want to enable 308 | #- decorder # checks declaration order and count of types, constants, variables and functions 309 | #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized 310 | #- gci # controls golang package import order and makes it always deterministic 311 | #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega 312 | #- goheader # checks is file header matches to pattern 313 | #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters 314 | #- interfacebloat # checks the number of methods inside an interface 315 | #- ireturn # accept interfaces, return concrete types 316 | #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated 317 | #- tagalign # checks that struct tags are well aligned 318 | #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope 319 | #- wrapcheck # checks that errors returned from external packages are wrapped 320 | #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event 321 | 322 | ## disabled 323 | #- containedctx # detects struct contained context.Context field 324 | #- contextcheck # [too many false positives] checks the function whether use a non-inherited context 325 | #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) 326 | #- dupword # [useless without config] checks for duplicate words in the source code 327 | #- err113 # [too strict] checks the errors handling expressions 328 | #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted 329 | #- forcetypeassert # [replaced by errcheck] finds forced type assertions 330 | #- gofmt # [replaced by goimports] checks whether code was gofmt-ed 331 | #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed 332 | #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies 333 | #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase 334 | #- grouper # analyzes expression groups 335 | #- importas # enforces consistent import aliases 336 | #- maintidx # measures the maintainability index of each function 337 | #- misspell # [useless] finds commonly misspelled English words in comments 338 | #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity 339 | #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test 340 | #- tagliatelle # checks the struct tags 341 | #- tenv # [deprecated, replaced by usetesting] detects using os.Setenv instead of t.Setenv since Go1.17 342 | #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers 343 | #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines 344 | --------------------------------------------------------------------------------