aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 15:38:08 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 15:38:08 +0200
commit2d8503cafb9f63ca4cb63f041281987f9a63f16a (patch)
treec90cb273473f265b1d0beb1004d9b0e4dfbeee03
parent1125e0b7086dd2b71e0558aaf641c23dea02f200 (diff)
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.
-rw-r--r--README.md43
-rw-r--r--cli/cli.go14
-rw-r--r--web/server.go51
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())
}