429 lines
13 KiB
Go
429 lines
13 KiB
Go
package monitor
|
|
|
|
import (
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gctx"
|
|
)
|
|
|
|
const (
|
|
maxActiveRequestsInResponse = 200
|
|
|
|
// 采样周期与保留窗口:后台采样器固定 5s 采一次,环形缓冲保留 120 个点 = 10 分钟。
|
|
samplerInterval = 5 * time.Second
|
|
historyCapacity = 120
|
|
historyWindow = time.Duration(historyCapacity) * samplerInterval
|
|
|
|
// 悬挂条目清扫阈值:普通 relay 请求受 HTTP 超时约束,异步任务由轮询器 30min 超时兜底。
|
|
// 超过该时长仍留在 map 中的一定是漏调 Unregister(如任务被另一实例的轮询器终结)。
|
|
staleRequestTTL = 30 * time.Minute
|
|
staleTaskTTL = 60 * time.Minute
|
|
)
|
|
|
|
// TrackedRequest represents a single active relay request.
|
|
type TrackedRequest struct {
|
|
RequestID string `json:"request_id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
UserID int64 `json:"user_id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
ModelName string `json:"model_name"`
|
|
ChannelID int64 `json:"channel_id"`
|
|
ChannelName string `json:"channel_name"`
|
|
IsStream bool `json:"is_stream"`
|
|
StartTime time.Time `json:"start_time"`
|
|
Path string `json:"path"`
|
|
IsAsyncTask bool `json:"is_async_task"` // 异步任务(视频/音乐/图像生成)
|
|
TaskID string `json:"task_id,omitempty"`
|
|
}
|
|
|
|
// ConcurrencySnapshot is a point-in-time concurrency view.
|
|
type ConcurrencySnapshot struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
TotalActive int `json:"total_active"`
|
|
StreamingActive int `json:"streaming_active"`
|
|
NonStreamingActive int `json:"non_streaming_active"`
|
|
}
|
|
|
|
// BandwidthSnapshot is a point-in-time bandwidth view.
|
|
type BandwidthSnapshot struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
BytesInPerSec float64 `json:"bytes_in_per_sec"`
|
|
BytesOutPerSec float64 `json:"bytes_out_per_sec"`
|
|
}
|
|
|
|
// RealtimeData is the combined response for /monitor/realtime.
|
|
type RealtimeData struct {
|
|
Concurrency ConcurrencySnapshot `json:"concurrency"`
|
|
Bandwidth BandwidthSnapshot `json:"bandwidth"`
|
|
Runtime RuntimeMetrics `json:"runtime"`
|
|
GoRuntime GoRuntimeInfo `json:"go_runtime"`
|
|
History []ConcurrencySnapshot `json:"history"`
|
|
BandwidthHistory []BandwidthSnapshot `json:"bandwidth_history"`
|
|
ActiveRequests []TrackedRequest `json:"active_requests"`
|
|
ByModel map[string]int `json:"by_model"`
|
|
ByChannel map[string]int `json:"by_channel"`
|
|
ByTenant map[int64]int `json:"by_tenant"`
|
|
SyncImagePool SyncImagePoolSnapshot `json:"sync_image_pool"`
|
|
Rpm int64 `json:"rpm"` // 最近60秒滑动窗口请求数(全平台,由 Realtime 方法填充)
|
|
Tpm int64 `json:"tpm"` // 最近60秒滑动窗口token数(全平台,由 Realtime 方法填充)
|
|
}
|
|
|
|
// snapshotRing is a fixed-size circular buffer for arbitrary snapshot types.
|
|
type snapshotRing[T any] struct {
|
|
items []T
|
|
cap int
|
|
head int
|
|
size int
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func newSnapshotRing[T any](capacity int) *snapshotRing[T] {
|
|
return &snapshotRing[T]{
|
|
items: make([]T, capacity),
|
|
cap: capacity,
|
|
}
|
|
}
|
|
|
|
func (r *snapshotRing[T]) Push(v T) {
|
|
r.mu.Lock()
|
|
r.items[r.head] = v
|
|
r.head = (r.head + 1) % r.cap
|
|
if r.size < r.cap {
|
|
r.size++
|
|
}
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
func (r *snapshotRing[T]) All() []T {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
if r.size == 0 {
|
|
return nil
|
|
}
|
|
result := make([]T, r.size)
|
|
for i := 0; i < r.size; i++ {
|
|
idx := (r.head - r.size + i + r.cap) % r.cap
|
|
result[i] = r.items[idx]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Last 返回最新一个样本(无样本时 ok=false)。
|
|
func (r *snapshotRing[T]) Last() (T, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
var zero T
|
|
if r.size == 0 {
|
|
return zero, false
|
|
}
|
|
return r.items[(r.head-1+r.cap)%r.cap], true
|
|
}
|
|
|
|
// filterRecent 裁掉早于 cutoff 的样本。环形缓冲按时间升序返回,命中首个在窗口内的点即可整体切片。
|
|
// 采样器正常运行时不会有过期点,此处是采样器停摆(如进程挂起)后的兜底,
|
|
// 避免把陈旧曲线当成当前流量画出来。
|
|
func filterRecent[T any](items []T, cutoff time.Time, at func(T) time.Time) []T {
|
|
for i := range items {
|
|
if !at(items[i]).Before(cutoff) {
|
|
return items[i:]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// requestTracker is the global singleton for tracking active requests.
|
|
type requestTracker struct {
|
|
mu sync.RWMutex
|
|
activeRequests map[string]*TrackedRequest
|
|
|
|
totalActive atomic.Int64
|
|
streamingActive atomic.Int64
|
|
|
|
bytesIn atomic.Int64
|
|
bytesOut atomic.Int64
|
|
bandwidthMu sync.Mutex
|
|
lastBytesIn int64
|
|
lastBytesOut int64
|
|
lastBwTime time.Time
|
|
|
|
concHistory *snapshotRing[ConcurrencySnapshot]
|
|
bwHistory *snapshotRing[BandwidthSnapshot]
|
|
}
|
|
|
|
var (
|
|
tracker *requestTracker
|
|
trackerStop chan struct{}
|
|
)
|
|
|
|
// InitRequestTracker initializes the global request tracker and starts its sampler.
|
|
//
|
|
// 采样必须由后台定时器驱动、与管理页轮询解耦:早期实现只在 /monitor/realtime 被调用时
|
|
// 才往环形缓冲写点,页面关闭期间既不采样也不过期,再次打开时读到的仍是上一次会话遗留的
|
|
// 旧曲线(前端 x 轴只显示 mm:ss,看起来就像"刚刚还有并发/带宽")。
|
|
func InitRequestTracker() {
|
|
StopRequestTracker()
|
|
|
|
t := &requestTracker{
|
|
activeRequests: make(map[string]*TrackedRequest),
|
|
concHistory: newSnapshotRing[ConcurrencySnapshot](historyCapacity),
|
|
bwHistory: newSnapshotRing[BandwidthSnapshot](historyCapacity),
|
|
}
|
|
// 带宽基线置为启动时刻,避免首个样本把「进程启动至今累计字节」算成瞬时速率
|
|
t.lastBwTime = time.Now()
|
|
|
|
tracker = t
|
|
trackerStop = make(chan struct{})
|
|
go t.runSampler(trackerStop)
|
|
}
|
|
|
|
// StopRequestTracker stops the background sampler (idempotent).
|
|
func StopRequestTracker() {
|
|
if trackerStop != nil {
|
|
close(trackerStop)
|
|
trackerStop = nil
|
|
}
|
|
}
|
|
|
|
func (t *requestTracker) runSampler(stop chan struct{}) {
|
|
ticker := time.NewTicker(samplerInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case now := <-ticker.C:
|
|
t.sample(now)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sample 采一次并发 + 带宽快照。带宽差值只在这里计算(固定 5s 间隔),
|
|
// 不再由 HTTP 轮询触发——否则多个管理页同时打开会互相偷走增量,
|
|
// 两次轮询间隔极短时还会算出「字节数 / 几毫秒」的假尖峰。
|
|
func (t *requestTracker) sample(now time.Time) {
|
|
total, streaming := t.sweepStale(now)
|
|
t.concHistory.Push(ConcurrencySnapshot{
|
|
Timestamp: now,
|
|
TotalActive: total,
|
|
StreamingActive: streaming,
|
|
NonStreamingActive: total - streaming,
|
|
})
|
|
t.bwHistory.Push(t.snapshotBandwidth(now))
|
|
}
|
|
|
|
// sweepStale 清扫超时仍未反注册的悬挂条目,并以 map 为唯一真相重算计数。
|
|
// 漏减场景确实存在:异步任务由轮询器终结,多实例部署下终结它的实例未必是当初受理提交的实例,
|
|
// 受理实例内存里的条目就再也没人删——表现为并发数永远回不到 0。
|
|
func (t *requestTracker) sweepStale(now time.Time) (total, streaming int) {
|
|
var swept int
|
|
|
|
t.mu.Lock()
|
|
for key, req := range t.activeRequests {
|
|
ttl := staleRequestTTL
|
|
if req.IsAsyncTask {
|
|
ttl = staleTaskTTL
|
|
}
|
|
if now.Sub(req.StartTime) > ttl {
|
|
delete(t.activeRequests, key)
|
|
swept++
|
|
continue
|
|
}
|
|
total++
|
|
if req.IsStream {
|
|
streaming++
|
|
}
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
// 计数以 map 为准回写,顺带修正任何漏减/多减造成的漂移
|
|
t.totalActive.Store(int64(total))
|
|
t.streamingActive.Store(int64(streaming))
|
|
|
|
if swept > 0 {
|
|
g.Log().Warningf(gctx.New(), "monitor: 清扫 %d 条悬挂的活跃请求记录(注册后未反注册)", swept)
|
|
}
|
|
return total, streaming
|
|
}
|
|
|
|
// RegisterRequest adds a new request to the tracker.
|
|
func RegisterRequest(req *TrackedRequest) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
tracker.activeRequests[req.RequestID] = req
|
|
tracker.totalActive.Add(1)
|
|
if req.IsStream {
|
|
tracker.streamingActive.Add(1)
|
|
}
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// UnregisterRequest removes a completed request.
|
|
func UnregisterRequest(requestID string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
if req, ok := tracker.activeRequests[requestID]; ok {
|
|
delete(tracker.activeRequests, requestID)
|
|
tracker.totalActive.Add(-1)
|
|
if req.IsStream {
|
|
tracker.streamingActive.Add(-1)
|
|
}
|
|
}
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// GetTrackedRequest returns the tracked request by ID (for in-place updates like channel info).
|
|
func GetTrackedRequest(requestID string) *TrackedRequest {
|
|
if tracker == nil {
|
|
return nil
|
|
}
|
|
tracker.mu.RLock()
|
|
defer tracker.mu.RUnlock()
|
|
return tracker.activeRequests[requestID]
|
|
}
|
|
|
|
// SwitchToTaskID replaces the request ID key with a task ID for long-lived async tasks.
|
|
// The tracked request keeps its position in the active list but is now keyed by taskID.
|
|
func SwitchToTaskID(requestID, taskID string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
if req, ok := tracker.activeRequests[requestID]; ok {
|
|
delete(tracker.activeRequests, requestID)
|
|
req.TaskID = taskID
|
|
tracker.activeRequests[taskID] = req
|
|
}
|
|
}
|
|
|
|
// UnregisterRequestByTaskID removes a completed async task by its task ID.
|
|
func UnregisterRequestByTaskID(taskID string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
if req, ok := tracker.activeRequests[taskID]; ok {
|
|
delete(tracker.activeRequests, taskID)
|
|
tracker.totalActive.Add(-1)
|
|
if req.IsStream {
|
|
tracker.streamingActive.Add(-1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RecordBytesIn atomically accumulates inbound bytes.
|
|
func RecordBytesIn(n int) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.bytesIn.Add(int64(n))
|
|
}
|
|
|
|
// RecordBytesOut atomically accumulates outbound bytes.
|
|
func RecordBytesOut(n int) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.bytesOut.Add(int64(n))
|
|
}
|
|
|
|
// GetRealtimeData builds the combined realtime snapshot.
|
|
// 纯读:历史点与带宽速率均由后台采样器产出,此处不再推进任何采样状态,
|
|
// 因此多个管理页同时打开互不干扰,页面不打开时曲线也照常滚动过期。
|
|
func GetRealtimeData() *RealtimeData {
|
|
if tracker == nil {
|
|
return &RealtimeData{}
|
|
}
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-historyWindow)
|
|
|
|
// 当前并发:取实时计数(比最近一个样本更新)
|
|
total := int(tracker.totalActive.Load())
|
|
streaming := int(tracker.streamingActive.Load())
|
|
concSnap := ConcurrencySnapshot{
|
|
Timestamp: now,
|
|
TotalActive: total,
|
|
StreamingActive: streaming,
|
|
NonStreamingActive: total - streaming,
|
|
}
|
|
|
|
// 当前带宽:取最近一个样本;样本过旧(采样器停摆)则视为 0,不展示陈旧速率
|
|
bwSnap := BandwidthSnapshot{Timestamp: now}
|
|
if last, ok := tracker.bwHistory.Last(); ok && !last.Timestamp.Before(now.Add(-2*samplerInterval)) {
|
|
bwSnap = last
|
|
}
|
|
|
|
// Build breakdowns
|
|
tracker.mu.RLock()
|
|
byModel := make(map[string]int, len(tracker.activeRequests))
|
|
byChannel := make(map[string]int, len(tracker.activeRequests))
|
|
byTenant := make(map[int64]int, len(tracker.activeRequests))
|
|
|
|
activeReqs := make([]TrackedRequest, 0, min(len(tracker.activeRequests), maxActiveRequestsInResponse))
|
|
for _, req := range tracker.activeRequests {
|
|
byModel[req.ModelName]++
|
|
if req.ChannelName != "" {
|
|
byChannel[req.ChannelName]++
|
|
}
|
|
byTenant[req.TenantID]++
|
|
activeReqs = append(activeReqs, *req)
|
|
}
|
|
tracker.mu.RUnlock()
|
|
|
|
// Runtime from existing collector
|
|
var rt RuntimeMetrics
|
|
if snap := GetLatestMetrics(); snap != nil {
|
|
rt = snap.Runtime
|
|
}
|
|
|
|
return &RealtimeData{
|
|
Concurrency: concSnap,
|
|
Bandwidth: bwSnap,
|
|
Runtime: rt,
|
|
GoRuntime: GetGoRuntimeInfo(),
|
|
History: filterRecent(tracker.concHistory.All(), cutoff,
|
|
func(s ConcurrencySnapshot) time.Time { return s.Timestamp }),
|
|
BandwidthHistory: filterRecent(tracker.bwHistory.All(), cutoff,
|
|
func(s BandwidthSnapshot) time.Time { return s.Timestamp }),
|
|
ActiveRequests: activeReqs,
|
|
ByModel: byModel,
|
|
ByChannel: byChannel,
|
|
ByTenant: byTenant,
|
|
SyncImagePool: GetSyncImagePoolSnapshot(),
|
|
}
|
|
}
|
|
|
|
func (t *requestTracker) snapshotBandwidth(now time.Time) BandwidthSnapshot {
|
|
t.bandwidthMu.Lock()
|
|
defer t.bandwidthMu.Unlock()
|
|
|
|
currentIn := t.bytesIn.Load()
|
|
currentOut := t.bytesOut.Load()
|
|
|
|
snap := BandwidthSnapshot{Timestamp: now}
|
|
|
|
if !t.lastBwTime.IsZero() && !now.After(t.lastBwTime) {
|
|
return snap
|
|
}
|
|
if !t.lastBwTime.IsZero() {
|
|
elapsed := now.Sub(t.lastBwTime).Seconds()
|
|
snap.BytesInPerSec = float64(currentIn-t.lastBytesIn) / elapsed
|
|
snap.BytesOutPerSec = float64(currentOut-t.lastBytesOut) / elapsed
|
|
}
|
|
|
|
t.lastBytesIn = currentIn
|
|
t.lastBytesOut = currentOut
|
|
t.lastBwTime = now
|
|
|
|
return snap
|
|
}
|