Files
housecarl/internal/stats/query.go
T
abrown38andClaude Opus 5.5 cf30715c5a Statistics: traffic, drops, devices and connections over time
internal/stats samples every minute what the kernel already counts: the
interfaces' counters (per zone too), Housecarl's table (hits per rule,
built-in and policy; drops by chain; quotas; per-device sets), the
connection tracking table, and housecarld's and the host's memory, CPU
and load. Logged drops feed top lists of sources and what they tried.
Aggregates go to a database of their own (stats.db; per minute 7 days,
per hour 90 days, per day 2 years), with the store and queries from
Jarl's.

Traffic per inside device is a setting (stats_per_device): counting sets
stats_up/down_v4/v6 and rules at the top of the forward chain, IPv6 per
/64, carried across reloads. Set names starting with meter_, conn_,
quota_ or stats_ are reserved (set_name_reserved).

GET /stats/{status,summary,timeline,top,hits,heatmap}, hc stats, a
Statistics page with Jarl's charts, two charts on the dashboard, and a
conntrack_full warning. e2e: 188 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:27:48 +01:00

481 lines
14 KiB
Go

package stats
import (
"cmp"
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
// Querier answers questions about stored statistics.
type Querier struct {
Store *Store
Opts Options
Now func() time.Time
}
// NewQuerier returns a querier for a collector's data.
func NewQuerier(c *Collector) *Querier {
return &Querier{Store: c.store, Opts: c.opts, Now: c.now}
}
func (q *Querier) loc() *time.Location {
if q.Opts.Location != nil {
return q.Opts.Location
}
return time.Local
}
// Resolution picks the finest stored resolution that covers a time range
// starting since ago.
func (q *Querier) Resolution(since time.Duration) string {
switch {
case since <= 48*time.Hour && since <= q.Opts.RetainMinute:
return ResMinute
case since <= q.Opts.RetainHour:
return ResHour
}
return ResDay
}
// topsResolution picks the resolution for top lists, which exist per hour
// and per day.
func (q *Querier) topsResolution(since time.Duration) string {
if since <= 7*24*time.Hour && since <= q.Opts.RetainHour {
return ResHour
}
return ResDay
}
// bucketStart returns the start of the bucket containing t at a resolution.
func (q *Querier) bucketStart(res string, t time.Time) int64 {
switch res {
case ResMinute:
return t.Unix() - t.Unix()%60
case ResHour:
return t.Unix() - t.Unix()%3600
}
l := t.In(q.loc())
return time.Date(l.Year(), l.Month(), l.Day(), 0, 0, 0, 0, q.loc()).Unix()
}
// sums returns the totals of counters (optionally with a key prefix) in
// [from, to).
func (s *Store) sums(ctx context.Context, res string, from, to int64, prefix string) (map[string]int64, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(value) FROM counters WHERE res = ? AND ts >= ? AND ts < ? AND key LIKE ? ESCAPE '\' GROUP BY key`,
res, from, to, likePrefix(prefix))
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var k string
var v int64
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
func likePrefix(p string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(p) + "%"
}
// series returns the values of a counter per bucket in [from, to).
func (s *Store) series(ctx context.Context, res string, from, to int64, key string) (map[int64]int64, error) {
rows, err := s.db.QueryContext(ctx, `SELECT ts, value FROM counters WHERE res = ? AND key = ? AND ts >= ? AND ts < ?`, res, key, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]int64{}
for rows.Next() {
var ts, v int64
if err := rows.Scan(&ts, &v); err != nil {
return nil, err
}
out[ts] = v
}
return out, rows.Err()
}
// tops sums top lists (of one or more kinds) in [from, to).
func (s *Store) tops(ctx context.Context, res string, kinds []string, from, to int64, limit int) ([]Entry, error) {
args := []any{res}
for _, k := range kinds {
args = append(args, k)
}
args = append(args, from, to, limit)
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind IN (`+strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")+`) AND ts >= ? AND ts < ?
GROUP BY key ORDER BY n DESC, key LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Entry{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.Key, &e.Count); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func sorted(m map[string]int64, prefix string) []Entry {
out := []Entry{}
for k, v := range m {
if strings.HasPrefix(k, prefix) && v != 0 {
out = append(out, Entry{Key: strings.TrimPrefix(k, prefix), Count: v})
}
}
slices.SortFunc(out, func(a, b Entry) int {
if a.Count != b.Count {
return cmp.Compare(b.Count, a.Count)
}
return cmp.Compare(a.Key, b.Key)
})
return out
}
// Top returns the largest entries of a top list over the last since.
func (q *Querier) Top(ctx context.Context, kind string, since time.Duration, limit int) ([]Entry, error) {
if !slices.Contains(Kinds, kind) && !quotaKind(kind) {
return nil, fmt.Errorf("unknown list %q (use %s, or quota_rN)", kind, strings.Join(Kinds, ", "))
}
now := q.Now()
res := q.topsResolution(since)
return q.Store.tops(ctx, res, []string{kind}, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
}
func quotaKind(k string) bool {
n, ok := strings.CutPrefix(k, "quota_r")
_, err := strconv.ParseInt(n, 10, 64)
return ok && err == nil
}
// Sums returns the totals of the counters with a prefix over the last
// since, largest first, the prefix taken off ("hits:" → "r12").
func (q *Querier) Sums(ctx context.Context, prefix string, since time.Duration) ([]Entry, error) {
now := q.Now()
res := q.Resolution(since)
m, err := q.Store.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, prefix)
if err != nil {
return nil, err
}
return sorted(m, prefix), nil
}
// Traffic is what an interface or zone received and sent, in bytes.
type Traffic struct {
Name string `json:"name"`
Received int64 `json:"received"`
Sent int64 `json:"sent"`
}
// Summary is an overview of the last since.
type Summary struct {
Since string `json:"since"`
From time.Time `json:"from"`
Zones []Traffic `json:"zones"`
Interfaces []Traffic `json:"interfaces"`
// Drops are the packets dropped (rules, built-ins, policies), and by
// chain.
Drops int64 `json:"drops"`
DropsByChain []Entry `json:"drops_by_chain"`
// Rules are the busiest rules, built-ins and policies, by packets.
Rules []Entry `json:"rules"`
// Connections: the most tracked at once (per-bucket averages), and
// the table's size.
PeakConnections int64 `json:"peak_connections"`
ConntrackMax int64 `json:"conntrack_max"`
BlockedSources []Entry `json:"blocked_sources"`
BlockedPorts []Entry `json:"blocked_ports"`
DevicesUp []Entry `json:"devices_up"`
DevicesDown []Entry `json:"devices_down"`
}
// Summary sums up the last since, with top lists of up to topLimit.
func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int) (*Summary, error) {
now := q.Now()
res := q.Resolution(since)
from := q.bucketStart(res, now.Add(-since))
all, err := q.Store.sums(ctx, res, from, now.Unix()+1, "")
if err != nil {
return nil, err
}
s := &Summary{Since: humanDuration(since), From: time.Unix(from, 0), Zones: []Traffic{}, Interfaces: []Traffic{}}
traffic := func(rx, tx string) []Traffic {
by := map[string]*Traffic{}
for k, v := range all {
var name string
var in bool
if n, ok := strings.CutPrefix(k, rx); ok {
name, in = n, true
} else if n, ok := strings.CutPrefix(k, tx); ok {
name = n
} else {
continue
}
t := by[name]
if t == nil {
t = &Traffic{Name: name}
by[name] = t
}
if in {
t.Received += v
} else {
t.Sent += v
}
}
out := []Traffic{}
for _, t := range by {
out = append(out, *t)
}
slices.SortFunc(out, func(a, b Traffic) int {
if c := cmp.Compare(b.Received+b.Sent, a.Received+a.Sent); c != 0 {
return c
}
return cmp.Compare(a.Name, b.Name)
})
return out
}
s.Zones, s.Interfaces = traffic("zrx:", "ztx:"), traffic("rx:", "tx:")
s.Drops = all["drops"]
s.DropsByChain = sorted(all, "drop:")
s.Rules = sorted(all, "hits:")
if len(s.Rules) > topLimit {
s.Rules = s.Rules[:topLimit]
}
if tl, err := q.Timeline(ctx, since, "auto", []string{"g:" + GaugeConntrack, "g:" + GaugeConntrackMax}); err == nil {
for _, v := range tl.Series[0].Values {
s.PeakConnections = max(s.PeakConnections, v)
}
for _, v := range tl.Series[1].Values {
s.ConntrackMax = max(s.ConntrackMax, v)
}
}
for _, l := range []struct {
kind string
dst *[]Entry
}{{KindBlockedSource, &s.BlockedSources}, {KindBlockedPort, &s.BlockedPorts}, {KindDeviceUp, &s.DevicesUp}, {KindDeviceDown, &s.DevicesDown}} {
es, err := q.Top(ctx, l.kind, since, topLimit)
if err != nil {
return nil, err
}
*l.dst = es
}
return s, nil
}
// Timeline is a set of series over time.
type Timeline struct {
Step string `json:"step"`
Times []int64 `json:"times"` // bucket starts (Unix seconds)
Series []*Series `json:"series"`
}
// 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"`
}
var steps = []struct {
name string
d time.Duration
res string
}{
{"1m", time.Minute, ResMinute}, {"5m", 5 * time.Minute, ResMinute}, {"15m", 15 * time.Minute, ResMinute},
{"1h", time.Hour, ResHour}, {"6h", 6 * time.Hour, ResHour}, {"1d", 24 * time.Hour, ResDay},
}
// Timeline returns counters over the last since. keys are counter names;
// a key ending in ":*" means every counter of that group. step is "auto"
// or one of 1m, 5m, 15m, 1h, 6h, 1d.
func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string, keys []string) (*Timeline, error) {
idx := -1
for i, st := range steps {
ok := st.res == ResMinute && since <= q.Opts.RetainMinute || st.res == ResHour && since <= q.Opts.RetainHour || st.res == ResDay
if step == st.name {
if !ok {
return nil, fmt.Errorf("step %s needs %s data, which is only kept for %s", st.name, map[string]string{ResMinute: "per-minute", ResHour: "hourly"}[st.res], humanDuration(map[string]time.Duration{ResMinute: q.Opts.RetainMinute, ResHour: q.Opts.RetainHour}[st.res]))
}
idx = i
break
}
if step == "auto" || step == "" {
if ok && since/st.d <= 300 {
idx = i
break
}
}
}
if idx < 0 {
if step != "auto" && step != "" {
return nil, fmt.Errorf("unknown step %q (use auto, 1m, 5m, 15m, 1h, 6h or 1d)", step)
}
idx = len(steps) - 1
}
st := steps[idx]
now := q.Now()
// Buckets aligned to the step (days to local midnight).
var starts []int64
if st.res == ResDay {
for t := time.Unix(q.bucketStart(ResDay, now.Add(-since)), 0).In(q.loc()); !t.After(now); t = t.AddDate(0, 0, 1) {
starts = append(starts, t.Unix())
}
} else {
sd := int64(st.d.Seconds())
first := now.Add(-since).Unix()
first -= first % sd
for t := first; t <= now.Unix(); t += sd {
starts = append(starts, t)
}
}
from, to := starts[0], now.Unix()+1
// Expand group keys.
var expanded []string
for _, k := range keys {
if g, ok := strings.CutSuffix(k, ":*"); ok {
m, err := q.Store.sums(ctx, st.res, from, to, g+":")
if err != nil {
return nil, err
}
for _, e := range sorted(m, "") {
expanded = append(expanded, e.Key)
}
continue
}
expanded = append(expanded, k)
}
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
bucketed := func(key string) ([]int64, error) {
raw, err := q.Store.series(ctx, st.res, from, to, key)
if err != nil {
return nil, err
}
vals := make([]int64, len(starts))
for ts, v := range raw {
// The bucket is the last start <= ts.
i, found := slices.BinarySearch(starts, ts)
if !found {
i--
}
if i >= 0 && i < len(vals) {
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
}
// Heatmap is average activity per weekday and hour of day (local time).
type Heatmap struct {
Key string `json:"key"`
// Avg[d][h] is the average per hour on weekday d (0 = Monday) at
// hour h; -1 where there is no data.
Avg [7][24]float64 `json:"avg"`
// Samples[d][h] is how many hours the average is based on.
Samples [7][24]int `json:"samples"`
From time.Time `json:"from"`
}
// Heatmap averages a counter (default "drops") by weekday and hour over
// the last since (at most the hourly retention).
func (q *Querier) Heatmap(ctx context.Context, key string, since time.Duration) (*Heatmap, error) {
if key == "" {
key = "drops"
}
since = min(since, q.Opts.RetainHour)
now := q.Now()
from := q.bucketStart(ResHour, now.Add(-since))
raw, err := q.Store.series(ctx, ResHour, from, now.Unix()+1, key)
if err != nil {
return nil, err
}
hm := &Heatmap{Key: key, From: time.Unix(from, 0)}
var sum [7][24]int64
for ts, v := range raw {
t := time.Unix(ts, 0).In(q.loc())
d := (int(t.Weekday()) + 6) % 7
sum[d][t.Hour()] += v
hm.Samples[d][t.Hour()]++
}
for d := range 7 {
for h := range 24 {
if n := hm.Samples[d][h]; n > 0 {
hm.Avg[d][h] = float64(sum[d][h]) / float64(n)
} else {
hm.Avg[d][h] = -1
}
}
}
return hm, nil
}
// ParseSince parses a range such as 90m, 24h, 7d or 2w.
func ParseSince(s string) (time.Duration, error) {
if s == "" {
return 24 * time.Hour, nil
}
mult := time.Duration(0)
switch {
case strings.HasSuffix(s, "d"):
mult = 24 * time.Hour
case strings.HasSuffix(s, "w"):
mult = 7 * 24 * time.Hour
}
if mult > 0 {
var n int
if _, err := fmt.Sscanf(s[:len(s)-1], "%d", &n); err != nil || n <= 0 || fmt.Sprint(n) != s[:len(s)-1] {
return 0, fmt.Errorf("invalid range %q (use e.g. 90m, 24h, 7d, 2w)", s)
}
return time.Duration(n) * mult, nil
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return 0, fmt.Errorf("invalid range %q (use e.g. 90m, 24h, 7d, 2w)", s)
}
return d, nil
}
func humanDuration(d time.Duration) string {
if d%(24*time.Hour) == 0 && d > 0 {
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
}
return d.String()
}