**feat(logging, middleware): add error-based logging support and error log management**

- Introduced `logOnErrorOnly` mode to enable logging only for error responses when request logging is disabled.
- Added endpoints to list and download error logs (`/request-error-logs`).
- Implemented error log file cleanup to retain only the newest 10 logs.
- Refactored `ResponseWriterWrapper` to support forced logging for error responses.
- Enhanced middleware to capture data for upstream error persistence.
- Improved log file naming and error log filename generation.
This commit is contained in:
Luis Pater
2025-11-23 22:41:57 +08:00
parent ddb0c0ec1c
commit e3082887a6
5 changed files with 314 additions and 80 deletions

View File

@@ -139,6 +139,126 @@ func (h *Handler) DeleteLogs(c *gin.Context) {
}) })
} }
// GetRequestErrorLogs lists error request log files when RequestLog is disabled.
// It returns an empty list when RequestLog is enabled.
func (h *Handler) GetRequestErrorLogs(c *gin.Context) {
if h == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
return
}
if h.cfg == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
return
}
if h.cfg.RequestLog {
c.JSON(http.StatusOK, gin.H{"files": []any{}})
return
}
dir := h.logDirectory()
if strings.TrimSpace(dir) == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
return
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{"files": []any{}})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list request error logs: %v", err)})
return
}
type errorLog struct {
Name string `json:"name"`
Size int64 `json:"size"`
Modified int64 `json:"modified"`
}
files := make([]errorLog, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
continue
}
info, errInfo := entry.Info()
if errInfo != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log info for %s: %v", name, errInfo)})
return
}
files = append(files, errorLog{
Name: name,
Size: info.Size(),
Modified: info.ModTime().Unix(),
})
}
sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
c.JSON(http.StatusOK, gin.H{"files": files})
}
// DownloadRequestErrorLog downloads a specific error request log file by name.
func (h *Handler) DownloadRequestErrorLog(c *gin.Context) {
if h == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
return
}
if h.cfg == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
return
}
dir := h.logDirectory()
if strings.TrimSpace(dir) == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
return
}
name := strings.TrimSpace(c.Param("name"))
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file name"})
return
}
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
return
}
dirAbs, errAbs := filepath.Abs(dir)
if errAbs != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)})
return
}
fullPath := filepath.Clean(filepath.Join(dirAbs, name))
prefix := dirAbs + string(os.PathSeparator)
if !strings.HasPrefix(fullPath, prefix) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"})
return
}
info, errStat := os.Stat(fullPath)
if errStat != nil {
if os.IsNotExist(errStat) {
c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)})
return
}
if info.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"})
return
}
c.FileAttachment(fullPath, name)
}
func (h *Handler) logDirectory() string { func (h *Handler) logDirectory() string {
if h == nil { if h == nil {
return "" return ""
@@ -215,7 +335,9 @@ func (acc *logAccumulator) consumeFile(path string) error {
} }
return err return err
} }
defer file.Close() defer func() {
_ = file.Close()
}()
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
buf := make([]byte, 0, logScannerInitialBuffer) buf := make([]byte, 0, logScannerInitialBuffer)

View File

@@ -15,8 +15,8 @@ import (
// RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses. // RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses.
// It captures detailed information about the request and response, including headers and body, // It captures detailed information about the request and response, including headers and body,
// and uses the provided RequestLogger to record this data. If logging is disabled in the // and uses the provided RequestLogger to record this data. When logging is disabled in the
// logger, the middleware has minimal overhead. // logger, it still captures data so that upstream errors can be persisted.
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc { func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if logger == nil { if logger == nil {
@@ -30,12 +30,6 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
return return
} }
// Early return if logging is disabled (zero overhead)
if !logger.IsEnabled() {
c.Next()
return
}
// Capture request information // Capture request information
requestInfo, err := captureRequestInfo(c) requestInfo, err := captureRequestInfo(c)
if err != nil { if err != nil {
@@ -47,6 +41,9 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
// Create response writer wrapper // Create response writer wrapper
wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo) wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo)
if !logger.IsEnabled() {
wrapper.logOnErrorOnly = true
}
c.Writer = wrapper c.Writer = wrapper
// Process the request // Process the request

View File

@@ -5,6 +5,7 @@ package middleware
import ( import (
"bytes" "bytes"
"net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -24,15 +25,16 @@ type RequestInfo struct {
// It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response. // It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response.
type ResponseWriterWrapper struct { type ResponseWriterWrapper struct {
gin.ResponseWriter gin.ResponseWriter
body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses. body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses.
isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream). isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).
streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries. streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.
chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger. chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger.
streamDone chan struct{} // streamDone signals when the streaming goroutine completes. streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
logger logging.RequestLogger // logger is the instance of the request logger service. logger logging.RequestLogger // logger is the instance of the request logger service.
requestInfo *RequestInfo // requestInfo holds the details of the original request. requestInfo *RequestInfo // requestInfo holds the details of the original request.
statusCode int // statusCode stores the HTTP status code of the response. statusCode int // statusCode stores the HTTP status code of the response.
headers map[string][]string // headers stores the response headers. headers map[string][]string // headers stores the response headers.
logOnErrorOnly bool // logOnErrorOnly enables logging only when an error response is detected.
} }
// NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper. // NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper.
@@ -192,12 +194,34 @@ func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
// For non-streaming responses, it logs the complete request and response details, // For non-streaming responses, it logs the complete request and response details,
// including any API-specific request/response data stored in the Gin context. // including any API-specific request/response data stored in the Gin context.
func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error { func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
if !w.logger.IsEnabled() { if w.logger == nil {
return nil
}
finalStatusCode := w.statusCode
if finalStatusCode == 0 {
if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok {
finalStatusCode = statusWriter.Status()
} else {
finalStatusCode = 200
}
}
var slicesAPIResponseError []*interfaces.ErrorMessage
apiResponseError, isExist := c.Get("API_RESPONSE_ERROR")
if isExist {
if apiErrors, ok := apiResponseError.([]*interfaces.ErrorMessage); ok {
slicesAPIResponseError = apiErrors
}
}
hasAPIError := len(slicesAPIResponseError) > 0 || finalStatusCode >= http.StatusBadRequest
forceLog := w.logOnErrorOnly && hasAPIError && !w.logger.IsEnabled()
if !w.logger.IsEnabled() && !forceLog {
return nil return nil
} }
if w.isStreaming { if w.isStreaming {
// Close streaming channel and writer
if w.chunkChannel != nil { if w.chunkChannel != nil {
close(w.chunkChannel) close(w.chunkChannel)
w.chunkChannel = nil w.chunkChannel = nil
@@ -209,80 +233,98 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
} }
if w.streamWriter != nil { if w.streamWriter != nil {
err := w.streamWriter.Close() if err := w.streamWriter.Close(); err != nil {
w.streamWriter = nil
return err
}
w.streamWriter = nil w.streamWriter = nil
return err
} }
} else { if forceLog {
// Capture final status code and headers if not already captured return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog)
finalStatusCode := w.statusCode
if finalStatusCode == 0 {
// Get status from underlying ResponseWriter if available
if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok {
finalStatusCode = statusWriter.Status()
} else {
finalStatusCode = 200 // Default
}
} }
return nil
}
// Ensure we have the latest headers before finalizing return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog)
w.ensureHeadersCaptured() }
// Use the captured headers as the final headers func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {
finalHeaders := make(map[string][]string) w.ensureHeadersCaptured()
for key, values := range w.headers {
// Make a copy of the values slice to avoid reference issues
headerValues := make([]string, len(values))
copy(headerValues, values)
finalHeaders[key] = headerValues
}
var apiRequestBody []byte finalHeaders := make(map[string][]string, len(w.headers))
apiRequest, isExist := c.Get("API_REQUEST") for key, values := range w.headers {
if isExist { headerValues := make([]string, len(values))
var ok bool copy(headerValues, values)
apiRequestBody, ok = apiRequest.([]byte) finalHeaders[key] = headerValues
if !ok { }
apiRequestBody = nil
}
}
var apiResponseBody []byte return finalHeaders
apiResponse, isExist := c.Get("API_RESPONSE") }
if isExist {
var ok bool
apiResponseBody, ok = apiResponse.([]byte)
if !ok {
apiResponseBody = nil
}
}
var slicesAPIResponseError []*interfaces.ErrorMessage func (w *ResponseWriterWrapper) extractAPIRequest(c *gin.Context) []byte {
apiResponseError, isExist := c.Get("API_RESPONSE_ERROR") apiRequest, isExist := c.Get("API_REQUEST")
if isExist { if !isExist {
var ok bool return nil
slicesAPIResponseError, ok = apiResponseError.([]*interfaces.ErrorMessage) }
if !ok { data, ok := apiRequest.([]byte)
slicesAPIResponseError = nil if !ok || len(data) == 0 {
} return nil
} }
return data
}
// Log complete non-streaming response func (w *ResponseWriterWrapper) extractAPIResponse(c *gin.Context) []byte {
return w.logger.LogRequest( apiResponse, isExist := c.Get("API_RESPONSE")
if !isExist {
return nil
}
data, ok := apiResponse.([]byte)
if !ok || len(data) == 0 {
return nil
}
return data
}
func (w *ResponseWriterWrapper) logRequest(statusCode int, headers map[string][]string, body []byte, apiRequestBody, apiResponseBody []byte, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
if w.requestInfo == nil {
return nil
}
var requestBody []byte
if len(w.requestInfo.Body) > 0 {
requestBody = w.requestInfo.Body
}
if loggerWithOptions, ok := w.logger.(interface {
LogRequestWithOptions(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []*interfaces.ErrorMessage, bool) error
}); ok {
return loggerWithOptions.LogRequestWithOptions(
w.requestInfo.URL, w.requestInfo.URL,
w.requestInfo.Method, w.requestInfo.Method,
w.requestInfo.Headers, w.requestInfo.Headers,
w.requestInfo.Body, requestBody,
finalStatusCode, statusCode,
finalHeaders, headers,
w.body.Bytes(), body,
apiRequestBody, apiRequestBody,
apiResponseBody, apiResponseBody,
slicesAPIResponseError, apiResponseErrors,
forceLog,
) )
} }
return nil return w.logger.LogRequest(
w.requestInfo.URL,
w.requestInfo.Method,
w.requestInfo.Headers,
requestBody,
statusCode,
headers,
body,
apiRequestBody,
apiResponseBody,
apiResponseErrors,
)
} }
// Status returns the HTTP response status code captured by the wrapper. // Status returns the HTTP response status code captured by the wrapper.

