metric_history.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. package service
  2. import (
  3. "encoding/gob"
  4. "os"
  5. "path/filepath"
  6. "slices"
  7. "sync"
  8. "time"
  9. "github.com/mhsanaei/3x-ui/v3/internal/config"
  10. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  11. )
  12. // MetricSample is one point of any time-series we keep in memory.
  13. // The frontend deserializes both keys, so they must stay short.
  14. type MetricSample struct {
  15. T int64 `json:"t"`
  16. V float64 `json:"v"`
  17. }
  18. // tierSpec defines one resolution layer of the rollup ladder: a fixed bucket
  19. // size in seconds and how many buckets to retain. window = resolution*capacity.
  20. type tierSpec struct {
  21. resolution int
  22. capacity int
  23. }
  24. // metricTiers is the rollup ladder applied to every series. High resolution is
  25. // kept only for the recent past; older samples roll up into progressively
  26. // coarser, cheaper layers (RRDtool-style). Per series this totals ~5700 samples
  27. // (~90 KiB) yet spans a live 2s view through ~7 days of history.
  28. var metricTiers = []tierSpec{
  29. {resolution: 2, capacity: 1800}, // 1h at 2s
  30. {resolution: 60, capacity: 2880}, // 48h at 1m
  31. {resolution: 600, capacity: 1008}, // 7d at 10m
  32. }
  33. // tierBuf is one fixed-resolution ring of a series. Samples land in an open
  34. // bucket and are averaged into the ring only when the next bucket begins, so a
  35. // coarse tier carries one mean per bucket instead of every raw point.
  36. type tierBuf struct {
  37. resolution int
  38. capacity int
  39. samples []MetricSample
  40. open bool
  41. openStart int64
  42. openSum float64
  43. openCount int
  44. }
  45. func (tb *tierBuf) add(unixSec int64, v float64) {
  46. res := int64(tb.resolution)
  47. b := (unixSec / res) * res
  48. if tb.open && b != tb.openStart {
  49. tb.flush()
  50. }
  51. tb.open = true
  52. tb.openStart = b
  53. tb.openSum += v
  54. tb.openCount++
  55. }
  56. func (tb *tierBuf) flush() {
  57. if tb.openCount == 0 {
  58. tb.open = false
  59. return
  60. }
  61. tb.samples = append(tb.samples, MetricSample{T: tb.openStart, V: tb.openSum / float64(tb.openCount)})
  62. if len(tb.samples) > tb.capacity {
  63. tb.samples = tb.samples[len(tb.samples)-tb.capacity:]
  64. }
  65. tb.open = false
  66. tb.openStart = 0
  67. tb.openSum = 0
  68. tb.openCount = 0
  69. }
  70. // readSamples returns a copy of the closed buckets plus the still-open one, so
  71. // the most recent point is visible before its bucket boundary closes.
  72. func (tb *tierBuf) readSamples() []MetricSample {
  73. out := make([]MetricSample, len(tb.samples), len(tb.samples)+1)
  74. copy(out, tb.samples)
  75. if tb.openCount > 0 {
  76. out = append(out, MetricSample{T: tb.openStart, V: tb.openSum / float64(tb.openCount)})
  77. }
  78. return out
  79. }
  80. // series is the rollup ladder for one metric: a sample is fed to every tier.
  81. type series struct {
  82. tiers []*tierBuf
  83. }
  84. func newSeries() *series {
  85. s := &series{tiers: make([]*tierBuf, len(metricTiers))}
  86. for i, spec := range metricTiers {
  87. s.tiers[i] = &tierBuf{resolution: spec.resolution, capacity: spec.capacity}
  88. }
  89. return s
  90. }
  91. func (s *series) add(unixSec int64, v float64) {
  92. for _, tb := range s.tiers {
  93. tb.add(unixSec, v)
  94. }
  95. }
  96. // pickTier returns the finest tier whose window covers spanSeconds, falling back
  97. // to the coarsest (longest-window) tier when nothing covers it.
  98. func (s *series) pickTier(spanSeconds int64) *tierBuf {
  99. for _, tb := range s.tiers {
  100. if int64(tb.resolution)*int64(tb.capacity) >= spanSeconds {
  101. return tb
  102. }
  103. }
  104. return s.tiers[len(s.tiers)-1]
  105. }
  106. // metricHistory is a thread-safe, in-memory store of tiered series keyed by
  107. // arbitrary strings. Three singletons live below: system-wide host metrics,
  108. // per-node metrics, and xray expvar metrics.
  109. type metricHistory struct {
  110. mu sync.Mutex
  111. series map[string]*series
  112. }
  113. func newMetricHistory() *metricHistory {
  114. return &metricHistory{series: map[string]*series{}}
  115. }
  116. // append stores a single sample for the given metric across all tiers.
  117. func (h *metricHistory) append(metric string, t time.Time, v float64) {
  118. h.mu.Lock()
  119. defer h.mu.Unlock()
  120. s := h.series[metric]
  121. if s == nil {
  122. s = newSeries()
  123. h.series[metric] = s
  124. }
  125. s.add(t.Unix(), v)
  126. }
  127. // drop removes the entire history for one metric. Used when a node is deleted so
  128. // its old samples don't linger forever in the singleton.
  129. func (h *metricHistory) drop(metric string) {
  130. h.mu.Lock()
  131. delete(h.series, metric)
  132. h.mu.Unlock()
  133. }
  134. // aggregate returns up to maxPoints buckets of size bucketSeconds, each carrying
  135. // the arithmetic mean of the underlying samples from the finest tier that covers
  136. // the requested span. Bucket alignment is to absolute Unix-second boundaries so
  137. // two concurrent calls see identical x-axes.
  138. func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints int) []map[string]any {
  139. empty := []map[string]any{}
  140. if bucketSeconds <= 0 || maxPoints <= 0 {
  141. return empty
  142. }
  143. span := int64(bucketSeconds) * int64(maxPoints)
  144. cutoff := time.Now().Unix() - span
  145. h.mu.Lock()
  146. s := h.series[metric]
  147. if s == nil {
  148. h.mu.Unlock()
  149. return empty
  150. }
  151. raw := s.pickTier(span).readSamples()
  152. h.mu.Unlock()
  153. startIdx := len(raw)
  154. for i, r := range slices.Backward(raw) {
  155. if r.T < cutoff {
  156. break
  157. }
  158. startIdx = i
  159. }
  160. tmp := raw[startIdx:]
  161. if len(tmp) == 0 {
  162. return empty
  163. }
  164. bSize := int64(bucketSeconds)
  165. curBucket := (tmp[0].T / bSize) * bSize
  166. var out []map[string]any
  167. var acc []float64
  168. flush := func(ts int64) {
  169. if len(acc) == 0 {
  170. return
  171. }
  172. sum := 0.0
  173. for _, v := range acc {
  174. sum += v
  175. }
  176. out = append(out, map[string]any{"t": ts, "v": sum / float64(len(acc))})
  177. acc = acc[:0]
  178. }
  179. for _, p := range tmp {
  180. b := (p.T / bSize) * bSize
  181. if b != curBucket {
  182. flush(curBucket)
  183. curBucket = b
  184. }
  185. acc = append(acc, p.V)
  186. }
  187. flush(curBucket)
  188. if len(out) > maxPoints {
  189. out = out[len(out)-maxPoints:]
  190. }
  191. if out == nil {
  192. return empty
  193. }
  194. return out
  195. }
  196. // persistedTier and persistedSeries are the on-disk shape of a series. Tiers are
  197. // matched back by resolution on restore, so changing the ladder degrades
  198. // gracefully (unmatched layers are dropped) instead of corrupting state.
  199. type persistedTier struct {
  200. Resolution int
  201. Samples []MetricSample
  202. }
  203. type persistedSeries struct {
  204. Tiers []persistedTier
  205. }
  206. // snapshot returns a deep copy of every series' closed buckets, safe to
  207. // serialize without holding the lock during disk I/O.
  208. func (h *metricHistory) snapshot() map[string]persistedSeries {
  209. h.mu.Lock()
  210. defer h.mu.Unlock()
  211. out := make(map[string]persistedSeries, len(h.series))
  212. for k, s := range h.series {
  213. ps := persistedSeries{Tiers: make([]persistedTier, len(s.tiers))}
  214. for i, tb := range s.tiers {
  215. cp := make([]MetricSample, len(tb.samples))
  216. copy(cp, tb.samples)
  217. ps.Tiers[i] = persistedTier{Resolution: tb.resolution, Samples: cp}
  218. }
  219. out[k] = ps
  220. }
  221. return out
  222. }
  223. // restore replaces the in-memory series with a previously persisted set,
  224. // re-applying each tier's capacity cap so a tampered or oversized file can't grow
  225. // the working set unbounded.
  226. func (h *metricHistory) restore(data map[string]persistedSeries) {
  227. h.mu.Lock()
  228. defer h.mu.Unlock()
  229. for k, ps := range data {
  230. s := newSeries()
  231. for _, pt := range ps.Tiers {
  232. for _, tb := range s.tiers {
  233. if tb.resolution != pt.Resolution {
  234. continue
  235. }
  236. samples := pt.Samples
  237. if len(samples) > tb.capacity {
  238. samples = samples[len(samples)-tb.capacity:]
  239. }
  240. tb.samples = samples
  241. break
  242. }
  243. }
  244. h.series[k] = s
  245. }
  246. }
  247. // systemMetrics holds whole-host time series (cpu, mem, netUp, etc.) fed by
  248. // ServerService.RefreshStatus every 2s. nodeMetrics holds per-node CPU/Mem fed
  249. // by NodeHeartbeatJob. xrayMetrics holds xray expvar series. Only systemMetrics
  250. // is persisted; the others rebuild from live connections.
  251. var (
  252. systemMetrics = newMetricHistory()
  253. nodeMetrics = newMetricHistory()
  254. xrayMetrics = newMetricHistory()
  255. )
  256. // SystemMetricKeys lists the metric names ServerService writes on every status
  257. // sample. Exposed for documentation/test purposes; the controller validates
  258. // incoming names against an allow-list.
  259. var SystemMetricKeys = []string{
  260. "cpu", "mem", "swap", "netUp", "netDown", "pktUp", "pktDown", "diskRead", "diskWrite", "diskUsage", "tcpCount", "udpCount", "online", "load1", "load5", "load15",
  261. }
  262. // NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
  263. var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
  264. // XrayMetricKeys lists series sourced from xray's /debug/vars expvar endpoint.
  265. var XrayMetricKeys = []string{
  266. "xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
  267. }
  268. // systemMetricsStorePath is where the host time-series is persisted between
  269. // restarts. It lives next to the database so a single volume mount carries both.
  270. func systemMetricsStorePath() string {
  271. return filepath.Join(config.GetDBFolderPath(), "system_metrics.gob")
  272. }
  273. // PersistSystemMetrics writes the host time-series to disk via a temp file +
  274. // rename so a crash mid-write can't corrupt the previous snapshot. Called on a
  275. // timer and at shutdown.
  276. func PersistSystemMetrics() error {
  277. path := systemMetricsStorePath()
  278. tmp := path + ".tmp"
  279. f, err := os.Create(tmp)
  280. if err != nil {
  281. return err
  282. }
  283. if err := gob.NewEncoder(f).Encode(systemMetrics.snapshot()); err != nil {
  284. f.Close()
  285. os.Remove(tmp)
  286. return err
  287. }
  288. if err := f.Close(); err != nil {
  289. os.Remove(tmp)
  290. return err
  291. }
  292. return os.Rename(tmp, path)
  293. }
  294. // RestoreSystemMetrics loads a previously persisted host time-series on startup.
  295. // A missing file is not an error (first boot). A pre-tier flat snapshot is
  296. // migrated by replaying its samples through the rollup.
  297. func RestoreSystemMetrics() {
  298. path := systemMetricsStorePath()
  299. f, err := os.Open(path)
  300. if err != nil {
  301. if !os.IsNotExist(err) {
  302. logger.Warning("restore system metrics failed:", err)
  303. }
  304. return
  305. }
  306. var data map[string]persistedSeries
  307. decErr := gob.NewDecoder(f).Decode(&data)
  308. f.Close()
  309. if decErr == nil {
  310. systemMetrics.restore(data)
  311. return
  312. }
  313. if migrateLegacySystemMetrics(path) {
  314. return
  315. }
  316. logger.Warning("decode system metrics failed:", decErr)
  317. }
  318. // migrateLegacySystemMetrics loads a pre-tier flat snapshot
  319. // (map[string][]MetricSample) and replays it through append so the new tiers are
  320. // seeded from the existing history instead of starting empty.
  321. func migrateLegacySystemMetrics(path string) bool {
  322. f, err := os.Open(path)
  323. if err != nil {
  324. return false
  325. }
  326. defer f.Close()
  327. var legacy map[string][]MetricSample
  328. if err := gob.NewDecoder(f).Decode(&legacy); err != nil {
  329. return false
  330. }
  331. for metric, samples := range legacy {
  332. for _, p := range samples {
  333. systemMetrics.append(metric, time.Unix(p.T, 0), p.V)
  334. }
  335. }
  336. return true
  337. }