Each explore filter takes several values, comma separated (any of them), !value to leave one out, and *.example.com for a name and the names below it. Every value is a chip with its own ✕; clicking a value in the lists adds it, and hide leaves it out. The filter bar's fields take lists. domain= still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
8.3 KiB
Go
272 lines
8.3 KiB
Go
package stats
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
|
// last days (stats.query_retention), 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.
|
|
Retention string `json:"retention"`
|
|
}
|
|
|
|
// 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.query_retention 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)}
|
|
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
|
|
}
|