carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
6package console
7
8import (
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.
22type 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.
47func (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.
106func 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