package obs
import (
"net/http"
"net/url"
"sync"
"time"
)
// The user-agent classes a rollup row is split by. Closed set.
const (
AgentCrawler = "crawler" // names itself as a search or AI crawler
AgentAutomated = "automated" // a script, a library, a sync client, anything not a browser
AgentBrowser = "browser"
)
// Unmatched is the pattern of a request no route took.
const Unmatched = "(unmatched)"
// HourKey is what a rollup row is counted by: the hour, the mux
// pattern (never the raw path), and the kind of source. No address is
// in it, so nothing here needs to be dropped after a day.
type HourKey struct {
Hour time.Time // UTC, truncated to the hour
Pattern string
SignedIn bool // the forge verified an account
Agent string // AgentCrawler, AgentAutomated or AgentBrowser
Network string // from the local ASN table, "" when unknown
Country string // the edge's guess, "" when nothing in front says
}
// HourCount is one rollup row: requests and the handler time they took.
type HourCount struct {
HourKey
Requests int64
Elapsed time.Duration
}
// Rollup counts requests in memory by HourKey. Adding is one map
// update under a lock; the store sees a row only when a closed hour is
// taken, so the hot path never waits on it.
type Rollup struct {
// Max bounds the keys held; 0 means 20000. Past it a new key loses
// its network and country, so a flood of distinct networks cannot
// grow the map without bound.
Max int
mu sync.Mutex
m map[HourKey]*HourCount
}
// AgentOf classes a user agent. Only the string and the network speak
// here; who signed in is its own column.
func AgentOf(ua string, asn uint32) string {
switch r := Classify(Facts{UA: ua, ASN: asn}); {
case r.Kind == KindCrawler:
return AgentCrawler
case r.Kind == KindAutomated:
return AgentAutomated
case isBrowser(ua):
return AgentBrowser
}
return AgentAutomated
}
// Add counts one hit.
func (r *Rollup) Add(h Hit) {
k := HourKey{
Hour: h.At.UTC().Truncate(time.Hour), Pattern: h.Pattern,
SignedIn: h.Account != "", Agent: AgentOf(h.UA, h.ASN),
Network: h.Network, Country: h.Country,
}
if k.Pattern == "" {
k.Pattern = Unmatched
}
var took time.Duration
if !h.Stream { // a stream's time is how long a tab stayed open
took = h.Elapsed
}
r.mu.Lock()
defer r.mu.Unlock()
r.bump(k, 1, took)
}
func (r *Rollup) bump(k HourKey, n int64, took time.Duration) {
if r.m == nil {
r.m = map[HourKey]*HourCount{}
}
c, ok := r.m[k]
if !ok {
limit := r.Max
if limit <= 0 {
limit = 20000
}
if len(r.m) >= limit {
k.Network, k.Country = "", ""
c, ok = r.m[k]
}
if !ok {
c = &HourCount{HourKey: k}
r.m[k] = c
}
}
c.Requests += n
c.Elapsed += took
}
// Take removes and returns the rows for hours before the given one; a
// zero time takes everything.
func (r *Rollup) Take(before time.Time) []HourCount {
r.mu.Lock()
defer r.mu.Unlock()
var out []HourCount
for k, c := range r.m {
if before.IsZero() || k.Hour.Before(before) {
out = append(out, *c)
delete(r.m, k)
}
}
return out
}
// Restore puts rows back, for a write that failed.
func (r *Rollup) Restore(rows []HourCount) {
r.mu.Lock()
defer r.mu.Unlock()
for _, c := range rows {
r.bump(c.HourKey, c.Requests, c.Elapsed)
}
}
// Pattern records the mux pattern that served the request. The finest
// one wins: a handler inside the mux knows more than a resolver outside
// it, so a later non-empty call replaces an earlier one.
func (n *HitNote) Pattern(p string) {
if n == nil || p == "" {
return
}
n.mu.Lock()
n.pattern = p
n.mu.Unlock()
}
// SetRoutes is how the recorder names the route of a request no handler
// noted — sync, runner downloads, assets. It must not dispatch.
func (r *Recorder) SetRoutes(f func(*http.Request) string) {
r.mu.Lock()
r.routes = f
r.mu.Unlock()
}
// routeOf asks the resolver about the request as it came in, whatever
// the handlers did to the path since.
func (r *Recorder) routeOf(req *http.Request, path string) string {
r.mu.Lock()
f := r.routes
r.mu.Unlock()
if f == nil {
return ""
}
u := url.URL{Path: path}
c := *req
c.URL = &u
return f(&c)
}
// HourlyQuery selects rollup rows. Pattern "" is every route; a zero
// Since or Until leaves that end open. Until is exclusive.
type HourlyQuery struct {
Pattern string
Since, Until time.Time
}