Files
zhanlu_proxy/internal/server/recorder.go
T
m1saka 22d78c4576
build / build (push) Successful in 2m31s
Add recent API request recorder with admin tab and /api/recent endpoint
Record the last 10 /v1/ API requests (including errors) in an in-memory
ring buffer. Each entry captures request headers (Authorization masked),
request body, response status, and response body — all up to 1 MB.

- recorder.go: RecentRecorder ring buffer, recordingResponseWriter,
  withRecording middleware, getRecent handler
- server.go: add recorder to Server, wrap /v1/ routes, add /api/recent
- admin.html: new 最近请求 tab with lazy-load and expandable cards
- recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
2026-08-23 14:52:33 +08:00

175 lines
5.0 KiB
Go

package server
import (
"bytes"
"fmt"
"io"
"net/http"
"sync"
"time"
)
const (
maxRecentEntries = 10
maxBodyCapture = 1 << 20 // 1 MB per body — full request/response capture for debugging
)
// RecentEntry captures a single API request and its response for debugging.
type RecentEntry struct {
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
DurationMs int64 `json:"duration_ms"`
RequestHeaders map[string]string `json:"request_headers"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
}
// RecentRecorder is an in-memory ring buffer that stores the most recent API
// requests. It is safe for concurrent use.
type RecentRecorder struct {
mu sync.Mutex
entries []RecentEntry
}
func NewRecentRecorder() *RecentRecorder {
return &RecentRecorder{}
}
// Record appends an entry, evicting the oldest when the buffer is full.
func (r *RecentRecorder) Record(e RecentEntry) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, e)
if len(r.entries) > maxRecentEntries {
r.entries = r.entries[len(r.entries)-maxRecentEntries:]
}
}
// Entries returns a copy of the buffer in newest-first order.
func (r *RecentRecorder) Entries() []RecentEntry {
r.mu.Lock()
defer r.mu.Unlock()
n := len(r.entries)
out := make([]RecentEntry, n)
for i, e := range r.entries {
out[n-1-i] = e // reverse: newest first
}
return out
}
// recordingResponseWriter wraps http.ResponseWriter to capture the status code
// and a truncated copy of the response body. It implements http.Flusher so
// streaming handlers can flush through the wrapper.
type recordingResponseWriter struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
totalBytes int
}
func newRecordingResponseWriter(w http.ResponseWriter) *recordingResponseWriter {
return &recordingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
}
func (w *recordingResponseWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
func (w *recordingResponseWriter) Write(b []byte) (int, error) {
w.totalBytes += len(b)
if w.body.Len() < maxBodyCapture {
remaining := maxBodyCapture - w.body.Len()
if len(b) <= remaining {
w.body.Write(b)
} else {
w.body.Write(b[:remaining])
}
}
return w.ResponseWriter.Write(b)
}
func (w *recordingResponseWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// captureRequestHeaders extracts selected request headers, masking the
// Authorization value to avoid leaking API keys.
func captureRequestHeaders(r *http.Request) map[string]string {
headers := map[string]string{}
for _, key := range []string{"Content-Type", "User-Agent", "Accept", "Authorization"} {
if v := r.Header.Get(key); v != "" {
if key == "Authorization" {
headers[key] = mask(v)
} else {
headers[key] = v
}
}
}
return headers
}
// truncateBody returns the string form of b, truncated to maxBodyCapture
// bytes with a marker if the original was longer.
func truncateBody(b []byte) string {
if len(b) > maxBodyCapture {
return string(b[:maxBodyCapture]) + fmt.Sprintf("\n...(truncated, total %d bytes)", len(b))
}
return string(b)
}
// formatResponseBody returns the captured response body, with a truncation
// marker if the full response exceeded the capture limit.
func formatResponseBody(buf *bytes.Buffer, totalBytes int) string {
s := buf.String()
if totalBytes > maxBodyCapture {
s += fmt.Sprintf("\n...(truncated, total %d bytes)", totalBytes)
}
return s
}
// withRecording wraps a handler so that each request's headers, body,
// response status, and response body (truncated) are captured into the
// server's RecentRecorder. It is applied to the /v1/ API routes so that
// both successful and error responses are recorded.
func (s *Server) withRecording(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Read and restore the request body so the downstream handler
// still sees the full content.
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(reqBody))
}
recW := newRecordingResponseWriter(w)
next(recW, r)
s.recorder.Record(RecentEntry{
Timestamp: start,
Method: r.Method,
Path: r.URL.Path,
Status: recW.statusCode,
DurationMs: time.Since(start).Milliseconds(),
RequestHeaders: captureRequestHeaders(r),
RequestBody: truncateBody(reqBody),
ResponseBody: formatResponseBody(&recW.body, recW.totalBytes),
})
}
}
// getRecent handles GET /api/recent — returns the most recent API requests
// as JSON, newest first.
func (s *Server) getRecent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"requests": s.recorder.Entries(),
})
}