a9c1997fbc
gates / gates (push) Successful in 1m17s
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s, 25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex, app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs. Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
415 lines
11 KiB
Go
415 lines
11 KiB
Go
package metrics
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"log"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
// MetricsStore manages SQLite storage for system and container metrics.
|
||
type MetricsStore struct {
|
||
db *sql.DB
|
||
logger *log.Logger
|
||
}
|
||
|
||
// NewMetricsStore opens (or creates) a SQLite database at dbPath and initializes the schema.
|
||
func NewMetricsStore(dbPath string, logger *log.Logger) (*MetricsStore, error) {
|
||
db, err := sql.Open("sqlite", dbPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||
}
|
||
|
||
// Enable WAL mode and verify it took effect
|
||
var walMode string
|
||
if err := db.QueryRow("PRAGMA journal_mode=WAL").Scan(&walMode); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("set WAL mode: %w", err)
|
||
}
|
||
if walMode != "wal" {
|
||
db.Close()
|
||
return nil, fmt.Errorf("WAL mode not supported on this filesystem (got %q)", walMode)
|
||
}
|
||
// Set remaining pragmas for performance and concurrency
|
||
pragmas := []string{
|
||
"PRAGMA synchronous=NORMAL",
|
||
"PRAGMA busy_timeout=5000",
|
||
}
|
||
for _, p := range pragmas {
|
||
if _, err := db.Exec(p); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("pragma %q: %w", p, err)
|
||
}
|
||
}
|
||
|
||
// Create tables
|
||
schema := []string{
|
||
`CREATE TABLE IF NOT EXISTS system_metrics (
|
||
ts INTEGER NOT NULL,
|
||
cpu_percent REAL NOT NULL,
|
||
mem_used_mb INTEGER NOT NULL,
|
||
mem_total_mb INTEGER NOT NULL,
|
||
temp_celsius REAL,
|
||
load_avg_1 REAL,
|
||
load_avg_5 REAL,
|
||
load_avg_15 REAL,
|
||
disk_used_gb REAL,
|
||
disk_total_gb REAL,
|
||
hdd_used_gb REAL,
|
||
hdd_total_gb REAL
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_system_ts ON system_metrics(ts)`,
|
||
`CREATE TABLE IF NOT EXISTS container_metrics (
|
||
ts INTEGER NOT NULL,
|
||
container_name TEXT NOT NULL,
|
||
cpu_percent REAL NOT NULL,
|
||
mem_usage_mb REAL NOT NULL,
|
||
mem_limit_mb REAL,
|
||
net_rx_bytes INTEGER,
|
||
net_tx_bytes INTEGER,
|
||
block_read_bytes INTEGER,
|
||
block_write_bytes INTEGER
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_container_ts ON container_metrics(ts)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_container_name ON container_metrics(container_name, ts)`,
|
||
}
|
||
for _, s := range schema {
|
||
if _, err := db.Exec(s); err != nil {
|
||
db.Close()
|
||
return nil, fmt.Errorf("schema: %w", err)
|
||
}
|
||
}
|
||
|
||
return &MetricsStore{db: db, logger: logger}, nil
|
||
}
|
||
|
||
// Close closes the underlying database connection.
|
||
func (s *MetricsStore) Close() error {
|
||
return s.db.Close()
|
||
}
|
||
|
||
// InsertSystemMetrics inserts a single system metrics sample.
|
||
func (s *MetricsStore) InsertSystemMetrics(m SystemSample) error {
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO system_metrics (ts, cpu_percent, mem_used_mb, mem_total_mb, temp_celsius,
|
||
load_avg_1, load_avg_5, load_avg_15, disk_used_gb, disk_total_gb, hdd_used_gb, hdd_total_gb)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
m.Timestamp, m.CPUPercent, m.MemUsedMB, m.MemTotalMB, m.TempCelsius,
|
||
m.LoadAvg1, m.LoadAvg5, m.LoadAvg15, m.DiskUsedGB, m.DiskTotalGB, m.HDDUsedGB, m.HDDTotalGB,
|
||
)
|
||
return err
|
||
}
|
||
|
||
// InsertContainerMetrics inserts a batch of container metrics samples.
|
||
func (s *MetricsStore) InsertContainerMetrics(samples []ContainerSample) error {
|
||
if len(samples) == 0 {
|
||
return nil
|
||
}
|
||
|
||
tx, err := s.db.Begin()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
stmt, err := tx.Prepare(
|
||
`INSERT INTO container_metrics (ts, container_name, cpu_percent, mem_usage_mb, mem_limit_mb,
|
||
net_rx_bytes, net_tx_bytes, block_read_bytes, block_write_bytes)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer stmt.Close()
|
||
|
||
for _, c := range samples {
|
||
if _, err := stmt.Exec(c.Timestamp, c.ContainerName, c.CPUPercent, c.MemUsageMB, c.MemLimitMB,
|
||
c.NetRxBytes, c.NetTxBytes, c.BlockReadBytes, c.BlockWriteBytes); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return tx.Commit()
|
||
}
|
||
|
||
// QuerySystemMetrics returns downsampled system metrics between from and to.
|
||
// resolution controls the approximate number of data points returned.
|
||
func (s *MetricsStore) QuerySystemMetrics(from, to time.Time, resolution int) ([]SystemSample, error) {
|
||
fromTS := from.Unix()
|
||
toTS := to.Unix()
|
||
|
||
if resolution <= 0 {
|
||
resolution = 200
|
||
}
|
||
|
||
rangeSeconds := toTS - fromTS
|
||
if rangeSeconds <= 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
bucketSeconds := rangeSeconds / int64(resolution)
|
||
if bucketSeconds < 1 {
|
||
bucketSeconds = 1
|
||
}
|
||
|
||
rows, err := s.db.Query(`
|
||
SELECT
|
||
(ts / ?) * ? AS bucket_ts,
|
||
AVG(cpu_percent),
|
||
ROUND(AVG(mem_used_mb)),
|
||
ROUND(AVG(mem_total_mb)),
|
||
AVG(temp_celsius),
|
||
AVG(load_avg_1),
|
||
AVG(load_avg_5),
|
||
AVG(load_avg_15),
|
||
AVG(disk_used_gb),
|
||
AVG(disk_total_gb),
|
||
AVG(hdd_used_gb),
|
||
AVG(hdd_total_gb)
|
||
FROM system_metrics
|
||
WHERE ts >= ? AND ts <= ?
|
||
GROUP BY ts / ?
|
||
ORDER BY bucket_ts ASC`,
|
||
bucketSeconds, bucketSeconds, fromTS, toTS, bucketSeconds,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var result []SystemSample
|
||
for rows.Next() {
|
||
var m SystemSample
|
||
var tempC, load1, load5, load15, diskUsed, diskTotal, hddUsed, hddTotal sql.NullFloat64
|
||
if err := rows.Scan(&m.Timestamp, &m.CPUPercent, &m.MemUsedMB, &m.MemTotalMB,
|
||
&tempC, &load1, &load5, &load15, &diskUsed, &diskTotal, &hddUsed, &hddTotal); err != nil {
|
||
return nil, err
|
||
}
|
||
if tempC.Valid {
|
||
m.TempCelsius = tempC.Float64
|
||
}
|
||
if load1.Valid {
|
||
m.LoadAvg1 = load1.Float64
|
||
}
|
||
if load5.Valid {
|
||
m.LoadAvg5 = load5.Float64
|
||
}
|
||
if load15.Valid {
|
||
m.LoadAvg15 = load15.Float64
|
||
}
|
||
if diskUsed.Valid {
|
||
m.DiskUsedGB = diskUsed.Float64
|
||
}
|
||
if diskTotal.Valid {
|
||
m.DiskTotalGB = diskTotal.Float64
|
||
}
|
||
if hddUsed.Valid {
|
||
m.HDDUsedGB = hddUsed.Float64
|
||
}
|
||
if hddTotal.Valid {
|
||
m.HDDTotalGB = hddTotal.Float64
|
||
}
|
||
result = append(result, m)
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
// QueryContainerMetrics returns downsampled metrics for a specific container.
|
||
func (s *MetricsStore) QueryContainerMetrics(name string, from, to time.Time, resolution int) ([]ContainerSample, error) {
|
||
fromTS := from.Unix()
|
||
toTS := to.Unix()
|
||
|
||
if resolution <= 0 {
|
||
resolution = 200
|
||
}
|
||
|
||
rangeSeconds := toTS - fromTS
|
||
if rangeSeconds <= 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
bucketSeconds := rangeSeconds / int64(resolution)
|
||
if bucketSeconds < 1 {
|
||
bucketSeconds = 1
|
||
}
|
||
|
||
rows, err := s.db.Query(`
|
||
SELECT
|
||
(ts / ?) * ? AS bucket_ts,
|
||
container_name,
|
||
AVG(cpu_percent),
|
||
AVG(mem_usage_mb),
|
||
AVG(mem_limit_mb),
|
||
AVG(net_rx_bytes),
|
||
AVG(net_tx_bytes),
|
||
AVG(block_read_bytes),
|
||
AVG(block_write_bytes)
|
||
FROM container_metrics
|
||
WHERE container_name = ? AND ts >= ? AND ts <= ?
|
||
GROUP BY ts / ?
|
||
ORDER BY bucket_ts ASC`,
|
||
bucketSeconds, bucketSeconds, name, fromTS, toTS, bucketSeconds,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var result []ContainerSample
|
||
for rows.Next() {
|
||
var c ContainerSample
|
||
var memLimit, netRx, netTx, blkRead, blkWrite sql.NullFloat64
|
||
if err := rows.Scan(&c.Timestamp, &c.ContainerName, &c.CPUPercent, &c.MemUsageMB,
|
||
&memLimit, &netRx, &netTx, &blkRead, &blkWrite); err != nil {
|
||
return nil, err
|
||
}
|
||
if memLimit.Valid {
|
||
c.MemLimitMB = memLimit.Float64
|
||
}
|
||
if netRx.Valid {
|
||
c.NetRxBytes = int64(netRx.Float64)
|
||
}
|
||
if netTx.Valid {
|
||
c.NetTxBytes = int64(netTx.Float64)
|
||
}
|
||
if blkRead.Valid {
|
||
c.BlockReadBytes = int64(blkRead.Float64)
|
||
}
|
||
if blkWrite.Valid {
|
||
c.BlockWriteBytes = int64(blkWrite.Float64)
|
||
}
|
||
result = append(result, c)
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
// QueryContainerSummary returns the latest metrics for all containers.
|
||
func (s *MetricsStore) QueryContainerSummary() ([]ContainerCurrentStats, error) {
|
||
rows, err := s.db.Query(`
|
||
SELECT container_name, cpu_percent, mem_usage_mb, COALESCE(mem_limit_mb, 0)
|
||
FROM container_metrics
|
||
WHERE ts = (SELECT MAX(ts) FROM container_metrics)
|
||
ORDER BY cpu_percent DESC`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var result []ContainerCurrentStats
|
||
for rows.Next() {
|
||
var c ContainerCurrentStats
|
||
if err := rows.Scan(&c.ContainerName, &c.CPUPercent, &c.MemUsageMB, &c.MemLimitMB); err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, c)
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
// Prune deletes rows older than the given duration. Returns the number of deleted rows.
|
||
func (s *MetricsStore) Prune(olderThan time.Duration) (int64, error) {
|
||
cutoff := time.Now().Add(-olderThan).Unix()
|
||
|
||
var total int64
|
||
res, err := s.db.Exec("DELETE FROM system_metrics WHERE ts < ?", cutoff)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
total += n
|
||
|
||
res, err = s.db.Exec("DELETE FROM container_metrics WHERE ts < ?", cutoff)
|
||
if err != nil {
|
||
return total, err
|
||
}
|
||
n, _ = res.RowsAffected()
|
||
total += n
|
||
|
||
return total, nil
|
||
}
|
||
|
||
// SampleCount is how many system samples (one a minute) exist in [from, to) — the box's own record of whether it
|
||
// was on (R-871 banner: "the box was off at 02:30").
|
||
func (s *MetricsStore) SampleCount(from, to time.Time) (int, error) {
|
||
var n int
|
||
err := s.db.QueryRow(`SELECT COUNT(*) FROM system_metrics WHERE ts >= ? AND ts < ?`, from.Unix(), to.Unix()).Scan(&n)
|
||
return n, err
|
||
}
|
||
|
||
// HourSampleCounts returns the number of system samples per UTC hour start (unix seconds) in [from, to) — the
|
||
// banner's "when is this box usually on" pattern (R-871). At most 24×7 rows for a week.
|
||
func (s *MetricsStore) HourSampleCounts(from, to time.Time) (map[int64]int, error) {
|
||
rows, err := s.db.Query(`SELECT (ts / 3600) * 3600 AS h, COUNT(*) FROM system_metrics WHERE ts >= ? AND ts < ? GROUP BY h`, from.Unix(), to.Unix())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := map[int64]int{}
|
||
for rows.Next() {
|
||
var h int64
|
||
var n int
|
||
if err := rows.Scan(&h, &n); err != nil {
|
||
return nil, err
|
||
}
|
||
out[h] = n
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// StackTxP95Mbps is the 95th percentile of an app's upload, its containers summed per minute, over the rows since
|
||
// `since` (the upload watch's „far above its own p95" rule for an app the box cannot ask; internal/upwatch). minutes
|
||
// is how many one-minute buckets the history holds. The counters are `docker stats`' text values (three significant
|
||
// digits), coarse but enough for a p95 the rule multiplies by ten.
|
||
func (s *MetricsStore) StackTxP95Mbps(containers []string, since time.Time) (p95 float64, minutes int, err error) {
|
||
if len(containers) == 0 {
|
||
return 0, 0, nil
|
||
}
|
||
q := "SELECT container_name, ts, net_tx_bytes FROM container_metrics WHERE ts >= ? AND container_name IN (?" +
|
||
strings.Repeat(",?", len(containers)-1) + ") ORDER BY container_name, ts"
|
||
args := []interface{}{since.Unix()}
|
||
for _, c := range containers {
|
||
args = append(args, c)
|
||
}
|
||
rows, err := s.db.Query(q, args...)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
defer rows.Close()
|
||
buckets := map[int64]float64{}
|
||
var prevName string
|
||
var prevTS, prevTx int64
|
||
havePrev := false
|
||
for rows.Next() {
|
||
var name string
|
||
var ts int64
|
||
var tx sql.NullInt64
|
||
if err := rows.Scan(&name, &ts, &tx); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
if !tx.Valid {
|
||
havePrev = false
|
||
continue
|
||
}
|
||
if havePrev && name == prevName && ts > prevTS && ts-prevTS <= 180 && tx.Int64 >= prevTx {
|
||
buckets[ts/60] += float64(tx.Int64-prevTx) * 8 / float64(ts-prevTS) / 1e6
|
||
}
|
||
prevName, prevTS, prevTx, havePrev = name, ts, tx.Int64, true
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
if len(buckets) == 0 {
|
||
return 0, 0, nil
|
||
}
|
||
vals := make([]float64, 0, len(buckets))
|
||
for _, v := range buckets {
|
||
vals = append(vals, v)
|
||
}
|
||
sort.Float64s(vals)
|
||
return vals[int(float64(len(vals)-1)*0.95)], len(vals), nil
|
||
}
|