Skip to content

Commit b0b5ced

Browse files
authored
feat: added slog logger middleware (#54)
1 parent 08fb53f commit b0b5ced

File tree

2 files changed

+318
-0
lines changed

2 files changed

+318
-0
lines changed

middlewares/logger.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//go:build go1.21
2+
3+
package middlewares
4+
5+
import (
6+
"context"
7+
"io"
8+
"log/slog"
9+
"os"
10+
"time"
11+
12+
"github.com/mojixcoder/kid"
13+
)
14+
15+
type (
16+
// LoggerConfig is the config used to build logger middleware.
17+
LoggerConfig struct {
18+
// Logger is the logger instance.
19+
// Optional. If set, Out, Level and Type configs won't be used.
20+
Logger *slog.Logger
21+
22+
// Out is the writer that logs will be written at.
23+
// Defaults to os.Stdout.
24+
Out io.Writer
25+
26+
// Level is the log level used for initializing a logger instance.
27+
// Defaults to slog.LevelInfo.
28+
Level slog.Leveler
29+
30+
// SuccessLevel is the log level when status code < 400.
31+
// Defaults to slog.LevelInfo.
32+
SuccessLevel slog.Leveler
33+
34+
// ClientErrorLevel is the log level when status code is between 400 and 499.
35+
// Defaults to slog.LevelWarn.
36+
ClientErrorLevel slog.Leveler
37+
38+
// ServerErrorLevel is the log level when status code >= 500.
39+
// Defaults to slog.LevelError.
40+
ServerErrorLevel slog.Leveler
41+
42+
// Type is the logger type.
43+
// Defaults to JSON.
44+
Type LoggerType
45+
46+
// Skipper is a function used for skipping middleware execution.
47+
// Defaults to nil.
48+
Skipper func(c *kid.Context) bool
49+
}
50+
51+
// LoggerType is the type for specifying logger type.
52+
LoggerType string
53+
)
54+
55+
const (
56+
// JSONLogger is the JSON logger type.
57+
TypeJSON LoggerType = "JSON"
58+
59+
// TextLogger is the text logger type.
60+
TypeText LoggerType = "TEXT"
61+
)
62+
63+
// DefaultLoggerConfig is the default logger config.
64+
var DefaultLoggerConfig = LoggerConfig{
65+
Out: os.Stdout,
66+
Level: slog.LevelInfo,
67+
SuccessLevel: slog.LevelInfo,
68+
ClientErrorLevel: slog.LevelWarn,
69+
ServerErrorLevel: slog.LevelError,
70+
Type: TypeJSON,
71+
}
72+
73+
// NewLogger returns a new logger middleware.
74+
func NewLogger() kid.MiddlewareFunc {
75+
return NewLoggerWithConfig(DefaultLoggerConfig)
76+
}
77+
78+
// NewLoggerWithConfig returns a new logger middleware with the given config.
79+
func NewLoggerWithConfig(cfg LoggerConfig) kid.MiddlewareFunc {
80+
setLoggerDefaults(&cfg)
81+
82+
logger := cfg.getLogger()
83+
84+
successLvl := cfg.SuccessLevel.Level()
85+
clientErrLvl := cfg.ClientErrorLevel.Level()
86+
serverErrLvl := cfg.ServerErrorLevel.Level()
87+
88+
return func(next kid.HandlerFunc) kid.HandlerFunc {
89+
return func(c *kid.Context) {
90+
// Skip if necessary.
91+
if cfg.Skipper != nil && cfg.Skipper(c) {
92+
next(c)
93+
return
94+
}
95+
96+
start := time.Now()
97+
98+
next(c)
99+
100+
end := time.Now()
101+
req := c.Request()
102+
duration := end.Sub(start)
103+
104+
status := c.Response().Status()
105+
106+
attrs := []slog.Attr{
107+
slog.Time("time", end),
108+
slog.Duration("latency_ns", duration),
109+
slog.String("latency", duration.String()),
110+
slog.Int("status", status),
111+
slog.String("path", req.URL.Path),
112+
slog.String("method", req.Method),
113+
slog.String("user_agent", req.Header.Get("User-Agent")),
114+
}
115+
116+
if status < 400 {
117+
logger.LogAttrs(context.Background(), successLvl, "SUCCESS", attrs...)
118+
} else if status <= 499 {
119+
logger.LogAttrs(context.Background(), clientErrLvl, "CLIENT ERROR", attrs...)
120+
} else { // 5xx status codes.
121+
logger.LogAttrs(context.Background(), serverErrLvl, "SERVER ERROR", attrs...)
122+
}
123+
}
124+
}
125+
}
126+
127+
// getLogger returns the appropriate logger instance.
128+
func (cfg LoggerConfig) getLogger() *slog.Logger {
129+
if cfg.Logger != nil {
130+
return cfg.Logger
131+
}
132+
133+
switch cfg.Type {
134+
case TypeJSON:
135+
return slog.New(slog.NewJSONHandler(cfg.Out, &slog.HandlerOptions{Level: cfg.Level}))
136+
case TypeText:
137+
return slog.New(slog.NewTextHandler(cfg.Out, &slog.HandlerOptions{Level: cfg.Level}))
138+
default:
139+
panic("invalid logger type")
140+
}
141+
}
142+
143+
// setLoggerDefaults sets logger default values.
144+
func setLoggerDefaults(cfg *LoggerConfig) {
145+
if cfg.Out == nil {
146+
cfg.Out = DefaultLoggerConfig.Out
147+
}
148+
149+
if cfg.Level == nil {
150+
cfg.Level = DefaultLoggerConfig.Level
151+
}
152+
153+
if cfg.SuccessLevel == nil {
154+
cfg.SuccessLevel = DefaultLoggerConfig.SuccessLevel
155+
}
156+
157+
if cfg.ClientErrorLevel == nil {
158+
cfg.ClientErrorLevel = DefaultLoggerConfig.ClientErrorLevel
159+
}
160+
161+
if cfg.ServerErrorLevel == nil {
162+
cfg.ServerErrorLevel = DefaultLoggerConfig.ServerErrorLevel
163+
}
164+
165+
if cfg.Type == "" {
166+
cfg.Type = DefaultLoggerConfig.Type
167+
}
168+
}

middlewares/logger_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//go:build go1.21
2+
3+
package middlewares
4+
5+
import (
6+
"bytes"
7+
"encoding/json"
8+
"io"
9+
"log/slog"
10+
"net/http"
11+
"net/http/httptest"
12+
"testing"
13+
"time"
14+
15+
"github.com/mojixcoder/kid"
16+
"github.com/stretchr/testify/assert"
17+
)
18+
19+
type logRecord struct {
20+
Msg string `json:"msg"`
21+
Time time.Time `json:"time"`
22+
LatenyNS int64 `json:"latency_ns"`
23+
Latency string `json:"latency"`
24+
Status int `json:"status"`
25+
Path string `json:"path"`
26+
Method string `json:"method"`
27+
UserAgent string `json:"user_agent"`
28+
}
29+
30+
func TestNewLogger(t *testing.T) {
31+
middleware := NewLogger()
32+
33+
assert.NotNil(t, middleware)
34+
}
35+
36+
func TestSetLoggerDefaults(t *testing.T) {
37+
var cfg LoggerConfig
38+
39+
setLoggerDefaults(&cfg)
40+
41+
assert.Equal(t, DefaultLoggerConfig.Out, cfg.Out)
42+
assert.Equal(t, DefaultLoggerConfig.Logger, cfg.Logger)
43+
assert.Equal(t, DefaultLoggerConfig.Level, cfg.Level)
44+
assert.Equal(t, DefaultLoggerConfig.ServerErrorLevel, cfg.ServerErrorLevel)
45+
assert.Equal(t, DefaultLoggerConfig.ClientErrorLevel, cfg.ClientErrorLevel)
46+
assert.Equal(t, DefaultLoggerConfig.SuccessLevel, cfg.SuccessLevel)
47+
assert.Equal(t, DefaultLoggerConfig.Type, cfg.Type)
48+
}
49+
50+
func TestLoggerConfig_getLogger(t *testing.T) {
51+
var cfg LoggerConfig
52+
setLoggerDefaults(&cfg)
53+
54+
logger := cfg.getLogger()
55+
assert.IsType(t, &slog.JSONHandler{}, logger.Handler())
56+
57+
cfg.Type = TypeText
58+
59+
logger = cfg.getLogger()
60+
assert.IsType(t, &slog.TextHandler{}, logger.Handler())
61+
62+
cfg.Logger = slog.New(slog.NewJSONHandler(io.Discard, nil))
63+
assert.Equal(t, cfg.Logger, cfg.getLogger())
64+
65+
assert.PanicsWithValue(t, "invalid logger type", func() {
66+
cfg.Logger = nil
67+
cfg.Type = ""
68+
cfg.getLogger()
69+
})
70+
}
71+
72+
func TestNewLoggerWithConfig(t *testing.T) {
73+
var buf bytes.Buffer
74+
75+
cfg := DefaultLoggerConfig
76+
cfg.Out = &buf
77+
78+
k := kid.New()
79+
k.Use(NewLoggerWithConfig(cfg))
80+
81+
k.Get("/", func(c *kid.Context) {
82+
time.Sleep(time.Millisecond)
83+
c.String(http.StatusOK, "Ok")
84+
})
85+
86+
k.Get("/server-error", func(c *kid.Context) {
87+
time.Sleep(time.Millisecond)
88+
c.String(http.StatusInternalServerError, "Internal Server Error")
89+
})
90+
91+
k.Get("/not-found", func(c *kid.Context) {
92+
time.Sleep(time.Millisecond)
93+
c.String(http.StatusNotFound, "Not Found")
94+
})
95+
96+
testCases := []struct {
97+
path string
98+
msg string
99+
status int
100+
}{
101+
{path: "/not-found", msg: "CLIENT ERROR", status: http.StatusNotFound},
102+
{path: "/", msg: "SUCCESS", status: http.StatusOK},
103+
{path: "/server-error", msg: "SERVER ERROR", status: http.StatusInternalServerError},
104+
}
105+
106+
for _, testCase := range testCases {
107+
t.Run(testCase.msg, func(t *testing.T) {
108+
res := httptest.NewRecorder()
109+
req := httptest.NewRequest(http.MethodGet, testCase.path, nil)
110+
req.Header.Set("User-Agent", "Go Test")
111+
112+
k.ServeHTTP(res, req)
113+
114+
var logRecord logRecord
115+
err := json.Unmarshal(buf.Bytes(), &logRecord)
116+
assert.NoError(t, err)
117+
118+
buf.Reset()
119+
120+
assert.Equal(t, testCase.status, logRecord.Status)
121+
assert.Equal(t, testCase.path, logRecord.Path)
122+
assert.Equal(t, http.MethodGet, logRecord.Method)
123+
assert.Equal(t, "Go Test", logRecord.UserAgent)
124+
assert.NotZero(t, logRecord.Time)
125+
assert.NotEmpty(t, logRecord.Latency)
126+
assert.NotEmpty(t, logRecord.LatenyNS)
127+
assert.Equal(t, testCase.msg, logRecord.Msg)
128+
})
129+
}
130+
}
131+
132+
func TestLogger_Skipper(t *testing.T) {
133+
var buf bytes.Buffer
134+
135+
cfg := DefaultLoggerConfig
136+
cfg.Out = &buf
137+
cfg.Skipper = func(c *kid.Context) bool {
138+
return true
139+
}
140+
141+
k := kid.New()
142+
k.Use(NewLoggerWithConfig(cfg))
143+
144+
res := httptest.NewRecorder()
145+
req := httptest.NewRequest(http.MethodGet, "/", nil)
146+
147+
k.ServeHTTP(res, req)
148+
149+
assert.Empty(t, buf.Bytes())
150+
}

0 commit comments

Comments
 (0)