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