chore: merge backend repo history into monorepo at backend/

git-subtree-dir: backend
git-subtree-mainline: dc11190527
git-subtree-split: acae976622
This commit is contained in:
2026-09-27 10:30:16 +03:30
67 changed files with 6835 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
// Package monitoring یک ذخیره‌ساز سبک و درون‌حافظه‌ای برای دیدبانی درخواست‌هاست:
// آخرین درخواست‌ها (برای دیباگ لحظه‌ای) + آمار تجمیعی (بار، زمان پاسخ، نرخ خطا).
// عمداً بدون وابستگی بیرونی (بدون Prometheus/Redis) چون در این مقیاس یک حلقه‌ی
// درون‌حافظه‌ای thread-safe کافیه؛ اگه بعداً مقیاس بزرگ‌تر شد، همین Store رو می‌شه
// پشت یک exporter واقعی گذاشت.
package monitoring
import (
"sort"
"sync"
"time"
)
const bufferSize = 1000
type RequestRecord struct {
Time time.Time `json:"time"`
Method string `json:"method"`
Path string `json:"path"`
StatusCode int `json:"status_code"`
DurationMs float64 `json:"duration_ms"`
ClientIP string `json:"client_ip"`
UserID *uint `json:"user_id,omitempty"`
}
type Summary struct {
TotalRequests int64 `json:"total_requests"`
TotalErrors int64 `json:"total_errors"`
ErrorRatePct float64 `json:"error_rate_pct"`
AvgLatencyMs float64 `json:"avg_latency_ms"`
P95LatencyMs float64 `json:"p95_latency_ms"`
RequestsLastMin int `json:"requests_last_min"`
StartedAt time.Time `json:"started_at"`
UptimeSeconds float64 `json:"uptime_seconds"`
}
// Store یک حلقه‌ی ثابت‌اندازه (ring buffer) از آخرین درخواست‌ها به‌همراه
// شمارنده‌های تجمیعیه. نوشتن/خواندن با RWMutex محافظت می‌شه؛ هزینه‌ی هر
// درخواست فقط یک قفل کوتاه و بدون I/O روی دیسکه (سریع، مناسب مسیر داغ).
type Store struct {
mu sync.RWMutex
buf []RequestRecord
next int
count int
total int64
totalErr int64
sumLatency float64
startedAt time.Time
}
func NewStore() *Store {
return &Store{
buf: make([]RequestRecord, bufferSize),
startedAt: time.Now(),
}
}
func (s *Store) Record(rec RequestRecord) {
s.mu.Lock()
defer s.mu.Unlock()
s.buf[s.next] = rec
s.next = (s.next + 1) % bufferSize
if s.count < bufferSize {
s.count++
}
s.total++
s.sumLatency += rec.DurationMs
if rec.StatusCode >= 400 {
s.totalErr++
}
}
// Recent آخرین n درخواست رو برمی‌گردونه (جدیدترین اول).
func (s *Store) Recent(n int) []RequestRecord {
s.mu.RLock()
defer s.mu.RUnlock()
if n > s.count {
n = s.count
}
out := make([]RequestRecord, 0, n)
idx := s.next - 1
for i := 0; i < n; i++ {
if idx < 0 {
idx = bufferSize - 1
}
out = append(out, s.buf[idx])
idx--
}
return out
}
// RecentByUser مثل Recent ولی فقط درخواست‌های یک کاربر مشخص رو برمی‌گردونه
// (برای دیدن فعالیت اخیر یک کاربر خاص از صفحه‌ی جزئیاتش توی پنل ادمین).
// کل بافر رو می‌گرده (نه فقط آخرین n)، چون فیلتر شده و ممکنه درخواست‌های آن
// کاربر پراکنده باشن.
func (s *Store) RecentByUser(userID uint, n int) []RequestRecord {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]RequestRecord, 0, n)
idx := s.next - 1
for i := 0; i < s.count && len(out) < n; i++ {
if idx < 0 {
idx = bufferSize - 1
}
rec := s.buf[idx]
if rec.UserID != nil && *rec.UserID == userID {
out = append(out, rec)
}
idx--
}
return out
}
func (s *Store) Summary() Summary {
s.mu.RLock()
defer s.mu.RUnlock()
var errRate, avgLatency float64
if s.total > 0 {
errRate = float64(s.totalErr) / float64(s.total) * 100
avgLatency = s.sumLatency / float64(s.total)
}
cutoff := time.Now().Add(-1 * time.Minute)
lastMin := 0
latencies := make([]float64, 0, s.count)
for i := 0; i < s.count; i++ {
r := s.buf[i]
if r.Time.After(cutoff) {
lastMin++
}
latencies = append(latencies, r.DurationMs)
}
sort.Float64s(latencies)
p95 := 0.0
if len(latencies) > 0 {
idx := int(float64(len(latencies)) * 0.95)
if idx >= len(latencies) {
idx = len(latencies) - 1
}
p95 = latencies[idx]
}
return Summary{
TotalRequests: s.total,
TotalErrors: s.totalErr,
ErrorRatePct: errRate,
AvgLatencyMs: avgLatency,
P95LatencyMs: p95,
RequestsLastMin: lastMin,
StartedAt: s.startedAt,
UptimeSeconds: time.Since(s.startedAt).Seconds(),
}
}
+93
View File
@@ -0,0 +1,93 @@
package monitoring
import (
"testing"
"time"
)
func TestStore_RecordAndSummary(t *testing.T) {
s := NewStore()
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 200, DurationMs: 10})
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 500, DurationMs: 20})
sum := s.Summary()
if sum.TotalRequests != 2 {
t.Fatalf("expected 2 total requests, got %d", sum.TotalRequests)
}
if sum.TotalErrors != 1 {
t.Fatalf("expected 1 error (status>=400), got %d", sum.TotalErrors)
}
if sum.ErrorRatePct != 50 {
t.Fatalf("expected 50%% error rate, got %v", sum.ErrorRatePct)
}
if sum.AvgLatencyMs != 15 {
t.Fatalf("expected avg latency 15ms, got %v", sum.AvgLatencyMs)
}
}
func TestStore_RecentOrder(t *testing.T) {
s := NewStore()
s.Record(RequestRecord{Time: time.Now(), Path: "/1", StatusCode: 200})
s.Record(RequestRecord{Time: time.Now(), Path: "/2", StatusCode: 200})
s.Record(RequestRecord{Time: time.Now(), Path: "/3", StatusCode: 200})
recent := s.Recent(10)
if len(recent) != 3 {
t.Fatalf("expected 3 records, got %d", len(recent))
}
// جدیدترین باید اول باشه
if recent[0].Path != "/3" || recent[1].Path != "/2" || recent[2].Path != "/1" {
t.Fatalf("expected newest-first order, got %+v", recent)
}
}
func TestStore_RecentLimit(t *testing.T) {
s := NewStore()
for i := 0; i < 5; i++ {
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200})
}
if got := len(s.Recent(2)); got != 2 {
t.Fatalf("expected limit of 2, got %d", got)
}
if got := len(s.Recent(100)); got != 5 {
t.Fatalf("expected all 5 when limit exceeds count, got %d", got)
}
}
func TestStore_RingBufferWraparound(t *testing.T) {
s := NewStore()
// بیشتر از ظرفیت بافر ثبت می‌کنیم تا رفتار wraparound رو تست کنیم
for i := 0; i < bufferSize+50; i++ {
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200, DurationMs: 1})
}
sum := s.Summary()
if sum.TotalRequests != int64(bufferSize+50) {
t.Fatalf("expected total requests to keep counting past buffer size, got %d", sum.TotalRequests)
}
recent := s.Recent(bufferSize + 100)
if len(recent) != bufferSize {
t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent))
}
}
func TestStore_RecentByUser(t *testing.T) {
s := NewStore()
uidA := uint(1)
uidB := uint(2)
s.Record(RequestRecord{Time: time.Now(), Path: "/a1", UserID: &uidA})
s.Record(RequestRecord{Time: time.Now(), Path: "/b1", UserID: &uidB})
s.Record(RequestRecord{Time: time.Now(), Path: "/a2", UserID: &uidA})
s.Record(RequestRecord{Time: time.Now(), Path: "/none"}) // بدون کاربر (مثل OTP)
got := s.RecentByUser(uidA, 10)
if len(got) != 2 {
t.Fatalf("expected 2 records for user A, got %d", len(got))
}
if got[0].Path != "/a2" || got[1].Path != "/a1" {
t.Fatalf("expected newest-first order for user A, got %+v", got)
}
}