sub2api/backend/internal/service/request_event_bus.go
win d1e2d39c26 feat(viewer): add real-time request stream WebSocket endpoint
Adds GET /api/v1/admin/ops/ws/requests — a fan-out WebSocket that pushes
per-request metadata (method, path, model, account_id, status, latency_ms)
to all connected admin clients the moment each gateway dispatch completes.

- service/request_event_bus.go: lock-free pub/sub with non-blocking drop
  when per-subscriber buffer (64 slots) is full; nil-safe Publish
- service/request_event_bus_test.go: 6 tests (basic, fanout, drop, nil, close)
- GatewayHandler: records reqStartTime at entry; defer emits RequestEvent on
  every return; sets status success/error/rate_limited in both Gemini and
  Anthropic dispatch paths
- OpsHandler: accepts *RequestEventBus; wires it to RequestStreamWSHandler
- ops_ws_requests_handler.go: subscribes to bus, pushes JSON per event,
  reuses existing upgrader/conn-limit/ping-pong infrastructure
- Route: ws.GET("/requests", ...) alongside existing /ws/qps
- wire_gen.go: requestEventBus shared between OpsHandler and GatewayHandler
2026-04-29 01:48:15 +08:00

76 lines
1.8 KiB
Go

package service
import (
"sync"
"sync/atomic"
"time"
)
const requestEventBufSize = 64
// RequestEvent is published for every gateway dispatch completion.
type RequestEvent struct {
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Path string `json:"path"`
Model string `json:"model"`
AccountID int64 `json:"account_id"`
// Status is "success", "error", or "rate_limited".
Status string `json:"status"`
LatencyMS int64 `json:"latency_ms"`
}
// RequestEventBus is a fan-out hub for real-time request events.
// Publishers call Publish; subscribers call Subscribe/Unsubscribe.
// Each subscriber gets its own buffered channel. If the buffer is full
// the event is dropped for that subscriber (non-blocking publish).
type RequestEventBus struct {
mu sync.RWMutex
subscribers map[uint64]chan RequestEvent
nextID atomic.Uint64
}
func NewRequestEventBus() *RequestEventBus {
return &RequestEventBus{
subscribers: make(map[uint64]chan RequestEvent),
}
}
// Subscribe registers a new subscriber and returns its ID and a receive-only channel.
func (b *RequestEventBus) Subscribe() (uint64, <-chan RequestEvent) {
id := b.nextID.Add(1)
ch := make(chan RequestEvent, requestEventBufSize)
b.mu.Lock()
b.subscribers[id] = ch
b.mu.Unlock()
return id, ch
}
// Unsubscribe removes a subscriber and closes its channel.
func (b *RequestEventBus) Unsubscribe(id uint64) {
b.mu.Lock()
ch, ok := b.subscribers[id]
if ok {
delete(b.subscribers, id)
}
b.mu.Unlock()
if ok {
close(ch)
}
}
// Publish sends an event to all current subscribers without blocking.
func (b *RequestEventBus) Publish(e RequestEvent) {
if b == nil {
return
}
b.mu.RLock()
defer b.mu.RUnlock()
for _, ch := range b.subscribers {
select {
case ch <- e:
default:
}
}
}