diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go index 4aba9992..662edf20 100644 --- a/internal/api/handlers/management/logs.go +++ b/internal/api/handlers/management/logs.go @@ -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 { if h == nil { return "" @@ -215,7 +335,9 @@ func (acc *logAccumulator) consumeFile(path string) error { } return err } - defer file.Close() + defer func() { + _ = file.Close() + }() scanner := bufio.NewScanner(file) buf := make([]byte, 0, logScannerInitialBuffer) diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go index 9e47780b..63a5dbfc 100644 --- a/internal/api/middleware/request_logging.go +++ b/internal/api/middleware/request_logging.go @@ -15,8 +15,8 @@ import ( // RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses. // 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 -// logger, the middleware has minimal overhead. +// and uses the provided RequestLogger to record this data. When logging is disabled in the +// logger, it still captures data so that upstream errors can be persisted. func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc { return func(c *gin.Context) { if logger == nil { @@ -30,12 +30,6 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc { return } - // Early return if logging is disabled (zero overhead) - if !logger.IsEnabled() { - c.Next() - return - } - // Capture request information requestInfo, err := captureRequestInfo(c) if err != nil { @@ -47,6 +41,9 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc { // Create response writer wrapper wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo) + if !logger.IsEnabled() { + wrapper.logOnErrorOnly = true + } c.Writer = wrapper // Process the request diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go index 8bd35775..f0d1ad26 100644 --- a/internal/api/middleware/response_writer.go +++ b/internal/api/middleware/response_writer.go @@ -5,6 +5,7 @@ package middleware import ( "bytes" + "net/http" "strings" "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. type ResponseWriterWrapper struct { gin.ResponseWriter - 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). - 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. - streamDone chan struct{} // streamDone signals when the streaming goroutine completes. - logger logging.RequestLogger // logger is the instance of the request logger service. - requestInfo *RequestInfo // requestInfo holds the details of the original request. - statusCode int // statusCode stores the HTTP status code of the response. - headers map[string][]string // headers stores the response headers. + 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). + 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. + streamDone chan struct{} // streamDone signals when the streaming goroutine completes. + logger logging.RequestLogger // logger is the instance of the request logger service. + requestInfo *RequestInfo // requestInfo holds the details of the original request. + statusCode int // statusCode stores the HTTP status code of the response. + 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. @@ -192,12 +194,34 @@ func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) { // For non-streaming responses, it logs the complete request and response details, // including any API-specific request/response data stored in the Gin context. 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 } if w.isStreaming { - // Close streaming channel and writer if w.chunkChannel != nil { close(w.chunkChannel) w.chunkChannel = nil @@ -209,80 +233,98 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error { } if w.streamWriter != nil { - err := w.streamWriter.Close() + if err := w.streamWriter.Close(); err != nil { + w.streamWriter = nil + return err + } w.streamWriter = nil - return err } - } else { - // Capture final status code and headers if not already captured - 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 - } + if forceLog { + return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog) } + return nil + } - // Ensure we have the latest headers before finalizing - w.ensureHeadersCaptured() + return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog) +} - // Use the captured headers as the final headers - finalHeaders := make(map[string][]string) - 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 - } +func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string { + w.ensureHeadersCaptured() - var apiRequestBody []byte - apiRequest, isExist := c.Get("API_REQUEST") - if isExist { - var ok bool - apiRequestBody, ok = apiRequest.([]byte) - if !ok { - apiRequestBody = nil - } - } + finalHeaders := make(map[string][]string, len(w.headers)) + for key, values := range w.headers { + headerValues := make([]string, len(values)) + copy(headerValues, values) + finalHeaders[key] = headerValues + } - var apiResponseBody []byte - apiResponse, isExist := c.Get("API_RESPONSE") - if isExist { - var ok bool - apiResponseBody, ok = apiResponse.([]byte) - if !ok { - apiResponseBody = nil - } - } + return finalHeaders +} - var slicesAPIResponseError []*interfaces.ErrorMessage - apiResponseError, isExist := c.Get("API_RESPONSE_ERROR") - if isExist { - var ok bool - slicesAPIResponseError, ok = apiResponseError.([]*interfaces.ErrorMessage) - if !ok { - slicesAPIResponseError = nil - } - } +func (w *ResponseWriterWrapper) extractAPIRequest(c *gin.Context) []byte { + apiRequest, isExist := c.Get("API_REQUEST") + if !isExist { + return nil + } + data, ok := apiRequest.([]byte) + if !ok || len(data) == 0 { + return nil + } + return data +} - // Log complete non-streaming response - return w.logger.LogRequest( +func (w *ResponseWriterWrapper) extractAPIResponse(c *gin.Context) []byte { + 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.Method, w.requestInfo.Headers, - w.requestInfo.Body, - finalStatusCode, - finalHeaders, - w.body.Bytes(), + requestBody, + statusCode, + headers, + body, apiRequestBody, 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. diff --git a/internal/api/server.go b/internal/api/server.go index 374e0a36..8e0de284 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -509,6 +509,8 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/logs", s.mgmt.GetLogs) 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.PUT("/request-log", s.mgmt.PutRequestLog) mgmt.PATCH("/request-log", s.mgmt.PutRequestLog) diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go index d47b3253..eb31bfa9 100644 --- a/internal/logging/request_logger.go +++ b/internal/logging/request_logger.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "time" @@ -156,17 +157,30 @@ func (l *FileRequestLogger) SetEnabled(enabled bool) { // Returns: // - 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 { - 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 } // Ensure logs directory exists - if err := l.ensureLogsDir(); err != nil { - return fmt.Errorf("failed to create logs directory: %w", err) + if errEnsure := l.ensureLogsDir(); errEnsure != nil { + return fmt.Errorf("failed to create logs directory: %w", errEnsure) } // Generate filename filename := l.generateFilename(url) + if force && !l.enabled { + filename = l.generateErrorFilename(url) + } filePath := filepath.Join(l.logsDir, filename) // 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) } + if force && !l.enabled { + if errCleanup := l.cleanupOldErrorLogs(); errCleanup != nil { + log.WithError(errCleanup).Warn("failed to clean up old error logs") + } + } + return nil } @@ -239,6 +259,11 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[ 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. // // Returns: @@ -312,6 +337,52 @@ func (l *FileRequestLogger) sanitizeForFilename(path string) string { 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. // // Parameters: