314 lines
12 KiB
Go
314 lines
12 KiB
Go
|
|
package server
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/danielgtaylor/huma/v2"
|
||
|
|
|
||
|
|
"glchat/internal/sysinfo"
|
||
|
|
)
|
||
|
|
|
||
|
|
// metricsPath — ручка живого дашборда инстанса. Вынесена в константу: её
|
||
|
|
// опрос раз в секунду исключён из общего лимита API (см. registerRoutes).
|
||
|
|
const metricsPath = "/api/v1/instance/metrics"
|
||
|
|
|
||
|
|
// healthCacheTTL — как долго переиспользуются результаты тяжёлых проверок
|
||
|
|
// (запрос к БД и к SFU). Метрики при этом считаются на каждый запрос, поэтому
|
||
|
|
// ручку можно опрашивать раз в секунду.
|
||
|
|
const healthCacheTTL = 5 * time.Second
|
||
|
|
|
||
|
|
// healthCache хранит последний healthcheck: дашборд обновляется раз в секунду,
|
||
|
|
// а проверки с сетевыми вызовами чаще раза в healthCacheTTL не нужны.
|
||
|
|
type healthCache struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
checks []metricsCheck
|
||
|
|
at time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type metricsCPU struct {
|
||
|
|
Percent float64 `json:"percent" doc:"Загрузка процессора в процентах от доступного времени"`
|
||
|
|
Cores float64 `json:"cores" doc:"Доступные ядра (лимит контейнера или ядра хоста)"`
|
||
|
|
Source string `json:"source" doc:"Источник данных: cgroup или host"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type metricsMemory struct {
|
||
|
|
UsedBytes int64 `json:"used_bytes"`
|
||
|
|
TotalBytes int64 `json:"total_bytes"`
|
||
|
|
Percent float64 `json:"percent"`
|
||
|
|
Source string `json:"source" doc:"Источник данных: cgroup (лимит) или host"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type metricsDisk struct {
|
||
|
|
UsedBytes int64 `json:"used_bytes"`
|
||
|
|
TotalBytes int64 `json:"total_bytes"`
|
||
|
|
FreeBytes int64 `json:"free_bytes"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type metricsDatabase struct {
|
||
|
|
Bytes int64 `json:"bytes" doc:"Размер файла базы"`
|
||
|
|
WalBytes int64 `json:"wal_bytes" doc:"Размер журнала WAL"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// metricsCheck — одна проверка инстанса. name и status машиночитаемые, текст
|
||
|
|
// сообщения технический (локализуется на клиенте по name).
|
||
|
|
type metricsCheck struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
Status string `json:"status" enum:"ok,warn,failed,off"`
|
||
|
|
Message string `json:"message,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type metricsPayload struct {
|
||
|
|
CPU metricsCPU `json:"cpu"`
|
||
|
|
Memory metricsMemory `json:"memory"`
|
||
|
|
Disk metricsDisk `json:"disk"`
|
||
|
|
Database metricsDatabase `json:"database"`
|
||
|
|
Checks []metricsCheck `json:"checks"`
|
||
|
|
Health string `json:"health" enum:"ok,warn,failed"`
|
||
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||
|
|
Version string `json:"version"`
|
||
|
|
Commit string `json:"commit"`
|
||
|
|
CollectedAt string `json:"collected_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type metricsOutput struct {
|
||
|
|
Body struct {
|
||
|
|
Metrics metricsPayload `json:"metrics"`
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// registerMetricsRoutes описывает дашборд инстанса: живые метрики, состояние
|
||
|
|
// диска и базы, healthcheck и версия (AGENT.md 7.19).
|
||
|
|
func (s *Server) registerMetricsRoutes(api huma.API) {
|
||
|
|
huma.Register(api, huma.Operation{
|
||
|
|
OperationID: "getInstanceMetrics",
|
||
|
|
Method: http.MethodGet,
|
||
|
|
Path: "/instance/metrics",
|
||
|
|
Summary: "Живые метрики инстанса (только администратор)",
|
||
|
|
Tags: []string{"Instance"},
|
||
|
|
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
|
||
|
|
}, func(ctx context.Context, _ *struct{}) (*metricsOutput, error) {
|
||
|
|
user, _, err := requireUser(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if !user.IsInstanceAdmin {
|
||
|
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "instance admin is required")
|
||
|
|
}
|
||
|
|
// Свой лимит: раз в секунду — это 60 запросов в минуту, под общим
|
||
|
|
// лимитом API такой опрос съедал бы половину бюджета (AGENT.md 8.6).
|
||
|
|
if allowed, retryAfter := s.metricsLimiter.Allow("metrics:" + formatSnowflake(user.ID)); !allowed {
|
||
|
|
return nil, rateLimitedError(retryAfter)
|
||
|
|
}
|
||
|
|
|
||
|
|
output := &metricsOutput{}
|
||
|
|
output.Body.Metrics = s.collectMetrics(ctx)
|
||
|
|
return output, nil
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// collectMetrics собирает метрики и (при необходимости) обновляет healthcheck.
|
||
|
|
// Недоступные источники не ломают ответ: они попадают в проверку «metrics»,
|
||
|
|
// а соответствующий блок остаётся пустым (так ведёт себя, например, macOS без
|
||
|
|
// cgroup и /proc).
|
||
|
|
func (s *Server) collectMetrics(ctx context.Context) metricsPayload {
|
||
|
|
payload := metricsPayload{
|
||
|
|
UptimeSeconds: int64(time.Since(startedAt).Seconds()),
|
||
|
|
Version: s.cfg.Version,
|
||
|
|
Commit: s.cfg.Commit,
|
||
|
|
CollectedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||
|
|
}
|
||
|
|
var problems []string
|
||
|
|
|
||
|
|
if s.cpuSampler == nil {
|
||
|
|
problems = append(problems, "cpu: sampler is not configured")
|
||
|
|
} else if percent, cores, source, err := s.cpuSampler.CPUPercent(); err != nil {
|
||
|
|
problems = append(problems, "cpu: "+err.Error())
|
||
|
|
} else {
|
||
|
|
payload.CPU = metricsCPU{Percent: round1(percent), Cores: cores, Source: source}
|
||
|
|
}
|
||
|
|
|
||
|
|
if memory, err := sysinfo.ReadMemory(s.sysinfoPaths); err != nil {
|
||
|
|
problems = append(problems, "memory: "+err.Error())
|
||
|
|
} else {
|
||
|
|
payload.Memory = metricsMemory{
|
||
|
|
UsedBytes: memory.UsedBytes,
|
||
|
|
TotalBytes: memory.TotalBytes,
|
||
|
|
Percent: round1(memory.Percent()),
|
||
|
|
Source: memory.Source,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil {
|
||
|
|
problems = append(problems, "disk: "+err.Error())
|
||
|
|
} else {
|
||
|
|
payload.Disk = metricsDisk{
|
||
|
|
UsedBytes: disk.UsedBytes,
|
||
|
|
TotalBytes: disk.TotalBytes,
|
||
|
|
FreeBytes: disk.FreeBytes,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
dbPath := s.databasePath()
|
||
|
|
payload.Database = metricsDatabase{
|
||
|
|
Bytes: sysinfo.FileBytes(dbPath),
|
||
|
|
// WAL живёт рядом с базой и в пике бывает больше её самой.
|
||
|
|
WalBytes: sysinfo.FileBytes(dbPath + "-wal"),
|
||
|
|
}
|
||
|
|
|
||
|
|
payload.Checks = s.instanceChecks(ctx, problems)
|
||
|
|
payload.Health = summarizeHealth(payload.Checks)
|
||
|
|
return payload
|
||
|
|
}
|
||
|
|
|
||
|
|
// databasePath возвращает путь к файлу базы: в конфиге он может быть не задан
|
||
|
|
// (сборка конфига вручную, тесты), тогда берём его у открытой базы.
|
||
|
|
func (s *Server) databasePath() string {
|
||
|
|
if s.cfg.DatabasePath != "" {
|
||
|
|
return s.cfg.DatabasePath
|
||
|
|
}
|
||
|
|
if s.db != nil {
|
||
|
|
return s.db.Path()
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// instanceChecks отдаёт проверки инстанса из кэша либо пересчитывает их.
|
||
|
|
func (s *Server) instanceChecks(ctx context.Context, problems []string) []metricsCheck {
|
||
|
|
s.health.mu.Lock()
|
||
|
|
defer s.health.mu.Unlock()
|
||
|
|
if s.health.checks == nil || time.Since(s.health.at) >= healthCacheTTL {
|
||
|
|
s.health.checks = s.buildChecks(ctx)
|
||
|
|
s.health.at = time.Now()
|
||
|
|
}
|
||
|
|
if len(problems) == 0 {
|
||
|
|
return s.health.checks
|
||
|
|
}
|
||
|
|
// Проблемы с источниками метрик показываем отдельной проверкой, не затирая
|
||
|
|
// кэш: причина обычно постоянная (нет cgroup), а не разовая.
|
||
|
|
checks := make([]metricsCheck, 0, len(s.health.checks)+1)
|
||
|
|
checks = append(checks, s.health.checks...)
|
||
|
|
checks = append(checks, metricsCheck{
|
||
|
|
Name: "metrics",
|
||
|
|
Status: "warn",
|
||
|
|
Message: strings.Join(problems, "; "),
|
||
|
|
})
|
||
|
|
return checks
|
||
|
|
}
|
||
|
|
|
||
|
|
// buildChecks выполняет проверки: приложение, база, диск, SFU, Gateway.
|
||
|
|
func (s *Server) buildChecks(ctx context.Context) []metricsCheck {
|
||
|
|
checks := []metricsCheck{{
|
||
|
|
Name: "app",
|
||
|
|
Status: "ok",
|
||
|
|
Message: s.cfg.Version,
|
||
|
|
}}
|
||
|
|
|
||
|
|
// База: ping и версия схемы (миграции применяются при старте).
|
||
|
|
if s.db != nil {
|
||
|
|
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||
|
|
if err := s.db.Ready(checkCtx); err != nil {
|
||
|
|
checks = append(checks, metricsCheck{Name: "database", Status: "failed", Message: err.Error()})
|
||
|
|
} else {
|
||
|
|
version, err := s.db.SchemaVersion(checkCtx)
|
||
|
|
switch {
|
||
|
|
case err != nil:
|
||
|
|
checks = append(checks, metricsCheck{Name: "database", Status: "warn", Message: err.Error()})
|
||
|
|
default:
|
||
|
|
checks = append(checks, metricsCheck{
|
||
|
|
Name: "database",
|
||
|
|
Status: "ok",
|
||
|
|
Message: fmt.Sprintf("schema_version=%d", version),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
cancel()
|
||
|
|
} else {
|
||
|
|
checks = append(checks, metricsCheck{Name: "database", Status: "off"})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Диск: предупреждаем заранее, до заполнения раздела.
|
||
|
|
if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil {
|
||
|
|
checks = append(checks, metricsCheck{Name: "disk", Status: "warn", Message: err.Error()})
|
||
|
|
} else {
|
||
|
|
status := "ok"
|
||
|
|
switch freePercent := float64(disk.FreeBytes) / float64(disk.TotalBytes) * 100; {
|
||
|
|
case freePercent < 5:
|
||
|
|
status = "failed"
|
||
|
|
case freePercent < 15:
|
||
|
|
status = "warn"
|
||
|
|
}
|
||
|
|
checks = append(checks, metricsCheck{
|
||
|
|
Name: "disk",
|
||
|
|
Status: status,
|
||
|
|
Message: fmt.Sprintf("%.1f%% free", float64(disk.FreeBytes)/float64(disk.TotalBytes)*100),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Запись в каталог данных: база, вложения и звуки должны быть записываемы.
|
||
|
|
checks = append(checks, s.checkDataWritable())
|
||
|
|
|
||
|
|
// SFU: заодно проверяются ключи LiveKit — их неверность ломает модерацию.
|
||
|
|
switch {
|
||
|
|
case !s.voice.Enabled():
|
||
|
|
checks = append(checks, metricsCheck{Name: "voice", Status: "off"})
|
||
|
|
case !s.voiceAdmin.Enabled():
|
||
|
|
checks = append(checks, metricsCheck{Name: "voice", Status: "off", Message: "api url is not set"})
|
||
|
|
default:
|
||
|
|
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
if err := s.voiceAdmin.Ping(checkCtx); err != nil {
|
||
|
|
checks = append(checks, metricsCheck{Name: "voice", Status: "failed", Message: err.Error()})
|
||
|
|
} else {
|
||
|
|
checks = append(checks, metricsCheck{Name: "voice", Status: "ok", Message: "RoomService"})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if s.gateway != nil {
|
||
|
|
checks = append(checks, metricsCheck{
|
||
|
|
Name: "gateway",
|
||
|
|
Status: "ok",
|
||
|
|
Message: fmt.Sprintf("sessions=%d", s.gateway.ActiveSessions()),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return checks
|
||
|
|
}
|
||
|
|
|
||
|
|
// checkDataWritable проверяет, что в каталоге данных можно создавать файлы.
|
||
|
|
func (s *Server) checkDataWritable() metricsCheck {
|
||
|
|
probe := filepath.Join(s.cfg.DataDir, ".healthcheck")
|
||
|
|
// Путь собирается из каталога данных инстанса, а не из пользовательского ввода.
|
||
|
|
file, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // каталог данных задаёт конфигурация
|
||
|
|
if err != nil {
|
||
|
|
return metricsCheck{Name: "storage", Status: "failed", Message: err.Error()}
|
||
|
|
}
|
||
|
|
_ = file.Close()
|
||
|
|
_ = os.Remove(probe)
|
||
|
|
return metricsCheck{Name: "storage", Status: "ok"}
|
||
|
|
}
|
||
|
|
|
||
|
|
// summarizeHealth сводит проверки к общему состоянию: failed → failed,
|
||
|
|
// warn/off пропускаем как «не критично», иначе ok.
|
||
|
|
func summarizeHealth(checks []metricsCheck) string {
|
||
|
|
summary := "ok"
|
||
|
|
for _, check := range checks {
|
||
|
|
switch check.Status {
|
||
|
|
case "failed":
|
||
|
|
return "failed"
|
||
|
|
case "warn":
|
||
|
|
summary = "warn"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return summary
|
||
|
|
}
|
||
|
|
|
||
|
|
func round1(value float64) float64 {
|
||
|
|
return float64(int(value*10+0.5)) / 10
|
||
|
|
}
|