From 56ef7afe9d03ffafb232bce1639e534252d6b4a2 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 11 Oct 2025 12:35:21 -0500 Subject: [PATCH 1/2] add -silent-healthchecks flag to executables Silence request logs for health check routes using slog-http filters. - Parse and wire `-silent-healthchecks` to skip logs for `/api/health-checks` via `IgnorePathPrefix` in slog-http - Refactor `initServer` to accept unexported `initServerOpts` type to handle growing number of params (logger, pathPrefix, silentHealthChecks) signature. - Document flag in docs/health_checks.md and add guidance for embedded usage. Fixes #227. --- docs/health_checks.md | 22 ++++- internal/riveruicmd/auth_middleware_test.go | 6 +- internal/riveruicmd/riveruicmd.go | 40 ++++++-- internal/riveruicmd/riveruicmd_test.go | 100 +++++++++++++++++++- internal/uicommontest/uicommontest.go | 2 +- 5 files changed, 154 insertions(+), 16 deletions(-) diff --git a/docs/health_checks.md b/docs/health_checks.md index 739931f4..1c6044d0 100644 --- a/docs/health_checks.md +++ b/docs/health_checks.md @@ -52,4 +52,24 @@ When setting this command in ECS tasks for healtechecks it would something like } ] } -``` \ No newline at end of file +``` + +### Silencing request logs for health checks + +If you run the bundled `riverui` server and want to reduce log noise from frequent health probes, use the `-silent-healthchecks` flag. This will configure the HTTP logging middleware to skip logs for health endpoints under the configured prefix. + +```text +/bin/riverui -prefix=/my-prefix -silent-healthchecks +``` + +If you embed the UI in your own server, you can apply a similar filter to your logging middleware. For example with `slog-http`: + +```go +// assuming prefix has been normalized (e.g., "/my-prefix") +apiHealthPrefix := strings.TrimSuffix(prefix, "/") + "/api/health-checks" +logHandler := sloghttp.NewWithConfig(logger, sloghttp.Config{ + Filters: []sloghttp.Filter{sloghttp.IgnorePathPrefix(apiHealthPrefix)}, + WithSpanID: otelEnabled, + WithTraceID: otelEnabled, +}) +``` diff --git a/internal/riveruicmd/auth_middleware_test.go b/internal/riveruicmd/auth_middleware_test.go index 85a88a2a..a892db44 100644 --- a/internal/riveruicmd/auth_middleware_test.go +++ b/internal/riveruicmd/auth_middleware_test.go @@ -36,7 +36,11 @@ func TestAuthMiddleware(t *testing.T) { //nolint:tparallel setup := func(t *testing.T, prefix string) http.Handler { t.Helper() - initRes, err := initServer(ctx, riversharedtest.Logger(t), prefix, + initRes, err := initServer(ctx, + &initServerOpts{ + logger: riversharedtest.Logger(t), + pathPrefix: prefix, + }, func(dbPool *pgxpool.Pool) (*river.Client[pgx.Tx], error) { return river.NewClient(riverpgxv5.New(dbPool), &river.Config{}) }, diff --git a/internal/riveruicmd/riveruicmd.go b/internal/riveruicmd/riveruicmd.go index 5528d7e7..6be5bc57 100644 --- a/internal/riveruicmd/riveruicmd.go +++ b/internal/riveruicmd/riveruicmd.go @@ -41,6 +41,9 @@ func Run[TClient any](createClient func(*pgxpool.Pool) (TClient, error), createB var healthCheckName string flag.StringVar(&healthCheckName, "healthcheck", "", "the name of the health checks: minimal or complete") + var silentHealthChecks bool + flag.BoolVar(&silentHealthChecks, "silent-healthchecks", false, "silence request logs for health check routes") + flag.Parse() if healthCheckName != "" { @@ -51,7 +54,11 @@ func Run[TClient any](createClient func(*pgxpool.Pool) (TClient, error), createB os.Exit(0) } - initRes, err := initServer(ctx, logger, pathPrefix, createClient, createBundle) + initRes, err := initServer(ctx, &initServerOpts{ + logger: logger, + pathPrefix: pathPrefix, + silentHealthChecks: silentHealthChecks, + }, createClient, createBundle) if err != nil { logger.ErrorContext(ctx, "Error initializing server", slog.String("error", err.Error())) os.Exit(1) @@ -129,12 +136,21 @@ type initServerResult struct { uiHandler *riverui.Handler // River UI handler } -func initServer[TClient any](ctx context.Context, logger *slog.Logger, pathPrefix string, createClient func(*pgxpool.Pool) (TClient, error), createBundle func(TClient) uiendpoints.Bundle) (*initServerResult, error) { - if !strings.HasPrefix(pathPrefix, "/") || pathPrefix == "" { - return nil, fmt.Errorf("invalid path prefix: %s", pathPrefix) +type initServerOpts struct { + logger *slog.Logger + pathPrefix string + silentHealthChecks bool +} + +func initServer[TClient any](ctx context.Context, opts *initServerOpts, createClient func(*pgxpool.Pool) (TClient, error), createBundle func(TClient) uiendpoints.Bundle) (*initServerResult, error) { + if opts == nil { + return nil, errors.New("opts is required") + } + if !strings.HasPrefix(opts.pathPrefix, "/") || opts.pathPrefix == "" { + return nil, fmt.Errorf("invalid path prefix: %s", opts.pathPrefix) } - pathPrefix = riverui.NormalizePathPrefix(pathPrefix) + opts.pathPrefix = riverui.NormalizePathPrefix(opts.pathPrefix) var ( basicAuthUsername = os.Getenv("RIVER_BASIC_AUTH_USER") @@ -173,8 +189,8 @@ func initServer[TClient any](ctx context.Context, logger *slog.Logger, pathPrefi Endpoints: createBundle(client), JobListHideArgsByDefault: jobListHideArgsByDefault, LiveFS: liveFS, - Logger: logger, - Prefix: pathPrefix, + Logger: opts.logger, + Prefix: opts.pathPrefix, }) if err != nil { return nil, err @@ -184,7 +200,13 @@ func initServer[TClient any](ctx context.Context, logger *slog.Logger, pathPrefi AllowedMethods: []string{"GET", "HEAD", "POST", "PUT"}, AllowedOrigins: corsOrigins, }) - logHandler := sloghttp.NewWithConfig(logger, sloghttp.Config{ + filters := []sloghttp.Filter{} + if opts.silentHealthChecks { + apiHealthPrefix := strings.TrimSuffix(opts.pathPrefix, "/") + "/api/health-checks" + filters = append(filters, sloghttp.IgnorePathPrefix(apiHealthPrefix)) + } + logHandler := sloghttp.NewWithConfig(opts.logger, sloghttp.Config{ + Filters: filters, WithSpanID: otelEnabled, WithTraceID: otelEnabled, }) @@ -205,7 +227,7 @@ func initServer[TClient any](ctx context.Context, logger *slog.Logger, pathPrefi Handler: middlewareStack.Mount(uiHandler), ReadHeaderTimeout: 5 * time.Second, }, - logger: logger, + logger: opts.logger, uiHandler: uiHandler, }, nil } diff --git a/internal/riveruicmd/riveruicmd_test.go b/internal/riveruicmd/riveruicmd_test.go index cf12ee2f..c6b8f70b 100644 --- a/internal/riveruicmd/riveruicmd_test.go +++ b/internal/riveruicmd/riveruicmd_test.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -37,7 +38,10 @@ func TestInitServer(t *testing.T) { //nolint:tparallel setup := func(t *testing.T) (*initServerResult, *testBundle) { t.Helper() - initRes, err := initServer(ctx, riversharedtest.Logger(t), "/", + initRes, err := initServer(ctx, &initServerOpts{ + logger: riversharedtest.Logger(t), + pathPrefix: "/", + }, func(dbPool *pgxpool.Pool) (*river.Client[pgx.Tx], error) { return river.NewClient(riverpgxv5.New(dbPool), &river.Config{}) }, @@ -59,7 +63,7 @@ func TestInitServer(t *testing.T) { //nolint:tparallel require.NoError(t, err) }) - t.Run("WithPGEnvVars", func(t *testing.T) { //nolint:paralleltest + t.Run("WithPGEnvVars", func(t *testing.T) { // Cannot be parallelized because of Setenv calls. t.Setenv("DATABASE_URL", "") @@ -98,7 +102,7 @@ func TestInitServer(t *testing.T) { //nolint:tparallel require.False(t, resp.JobListHideArgsByDefault) }) - t.Run("SetToTrueWithTrue", func(t *testing.T) { //nolint:paralleltest + t.Run("SetToTrueWithTrue", func(t *testing.T) { // Cannot be parallelized because of Setenv calls. t.Setenv("RIVER_JOB_LIST_HIDE_ARGS_BY_DEFAULT", "true") initRes, _ := setup(t) @@ -115,7 +119,7 @@ func TestInitServer(t *testing.T) { //nolint:tparallel require.True(t, resp.JobListHideArgsByDefault) }) - t.Run("SetToTrueWith1", func(t *testing.T) { //nolint:paralleltest + t.Run("SetToTrueWith1", func(t *testing.T) { // Cannot be parallelized because of Setenv calls. t.Setenv("RIVER_JOB_LIST_HIDE_ARGS_BY_DEFAULT", "1") initRes, _ := setup(t) @@ -133,3 +137,91 @@ func TestInitServer(t *testing.T) { //nolint:tparallel }) }) } + +// inMemoryHandler is a simple slog.Handler that records all emitted records. +type inMemoryHandler struct { + records []slog.Record +} + +func (h *inMemoryHandler) Enabled(context.Context, slog.Level) bool { return true } + +func (h *inMemoryHandler) Handle(_ context.Context, r slog.Record) error { + // clone record to avoid later mutation issues + cloned := slog.Record{} + cloned.Level = r.Level + cloned.Time = r.Time + cloned.Message = r.Message + r.Attrs(func(a slog.Attr) bool { + cloned.AddAttrs(a) + return true + }) + h.records = append(h.records, cloned) + return nil +} + +func (h *inMemoryHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h } +func (h *inMemoryHandler) WithGroup(name string) slog.Handler { return h } + +func TestSilentHealthchecks_SuppressesLogs(t *testing.T) { + // Cannot be parallelized because of Setenv calls. + var ( + ctx = context.Background() + databaseURL = cmp.Or(os.Getenv("TEST_DATABASE_URL"), "postgres://localhost/river_test") + ) + + t.Setenv("DEV", "true") + t.Setenv("DATABASE_URL", databaseURL) + + memoryHandler := &inMemoryHandler{} + logger := slog.New(memoryHandler) + + makeServer := func(t *testing.T, prefix string, silent bool) *initServerResult { + t.Helper() + initRes, err := initServer(ctx, &initServerOpts{ + logger: logger, + pathPrefix: prefix, + silentHealthChecks: silent, + }, + func(dbPool *pgxpool.Pool) (*river.Client[pgx.Tx], error) { + return river.NewClient(riverpgxv5.New(dbPool), &river.Config{}) + }, + func(client *river.Client[pgx.Tx]) uiendpoints.Bundle { + return riverui.NewEndpoints(client, nil) + }, + ) + require.NoError(t, err) + t.Cleanup(initRes.dbPool.Close) + return initRes + } + + // silent=true should suppress health logs but not others + initRes := makeServer(t, "/", true) + + recorder := httptest.NewRecorder() + initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/health-checks/minimal", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, memoryHandler.records) + + recorder = httptest.NewRecorder() + initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/features", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.NotEmpty(t, memoryHandler.records) + + // reset and test with non-root prefix + memoryHandler.records = nil + initRes = makeServer(t, "/pfx", true) + + recorder = httptest.NewRecorder() + initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/pfx/api/health-checks/minimal", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, memoryHandler.records) + + // now silent=false should log health + memoryHandler.records = nil + initRes = makeServer(t, "/", false) + + recorder = httptest.NewRecorder() + initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/health-checks/minimal", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.NotEmpty(t, memoryHandler.records) +} diff --git a/internal/uicommontest/uicommontest.go b/internal/uicommontest/uicommontest.go index 0c195012..3e2c29f7 100644 --- a/internal/uicommontest/uicommontest.go +++ b/internal/uicommontest/uicommontest.go @@ -30,7 +30,7 @@ func MustMarshalJSON(t *testing.T, v any) []byte { return data } -// Requires that err is an equivalent API error to expectedErr. +// RequireAPIError requires that err is an equivalent API error to expectedErr. // // TError is a pointer to an API error type like *apierror.NotFound. func RequireAPIError[TError error](t *testing.T, expectedErr TError, err error) { From 7b0821a04f6e7295aa4e1ebd0b88ae49d84d2a3b Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 11 Oct 2025 12:44:24 -0500 Subject: [PATCH 2/2] use golangci-lint v2.5.0 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6b2376b0..4e74bbce 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -90,7 +90,7 @@ jobs: name: Go lint runs-on: ubuntu-latest env: - GOLANGCI_LINT_VERSION: v2.4.0 + GOLANGCI_LINT_VERSION: v2.5.0 GOPROXY: https://proxy.golang.org,https://u:${{ secrets.RIVERPRO_GO_MOD_CREDENTIAL }}@riverqueue.com/goproxy,direct permissions: contents: read