Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
The logging middleware's responseWriter wrapped http.ResponseWriter but only implemented WriteHeader, Write, and Unwrap. The missing Flush() method caused w.(http.Flusher) type assertions to fail in the claudebox sidecar's streaming endpoint, returning 500 "streaming not supported". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
153 lines
3.8 KiB
Go
153 lines
3.8 KiB
Go
package logging
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// responseWriter wraps http.ResponseWriter to capture status code.
|
|
type responseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
wroteHeader bool
|
|
bytesWritten int
|
|
}
|
|
|
|
func (rw *responseWriter) WriteHeader(code int) {
|
|
if rw.wroteHeader {
|
|
return
|
|
}
|
|
rw.status = code
|
|
rw.wroteHeader = true
|
|
rw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (rw *responseWriter) Write(b []byte) (int, error) {
|
|
if !rw.wroteHeader {
|
|
rw.WriteHeader(http.StatusOK)
|
|
}
|
|
n, err := rw.ResponseWriter.Write(b)
|
|
rw.bytesWritten += n
|
|
return n, err
|
|
}
|
|
|
|
// Flush implements http.Flusher, required for SSE streaming through middleware chains.
|
|
func (rw *responseWriter) Flush() {
|
|
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// Unwrap returns the original http.ResponseWriter, required for http.ResponseController.
|
|
func (rw *responseWriter) Unwrap() http.ResponseWriter {
|
|
return rw.ResponseWriter
|
|
}
|
|
|
|
// RequestIDHeader is the header name for request ID propagation.
|
|
const RequestIDHeader = "X-Request-ID"
|
|
|
|
// MiddlewareConfig configures the logging middleware.
|
|
type MiddlewareConfig struct {
|
|
// Logger is the logger to use. If nil, uses Default().
|
|
Logger *Logger
|
|
|
|
// SkipPaths are paths that should not be logged.
|
|
SkipPaths map[string]bool
|
|
|
|
// GenerateRequestID controls whether to generate a request ID if not present.
|
|
GenerateRequestID bool
|
|
}
|
|
|
|
// DefaultMiddlewareConfig returns sensible defaults.
|
|
func DefaultMiddlewareConfig() MiddlewareConfig {
|
|
return MiddlewareConfig{
|
|
SkipPaths: map[string]bool{
|
|
"/health": true,
|
|
"/ready": true,
|
|
},
|
|
GenerateRequestID: true,
|
|
}
|
|
}
|
|
|
|
// Middleware returns an HTTP middleware that logs requests and enriches context.
|
|
// It:
|
|
// - Generates or propagates request IDs
|
|
// - Logs request start (debug) and completion (info/warn/error based on status)
|
|
// - Stores an enriched logger in context for handlers to use
|
|
func Middleware(cfg MiddlewareConfig) func(http.Handler) http.Handler {
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = Default()
|
|
}
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Skip logging for configured paths
|
|
if cfg.SkipPaths[r.URL.Path] {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
|
|
// Get or generate request ID
|
|
requestID := r.Header.Get(RequestIDHeader)
|
|
if requestID == "" && cfg.GenerateRequestID {
|
|
requestID = uuid.New().String()
|
|
}
|
|
|
|
// Set request ID in response header
|
|
if requestID != "" {
|
|
w.Header().Set(RequestIDHeader, requestID)
|
|
}
|
|
|
|
// Wrap response writer to capture status code and bytes
|
|
wrapped := &responseWriter{
|
|
ResponseWriter: w,
|
|
status: http.StatusOK,
|
|
}
|
|
|
|
// Create request-scoped logger
|
|
reqLogger := logger.With(
|
|
FieldRequestID, requestID,
|
|
FieldHTTPMethod, r.Method,
|
|
FieldHTTPPath, r.URL.Path,
|
|
FieldHTTPRemoteAddr, r.RemoteAddr,
|
|
)
|
|
|
|
// Store logger in context for handlers to use
|
|
ctx := WithContext(r.Context(), reqLogger)
|
|
|
|
// Log request start at debug level
|
|
reqLogger.Debug("request started",
|
|
FieldHTTPUserAgent, r.UserAgent(),
|
|
FieldHTTPHost, r.Host,
|
|
)
|
|
|
|
// Call next handler with enriched context
|
|
next.ServeHTTP(wrapped, r.WithContext(ctx))
|
|
|
|
// Calculate duration
|
|
duration := time.Since(start)
|
|
|
|
// Log completion with appropriate level based on status
|
|
attrs := []any{
|
|
FieldHTTPStatus, wrapped.status,
|
|
FieldDuration, duration.Milliseconds(),
|
|
"bytes", wrapped.bytesWritten,
|
|
}
|
|
|
|
switch {
|
|
case wrapped.status >= 500:
|
|
reqLogger.Error("request completed", attrs...)
|
|
case wrapped.status >= 400:
|
|
reqLogger.Warn("request completed", attrs...)
|
|
default:
|
|
reqLogger.Info("request completed", attrs...)
|
|
}
|
|
})
|
|
}
|
|
}
|