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)
175 lines
5.0 KiB
Go
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(),
|
|
})
|
|
}
|