// The CLI's analytics API — the read side of `carlos analytics` (spec // 2026-09-07 §3.6). It shares the instance family's gate (instanceGate) // exactly as the errors and logs reads do: same principal proof, same // byte-identical 404, same rate limiter — and an app token may read its own // app's counts, which is the trust those two doors already extend it. package console import ( "context" "log" "net/http" "sort" "time" "github.com/carlosframework/platform/internal/analytics" ) // analyticsResponse is the wire shape `carlos analytics` renders and the tab // reads from the same assembly. The setting travels with the numbers because // "0 page views" and "counting is off" are different answers and a terminal // has no tab beside it to say which one it is looking at. type analyticsResponse struct { Range string `json:"range"` From string `json:"from"` To string `json:"to"` On bool `json:"on"` Count string `json:"count"` CountFrom string `json:"count_from"` PV int64 `json:"pv"` Visitors int64 `json:"visitors"` Bytes int64 `json:"bytes"` Bots int64 `json:"bots"` Days []analytics.DaySummary `json:"days"` Pages map[string]int64 `json:"pages"` Referers map[string]int64 `json:"referers"` Events map[string]int64 `json:"events"` Countries map[string]int64 `json:"countries"` GeoSources map[string]int64 `json:"geo_sources"` PagesApprox bool `json:"pages_approx"` ReferersApprox bool `json:"referers_approx"` EventsApprox bool `json:"events_approx"` Dropped analytics.Dropped `json:"dropped"` } // handleAPIAnalytics answers `carlos analytics`: the instance family's gate, // one validated range, one read. func (s *Server) handleAPIAnalytics(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.instanceGate(w, r) if !ok { return } // An absent range is the tab's default; a NAMED range that is not one of // the three is a mistake the API says out loud, where the page quietly // falls back — a script asking for a fortnight of counts must not be // handed today's and told nothing. rng := r.URL.Query().Get("range") if rng == "" { rng = rangeToday } if !validRange(rng) { jsonError(w, http.StatusBadRequest, "range: want today, 7d or 30d") return } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() win, err := s.readAnalytics(ctx, acct.Sqid, app, rng, time.Now()) if err != nil { log.Printf("console: api analytics %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the analytics") return } sum := win.Sum resp := analyticsResponse{ Range: rng, From: sum.From, To: sum.To, On: win.On, Count: win.Setting.Count, CountFrom: win.Setting.CountFrom, PV: sum.PV, Visitors: sum.Visitors, Bytes: sum.Bytes, Bots: sum.Bots, Days: sum.Days, Pages: sum.Pages, Referers: sum.Referers, Events: sum.Events, PagesApprox: sum.PagesApprox, ReferersApprox: sum.ReferersApprox, EventsApprox: sum.EventsApprox, Dropped: sum.Dropped, Countries: topWithOther(sum.Countries, 10), GeoSources: sum.GeoSources, } // "Nothing counted" is an empty object, not null — the same shape // errorsResponse gives its groups, so a caller may index the answer // without first testing it for null. if resp.Days == nil { resp.Days = []analytics.DaySummary{} } if resp.Pages == nil { resp.Pages = map[string]int64{} } if resp.Referers == nil { resp.Referers = map[string]int64{} } if resp.Events == nil { resp.Events = map[string]int64{} } if resp.GeoSources == nil { resp.GeoSources = map[string]int64{} } writeJSON(w, http.StatusOK, resp) } // topWithOther keeps the n largest keys (ties by key) and sums the rest, // plus any existing _other, into "_other" (geoip spec §9: top 10 plus // other). Never nil. func topWithOther(m map[string]int64, n int) map[string]int64 { type kv struct { k string v int64 } var all []kv for k, v := range m { if k != "_other" { all = append(all, kv{k, v}) } } sort.Slice(all, func(i, j int) bool { if all[i].v != all[j].v { return all[i].v > all[j].v } return all[i].k < all[j].k }) out := map[string]int64{} other := m["_other"] for i, e := range all { if i < n { out[e.k] = e.v } else { other += e.v } } if other > 0 { out["_other"] = other } return out }