Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
413
backend/internal/logging/request_logger_writer.go
Normal file
413
backend/internal/logging/request_logger_writer.go
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var requestLogID atomic.Uint64
|
||||
|
||||
// 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
|
||||
// - requestTimestamp: When the request was received
|
||||
// - apiResponseTimestamp: When the API response was received
|
||||
//
|
||||
// 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, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiResponseErrors []*interfaces.ErrorMessage, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
|
||||
return l.logRequest(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline, apiResponseErrors, false, requestID, requestTimestamp, apiResponseTimestamp)
|
||||
}
|
||||
|
||||
// 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, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
|
||||
return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, nil, apiResponse, nil, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
|
||||
}
|
||||
|
||||
func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
|
||||
return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, nil, apiRequest, nil, apiResponse, nil, apiWebsocketTimeline, nil, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
|
||||
}
|
||||
|
||||
// LogRequestWithOptionsAndSources logs a request with optional file-backed large sections.
|
||||
func (l *FileRequestLogger) LogRequestWithOptionsAndSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest, apiResponse, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
|
||||
return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, websocketTimelineSource, apiRequest, nil, apiResponse, nil, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
|
||||
}
|
||||
|
||||
// LogRequestWithOptionsAndAllSources logs a request with optional file-backed request and response sections.
|
||||
func (l *FileRequestLogger) LogRequestWithOptionsAndAllSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest []byte, apiRequestSource *FileBodySource, apiResponse []byte, apiResponseSource *FileBodySource, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
|
||||
return l.logRequestWithSources(url, method, requestHeaders, body, statusCode, responseHeaders, response, websocketTimeline, websocketTimelineSource, apiRequest, apiRequestSource, apiResponse, apiResponseSource, apiWebsocketTimeline, apiWebsocketTimelineSource, apiResponseErrors, force, requestID, requestTimestamp, apiResponseTimestamp)
|
||||
}
|
||||
|
||||
func (l *FileRequestLogger) logRequestWithSources(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, websocketTimeline []byte, websocketTimelineSource *FileBodySource, apiRequest []byte, apiRequestSource *FileBodySource, apiResponse []byte, apiResponseSource *FileBodySource, apiWebsocketTimeline []byte, apiWebsocketTimelineSource *FileBodySource, apiResponseErrors []*interfaces.ErrorMessage, force bool, requestID string, requestTimestamp, apiResponseTimestamp time.Time) error {
|
||||
defer cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource, apiWebsocketTimelineSource)
|
||||
|
||||
if !l.enabled && !force {
|
||||
return nil
|
||||
}
|
||||
|
||||
if l.homeEnabled && l.enabled {
|
||||
responseToWrite, decompressErr := l.decompressResponse(responseHeaders, response)
|
||||
if decompressErr != nil {
|
||||
responseToWrite = response
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeErr := l.writeNonStreamingLog(
|
||||
&buf,
|
||||
url,
|
||||
method,
|
||||
requestHeaders,
|
||||
body,
|
||||
"",
|
||||
websocketTimeline,
|
||||
websocketTimelineSource,
|
||||
apiRequest,
|
||||
apiRequestSource,
|
||||
apiResponse,
|
||||
apiResponseSource,
|
||||
apiWebsocketTimeline,
|
||||
apiWebsocketTimelineSource,
|
||||
apiResponseErrors,
|
||||
statusCode,
|
||||
responseHeaders,
|
||||
responseToWrite,
|
||||
decompressErr,
|
||||
requestTimestamp,
|
||||
apiResponseTimestamp,
|
||||
)
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("failed to build request log content: %w", writeErr)
|
||||
}
|
||||
return l.forwardRequestLogToHome(context.Background(), requestHeaders, requestID, buf.String())
|
||||
}
|
||||
|
||||
// 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,
|
||||
websocketTimeline,
|
||||
websocketTimelineSource,
|
||||
apiRequest,
|
||||
apiRequestSource,
|
||||
apiResponse,
|
||||
apiResponseSource,
|
||||
apiWebsocketTimeline,
|
||||
apiWebsocketTimelineSource,
|
||||
apiResponseErrors,
|
||||
statusCode,
|
||||
responseHeaders,
|
||||
responseToWrite,
|
||||
decompressErr,
|
||||
requestTimestamp,
|
||||
apiResponseTimestamp,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
if l.homeEnabled {
|
||||
client := currentHomeRequestLogClient()
|
||||
if client == nil || !client.HeartbeatOK() {
|
||||
return &NoOpStreamingLogWriter{}, nil
|
||||
}
|
||||
return newHomeStreamingLogWriter(url, method, headers, body, requestID), 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 errorLogsMaxFiles forced error log files.
|
||||
func (l *FileRequestLogger) cleanupOldErrorLogs() error {
|
||||
if l.errorLogsMaxFiles <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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) <= l.errorLogsMaxFiles {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].modTime.After(files[j].modTime)
|
||||
})
|
||||
|
||||
for _, file := range files[l.errorLogsMaxFiles:] {
|
||||
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
|
||||
}
|
||||
Loading…
Reference in a new issue