Improve Request Logging Efficiency and Standardize Error Responses
Unified · Split
| @@ -71,22 +71,64 @@ func (w *ResponseWriterWrapper) Write(data []byte) (int, error) { · 70 unmodified lines | ||
| 71 | 71 | n, err := w.ResponseWriter.Write(data) |
| 72 | 72 | |
| 73 | 73 | // THEN: Handle logging based on response type |
| 74 | − if w.isStreaming { | |
| 74 | + if w.isStreaming && w.chunkChannel != nil { | |
| 75 | 75 | // For streaming responses: Send to async logging channel (non-blocking) |
| 76 | − if w.chunkChannel != nil { | |
| 77 | − select { | |
| 78 | − case w.chunkChannel <- append([]byte(nil), data...): // Non-blocking send with copy | |
| 79 | − default: // Channel full, skip logging to avoid blocking | |
| 80 | − } | |
| 76 | + select { | |
| 77 | + case w.chunkChannel <- append([]byte(nil), data...): // Non-blocking send with copy | |
| 78 | + default: // Channel full, skip logging to avoid blocking | |
| 81 | 79 | } |
| 82 | − } else { | |
| 83 | − // For non-streaming responses: Buffer complete response | |
| 80 | + return n, err | |
| 81 | + } | |
| 82 | + | |
| 83 | + if w.shouldBufferResponseBody() { | |
| 84 | 84 | w.body.Write(data) |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | 87 | return n, err |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | +func (w *ResponseWriterWrapper) shouldBufferResponseBody() bool { | |
| 91 | + if w.logger != nil && w.logger.IsEnabled() { | |
| 92 | + return true | |
| 93 | + } | |
| 94 | + if !w.logOnErrorOnly { | |
| 95 | + return false | |
| 96 | + } | |
| 97 | + status := w.statusCode | |
| 98 | + if status == 0 { | |
| 99 | + if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok && statusWriter != nil { | |
| 100 | + status = statusWriter.Status() | |
| 101 | + } else { | |
| 102 | + status = http.StatusOK | |
| 103 | + } | |
| 104 | + } | |
| 105 | + return status >= http.StatusBadRequest | |
| 106 | +} | |
| 107 | + | |
| 108 | +// WriteString wraps the underlying ResponseWriter's WriteString method to capture response data. | |
| 109 | +// Some handlers (and fmt/io helpers) write via io.StringWriter; without this override, those writes | |
| 110 | +// bypass Write() and would be missing from request logs. | |
| 111 | +func (w *ResponseWriterWrapper) WriteString(data string) (int, error) { | |
| 112 | + w.ensureHeadersCaptured() | |
| 113 | + | |
| 114 | + // CRITICAL: Write to client first (zero latency) | |
| 115 | + n, err := w.ResponseWriter.WriteString(data) | |
| 116 | + | |
| 117 | + // THEN: Capture for logging | |
| 118 | + if w.isStreaming && w.chunkChannel != nil { | |
| 119 | + select { | |
| 120 | + case w.chunkChannel <- []byte(data): | |
| 121 | + default: | |
| 122 | + } | |
| 123 | + return n, err | |
| 124 | + } | |
| 125 | + | |
| 126 | + if w.shouldBufferResponseBody() { | |
| 127 | + w.body.WriteString(data) | |
| 128 | + } | |
| 129 | + return n, err | |
| 130 | +} | |
| 131 | + | |
| 90 | 132 | // WriteHeader wraps the underlying ResponseWriter's WriteHeader method. |
| 91 | 133 | // It captures the status code, detects if the response is streaming based on the Content-Type header, |
| 92 | 134 | // and initializes the appropriate logging mechanism (standard or streaming). |
| @@ -160,12 +202,16 @@ func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool { · 67 unmodified lines | ||
| 160 | 202 | return true |
| 161 | 203 | } |
| 162 | 204 | |
| 163 | − // Check request body for streaming indicators | |
| 164 | − if w.requestInfo.Body != nil { | |
| 205 | + // If a concrete Content-Type is already set (e.g., application/json for error responses), | |
| 206 | + // treat it as non-streaming instead of inferring from the request payload. | |
| 207 | + if strings.TrimSpace(contentType) != "" { | |
| 208 | + return false | |
| 209 | + } | |
| 210 | + | |
| 211 | + // Only fall back to request payload hints when Content-Type is not set yet. | |
| 212 | + if w.requestInfo != nil && len(w.requestInfo.Body) > 0 { | |
| 165 | 213 | bodyStr := string(w.requestInfo.Body) |
| 166 | − if strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`) { | |
| 167 | − return true | |
| 168 | − } | |
| 214 | + return strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`) | |
| 169 | 215 | } |
| 170 | 216 | |
| 171 | 217 | return false |
| @@ -221,7 +267,7 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error { · 49 unmodified lines | ||
| 221 | 267 | return nil |
| 222 | 268 | } |
| 223 | 269 | |
| 224 | − if w.isStreaming { | |
| 270 | + if w.isStreaming && w.streamWriter != nil { | |
| 225 | 271 | if w.chunkChannel != nil { |
| 226 | 272 | close(w.chunkChannel) |
| 227 | 273 | w.chunkChannel = nil |
| @@ -233,24 +279,19 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error { · 5 unmodified lines | ||
| 233 | 279 | } |
| 234 | 280 | |
| 235 | 281 | // Write API Request and Response to the streaming log before closing |
| 236 | − if w.streamWriter != nil { | |
| 237 | − apiRequest := w.extractAPIRequest(c) | |
| 238 | − if len(apiRequest) > 0 { | |
| 239 | − _ = w.streamWriter.WriteAPIRequest(apiRequest) | |
| 240 | − } | |
| 241 | − apiResponse := w.extractAPIResponse(c) | |
| 242 | − if len(apiResponse) > 0 { | |
| 243 | − _ = w.streamWriter.WriteAPIResponse(apiResponse) | |
| 244 | − } | |
| 245 | − if err := w.streamWriter.Close(); err != nil { | |
| 246 | − w.streamWriter = nil | |
| 247 | − return err | |
| 248 | − } | |
| 249 | − w.streamWriter = nil | |
| 282 | + apiRequest := w.extractAPIRequest(c) | |
| 283 | + if len(apiRequest) > 0 { | |
| 284 | + _ = w.streamWriter.WriteAPIRequest(apiRequest) | |
| 285 | + } | |
| 286 | + apiResponse := w.extractAPIResponse(c) | |
| 287 | + if len(apiResponse) > 0 { | |
| 288 | + _ = w.streamWriter.WriteAPIResponse(apiResponse) | |
| 250 | 289 | } |
| 251 | − if forceLog { | |
| 252 | − return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog) | |
| 290 | + if err := w.streamWriter.Close(); err != nil { | |
| 291 | + w.streamWriter = nil | |
| 292 | + return err | |
| 253 | 293 | } |
| 294 | + w.streamWriter = nil | |
| 254 | 295 | return nil |
| 255 | 296 | } |
| 256 | 297 | |
| @@ -335,26 +376,3 @@ func (w *ResponseWriterWrapper) logRequest(statusCode int, headers map[string][] · 78 unmodified lines | ||
| 335 | 376 | apiResponseErrors, |
| 336 | 377 | ) |
| 337 | 378 | } |
| 338 | − | |
| 339 | −// Status returns the HTTP response status code captured by the wrapper. | |
| 340 | −// It defaults to 200 if WriteHeader has not been called. | |
| 341 | −func (w *ResponseWriterWrapper) Status() int { | |
| 342 | − if w.statusCode == 0 { | |
| 343 | − return 200 // Default status code | |
| 344 | − } | |
| 345 | − return w.statusCode | |
| 346 | −} | |
| 347 | − | |
| 348 | −// Size returns the size of the response body in bytes for non-streaming responses. | |
| 349 | −// For streaming responses, it returns -1, as the total size is unknown. | |
| 350 | −func (w *ResponseWriterWrapper) Size() int { | |
| 351 | − if w.isStreaming { | |
| 352 | − return -1 // Unknown size for streaming responses | |
| 353 | − } | |
| 354 | − return w.body.Len() | |
| 355 | −} | |
| 356 | − | |
| 357 | −// Written returns true if the response header has been written (i.e., a status code has been set). | |
| 358 | −func (w *ResponseWriterWrapper) Written() bool { | |
| 359 | − return w.statusCode != 0 | |
| 360 | −} | |
| @@ -5,6 +5,7 @@ package handlers · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import ( |
| 7 | 7 | "bytes" |
| 8 | + "encoding/json" | |
| 8 | 9 | "fmt" |
| 9 | 10 | "net/http" |
| 10 | 11 | "strings" |
| @@ -117,6 +118,16 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c * · 106 unmodified lines | ||
| 117 | 118 | newCtx = context.WithValue(newCtx, "handler", handler) |
| 118 | 119 | return newCtx, func(params ...interface{}) { |
| 119 | 120 | if h.Cfg.RequestLog && len(params) == 1 { |
| 121 | + if existing, exists := c.Get("API_RESPONSE"); exists { | |
| 122 | + if existingBytes, ok := existing.([]byte); ok && len(bytes.TrimSpace(existingBytes)) > 0 { | |
| 123 | + switch params[0].(type) { | |
| 124 | + case error, string: | |
| 125 | + cancel() | |
| 126 | + return | |
| 127 | + } | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 120 | 131 | var payload []byte |
| 121 | 132 | switch data := params[0].(type) { |
| 122 | 133 | case []byte: |
| @@ -437,12 +448,53 @@ func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.Erro · 314 unmodified lines | ||
| 437 | 448 | } |
| 438 | 449 | } |
| 439 | 450 | } |
| 440 | − c.Status(status) | |
| 451 | + | |
| 452 | + errText := http.StatusText(status) | |
| 441 | 453 | if msg != nil && msg.Error != nil { |
| 442 | − _, _ = c.Writer.Write([]byte(msg.Error.Error())) | |
| 443 | − } else { | |
| 444 | − _, _ = c.Writer.Write([]byte(http.StatusText(status))) | |
| 454 | + if v := strings.TrimSpace(msg.Error.Error()); v != "" { | |
| 455 | + errText = v | |
| 456 | + } | |
| 457 | + } | |
| 458 | + | |
| 459 | + // Prefer preserving upstream JSON error bodies when possible. | |
| 460 | + buildJSONBody := func() []byte { | |
| 461 | + trimmed := strings.TrimSpace(errText) | |
| 462 | + if trimmed != "" && json.Valid([]byte(trimmed)) { | |
| 463 | + return []byte(trimmed) | |
| 464 | + } | |
| 465 | + errType := "invalid_request_error" | |
| 466 | + switch status { | |
| 467 | + case http.StatusUnauthorized: | |
| 468 | + errType = "authentication_error" | |
| 469 | + case http.StatusForbidden: | |
| 470 | + errType = "permission_error" | |
| 471 | + case http.StatusTooManyRequests: | |
| 472 | + errType = "rate_limit_error" | |
| 473 | + default: | |
| 474 | + if status >= http.StatusInternalServerError { | |
| 475 | + errType = "server_error" | |
| 476 | + } | |
| 477 | + } | |
| 478 | + payload, err := json.Marshal(ErrorResponse{ | |
| 479 | + Error: ErrorDetail{ | |
| 480 | + Message: errText, | |
| 481 | + Type: errType, | |
| 482 | + }, | |
| 483 | + }) | |
| 484 | + if err != nil { | |
| 485 | + return []byte(fmt.Sprintf(`{"error":{"message":%q,"type":"server_error"}}`, errText)) | |
| 486 | + } | |
| 487 | + return payload | |
| 445 | 488 | } |
| 489 | + | |
| 490 | + body := buildJSONBody() | |
| 491 | + c.Set("API_RESPONSE", bytes.Clone(body)) | |
| 492 | + | |
| 493 | + if !c.Writer.Written() { | |
| 494 | + c.Writer.Header().Set("Content-Type", "application/json") | |
| 495 | + } | |
| 496 | + c.Status(status) | |
| 497 | + _, _ = c.Writer.Write(body) | |
| 446 | 498 | } |
| 447 | 499 | |
| 448 | 500 | func (h *BaseAPIHandler) LoggingAPIResponseError(ctx context.Context, err *interfaces.ErrorMessage) { |