xray_metrics.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "regexp"
  8. "sort"
  9. "strings"
  10. "sync"
  11. "time"
  12. "github.com/mhsanaei/3x-ui/v3/internal/eventbus"
  13. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  14. )
  15. type xrayMetricsState struct {
  16. Enabled bool `json:"enabled"`
  17. Listen string `json:"listen"`
  18. Reason string `json:"reason,omitempty"`
  19. }
  20. type ObsTagSnapshot struct {
  21. Tag string `json:"tag"`
  22. Alive bool `json:"alive"`
  23. Delay int64 `json:"delay"`
  24. LastSeenTime int64 `json:"lastSeenTime"`
  25. LastTryTime int64 `json:"lastTryTime"`
  26. UpdatedAt int64 `json:"updatedAt"`
  27. }
  28. // eventBus is the shared bus for publishing observatory state-change events.
  29. // Set once during startup via SetEventBus; nil when no bus is configured.
  30. var eventBus *eventbus.Bus
  31. // SetEventBus assigns the global event bus used by applyObservatory to publish
  32. // outbound health transitions. Must be called once during startup before any
  33. // Sample tick runs.
  34. func SetEventBus(b *eventbus.Bus) { eventBus = b }
  35. type XrayMetricsService struct {
  36. settingService SettingService
  37. mu sync.RWMutex
  38. state xrayMetricsState
  39. client *http.Client
  40. obsByTag map[string]ObsTagSnapshot
  41. health map[string]outboundHealth
  42. }
  43. // outboundHealth debounces observatory flapping. Xray flips an outbound's
  44. // alive flag on a single failed probe, so raw transitions produce a storm of
  45. // down/up notifications on a flaky link. We instead require failStreak to reach
  46. // the configured threshold (consecutive FAILED probes, tracked per new probe
  47. // via lastTry) before publishing outbound.down, and only publish outbound.up
  48. // once a down has actually been notified.
  49. type outboundHealth struct {
  50. lastTry int64
  51. failStreak int
  52. notified bool
  53. }
  54. var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
  55. func obsHistoryKey(tag string) string {
  56. return "xrObs." + tag + ".delay"
  57. }
  58. func newXrayMetricsClient() *http.Client {
  59. return &http.Client{Timeout: 1500 * time.Millisecond}
  60. }
  61. func (s *XrayMetricsService) getClient() *http.Client {
  62. s.mu.Lock()
  63. defer s.mu.Unlock()
  64. if s.client == nil {
  65. s.client = newXrayMetricsClient()
  66. }
  67. return s.client
  68. }
  69. func (s *XrayMetricsService) State() xrayMetricsState {
  70. s.mu.RLock()
  71. defer s.mu.RUnlock()
  72. return s.state
  73. }
  74. func (s *XrayMetricsService) AggregateMetric(metric string, bucketSeconds, maxPoints int) []map[string]any {
  75. return xrayMetrics.aggregate(metric, bucketSeconds, maxPoints)
  76. }
  77. func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot {
  78. s.mu.RLock()
  79. defer s.mu.RUnlock()
  80. out := make([]ObsTagSnapshot, 0, len(s.obsByTag))
  81. for _, v := range s.obsByTag {
  82. out = append(out, v)
  83. }
  84. sort.Slice(out, func(i, j int) bool { return out[i].Tag < out[j].Tag })
  85. return out
  86. }
  87. func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
  88. if !validObsTag.MatchString(tag) {
  89. return false
  90. }
  91. s.mu.RLock()
  92. defer s.mu.RUnlock()
  93. _, ok := s.obsByTag[tag]
  94. return ok
  95. }
  96. func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
  97. if !validObsTag.MatchString(tag) {
  98. return []map[string]any{}
  99. }
  100. return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
  101. }
  102. func (s *XrayMetricsService) discoverListen() (string, error) {
  103. tmpl, err := s.settingService.GetXrayConfigTemplate()
  104. if err != nil {
  105. return "", err
  106. }
  107. var parsed struct {
  108. Metrics *struct {
  109. Listen string `json:"listen"`
  110. } `json:"metrics"`
  111. }
  112. if err := json.Unmarshal([]byte(tmpl), &parsed); err != nil {
  113. return "", err
  114. }
  115. if parsed.Metrics == nil || strings.TrimSpace(parsed.Metrics.Listen) == "" {
  116. return "", nil
  117. }
  118. return strings.TrimSpace(parsed.Metrics.Listen), nil
  119. }
  120. type rawObsEntry struct {
  121. Alive bool `json:"alive"`
  122. Delay int64 `json:"delay"`
  123. LastSeenTime int64 `json:"last_seen_time"`
  124. LastTryTime int64 `json:"last_try_time"`
  125. OutboundTag string `json:"outbound_tag"`
  126. }
  127. func (s *XrayMetricsService) Sample(t time.Time) {
  128. listen, err := s.discoverListen()
  129. if err != nil {
  130. s.setState(xrayMetricsState{Reason: err.Error()})
  131. return
  132. }
  133. if listen == "" {
  134. s.setState(xrayMetricsState{Reason: "metrics block not configured in xray template"})
  135. return
  136. }
  137. ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
  138. defer cancel()
  139. url := fmt.Sprintf("http://%s/debug/vars", listen)
  140. req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  141. if err != nil {
  142. s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
  143. return
  144. }
  145. resp, err := s.getClient().Do(req)
  146. if err != nil {
  147. s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
  148. return
  149. }
  150. defer resp.Body.Close()
  151. if resp.StatusCode != http.StatusOK {
  152. s.setState(xrayMetricsState{Listen: listen, Reason: fmt.Sprintf("HTTP %d", resp.StatusCode)})
  153. return
  154. }
  155. var payload struct {
  156. MemStats struct {
  157. HeapAlloc uint64 `json:"HeapAlloc"`
  158. Sys uint64 `json:"Sys"`
  159. HeapObjects uint64 `json:"HeapObjects"`
  160. NumGC uint32 `json:"NumGC"`
  161. PauseNs [256]uint64 `json:"PauseNs"`
  162. } `json:"memstats"`
  163. Observatory map[string]rawObsEntry `json:"observatory"`
  164. }
  165. if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
  166. s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
  167. return
  168. }
  169. xrayMetrics.append("xrAlloc", t, float64(payload.MemStats.HeapAlloc))
  170. xrayMetrics.append("xrSys", t, float64(payload.MemStats.Sys))
  171. xrayMetrics.append("xrHeapObjects", t, float64(payload.MemStats.HeapObjects))
  172. xrayMetrics.append("xrNumGC", t, float64(payload.MemStats.NumGC))
  173. var lastPause uint64
  174. if payload.MemStats.NumGC > 0 {
  175. idx := (payload.MemStats.NumGC + 255) % 256
  176. lastPause = payload.MemStats.PauseNs[idx]
  177. }
  178. xrayMetrics.append("xrPauseNs", t, float64(lastPause))
  179. s.applyObservatory(t, payload.Observatory)
  180. s.setState(xrayMetricsState{Enabled: true, Listen: listen})
  181. }
  182. func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]rawObsEntry) {
  183. next := make(map[string]ObsTagSnapshot, len(entries))
  184. for key, e := range entries {
  185. tag := e.OutboundTag
  186. if tag == "" {
  187. tag = key
  188. }
  189. if !validObsTag.MatchString(tag) {
  190. continue
  191. }
  192. snap := ObsTagSnapshot{
  193. Tag: tag,
  194. Alive: e.Alive,
  195. Delay: e.Delay,
  196. LastSeenTime: e.LastSeenTime,
  197. LastTryTime: e.LastTryTime,
  198. UpdatedAt: t.Unix(),
  199. }
  200. next[tag] = snap
  201. xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay))
  202. }
  203. threshold := 3
  204. if v, err := s.settingService.GetOutboundDownThreshold(); err == nil && v > 0 {
  205. threshold = v
  206. }
  207. s.mu.Lock()
  208. // Debounce observatory flapping into stable down/up notifications.
  209. if eventBus != nil {
  210. if s.health == nil {
  211. s.health = make(map[string]outboundHealth, len(next))
  212. }
  213. for tag, cur := range next {
  214. // React only to a genuinely new probe attempt (lastTry advanced).
  215. // The sampler polls far more often than xray probes, so counting
  216. // samples instead of probes would trip the threshold instantly.
  217. h := s.health[tag]
  218. if cur.LastTryTime == 0 || cur.LastTryTime == h.lastTry {
  219. continue
  220. }
  221. h.lastTry = cur.LastTryTime
  222. if cur.Alive {
  223. if h.notified {
  224. eventBus.Publish(eventbus.Event{
  225. Type: eventbus.EventOutboundUp,
  226. Source: tag,
  227. Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
  228. })
  229. }
  230. h.failStreak = 0
  231. h.notified = false
  232. } else {
  233. h.failStreak++
  234. if h.failStreak >= threshold && !h.notified {
  235. errMsg := ""
  236. if cur.Delay < 0 {
  237. errMsg = "probe failed"
  238. }
  239. eventBus.Publish(eventbus.Event{
  240. Type: eventbus.EventOutboundDown,
  241. Source: tag,
  242. Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
  243. })
  244. h.notified = true
  245. }
  246. }
  247. s.health[tag] = h
  248. }
  249. // Forget tags that vanished from the observatory.
  250. for tag := range s.health {
  251. if _, ok := next[tag]; !ok {
  252. delete(s.health, tag)
  253. }
  254. }
  255. }
  256. for tag := range s.obsByTag {
  257. if _, kept := next[tag]; !kept {
  258. xrayMetrics.drop(obsHistoryKey(tag))
  259. }
  260. }
  261. s.obsByTag = next
  262. s.mu.Unlock()
  263. }
  264. func (s *XrayMetricsService) setState(st xrayMetricsState) {
  265. s.mu.Lock()
  266. s.state = st
  267. s.mu.Unlock()
  268. if !st.Enabled && st.Reason != "" {
  269. logger.Debugf("xray metrics unavailable: %s", st.Reason)
  270. }
  271. }