From 22d78c4576da3e120ae3122746edcf87b0ee0317 Mon Sep 17 00:00:00 2001 From: m1saka Date: Sun, 23 Aug 2026 14:52:33 +0800 Subject: [PATCH] Add recent API request recorder with admin tab and /api/recent endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/server/recorder.go | 174 +++++++++++++++++++++++++++ internal/server/recorder_test.go | 148 +++++++++++++++++++++++ internal/server/server.go | 10 +- internal/server/templates/admin.html | 83 +++++++++++++ 4 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 internal/server/recorder.go create mode 100644 internal/server/recorder_test.go diff --git a/internal/server/recorder.go b/internal/server/recorder.go new file mode 100644 index 0000000..1b22fb8 --- /dev/null +++ b/internal/server/recorder.go @@ -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(), + }) +} diff --git a/internal/server/recorder_test.go b/internal/server/recorder_test.go new file mode 100644 index 0000000..064ade2 --- /dev/null +++ b/internal/server/recorder_test.go @@ -0,0 +1,148 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestRecentRecorderRingBuffer verifies that the recorder keeps at most +// maxRecentEntries and evicts the oldest when full. +func TestRecentRecorderRingBuffer(t *testing.T) { + rec := NewRecentRecorder() + for i := 0; i < maxRecentEntries+5; i++ { + rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200}) + } + entries := rec.Entries() + if len(entries) != maxRecentEntries { + t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries) + } +} + +// TestRecentRecorderNewestFirst verifies entries are returned newest-first. +func TestRecentRecorderNewestFirst(t *testing.T) { + rec := NewRecentRecorder() + rec.Record(RecentEntry{Path: "/first"}) + rec.Record(RecentEntry{Path: "/second"}) + rec.Record(RecentEntry{Path: "/third"}) + entries := rec.Entries() + if len(entries) != 3 { + t.Fatalf("got %d entries", len(entries)) + } + if entries[0].Path != "/third" { + t.Fatalf("first entry = %q, want /third", entries[0].Path) + } + if entries[2].Path != "/first" { + t.Fatalf("last entry = %q, want /first", entries[2].Path) + } +} + +// TestWithRecordingCapturesRequestResponse verifies the middleware captures +// request headers, request body, response status, and response body. +func TestWithRecordingCapturesRequestResponse(t *testing.T) { + rec := NewRecentRecorder() + s := &Server{recorder: rec} + + handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"result":"ok"}`)) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer sk-secret-key-12345") + req.Header.Set("User-Agent", "test-client/1.0") + w := httptest.NewRecorder() + handler(w, req) + + entries := rec.Entries() + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + e := entries[0] + if e.Method != "POST" { + t.Fatalf("method = %q", e.Method) + } + if e.Path != "/v1/chat/completions" { + t.Fatalf("path = %q", e.Path) + } + if e.Status != 200 { + t.Fatalf("status = %d", e.Status) + } + if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` { + t.Fatalf("request body = %q", e.RequestBody) + } + if e.ResponseBody != `{"result":"ok"}` { + t.Fatalf("response body = %q", e.ResponseBody) + } + // Authorization must be masked + auth, ok := e.RequestHeaders["Authorization"] + if !ok || !strings.Contains(auth, "****") { + t.Fatalf("authorization not masked: %q", auth) + } + if strings.Contains(auth, "sk-secret-key-12345") { + t.Fatal("authorization leaked raw key") + } + if e.RequestHeaders["Content-Type"] != "application/json" { + t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"]) + } + if e.RequestHeaders["User-Agent"] != "test-client/1.0" { + t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"]) + } +} + +// TestWithRecordingCapturesErrors verifies error responses are recorded. +func TestWithRecordingCapturesErrors(t *testing.T) { + rec := NewRecentRecorder() + s := &Server{recorder: rec} + + handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) { + writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key") + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`)) + req.Header.Set("Authorization", "Bearer wrong-key") + w := httptest.NewRecorder() + handler(w, req) + + entries := rec.Entries() + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + e := entries[0] + if e.Status != 401 { + t.Fatalf("status = %d, want 401", e.Status) + } + if !strings.Contains(e.ResponseBody, "invalid api key") { + t.Fatalf("response body = %q", e.ResponseBody) + } +} + +// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON. +func TestGetRecentAPI(t *testing.T) { + rec := NewRecentRecorder() + rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200}) + rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500}) + s := &Server{recorder: rec} + + req := httptest.NewRequest(http.MethodGet, "/api/recent", nil) + w := httptest.NewRecorder() + s.getRecent(w, req) + + body, _ := io.ReadAll(w.Body) + if !strings.Contains(string(body), `"ok":true`) { + t.Fatalf("response missing ok:true: %s", string(body)) + } + if !strings.Contains(string(body), "/v1/responses") { + t.Fatalf("response missing /v1/responses: %s", string(body)) + } + // newest first + idxResponses := strings.Index(string(body), "/v1/responses") + idxChat := strings.Index(string(body), "/v1/chat/completions") + if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat { + t.Fatalf("entries not newest-first: %s", string(body)) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ba214b7..c1462f3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -33,10 +33,11 @@ type Server struct { loginSession string st *store.Store statsEnabled bool + recorder *RecentRecorder } func New(cfg config.Config, st *store.Store) http.Handler { - s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled} + s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled, recorder: NewRecentRecorder()} if cfg.LoginPassword != "" { s.loginSession = randomSessionToken() } @@ -62,11 +63,12 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode)) s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats)) s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats)) + s.mux.HandleFunc("GET /api/recent", s.withLoginSession(s.getRecent)) s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels)) s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel)) - s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) - s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) - s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses)) + s.mux.HandleFunc("GET /v1/models", s.withRecording(s.withAPIKey(s.models))) + s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions))) + s.mux.HandleFunc("POST /v1/responses", s.withRecording(s.withAPIKey(s.responses))) } func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/templates/admin.html b/internal/server/templates/admin.html index 551688f..1d81e35 100644 --- a/internal/server/templates/admin.html +++ b/internal/server/templates/admin.html @@ -101,6 +101,16 @@ @media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}} .footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7} .footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all} + .recent-card{border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;background:var(--bg)} + .recent-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px} + .recent-time{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums} + .recent-method{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:999px} + .recent-path{font-size:13px;color:var(--ink);font-family:"SF Mono",ui-monospace,Consolas,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .recent-duration{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums} + .recent-card details{margin-top:6px} + .recent-card summary{cursor:pointer;font-size:12.5px;font-weight:600;color:var(--body);padding:4px 0;user-select:none} + .recent-card summary:hover{color:var(--accent)} + .recent-card pre{background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:8px;font-size:12px;overflow-x:auto;max-height:400px;overflow-y:auto;font-family:"SF Mono",ui-monospace,Consolas,monospace;line-height:1.5;margin:8px 0 0;white-space:pre-wrap;word-break:break-all} @@ -116,6 +126,7 @@
+
@@ -191,6 +202,17 @@ +
+
+ +
+
+

最近 API 请求

+

记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。

+
+
+
+