feat: add telescope

This commit is contained in:
2026-07-05 08:42:42 +03:30
parent c6997477b9
commit 37baef565e
9 changed files with 530 additions and 8 deletions
+188
View File
@@ -0,0 +1,188 @@
// Package telescope یک بازرسِ سبکِ درخواست‌ها است (شبیهِ Laravel Telescope).
// آخرین N درخواست را در یک رینگ‌بافرِ حافظه نگه می‌دارد: متد، مسیر، وضعیت،
// مدت‌زمان، IP و در صورتِ خطا (>=۴۰۰) بدنه‌ی درخواست. بدونِ هیچ وابستگیِ خارجی
// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کم‌منابع مناسب است.
package telescope
import (
"bufio"
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Entry یک درخواستِ ثبت‌شده را نگه می‌دارد.
type Entry struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
Duration int64 `json:"duration_ms"`
IP string `json:"ip"`
Bytes int `json:"bytes"`
Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT
}
// Telescope رینگ‌بافرِ هم‌زمان‌امنِ درخواست‌ها.
type Telescope struct {
mu sync.Mutex
buf []Entry
size int
next int64 // شناسه‌ی افزایشی
full bool
head int // اندیسِ نوشتنِ بعدی
}
// New یک بازرس با ظرفیتِ size می‌سازد (حداقل ۱).
func New(size int) *Telescope {
if size < 1 {
size = 200
}
return &Telescope{buf: make([]Entry, size), size: size}
}
func (t *Telescope) add(e Entry) {
t.mu.Lock()
t.next++
e.ID = t.next
t.buf[t.head] = e
t.head = (t.head + 1) % t.size
if t.head == 0 {
t.full = true
}
t.mu.Unlock()
}
// Entries آخرین درخواست‌ها را به‌ترتیبِ جدید-به-قدیم برمی‌گرداند.
func (t *Telescope) Entries() []Entry {
t.mu.Lock()
defer t.mu.Unlock()
n := t.head
if t.full {
n = t.size
}
out := make([]Entry, 0, n)
// از جدیدترین (درست قبلِ head) عقب می‌رویم.
for i := 0; i < n; i++ {
idx := (t.head - 1 - i + t.size*2) % t.size
out = append(out, t.buf[idx])
}
return out
}
// statusRecorder کدِ وضعیت و حجمِ پاسخ را می‌گیرد.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
// Hijack برای WebSocket لازم است تا میدل‌ور مانعِ ارتقاء نشود.
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, http.ErrNotSupported
}
// Middleware هر درخواستِ /api و /admin را ثبت می‌کند (به‌جز خودِ صفحه‌ی تلسکوپ).
func (t *Telescope) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
p := req.URL.Path
if p == "/ws" || strings.HasPrefix(p, "/admin/telescope") ||
strings.HasPrefix(p, "/cards/") || strings.HasPrefix(p, "/carpets/") {
next.ServeHTTP(w, req)
return
}
var body string
if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
req.Body = io.NopCloser(bytes.NewReader(b))
body = maskSecrets(string(b))
}
rec := &statusRecorder{ResponseWriter: w}
start := time.Now()
next.ServeHTTP(rec, req)
dur := time.Since(start)
e := Entry{
Time: start,
Method: req.Method,
Path: p,
Status: rec.status,
Duration: dur.Milliseconds(),
IP: req.RemoteAddr,
Bytes: rec.bytes,
}
// بدنه را فقط برای خطاها نگه می‌داریم تا حافظه/حریمِ خصوصی حفظ شود.
if rec.status >= 400 {
e.Body = body
}
t.add(e)
})
}
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنه‌ی JSON پنهان می‌کند.
func maskSecrets(s string) string {
for _, k := range []string{"password", "token", "code", "otp", "signature"} {
s = maskField(s, k)
}
return s
}
func maskField(s, key string) string {
needle := `"` + key + `"`
i := strings.Index(s, needle)
if i < 0 {
return s
}
// دنبالِ ": " و سپس مقدار می‌گردیم.
j := strings.Index(s[i:], ":")
if j < 0 {
return s
}
rest := s[i+j+1:]
rest = strings.TrimLeft(rest, " ")
if strings.HasPrefix(rest, `"`) {
end := strings.Index(rest[1:], `"`)
if end >= 0 {
masked := s[:i+j+1] + `"***"` + rest[end+2:]
return masked
}
}
return s
}
// Data آخرین درخواست‌ها را به‌صورتِ JSON برمی‌گرداند.
func (t *Telescope) Data(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(t.Entries())
}
// Page یک صفحه‌ی HTML سبک برای مرورِ درخواست‌ها می‌دهد.
func (t *Telescope) Page(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, pageHTML)
}