From 2d8503cafb9f63ca4cb63f041281987f9a63f16a Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 23 Aug 2026 15:38:08 +0200 Subject: Add on-demand pprof profiling grouped by route Expose pprof at /debug/pprof via -pprof flag, tag each handler with runtime/pprof route label, add handleRoute helper to avoid repeating patterns, and document UI workflow in README. --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ cli/cli.go | 14 +++++++++----- web/server.go | 51 +++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d49e761..ea6809f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ mise run dev-watch | `-extract-osm` | Extract mountain passes from a France OSM PBF | `""` | | `-backfill` | Recompute and persist all ride computed values | `false` | | `-backtest` | Benchmark computed vs stored ride values | `false` | +| `-pprof` | Expose pprof endpoints at `/debug/pprof` | `false` | Configuration is stored in `config.yaml`. Start from `config.example.yaml` and set the Strava credentials before launching the server. @@ -106,6 +107,47 @@ mise run build TypeScript checks can also be run directly with `npm run check`, covering formatting, linting, compilation, type checking, and tests. +## Profiling + +Answer "what slows the server" with function-level CPU profiles grouped by route. + +```bash +# Start the server with pprof (exposes http://localhost:8080/debug/pprof/) +go run . -pprof +# or: mise run dev -- -pprof +``` + +```bash +# Capture 10s CPU sample and open interactive UI at http://localhost:8081 +go tool pprof -http=:8081 http://localhost:8080/debug/pprof/profile?seconds=10 +``` + +While it samples (10s), hit the routes you care about: + +```bash +for _ in {1..50}; do curl -s http://localhost:8080/rides/1 >/dev/null; curl -s http://localhost:8080/ >/dev/null; done +``` + +In the UI: `VIEW > Flame Graph`, `Top`, `Source` (annotates `ride/ride.go:90` etc.). Each request is tagged with `route` via `runtime/pprof` labels (`GET /`, `GET /rides/{id}`, `GET /analytics`, …), so filter by route: + +```bash +go tool pprof -http=:8081 -tagfocus='route:GET /rides/{id}' http://localhost:8080/debug/pprof/profile?seconds=10 +``` + +Other endpoints: + +```bash +go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap # allocs +``` + +Optional continuous profiling with Pyroscope (local docker, ~1-2% overhead): + +```bash +docker run --rm -p 4040:4040 grafana/pyroscope +``` + +Add `github.com/grafana/pyroscope-go` and call `pyroscope.Start` with `ServerAddress: "http://localhost:4040"` and `route` label — gives the same flamegraphs but over time. Keep it only if pprof snapshots are not enough. + ## TODO - Compute estimated power, speed @@ -128,6 +170,7 @@ TypeScript checks can also be run directly with `npm run check`, covering format - processing takes components as input, not entities - issue: many passes are pedestriant (cf Marseille Calanques, http://localhost:8080/rides/397) - analytics: color split (by year, type, etc) +- perf: improve GPX parsing and Analyze ## Resources diff --git a/cli/cli.go b/cli/cli.go index 68a86a6..bd54b35 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -21,10 +21,11 @@ import ( ) var ( - fetchOSM = flag.Bool("fetch-osm", false, "download the France OSM PBF (resumable) into france-latest.osm.pbf") - extractOSM = flag.String("extract-osm", "", "extract mountain passes from an OSM PBF file into the database") - backfill = flag.Bool("backfill", false, "recompute all stored ride values") - backtest = flag.Bool("backtest", false, "run data benchmarks comparing computed values against stored reference values") + fetchOSM = flag.Bool("fetch-osm", false, "download the France OSM PBF (resumable) into france-latest.osm.pbf") + extractOSM = flag.String("extract-osm", "", "extract mountain passes from an OSM PBF file into the database") + backfill = flag.Bool("backfill", false, "recompute all stored ride values") + backtest = flag.Bool("backtest", false, "run data benchmarks comparing computed values against stored reference values") + enablePprof = flag.Bool("pprof", false, "expose pprof endpoints at /debug/pprof") ) const ( @@ -52,7 +53,10 @@ func Run(db *sql.DB, configPath string, appConfig config.Config) { } func runServer(db *sql.DB, configPath string, appConfig config.Config) { - server := web.NewServer(db, configPath) + server := web.NewServer(db, configPath).WithPprof(*enablePprof) + if *enablePprof { + slog.Info("pprof enabled", "endpoint", appConfig.Server.PublicURL+"/debug/pprof/") + } slog.Info("Starting web server", "address", appConfig.Server.PublicURL) kcore.Expect(server.ListenAndServe(appConfig.Server.Address), "web server stopped") } diff --git a/web/server.go b/web/server.go index 198cb44..26dbb06 100644 --- a/web/server.go +++ b/web/server.go @@ -1,8 +1,11 @@ package web import ( + "context" "database/sql" "net/http" + http_pprof "net/http/pprof" + runtime_pprof "runtime/pprof" "sync" "time" @@ -21,6 +24,7 @@ type Server struct { returnToURL string syncMu sync.Mutex syncActive bool + enablePprof bool } type syncClient interface { @@ -32,20 +36,51 @@ func NewServer(db *sql.DB, configPath string) *Server { return &Server{db: db, configPath: configPath} } +func (s *Server) WithPprof(enabled bool) *Server { + s.enablePprof = enabled + return s +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.Handle("GET /static/", http.FileServer(http.FS(staticFiles))) - mux.HandleFunc("GET /", s.handleRides) - mux.HandleFunc("GET /rides/{id}", s.handleRide) - mux.HandleFunc("GET /analytics", s.handleAnalytics) - mux.HandleFunc("POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) - mux.HandleFunc("GET /sync", s.handleSyncForm) - mux.HandleFunc("POST /sync", s.handleSync) - mux.HandleFunc("GET /strava/login", s.handleStravaLogin) - mux.HandleFunc("GET /strava/callback", s.handleStravaCallback) + handleRoute(mux, "GET /", s.handleRides) + handleRoute(mux, "GET /rides/{id}", s.handleRide) + handleRoute(mux, "GET /analytics", s.handleAnalytics) + handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb) + handleRoute(mux, "GET /sync", s.handleSyncForm) + handleRoute(mux, "POST /sync", s.handleSync) + handleRoute(mux, "GET /strava/login", s.handleStravaLogin) + handleRoute(mux, "GET /strava/callback", s.handleStravaCallback) + if s.enablePprof { + registerPprof(mux) + } return kcore.RecoverMiddleware(mux) } +func handleRoute(mux *http.ServeMux, pattern string, handler http.HandlerFunc) { + mux.HandleFunc(pattern, withPprofLabel(pattern, handler)) +} + +func withPprofLabel(route string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + runtime_pprof.Do(r.Context(), runtime_pprof.Labels("route", route), func(ctx context.Context) { + next(w, r.WithContext(ctx)) + }) + } +} + +func registerPprof(mux *http.ServeMux) { + mux.HandleFunc("GET /debug/pprof/", http_pprof.Index) + mux.HandleFunc("GET /debug/pprof/cmdline", http_pprof.Cmdline) + mux.HandleFunc("GET /debug/pprof/profile", http_pprof.Profile) + mux.HandleFunc("GET /debug/pprof/symbol", http_pprof.Symbol) + mux.HandleFunc("GET /debug/pprof/trace", http_pprof.Trace) + for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} { + mux.Handle("GET /debug/pprof/"+name, http_pprof.Handler(name)) + } +} + func (s *Server) ListenAndServe(addr string) error { return http.ListenAndServe(addr, s.Handler()) } -- cgit v1.3