From b07356b29b0bb1570ccb721e028a00f4d89db807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 1 Apr 2026 23:21:29 +0100 Subject: [PATCH] feat(debug): enable pprof memory profiling in dev/debug mode (#518) --- apps/backend/Makefile | 14 +++-- apps/backend/cmd/kandev/helpers.go | 6 +++ apps/backend/cmd/kandev/main.go | 1 + apps/backend/internal/common/config/config.go | 12 +++++ apps/backend/internal/debug/pprof.go | 54 +++++++++++++++++++ apps/cli/src/dev.ts | 1 + apps/cli/src/run.ts | 2 +- apps/cli/src/start.ts | 2 +- 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 apps/backend/internal/debug/pprof.go diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 252c3f9de8..c09bd391e5 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -25,7 +25,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-agentctl-linux build-mock-agent clean run dev test test-e2e test-sprites-e2e lint fmt vet help +.PHONY: all build build-all build-agentctl build-agentctl-linux build-mock-agent clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help ## Default target all: build @@ -71,10 +71,15 @@ run: build @echo "Running $(BINARY_NAME)..." ./$(BUILD_DIR)/$(BINARY_NAME) -## Run in development mode +## Run in development mode (with pprof enabled) dev: build @echo "Running $(BINARY_NAME) in development mode..." - @exec ./$(BUILD_DIR)/$(BINARY_NAME) + @KANDEV_DEBUG_PPROF_ENABLED=true exec ./$(BUILD_DIR)/$(BINARY_NAME) + +## Run in debug mode (pprof + debug logging) +start-debug: build + @echo "Running $(BINARY_NAME) in debug mode (pprof + debug logging)..." + @KANDEV_DEBUG_PPROF_ENABLED=true KANDEV_LOG_LEVEL=debug exec ./$(BUILD_DIR)/$(BINARY_NAME) ## Run tests test: @@ -149,7 +154,8 @@ help: @echo " build-mock-agent Build the mock-agent binary for testing" @echo " clean Remove build artifacts" @echo " run Build and run the application" - @echo " dev Run in development mode with a local database" + @echo " dev Run in development mode (pprof enabled)" + @echo " start-debug Run in debug mode (pprof + debug logging)" @echo " test Run tests" @echo " test-e2e Run E2E adapter tests (real agents, costs money)" @echo " test-coverage Run tests with coverage report" diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 6a0daa0bf7..8298568f0e 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -431,6 +431,7 @@ type routeParams struct { secretStore secrets.SecretStore mcpConfigSvc *mcpconfig.Service webInternalURL string + pprofEnabled bool log *logger.Logger } @@ -590,6 +591,11 @@ func registerMCPAndDebugRoutes( debughandlers.RegisterRoutes(p.router, p.log) p.log.Debug("Registered Debug handlers (HTTP)") + + if p.pprofEnabled { + debughandlers.RegisterPprofRoutes(p.router, p.log) + debughandlers.RegisterMemoryRoute(p.router, p.log) + } } // runGracefulShutdown gracefully stops all services and runs cleanups. diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 561ff5faeb..f44279b731 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -446,6 +446,7 @@ func buildHTTPServer( secretStore: repos.Secrets, mcpConfigSvc: mcpconfig.NewService(repos.AgentSettings), webInternalURL: cfg.Server.WebInternalURL, + pprofEnabled: cfg.Debug.PprofEnabled, log: log, }) diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 03ebd0caf7..fbcb1d61d2 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -27,6 +27,7 @@ type Config struct { RepositoryDiscovery RepositoryDiscoveryConfig `mapstructure:"repositoryDiscovery"` Worktree WorktreeConfig `mapstructure:"worktree"` RepoClone RepoCloneConfig `mapstructure:"repoClone"` + Debug DebugConfig `mapstructure:"debug"` } // ResolvedHomeDir returns the Kandev home directory (~/.kandev for local dev). @@ -153,6 +154,13 @@ type RepoCloneConfig struct { BasePath string `mapstructure:"basePath"` // Base directory for cloned repos (default: ~/.kandev/repos) } +// DebugConfig holds debug/profiling configuration. +type DebugConfig struct { + // PprofEnabled enables pprof endpoints at /debug/pprof/ and /api/v1/debug/memory. + // Controlled via KANDEV_DEBUG_PPROF_ENABLED env var. Default: false. + PprofEnabled bool `mapstructure:"pprofEnabled"` +} + // AgentConfig holds agent runtime configuration. // Note: Runtime selection is now per-task based on executor type, not global. // The Standalone runtime (agentctl) always runs as a core service. @@ -282,6 +290,9 @@ func setDefaults(v *viper.Viper) { // RepoClone defaults v.SetDefault("repoClone.basePath", "") + + // Debug defaults + v.SetDefault("debug.pprofEnabled", false) } // DefaultDockerHost returns the platform-appropriate Docker socket path. @@ -338,6 +349,7 @@ func LoadWithPath(configPath string) (*Config, error) { _ = v.BindEnv("dataDir", "KANDEV_DATA_DIR") _ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL") _ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE") + _ = v.BindEnv("debug.pprofEnabled", "KANDEV_DEBUG_PPROF_ENABLED") // Configure config file v.SetConfigName("config") diff --git a/apps/backend/internal/debug/pprof.go b/apps/backend/internal/debug/pprof.go new file mode 100644 index 0000000000..abc44200c1 --- /dev/null +++ b/apps/backend/internal/debug/pprof.go @@ -0,0 +1,54 @@ +package debug + +import ( + "net/http" + "net/http/pprof" + "runtime" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" +) + +// RegisterPprofRoutes mounts the standard net/http/pprof handlers on the Gin router +// at /debug/pprof/. These endpoints are only registered when debug.pprofEnabled is true. +func RegisterPprofRoutes(router *gin.Engine, log *logger.Logger) { + g := router.Group("/debug/pprof") + g.GET("/", gin.WrapF(pprof.Index)) + g.GET("/cmdline", gin.WrapF(pprof.Cmdline)) + g.GET("/profile", gin.WrapF(pprof.Profile)) + g.GET("/symbol", gin.WrapF(pprof.Symbol)) + g.POST("/symbol", gin.WrapF(pprof.Symbol)) + g.GET("/trace", gin.WrapF(pprof.Trace)) + + for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} { + g.GET("/"+name, gin.WrapH(pprof.Handler(name))) + } + + log.Info("pprof endpoints registered at /debug/pprof/") +} + +// RegisterMemoryRoute registers GET /api/v1/debug/memory which returns a JSON summary +// of runtime memory statistics. Useful for quick health checks without pulling a full +// pprof heap profile. +func RegisterMemoryRoute(router *gin.Engine, log *logger.Logger) { + router.GET("/api/v1/debug/memory", handleMemoryStats()) + log.Info("Memory stats endpoint registered at /api/v1/debug/memory") +} + +func handleMemoryStats() gin.HandlerFunc { + return func(c *gin.Context) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + c.JSON(http.StatusOK, gin.H{ + "heap_alloc_mb": float64(m.HeapAlloc) / (1024 * 1024), + "heap_inuse_mb": float64(m.HeapInuse) / (1024 * 1024), + "heap_sys_mb": float64(m.HeapSys) / (1024 * 1024), + "heap_objects": m.HeapObjects, + "goroutines": runtime.NumGoroutine(), + "num_gc": m.NumGC, + "sys_mb": float64(m.Sys) / (1024 * 1024), + "stack_inuse_mb": float64(m.StackInuse) / (1024 * 1024), + }) + } +} diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index 3f3e1c7f16..3657502768 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -26,6 +26,7 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr const extra: Record = { KANDEV_DATABASE_PATH: dbPath, KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true", + KANDEV_DEBUG_PPROF_ENABLED: "true", }; const backendEnv = buildBackendEnv({ ports, extra }); const webEnv = buildWebEnv({ ports, debug: true }); diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 266576917a..ae3dca8897 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -187,7 +187,7 @@ async function prepareReleaseBundle({ KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort), KANDEV_DATABASE_PATH: dbPath, KANDEV_LOG_LEVEL: logLevel, - ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}), + ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}), }; const webEnv: NodeJS.ProcessEnv = { diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index e4b12b752c..bc1d4e82c6 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -118,7 +118,7 @@ export async function runStart({ logLevel, extra: { KANDEV_DATABASE_PATH: dbPath, - ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}), + ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}), }, }); const webEnv = buildWebEnv({ ports, production: true, debug });