New GET /api/dbstats reads VictoriaLogs /metrics (stored lines, size on disk, raw size, free space, partitions, retention) and two LogsQL queries (period covered, distinct hosts and apps, lines of the last 24 h and hour), cached for 30 s. The danger zone shows them, sizes in KB/MB/GB or Ko/Mo/Go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
168 lines
4.9 KiB
Go
168 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DBStats describes what VictoriaLogs holds (Settings > Data). Sizes are in
|
|
// bytes; a field VictoriaLogs did not give stays at its zero value.
|
|
type DBStats struct {
|
|
Rows int64 `json:"rows"` // stored log lines
|
|
DiskBytes int64 `json:"diskBytes"` // compressed data + index, on disk
|
|
IndexBytes int64 `json:"indexBytes"` // index part of DiskBytes
|
|
RawBytes int64 `json:"rawBytes"` // data before compression
|
|
FreeBytes int64 `json:"freeBytes"` // free space on the VictoriaLogs volume
|
|
Days int64 `json:"days"` // per-day partitions
|
|
Retention string `json:"retention"` // -retentionPeriod, e.g. "30d"
|
|
Oldest string `json:"oldest,omitempty"`
|
|
Newest string `json:"newest,omitempty"`
|
|
Last1h int64 `json:"last1h"`
|
|
Last24h int64 `json:"last24h"`
|
|
Hosts int64 `json:"hosts"`
|
|
Apps int64 `json:"apps"`
|
|
QueryError string `json:"queryError,omitempty"` // the LogsQL part failed, the metrics are still valid
|
|
Updated string `json:"updated"`
|
|
}
|
|
|
|
// dbStatsCache keeps the last answer for a while: the LogsQL part reads the
|
|
// whole base, which is too heavy to repeat each time the tab is opened.
|
|
type dbStatsCache struct {
|
|
mu sync.Mutex
|
|
at time.Time
|
|
last *DBStats
|
|
}
|
|
|
|
const dbStatsTTL = 30 * time.Second
|
|
|
|
func (c *dbStatsCache) get(ctx context.Context, s *Store, force bool) (*DBStats, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.last != nil && !force && time.Since(c.at) < dbStatsTTL {
|
|
return c.last, nil
|
|
}
|
|
st, err := s.DBStats(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.last, c.at = st, time.Now()
|
|
return st, nil
|
|
}
|
|
|
|
// DBStats reads the storage metrics of VictoriaLogs (/metrics), then counts
|
|
// with LogsQL what the metrics do not tell (period, recent volume, sources).
|
|
func (s *Store) DBStats(ctx context.Context) (*DBStats, error) {
|
|
st, err := s.storageMetrics(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st.Updated = time.Now().UTC().Format(time.RFC3339)
|
|
if st.Rows == 0 {
|
|
return st, nil
|
|
}
|
|
|
|
qctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
rows, err := s.Query(qctx, "* | stats min(_time) oldest, max(_time) newest, count_uniq(host) hosts, count_uniq(app) apps")
|
|
if err == nil && len(rows) > 0 {
|
|
r := rows[0]
|
|
st.Oldest, _ = r["oldest"].(string)
|
|
st.Newest, _ = r["newest"].(string)
|
|
st.Hosts, st.Apps = toInt(r["hosts"]), toInt(r["apps"])
|
|
rows, err = s.Query(qctx, "_time:24h | stats count() last24h, count() if (_time:1h) last1h")
|
|
if err == nil && len(rows) > 0 {
|
|
st.Last24h, st.Last1h = toInt(rows[0]["last24h"]), toInt(rows[0]["last1h"])
|
|
}
|
|
}
|
|
if err != nil {
|
|
st.QueryError = err.Error()
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
func (s *Store) storageMetrics(ctx context.Context) (*DBStats, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/metrics", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("VictoriaLogs /metrics: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
|
}
|
|
return parseStorageMetrics(resp.Body)
|
|
}
|
|
|
|
// parseStorageMetrics picks the storage figures out of the Prometheus text
|
|
// format of VictoriaLogs' /metrics (app/vlstorage).
|
|
func parseStorageMetrics(r io.Reader) (*DBStats, error) {
|
|
st := &DBStats{}
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 64*1024), 1<<20)
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if line == "" || line[0] == '#' {
|
|
continue
|
|
}
|
|
name, labels, value := splitMetric(line)
|
|
switch name {
|
|
case "vl_storage_rows":
|
|
st.Rows += int64(value)
|
|
case "vl_data_size_bytes":
|
|
st.DiskBytes += int64(value)
|
|
if labels["type"] == "indexdb" {
|
|
st.IndexBytes += int64(value)
|
|
}
|
|
case "vl_uncompressed_data_size_bytes":
|
|
st.RawBytes += int64(value)
|
|
case "vl_free_disk_space_bytes":
|
|
st.FreeBytes = int64(value)
|
|
case "vl_partitions":
|
|
st.Days = int64(value)
|
|
case "flag":
|
|
if labels["name"] == "retentionPeriod" {
|
|
st.Retention = labels["value"]
|
|
}
|
|
}
|
|
}
|
|
return st, sc.Err()
|
|
}
|
|
|
|
// splitMetric splits `name{a="x",b="y"} 12` into its parts. Label values with
|
|
// escaped quotes are not expected in the metrics read here.
|
|
func splitMetric(line string) (string, map[string]string, float64) {
|
|
sp := strings.LastIndexByte(line, ' ')
|
|
if sp < 0 {
|
|
return "", nil, 0
|
|
}
|
|
value, _ := strconv.ParseFloat(line[sp+1:], 64)
|
|
head := line[:sp]
|
|
name, rest, ok := strings.Cut(head, "{")
|
|
if !ok {
|
|
return head, nil, value
|
|
}
|
|
labels := map[string]string{}
|
|
rest = strings.TrimSuffix(rest, "}")
|
|
for rest != "" {
|
|
k, v, ok := strings.Cut(rest, `="`)
|
|
if !ok {
|
|
break
|
|
}
|
|
val, after, _ := strings.Cut(v, `"`)
|
|
labels[strings.TrimSpace(k)] = val
|
|
rest = strings.TrimPrefix(after, ",")
|
|
}
|
|
return name, labels, value
|
|
}
|