diff options
author | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-02 20:55:11 +0530 |
---|---|---|
committer | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-02 20:55:11 +0530 |
commit | ce6cf13c2d67c3368251d1eea5593198f5021330 (patch) | |
tree | d4c2347fd45fce395bf22a30e423697494d4284f /logging/http | |
download | kit-f56c6730f284513e728f9337d1c92f91938bea46.tar kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.gz kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.bz2 kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.lz kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.xz kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.zst kit-f56c6730f284513e728f9337d1c92f91938bea46.zip |
Initial Commitv0.1.0
Diffstat (limited to 'logging/http')
-rw-r--r-- | logging/http/http.go | 134 | ||||
-rw-r--r-- | logging/http/http_test.go | 113 |
2 files changed, 247 insertions, 0 deletions
diff --git a/logging/http/http.go b/logging/http/http.go new file mode 100644 index 0000000..b88ae3a --- /dev/null +++ b/logging/http/http.go @@ -0,0 +1,134 @@ +// Package http provides HTTP middleware and utilities, including request logging using slog. +// +// The LoggingHandler middleware logs HTTP request details, response status, bytes written, +// and handles panics gracefully with stack trace logging. + +package http + +import ( + "bufio" + "log/slog" + "net" + "net/http" + "time" + + "go.sudomsg.com/kit/logging" +) + +type tracingWriter struct { + http.ResponseWriter + StatusCode int + BytesWritten int + Hijacked bool +} + +var ( + _ http.ResponseWriter = &tracingWriter{} + _ http.Flusher = &tracingWriter{} + _ http.Hijacker = &tracingWriter{} + _ interface{ Unwrap() http.ResponseWriter } = &tracingWriter{} +) + +func (tw *tracingWriter) WriteHeader(code int) { + tw.ResponseWriter.WriteHeader(code) + tw.StatusCode = code +} + +func (tw *tracingWriter) Write(b []byte) (int, error) { + if tw.StatusCode == 0 { + tw.WriteHeader(http.StatusOK) + } + n, err := tw.ResponseWriter.Write(b) + tw.BytesWritten += n + return n, err +} + +func (tw *tracingWriter) Flush() { + rc := http.NewResponseController(tw.ResponseWriter) + _ = rc.Flush() +} + +func (tw *tracingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + rc := http.NewResponseController(tw.ResponseWriter) + conn, rw, err := rc.Hijack() + if err == nil { + tw.Hijacked = true + } + return conn, rw, err +} + +func (tw *tracingWriter) Unwrap() http.ResponseWriter { + return tw.ResponseWriter +} + +// LoggingHandler is an HTTP middleware that logs requests and responses using slog. +// +// It recovers panics during request processing, logs them with stack traces, +// and returns a 500 Internal Server Error if the connection is not hijacked. +type LoggingHandler struct { + next http.Handler + logger *slog.Logger +} + +var _ http.Handler = &LoggingHandler{} + +// New creates a new LoggingHandler wrapping the next http.Handler, using the provided slog.Logger. +// +// If logger is nil, it attempts to obtain one from the request context. +func New(next http.Handler, logger *slog.Logger) *LoggingHandler { + return &LoggingHandler{ + next: next, + logger: logger, + } +} + +// ServeHTTP implements the http.Handler interface. +// +// It logs the HTTP method, path, client IP, protocol, host, user-agent, referer, +// request latency, response status, and bytes written. +// It also recovers panics, logs them, and returns HTTP 500 if appropriate. +func (h *LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + logger := h.logger + if logger == nil { + logger = logging.FromContext(ctx) + } + + logger = logger.With( + slog.String("method", r.Method), + slog.String("path", r.URL.RequestURI()), + slog.String("ip", r.RemoteAddr), + ) + ctx = logging.WithLogger(ctx, logger) + r = r.WithContext(ctx) + + tw := &tracingWriter{ResponseWriter: w} + + defer func() { + if err := recover(); err != nil { + logging.RecoverAndLog(ctx, "http panic", err) + if !tw.Hijacked && tw.StatusCode == 0 { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + }() + + start := time.Now() + + h.next.ServeHTTP(tw, r) + + if !tw.Hijacked { + latency := time.Since(start) + + logger.LogAttrs(r.Context(), slog.LevelInfo, "HTTP request Handled", + slog.String("proto", r.Proto), + slog.String("host", r.Host), + slog.String("user-agent", r.UserAgent()), + slog.String("referer", r.Referer()), + slog.Duration("latency", latency), + slog.Int("status", tw.StatusCode), + slog.Int("bytes", tw.BytesWritten), + ) + } +} diff --git a/logging/http/http_test.go b/logging/http/http_test.go new file mode 100644 index 0000000..26e5974 --- /dev/null +++ b/logging/http/http_test.go @@ -0,0 +1,113 @@ +package http_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + httpHandler "go.sudomsg.com/kit/logging/http" + "go.sudomsg.com/kit/logging/test" +) + +func TestNew(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + url string + status int + handler http.HandlerFunc + }{ + { + name: "No Status", + method: http.MethodGet, + url: "http://example.com/", + status: http.StatusOK, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte{}) + }, + }, + { + name: "NotFound", + method: http.MethodGet, + url: "http://example.com/", + status: http.StatusNotFound, + handler: http.NotFound, + }, + { + name: "Query", + method: http.MethodGet, + url: "http://example.com/a?b", + status: http.StatusNotFound, + handler: http.NotFound, + }, + { + name: "Flush", + method: http.MethodGet, + url: "http://example.com/", + status: http.StatusOK, + handler: func(w http.ResponseWriter, r *http.Request) { + rc := http.NewResponseController(w) + w.Write([]byte{}) + rc.Flush() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + log := test.NewMockLogHandler(t) + + logger := slog.New(log) + + handler := httpHandler.New(tt.handler, logger) + + r := httptest.NewRequest(tt.method, tt.url, nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, r) + + records := log.Records() + + if len(records) != 1 { + t.Fatalf("expected 1 log record, got %d", len(records)) + } + + record := records[0] + + attrs := map[string]slog.Value{} + record.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value + return true + }) + + method := attrs["method"] + if got := method.String(); got != tt.method { + t.Fatalf("expected %v log record, got %v", tt.method, got) + } + status := attrs["status"] + if got := int(status.Int64()); got != tt.status { + t.Fatalf("expected %v log record, got %v", tt.status, got) + } + + url, err := url.Parse(tt.url) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + host := attrs["host"] + if got := host.String(); got != url.Host { + t.Fatalf("expected %v log record, got %v", url.Host, got) + } + path := attrs["path"] + if got := path.String(); got != url.RequestURI() { + t.Fatalf("expected %v log record, got %v", url.RequestURI(), got) + } + }) + } +} |