Files
backend/internal/http/logging.go
Andriy Oblivantsev 6c26135cad
CI / test (push) Successful in 4s
Update
2026-03-02 21:21:21 +00:00

96 lines
2.3 KiB
Go

package httpapi
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// WithRequestLogging wraps h with a handler that logs each request to dir/access.log.
// Uses dir "var/logs" by default. Returns h unchanged if dir is empty.
func WithRequestLogging(dir string, h http.Handler) (http.Handler, error) {
if dir == "" {
return h, nil
}
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
f, err := os.OpenFile(filepath.Join(dir, "access.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
return &requestLogger{handler: h, file: f}, nil
}
type requestLogger struct {
handler http.Handler
file *os.File
mu sync.Mutex
}
func (l *requestLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ip := clientIP(r)
method := r.Method
path := r.URL.Path
userAgent := r.Header.Get("User-Agent")
if userAgent == "" {
userAgent = "-"
}
wrapped := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
l.handler.ServeHTTP(wrapped, r)
elapsed := time.Since(start).Milliseconds()
line := formatLogLine(ip, method, path, wrapped.status, elapsed, strings.ReplaceAll(userAgent, "\"", "'"))
l.mu.Lock()
_, _ = l.file.WriteString(line)
l.mu.Unlock()
}
type responseRecorder struct {
http.ResponseWriter
status int
}
func (r *responseRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func formatLogLine(ip, method, path string, status int, elapsedMs int64, userAgent string) string {
// Common log format: ip - - [timestamp] "method path protocol" status size "referer" "user-agent" elapsed_ms
t := time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700")
return fmt.Sprintf("%s - - [%s] \"%s %s\" %d %d \"-\" \"%s\" %dms\n",
ip, t, method, path, status, 0, userAgent, elapsedMs)
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First IP in the list is the original client
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
host, _ := splitHostPort(r.RemoteAddr)
return host
}
func splitHostPort(addr string) (host, port string) {
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
return addr[:idx], addr[idx+1:]
}
return addr, ""
}