| 1 | package 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 | |
| 10 | import ( |
| 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. |
| 30 | func validCount(s string) bool { return s == telemetry.CountEdge || s == telemetry.CountOff } |
| 31 | func 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). |
| 37 | func 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. |
| 53 | func 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". |
| 69 | func 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. |
| 85 | func (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. |
| 131 | func (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. |
| 174 | func 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. |
| 194 | type 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. |
| 206 | type 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. |
| 215 | const ( |
| 216 | rangeToday = "today" |
| 217 | range7d = "7d" |
| 218 | range30d = "30d" |
| 219 | ) |
| 220 | |
| 221 | func 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. |
| 227 | func 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. |
| 243 | const ( |
| 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. |
| 252 | func (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. |
| 280 | func 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. |
| 303 | func 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. |
| 321 | func 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 | |
| 351 | func round2(f float64) float64 { return math.Round(f*100) / 100 } |
| 352 | |
| 353 | // kv is one row of a top-keys table. |
| 354 | type 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. |
| 365 | func 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. |
| 384 | func (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. |
| 415 | func 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. |
| 425 | func 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. |
| 438 | func 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 | |