xray_metrics.go 8.3 KB

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