View File

@@ -509,6 +509,8 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/logs", s.mgmt.GetLogs) mgmt.GET("/logs", s.mgmt.GetLogs)
mgmt.DELETE("/logs", s.mgmt.DeleteLogs) mgmt.DELETE("/logs", s.mgmt.DeleteLogs)
mgmt.GET("/request-error-logs", s.mgmt.GetRequestErrorLogs)
mgmt.GET("/request-error-logs/:name", s.mgmt.DownloadRequestErrorLog)
mgmt.GET("/request-log", s.mgmt.GetRequestLog) mgmt.GET("/request-log", s.mgmt.GetRequestLog)
mgmt.PUT("/request-log", s.mgmt.PutRequestLog) mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog) mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)

View File

@@ -12,6 +12,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "time"
@@ -156,17 +157,30 @@ func (l *FileRequestLogger) SetEnabled(enabled bool) {
// Returns: // Returns:
// - error: An error if logging fails, nil otherwise // - error: An error if logging fails, nil otherwise
func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error { func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error {
if !l.enabled { return l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, apiRequest, apiResponse, apiResponseErrors, false)
}
// LogRequestWithOptions logs a request with optional forced logging behavior.
// The force flag allows writing error logs even when regular request logging is disabled.
func (l *FileRequestLogger) LogRequestWithOptions(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool) error {
return l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, apiRequest, apiResponse, apiResponseErrors, force)
}
func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool) error {
if !l.enabled && !force {
return nil return nil
} }
// Ensure logs directory exists // Ensure logs directory exists
if err := l.ensureLogsDir(); err != nil { if errEnsure := l.ensureLogsDir(); errEnsure != nil {
return fmt.Errorf("failed to create logs directory: %w", err) return fmt.Errorf("failed to create logs directory: %w", errEnsure)
} }
// Generate filename // Generate filename
filename := l.generateFilename(url) filename := l.generateFilename(url)
if force && !l.enabled {
filename = l.generateErrorFilename(url)
}
filePath := filepath.Join(l.logsDir, filename) filePath := filepath.Join(l.logsDir, filename)
// Decompress response if needed // Decompress response if needed
@@ -184,6 +198,12 @@ func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[st
return fmt.Errorf("failed to write log file: %w", err) return fmt.Errorf("failed to write log file: %w", err)
} }
if force && !l.enabled {
if errCleanup := l.cleanupOldErrorLogs(); errCleanup != nil {
log.WithError(errCleanup).Warn("failed to clean up old error logs")
}
}
return nil return nil
} }
@@ -239,6 +259,11 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[
return writer, nil return writer, nil
} }
// generateErrorFilename creates a filename with an error prefix to differentiate forced error logs.
func (l *FileRequestLogger) generateErrorFilename(url string) string {
return fmt.Sprintf("error-%s", l.generateFilename(url))
}
// ensureLogsDir creates the logs directory if it doesn't exist. // ensureLogsDir creates the logs directory if it doesn't exist.
// //
// Returns: // Returns:
@@ -312,6 +337,52 @@ func (l *FileRequestLogger) sanitizeForFilename(path string) string {
return sanitized return sanitized
} }
// cleanupOldErrorLogs keeps only the newest 10 forced error log files.
func (l *FileRequestLogger) cleanupOldErrorLogs() error {
entries, errRead := os.ReadDir(l.logsDir)
if errRead != nil {
return errRead
}
type logFile struct {
name string
modTime time.Time
}
var files []logFile
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
continue
}
info, errInfo := entry.Info()
if errInfo != nil {
log.WithError(errInfo).Warn("failed to read error log info")
continue
}
files = append(files, logFile{name: name, modTime: info.ModTime()})
}
if len(files) <= 10 {
return nil
}
sort.Slice(files, func(i, j int) bool {
return files[i].modTime.After(files[j].modTime)
})
for _, file := range files[10:] {
if errRemove := os.Remove(filepath.Join(l.logsDir, file.name)); errRemove != nil {
log.WithError(errRemove).Warnf("failed to remove old error log: %s", file.name)
}
}
return nil
}
// formatLogContent creates the complete log content for non-streaming requests. // formatLogContent creates the complete log content for non-streaming requests.
// //
// Parameters: // Parameters: