feat(instance): метрики и healthcheck инстанса для дашборда

Раздел «Инстанс» получает живые показатели сервера (AGENT.md 7.19).

- `internal/sysinfo`: загрузка процессора по cgroup v2 (`cpu.stat`/`cpu.max`)
  с откатом на `/proc/stat`, память по лимиту контейнера либо `/proc/meminfo`,
  место на разделе данных и размер базы вместе с журналом WAL;
- `GET /api/v1/instance/metrics` (только администратор инстанса): метрики,
  состояние (`ok|warn|failed`), проверки app/database/disk/storage/voice/
  gateway, версия и время работы; недоступные источники не ломают ответ, а
  попадают в проверку `metrics`;
- тяжёлые проверки (БД, SFU, запись) кэшируются на 5 секунд: ручку можно
  опрашивать раз в секунду;
- `voice.IssueRoomList` и `AdminClient.Ping` проверяют доступность RoomService
  и валидность ключей LiveKit (та же связка, что ломала модерацию);
- у метрик свой лимит частоты, они исключены из общего лимита API
  (`RateLimiter.MiddlewareExcept`), иначе открытый раздел съедал бы половину
  бюджета запросов;
- тесты: `internal/sysinfo` (cgroup, /proc, память, диск, размеры файлов) и
  `internal/server/api_metrics_test.go` (доступ, состав метрик, кэш, 429).
This commit is contained in:
2026-09-20 21:26:17 +03:00
parent 8ae8ca1a1d
commit 4c3736b5fb
8 changed files with 1128 additions and 19 deletions
+17 -3
View File
@@ -22,6 +22,7 @@ import (
"glchat/internal/permissions"
"glchat/internal/source"
"glchat/internal/store"
"glchat/internal/sysinfo"
"glchat/internal/voice"
)
@@ -66,6 +67,13 @@ type Server struct {
voice *voice.TokenIssuer
// voiceAdmin — RoomService для модерации на стороне SFU.
voiceAdmin *voice.AdminClient
// sysinfoPaths и cpuSampler — источники живых метрик дашборда инстанса.
sysinfoPaths sysinfo.Paths
cpuSampler *sysinfo.Sampler
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
metricsLimiter *httpx.RateLimiter
// health — кэш тяжёлых проверок инстанса.
health healthCache
// presence — время последнего обновления last_seen по пользователю.
presenceMu sync.Mutex
presence map[uint64]time.Time
@@ -101,9 +109,13 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL),
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
metricsLimiter: httpx.NewRateLimiter(120, 30),
sysinfoPaths: sysinfo.DefaultPaths(),
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL),
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
}
s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths)
switch {
case deps.Permissions != nil:
s.perms = deps.Permissions
@@ -115,7 +127,8 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
router.Route("/api/v1", func(apiRouter chi.Router) {
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
apiRouter.Use(s.sessionContext)
apiRouter.Use(s.apiLimiter.Middleware(apiRateLimitKey))
// Метрики дашборда вне общего лимита: у них свой (см. api_metrics.go).
apiRouter.Use(s.apiLimiter.MiddlewareExcept(apiRateLimitKey, metricsPath))
s.api = s.registerAPI(apiRouter)
s.registerMetaRoutes(s.api)
s.registerAuthRoutes(apiRouter)
@@ -123,6 +136,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerUserRoutes(s.api)
s.registerGuildRoutes(s.api)
s.registerInstanceRoutes(s.api)
s.registerMetricsRoutes(s.api)
s.registerMessageRoutes(s.api)
s.registerInviteRoutes(s.api)
s.registerFileRoutes(s.api, apiRouter)