From 8ff83ae37c79c06b514c38abdc892ec640254a4e Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Mon, 15 Jun 2026 11:52:40 +0200 Subject: [PATCH] feat: initial shared logging module slog SetupLogger (text/json/otel), context logger helpers, MockLogger test helper, and a request-logger HTTP middleware sub-package. Replaces the logging package + middleware request-logger copied across the backend services. --- .gitea/workflows/ci.yaml | 35 +++++++++++++++++ .gitignore | 4 ++ README.md | 13 +++++++ go.mod | 22 +++++++++++ go.sum | 38 +++++++++++++++++++ log.go | 63 +++++++++++++++++++++++++++++++ log_test.go | 33 ++++++++++++++++ middleware/request_logger.go | 38 +++++++++++++++++++ middleware/request_logger_test.go | 26 +++++++++++++ mocklogger.go | 48 +++++++++++++++++++++++ 10 files changed, 320 insertions(+) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 log.go create mode 100644 log_test.go create mode 100644 middleware/request_logger.go create mode 100644 middleware/request_logger_test.go create mode 100644 mocklogger.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..7072b34 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,35 @@ +name: logging + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + if: gitea.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 'stable' + - name: Format check + run: | + go install mvdan.cc/gofumpt@latest + test -z "$(gofumpt -l .)" + - name: Run tests + run: go test -race ./... + vulnerabilities: + if: gitea.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 'stable' + - name: Check vulnerabilities + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31bb341 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +.claude +/release +coverage.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..61da759 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# logging + +Shared logging primitives for Shiny backend services. + +- `SetupLogger(level, format, service, version)` — configures the slog default + logger (text / json / otel) and returns it. +- `ContextWithLogger` / `LoggerFromContext` — carry a logger on the context. +- `NewMockLogger()` — test helper for asserting log output. +- `logging/middleware.RequestLogger(logger)` — HTTP middleware that debug-logs + request/response bodies. + +Replaces the `logging` package (and the `middleware` request-logger) copied +into the backend services. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b38db43 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module gitea.unbound.se/shiny/logging + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/log v0.20.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1723ff2 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/log.go b/log.go new file mode 100644 index 0000000..30cb503 --- /dev/null +++ b/log.go @@ -0,0 +1,63 @@ +package logging + +import ( + "context" + "log/slog" + "os" + + "go.opentelemetry.io/contrib/bridges/otelslog" +) + +type Logger interface { + Info(msg string, args ...any) + Warn(msg string, args ...any) + Error(msg string, args ...any) +} + +var defaultLogger *slog.Logger + +type contextKey string + +const loggerKey = contextKey("logger") + +func SetupLogger(logLevel, logFormat, serviceName, buildVersion string) *slog.Logger { + var leveler slog.LevelVar + + err := leveler.UnmarshalText([]byte(logLevel)) + handlerOpts := &slog.HandlerOptions{ + AddSource: false, + Level: leveler.Level(), + ReplaceAttr: nil, + } + var handler slog.Handler + switch logFormat { + case "json": + handler = slog.NewJSONHandler(os.Stdout, handlerOpts) + case "text": + handler = slog.NewTextHandler(os.Stdout, handlerOpts) + case "otel": + handler = otelslog.NewHandler(serviceName, + otelslog.WithVersion(buildVersion)) + } + defaultLogger = slog.New(handler).With("service", serviceName).With("version", buildVersion) + if err != nil { + defaultLogger.With("err", err).Error("Failed to parse log level") + os.Exit(1) + } + slog.SetDefault(defaultLogger) + return defaultLogger +} + +// ContextWithLogger returns a new Context with the logger attached +func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey, logger) +} + +// LoggerFromContext returns a logger from the passed context or the default logger +func LoggerFromContext(ctx context.Context) *slog.Logger { + logger := ctx.Value(loggerKey) + if l, ok := logger.(*slog.Logger); ok { + return l + } + return defaultLogger +} diff --git a/log_test.go b/log_test.go new file mode 100644 index 0000000..18b9d4e --- /dev/null +++ b/log_test.go @@ -0,0 +1,33 @@ +package logging + +import ( + "context" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetupLogger(t *testing.T) { + for _, format := range []string{"text", "json"} { + l := SetupLogger("info", format, "test-service", "v0.0.0") + assert.NotNil(t, l) + assert.Same(t, l, slog.Default()) + } +} + +func TestContextLogger(t *testing.T) { + base := SetupLogger("info", "text", "svc", "v1") + assert.Same(t, base, LoggerFromContext(context.Background())) + custom := slog.New(slog.NewTextHandler(nil, nil)) + ctx := ContextWithLogger(context.Background(), custom) + assert.Same(t, custom, LoggerFromContext(ctx)) +} + +func TestMockLogger(t *testing.T) { + m := NewMockLogger() + m.Logger().Info("hello", "k", "v") + m.Check(t, []string{`level=INFO msg=hello k=v`}) + empty := NewMockLogger() + empty.Check(t, nil) +} diff --git a/middleware/request_logger.go b/middleware/request_logger.go new file mode 100644 index 0000000..f9a2890 --- /dev/null +++ b/middleware/request_logger.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "bytes" + "io" + "log/slog" + "net/http" +) + +func RequestLogger(logger *slog.Logger) func(handler http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buff := &bytes.Buffer{} + req := r.Clone(r.Context()) + req.Body = io.NopCloser(io.TeeReader(r.Body, buff)) + + rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(rw, req) + logger.With("request", buff.String(), "response", rw.responseBody).Debug("http request") + }) + } +} + +type responseWriter struct { + http.ResponseWriter + statusCode int + responseBody string +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.statusCode = statusCode + rw.ResponseWriter.WriteHeader(statusCode) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + rw.responseBody = string(b) // You may want to capture only part of the response or use a different method + return rw.ResponseWriter.Write(b) +} diff --git a/middleware/request_logger_test.go b/middleware/request_logger_test.go new file mode 100644 index 0000000..4e4bf5d --- /dev/null +++ b/middleware/request_logger_test.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRequestLogger(t *testing.T) { + called := false + h := RequestLogger(slog.New(slog.NewTextHandler(nil, nil)))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("ok")) + })) + req := httptest.NewRequest(http.MethodPost, "/q", strings.NewReader("body")) + rw := httptest.NewRecorder() + h.ServeHTTP(rw, req) + assert.True(t, called) + assert.Equal(t, http.StatusCreated, rw.Code) + assert.Equal(t, "ok", rw.Body.String()) +} diff --git a/mocklogger.go b/mocklogger.go new file mode 100644 index 0000000..37bf6cc --- /dev/null +++ b/mocklogger.go @@ -0,0 +1,48 @@ +package logging + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func NewMockLogger() *MockLogger { + logged := &bytes.Buffer{} + + return &MockLogger{ + logged: logged, + logger: slog.New(slog.NewTextHandler(logged, &slog.HandlerOptions{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == "time" { + return slog.Attr{} + } + return a + }, + })), + } +} + +type MockLogger struct { + logger *slog.Logger + logged *bytes.Buffer +} + +func (m *MockLogger) Logger() *slog.Logger { + return m.logger +} + +func (m *MockLogger) Check(t testing.TB, wantLogged []string) { + var gotLogged []string + if m.logged.String() != "" { + gotLogged = strings.Split(m.logged.String(), "\n") + gotLogged = gotLogged[:len(gotLogged)-1] + } + if len(wantLogged) == 0 { + assert.Empty(t, gotLogged) + return + } + assert.Equal(t, wantLogged, gotLogged) +}