Documentation
¶
Overview ¶
Package analytics is GoRun's built-in, cookieless site analytics: a page view and form-submit beacon, stored in its own analytics.db beside the main database, with daily rollups and an admin dashboard. Visitors are counted by a daily-salted hash; the raw IP, the user agent and query strings are never stored (docs/superpowers/specs/2026-11-03-site-analytics-design.md).
Index ¶
- Constants
- Variables
- type Buffer
- type Event
- type Filter
- type FormRow
- type Module
- type Point
- type PointJSON
- type Row
- type RowJSON
- type Store
- func (s *Store) Close() error
- func (s *Store) DB() *sql.DB
- func (s *Store) DeleteAll(ctx context.Context) (deleted, error)
- func (s *Store) DeleteBefore(ctx context.Context, day time.Time) (deleted, error)
- func (s *Store) Forms(ctx context.Context, f Filter) ([]FormRow, error)
- func (s *Store) Insert(ctx context.Context, evs []Event) error
- func (s *Store) Now(ctx context.Context, at time.Time) (int, error)
- func (s *Store) Path() string
- func (s *Store) Reopen() error
- func (s *Store) Rollup(ctx context.Context, now time.Time) error
- func (s *Store) Series(ctx context.Context, f Filter) ([]Point, error)
- func (s *Store) Summary(ctx context.Context, f Filter) (Summary, error)
- func (s *Store) Top(ctx context.Context, f Filter, dim string, limit int) ([]Row, error)
- func (s *Store) VisitorHash(ctx context.Context, day time.Time, siteID, ip, ua string) (string, error)
- func (s *Store) With(ctx context.Context, fn func(*sql.DB) error) error
- type Summary
- type SummaryJSON
- type SummaryResponse
Constants ¶
const ( BufferCap = 10000 // events held in memory before Add drops FlushEvery = 2 * time.Second // Run's flush interval FlushBatch = 500 // rows per Insert call (and per transaction) )
Write buffer defaults (plan, Global Constraints).
const ( ScriptPath = "/_a/s.js" BeaconPath = "/_a/e" // SettingEnabled turns tracking on or off (JSON bool, default true). SettingEnabled = "analytics.enabled" // SettingExclude lists excluded path prefixes, one per line (JSON string). SettingExclude = "analytics.exclude" )
Collection endpoints and settings.
const ( ModuleName = "analytics" PermView = "analytics.view" CronRollup = "analytics.rollup" )
Module name, permission and cron.
const ( DimTotal = "total" DimPage = "page" DimRef = "ref" DimUTM = "utm" DimBrowser = "browser" DimOS = "os" DimDevice = "device" DimCountry = "country" DimForm = "form" )
daily dims (spec §3). Each row is one (day, dim, key):
- total: key ""; views, visitors and submits of the day.
- page: key is the path; views and visitors of that page's views, and the submits made on that page.
- ref, browser, os, device, country: the column's value; views only (a submit carries none of these), submits 0.
- utm: key "source/medium/campaign" (each part may be empty, e.g. "//spring" for a campaign without source and medium); views carrying no utm value at all have no utm row. Top(dim="utm") uses the same key.
- form: key formKey(form, page); submits only, views and visitors 0 (Forms reads the page's views from the page dim).
visitors is COUNT(DISTINCT visitor) over the day's views with a non-empty visitor. Summing it across days double counts a returning visitor, so a range's visitors are always counted on events (query.go).
const ( KindView = "view" KindSubmit = "submit" )
Event kinds (events.kind).
const DBFile = "analytics.db"
DBFile is the analytics database file name inside the data directory. internal/backup finds the optional backup member by this name (ruling A2).
const GeoFile = "GeoLite2-Country.mmdb"
GeoFile is the optional MaxMind country database the operator drops into the data dir. Without it every country is unknownCountry.
const InsertBatch = 500
InsertBatch is the largest number of rows Insert writes in one transaction.
const ( // SettingBackupExclude leaves analytics.db out of backups (JSON bool, // default false). internal/backup's callers read it by this name. SettingBackupExclude = "analytics.backup_exclude" )
Variables ¶
var ErrClosed = errors.New("analytics: store closed")
ErrClosed is returned by Store methods called after Close.
var ErrDim = errors.New("analytics: unknown dim")
ErrDim is returned by Top for a dim it does not know.
var ErrRange = errors.New("analytics: empty or invalid range")
ErrRange is returned for a Filter whose From is not before its To.
Functions ¶
This section is empty.
Types ¶
type Buffer ¶
type Buffer struct {
// contains filtered or unexported fields
}
Buffer queues events in memory and writes them to the store in batches, so a burst of page views costs one transaction per batch instead of one per request. When it holds cap events, Add drops new ones. It is safe for concurrent use.
func NewBuffer ¶
NewBuffer returns a buffer of capacity cap that Run flushes every every, batch events per Insert (at most InsertBatch). Non-positive values take the defaults above; log may be nil (slog.Default).
func (*Buffer) Add ¶
Add queues e and reports whether it was kept. A zero TS is set to the time of the call, so an event keeps the day it happened on however late it is flushed. An event whose Kind is neither KindView nor KindSubmit is refused (the events CHECK would fail its whole batch), as is every event once Run has stopped. When the buffer is full the event is dropped and a warning is logged at most once per minute, with the number dropped since the last one.
type Event ¶
type Event struct {
TS time.Time
Kind, Path, Visitor, RefHost string
UTMSource, UTMMedium, UTMCampaign string
Browser, OS, Device, Country string
Locale, Form string
}
Event is one stored row of events. TS is stored in UTC at second precision (db.FormatTime); the other fields are stored as given, so the caller strips query strings, caps the path and hashes the visitor before the event reaches the buffer.
type Filter ¶
Filter selects the events a query counts: those with From <= ts < To (UTC; day boundaries are UTC midnights), narrowed by the non-empty fields. Page is a path, Ref a referrer host ("direct" for none), Country an ISO code ("--" unknown), Device mobile, tablet, desktop or unknown.
Submit events carry only a page, so with Ref, Country or Device set they all drop out: submissions (Summary.Submits, Forms) are 0 under those filters.
type FormRow ¶
FormRow is one form on one page: the page's views, the form's submissions there and Submits/Views (0 without views).
type Module ¶
type Module struct {
// contains filtered or unexported fields
}
Module is the analytics kernel.Module.
func (*Module) Enabled ¶
Enabled implements kernel.OptionalModule: on unless the operator turned it off (addons.analytics in the config or the addons.analytics.enabled setting). Disabled, it opens no analytics.db and registers nothing; an existing file is kept.
func (*Module) Migrations ¶
Migrations implements kernel.Module. The module has no tables in the main database: its migrations (migrations/) belong to analytics.db and OpenStore applies them there.
func (*Module) Register ¶
Register opens analytics.db, starts the write buffer (stopped and flushed when the app closes), serves the beacon script and endpoint, injects the script into public HTML, records form submissions (site.form.submitted), declares analytics.view, adds the hourly rollup cron and registers the admin pages, the dashboard widget and the summary API operation. A store that fails to open is logged and the module registers nothing else, so the site keeps serving with collection off.
type PointJSON ¶
type PointJSON struct {
At time.Time `json:"at"`
Visitors int `json:"visitors"`
Views int `json:"views"`
}
PointJSON is one series bucket (hourly for a range of a day or less, else daily), starting at At (UTC).
type RowJSON ¶
type RowJSON struct {
Key string `json:"key"`
Visitors int `json:"visitors"`
Views int `json:"views"`
}
RowJSON is one key of a top list. Paths are percent-encoded as stored; campaign keys are source/medium/campaign.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store owns analytics.db. It is safe for concurrent use.
Every use of the handle (Insert, VisitorHash, With) holds the read lock for its whole duration, and Reopen takes the write lock, so the old handle is closed only once no connection of it is in use. Closing an in-use connection later would let SQLite's WAL close unlink the -wal and -shm files by path, which by then belong to the recreated database.
func OpenStore ¶
OpenStore opens (creating it when missing) <dataDir>/analytics.db and applies the module's migrations to it.
func (*Store) Close ¶
Close closes analytics.db, waiting for in-flight uses. The store stays closed: later calls fail with ErrClosed instead of recreating the file.
func (*Store) DB ¶
DB returns the current handle without holding any lock: a Reopen may close it at any moment. It exists for tests and diagnostics; everything else uses With. It is never nil (a closed handle reports "sql: database is closed").
func (*Store) DeleteAll ¶
DeleteAll empties events and daily and drops every salt but today's (UTC), then VACUUMs analytics.db. Today's salt is kept so a visitor seen again today keeps the same hash.
func (*Store) DeleteBefore ¶
DeleteBefore removes the events before day's UTC midnight and the daily rows of the days before it, then VACUUMs analytics.db.
func (*Store) Forms ¶
Forms returns, for every form submitted in f's range, per page it was submitted on: that page's views, the submissions and the conversion rate, most submissions first. A form with no submission in the range is not listed (the module does not know the site's forms).
func (*Store) Insert ¶
Insert stores evs, InsertBatch rows per transaction, holding the read lock throughout. A batch that fails stops the call; earlier batches stay committed.
func (*Store) Now ¶
Now returns the distinct visitors with a view in the 5 minutes up to at (inclusive at both ends, at second precision).
func (*Store) Reopen ¶
Reopen waits for in-flight uses, closes the handle and opens the file again, creating and migrating it when it is missing. It is the recreate path for a file deleted while running, and what a restore uses to pick up a replaced file.
func (*Store) Rollup ¶
Rollup recomputes daily, with replace semantics, from the earlier of yesterday and the newest day already in daily, through today (now's UTC day). That is today and yesterday on every regular hourly run, and it also fills the days a stopped server missed. When daily is empty but events is not (first start, or daily was cleared), it backfills from the first event's day. Every recomputed day gets a total row, zero when the day had no events, so reads can tell a quiet day from one not rolled up. Running it twice gives the same daily.
func (*Store) Series ¶
Series returns visitors and views per hour when f spans at most one day, else per UTC day, with a zero point for every empty bucket. A day point's visitors are that day's distinct visitors.
func (*Store) Summary ¶
Summary returns f's visitors, views, submissions and views per visitor, with Prev for the equal-length period before it.
func (*Store) Top ¶
Top returns f's keys of dim (page, ref, utm, browser, os, device or country) by visitors, then views, then key; limit <= 0 returns all. A key's views come from daily for settled days; its visitors are counted distinct on events over the whole range. The utm key is "source/medium/campaign", as in daily, and views without utm are left out.
func (*Store) VisitorHash ¶
func (s *Store) VisitorHash(ctx context.Context, day time.Time, siteID, ip, ua string) (string, error)
VisitorHash returns hex(sha256(salt || siteID || ip || ua))[:16], where salt is the 32 random bytes of day's UTC date. It creates that salt on first use and then deletes the salts of earlier days, so a visitor cannot be linked across days and the raw IP and user agent are never stored. Pass the time of the request. A day older than the newest stored salt (a request straddling midnight) uses the newest salt: a past day's salt is never recreated once it has been rotated out.
type Summary ¶
Summary is a period's headline numbers. Prev is the same numbers for the previous period of equal length, ending where this one starts (nil on Prev itself).
type SummaryJSON ¶
type SummaryJSON struct {
Visitors int `json:"visitors"`
Views int `json:"views"`
Submits int `json:"submits"`
ViewsPerVisitor float64 `json:"views_per_visitor"`
Prev *SummaryJSON `json:"prev,omitempty"`
}
SummaryJSON is a period's headline numbers in the API.
type SummaryResponse ¶
type SummaryResponse struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Summary SummaryJSON `json:"summary"`
Series []PointJSON `json:"series"`
Top map[string][]RowJSON `json:"top"`
}
SummaryResponse is the body of GET /api/v1/analytics/summary.