Skip to content

Commit 3c6fce9

Browse files
authored
Add -silent-healthchecks flag (#444)
* 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 `<prefix>/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. * use golangci-lint v2.5.0
1 parent 901fc90 commit 3c6fce9

File tree

6 files changed

+155
-17
lines changed

6 files changed

+155
-17
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ jobs:
9090
name: Go lint
9191
runs-on: ubuntu-latest
9292
env:
93-
GOLANGCI_LINT_VERSION: v2.4.0
93+
GOLANGCI_LINT_VERSION: v2.5.0
9494
GOPROXY: https://proxy.golang.org,https://u:${{ secrets.RIVERPRO_GO_MOD_CREDENTIAL }}@riverqueue.com/goproxy,direct
9595
permissions:
9696
contents: read

docs/health_checks.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,24 @@ When setting this command in ECS tasks for healtechecks it would something like
5252
}
5353
]
5454
}
55-
```
55+
```
56+
57+
### Silencing request logs for health checks
58+
59+
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.
60+
61+
```text
62+
/bin/riverui -prefix=/my-prefix -silent-healthchecks
63+
```
64+
65+
If you embed the UI in your own server, you can apply a similar filter to your logging middleware. For example with `slog-http`:
66+
67+
```go
68+
// assuming prefix has been normalized (e.g., "/my-prefix")
69+
apiHealthPrefix := strings.TrimSuffix(prefix, "/") + "/api/health-checks"
70+
logHandler := sloghttp.NewWithConfig(logger, sloghttp.Config{
71+
Filters: []sloghttp.Filter{sloghttp.IgnorePathPrefix(apiHealthPrefix)},
72+
WithSpanID: otelEnabled,
73+
WithTraceID: otelEnabled,
74+
})
75+
```

internal/riveruicmd/auth_middleware_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ func TestAuthMiddleware(t *testing.T) { //nolint:tparallel
3636

3737
setup := func(t *testing.T, prefix string) http.Handler {
3838
t.Helper()
39-
initRes, err := initServer(ctx, riversharedtest.Logger(t), prefix,
39+
initRes, err := initServer(ctx,
40+
&initServerOpts{
41+
logger: riversharedtest.Logger(t),
42+
pathPrefix: prefix,
43+
},
4044
func(dbPool *pgxpool.Pool) (*river.Client[pgx.Tx], error) {
4145
return river.NewClient(riverpgxv5.New(dbPool), &river.Config{})
4246
},

internal/riveruicmd/riveruicmd.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ func Run[TClient any](createClient func(*pgxpool.Pool) (TClient, error), createB
4141
var healthCheckName string
4242
flag.StringVar(&healthCheckName, "healthcheck", "", "the name of the health checks: minimal or complete")
4343

44+
var silentHealthChecks bool
45+
flag.BoolVar(&silentHealthChecks, "silent-healthchecks", false, "silence request logs for health check routes")
46+
4447
flag.Parse()
4548

4649
if healthCheckName != "" {
@@ -51,7 +54,11 @@ func Run[TClient any](createClient func(*pgxpool.Pool) (TClient, error), createB
5154
os.Exit(0)
5255
}
5356

54-
initRes, err := initServer(ctx, logger, pathPrefix, createClient, createBundle)
57+
initRes, err := initServer(ctx, &initServerOpts{
58+
logger: logger,
59+
pathPrefix: pathPrefix,
60+
silentHealthChecks: silentHealthChecks,
61+
}, createClient, createBundle)
5562
if err != nil {
5663
logger.ErrorContext(ctx, "Error initializing server", slog.String("error", err.Error()))
5764
os.Exit(1)
@@ -129,12 +136,21 @@ type initServerResult struct {
129136
uiHandler *riverui.Handler // River UI handler
130137
}
131138

132-
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) {
133-
if !strings.HasPrefix(pathPrefix, "/") || pathPrefix == "" {
134-
return nil, fmt.Errorf("invalid path prefix: %s", pathPrefix)
139+
type initServerOpts struct {
140+
logger *slog.Logger
141+
pathPrefix string
142+
silentHealthChecks bool
143+
}
144+
145+
func initServer[TClient any](ctx context.Context, opts *initServerOpts, createClient func(*pgxpool.Pool) (TClient, error), createBundle func(TClient) uiendpoints.Bundle) (*initServerResult, error) {
146+
if opts == nil {
147+
return nil, errors.New("opts is required")
148+
}
149+
if !strings.HasPrefix(opts.pathPrefix, "/") || opts.pathPrefix == "" {
150+
return nil, fmt.Errorf("invalid path prefix: %s", opts.pathPrefix)
135151
}
136152

137-
pathPrefix = riverui.NormalizePathPrefix(pathPrefix)
153+
opts.pathPrefix = riverui.NormalizePathPrefix(opts.pathPrefix)
138154

139155
var (
140156
basicAuthUsername = os.Getenv("RIVER_BASIC_AUTH_USER")
@@ -173,8 +189,8 @@ func initServer[TClient any](ctx context.Context, logger *slog.Logger, pathPrefi
173189
Endpoints: createBundle(client),
174190
JobListHideArgsByDefault: jobListHideArgsByDefault,
175191
LiveFS: liveFS,
176-
Logger: logger,
177-
Prefix: pathPrefix,
192+
Logger: opts.logger,
193+
Prefix: opts.pathPrefix,
178194
})
179195
if err != nil {
180196
return nil, err
@@ -184,7 +200,13 @@ func initServer[TClient any](ctx context.Context, logger *slog.Logger, pathPrefi
184200
AllowedMethods: []string{"GET", "HEAD", "POST", "PUT"},
185201
AllowedOrigins: corsOrigins,
186202
})
187-
logHandler := sloghttp.NewWithConfig(logger, sloghttp.Config{
203+
filters := []sloghttp.Filter{}
204+
if opts.silentHealthChecks {
205+
apiHealthPrefix := strings.TrimSuffix(opts.pathPrefix, "/") + "/api/health-checks"
206+
filters = append(filters, sloghttp.IgnorePathPrefix(apiHealthPrefix))
207+
}
208+
logHandler := sloghttp.NewWithConfig(opts.logger, sloghttp.Config{
209+
Filters: filters,
188210
WithSpanID: otelEnabled,
189211
WithTraceID: otelEnabled,
190212
})
@@ -205,7 +227,7 @@ func initServer[TClient any](ctx context.Context, logger *slog.Logger, pathPrefi
205227
Handler: middlewareStack.Mount(uiHandler),
206228
ReadHeaderTimeout: 5 * time.Second,
207229
},
208-
logger: logger,
230+
logger: opts.logger,
209231
uiHandler: uiHandler,
210232
}, nil
211233
}

internal/riveruicmd/riveruicmd_test.go

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"cmp"
55
"context"
66
"encoding/json"
7+
"log/slog"
78
"net/http"
89
"net/http/httptest"
910
"net/url"
@@ -37,7 +38,10 @@ func TestInitServer(t *testing.T) { //nolint:tparallel
3738
setup := func(t *testing.T) (*initServerResult, *testBundle) {
3839
t.Helper()
3940

40-
initRes, err := initServer(ctx, riversharedtest.Logger(t), "/",
41+
initRes, err := initServer(ctx, &initServerOpts{
42+
logger: riversharedtest.Logger(t),
43+
pathPrefix: "/",
44+
},
4145
func(dbPool *pgxpool.Pool) (*river.Client[pgx.Tx], error) {
4246
return river.NewClient(riverpgxv5.New(dbPool), &river.Config{})
4347
},
@@ -59,7 +63,7 @@ func TestInitServer(t *testing.T) { //nolint:tparallel
5963
require.NoError(t, err)
6064
})
6165

62-
t.Run("WithPGEnvVars", func(t *testing.T) { //nolint:paralleltest
66+
t.Run("WithPGEnvVars", func(t *testing.T) {
6367
// Cannot be parallelized because of Setenv calls.
6468
t.Setenv("DATABASE_URL", "")
6569

@@ -98,7 +102,7 @@ func TestInitServer(t *testing.T) { //nolint:tparallel
98102
require.False(t, resp.JobListHideArgsByDefault)
99103
})
100104

101-
t.Run("SetToTrueWithTrue", func(t *testing.T) { //nolint:paralleltest
105+
t.Run("SetToTrueWithTrue", func(t *testing.T) {
102106
// Cannot be parallelized because of Setenv calls.
103107
t.Setenv("RIVER_JOB_LIST_HIDE_ARGS_BY_DEFAULT", "true")
104108
initRes, _ := setup(t)
@@ -115,7 +119,7 @@ func TestInitServer(t *testing.T) { //nolint:tparallel
115119
require.True(t, resp.JobListHideArgsByDefault)
116120
})
117121

118-
t.Run("SetToTrueWith1", func(t *testing.T) { //nolint:paralleltest
122+
t.Run("SetToTrueWith1", func(t *testing.T) {
119123
// Cannot be parallelized because of Setenv calls.
120124
t.Setenv("RIVER_JOB_LIST_HIDE_ARGS_BY_DEFAULT", "1")
121125
initRes, _ := setup(t)
@@ -133,3 +137,91 @@ func TestInitServer(t *testing.T) { //nolint:tparallel
133137
})
134138
})
135139
}
140+
141+
// inMemoryHandler is a simple slog.Handler that records all emitted records.
142+
type inMemoryHandler struct {
143+
records []slog.Record
144+
}
145+
146+
func (h *inMemoryHandler) Enabled(context.Context, slog.Level) bool { return true }
147+
148+
func (h *inMemoryHandler) Handle(_ context.Context, r slog.Record) error {
149+
// clone record to avoid later mutation issues
150+
cloned := slog.Record{}
151+
cloned.Level = r.Level
152+
cloned.Time = r.Time
153+
cloned.Message = r.Message
154+
r.Attrs(func(a slog.Attr) bool {
155+
cloned.AddAttrs(a)
156+
return true
157+
})
158+
h.records = append(h.records, cloned)
159+
return nil
160+
}
161+
162+
func (h *inMemoryHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h }
163+
func (h *inMemoryHandler) WithGroup(name string) slog.Handler { return h }
164+
165+
func TestSilentHealthchecks_SuppressesLogs(t *testing.T) {
166+
// Cannot be parallelized because of Setenv calls.
167+
var (
168+
ctx = context.Background()
169+
databaseURL = cmp.Or(os.Getenv("TEST_DATABASE_URL"), "postgres://localhost/river_test")
170+
)
171+
172+
t.Setenv("DEV", "true")
173+
t.Setenv("DATABASE_URL", databaseURL)
174+
175+
memoryHandler := &inMemoryHandler{}
176+
logger := slog.New(memoryHandler)
177+
178+
makeServer := func(t *testing.T, prefix string, silent bool) *initServerResult {
179+
t.Helper()
180+
initRes, err := initServer(ctx, &initServerOpts{
181+
logger: logger,
182+
pathPrefix: prefix,
183+
silentHealthChecks: silent,
184+
},
185+
func(dbPool *pgxpool.Pool) (*river.Client[pgx.Tx], error) {
186+
return river.NewClient(riverpgxv5.New(dbPool), &river.Config{})
187+
},
188+
func(client *river.Client[pgx.Tx]) uiendpoints.Bundle {
189+
return riverui.NewEndpoints(client, nil)
190+
},
191+
)
192+
require.NoError(t, err)
193+
t.Cleanup(initRes.dbPool.Close)
194+
return initRes
195+
}
196+
197+
// silent=true should suppress health logs but not others
198+
initRes := makeServer(t, "/", true)
199+
200+
recorder := httptest.NewRecorder()
201+
initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/health-checks/minimal", nil))
202+
require.Equal(t, http.StatusOK, recorder.Code)
203+
require.Empty(t, memoryHandler.records)
204+
205+
recorder = httptest.NewRecorder()
206+
initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/features", nil))
207+
require.Equal(t, http.StatusOK, recorder.Code)
208+
require.NotEmpty(t, memoryHandler.records)
209+
210+
// reset and test with non-root prefix
211+
memoryHandler.records = nil
212+
initRes = makeServer(t, "/pfx", true)
213+
214+
recorder = httptest.NewRecorder()
215+
initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/pfx/api/health-checks/minimal", nil))
216+
require.Equal(t, http.StatusOK, recorder.Code)
217+
require.Empty(t, memoryHandler.records)
218+
219+
// now silent=false should log health
220+
memoryHandler.records = nil
221+
initRes = makeServer(t, "/", false)
222+
223+
recorder = httptest.NewRecorder()
224+
initRes.httpServer.Handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/health-checks/minimal", nil))
225+
require.Equal(t, http.StatusOK, recorder.Code)
226+
require.NotEmpty(t, memoryHandler.records)
227+
}

internal/uicommontest/uicommontest.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func MustMarshalJSON(t *testing.T, v any) []byte {
3030
return data
3131
}
3232

33-
// Requires that err is an equivalent API error to expectedErr.
33+
// RequireAPIError requires that err is an equivalent API error to expectedErr.
3434
//
3535
// TError is a pointer to an API error type like *apierror.NotFound.
3636
func RequireAPIError[TError error](t *testing.T, expectedErr TError, err error) {

0 commit comments

Comments
 (0)