carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package analytics
2
3import (
4 "crypto/sha256"
5 "encoding/binary"
6 "log"
7 "regexp"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/carlosframework/platform/internal/apperr"
13 "github.com/carlosframework/platform/internal/applog"
14 "github.com/carlosframework/platform/internal/usage"
15)
16
17// Obs is one served response as the edge saw it (spec §3.3). The edge
18// fills it from the observer defer; nothing here touches the request.
19type Obs struct {
20 Acct, App, Host string
21 Method string
22 Status int
23 ContentType string
24 Path string // raw; sanitised here
25 RefererHost string // host only, "" = direct
26 UA string
27 ClientIP string
28 Bytes int64
29 Prefetch bool // Sec-Purpose/Purpose: prefetch
30 FetchDest string // Sec-Fetch-Dest, "" when absent
31 When time.Time
32 // GeoIP (geoip spec §9): the request's one answer, as the ingress edge
33 // computed it or as this edge adopted it. GeoState is hit | miss | off,
34 // "" from an edge that predates GeoIP (counted like off).
35 Country string
36 GeoSource string
37 GeoState string
38}
39
40// AppKey is the (account, app) the meter buckets by.
41type AppKey = applog.AppKey
42
43// IsPageview applies §3.3's rule: GET, 200, text/html, not prefetch, dest
44// document or absent, not under /__carlos/. A bot UA is reported
45// separately and never a pageview.
46func IsPageview(o Obs) (pageview, bot bool) {
47 if o.Method != "GET" || o.Status != 200 {
48 return false, false
49 }
50 mt := strings.ToLower(strings.TrimSpace(strings.SplitN(o.ContentType, ";", 2)[0]))
51 if mt != "text/html" {
52 return false, false
53 }
54 if o.Prefetch || (o.FetchDest != "" && o.FetchDest != "document") {
55 return false, false
56 }
57 if strings.HasPrefix(o.Path, "/__carlos/") {
58 return false, false
59 }
60 if IsBot(o.UA) {
61 return false, true
62 }
63 return true, false
64}
65
66// VisitorHash is sha256(salt ‖ host ‖ ip ‖ ua) folded to 64 bits for the
67// sketch; the platform never stores the inputs.
68func VisitorHash(salt []byte, host, ip, ua string) uint64 {
69 h := sha256.New()
70 h.Write(salt)
71 h.Write([]byte{0})
72 h.Write([]byte(host))
73 h.Write([]byte{0})
74 h.Write([]byte(ip))
75 h.Write([]byte{0})
76 h.Write([]byte(ua))
77 return binary.BigEndian.Uint64(h.Sum(nil)[:8])
78}
79
80var eventName = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
81
82// EventName normalises a named event: lowercased, ≤64 bytes of [a-z0-9_.-].
83func EventName(s string) (string, bool) {
84 s = strings.ToLower(strings.TrimSpace(s))
85 if !eventName.MatchString(s) {
86 return "", false
87 }
88 return s, true
89}
90
91// saltRetryDelay bounds how often a failing DaySalt is retried. Visitors
92// go uncounted while it fails, but Observe must never do that I/O (or log)
93// on every request — one attempt, and one log line, per window.
94const saltRetryDelay = 30 * time.Second
95
96// Meter accumulates per-app deltas between flushes. One per edge process;
97// Observe sits on the serving path (one mutex, no I/O except the first
98// salt read of a day).
99type Meter struct {
100 box string
101 saltDir string
102 now func() time.Time
103
104 mu sync.Mutex
105 cur map[AppKey]map[string]*Delta // app → date → delta being built
106 pending map[AppKey][]Delta // re-queued after a failed flush, each marked once
107 dropPending map[AppKey]int64 // deltas dropped by a Requeue that found an already-marked one, awaiting the next delta to land on
108 seq int64
109 saltDay string
110 salt []byte
111 saltFailDay string // date DaySalt last failed for, "" once it next succeeds
112 saltFailAt time.Time // when it failed; not retried until saltRetryDelay has passed
113 started string // date the process started; "" once the wall clock has moved past it (see Drain)
114 restartedFlagged map[AppKey]bool // apps whose start-day delta has already carried Restarted
115}
116
117// NewMeter returns a meter whose salts live in saltDir (the box data dir).
118func NewMeter(saltDir, box string, now func() time.Time) *Meter {
119 return &Meter{box: box, saltDir: saltDir, now: now, cur: map[AppKey]map[string]*Delta{},
120 pending: map[AppKey][]Delta{}, dropPending: map[AppKey]int64{},
121 seq: now().UnixMilli(), started: usage.DayOf(now()), restartedFlagged: map[AppKey]bool{}}
122}
123
124func (m *Meter) saltFor(date string) []byte {
125 if m.saltDay == date && m.salt != nil {
126 return m.salt
127 }
128 if m.saltFailDay == date && m.now().Sub(m.saltFailAt) < saltRetryDelay {
129 return nil // still within the backoff window: don't retry, don't log again
130 }
131 s, err := DaySalt(m.saltDir, date)
132 if err != nil {
133 m.saltFailDay, m.saltFailAt = date, m.now()
134 log.Printf("analytics: day salt: %v (visitors not counted until it works)", err)
135 return nil
136 }
137 m.saltFailDay = ""
138 m.saltDay, m.salt = date, s
139 return s
140}
141
142// delta returns the app's delta being built for date, creating it (and
143// setting Restarted) on first touch. Restarted is per (app, day), not per
144// process: every app gets its own first-delta-of-the-start-day flag, since
145// each app's counts since the last flush may be missing regardless of
146// which app happened to be observed first. restartedFlagged stops a later
147// drain's fresh delta for the same app-day from being flagged again.
148func (m *Meter) delta(k AppKey, date string) *Delta {
149 days := m.cur[k]
150 if days == nil {
151 days = map[string]*Delta{}
152 m.cur[k] = days
153 }
154 d := days[date]
155 if d == nil {
156 restarted := date == m.started && !m.restartedFlagged[k]
157 d = &Delta{Date: date, Box: m.box, Hours: map[string]HourRow{}, Visitors: &HLL{},
158 Pages: NewTopK(), Referers: NewTopK(), Events: NewTopK(), Restarted: restarted,
159 Countries: map[string]int64{}, GeoSources: map[string]int64{}}
160 days[date] = d
161 if restarted {
162 m.restartedFlagged[k] = true // this app's start-day note is spent; a later drain's fresh delta for the same day must not repeat it
163 }
164 }
165 return d
166}
167
168// Observe counts one response. Bytes count for every response; pageview,
169// bot, visitor, page and referer only when the rule says so.
170func (m *Meter) Observe(o Obs) {
171 pv, bot := IsPageview(o)
172 date, hour := usage.DayOf(o.When), usage.HourLabel(o.When)
173 path := apperr.SanitizePath(o.Path)
174 m.mu.Lock()
175 defer m.mu.Unlock()
176 d := m.delta(AppKey{Acct: o.Acct, App: o.App}, date)
177 row := d.Hours[hour]
178 row.Bytes += o.Bytes
179 if bot {
180 row.Bots++
181 }
182 if pv {
183 row.PV++
184 if salt := m.saltFor(date); salt != nil {
185 d.Visitors.Add(VisitorHash(salt, o.Host, o.ClientIP, o.UA))
186 }
187 d.Pages.Add(path, 1)
188 ref := o.RefererHost
189 if ref == "" {
190 ref = "direct"
191 }
192 d.Referers.Add(strings.ToLower(ref), 1)
193 countGeo(d, o)
194 }
195 d.Hours[hour] = row
196}
197
198// Track counts one named event (the doors call it; nothing does in step 2).
199// An invalid name is not silently discarded: it counts against the app's
200// current-day Dropped.A.Malformed so the honesty block sees it.
201func (m *Meter) Track(acct, app, name string, when time.Time) {
202 m.mu.Lock()
203 defer m.mu.Unlock()
204 d := m.delta(AppKey{Acct: acct, App: app}, usage.DayOf(when))
205 n, ok := EventName(name)
206 if !ok {
207 d.Dropped.A.Malformed++
208 return
209 }
210 d.Events.Add(n, 1)
211}
212
213// Drain returns everything buffered — re-queued deltas first, then the
214// current ones, each stamped with a fresh box-monotonic seq — and resets.
215// A window an earlier Requeue had to drop (because it was already marked
216// re-queued when Requeue saw it again) is not silently lost: its count is
217// added to Dropped.Flush on the first delta this drain emits for that app.
218func (m *Meter) Drain() map[AppKey][]Delta {
219 m.mu.Lock()
220 defer m.mu.Unlock()
221 out := map[AppKey][]Delta{}
222 for k, ds := range m.pending {
223 out[k] = append(out[k], ds...)
224 }
225 m.pending = map[AppKey][]Delta{}
226 for k, days := range m.cur {
227 for _, d := range days {
228 m.seq++
229 d.Seq = m.seq
230 out[k] = append(out[k], *d)
231 }
232 }
233 m.cur = map[AppKey]map[string]*Delta{}
234 for k, ds := range out {
235 if len(ds) == 0 {
236 continue
237 }
238 if dropped := m.dropPending[k]; dropped > 0 {
239 ds[0].Dropped.Flush += dropped
240 delete(m.dropPending, k)
241 }
242 }
243 if m.started != "" && usage.DayOf(m.now()) != m.started {
244 m.started = "" // the restart note is only for the day it happened
245 m.restartedFlagged = map[AppKey]bool{} // free the bookkeeping once no future delta can consult it
246 }
247 return out
248}
249
250// Requeue holds a failed flush for the next drain. Each delta may survive
251// one failed flush: Requeue marks it (an unexported field on Delta, never
252// serialised) and holds it in pending. A delta Requeue is handed that is
253// already marked — the flush carrying it forward also failed, with no
254// successful Drain landing it in between — is dropped instead, and the
255// drop is counted per app in dropPending rather than kept, so pending
256// never grows past one window's worth of history no matter how many
257// flushes in a row fail. Seq is left untouched — a re-queued delta keeps
258// the seq it was drained with, so a replay that actually landed is a
259// no-op at the day file.
260func (m *Meter) Requeue(failed map[AppKey][]Delta) {
261 m.mu.Lock()
262 defer m.mu.Unlock()
263 for k, ds := range failed {
264 for _, d := range ds {
265 if d.requeued {
266 m.dropPending[k]++
267 continue
268 }
269 d.requeued = true
270 m.pending[k] = append(m.pending[k], d)
271 }
272 }
273}
274