Add recent API request recorder with admin tab and /api/recent endpoint
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:
2026-08-23 14:52:33 +08:00
parent f988c47fec
commit 22d78c4576
4 changed files with 411 additions and 4 deletions
+174
View File
@@ -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(),
})
}
+148
View File
@@ -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))
}
}
+6 -4
View File
@@ -33,10 +33,11 @@ type Server struct {
loginSession string loginSession string
st *store.Store st *store.Store
statsEnabled bool statsEnabled bool
recorder *RecentRecorder
} }
func New(cfg config.Config, st *store.Store) http.Handler { 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 != "" { if cfg.LoginPassword != "" {
s.loginSession = randomSessionToken() 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("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats)) s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats)) 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("GET /api/models", s.withLoginSession(s.getModels))
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel)) s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) s.mux.HandleFunc("GET /v1/models", s.withRecording(s.withAPIKey(s.models)))
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions)))
s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses)) s.mux.HandleFunc("POST /v1/responses", s.withRecording(s.withAPIKey(s.responses)))
} }
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
+83
View File
@@ -101,6 +101,16 @@
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}} @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{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} .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}
</style> </style>
</head> </head>
<body> <body>
@@ -116,6 +126,7 @@
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button> <button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button> <button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
<button class="tab" role="tab" data-tab="recent" aria-selected="false">最近请求</button>
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button> <button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
</div> </div>
@@ -191,6 +202,17 @@
</div> </div>
</section> </section>
<section class="tabpanel" data-tab="recent" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="recent-refresh" type="button">刷新</button>
</div>
<div class="panel">
<h2>最近 API 请求</h2>
<p class="muted" style="margin:0 0 16px">记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。</p>
<div id="recent-list"></div>
</div>
</section>
<section class="tabpanel" data-tab="login" role="tabpanel"> <section class="tabpanel" data-tab="login" role="tabpanel">
<div class="login-card"> <div class="login-card">
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p> <p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
@@ -225,6 +247,7 @@
}); });
var hash = location.hash.replace('#', ''); var hash = location.hash.replace('#', '');
if (hash === 'models') activate('models'); if (hash === 'models') activate('models');
else if (hash === 'recent') activate('recent');
else if (hash === 'login') activate('login'); else if (hash === 'login') activate('login');
var rows = document.querySelectorAll('#daily .bar'); var rows = document.querySelectorAll('#daily .bar');
@@ -521,6 +544,66 @@
if (modelsTab) modelsTab.addEventListener('click', loadIfActive); if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
loadIfActive(); loadIfActive();
})(); })();
(function () {
var recentList = document.getElementById('recent-list');
if (!recentList) return;
var loaded = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function formatTime(ts) {
var d = new Date(ts);
return d.toLocaleString('zh-CN', { hour12: false });
}
async function loadRecent() {
recentList.innerHTML = '<p class="muted">加载中…</p>';
try {
var res = await fetch('/api/recent');
var data = await res.json();
if (!data.ok || !data.requests || data.requests.length === 0) {
recentList.innerHTML = '<p class="empty">暂无数据</p>';
return;
}
var html = '';
for (var i = 0; i < data.requests.length; i++) {
var r = data.requests[i];
var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err';
var headers = r.request_headers || {};
var headerStr = Object.keys(headers).map(function (k) {
return k + ': ' + headers[k];
}).join('\n');
html += '<div class="recent-card">' +
'<div class="recent-head">' +
'<span class="recent-time">' + escapeHtml(formatTime(r.timestamp)) + '</span>' +
'<span class="recent-method">' + escapeHtml(r.method) + '</span>' +
'<span class="recent-path">' + escapeHtml(r.path) + '</span>' +
'<span class="badge ' + statusClass + '">' + r.status + '</span>' +
'<span class="recent-duration">' + r.duration_ms + 'ms</span>' +
'</div>' +
(headerStr ? '<details><summary>请求头</summary><pre>' + escapeHtml(headerStr) + '</pre></details>' : '') +
'<details><summary>请求体</summary><pre>' + escapeHtml(r.request_body || '(空)') + '</pre></details>' +
'<details><summary>响应体</summary><pre>' + escapeHtml(r.response_body || '(空)') + '</pre></details>' +
'</div>';
}
recentList.innerHTML = html;
} catch (e) {
recentList.innerHTML = '<p class="empty">加载失败: ' + escapeHtml(e.message) + '</p>';
}
}
var recentTab = document.querySelector('.tab[data-tab="recent"]');
if (recentTab) recentTab.addEventListener('click', function () {
if (!loaded) { loaded = true; loadRecent(); }
});
var refreshBtn = document.getElementById('recent-refresh');
if (refreshBtn) refreshBtn.addEventListener('click', loadRecent);
// auto-load if the tab is active on page load
var panel = document.querySelector('.tabpanel[data-tab="recent"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); }
})();
</script> </script>
</body> </body>
</html> </html>