package console // The Analytics switch (spec 2026-09-07 §3.3, §3.6): the two settings // forms that turn edge counting on, at app and account level. The switch // is the whole feature's consent — counting is off until an owner turns it // on — so these two doors are the owner's alone, and every write moves the // serial: a box acts on this setting, unlike the Errors log level, which // only the console reads. import ( "context" "crypto/subtle" "fmt" "log" "math" "net/http" "net/url" "sort" "time" "github.com/carlosframework/platform/internal/analytics" "github.com/carlosframework/platform/internal/geoip" "github.com/carlosframework/platform/internal/telemetry" ) // validCount and validDoor name the two keys' vocabularies, the way // telemetry.ValidLogLevel names the errors half's. "browser" is a mode the // spec reserves and no box acts on yet, so it is NOT valid here: a setting // nothing honours must not be storable. func validCount(s string) bool { return s == telemetry.CountEdge || s == telemetry.CountOff } func validDoor(s string) bool { return s == telemetry.DoorOn || s == telemetry.DoorOff } // mergeAnalytics folds the keys a caller actually sent into st, keeping the // one they did not: an owner flipping counting on must not silently reset a // door someone set from the CLI. An empty string means "leave alone", which // is also how the stored shape spells "not set here" (telemetry.Analytics). func mergeAnalytics(st *telemetry.Settings, count, door string) { if st.Analytics == nil { st.Analytics = &telemetry.Analytics{} } if count != "" { st.Analytics.Count = count } if door != "" { st.Analytics.Door = door } } // analyticsFormValues reads the two form fields both switches share. An // empty field leaves that key unchanged; a form that names neither changed // nothing, and says so rather than writing an empty object and bumping the // serial over it. func analyticsFormValues(r *http.Request) (count, door, bad string) { count, door = r.FormValue("count"), r.FormValue("door") if count == "" && door == "" { return "", "", "Choose what to change." } if count != "" && !validCount(count) { return "", "", "Choose edge or off for counting." } if door != "" && !validDoor(door) { return "", "", "Choose on or off for the browser door." } return count, door, "" } // analyticsFlash is what the owner is told after a save: what is now true, // named, rather than "saved". func analyticsFlash(count, door, scope string) string { switch { case count != "" && door != "": return "Analytics counting is now " + count + " and the browser door " + door + " " + scope + "." case door != "": return "The analytics browser door is now " + door + " " + scope + "." default: return "Analytics counting is now " + count + " " + scope + "." } } // handleAppAnalyticsSetting is the app Settings tab's form: owner, CSRF, // the two analytics keys. It mirrors handleAppErrorLevel gate for gate — // the session first, then the constant-time CSRF compare, then the bare // 404 a non-owner gets, so nobody learns from here what they could not // already see. func (s *Server) handleAppAnalyticsSetting(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } count, door, bad := analyticsFormValues(r) back := appSettingsURL(acct.Sqid, app, "general") // The setting lives in the deployment bucket, so a console without one // has nowhere to put it — refused before the write rather than panicking // inside it, exactly as the errors level is. if s.cfg.Releases == nil { s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.") http.Redirect(w, r, back, http.StatusSeeOther) return } if bad != "" { s.setFlash(w, "err", bad) http.Redirect(w, r, back, http.StatusSeeOther) return } if err := s.updateAppTelemetry(r.Context(), acct, app, "analytics-setting", func(st *telemetry.Settings) { mergeAnalytics(st, count, door) }); err != nil { log.Printf("console: analytics setting %s/%s: %v", acct.Sqid, app, err) s.setFlash(w, "err", "The setting could not be saved — try again in a moment.") http.Redirect(w, r, back, http.StatusSeeOther) return } s.audit(acct, app, addr, "analytics-setting", "set "+analyticsAudit(count, door)+" for this app") // Box-consumed: the edge starts or stops counting on the next pass, so // the serial moves rather than leaving it to the hourly floor. s.bumpSerial(r.Context()) s.setFlash(w, "ok", analyticsFlash(count, door, "for this app")) http.Redirect(w, r, back, http.StatusSeeOther) } // handleAccountAnalyticsSetting is the account settings form: the default // every app without its own setting inherits. func (s *Server) handleAccountAnalyticsSetting(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, ok := s.account(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } count, door, bad := analyticsFormValues(r) back := "/a/" + url.PathEscape(acct.Sqid) + "/settings" if s.cfg.Releases == nil { s.setFlash(w, "err", "No deployment bucket on this host, so this setting cannot be stored.") http.Redirect(w, r, back, http.StatusSeeOther) return } if bad != "" { s.setFlash(w, "err", bad) http.Redirect(w, r, back, http.StatusSeeOther) return } if err := telemetry.Update(r.Context(), s.cfg.Releases, telemetry.AccountKey(acct.Sqid), func(st *telemetry.Settings) { mergeAnalytics(st, count, door) }); err != nil { log.Printf("console: account analytics setting %s: %v", acct.Sqid, err) s.setFlash(w, "err", "The setting could not be saved — try again in a moment.") http.Redirect(w, r, back, http.StatusSeeOther) return } // Filed under the account subject rather than an empty app column, for // the reason handleAccountErrorLevel spells out. s.audit(acct, accountAuditSubject, addr, "analytics-setting", "set the account's "+analyticsAudit(count, door)) s.bumpSerial(r.Context()) s.setFlash(w, "ok", analyticsFlash(count, door, "for every app without its own setting")) http.Redirect(w, r, back, http.StatusSeeOther) } // analyticsAudit names the change for the activity feed: only the keys the // form actually carried. func analyticsAudit(count, door string) string { switch { case count != "" && door != "": return "analytics counting to " + count + " and the browser door to " + door case door != "": return "the analytics browser door to " + door default: return "analytics counting to " + count } } // ---- the Analytics tab's read (spec §3.6) ----------------------------------- // // The tab and `carlos analytics` are the same read: readAnalytics assembles // the window, the page renders it and apianalytics.go serialises it, exactly // as readErrors serves both ends of the Errors pair. // analyticsWindow is one assembled read: the summary, the range it covers, // the resolved setting (so the page can say counting is off, and where that // came from), and the chart geometry the template plots. type analyticsWindow struct { Sum analytics.Summary Range string On bool Setting telemetry.Resolved ChartBars []chartBar ChartMax int64 } // chartBar is one plotted column: the reading, and the rectangle that draws // it. The geometry is computed here rather than in the template because a // template cannot divide, and because the scaling is the part worth testing. type chartBar struct { Label string Value int64 X, Y, W float64 H float64 } // The three windows the tab and the API accept. Anything else is a mistake // worth naming at the API and worth ignoring at the page. const ( rangeToday = "today" range7d = "7d" range30d = "30d" ) func validRange(s string) bool { return s == rangeToday || s == range7d || s == range30d } // analyticsSpan turns a range name into a UTC span. Truncate(24h) lands on // midnight because Go's zero instant is midnight UTC. The end is now, not // the end of the day: a window never claims to cover hours that have not // happened yet. func analyticsSpan(rng string, now time.Time) (from, to time.Time) { to = now.UTC() midnight := to.Truncate(24 * time.Hour) switch rng { case range7d: return midnight.AddDate(0, 0, -6), to case range30d: return midnight.AddDate(0, 0, -29), to default: return midnight, to } } // chartWidth and chartHeight are the plot's viewBox units — not pixels: the // SVG scales to whatever column it lands in, so these fix only the aspect // and the resolution of the geometry. const ( chartWidth = 480.0 chartHeight = 120.0 ) // readAnalytics assembles the window. It resolves the setting first and then // reads whichever way that came out: numbers already collected are still // shown after an owner turns counting off, because they were collected while // it was on and hiding them would read as data loss. func (s *Server) readAnalytics(ctx context.Context, sqid, app, rng string, now time.Time) (analyticsWindow, error) { out := analyticsWindow{Range: rng} res, err := telemetry.Resolve(ctx, s.cfg.Releases, sqid, app) if err != nil { return out, err } out.Setting, out.On = res, res.Count == telemetry.CountEdge // No deployment bucket on this host: there is nothing to read, and // ReadRange would be handed a nil store. The resolved defaults above are // still the honest answer to "is counting on". if s.cfg.Releases == nil { return out, nil } from, to := analyticsSpan(rng, now) sum, err := analytics.ReadRange(ctx, s.cfg.Releases, sqid, app, from, to) if err != nil { return out, err } out.Sum = sum vals, labels := chartSeries(rng, sum) out.ChartBars, out.ChartMax = chartBars(vals, labels, chartWidth, chartHeight) return out, nil } // chartSeries picks what the bars plot: today is the 24 hourly page-view // counts, a multi-day window is one bar per day. A day the range covers but // no box wrote a file for is a zero rather than a gap — ReadRange returns // one DaySummary per date either way, so the axis stays evenly spaced. func chartSeries(rng string, sum analytics.Summary) (vals []int64, labels []string) { if rng == rangeToday { if len(sum.Days) == 0 { return nil, nil } // The last row, not the first: today's span is one date, and taking // the tail keeps this right if a caller ever passes a wider one. d := sum.Days[len(sum.Days)-1] for h := 0; h < 24; h++ { vals = append(vals, d.Hours[h]) labels = append(labels, fmt.Sprintf("%02d", h)) } return vals, labels } for _, d := range sum.Days { vals = append(vals, d.PV) labels = append(labels, dayLabel(d.Date)) } return vals, labels } // dayLabel shortens a UTC date to MM-DD: thirty bars cannot each carry a // year, and the window's own dates are printed in full beside the chart. func dayLabel(date string) string { if len(date) == len("2006-01-02") { return date[5:] } return date } // chartBars scales vals onto a w x h box: one column per reading, two units // of gap between columns, height proportional to the largest reading in the // window — returned as the second value, so the template can say what the // peak was rather than leaving the axis unlabelled. // // Nothing to plot — no readings, or nothing but zeros — returns no bars at // all: a row of flat rectangles under a "peak 0" caption is noise, and the // template renders no chart rather than an empty one. Coordinates are // rounded to two decimals for the reason sparkPoints rounds its own: the // trailing digits of a binary fraction are bytes on every page view that // change nothing anyone can see. func chartBars(vals []int64, labels []string, w, h float64) ([]chartBar, int64) { var max int64 for _, v := range vals { if v > max { max = v } } if len(vals) == 0 || max <= 0 { return nil, max } step := w / float64(len(vals)) bw := step - 2 if bw <= 0 { bw = step // more columns than gaps will fit: draw them touching } out := make([]chartBar, 0, len(vals)) for i, v := range vals { b := chartBar{Value: v, X: round2(float64(i) * step), W: round2(bw)} if i < len(labels) { b.Label = labels[i] } if v > 0 { b.H = round2(float64(v) / float64(max) * h) } b.Y = round2(h - b.H) out = append(out, b) } return out, max } func round2(f float64) float64 { return math.Round(f*100) / 100 } // kv is one row of a top-keys table. type kv struct { Key string Value int64 } // sortedKV orders a top-keys map for rendering: biggest first, ties broken // by key so two runs of the same data render the same bytes, and the // overflow row (analytics.OtherKey — "everything past the cap", not a page // anybody visited) pinned to the bottom whatever its count is. It is a // template func because a Go template ranges a map in KEY order, which for a // top-pages table is the one order that hides the answer. func sortedKV(m map[string]int64) []kv { out := make([]kv, 0, len(m)) for k, v := range m { out = append(out, kv{Key: k, Value: v}) } sort.Slice(out, func(i, j int) bool { if a, b := out[i].Key == analytics.OtherKey, out[j].Key == analytics.OtherKey; a != b { return b } if out[i].Value != out[j].Value { return out[i].Value > out[j].Value } return out[i].Key < out[j].Key }) return out } // handleAppAnalytics renders the Analytics tab: the same read the API // answers, with the window as a GET param so a view is a URL. func (s *Server) handleAppAnalytics(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.appPage(w, r, "analytics") if !ok { return } // A page never 400s over its own query string: an unknown range falls // back to today, the way the Errors tab falls back to its default window // rather than refusing a URL someone mistyped. p.AnalyticsRange = r.URL.Query().Get("range") if !validRange(p.AnalyticsRange) { p.AnalyticsRange = rangeToday } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() win, err := s.readAnalytics(ctx, acct.Sqid, p.AppName, p.AnalyticsRange, time.Now()) if err != nil { log.Printf("console: app analytics %s/%s: %v", acct.Sqid, p.AppName, err) p.AnalyticsErr = "The analytics could not be read just now — try again in a moment." // A failed read leaves no resolved setting, and the page must not // then say counting is off "from the " with nothing after it. The // defaults are what a nil store resolves to, so ask for them rather // than restating them here. win.Setting, _ = telemetry.Resolve(ctx, nil, acct.Sqid, p.AppName) } p.AnalyticsWin = win s.render(w, http.StatusOK, "app_analytics", p) } // geoCountries is the Countries table (geoip spec §9): the top 10 plus // other, with the two reserved buckets given words. nil when nothing was // counted, so the template can say so. func geoCountries(m map[string]int64) map[string]int64 { if len(m) == 0 { return nil } // The reserved keys stay as they are so sortedKV can put _other last; // geoLabel gives them words at render time (code review R1-6). return topWithOther(m, 10) } // geoLabel is a Countries row's label: the reserved buckets get words. func geoLabel(k string) string { switch k { case "_unknown": return "unknown" case "_other": return "other" } return k } // geoAttributions is one credit line per source in the range that requires // one (geoip spec §13), in a stable order, without repeats: two GeoLite2 // types share one line. func geoAttributions(sources map[string]int64) []geoip.Attribution { var keys []string for k := range sources { keys = append(keys, k) } sort.Strings(keys) seen := map[string]bool{} var out []geoip.Attribution for _, k := range keys { if a, ok := geoip.AttributionFor(k); ok && !seen[a.Text] { seen[a.Text] = true out = append(out, a) } } return out }