mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
1228 lines
36 KiB
Go
1228 lines
36 KiB
Go
// Package logging provides request logging functionality for the CLI Proxy API server.
|
|
// It handles capturing and storing detailed HTTP request and response data when enabled
|
|
// through configuration, supporting both regular and streaming responses.
|
|
package logging
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/klauspost/compress/zstd"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
)
|
|
|
|
var requestLogID atomic.Uint64
|
|
|
|
// RequestLogger defines the interface for logging HTTP requests and responses.
|
|
// It provides methods for logging both regular and streaming HTTP request/response cycles.
|
|
type RequestLogger interface {
|
|
// LogRequest logs a complete non-streaming request/response cycle.
|
|
//
|
|
// Parameters:
|
|
// - url: The request URL
|
|
// - method: The HTTP method
|
|
// - requestHeaders: The request headers
|
|
// - body: The request body
|
|
// - statusCode: The response status code
|
|
// - responseHeaders: The response headers
|
|
// - response: The raw response data
|
|
// - apiRequest: The API request data
|
|
// - apiResponse: The API response data
|
|
// - requestID: Optional request ID for log file naming
|
|
//
|
|
// Returns:
|
|
// - error: An error if logging fails, nil otherwise
|
|
LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage, requestID string) error
|
|
|
|
// LogStreamingRequest initiates logging for a streaming request and returns a writer for chunks.
|
|
//
|
|
// Parameters:
|
|
// - url: The request URL
|
|
// - method: The HTTP method
|
|
// - headers: The request headers
|
|
// - body: The request body
|
|
// - requestID: Optional request ID for log file naming
|
|
//
|
|
// Returns:
|
|
// - StreamingLogWriter: A writer for streaming response chunks
|
|
// - error: An error if logging initialization fails, nil otherwise
|
|
LogStreamingRequest(url, method string, headers map[string][]string, body []byte, requestID string) (StreamingLogWriter, error)
|
|
|
|
// IsEnabled returns whether request logging is currently enabled.
|
|
//
|
|
// Returns:
|
|
// - bool: True if logging is enabled, false otherwise
|
|
IsEnabled() bool
|
|
}
|
|
|
|
// StreamingLogWriter handles real-time logging of streaming response chunks.
|
|
// It provides methods for writing streaming response data asynchronously.
|
|
type StreamingLogWriter interface {
|
|
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
|
|
//
|
|
// Parameters:
|
|
// - chunk: The response chunk to write
|
|
WriteChunkAsync(chunk []byte)
|
|
|
|
// WriteStatus writes the response status and headers to the log.
|
|
//
|
|
// Parameters:
|
|
// - status: The response status code
|
|
// - headers: The response headers
|
|
//
|
|
// Returns:
|
|
// - error: An error if writing fails, nil otherwise
|
|
WriteStatus(status int, headers map[string][]string) error
|
|
|
|
// WriteAPIRequest writes the upstream API request details to the log.
|
|
// This should be called before WriteStatus to maintain proper log ordering.
|
|
//
|
|
// Parameters:
|
|
// - apiRequest: The API request data (typically includes URL, headers, body sent upstream)
|
|
//
|
|
// Returns:
|
|
// - error: An error if writing fails, nil otherwise
|
|
WriteAPIRequest(apiRequest []byte) error
|
|
|
|
// WriteAPIResponse writes the upstream API response details to the log.
|
|
// This should be called after the streaming response is complete.
|
|
//
|
|
// Parameters:
|
|
// - apiResponse: The API response data
|
|
//
|
|
// Returns:
|
|
// - error: An error if writing fails, nil otherwise
|
|
WriteAPIResponse(apiResponse []byte) error
|
|
|
|
// Close finalizes the log file and cleans up resources.
|
|
//
|
|
// Returns:
|
|
// - error: An error if closing fails, nil otherwise
|
|
Close() error
|
|
}
|
|
|
|
// FileRequestLogger implements RequestLogger using file-based storage.
|
|
// It provides file-based logging functionality for HTTP requests and responses.
|
|
type FileRequestLogger struct {
|
|
// enabled indicates whether request logging is currently enabled.
|
|
enabled bool
|
|
|
|
// logsDir is the directory where log files are stored.
|
|
logsDir string
|
|
}
|
|
|
|
// NewFileRequestLogger creates a new file-based request logger.
|
|
//
|
|
// Parameters:
|
|
// - enabled: Whether request logging should be enabled
|
|
// - logsDir: The directory where log files should be stored (can be relative)
|
|
// - configDir: The directory of the configuration file; when logsDir is
|
|
// relative, it will be resolved relative to this directory
|
|
//
|
|
// Returns:
|
|
// - *FileRequestLogger: A new file-based request logger instance
|
|
func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {
|
|
// Resolve logsDir relative to the configuration file directory when it's not absolute.
|
|
if !filepath.IsAbs(logsDir) {
|
|
// If configDir is provided, resolve logsDir relative to it.
|
|
if configDir != "" {
|
|
logsDir = filepath.Join(configDir, logsDir)
|
|
}
|
|
}
|
|
return &FileRequestLogger{
|
|
enabled: enabled,
|
|
logsDir: logsDir,
|
|
}
|
|
}
|
|
|
|
// IsEnabled returns whether request logging is currently enabled.
|
|
//
|
|
// Returns:
|
|
// - bool: True if logging is enabled, false otherwise
|
|
func (l *FileRequestLogger) IsEnabled() bool {
|
|
return l.enabled
|
|
}
|
|
|
|
// SetEnabled updates the request logging enabled state.
|
|
// This method allows dynamic enabling/disabling of request logging.
|
|
//
|
|
// Parameters:
|
|
// - enabled: Whether request logging should be enabled
|
|
func (l *FileRequestLogger) SetEnabled(enabled bool) {
|
|
l.enabled = enabled
|
|
}
|
|
|
|
// LogRequest logs a complete non-streaming request/response cycle to a file.
|
|
//
|
|
// Parameters:
|
|
// - url: The request URL
|
|
// - method: The HTTP method
|
|
// - requestHeaders: The request headers
|
|
// - body: The request body
|
|
// - statusCode: The response status code
|
|
// - responseHeaders: The response headers
|
|
// - response: The raw response data
|
|
// - apiRequest: The API request data
|
|
// - apiResponse: The API response data
|
|
// - requestID: Optional request ID for log file naming
|
|
//
|
|
// 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, requestID string) error {
|
|
return l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, apiRequest, apiResponse, apiResponseErrors, false, requestID)
|
|
}
|
|
|
|
// 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, requestID string) error {
|
|
return l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, apiRequest, apiResponse, apiResponseErrors, force, requestID)
|
|
}
|
|
|
|
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, requestID string) error {
|
|
if !l.enabled && !force {
|
|
return nil
|
|
}
|
|
|
|
// Ensure logs directory exists
|
|
if errEnsure := l.ensureLogsDir(); errEnsure != nil {
|
|
return fmt.Errorf("failed to create logs directory: %w", errEnsure)
|
|
}
|
|
|
|
// Generate filename with request ID
|
|
filename := l.generateFilename(url, requestID)
|
|
if force && !l.enabled {
|
|
filename = l.generateErrorFilename(url, requestID)
|
|
}
|
|
filePath := filepath.Join(l.logsDir, filename)
|
|
|
|
requestBodyPath, errTemp := l.writeRequestBodyTempFile(body)
|
|
if errTemp != nil {
|
|
log.WithError(errTemp).Warn("failed to create request body temp file, falling back to direct write")
|
|
}
|
|
if requestBodyPath != "" {
|
|
defer func() {
|
|
if errRemove := os.Remove(requestBodyPath); errRemove != nil {
|
|
log.WithError(errRemove).Warn("failed to remove request body temp file")
|
|
}
|
|
}()
|
|
}
|
|
|
|
responseToWrite, decompressErr := l.decompressResponse(responseHeaders, response)
|
|
if decompressErr != nil {
|
|
// If decompression fails, continue with original response and annotate the log output.
|
|
responseToWrite = response
|
|
}
|
|
|
|
logFile, errOpen := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
if errOpen != nil {
|
|
return fmt.Errorf("failed to create log file: %w", errOpen)
|
|
}
|
|
|
|
writeErr := l.writeNonStreamingLog(
|
|
logFile,
|
|
url,
|
|
method,
|
|
requestHeaders,
|
|
body,
|
|
requestBodyPath,
|
|
apiRequest,
|
|
apiResponse,
|
|
apiResponseErrors,
|
|
statusCode,
|
|
responseHeaders,
|
|
responseToWrite,
|
|
decompressErr,
|
|
)
|
|
if errClose := logFile.Close(); errClose != nil {
|
|
log.WithError(errClose).Warn("failed to close request log file")
|
|
if writeErr == nil {
|
|
return errClose
|
|
}
|
|
}
|
|
if writeErr != nil {
|
|
return fmt.Errorf("failed to write log file: %w", writeErr)
|
|
}
|
|
|
|
if force && !l.enabled {
|
|
if errCleanup := l.cleanupOldErrorLogs(); errCleanup != nil {
|
|
log.WithError(errCleanup).Warn("failed to clean up old error logs")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// LogStreamingRequest initiates logging for a streaming request.
|
|
//
|
|
// Parameters:
|
|
// - url: The request URL
|
|
// - method: The HTTP method
|
|
// - headers: The request headers
|
|
// - body: The request body
|
|
// - requestID: Optional request ID for log file naming
|
|
//
|
|
// Returns:
|
|
// - StreamingLogWriter: A writer for streaming response chunks
|
|
// - error: An error if logging initialization fails, nil otherwise
|
|
func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[string][]string, body []byte, requestID string) (StreamingLogWriter, error) {
|
|
if !l.enabled {
|
|
return &NoOpStreamingLogWriter{}, nil
|
|
}
|
|
|
|
// Ensure logs directory exists
|
|
if err := l.ensureLogsDir(); err != nil {
|
|
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
|
}
|
|
|
|
// Generate filename with request ID
|
|
filename := l.generateFilename(url, requestID)
|
|
filePath := filepath.Join(l.logsDir, filename)
|
|
|
|
requestHeaders := make(map[string][]string, len(headers))
|
|
for key, values := range headers {
|
|
headerValues := make([]string, len(values))
|
|
copy(headerValues, values)
|
|
requestHeaders[key] = headerValues
|
|
}
|
|
|
|
requestBodyPath, errTemp := l.writeRequestBodyTempFile(body)
|
|
if errTemp != nil {
|
|
return nil, fmt.Errorf("failed to create request body temp file: %w", errTemp)
|
|
}
|
|
|
|
responseBodyFile, errCreate := os.CreateTemp(l.logsDir, "response-body-*.tmp")
|
|
if errCreate != nil {
|
|
_ = os.Remove(requestBodyPath)
|
|
return nil, fmt.Errorf("failed to create response body temp file: %w", errCreate)
|
|
}
|
|
responseBodyPath := responseBodyFile.Name()
|
|
|
|
// Create streaming writer
|
|
writer := &FileStreamingLogWriter{
|
|
logFilePath: filePath,
|
|
url: url,
|
|
method: method,
|
|
timestamp: time.Now(),
|
|
requestHeaders: requestHeaders,
|
|
requestBodyPath: requestBodyPath,
|
|
responseBodyPath: responseBodyPath,
|
|
responseBodyFile: responseBodyFile,
|
|
chunkChan: make(chan []byte, 100), // Buffered channel for async writes
|
|
closeChan: make(chan struct{}),
|
|
errorChan: make(chan error, 1),
|
|
}
|
|
|
|
// Start async writer goroutine
|
|
go writer.asyncWriter()
|
|
|
|
return writer, nil
|
|
}
|
|
|
|
// generateErrorFilename creates a filename with an error prefix to differentiate forced error logs.
|
|
func (l *FileRequestLogger) generateErrorFilename(url string, requestID ...string) string {
|
|
return fmt.Sprintf("error-%s", l.generateFilename(url, requestID...))
|
|
}
|
|
|
|
// ensureLogsDir creates the logs directory if it doesn't exist.
|
|
//
|
|
// Returns:
|
|
// - error: An error if directory creation fails, nil otherwise
|
|
func (l *FileRequestLogger) ensureLogsDir() error {
|
|
if _, err := os.Stat(l.logsDir); os.IsNotExist(err) {
|
|
return os.MkdirAll(l.logsDir, 0755)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generateFilename creates a sanitized filename from the URL path and current timestamp.
|
|
// Format: v1-responses-2025-12-23T195811-a1b2c3d4.log
|
|
//
|
|
// Parameters:
|
|
// - url: The request URL
|
|
// - requestID: Optional request ID to include in filename
|
|
//
|
|
// Returns:
|
|
// - string: A sanitized filename for the log file
|
|
func (l *FileRequestLogger) generateFilename(url string, requestID ...string) string {
|
|
// Extract path from URL
|
|
path := url
|
|
if strings.Contains(url, "?") {
|
|
path = strings.Split(url, "?")[0]
|
|
}
|
|
|
|
// Remove leading slash
|
|
if strings.HasPrefix(path, "/") {
|
|
path = path[1:]
|
|
}
|
|
|
|
// Sanitize path for filename
|
|
sanitized := l.sanitizeForFilename(path)
|
|
|
|
// Add timestamp
|
|
timestamp := time.Now().Format("2006-01-02T150405")
|
|
|
|
// Use request ID if provided, otherwise use sequential ID
|
|
var idPart string
|
|
if len(requestID) > 0 && requestID[0] != "" {
|
|
idPart = requestID[0]
|
|
} else {
|
|
id := requestLogID.Add(1)
|
|
idPart = fmt.Sprintf("%d", id)
|
|
}
|
|
|
|
return fmt.Sprintf("%s-%s-%s.log", sanitized, timestamp, idPart)
|
|
}
|
|
|
|
// sanitizeForFilename replaces characters that are not safe for filenames.
|
|
//
|
|
// Parameters:
|
|
// - path: The path to sanitize
|
|
//
|
|
// Returns:
|
|
// - string: A sanitized filename
|
|
func (l *FileRequestLogger) sanitizeForFilename(path string) string {
|
|
// Replace slashes with hyphens
|
|
sanitized := strings.ReplaceAll(path, "/", "-")
|
|
|
|
// Replace colons with hyphens
|
|
sanitized = strings.ReplaceAll(sanitized, ":", "-")
|
|
|
|
// Replace other problematic characters with hyphens
|
|
reg := regexp.MustCompile(`[<>:"|?*\s]`)
|
|
sanitized = reg.ReplaceAllString(sanitized, "-")
|
|
|
|
// Remove multiple consecutive hyphens
|
|
reg = regexp.MustCompile(`-+`)
|
|
sanitized = reg.ReplaceAllString(sanitized, "-")
|
|
|
|
// Remove leading/trailing hyphens
|
|
sanitized = strings.Trim(sanitized, "-")
|
|
|
|
// Handle empty result
|
|
if sanitized == "" {
|
|
sanitized = "root"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (l *FileRequestLogger) writeRequestBodyTempFile(body []byte) (string, error) {
|
|
tmpFile, errCreate := os.CreateTemp(l.logsDir, "request-body-*.tmp")
|
|
if errCreate != nil {
|
|
return "", errCreate
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
|
|
if _, errCopy := io.Copy(tmpFile, bytes.NewReader(body)); errCopy != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return "", errCopy
|
|
}
|
|
if errClose := tmpFile.Close(); errClose != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return "", errClose
|
|
}
|
|
return tmpPath, nil
|
|
}
|
|
|
|
func (l *FileRequestLogger) writeNonStreamingLog(
|
|
w io.Writer,
|
|
url, method string,
|
|
requestHeaders map[string][]string,
|
|
requestBody []byte,
|
|
requestBodyPath string,
|
|
apiRequest []byte,
|
|
apiResponse []byte,
|
|
apiResponseErrors []*interfaces.ErrorMessage,
|
|
statusCode int,
|
|
responseHeaders map[string][]string,
|
|
response []byte,
|
|
decompressErr error,
|
|
) error {
|
|
if errWrite := writeRequestInfoWithBody(w, url, method, requestHeaders, requestBody, requestBodyPath, time.Now()); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if errWrite := writeAPISection(w, "=== API REQUEST ===\n", "=== API REQUEST", apiRequest); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if errWrite := writeAPIErrorResponses(w, apiResponseErrors); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if errWrite := writeAPISection(w, "=== API RESPONSE ===\n", "=== API RESPONSE", apiResponse); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
return writeResponseSection(w, statusCode, true, responseHeaders, bytes.NewReader(response), decompressErr, true)
|
|
}
|
|
|
|
func writeRequestInfoWithBody(
|
|
w io.Writer,
|
|
url, method string,
|
|
headers map[string][]string,
|
|
body []byte,
|
|
bodyPath string,
|
|
timestamp time.Time,
|
|
) error {
|
|
if _, errWrite := io.WriteString(w, "=== REQUEST INFO ===\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Version: %s\n", buildinfo.Version)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("URL: %s\n", url)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Method: %s\n", method)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Timestamp: %s\n", timestamp.Format(time.RFC3339Nano))); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
|
|
if _, errWrite := io.WriteString(w, "=== HEADERS ===\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
for key, values := range headers {
|
|
for _, value := range values {
|
|
masked := util.MaskSensitiveHeaderValue(key, value)
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("%s: %s\n", key, masked)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
}
|
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
|
|
if _, errWrite := io.WriteString(w, "=== REQUEST BODY ===\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
|
|
if bodyPath != "" {
|
|
bodyFile, errOpen := os.Open(bodyPath)
|
|
if errOpen != nil {
|
|
return errOpen
|
|
}
|
|
if _, errCopy := io.Copy(w, bodyFile); errCopy != nil {
|
|
_ = bodyFile.Close()
|
|
return errCopy
|
|
}
|
|
if errClose := bodyFile.Close(); errClose != nil {
|
|
log.WithError(errClose).Warn("failed to close request body temp file")
|
|
}
|
|
} else if _, errWrite := w.Write(body); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
|
|
if _, errWrite := io.WriteString(w, "\n\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeAPISection(w io.Writer, sectionHeader string, sectionPrefix string, payload []byte) error {
|
|
if len(payload) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if bytes.HasPrefix(payload, []byte(sectionPrefix)) {
|
|
if _, errWrite := w.Write(payload); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if !bytes.HasSuffix(payload, []byte("\n")) {
|
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
} else {
|
|
if _, errWrite := io.WriteString(w, sectionHeader); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := w.Write(payload); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
|
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeAPIErrorResponses(w io.Writer, apiResponseErrors []*interfaces.ErrorMessage) error {
|
|
for i := 0; i < len(apiResponseErrors); i++ {
|
|
if apiResponseErrors[i] == nil {
|
|
continue
|
|
}
|
|
if _, errWrite := io.WriteString(w, "=== API ERROR RESPONSE ===\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("HTTP Status: %d\n", apiResponseErrors[i].StatusCode)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if apiResponseErrors[i].Error != nil {
|
|
if _, errWrite := io.WriteString(w, apiResponseErrors[i].Error.Error()); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
if _, errWrite := io.WriteString(w, "\n\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeResponseSection(w io.Writer, statusCode int, statusWritten bool, responseHeaders map[string][]string, responseReader io.Reader, decompressErr error, trailingNewline bool) error {
|
|
if _, errWrite := io.WriteString(w, "=== RESPONSE ===\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if statusWritten {
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("Status: %d\n", statusCode)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
|
|
if responseHeaders != nil {
|
|
for key, values := range responseHeaders {
|
|
for _, value := range values {
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("%s: %s\n", key, value)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
|
|
if responseReader != nil {
|
|
if _, errCopy := io.Copy(w, responseReader); errCopy != nil {
|
|
return errCopy
|
|
}
|
|
}
|
|
if decompressErr != nil {
|
|
if _, errWrite := io.WriteString(w, fmt.Sprintf("\n[DECOMPRESSION ERROR: %v]", decompressErr)); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
|
|
if trailingNewline {
|
|
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// formatLogContent creates the complete log content for non-streaming requests.
|
|
//
|
|
// Parameters:
|
|
// - url: The request URL
|
|
// - method: The HTTP method
|
|
// - headers: The request headers
|
|
// - body: The request body
|
|
// - apiRequest: The API request data
|
|
// - apiResponse: The API response data
|
|
// - response: The raw response data
|
|
// - status: The response status code
|
|
// - responseHeaders: The response headers
|
|
//
|
|
// Returns:
|
|
// - string: The formatted log content
|
|
func (l *FileRequestLogger) formatLogContent(url, method string, headers map[string][]string, body, apiRequest, apiResponse, response []byte, status int, responseHeaders map[string][]string, apiResponseErrors []*interfaces.ErrorMessage) string {
|
|
var content strings.Builder
|
|
|
|
// Request info
|
|
content.WriteString(l.formatRequestInfo(url, method, headers, body))
|
|
|
|
if len(apiRequest) > 0 {
|
|
if bytes.HasPrefix(apiRequest, []byte("=== API REQUEST")) {
|
|
content.Write(apiRequest)
|
|
if !bytes.HasSuffix(apiRequest, []byte("\n")) {
|
|
content.WriteString("\n")
|
|
}
|
|
} else {
|
|
content.WriteString("=== API REQUEST ===\n")
|
|
content.Write(apiRequest)
|
|
content.WriteString("\n")
|
|
}
|
|
content.WriteString("\n")
|
|
}
|
|
|
|
for i := 0; i < len(apiResponseErrors); i++ {
|
|
content.WriteString("=== API ERROR RESPONSE ===\n")
|
|
content.WriteString(fmt.Sprintf("HTTP Status: %d\n", apiResponseErrors[i].StatusCode))
|
|
content.WriteString(apiResponseErrors[i].Error.Error())
|
|
content.WriteString("\n\n")
|
|
}
|
|
|
|
if len(apiResponse) > 0 {
|
|
if bytes.HasPrefix(apiResponse, []byte("=== API RESPONSE")) {
|
|
content.Write(apiResponse)
|
|
if !bytes.HasSuffix(apiResponse, []byte("\n")) {
|
|
content.WriteString("\n")
|
|
}
|
|
} else {
|
|
content.WriteString("=== API RESPONSE ===\n")
|
|
content.Write(apiResponse)
|
|
content.WriteString("\n")
|
|
}
|
|
content.WriteString("\n")
|
|
}
|
|
|
|
// Response section
|
|
content.WriteString("=== RESPONSE ===\n")
|
|
content.WriteString(fmt.Sprintf("Status: %d\n", status))
|
|
|
|
if responseHeaders != nil {
|
|
for key, values := range responseHeaders {
|
|
for _, value := range values {
|
|
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
|
|
}
|
|
}
|
|
}
|
|
|
|
content.WriteString("\n")
|
|
content.Write(response)
|
|
content.WriteString("\n")
|
|
|
|
return content.String()
|
|
}
|
|
|
|
// decompressResponse decompresses response data based on Content-Encoding header.
|
|
//
|
|
// Parameters:
|
|
// - responseHeaders: The response headers
|
|
// - response: The response data to decompress
|
|
//
|
|
// Returns:
|
|
// - []byte: The decompressed response data
|
|
// - error: An error if decompression fails, nil otherwise
|
|
func (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]string, response []byte) ([]byte, error) {
|
|
if responseHeaders == nil || len(response) == 0 {
|
|
return response, nil
|
|
}
|
|
|
|
// Check Content-Encoding header
|
|
var contentEncoding string
|
|
for key, values := range responseHeaders {
|
|
if strings.ToLower(key) == "content-encoding" && len(values) > 0 {
|
|
contentEncoding = strings.ToLower(values[0])
|
|
break
|
|
}
|
|
}
|
|
|
|
switch contentEncoding {
|
|
case "gzip":
|
|
return l.decompressGzip(response)
|
|
case "deflate":
|
|
return l.decompressDeflate(response)
|
|
case "br":
|
|
return l.decompressBrotli(response)
|
|
case "zstd":
|
|
return l.decompressZstd(response)
|
|
default:
|
|
// No compression or unsupported compression
|
|
return response, nil
|
|
}
|
|
}
|
|
|
|
// decompressGzip decompresses gzip-encoded data.
|
|
//
|
|
// Parameters:
|
|
// - data: The gzip-encoded data to decompress
|
|
//
|
|
// Returns:
|
|
// - []byte: The decompressed data
|
|
// - error: An error if decompression fails, nil otherwise
|
|
func (l *FileRequestLogger) decompressGzip(data []byte) ([]byte, error) {
|
|
reader, err := gzip.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
|
}
|
|
defer func() {
|
|
if errClose := reader.Close(); errClose != nil {
|
|
log.WithError(errClose).Warn("failed to close gzip reader in request logger")
|
|
}
|
|
}()
|
|
|
|
decompressed, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decompress gzip data: %w", err)
|
|
}
|
|
|
|
return decompressed, nil
|
|
}
|
|
|
|
// decompressDeflate decompresses deflate-encoded data.
|
|
//
|
|
// Parameters:
|
|
// - data: The deflate-encoded data to decompress
|
|
//
|
|
// Returns:
|
|
// - []byte: The decompressed data
|
|
// - error: An error if decompression fails, nil otherwise
|
|
func (l *FileRequestLogger) decompressDeflate(data []byte) ([]byte, error) {
|
|
reader := flate.NewReader(bytes.NewReader(data))
|
|
defer func() {
|
|
if errClose := reader.Close(); errClose != nil {
|
|
log.WithError(errClose).Warn("failed to close deflate reader in request logger")
|
|
}
|
|
}()
|
|
|
|
decompressed, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decompress deflate data: %w", err)
|
|
}
|
|
|
|
return decompressed, nil
|
|
}
|
|
|
|
// decompressBrotli decompresses brotli-encoded data.
|
|
//
|
|
// Parameters:
|
|
// - data: The brotli-encoded data to decompress
|
|
//
|
|
// Returns:
|
|
// - []byte: The decompressed data
|
|
// - error: An error if decompression fails, nil otherwise
|
|
func (l *FileRequestLogger) decompressBrotli(data []byte) ([]byte, error) {
|
|
reader := brotli.NewReader(bytes.NewReader(data))
|
|
|
|
decompressed, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decompress brotli data: %w", err)
|
|
}
|
|
|
|
return decompressed, nil
|
|
}
|
|
|
|
// decompressZstd decompresses zstd-encoded data.
|
|
//
|
|
// Parameters:
|
|
// - data: The zstd-encoded data to decompress
|
|
//
|
|
// Returns:
|
|
// - []byte: The decompressed data
|
|
// - error: An error if decompression fails, nil otherwise
|
|
func (l *FileRequestLogger) decompressZstd(data []byte) ([]byte, error) {
|
|
decoder, err := zstd.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create zstd reader: %w", err)
|
|
}
|
|
defer decoder.Close()
|
|
|
|
decompressed, err := io.ReadAll(decoder)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decompress zstd data: %w", err)
|
|
}
|
|
|
|
return decompressed, nil
|
|
}
|
|
|
|
// formatRequestInfo creates the request information section of the log.
|
|
//
|
|
// Parameters:
|
|
// - url: The request URL
|
|
// - method: The HTTP method
|
|
// - headers: The request headers
|
|
// - body: The request body
|
|
//
|
|
// Returns:
|
|
// - string: The formatted request information
|
|
func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[string][]string, body []byte) string {
|
|
var content strings.Builder
|
|
|
|
content.WriteString("=== REQUEST INFO ===\n")
|
|
content.WriteString(fmt.Sprintf("Version: %s\n", buildinfo.Version))
|
|
content.WriteString(fmt.Sprintf("URL: %s\n", url))
|
|
content.WriteString(fmt.Sprintf("Method: %s\n", method))
|
|
content.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
|
|
content.WriteString("\n")
|
|
|
|
content.WriteString("=== HEADERS ===\n")
|
|
for key, values := range headers {
|
|
for _, value := range values {
|
|
masked := util.MaskSensitiveHeaderValue(key, value)
|
|
content.WriteString(fmt.Sprintf("%s: %s\n", key, masked))
|
|
}
|
|
}
|
|
content.WriteString("\n")
|
|
|
|
content.WriteString("=== REQUEST BODY ===\n")
|
|
content.Write(body)
|
|
content.WriteString("\n\n")
|
|
|
|
return content.String()
|
|
}
|
|
|
|
// FileStreamingLogWriter implements StreamingLogWriter for file-based streaming logs.
|
|
// It spools streaming response chunks to a temporary file to avoid retaining large responses in memory.
|
|
// The final log file is assembled when Close is called.
|
|
type FileStreamingLogWriter struct {
|
|
// logFilePath is the final log file path.
|
|
logFilePath string
|
|
|
|
// url is the request URL (masked upstream in middleware).
|
|
url string
|
|
|
|
// method is the HTTP method.
|
|
method string
|
|
|
|
// timestamp is captured when the streaming log is initialized.
|
|
timestamp time.Time
|
|
|
|
// requestHeaders stores the request headers.
|
|
requestHeaders map[string][]string
|
|
|
|
// requestBodyPath is a temporary file path holding the request body.
|
|
requestBodyPath string
|
|
|
|
// responseBodyPath is a temporary file path holding the streaming response body.
|
|
responseBodyPath string
|
|
|
|
// responseBodyFile is the temp file where chunks are appended by the async writer.
|
|
responseBodyFile *os.File
|
|
|
|
// chunkChan is a channel for receiving response chunks to spool.
|
|
chunkChan chan []byte
|
|
|
|
// closeChan is a channel for signaling when the writer is closed.
|
|
closeChan chan struct{}
|
|
|
|
// errorChan is a channel for reporting errors during writing.
|
|
errorChan chan error
|
|
|
|
// responseStatus stores the HTTP status code.
|
|
responseStatus int
|
|
|
|
// statusWritten indicates whether a non-zero status was recorded.
|
|
statusWritten bool
|
|
|
|
// responseHeaders stores the response headers.
|
|
responseHeaders map[string][]string
|
|
|
|
// apiRequest stores the upstream API request data.
|
|
apiRequest []byte
|
|
|
|
// apiResponse stores the upstream API response data.
|
|
apiResponse []byte
|
|
}
|
|
|
|
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
|
|
//
|
|
// Parameters:
|
|
// - chunk: The response chunk to write
|
|
func (w *FileStreamingLogWriter) WriteChunkAsync(chunk []byte) {
|
|
if w.chunkChan == nil {
|
|
return
|
|
}
|
|
|
|
// Make a copy of the chunk to avoid data races
|
|
chunkCopy := make([]byte, len(chunk))
|
|
copy(chunkCopy, chunk)
|
|
|
|
// Non-blocking send
|
|
select {
|
|
case w.chunkChan <- chunkCopy:
|
|
default:
|
|
// Channel is full, skip this chunk to avoid blocking
|
|
}
|
|
}
|
|
|
|
// WriteStatus buffers the response status and headers for later writing.
|
|
//
|
|
// Parameters:
|
|
// - status: The response status code
|
|
// - headers: The response headers
|
|
//
|
|
// Returns:
|
|
// - error: Always returns nil (buffering cannot fail)
|
|
func (w *FileStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error {
|
|
if status == 0 {
|
|
return nil
|
|
}
|
|
|
|
w.responseStatus = status
|
|
if headers != nil {
|
|
w.responseHeaders = make(map[string][]string, len(headers))
|
|
for key, values := range headers {
|
|
headerValues := make([]string, len(values))
|
|
copy(headerValues, values)
|
|
w.responseHeaders[key] = headerValues
|
|
}
|
|
}
|
|
w.statusWritten = true
|
|
return nil
|
|
}
|
|
|
|
// WriteAPIRequest buffers the upstream API request details for later writing.
|
|
//
|
|
// Parameters:
|
|
// - apiRequest: The API request data (typically includes URL, headers, body sent upstream)
|
|
//
|
|
// Returns:
|
|
// - error: Always returns nil (buffering cannot fail)
|
|
func (w *FileStreamingLogWriter) WriteAPIRequest(apiRequest []byte) error {
|
|
if len(apiRequest) == 0 {
|
|
return nil
|
|
}
|
|
w.apiRequest = bytes.Clone(apiRequest)
|
|
return nil
|
|
}
|
|
|
|
// WriteAPIResponse buffers the upstream API response details for later writing.
|
|
//
|
|
// Parameters:
|
|
// - apiResponse: The API response data
|
|
//
|
|
// Returns:
|
|
// - error: Always returns nil (buffering cannot fail)
|
|
func (w *FileStreamingLogWriter) WriteAPIResponse(apiResponse []byte) error {
|
|
if len(apiResponse) == 0 {
|
|
return nil
|
|
}
|
|
w.apiResponse = bytes.Clone(apiResponse)
|
|
return nil
|
|
}
|
|
|
|
// Close finalizes the log file and cleans up resources.
|
|
// It writes all buffered data to the file in the correct order:
|
|
// API REQUEST -> API RESPONSE -> RESPONSE (status, headers, body chunks)
|
|
//
|
|
// Returns:
|
|
// - error: An error if closing fails, nil otherwise
|
|
func (w *FileStreamingLogWriter) Close() error {
|
|
if w.chunkChan != nil {
|
|
close(w.chunkChan)
|
|
}
|
|
|
|
// Wait for async writer to finish spooling chunks
|
|
if w.closeChan != nil {
|
|
<-w.closeChan
|
|
w.chunkChan = nil
|
|
}
|
|
|
|
select {
|
|
case errWrite := <-w.errorChan:
|
|
w.cleanupTempFiles()
|
|
return errWrite
|
|
default:
|
|
}
|
|
|
|
if w.logFilePath == "" {
|
|
w.cleanupTempFiles()
|
|
return nil
|
|
}
|
|
|
|
logFile, errOpen := os.OpenFile(w.logFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
if errOpen != nil {
|
|
w.cleanupTempFiles()
|
|
return fmt.Errorf("failed to create log file: %w", errOpen)
|
|
}
|
|
|
|
writeErr := w.writeFinalLog(logFile)
|
|
if errClose := logFile.Close(); errClose != nil {
|
|
log.WithError(errClose).Warn("failed to close request log file")
|
|
if writeErr == nil {
|
|
writeErr = errClose
|
|
}
|
|
}
|
|
|
|
w.cleanupTempFiles()
|
|
return writeErr
|
|
}
|
|
|
|
// asyncWriter runs in a goroutine to buffer chunks from the channel.
|
|
// It continuously reads chunks from the channel and appends them to a temp file for later assembly.
|
|
func (w *FileStreamingLogWriter) asyncWriter() {
|
|
defer close(w.closeChan)
|
|
|
|
for chunk := range w.chunkChan {
|
|
if w.responseBodyFile == nil {
|
|
continue
|
|
}
|
|
if _, errWrite := w.responseBodyFile.Write(chunk); errWrite != nil {
|
|
select {
|
|
case w.errorChan <- errWrite:
|
|
default:
|
|
}
|
|
if errClose := w.responseBodyFile.Close(); errClose != nil {
|
|
select {
|
|
case w.errorChan <- errClose:
|
|
default:
|
|
}
|
|
}
|
|
w.responseBodyFile = nil
|
|
}
|
|
}
|
|
|
|
if w.responseBodyFile == nil {
|
|
return
|
|
}
|
|
if errClose := w.responseBodyFile.Close(); errClose != nil {
|
|
select {
|
|
case w.errorChan <- errClose:
|
|
default:
|
|
}
|
|
}
|
|
w.responseBodyFile = nil
|
|
}
|
|
|
|
func (w *FileStreamingLogWriter) writeFinalLog(logFile *os.File) error {
|
|
if errWrite := writeRequestInfoWithBody(logFile, w.url, w.method, w.requestHeaders, nil, w.requestBodyPath, w.timestamp); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if errWrite := writeAPISection(logFile, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if errWrite := writeAPISection(logFile, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
|
|
responseBodyFile, errOpen := os.Open(w.responseBodyPath)
|
|
if errOpen != nil {
|
|
return errOpen
|
|
}
|
|
defer func() {
|
|
if errClose := responseBodyFile.Close(); errClose != nil {
|
|
log.WithError(errClose).Warn("failed to close response body temp file")
|
|
}
|
|
}()
|
|
|
|
return writeResponseSection(logFile, w.responseStatus, w.statusWritten, w.responseHeaders, responseBodyFile, nil, false)
|
|
}
|
|
|
|
func (w *FileStreamingLogWriter) cleanupTempFiles() {
|
|
if w.requestBodyPath != "" {
|
|
if errRemove := os.Remove(w.requestBodyPath); errRemove != nil {
|
|
log.WithError(errRemove).Warn("failed to remove request body temp file")
|
|
}
|
|
w.requestBodyPath = ""
|
|
}
|
|
|
|
if w.responseBodyPath != "" {
|
|
if errRemove := os.Remove(w.responseBodyPath); errRemove != nil {
|
|
log.WithError(errRemove).Warn("failed to remove response body temp file")
|
|
}
|
|
w.responseBodyPath = ""
|
|
}
|
|
}
|
|
|
|
// NoOpStreamingLogWriter is a no-operation implementation for when logging is disabled.
|
|
// It implements the StreamingLogWriter interface but performs no actual logging operations.
|
|
type NoOpStreamingLogWriter struct{}
|
|
|
|
// WriteChunkAsync is a no-op implementation that does nothing.
|
|
//
|
|
// Parameters:
|
|
// - chunk: The response chunk (ignored)
|
|
func (w *NoOpStreamingLogWriter) WriteChunkAsync(_ []byte) {}
|
|
|
|
// WriteStatus is a no-op implementation that does nothing and always returns nil.
|
|
//
|
|
// Parameters:
|
|
// - status: The response status code (ignored)
|
|
// - headers: The response headers (ignored)
|
|
//
|
|
// Returns:
|
|
// - error: Always returns nil
|
|
func (w *NoOpStreamingLogWriter) WriteStatus(_ int, _ map[string][]string) error {
|
|
return nil
|
|
}
|
|
|
|
// WriteAPIRequest is a no-op implementation that does nothing and always returns nil.
|
|
//
|
|
// Parameters:
|
|
// - apiRequest: The API request data (ignored)
|
|
//
|
|
// Returns:
|
|
// - error: Always returns nil
|
|
func (w *NoOpStreamingLogWriter) WriteAPIRequest(_ []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// WriteAPIResponse is a no-op implementation that does nothing and always returns nil.
|
|
//
|
|
// Parameters:
|
|
// - apiResponse: The API response data (ignored)
|
|
//
|
|
// Returns:
|
|
// - error: Always returns nil
|
|
func (w *NoOpStreamingLogWriter) WriteAPIResponse(_ []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// Close is a no-op implementation that does nothing and always returns nil.
|
|
//
|
|
// Returns:
|
|
// - error: Always returns nil
|
|
func (w *NoOpStreamingLogWriter) Close() error { return nil }
|