carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The Analytics switch (spec 2026-09-07 §3.3, §3.6): the two settings
4// forms that turn edge counting on, at app and account level. The switch
5// is the whole feature's consent — counting is off until an owner turns it
6// on — so these two doors are the owner's alone, and every write moves the
7// serial: a box acts on this setting, unlike the Errors log level, which
8// only the console reads.
9
10import (
11 "context"
12 "crypto/subtle"
13 "fmt"
14 "log"
15 "math"
16 "net/http"
17 "net/url"
18 "sort"
19 "time"
20
21 "github.com/carlosframework/platform/internal/analytics"
22 "github.com/carlosframework/platform/internal/geoip"
23 "github.com/carlosframework/platform/internal/telemetry"
24)
25
26// validCount and validDoor name the two keys' vocabularies, the way
27// telemetry.ValidLogLevel names the errors half's. "browser" is a mode the
28// spec reserves and no box acts on yet, so it is NOT valid here: a setting
29// nothing honours must not be storable.
30func validCount(s string) bool { return s == telemetry.CountEdge || s == telemetry.CountOff }
31func validDoor(s string) bool { return s == telemetry.DoorOn || s == telemetry.DoorOff }
32
33// mergeAnalytics folds the keys a caller actually sent into st, keeping the
34// one they did not: an owner flipping counting on must not silently reset a
35// door someone set from the CLI. An empty string means "leave alone", which
36// is also how the stored shape spells "not set here" (telemetry.Analytics).
37func mergeAnalytics(st *telemetry.Settings, count, door string) {
38 if st.Analytics == nil {
39 st.Analytics = &telemetry.Analytics{}
40 }
41 if count != "" {
42 st.Analytics.Count = count
43 }
44 if door != "" {
45 st.Analytics.Door = door
46 }
47}
48
49// analyticsFormValues reads the two form fields both switches share. An
50// empty field leaves that key unchanged; a form that names neither changed
51// nothing, and says so rather than writing an empty object and bumping the
52// serial over it.
53func analyticsFormValues(r *http.Request) (count, door, bad string) {
54 count, door = r.FormValue("count"), r.FormValue("door")
55 if count == "" && door == "" {
56 return "", "", "Choose what to change."
57 }
58 if count != "" && !validCount(count) {
59 return "", "", "Choose edge or off for counting."
60 }
61 if door != "" && !validDoor(door) {
62 return "", "", "Choose on or off for the browser door."
63 }
64 return count, door, ""
65}
66
67// analyticsFlash is what the owner is told after a save: what is now true,
68// named, rather than "saved".
69func analyticsFlash(count, door, scope string) string {
70 switch {
71 case count != "" && door != "":
72 return "Analytics counting is now " + count + " and the browser door " + door + " " + scope + "."
73 case door != "":
74 return "The analytics browser door is now " + door + " " + scope + "."
75 default:
76 return "Analytics counting is now " + count + " " + scope + "."
77 }
78}
79
80// handleAppAnalyticsSetting is the app Settings tab's form: owner, CSRF,
81// the two analytics keys. It mirrors handleAppErrorLevel gate for gate —
82// the session first, then the constant-time CSRF compare, then the bare
83// 404 a non-owner gets, so nobody learns from here what they could not
84// already see.
85func (s *Server) handleAppAnalyticsSetting(w http.ResponseWriter, r *http.Request) {
86 addr, raw, acct, role, app, ok := s.accountApp(w, r)
87 if !ok {
88 return
89 }
90 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
91 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
92 return
93 }
94 if role != RoleOwner {
95 http.NotFound(w, r)
96 return
97 }
98 count, door, bad := analyticsFormValues(r)
99 back := appSettingsURL(acct.Sqid, app, "general")
100 // The setting lives in the deployment bucket, so a console without one
101 // has nowhere to put it — refused before the write rather than panicking
102 // inside it, exactly as the errors level is.
103 if s.cfg.Releases == nil {
104 s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.")
105 http.Redirect(w, r, back, http.StatusSeeOther)
106 return
107 }
108 if bad != "" {
109 s.setFlash(w, "err", bad)
110 http.Redirect(w, r, back, http.StatusSeeOther)
111 return
112 }
113 if err := s.updateAppTelemetry(r.Context(), acct, app, "analytics-setting", func(st *telemetry.Settings) {
114 mergeAnalytics(st, count, door)
115 }); err != nil {
116 log.Printf("console: analytics setting %s/%s: %v", acct.Sqid, app, err)
117 s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
118 http.Redirect(w, r, back, http.StatusSeeOther)
119 return
120 }
121 s.audit(acct, app, addr, "analytics-setting", "set "+analyticsAudit(count, door)+" for this app")
122 // Box-consumed: the edge starts or stops counting on the next pass, so
123 // the serial moves rather than leaving it to the hourly floor.
124 s.bumpSerial(r.Context())
125 s.setFlash(w, "ok", analyticsFlash(count, door, "for this app"))
126 http.Redirect(w, r, back, http.StatusSeeOther)
127}
128
129// handleAccountAnalyticsSetting is the account settings form: the default
130// every app without its own setting inherits.
131func (s *Server) handleAccountAnalyticsSetting(w http.ResponseWriter, r *http.Request) {
132 addr, raw, acct, role, ok := s.account(w, r)
133 if !ok {
134 return
135 }
136 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
137 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
138 return
139 }
140 if role != RoleOwner {
141 http.NotFound(w, r)
142 return
143 }
144 count, door, bad := analyticsFormValues(r)
145 back := "/a/" + url.PathEscape(acct.Sqid) + "/settings"
146 if s.cfg.Releases == nil {
147 s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.")
148 http.Redirect(w, r, back, http.StatusSeeOther)
149 return
150 }
151 if bad != "" {
152 s.setFlash(w, "err", bad)
153 http.Redirect(w, r, back, http.StatusSeeOther)
154 return
155 }
156 if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AccountKey(acct.Sqid), func(st *telemetry.Settings) {
157 mergeAnalytics(st, count, door)
158 }); err != nil {
159 log.Printf("console: account analytics setting %s: %v", acct.Sqid, err)
160 s.setFlash(w, "err", "The setting could not be saved — try again in a moment.")
161 http.Redirect(w, r, back, http.StatusSeeOther)
162 return
163 }
164 // Filed under the account subject rather than an empty app column, for
165 // the reason handleAccountErrorLevel spells out.
166 s.audit(acct, accountAuditSubject, addr, "analytics-setting", "set the account's "+analyticsAudit(count, door))
167 s.bumpSerial(r.Context())
168 s.setFlash(w, "ok", analyticsFlash(count, door, "for every app without its own setting"))
169 http.Redirect(w, r, back, http.StatusSeeOther)
170}
171
172// analyticsAudit names the change for the activity feed: only the keys the
173// form actually carried.
174func analyticsAudit(count, door string) string {
175 switch {
176 case count != "" && door != "":
177 return "analytics counting to " + count + " and the browser door to " + door
178 case door != "":
179 return "the analytics browser door to " + door
180 default:
181 return "analytics counting to " + count
182 }
183}
184
185// ---- the Analytics tab's read (spec §3.6) -----------------------------------
186//
187// The tab and `carlos analytics` are the same read: readAnalytics assembles
188// the window, the page renders it and apianalytics.go serialises it, exactly
189// as readErrors serves both ends of the Errors pair.
190
191// analyticsWindow is one assembled read: the summary, the range it covers,
192// the resolved setting (so the page can say counting is off, and where that
193// came from), and the chart geometry the template plots.
194type analyticsWindow struct {
195 Sum analytics.Summary
196 Range string
197 On bool
198 Setting telemetry.Resolved
199 ChartBars []chartBar
200 ChartMax int64
201}
202
203// chartBar is one plotted column: the reading, and the rectangle that draws
204// it. The geometry is computed here rather than in the template because a
205// template cannot divide, and because the scaling is the part worth testing.
206type chartBar struct {
207 Label string
208 Value int64
209 X, Y, W float64
210 H float64
211}
212
213// The three windows the tab and the API accept. Anything else is a mistake
214// worth naming at the API and worth ignoring at the page.
215const (
216 rangeToday = "today"
217 range7d = "7d"
218 range30d = "30d"
219)
220
221func validRange(s string) bool { return s == rangeToday || s == range7d || s == range30d }
222
223// analyticsSpan turns a range name into a UTC span. Truncate(24h) lands on
224// midnight because Go's zero instant is midnight UTC. The end is now, not
225// the end of the day: a window never claims to cover hours that have not
226// happened yet.
227func analyticsSpan(rng string, now time.Time) (from, to time.Time) {
228 to = now.UTC()
229 midnight := to.Truncate(24 * time.Hour)
230 switch rng {
231 case range7d:
232 return midnight.AddDate(0, 0, -6), to
233 case range30d:
234 return midnight.AddDate(0, 0, -29), to
235 default:
236 return midnight, to
237 }
238}
239
240// chartWidth and chartHeight are the plot's viewBox units — not pixels: the
241// SVG scales to whatever column it lands in, so these fix only the aspect
242// and the resolution of the geometry.
243const (
244 chartWidth = 480.0
245 chartHeight = 120.0
246)
247
248// readAnalytics assembles the window. It resolves the setting first and then
249// reads whichever way that came out: numbers already collected are still
250// shown after an owner turns counting off, because they were collected while
251// it was on and hiding them would read as data loss.
252func (s *Server) readAnalytics(ctx context.Context, sqid, app, rng string, now time.Time) (analyticsWindow, error) {
253 out := analyticsWindow{Range: rng}
254 res, err := telemetry.Resolve(ctx, s.cfg.Releases, sqid, app)
255 if err != nil {
256 return out, err
257 }
258 out.Setting, out.On = res, res.Count == telemetry.CountEdge
259 // No deployment bucket on this host: there is nothing to read, and
260 // ReadRange would be handed a nil store. The resolved defaults above are
261 // still the honest answer to "is counting on".
262 if s.cfg.Releases == nil {
263 return out, nil
264 }
265 from, to := analyticsSpan(rng, now)
266 sum, err := analytics.ReadRange(ctx, s.cfg.Releases, sqid, app, from, to)
267 if err != nil {
268 return out, err
269 }
270 out.Sum = sum
271 vals, labels := chartSeries(rng, sum)
272 out.ChartBars, out.ChartMax = chartBars(vals, labels, chartWidth, chartHeight)
273 return out, nil
274}
275
276// chartSeries picks what the bars plot: today is the 24 hourly page-view
277// counts, a multi-day window is one bar per day. A day the range covers but
278// no box wrote a file for is a zero rather than a gap — ReadRange returns
279// one DaySummary per date either way, so the axis stays evenly spaced.
280func chartSeries(rng string, sum analytics.Summary) (vals []int64, labels []string) {
281 if rng == rangeToday {
282 if len(sum.Days) == 0 {
283 return nil, nil
284 }
285 // The last row, not the first: today's span is one date, and taking
286 // the tail keeps this right if a caller ever passes a wider one.
287 d := sum.Days[len(sum.Days)-1]
288 for h := 0; h < 24; h++ {
289 vals = append(vals, d.Hours[h])
290 labels = append(labels, fmt.Sprintf("%02d", h))
291 }
292 return vals, labels
293 }
294 for _, d := range sum.Days {
295 vals = append(vals, d.PV)
296 labels = append(labels, dayLabel(d.Date))
297 }
298 return vals, labels
299}
300
301// dayLabel shortens a UTC date to MM-DD: thirty bars cannot each carry a
302// year, and the window's own dates are printed in full beside the chart.
303func dayLabel(date string) string {
304 if len(date) == len("2006-01-02") {
305 return date[5:]
306 }
307 return date
308}
309
310// chartBars scales vals onto a w x h box: one column per reading, two units
311// of gap between columns, height proportional to the largest reading in the
312// window — returned as the second value, so the template can say what the
313// peak was rather than leaving the axis unlabelled.
314//
315// Nothing to plot — no readings, or nothing but zeros — returns no bars at
316// all: a row of flat rectangles under a "peak 0" caption is noise, and the
317// template renders no chart rather than an empty one. Coordinates are
318// rounded to two decimals for the reason sparkPoints rounds its own: the
319// trailing digits of a binary fraction are bytes on every page view that
320// change nothing anyone can see.
321func chartBars(vals []int64, labels []string, w, h float64) ([]chartBar, int64) {
322 var max int64
323 for _, v := range vals {
324 if v > max {
325 max = v
326 }
327 }
328 if len(vals) == 0 || max <= 0 {
329 return nil, max
330 }
331 step := w / float64(len(vals))
332 bw := step - 2
333 if bw <= 0 {
334 bw = step // more columns than gaps will fit: draw them touching
335 }
336 out := make([]chartBar, 0, len(vals))
337 for i, v := range vals {
338 b := chartBar{Value: v, X: round2(float64(i) * step), W: round2(bw)}
339 if i < len(labels) {
340 b.Label = labels[i]
341 }
342 if v > 0 {
343 b.H = round2(float64(v) / float64(max) * h)
344 }
345 b.Y = round2(h - b.H)
346 out = append(out, b)
347 }
348 return out, max
349}
350
351func round2(f float64) float64 { return math.Round(f*100) / 100 }
352
353// kv is one row of a top-keys table.
354type kv struct {
355 Key string
356 Value int64
357}
358
359// sortedKV orders a top-keys map for rendering: biggest first, ties broken
360// by key so two runs of the same data render the same bytes, and the
361// overflow row (analytics.OtherKey — "everything past the cap", not a page
362// anybody visited) pinned to the bottom whatever its count is. It is a
363// template func because a Go template ranges a map in KEY order, which for a
364// top-pages table is the one order that hides the answer.
365func sortedKV(m map[string]int64) []kv {
366 out := make([]kv, 0, len(m))
367 for k, v := range m {
368 out = append(out, kv{Key: k, Value: v})
369 }
370 sort.Slice(out, func(i, j int) bool {
371 if a, b := out[i].Key == analytics.OtherKey, out[j].Key == analytics.OtherKey; a != b {
372 return b
373 }
374 if out[i].Value != out[j].Value {
375 return out[i].Value > out[j].Value
376 }
377 return out[i].Key < out[j].Key
378 })
379 return out
380}
381
382// handleAppAnalytics renders the Analytics tab: the same read the API
383// answers, with the window as a GET param so a view is a URL.
384func (s *Server) handleAppAnalytics(w http.ResponseWriter, r *http.Request) {
385 p, acct, ok := s.appPage(w, r, "analytics")
386 if !ok {
387 return
388 }
389 // A page never 400s over its own query string: an unknown range falls
390 // back to today, the way the Errors tab falls back to its default window
391 // rather than refusing a URL someone mistyped.
392 p.AnalyticsRange = r.URL.Query().Get("range")
393 if !validRange(p.AnalyticsRange) {
394 p.AnalyticsRange = rangeToday
395 }
396 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
397 defer cancel()
398 win, err := s.readAnalytics(ctx, acct.Sqid, p.AppName, p.AnalyticsRange, time.Now())
399 if err != nil {
400 log.Printf("console: app analytics %s/%s: %v", acct.Sqid, p.AppName, err)
401 p.AnalyticsErr = "The analytics could not be read just now — try again in a moment."
402 // A failed read leaves no resolved setting, and the page must not
403 // then say counting is off "from the " with nothing after it. The
404 // defaults are what a nil store resolves to, so ask for them rather
405 // than restating them here.
406 win.Setting, _ = telemetry.Resolve(ctx, nil, acct.Sqid, p.AppName)
407 }
408 p.AnalyticsWin = win
409 s.render(w, http.StatusOK, "app_analytics", p)
410}
411
412// geoCountries is the Countries table (geoip spec §9): the top 10 plus
413// other, with the two reserved buckets given words. nil when nothing was
414// counted, so the template can say so.
415func geoCountries(m map[string]int64) map[string]int64 {
416 if len(m) == 0 {
417 return nil
418 }
419 // The reserved keys stay as they are so sortedKV can put _other last;
420 // geoLabel gives them words at render time (code review R1-6).
421 return topWithOther(m, 10)
422}
423
424// geoLabel is a Countries row's label: the reserved buckets get words.
425func geoLabel(k string) string {
426 switch k {
427 case "_unknown":
428 return "unknown"
429 case "_other":
430 return "other"
431 }
432 return k
433}
434
435// geoAttributions is one credit line per source in the range that requires
436// one (geoip spec §13), in a stable order, without repeats: two GeoLite2
437// types share one line.
438func geoAttributions(sources map[string]int64) []geoip.Attribution {
439 var keys []string
440 for k := range sources {
441 keys = append(keys, k)
442 }
443 sort.Strings(keys)
444 seen := map[string]bool{}
445 var out []geoip.Attribution
446 for _, k := range keys {
447 if a, ok := geoip.AttributionFor(k); ok && !seen[a.Text] {
448 seen[a.Text] = true
449 out = append(out, a)
450 }
451 }
452 return out
453}
454