The Statistics page offers only the ranges kept (longest of the minute, hour and day retentions), adds 1y and 2y when the daily data covers them, caps the heatmap at the hourly retention and links to Explore within retention.queries. Status and explore send retention_seconds. The query log error names stats.retention.queries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
8.5 KiB
Go
273 lines
8.5 KiB
Go
package stats
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
|
// last days (stats.retention.queries), filtered by any mix of client, name,
|
|
// view, type, result and blocking, broken down by each of them.
|
|
|
|
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
|
// field may hold several values, comma separated: any of them; a value
|
|
// written !VALUE excludes it. A name *.example.com is it and the names
|
|
// below it.
|
|
type ExploreFilter struct {
|
|
Client string // a client key, as the top lists show it
|
|
Name string // exactly this name
|
|
Domain string // this name or any below it
|
|
View string
|
|
Type string
|
|
RCode string
|
|
// Blocked is "yes" (blocked by anything), "no", or a blocklist's name
|
|
// ("rules" for your own rules).
|
|
Blocked string
|
|
Since time.Duration // how far back (default 24h)
|
|
}
|
|
|
|
// Dimensions are what the query log breaks down by, in the order shown.
|
|
var Dimensions = []string{"client", "name", "view", "type", "rcode", "blocked"}
|
|
|
|
// Exploration is what the query log says for a filter.
|
|
type Exploration struct {
|
|
From time.Time `json:"from"`
|
|
To time.Time `json:"to"`
|
|
Total int64 `json:"total"`
|
|
// Blocked counts the blocked answers among them.
|
|
Blocked int64 `json:"blocked"`
|
|
// Series are the answers (and blocked ones) per Step seconds.
|
|
Step int64 `json:"step"`
|
|
Series []ExplorePoint `json:"series"`
|
|
// By are the top values of each dimension the filter leaves open.
|
|
By map[string][]Entry `json:"by"`
|
|
// Recent are the latest answers.
|
|
Recent []QueryRow `json:"recent"`
|
|
// Oldest is the first answer the query log still has.
|
|
Oldest *time.Time `json:"oldest,omitempty"`
|
|
// Retention is how long it keeps them, also in seconds.
|
|
Retention string `json:"retention"`
|
|
RetentionSeconds int64 `json:"retention_seconds"`
|
|
}
|
|
|
|
// ExplorePoint is one step of the series.
|
|
type ExplorePoint struct {
|
|
Time int64 `json:"t"`
|
|
Count int64 `json:"count"`
|
|
Blocked int64 `json:"blocked"`
|
|
}
|
|
|
|
// ErrNoQueryLog is returned when the query log is off.
|
|
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.retention.queries above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
|
|
|
// values splits a filter field into the values it must be one of and the
|
|
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
|
func values(field string, norm func(string) string) (in, out []string) {
|
|
for _, v := range strings.Split(field, ",") {
|
|
v = strings.TrimSpace(v)
|
|
not := strings.HasPrefix(v, "!")
|
|
v = norm(strings.TrimSpace(strings.TrimPrefix(v, "!")))
|
|
switch {
|
|
case v == "":
|
|
case not:
|
|
out = append(out, v)
|
|
default:
|
|
in = append(in, v)
|
|
}
|
|
}
|
|
return in, out
|
|
}
|
|
|
|
// nameValues are the name filter's values: Name's, and Domain's as
|
|
// "*.DOMAIN" (the name and the names below it).
|
|
func (f ExploreFilter) nameValues() (in, out []string) {
|
|
norm := func(v string) string { return strings.ToLower(strings.TrimSuffix(v, ".")) }
|
|
in, out = values(f.Name, norm)
|
|
din, dout := values(f.Domain, norm)
|
|
for _, d := range din {
|
|
in = append(in, "*."+strings.TrimPrefix(d, "*."))
|
|
}
|
|
for _, d := range dout {
|
|
out = append(out, "*."+strings.TrimPrefix(d, "*."))
|
|
}
|
|
return in, out
|
|
}
|
|
|
|
// fieldValues returns a dimension's filter values.
|
|
func (f ExploreFilter) fieldValues(dim string) (in, out []string) {
|
|
same := func(v string) string { return v }
|
|
upper := strings.ToUpper
|
|
switch dim {
|
|
case "client":
|
|
return values(f.Client, same)
|
|
case "name":
|
|
return f.nameValues()
|
|
case "view":
|
|
return values(f.View, same)
|
|
case "type":
|
|
return values(f.Type, upper)
|
|
case "rcode":
|
|
return values(f.RCode, upper)
|
|
case "blocked":
|
|
return values(f.Blocked, same)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// cond is the SQL condition for one value of a dimension.
|
|
func cond(dim, v string) (string, []any) {
|
|
switch {
|
|
case dim == "name" && strings.HasPrefix(v, "*."):
|
|
d := strings.TrimPrefix(v, "*.")
|
|
return "(name = ? OR (length(name) > length(?) AND substr(name, length(name) - length(?)) = ?))", []any{d, d, d, "." + d}
|
|
case dim == "blocked" && v == "yes":
|
|
return "blocked != ''", nil
|
|
case dim == "blocked" && v == "no":
|
|
return "blocked = ''", nil
|
|
}
|
|
return dim + " = ?", []any{v}
|
|
}
|
|
|
|
// where builds the SQL condition and arguments for a filter: within a
|
|
// dimension any of its values, and none of its !values; the dimensions
|
|
// together.
|
|
func (q *Querier) where(f ExploreFilter, from int64) (string, []any) {
|
|
conds := []string{"ts >= ?"}
|
|
args := []any{from}
|
|
for _, dim := range Dimensions {
|
|
in, out := f.fieldValues(dim)
|
|
if len(in) > 0 {
|
|
var or []string
|
|
for _, v := range in {
|
|
c, a := cond(dim, v)
|
|
or = append(or, c)
|
|
args = append(args, a...)
|
|
}
|
|
conds = append(conds, "("+strings.Join(or, " OR ")+")")
|
|
}
|
|
for _, v := range out {
|
|
c, a := cond(dim, v)
|
|
conds = append(conds, "NOT "+c)
|
|
args = append(args, a...)
|
|
}
|
|
}
|
|
if len(q.Views) > 0 {
|
|
marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",")
|
|
conds = append(conds, "view IN ("+marks+")")
|
|
for _, v := range q.Views {
|
|
args = append(args, v)
|
|
}
|
|
}
|
|
return strings.Join(conds, " AND "), args
|
|
}
|
|
|
|
// Explore answers a filter: totals, a series, the top values of each open
|
|
// dimension (topN each) and the latest answers (recent of them).
|
|
func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int) (*Exploration, error) {
|
|
if !q.Opts.QueryLog || q.Opts.RetainQueries <= 0 {
|
|
return nil, ErrNoQueryLog
|
|
}
|
|
now := q.Now()
|
|
since := f.Since
|
|
if since <= 0 {
|
|
since = 24 * time.Hour
|
|
}
|
|
if since > q.Opts.RetainQueries {
|
|
since = q.Opts.RetainQueries
|
|
}
|
|
from := now.Add(-since).Unix()
|
|
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
|
Retention: humanDuration(q.Opts.RetainQueries), RetentionSeconds: int64(q.Opts.RetainQueries / time.Second)}
|
|
cond, args := q.where(f, from)
|
|
db := q.Store.db
|
|
|
|
if err := db.QueryRowContext(ctx, `SELECT count(*), coalesce(sum(blocked != ''), 0) FROM queries WHERE `+cond, args...).Scan(&out.Total, &out.Blocked); err != nil {
|
|
return nil, err
|
|
}
|
|
// The series: about 100 steps over the range, in whole minutes.
|
|
step := int64(since.Seconds()) / 100
|
|
step = max(60, step-step%60)
|
|
out.Step = step
|
|
rows, err := db.QueryContext(ctx, `SELECT ts - ts % ? AS t, count(*), sum(blocked != '') FROM queries WHERE `+cond+` GROUP BY t ORDER BY t`, append([]any{step}, args...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
got := map[int64]ExplorePoint{}
|
|
for rows.Next() {
|
|
var p ExplorePoint
|
|
if err := rows.Scan(&p.Time, &p.Count, &p.Blocked); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
got[p.Time] = p
|
|
}
|
|
rows.Close()
|
|
for t := from - from%step; t <= now.Unix(); t += step {
|
|
p := got[t]
|
|
p.Time = t
|
|
out.Series = append(out.Series, p)
|
|
}
|
|
|
|
// A dimension is broken down unless the filter pins it to one value
|
|
// (several: how they compare; !values leave the rest to see).
|
|
open := map[string]bool{}
|
|
for _, dim := range Dimensions {
|
|
in, _ := f.fieldValues(dim)
|
|
open[dim] = len(in) != 1 || (dim == "blocked" && in[0] == "yes") || (dim == "name" && strings.HasPrefix(in[0], "*."))
|
|
}
|
|
for _, dim := range Dimensions {
|
|
if !open[dim] {
|
|
continue
|
|
}
|
|
c := cond
|
|
if dim == "blocked" {
|
|
c += " AND blocked != ''"
|
|
}
|
|
rows, err := db.QueryContext(ctx, `SELECT `+dim+`, count(*) AS n FROM queries WHERE `+c+` GROUP BY `+dim+` ORDER BY n DESC, `+dim+` LIMIT ?`, append(args, topN)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
es := []Entry{}
|
|
for rows.Next() {
|
|
var e Entry
|
|
if err := rows.Scan(&e.Key, &e.Count); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
es = append(es, e)
|
|
}
|
|
rows.Close()
|
|
out.By[dim] = es
|
|
}
|
|
|
|
rows, err = db.QueryContext(ctx, `SELECT ts, client, view, name, type, rcode, blocked FROM queries WHERE `+cond+` ORDER BY ts DESC, rowid DESC LIMIT ?`, append(args, recent)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var r QueryRow
|
|
if err := rows.Scan(&r.Time, &r.Client, &r.View, &r.Name, &r.Type, &r.RCode, &r.Blocked); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
out.Recent = append(out.Recent, r)
|
|
}
|
|
rows.Close()
|
|
|
|
var oldest *int64
|
|
if err := db.QueryRowContext(ctx, `SELECT min(ts) FROM queries`).Scan(&oldest); err == nil && oldest != nil {
|
|
t := time.Unix(*oldest, 0)
|
|
out.Oldest = &t
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// QueryLogSize returns how many answers the query log has.
|
|
func (s *Store) QueryLogSize(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM queries`).Scan(&n)
|
|
return n, err
|
|
}
|