Add recent API request recorder with admin tab and /api/recent endpoint
build / build (push) Successful in 2m31s
build / build (push) Successful in 2m31s
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)
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user