diff --git a/cli/api.go b/cli/api.go index 838f346..a2ba727 100644 --- a/cli/api.go +++ b/cli/api.go @@ -238,8 +238,10 @@ func runSetup(_ *cobra.Command, _ []string) error { } var validFormats = map[string]bool{ - "summary": true, - "detailed": true, + "summary": true, + "detailed": true, + "summary-html": true, + "detailed-html": true, } // runTrack handles the track command execution @@ -259,12 +261,7 @@ func runTrackAuto(_ *cobra.Command, _ []string) error { return nil } - switch outputFormat { - case "summary": - printSummary(report) - case "detailed": - printDetailedReport(report) - } + chooseOutputFormat(report) return nil } @@ -285,11 +282,7 @@ func runTrackManual(_ *cobra.Command, _ []string) error { return nil } - switch outputFormat { - case "summary": - printSummary(report) - case "detailed": - printDetailedReport(report) - } + chooseOutputFormat(report) + return nil } diff --git a/cli/helpers.go b/cli/helpers.go index 6e252b4..b02c1d9 100644 --- a/cli/helpers.go +++ b/cli/helpers.go @@ -2,8 +2,10 @@ package cli import ( "fmt" + "html/template" "log/slog" "math" + "os" "sort" "github.com/AlexsanderHamir/prof/engine/benchmark" @@ -11,6 +13,7 @@ import ( "github.com/AlexsanderHamir/prof/internal/args" "github.com/AlexsanderHamir/prof/internal/config" "github.com/AlexsanderHamir/prof/internal/shared" + "github.com/microcosm-cc/bluemonday" ) func printConfiguration(benchArgs *args.BenchArgs, functionFilterPerBench map[string]config.FunctionFilter) { @@ -182,3 +185,225 @@ func printDetailedReport(report *tracker.ProfileChangeReport) { fmt.Print(change.Report()) } } + +type htmlData struct { + TotalFunctions int + Regressions []*tracker.FunctionChangeResult + Improvements []*tracker.FunctionChangeResult + Stable int +} + +func generateHTMLSummary(report *tracker.ProfileChangeReport, outputPath string) error { + var regressionList, improvementList []*tracker.FunctionChangeResult + var stable int + + for _, change := range report.FunctionChanges { + switch change.ChangeType { + case shared.REGRESSION: + regressionList = append(regressionList, change) + case shared.IMPROVEMENT: + improvementList = append(improvementList, change) + default: + stable++ + } + } + + sort.Slice(regressionList, func(i, j int) bool { + return regressionList[i].FlatChangePercent > regressionList[j].FlatChangePercent + }) + sort.Slice(improvementList, func(i, j int) bool { + return math.Abs(improvementList[i].FlatChangePercent) > math.Abs(improvementList[j].FlatChangePercent) + }) + + data := htmlData{ + TotalFunctions: len(report.FunctionChanges), + Regressions: regressionList, + Improvements: improvementList, + Stable: stable, + } + + tmpl := ` + + + + + Performance Tracking Summary + + + +

Performance Tracking Summary

+

Total Functions Analyzed: {{.TotalFunctions}}

+

Regressions: {{len .Regressions}}

+

Improvements: {{len .Improvements}}

+

Stable: {{.Stable}}

+ + {{if .Regressions}} +

⚠️ Top Regressions (worst first):

+ + {{end}} + + {{if .Improvements}} +

✅ Top Improvements (best first):

+ + {{end}} + + +` + + funcMap := template.FuncMap{ + "summary": func(fc *tracker.FunctionChangeResult) string { + return fc.Summary() + }, + } + + t, err := template.New("report").Funcs(funcMap).Parse(tmpl) + if err != nil { + return err + } + + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + return t.Execute(file, data) +} + +type detailedHTMLData struct { + Total int + Regressions int + Improvements int + Stable int + Changes []*tracker.FunctionChangeResult +} + +func generateDetailedHTMLReport(report *tracker.ProfileChangeReport, outputPath string) error { + changes := report.FunctionChanges + + // Count types + var regressions, improvements, stable int + for _, change := range changes { + switch change.ChangeType { + case shared.REGRESSION: + regressions++ + case shared.IMPROVEMENT: + improvements++ + default: + stable++ + } + } + + // Sort: regressions → improvements → stable, each by magnitude + typePriority := map[string]int{ + shared.REGRESSION: regressionPriority, + shared.IMPROVEMENT: improvementPriority, + shared.STABLE: stablePriority, + } + + sort.Slice(changes, func(i, j int) bool { + if typePriority[changes[i].ChangeType] != typePriority[changes[j].ChangeType] { + return typePriority[changes[i].ChangeType] < typePriority[changes[j].ChangeType] + } + return math.Abs(changes[i].FlatChangePercent) > math.Abs(changes[j].FlatChangePercent) + }) + + data := detailedHTMLData{ + Total: len(changes), + Regressions: regressions, + Improvements: improvements, + Stable: stable, + Changes: changes, + } + + tmpl := ` + + + + + Detailed Performance Report + + + +

📈 Detailed Performance Report

+ +
+

Total functions: {{.Total}}

+

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

+

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

+
+ + {{range .Changes}} +
+
{{report .}}
+
+ {{end}} + + +` + + sanitizer := bluemonday.StrictPolicy() + funcMap := template.FuncMap{ + "report": func(fc *tracker.FunctionChangeResult) template.HTML { + safe := sanitizer.Sanitize(fc.Report()) + return template.HTML(safe) //nolint:gosec // input is being sanatized + }, + } + + t, err := template.New("detailed").Funcs(funcMap).Parse(tmpl) + if err != nil { + return err + } + + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + return t.Execute(file, data) +} + +func chooseOutputFormat(report *tracker.ProfileChangeReport) { + switch outputFormat { + case "summary": + printSummary(report) + case "detailed": + printDetailedReport(report) + case "summary-html": + if err := generateHTMLSummary(report, "summary.html"); err != nil { + slog.Info("summary-html failed", "err", err) + } + case "detailed-html": + if err := generateDetailedHTMLReport(report, "detailed.html"); err != nil { + slog.Info("detailed-html failed", "err", err) + } + } +} diff --git a/engine/tracker/types.go b/engine/tracker/types.go index b93d7c1..d679af0 100644 --- a/engine/tracker/types.go +++ b/engine/tracker/types.go @@ -8,25 +8,6 @@ type ProfileChangeReport struct { FunctionChanges []*FunctionChangeResult } -type ProfileChangeSummary struct { - TotalFunctions int - Regressions int - Improvements int - Stable int - NewFunctions int - DeletedFunctions int - WorstRegression *FunctionChangeResult // Function with biggest regression - BestImprovement *FunctionChangeResult // Function with biggest improvement - AverageFlatChange float64 -} - -type ChangeMetadata struct { - Environment string - Version string - TestType string // "benchmark", "load-test", "production" - Tags map[string]string -} - type FunctionChangeResult struct { FunctionName string ChangeType string // shared.REGRESSION, shred.IMPROVEMENT, shared.STABLE diff --git a/go.mod b/go.mod index d4d179e..f6c38ae 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,10 @@ go 1.24.3 require github.com/spf13/cobra v1.9.1 require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/spf13/pflag v1.0.7 // indirect + golang.org/x/net v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index 4aae07f..6fb6ee1 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=