Explore the statistics: a query log to drill down by client, name, view and more; storage on the dashboard

jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. Every client, name, view, record type, result and blocklist on the Statistics page leads to an explore page for it: answers over time, the top values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore.

The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 18:15:36 +01:00
1 parent fef916b7ab
commit 01ec37b9b2
21 files changed
+922 -27

No files matched your search

+62
View File
@@ -45,6 +45,21 @@ var schema = []string{
PRIMARY KEY (res, ts, kind, key)
) WITHOUT ROWID;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
// 2: the query log, every answer for a while (stats.query_retention),
// for drilling down: what a client asked, who asked for a name
// (docs/stats.md#exploring).
`CREATE TABLE queries (
ts INTEGER NOT NULL,
client TEXT NOT NULL,
view TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
rcode TEXT NOT NULL,
blocked TEXT NOT NULL DEFAULT ''
);
CREATE INDEX queries_ts ON queries (ts);
CREATE INDEX queries_client ON queries (client, ts);
CREATE INDEX queries_name ON queries (name, ts);`,
}
// OpenStore opens (creating if needed) the statistics database. Use
@@ -123,8 +138,25 @@ type Batch struct {
counters []counterRow
tops []topsWrite
meta map[string]string
queries []QueryRow
}
// QueryRow is one answer in the query log.
type QueryRow struct {
Time int64 `json:"ts"` // Unix seconds
Client string `json:"client"`
View string `json:"view"` // "_default" without views
Name string `json:"name"`
Type string `json:"type"`
RCode string `json:"rcode"`
Blocked string `json:"blocked,omitempty"` // the blocklist (or "rules") that blocked it
// ClientName is the client's host name (set by the API).
ClientName string `json:"client_name,omitempty"`
}
// AddQueries adds answers to the query log.
func (b *Batch) AddQueries(rows []QueryRow) { b.queries = append(b.queries, rows...) }
type counterRow struct {
res string
ts int64
@@ -220,9 +252,39 @@ func (s *Store) Commit(ctx context.Context, b *Batch) error {
return err
}
}
if len(b.queries) > 0 {
st, err := tx.PrepareContext(ctx, `INSERT INTO queries (ts, client, view, name, type, rcode, blocked) VALUES (?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
for _, q := range b.queries {
if _, err := st.ExecContext(ctx, q.Time, q.Client, q.View, q.Name, q.Type, q.RCode, q.Blocked); err != nil {
st.Close()
return err
}
}
st.Close()
}
return tx.Commit()
}
// PruneQueries deletes the query log's answers older than before (Unix
// time), a slice at a time so the database isn't locked for long.
func (s *Store) PruneQueries(ctx context.Context, before int64) (int64, error) {
var n int64
for {
r, err := s.db.ExecContext(ctx, `DELETE FROM queries WHERE rowid IN (SELECT rowid FROM queries WHERE ts < ? LIMIT 50000)`, before)
if err != nil {
return n, err
}
c, _ := r.RowsAffected()
n += c
if c < 50000 {
return n, nil
}
}
}
// Meta returns a stored value ("" if unset).
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
var v string