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.
This commit is contained in:
@@ -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 ./...
|
||||
@@ -0,0 +1,4 @@
|
||||
.idea
|
||||
.claude
|
||||
/release
|
||||
coverage.txt
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
+33
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user