Server statistics, multi-view record commands, and rc.3 fixes
Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b097e10bef
commit
7be0762f48
48 files changed
+1346
-128
No files matched your search
@@ -47,6 +47,9 @@ type Options struct {
|
||||
Anonymize bool
|
||||
Exclude []netip.Prefix
|
||||
Location *time.Location // for days and log timestamps (default: local time)
|
||||
// MainConfig is named's main configuration file; it tells named's
|
||||
// process apart from other named processes (for its memory and CPU).
|
||||
MainConfig string
|
||||
// EnsureLogging turns named's response logging on (rndc responselog
|
||||
// on). It is called at start and when named answers queries but no
|
||||
// log lines arrive.
|
||||
@@ -107,6 +110,10 @@ type Collector struct {
|
||||
// rewritten holds queries named blocked (client#port|name) until their
|
||||
// response line arrives.
|
||||
rewritten map[string]bool
|
||||
// proc is the last sample of named's process, server the latest server
|
||||
// statistics (server.go).
|
||||
proc *namedProc
|
||||
server *Server
|
||||
}
|
||||
|
||||
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
||||
@@ -235,13 +242,17 @@ func (c *Collector) start(ctx context.Context) {
|
||||
}
|
||||
c.ensureLogging(ctx)
|
||||
// The first poll is the baseline for the counters.
|
||||
if snap, err := c.poller.Poll(ctx); err == nil {
|
||||
snap, err := c.poller.Poll(ctx)
|
||||
if err == nil {
|
||||
c.prev = snap
|
||||
t := c.now()
|
||||
c.status.LastPoll = &t
|
||||
} else {
|
||||
c.status.PollError = err.Error()
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.sampleServer(-1, c.now(), snap)
|
||||
c.mu.Unlock()
|
||||
c.prune(ctx)
|
||||
c.log.Info("statistics collector started", "query_log", c.opts.QueryLog, "statistics_channel", c.opts.StatisticsChannel)
|
||||
}
|
||||
@@ -470,6 +481,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
||||
}
|
||||
c.prev = snap
|
||||
}
|
||||
if !final {
|
||||
// Gauges are sampled once a minute, for the minute that ended.
|
||||
c.sampleServer(cur-60, now, snap)
|
||||
}
|
||||
b := &Batch{}
|
||||
first := c.status.FirstData == nil
|
||||
for m, vals := range c.minutes {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !unix
|
||||
|
||||
package stats
|
||||
|
||||
// DiskUsage is not available on this system.
|
||||
func DiskUsage(labels []string, paths map[string]string) []Disk { return nil }
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build unix
|
||||
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// DiskUsage returns the space on the file systems holding paths (label ->
|
||||
// directory), one entry per file system with the labels of the paths on
|
||||
// it, in the order of labels.
|
||||
func DiskUsage(labels []string, paths map[string]string) []Disk {
|
||||
var out []Disk
|
||||
byDev := map[string]int{}
|
||||
for _, l := range labels {
|
||||
p := paths[l]
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dev := fmt.Sprint(p)
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
dev = fmt.Sprint(st.Dev)
|
||||
}
|
||||
if i, ok := byDev[dev]; ok {
|
||||
out[i].Holds = append(out[i].Holds, l)
|
||||
continue
|
||||
}
|
||||
var fs syscall.Statfs_t
|
||||
if err := syscall.Statfs(p, &fs); err != nil {
|
||||
continue
|
||||
}
|
||||
byDev[dev] = len(out)
|
||||
out = append(out, Disk{Path: p, Holds: []string{l}, Total: int64(fs.Blocks) * int64(fs.Bsize), Free: int64(fs.Bavail) * int64(fs.Bsize)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
+36
-10
@@ -15,19 +15,31 @@ import (
|
||||
type Snapshot struct {
|
||||
BootTime string
|
||||
Values map[string]int64
|
||||
// Gauges are named's current values (memory, cache size, sockets),
|
||||
// not counters.
|
||||
Gauges map[string]int64
|
||||
Version string
|
||||
ConfigTime string
|
||||
}
|
||||
|
||||
// serverJSON is the part of /json/v1/server Jarl uses.
|
||||
// serverJSON is the part of /json/v1 Jarl uses.
|
||||
type serverJSON struct {
|
||||
BootTime string `json:"boot-time"`
|
||||
Opcodes map[string]int64 `json:"opcodes"`
|
||||
RCodes map[string]int64 `json:"rcodes"`
|
||||
QTypes map[string]int64 `json:"qtypes"`
|
||||
NSStats map[string]int64 `json:"nsstats"`
|
||||
Views map[string]struct {
|
||||
BootTime string `json:"boot-time"`
|
||||
ConfigTime string `json:"config-time"`
|
||||
Version string `json:"version"`
|
||||
SockStats map[string]int64 `json:"sockstats"`
|
||||
Memory struct {
|
||||
InUse int64 `json:"InUse"`
|
||||
} `json:"memory"`
|
||||
Opcodes map[string]int64 `json:"opcodes"`
|
||||
RCodes map[string]int64 `json:"rcodes"`
|
||||
QTypes map[string]int64 `json:"qtypes"`
|
||||
NSStats map[string]int64 `json:"nsstats"`
|
||||
Views map[string]struct {
|
||||
Resolver struct {
|
||||
Stats map[string]int64 `json:"stats"`
|
||||
CacheStats map[string]int64 `json:"cachestats"`
|
||||
Cache map[string]int64 `json:"cache"` // RRsets in the cache by type
|
||||
} `json:"resolver"`
|
||||
} `json:"views"`
|
||||
}
|
||||
@@ -77,7 +89,21 @@ func Flatten(body []byte) (*Snapshot, error) {
|
||||
v[name] = n
|
||||
}
|
||||
}
|
||||
g := map[string]int64{"named:mem": j.Memory.InUse,
|
||||
"named:recursion-highwater": j.NSStats["RecursHighwater"], "named:tcp-highwater": j.NSStats["TCPConnHighWater"]}
|
||||
for k, n := range j.SockStats {
|
||||
switch {
|
||||
case strings.HasPrefix(k, "UDP") && strings.HasSuffix(k, "Active"):
|
||||
g["sockets:udp"] += n
|
||||
case strings.HasPrefix(k, "TCP") && strings.HasSuffix(k, "Active"):
|
||||
g["sockets:tcp"] += n
|
||||
}
|
||||
}
|
||||
for _, view := range j.Views {
|
||||
g["cache:mem"] += view.Resolver.CacheStats["TreeMemInUse"] + view.Resolver.CacheStats["HeapMemInUse"]
|
||||
for _, n := range view.Resolver.Cache {
|
||||
g["cache:rrsets"] += n
|
||||
}
|
||||
for k, name := range rttKeys {
|
||||
v[name] += view.Resolver.Stats[k]
|
||||
}
|
||||
@@ -92,7 +118,7 @@ func Flatten(body []byte) (*Snapshot, error) {
|
||||
delete(v, k)
|
||||
}
|
||||
}
|
||||
return &Snapshot{BootTime: j.BootTime, Values: v}, nil
|
||||
return &Snapshot{BootTime: j.BootTime, Values: v, Gauges: g, Version: j.Version, ConfigTime: j.ConfigTime}, nil
|
||||
}
|
||||
|
||||
// Delta returns the change from prev to cur. When named restarted (its
|
||||
@@ -120,13 +146,13 @@ func Delta(prev, cur *Snapshot) map[string]int64 {
|
||||
|
||||
// Poller reads named's statistics channel.
|
||||
type Poller struct {
|
||||
URL string // e.g. http://127.0.0.1:8058/json/v1/server
|
||||
URL string // e.g. http://127.0.0.1:8058/json/v1 (server, memory and sockets)
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewPoller returns a poller for a statistics channel address.
|
||||
func NewPoller(addr string) *Poller {
|
||||
return &Poller{URL: "http://" + addr + "/json/v1/server", Client: &http.Client{Timeout: 10 * time.Second}}
|
||||
return &Poller{URL: "http://" + addr + "/json/v1", Client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
// Poll fetches a snapshot.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server statistics from /proc: named's process and the host. They need no
|
||||
// privileges: /proc/PID/stat and /proc/PID/status are readable by every
|
||||
// user (unless /proc is mounted with hidepid).
|
||||
|
||||
// clockTicks is USER_HZ, the unit of the CPU times in /proc/PID/stat. It
|
||||
// is 100 on every Linux architecture Jarl runs on.
|
||||
const clockTicks = 100
|
||||
|
||||
// procRoot is where /proc is (tests point it elsewhere).
|
||||
var procRoot = "/proc"
|
||||
|
||||
// namedProc is a sample of named's process.
|
||||
type namedProc struct {
|
||||
PID int
|
||||
RSS int64 // bytes
|
||||
Threads int64
|
||||
CPU int64 // user + system time, in clock ticks
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// findNamed returns the PID of named: the process named "named" whose
|
||||
// command line mentions mainConfig, or else the oldest one (the lowest
|
||||
// PID). 0 when there is none.
|
||||
func findNamed(mainConfig string) int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
best := 0
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
comm, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "comm"))
|
||||
if err != nil || strings.TrimSpace(string(comm)) != "named" {
|
||||
continue
|
||||
}
|
||||
if mainConfig != "" {
|
||||
if cmd, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "cmdline")); err == nil && bytes.Contains(cmd, []byte(mainConfig)) {
|
||||
return pid
|
||||
}
|
||||
}
|
||||
if best == 0 || pid < best {
|
||||
best = pid
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// readNamedProc samples a process.
|
||||
func readNamedProc(pid int, now time.Time) (*namedProc, bool) {
|
||||
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
stat, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
// The command name is in parentheses and may contain spaces: the
|
||||
// fields that follow start after the last ')'.
|
||||
i := bytes.LastIndexByte(stat, ')')
|
||||
if i < 0 {
|
||||
return nil, false
|
||||
}
|
||||
f := strings.Fields(string(stat[i+1:]))
|
||||
// f[0] is field 3 (state); utime and stime are fields 14 and 15.
|
||||
if len(f) < 13 {
|
||||
return nil, false
|
||||
}
|
||||
utime, _ := strconv.ParseInt(f[11], 10, 64)
|
||||
stime, _ := strconv.ParseInt(f[12], 10, 64)
|
||||
p := &namedProc{PID: pid, CPU: utime + stime, At: now}
|
||||
status, err := os.ReadFile(filepath.Join(dir, "status"))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, line := range strings.Split(string(status), "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "VmRSS":
|
||||
p.RSS = kib(v)
|
||||
case "Threads":
|
||||
p.Threads, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// kib parses a "1234 kB" value from /proc into bytes.
|
||||
func kib(v string) int64 {
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB")), 10, 64)
|
||||
return n * 1024
|
||||
}
|
||||
|
||||
// Host is the machine's memory, load and uptime.
|
||||
type Host struct {
|
||||
MemTotal int64 `json:"mem_total"`
|
||||
MemAvailable int64 `json:"mem_available"`
|
||||
Load []float64 `json:"load"` // 1, 5 and 15 minutes
|
||||
CPUs int `json:"cpus"`
|
||||
Uptime int64 `json:"uptime"` // seconds
|
||||
}
|
||||
|
||||
func readHost() *Host {
|
||||
h := &Host{CPUs: runtime.NumCPU()}
|
||||
if b, err := os.ReadFile(filepath.Join(procRoot, "meminfo")); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
k, v, _ := strings.Cut(line, ":")
|
||||
switch k {
|
||||
case "MemTotal":
|
||||
h.MemTotal = kib(v)
|
||||
case "MemAvailable":
|
||||
h.MemAvailable = kib(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(procRoot, "loadavg")); err == nil {
|
||||
for _, f := range strings.Fields(string(b))[:min(3, len(strings.Fields(string(b))))] {
|
||||
x, _ := strconv.ParseFloat(f, 64)
|
||||
h.Load = append(h.Load, x)
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(procRoot, "uptime")); err == nil {
|
||||
if f := strings.Fields(string(b)); len(f) > 0 {
|
||||
x, _ := strconv.ParseFloat(f[0], 64)
|
||||
h.Uptime = int64(x)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
+26
-3
@@ -265,7 +265,8 @@ type Timeline struct {
|
||||
Series []*Series `json:"series"`
|
||||
}
|
||||
|
||||
// Series is one counter over time.
|
||||
// Series is one counter over time. For a gauge (g:KEY) the values are
|
||||
// averages, and -1 where there is no sample.
|
||||
type Series struct {
|
||||
Key string `json:"key"`
|
||||
Values []int64 `json:"values"`
|
||||
@@ -340,8 +341,8 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
||||
expanded = append(expanded, k)
|
||||
}
|
||||
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
||||
for _, k := range expanded {
|
||||
raw, err := q.Store.series(ctx, st.res, from, to, k)
|
||||
bucketed := func(key string) ([]int64, error) {
|
||||
raw, err := q.Store.series(ctx, st.res, from, to, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -356,6 +357,28 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
||||
vals[i] += v
|
||||
}
|
||||
}
|
||||
return vals, nil
|
||||
}
|
||||
for _, k := range expanded {
|
||||
vals, err := bucketed(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A gauge (g:KEY) is the average of its samples: its sum divided
|
||||
// by gn:KEY, their number. A bucket without samples is -1.
|
||||
if g, ok := strings.CutPrefix(k, "g:"); ok {
|
||||
n, err := bucketed("gn:" + g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range vals {
|
||||
if n[i] > 0 {
|
||||
vals[i] /= n[i]
|
||||
} else {
|
||||
vals[i] = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
tl.Series = append(tl.Series, &Series{Key: k, Values: vals})
|
||||
}
|
||||
return tl, nil
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
// Server is the latest server statistics: named's process, memory, cache
|
||||
// and sockets, and the host's memory and load. It is sampled with the
|
||||
// counters, once a minute.
|
||||
type Server struct {
|
||||
At time.Time `json:"at"`
|
||||
Version string `json:"version,omitempty"`
|
||||
BootTime string `json:"boot_time,omitempty"`
|
||||
ConfigTime string `json:"config_time,omitempty"`
|
||||
// From /proc (0 when named's process isn't visible).
|
||||
PID int `json:"pid,omitempty"`
|
||||
RSS int64 `json:"rss,omitempty"` // resident memory, bytes
|
||||
Threads int64 `json:"threads,omitempty"`
|
||||
CPUPercent float64 `json:"cpu_percent"` // of one CPU, over the last minute
|
||||
// From the statistics channel.
|
||||
MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses
|
||||
CacheRRsets int64 `json:"cache_rrsets"`
|
||||
CacheMem int64 `json:"cache_mem"`
|
||||
SocketsUDP int64 `json:"sockets_udp"`
|
||||
SocketsTCP int64 `json:"sockets_tcp"`
|
||||
RecursionHighwater int64 `json:"recursion_highwater"` // most recursive clients at once since named started
|
||||
TCPHighwater int64 `json:"tcp_highwater"`
|
||||
Host *Host `json:"host"`
|
||||
}
|
||||
|
||||
// Gauge keys: each is stored as two counters, "g:KEY" (the sum of the
|
||||
// minute samples) and "gn:KEY" (their number), so hours and days add up
|
||||
// like counters and a bucket's value is their average (see Timeline).
|
||||
const (
|
||||
GaugeRSS = "named:rss"
|
||||
GaugeMem = "named:mem"
|
||||
GaugeCPU = "named:cpu" // tenths of a percent of one CPU
|
||||
GaugeCacheMem = "cache:mem"
|
||||
GaugeCacheRRs = "cache:rrsets"
|
||||
GaugeHostMem = "host:mem-used"
|
||||
GaugeHostLoad = "host:load" // the 1-minute load average, times 100
|
||||
)
|
||||
|
||||
// gauge records a sample of a gauge for a minute (none when minute < 0).
|
||||
func (c *Collector) gauge(minute int64, key string, v int64) {
|
||||
if minute < 0 {
|
||||
return
|
||||
}
|
||||
c.count(minute, "g:"+key, v)
|
||||
c.count(minute, "gn:"+key, 1)
|
||||
}
|
||||
|
||||
// sampleServer records the gauges for a minute and keeps the latest
|
||||
// values (minute -1: only those). snap is nil when named's statistics
|
||||
// channel didn't answer.
|
||||
// Called with c.mu held.
|
||||
func (c *Collector) sampleServer(minute int64, now time.Time, snap *Snapshot) {
|
||||
srv := &Server{At: now, Host: readHost()}
|
||||
if c.proc == nil || c.proc.PID == 0 {
|
||||
c.proc = &namedProc{PID: findNamed(c.opts.MainConfig)}
|
||||
}
|
||||
p, ok := readNamedProc(c.proc.PID, now)
|
||||
if !ok {
|
||||
// named restarted with a new PID: look again next minute.
|
||||
c.proc = nil
|
||||
} else {
|
||||
srv.PID, srv.RSS, srv.Threads = p.PID, p.RSS, p.Threads
|
||||
if prev := c.proc; prev.At.Before(now) && !prev.At.IsZero() && p.CPU >= prev.CPU {
|
||||
// Tenths of a percent of one CPU: ticks / clockTicks is CPU seconds.
|
||||
permille := (p.CPU - prev.CPU) * 1000 / clockTicks / max(int64(now.Sub(prev.At)/time.Second), 1)
|
||||
srv.CPUPercent = float64(permille) / 10
|
||||
c.gauge(minute, GaugeCPU, permille)
|
||||
}
|
||||
c.proc = p
|
||||
c.gauge(minute, GaugeRSS, p.RSS)
|
||||
}
|
||||
if h := srv.Host; h.MemTotal > 0 {
|
||||
c.gauge(minute, GaugeHostMem, h.MemTotal-h.MemAvailable)
|
||||
}
|
||||
if h := srv.Host; len(h.Load) > 0 {
|
||||
c.gauge(minute, GaugeHostLoad, int64(h.Load[0]*100))
|
||||
}
|
||||
if snap != nil && snap.Gauges != nil {
|
||||
g := snap.Gauges
|
||||
srv.Version, srv.BootTime, srv.ConfigTime = snap.Version, snap.BootTime, snap.ConfigTime
|
||||
srv.MemInUse, srv.CacheRRsets, srv.CacheMem = g["named:mem"], g["cache:rrsets"], g["cache:mem"]
|
||||
srv.SocketsUDP, srv.SocketsTCP = g["sockets:udp"], g["sockets:tcp"]
|
||||
srv.RecursionHighwater, srv.TCPHighwater = g["named:recursion-highwater"], g["named:tcp-highwater"]
|
||||
c.gauge(minute, GaugeMem, srv.MemInUse)
|
||||
c.gauge(minute, GaugeCacheMem, srv.CacheMem)
|
||||
c.gauge(minute, GaugeCacheRRs, srv.CacheRRsets)
|
||||
}
|
||||
c.server = srv
|
||||
}
|
||||
|
||||
// Server returns the latest server statistics (nil before the first
|
||||
// minute).
|
||||
func (c *Collector) Server() *Server {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.server == nil {
|
||||
return nil
|
||||
}
|
||||
s := *c.server
|
||||
return &s
|
||||
}
|
||||
|
||||
// Disk is the space on a file system Jarl and named use.
|
||||
type Disk struct {
|
||||
Path string `json:"path"` // the first of the directories on it
|
||||
Holds []string `json:"holds"` // what is on it: zone files, logs, ...
|
||||
Total int64 `json:"total"`
|
||||
Free int64 `json:"free"` // available to unprivileged users
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProc(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
old := procRoot
|
||||
procRoot = root
|
||||
defer func() { procRoot = old }()
|
||||
proc := func(pid, comm, cmdline string, utime, stime int) {
|
||||
d := filepath.Join(root, pid)
|
||||
os.MkdirAll(d, 0o755)
|
||||
os.WriteFile(filepath.Join(d, "comm"), []byte(comm+"\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(d, "cmdline"), []byte(cmdline), 0o644)
|
||||
// The command name may hold spaces and parentheses.
|
||||
stat := pid + " (" + comm + " (x)) S 1 1 1 0 -1 4194560 100 0 0 0 " + strconv.Itoa(utime) + " " + strconv.Itoa(stime) + " 0 0 20 0 9 0 100 0 0\n"
|
||||
os.WriteFile(filepath.Join(d, "stat"), []byte(stat), 0o644)
|
||||
os.WriteFile(filepath.Join(d, "status"), []byte("Name:\t"+comm+"\nVmRSS:\t 51200 kB\nThreads:\t9\n"), 0o644)
|
||||
}
|
||||
proc("90", "named", "/usr/sbin/named\x00-c\x00/etc/other.conf", 1, 1)
|
||||
proc("120", "named", "/usr/sbin/named\x00-c\x00/etc/bind/named.conf\x00-u\x00bind", 300, 100)
|
||||
proc("130", "bash", "bash", 0, 0)
|
||||
if pid := findNamed("/etc/bind/named.conf"); pid != 120 {
|
||||
t.Errorf("findNamed = %d, want the one with the config", pid)
|
||||
}
|
||||
if pid := findNamed("/nowhere.conf"); pid != 90 {
|
||||
t.Errorf("findNamed without a match = %d, want the oldest", pid)
|
||||
}
|
||||
now := time.Unix(1_000_000, 0)
|
||||
p, ok := readNamedProc(120, now)
|
||||
if !ok || p.RSS != 51200*1024 || p.Threads != 9 || p.CPU != 400 {
|
||||
t.Fatalf("readNamedProc = %+v, %v", p, ok)
|
||||
}
|
||||
os.WriteFile(filepath.Join(root, "meminfo"), []byte("MemTotal: 4000000 kB\nMemFree: 100 kB\nMemAvailable: 3000000 kB\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "loadavg"), []byte("0.52 0.40 0.31 1/300 4242\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "uptime"), []byte("86400.12 1000.0\n"), 0o644)
|
||||
h := readHost()
|
||||
if h.MemTotal != 4000000*1024 || h.MemAvailable != 3000000*1024 || len(h.Load) != 3 || h.Load[0] != 0.52 || h.Uptime != 86400 {
|
||||
t.Errorf("readHost = %+v", h)
|
||||
}
|
||||
|
||||
// Two samples a minute apart: 30 seconds of CPU time is 50%.
|
||||
c := &Collector{minutes: map[int64]map[string]int64{}, opts: Options{MainConfig: "/etc/bind/named.conf"}}
|
||||
snap := &Snapshot{Gauges: map[string]int64{"named:mem": 1000, "cache:rrsets": 7, "cache:mem": 300, "sockets:tcp": 2}, Version: "9.20"}
|
||||
c.sampleServer(-1, now, snap)
|
||||
if len(c.minutes) != 0 {
|
||||
t.Errorf("minute -1 recorded gauges: %v", c.minutes)
|
||||
}
|
||||
proc("120", "named", "/usr/sbin/named\x00-c\x00/etc/bind/named.conf", 2800, 600) // +3000 ticks = 30 s
|
||||
c.sampleServer(600, now.Add(time.Minute), snap)
|
||||
s := c.Server()
|
||||
if s.PID != 120 || s.CPUPercent != 50 || s.RSS != 51200*1024 || s.CacheRRsets != 7 || s.SocketsTCP != 2 || s.Version != "9.20" {
|
||||
t.Errorf("server = %+v", s)
|
||||
}
|
||||
m := c.minutes[600]
|
||||
if m["g:"+GaugeCPU] != 500 || m["gn:"+GaugeCPU] != 1 || m["g:"+GaugeMem] != 1000 || m["g:"+GaugeHostLoad] != 52 {
|
||||
t.Errorf("gauges = %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGaugeTimeline(t *testing.T) {
|
||||
store, err := OpenStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
start := now.Unix() - now.Unix()%300 - 600 // two 5-minute buckets ago
|
||||
b := &Batch{}
|
||||
for i := int64(0); i < 5; i++ {
|
||||
b.AddCounters(ResMinute, start+i*60, map[string]int64{"g:named:rss": 100 + i*10, "gn:named:rss": 1})
|
||||
}
|
||||
// The next bucket has no samples.
|
||||
if err := store.Commit(context.Background(), b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := &Querier{Store: store, Opts: Options{RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour}, Now: func() time.Time { return now }}
|
||||
tl, err := q.Timeline(context.Background(), 15*time.Minute, "5m", []string{"g:named:rss"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vals := map[int64]int64{}
|
||||
for i, ts := range tl.Times {
|
||||
vals[ts] = tl.Series[0].Values[i]
|
||||
}
|
||||
if vals[start] != 120 || vals[start+300] != -1 {
|
||||
t.Errorf("gauge timeline = %v (times %v)", tl.Series[0].Values, tl.Times)
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -23,7 +23,8 @@ const (
|
||||
|
||||
// Store is the statistics database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
var schema = []string{
|
||||
@@ -62,7 +63,7 @@ func OpenStore(path string) (*Store, error) {
|
||||
if path == ":memory:" {
|
||||
db.SetMaxOpenConns(1)
|
||||
}
|
||||
s := &Store{db: db}
|
||||
s := &Store{db: db, path: path}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("statistics database %s: %w", path, err)
|
||||
@@ -70,6 +71,14 @@ func OpenStore(path string) (*Store, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Path returns the database file ("" in memory).
|
||||
func (s *Store) Path() string {
|
||||
if s.path == ":memory:" {
|
||||
return ""
|
||||
}
|
||||
return s.path
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
|
||||
Reference in new issue
Block a user