diff --git a/internal/config/config.go b/internal/config/config.go index b454c19f..a0fc53a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,7 @@ type Config struct { APIKey string `yaml:"api_key" env:"API_KEY" desc:"API key for authenticating requests to the Outpost API." required:"Y"` APIJWTSecret string `yaml:"api_jwt_secret" env:"API_JWT_SECRET" desc:"Secret key for signing and verifying JWTs if JWT authentication is used for the API." required:"Y"` GinMode string `yaml:"gin_mode" env:"GIN_MODE" desc:"Sets the Gin framework mode (e.g., 'debug', 'release', 'test'). See Gin documentation for details." required:"N"` + PprofEnabled bool `yaml:"pprof_enabled" env:"PPROF_ENABLED" desc:"If true, exposes Go pprof profiling endpoints under /debug/pprof/ on the service's HTTP port (every service type). Unauthenticated; enable only when the port is not publicly reachable." required:"N" default:"false"` // Application DeploymentID string `yaml:"deployment_id" env:"DEPLOYMENT_ID" desc:"Optional deployment identifier for multi-tenancy. Enables multiple deployments to share the same infrastructure while maintaining data isolation." required:"N"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 35332ba2..b0d19676 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -62,6 +62,7 @@ func TestDefaultValues(t *testing.T) { // Test only fields that have explicit defaults assert.Equal(t, 3333, cfg.APIPort) + assert.False(t, cfg.PprofEnabled) assert.Equal(t, "127.0.0.1", cfg.Redis.Host) assert.Equal(t, 6379, cfg.Redis.Port) assert.Equal(t, "outpost", cfg.MQs.RabbitMQ.Exchange) @@ -110,6 +111,7 @@ retry_interval_seconds: 60 max_destinations_per_tenant: 50 delivery_timeout_seconds: 10 aes_encryption_secret: test-secret +pprof_enabled: true `), }, envVars: map[string]string{ @@ -133,6 +135,7 @@ aes_encryption_secret: test-secret assert.Equal(t, 5, cfg.LogMaxConcurrency) assert.Equal(t, 60, cfg.RetryIntervalSeconds) assert.Equal(t, 50, cfg.MaxDestinationsPerTenant) + assert.True(t, cfg.PprofEnabled) assert.Equal(t, 10, cfg.DeliveryTimeoutSeconds) assert.Equal(t, "test-secret", cfg.AESEncryptionSecret) // assert.Equal(t, "localhost:9000", cfg.ClickHouse.Addr) diff --git a/internal/services/builder.go b/internal/services/builder.go index 2a101c7c..bd906a96 100644 --- a/internal/services/builder.go +++ b/internal/services/builder.go @@ -78,7 +78,7 @@ func (b *ServiceBuilder) BuildWorkers() (*worker.WorkerSupervisor, error) { // Create base router with health check that all services will extend b.logger.Debug("creating base router with health check") - baseRouter := NewBaseRouter(b.supervisor, b.cfg.GinMode) + baseRouter := NewBaseRouter(b.supervisor, b.cfg.GinMode, b.cfg.PprofEnabled) if serviceType == config.ServiceTypeAPI || serviceType == config.ServiceTypeAll { if err := b.BuildAPIWorkers(baseRouter); err != nil { diff --git a/internal/services/health.go b/internal/services/health.go index 84daf751..3267e7fb 100644 --- a/internal/services/health.go +++ b/internal/services/health.go @@ -2,6 +2,7 @@ package services import ( "net/http" + "net/http/pprof" "github.com/gin-gonic/gin" "github.com/hookdeck/outpost/internal/worker" @@ -26,7 +27,7 @@ func HealthHandler(supervisor *worker.WorkerSupervisor) gin.HandlerFunc { // TODO: Rethink API versioning strategy in the future. // For now, we expose health check at both /healthz and /api/v1/healthz for backwards compatibility. // The /api/v1 prefix is hardcoded here but should be part of a broader versioning approach. -func NewBaseRouter(supervisor *worker.WorkerSupervisor, ginMode string) *gin.Engine { +func NewBaseRouter(supervisor *worker.WorkerSupervisor, ginMode string, pprofEnabled bool) *gin.Engine { gin.SetMode(ginMode) r := gin.New() r.Use(gin.Recovery()) @@ -35,5 +36,22 @@ func NewBaseRouter(supervisor *worker.WorkerSupervisor, ginMode string) *gin.Eng r.GET("/healthz", healthHandler) r.GET("/api/v1/healthz", healthHandler) + if pprofEnabled { + registerPprof(r) + } + return r } + +// registerPprof mounts net/http/pprof under /debug/pprof/. The handlers are +// unauthenticated, so this is opt-in via config. +func registerPprof(r *gin.Engine) { + r.GET("/debug/pprof/", gin.WrapF(pprof.Index)) + r.GET("/debug/pprof/cmdline", gin.WrapF(pprof.Cmdline)) + r.GET("/debug/pprof/profile", gin.WrapF(pprof.Profile)) + r.GET("/debug/pprof/symbol", gin.WrapF(pprof.Symbol)) + r.GET("/debug/pprof/trace", gin.WrapF(pprof.Trace)) + r.GET("/debug/pprof/:name", func(c *gin.Context) { + pprof.Handler(c.Param("name")).ServeHTTP(c.Writer, c.Request) + }) +} diff --git a/internal/services/health_test.go b/internal/services/health_test.go new file mode 100644 index 00000000..3d30bc59 --- /dev/null +++ b/internal/services/health_test.go @@ -0,0 +1,40 @@ +package services_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/hookdeck/outpost/internal/logging" + "github.com/hookdeck/outpost/internal/services" + "github.com/hookdeck/outpost/internal/worker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewBaseRouter_Pprof(t *testing.T) { + logger, err := logging.NewLogger(logging.WithLogLevel("error")) + require.NoError(t, err) + supervisor := worker.NewWorkerSupervisor(logger) + + get := func(r http.Handler, path string) int { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + return rec.Code + } + + t.Run("disabled by default", func(t *testing.T) { + r := services.NewBaseRouter(supervisor, "test", false) + assert.Equal(t, http.StatusNotFound, get(r, "/debug/pprof/")) + assert.Equal(t, http.StatusNotFound, get(r, "/debug/pprof/heap")) + assert.Equal(t, http.StatusOK, get(r, "/healthz")) + }) + + t.Run("enabled", func(t *testing.T) { + r := services.NewBaseRouter(supervisor, "test", true) + assert.Equal(t, http.StatusOK, get(r, "/debug/pprof/")) + assert.Equal(t, http.StatusOK, get(r, "/debug/pprof/heap")) + assert.Equal(t, http.StatusOK, get(r, "/debug/pprof/goroutine")) + assert.Equal(t, http.StatusOK, get(r, "/healthz")) + }) +}