Skip to content

Commit d75a3da

Browse files
Merge pull request #38 from oleksandrkhmil/feat/trace-generator
Feat/trace generator
2 parents 755aa4e + d69d34a commit d75a3da

File tree

9 files changed

+438
-12
lines changed

9 files changed

+438
-12
lines changed

cmd/service/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import (
1414
"github.com/nix-united/golang-echo-boilerplate/internal/db"
1515
"github.com/nix-united/golang-echo-boilerplate/internal/server"
1616
"github.com/nix-united/golang-echo-boilerplate/internal/server/routes"
17+
"github.com/nix-united/golang-echo-boilerplate/internal/slogx"
1718

1819
"github.com/caarlos0/env/v11"
20+
"github.com/google/uuid"
1921
"github.com/joho/godotenv"
2022
"github.com/labstack/echo/v4"
2123
)
@@ -54,14 +56,18 @@ func run() error {
5456

5557
docs.SwaggerInfo.Host = fmt.Sprintf("%s:%s", cfg.HTTP.Host, cfg.HTTP.Port)
5658

59+
if err := slogx.Init(cfg.Logger); err != nil {
60+
return fmt.Errorf("init logger: %w", err)
61+
}
62+
5763
gormDB, err := db.NewGormDB(cfg.DB)
5864
if err != nil {
5965
return fmt.Errorf("new db connection: %w", err)
6066
}
6167

6268
app := server.NewServer(echo.New(), gormDB, &cfg)
6369

64-
routes.ConfigureRoutes(app)
70+
routes.ConfigureRoutes(slogx.NewTraceStarter(uuid.NewV7), app)
6571

6672
go func() {
6773
if err = app.Start(cfg.HTTP.Port); err != nil {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/caarlos0/env/v11 v11.3.1
88
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
99
github.com/golang-jwt/jwt/v5 v5.2.2
10+
github.com/google/uuid v1.6.0
1011
github.com/joho/godotenv v1.5.1
1112
github.com/labstack/echo-jwt/v4 v4.3.1
1213
github.com/labstack/echo/v4 v4.13.3
@@ -128,7 +129,6 @@ require (
128129
github.com/golangci/revgrep v0.8.0 // indirect
129130
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
130131
github.com/google/go-cmp v0.7.0 // indirect
131-
github.com/google/uuid v1.6.0 // indirect
132132
github.com/gordonklaus/ineffassign v0.1.0 // indirect
133133
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
134134
github.com/gostaticanalysis/comment v1.4.2 // indirect

internal/config/config.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package config
22

3+
import "github.com/nix-united/golang-echo-boilerplate/internal/slogx"
4+
35
type Config struct {
4-
Auth AuthConfig
5-
DB DBConfig
6-
HTTP HTTPConfig
6+
Logger slogx.Config
7+
Auth AuthConfig
8+
DB DBConfig
9+
HTTP HTTPConfig
710
}
811

912
type DBConfig struct {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package middleware
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"log/slog"
10+
"net/http"
11+
"strings"
12+
13+
"github.com/labstack/echo/v4"
14+
)
15+
16+
// requestDebugger is a logging middleware that logs request and response bodies with DEBUG level logs.
17+
// Warning: Do not use this middleware with endpoints containing sensitive information.
18+
//
19+
// Based on the Content-Type, it determines how the body will be formatted.
20+
// If the content type is application/json, the body will be logged as JSON; otherwise, it will be logged as a string.
21+
type requestDebugger struct{}
22+
23+
func NewRequestDebugger() echo.MiddlewareFunc {
24+
debugger := requestDebugger{}
25+
return debugger.handle
26+
}
27+
28+
func (d requestDebugger) handle(next echo.HandlerFunc) echo.HandlerFunc {
29+
if !slog.Default().Enabled(context.Background(), slog.LevelDebug) {
30+
return next
31+
}
32+
33+
return func(c echo.Context) error {
34+
requestBody, err := d.getRequestBody(c)
35+
if err != nil {
36+
return fmt.Errorf("get request body for logging: %w", err)
37+
}
38+
39+
responseBodyGetter := d.getResponseBodyGetter(c)
40+
41+
errNext := next(c)
42+
43+
var attrs []any
44+
if requestBody != nil {
45+
attrs = append(attrs, "request_body", requestBody)
46+
}
47+
48+
responseBody := responseBodyGetter(c)
49+
if responseBody != nil {
50+
attrs = append(attrs, "response_body", responseBody)
51+
}
52+
53+
message := "Request/response data"
54+
if len(attrs) == 0 {
55+
message = "Request/response withot any data"
56+
}
57+
58+
slog.DebugContext(c.Request().Context(), message, attrs...)
59+
60+
if errNext != nil {
61+
return fmt.Errorf("handle request with request debugger: %w", errNext)
62+
}
63+
64+
return nil
65+
}
66+
}
67+
68+
func (d requestDebugger) getRequestBody(c echo.Context) (any, error) {
69+
if c.Request().Body == nil {
70+
return nil, nil
71+
}
72+
73+
rawRequestBody, err := io.ReadAll(c.Request().Body)
74+
if err != nil {
75+
return nil, fmt.Errorf("read request body: %w", err)
76+
}
77+
78+
request := c.Request()
79+
request.Body = io.NopCloser(bytes.NewReader(rawRequestBody))
80+
c.SetRequest(request)
81+
82+
if strings.HasPrefix(request.Header.Get(echo.HeaderContentType), "application/json") {
83+
return json.RawMessage(rawRequestBody), nil
84+
}
85+
86+
return string(rawRequestBody), nil
87+
}
88+
89+
func (d requestDebugger) getResponseBodyGetter(c echo.Context) func(echo.Context) any {
90+
response := c.Response()
91+
storer := newResponseStorer(response.Writer)
92+
response.Writer = storer
93+
c.SetResponse(response)
94+
95+
return func(c echo.Context) any {
96+
if storer.storedResponse == nil {
97+
return nil
98+
}
99+
100+
if strings.HasPrefix(c.Response().Header().Get(echo.HeaderContentType), "application/json") {
101+
return json.RawMessage(storer.storedResponse)
102+
}
103+
104+
return string(storer.storedResponse)
105+
}
106+
}
107+
108+
// responseStorer stores the written response by the handler into its field.
109+
// This is used to automate response logging.
110+
type responseStorer struct {
111+
http.ResponseWriter
112+
storedResponse []byte
113+
}
114+
115+
func newResponseStorer(writer http.ResponseWriter) *responseStorer {
116+
return &responseStorer{ResponseWriter: writer}
117+
}
118+
119+
func (s *responseStorer) Write(response []byte) (int, error) {
120+
s.storedResponse = response
121+
122+
n, err := s.ResponseWriter.Write(response)
123+
if err != nil {
124+
return n, fmt.Errorf("write response with storer: %w", err)
125+
}
126+
127+
return n, nil
128+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
9+
"github.com/labstack/echo/v4"
10+
)
11+
12+
type tracer interface {
13+
Start(ctx context.Context) (context.Context, error)
14+
}
15+
16+
// requestLogger is a logging middleware that generated trace ID for each request.
17+
type requestLogger struct {
18+
tracer tracer
19+
}
20+
21+
func NewRequestLogger(tracer tracer) echo.MiddlewareFunc {
22+
middleware := requestLogger{tracer: tracer}
23+
return middleware.handle
24+
}
25+
26+
// handle creates trace and logs request information.
27+
func (l requestLogger) handle(next echo.HandlerFunc) echo.HandlerFunc {
28+
return func(c echo.Context) error {
29+
ctx, err := l.tracer.Start(c.Request().Context())
30+
if err != nil {
31+
return fmt.Errorf("trace starter: %w", err)
32+
}
33+
34+
c.SetRequest(c.Request().WithContext(ctx))
35+
36+
errNext := next(c)
37+
38+
level := slog.LevelInfo
39+
if c.Response().Status >= http.StatusInternalServerError {
40+
level = slog.LevelError
41+
}
42+
43+
attrs := []any{
44+
"method", c.Request().Method,
45+
"status", c.Response().Status,
46+
"path", c.Path(),
47+
}
48+
49+
if errNext != nil {
50+
attrs = append(attrs, "error", errNext.Error())
51+
}
52+
53+
slog.Log(c.Request().Context(), level, "Request", slog.Group("http", attrs...))
54+
55+
if errNext != nil {
56+
return fmt.Errorf("handle request with request logger: %w", errNext)
57+
}
58+
59+
return nil
60+
}
61+
}

internal/server/routes/routes.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,38 @@
11
package routes
22

33
import (
4-
"fmt"
5-
64
"github.com/nix-united/golang-echo-boilerplate/internal/repositories"
75
s "github.com/nix-united/golang-echo-boilerplate/internal/server"
86
"github.com/nix-united/golang-echo-boilerplate/internal/server/handlers"
7+
"github.com/nix-united/golang-echo-boilerplate/internal/server/middleware"
98
"github.com/nix-united/golang-echo-boilerplate/internal/services/post"
109
"github.com/nix-united/golang-echo-boilerplate/internal/services/token"
10+
"github.com/nix-united/golang-echo-boilerplate/internal/slogx"
1111

1212
"github.com/golang-jwt/jwt/v5"
1313
echojwt "github.com/labstack/echo-jwt/v4"
1414
"github.com/labstack/echo/v4"
15-
"github.com/labstack/echo/v4/middleware"
1615
echoSwagger "github.com/swaggo/echo-swagger"
1716
)
1817

19-
func ConfigureRoutes(server *s.Server) {
18+
func ConfigureRoutes(tracer slogx.TraceStarter, server *s.Server) {
2019
postRepository := repositories.NewPostRepository(server.DB)
2120
postService := post.NewPostService(postRepository)
2221

2322
postHandler := handlers.NewPostHandlers(postService)
2423
authHandler := handlers.NewAuthHandler(server)
2524
registerHandler := handlers.NewRegisterHandler(server)
2625

27-
server.Echo.Use(middleware.Logger())
26+
server.Echo.Use(middleware.NewRequestLogger(tracer))
2827

2928
server.Echo.GET("/swagger/*", echoSwagger.WrapHandler)
3029

3130
server.Echo.POST("/login", authHandler.Login)
3231
server.Echo.POST("/register", registerHandler.Register)
3332
server.Echo.POST("/refresh", authHandler.RefreshToken)
3433

35-
fmt.Println(server.Config.Auth.AccessSecret)
34+
r := server.Echo.Group("", middleware.NewRequestDebugger())
3635

37-
r := server.Echo.Group("")
3836
// Configure middleware with the custom claims type
3937
config := echojwt.Config{
4038
NewClaimsFunc: func(_ echo.Context) jwt.Claims {

internal/slogx/init.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package slogx
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log/slog"
7+
"os"
8+
)
9+
10+
type Config struct {
11+
Application string `env:"LOG_APPLICATION"`
12+
13+
// File represents path to file where store logs. Used [os.Stdout] if empty.
14+
File string `env:"LOG_FILE"`
15+
16+
// One of: "DEBUG", "INFO", "WARN", "ERROR". Default: "DEBUG".
17+
Level string `env:"LOG_LEVEL"`
18+
19+
// Add source code position to messages.
20+
AddSource bool `env:"LOG_ADD_SOURCE"`
21+
}
22+
23+
func Init(config Config) (err error) {
24+
writer := io.Writer(os.Stdout)
25+
if config.File != "" {
26+
const permission = 0o644
27+
28+
writer, err = os.OpenFile(config.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, permission)
29+
if err != nil {
30+
return fmt.Errorf("open file %s: %w", config.File, err)
31+
}
32+
}
33+
34+
level := slog.LevelDebug
35+
if config.Level != "" {
36+
if err = level.UnmarshalText([]byte(config.Level)); err != nil {
37+
return fmt.Errorf("parse log level %s: %w", config.Level, err)
38+
}
39+
}
40+
41+
hostname, err := os.Hostname()
42+
if err != nil {
43+
return fmt.Errorf("get hostname: %w", err)
44+
}
45+
46+
jsonHandler := slog.NewJSONHandler(writer, &slog.HandlerOptions{AddSource: config.AddSource, Level: level})
47+
48+
traceHandler := newTraceHandler(jsonHandler)
49+
50+
logger := slog.New(traceHandler).
51+
With("application", config.Application).
52+
With("hostname", hostname)
53+
54+
slog.SetDefault(logger)
55+
56+
return nil
57+
}

0 commit comments

Comments
 (0)