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):
+
+ {{range .Regressions}}- {{summary .}}
{{end}}
+
+ {{end}}
+
+ {{if .Improvements}}
+ ✅ Top Improvements (best first):
+
+ {{range .Improvements}}- {{summary .}}
{{end}}
+
+ {{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}}
+
+ {{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=