| 1 | // The CLI's analytics API — the read side of `carlos analytics` (spec |
| 2 | // 2026-09-07 §3.6). It shares the instance family's gate (instanceGate) |
| 3 | // exactly as the errors and logs reads do: same principal proof, same |
| 4 | // byte-identical 404, same rate limiter — and an app token may read its own |
| 5 | // app's counts, which is the trust those two doors already extend it. |
| 6 | package console |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "log" |
| 11 | "net/http" |
| 12 | "sort" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/analytics" |
| 16 | ) |
| 17 | |
| 18 | // analyticsResponse is the wire shape `carlos analytics` renders and the tab |
| 19 | // reads from the same assembly. The setting travels with the numbers because |
| 20 | // "0 page views" and "counting is off" are different answers and a terminal |
| 21 | // has no tab beside it to say which one it is looking at. |
| 22 | type analyticsResponse struct { |
| 23 | Range string `json:"range"` |
| 24 | From string `json:"from"` |
| 25 | To string `json:"to"` |
| 26 | On bool `json:"on"` |
| 27 | Count string `json:"count"` |
| 28 | CountFrom string `json:"count_from"` |
| 29 | PV int64 `json:"pv"` |
| 30 | Visitors int64 `json:"visitors"` |
| 31 | Bytes int64 `json:"bytes"` |
| 32 | Bots int64 `json:"bots"` |
| 33 | Days []analytics.DaySummary `json:"days"` |
| 34 | Pages map[string]int64 `json:"pages"` |
| 35 | Referers map[string]int64 `json:"referers"` |
| 36 | Events map[string]int64 `json:"events"` |
| 37 | Countries map[string]int64 `json:"countries"` |
| 38 | GeoSources map[string]int64 `json:"geo_sources"` |
| 39 | PagesApprox bool `json:"pages_approx"` |
| 40 | ReferersApprox bool `json:"referers_approx"` |
| 41 | EventsApprox bool `json:"events_approx"` |
| 42 | Dropped analytics.Dropped `json:"dropped"` |
| 43 | } |
| 44 | |
| 45 | // handleAPIAnalytics answers `carlos analytics`: the instance family's gate, |
| 46 | // one validated range, one read. |
| 47 | func (s *Server) handleAPIAnalytics(w http.ResponseWriter, r *http.Request) { |
| 48 | _, acct, app, ok := s.instanceGate(w, r) |
| 49 | if !ok { |
| 50 | return |
| 51 | } |
| 52 | // An absent range is the tab's default; a NAMED range that is not one of |
| 53 | // the three is a mistake the API says out loud, where the page quietly |
| 54 | // falls back — a script asking for a fortnight of counts must not be |
| 55 | // handed today's and told nothing. |
| 56 | rng := r.URL.Query().Get("range") |
| 57 | if rng == "" { |
| 58 | rng = rangeToday |
| 59 | } |
| 60 | if !validRange(rng) { |
| 61 | jsonError(w, http.StatusBadRequest, "range: want today, 7d or 30d") |
| 62 | return |
| 63 | } |
| 64 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 65 | defer cancel() |
| 66 | win, err := s.readAnalytics(ctx, acct.Sqid, app, rng, time.Now()) |
| 67 | if err != nil { |
| 68 | log.Printf("console: api analytics %s/%s: %v", acct.Sqid, app, err) |
| 69 | jsonError(w, http.StatusBadGateway, "could not read the analytics") |
| 70 | return |
| 71 | } |
| 72 | sum := win.Sum |
| 73 | resp := analyticsResponse{ |
| 74 | Range: rng, From: sum.From, To: sum.To, |
| 75 | On: win.On, Count: win.Setting.Count, CountFrom: win.Setting.CountFrom, |
| 76 | PV: sum.PV, Visitors: sum.Visitors, Bytes: sum.Bytes, Bots: sum.Bots, |
| 77 | Days: sum.Days, Pages: sum.Pages, Referers: sum.Referers, Events: sum.Events, |
| 78 | PagesApprox: sum.PagesApprox, ReferersApprox: sum.ReferersApprox, EventsApprox: sum.EventsApprox, |
| 79 | Dropped: sum.Dropped, |
| 80 | Countries: topWithOther(sum.Countries, 10), GeoSources: sum.GeoSources, |
| 81 | } |
| 82 | // "Nothing counted" is an empty object, not null — the same shape |
| 83 | // errorsResponse gives its groups, so a caller may index the answer |
| 84 | // without first testing it for null. |
| 85 | if resp.Days == nil { |
| 86 | resp.Days = []analytics.DaySummary{} |
| 87 | } |
| 88 | if resp.Pages == nil { |
| 89 | resp.Pages = map[string]int64{} |
| 90 | } |
| 91 | if resp.Referers == nil { |
| 92 | resp.Referers = map[string]int64{} |
| 93 | } |
| 94 | if resp.Events == nil { |
| 95 | resp.Events = map[string]int64{} |
| 96 | } |
| 97 | if resp.GeoSources == nil { |
| 98 | resp.GeoSources = map[string]int64{} |
| 99 | } |
| 100 | writeJSON(w, http.StatusOK, resp) |
| 101 | } |
| 102 | |
| 103 | // topWithOther keeps the n largest keys (ties by key) and sums the rest, |
| 104 | // plus any existing _other, into "_other" (geoip spec §9: top 10 plus |
| 105 | // other). Never nil. |
| 106 | func topWithOther(m map[string]int64, n int) map[string]int64 { |
| 107 | type kv struct { |
| 108 | k string |
| 109 | v int64 |
| 110 | } |
| 111 | var all []kv |
| 112 | for k, v := range m { |
| 113 | if k != "_other" { |
| 114 | all = append(all, kv{k, v}) |
| 115 | } |
| 116 | } |
| 117 | sort.Slice(all, func(i, j int) bool { |
| 118 | if all[i].v != all[j].v { |
| 119 | return all[i].v > all[j].v |
| 120 | } |
| 121 | return all[i].k < all[j].k |
| 122 | }) |
| 123 | out := map[string]int64{} |
| 124 | other := m["_other"] |
| 125 | for i, e := range all { |
| 126 | if i < n { |
| 127 | out[e.k] = e.v |
| 128 | } else { |
| 129 | other += e.v |
| 130 | } |
| 131 | } |
| 132 | if other > 0 { |
| 133 | out["_other"] = other |
| 134 | } |
| 135 | return out |
| 136 | } |
| 137 | |