console: Analytics tab and API (spec §3.6)
The tab and `carlos analytics` are one read. readAnalytics (analyticsview.go) resolves the switch, reads the range across boxes and days, and computes the chart's geometry in Go, because a template cannot divide and the scaling is the part worth testing. The page renders that window; apianalytics.go serialises it. This is the shape readErrors already gave the Errors pair. The window is a GET param, so a view is a URL. An unknown range at the page falls back to today -- a page never 400s over its own query string -- while the API says `range: want today, 7d or 30d` out loud: a script asking for a fortnight of counts must not be handed today's and told nothing. Counting being off does not hide numbers already collected. They were collected while it was on, and blanking them would read as data loss; the tab shows them under a paragraph that says counting is off and where that setting came from, with a link to the switch. The API door is Instance|Operate, the errors read's footing exactly: an app token that may read every line of its own logs learns nothing new from a tally of its own page views. It is exempt from the legacy pin for the same reason the errors door is -- it did not exist before service principals, so listing it as legacy reach would make that map lie. The telemetry PUT that decides whether the counts exist at all stays Operate-only. sortedKV joins the funcMap because a Go template ranges a map in key order, which for a top-pages table is the one order that hides the answer: biggest first, ties broken by key so two renders are the same bytes, and the overflow row pinned last wherever its count lands. The seed in the test stamps midday UTC rather than an hour ago: an hour before a run that starts just after 00:00 lands on yesterday's day file, and range=today would read an empty window one hour in twenty-four.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+497
−3
internal/console/analyticsview.go+233 −0internal/console/apianalytics.go+94 −0internal/console/apianalytics_test.go+87 −0internal/console/authcli.go+6 −0internal/console/server.go+20 −3internal/console/svcroutes_test.go+11 −0internal/console/templates/app_analytics.html+45 −0internal/console/templates/appnav.html+1 −0
diff --git a/internal/console/analyticsview.go b/internal/console/analyticsview.go| index 9c05b9054..f9892dc57 100644 |
| --- a/internal/console/analyticsview.go |
| +++ b/internal/console/analyticsview.go |
| @@ -8,11 +8,17 @@ package console |
| // 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/telemetry" |
| ) |
| @@ -174,3 +180,230 @@ func analyticsAudit(count, door string) string { |
| 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) |
| +} |
diff --git a/internal/console/apianalytics.go b/internal/console/apianalytics.go| new file mode 100644 |
| index 000000000..93a0fd399 |
| --- /dev/null |
| +++ b/internal/console/apianalytics.go |
| @@ -0,0 +1,94 @@ |
| +// 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" |
| + "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"` |
| + 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, |
| + } |
| + // "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{} |
| + } |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
diff --git a/internal/console/apianalytics_test.go b/internal/console/apianalytics_test.go| index 2da623684..a47199f9a 100644 |
| --- a/internal/console/apianalytics_test.go |
| +++ b/internal/console/apianalytics_test.go |
| @@ -6,11 +6,15 @@ package console |
| import ( |
| "context" |
| + "encoding/json" |
| "net/http" |
| "net/url" |
| + "strconv" |
| "strings" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/svcrole" |
| "github.com/carlosframework/platform/internal/telemetry" |
| @@ -68,3 +72,86 @@ func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) { |
| t.Fatal("inheritance not shown") |
| } |
| } |
| + |
| +// seedAnalytics writes one box's day file for the fixture's app: five page |
| +// views of /pricing from five addresses with a referer, one bot hit, and one |
| +// named event. |
| +// |
| +// The observations are stamped at MIDDAY UTC of the current day rather than |
| +// an hour before the run: an hour before a run that starts just after 00:00 |
| +// UTC lands on YESTERDAY's day file, and range=today would then read an empty |
| +// window one hour in twenty-four. ReadRange is day-granular — `to` bounds |
| +// which DATES are read, never which hours inside them — so a stamp anywhere |
| +// in the current UTC day is counted whatever the wall clock says. |
| +func seedAnalytics(t *testing.T, fx *consoleFixture, when time.Time) { |
| + t.Helper() |
| + m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return when }) |
| + for i := 0; i < 5; i++ { |
| + m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, |
| + ContentType: "text/html", Path: "/pricing", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", |
| + ClientIP: "203.0.113." + strconv.Itoa(i), Bytes: 1000, When: when}) |
| + } |
| + m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, |
| + ContentType: "text/html", Path: "/", UA: "Googlebot/2.1", ClientIP: "1.1.1.1", Bytes: 500, When: when}) |
| + m.Track(release.OpsAccount, "titogo", "signup", when) |
| + for k, ds := range m.Drain() { |
| + for _, d := range ds { |
| + if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// seedTime is the stamp seedAnalytics uses — midday of the current UTC day. |
| +func seedTime() time.Time { return time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) } |
| + |
| +func TestAPIAnalyticsSummary(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-analytics-api") |
| + seedAnalytics(t, fx, seedTime()) |
| + status, _, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=today", token, "") |
| + if status != 200 { |
| + t.Fatalf("%d %s", status, body) |
| + } |
| + var resp analyticsResponse |
| + if err := json.Unmarshal([]byte(body), &resp); err != nil { |
| + t.Fatal(err) |
| + } |
| + if resp.PV != 5 || resp.Bots != 1 || resp.Bytes != 5500 || resp.Visitors < 4 || resp.Visitors > 6 || |
| + resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On { |
| + t.Fatalf("resp = %+v", resp) |
| + } |
| + if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 { |
| + t.Fatal("bad range") |
| + } |
| + if st, _, b := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=7d", token, ""); st != 200 { |
| + t.Fatalf("7d: %d %s", st, b) |
| + } |
| +} |
| + |
| +func TestAppAnalyticsPage(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-analytics-tab") |
| + seedAnalytics(t, fx, seedTime()) |
| + fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-analytics-tab-signin") |
| + status, _, body := get(t, c, fx.u("/apps/titogo/analytics")) |
| + if status != 200 { |
| + t.Fatalf("%d %s", status, body) |
| + } |
| + // Off: the switch and the reading, and the numbers that exist already. |
| + for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "<svg"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("page missing %q", want) |
| + } |
| + } |
| + if err := telemetry.Update(context.Background(), fx.rel, telemetry.AppKey(fx.sqid, "titogo"), func(st *telemetry.Settings) { |
| + st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge} |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/titogo/analytics?range=7d")) |
| + if strings.Contains(body, "Analytics is off") || !strings.Contains(body, "daily visitors, summed") || !strings.Contains(body, "carlos analytics --app titogo") { |
| + t.Fatal("on + 7d page") |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 88e10b135..ef15687ea 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -598,6 +598,12 @@ func (s *Server) registerCLIRoutes() { |
| // and an app token may read its own app's errors for the same reason it |
| // may read its own logs. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/errors", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIErrors) |
| + // The analytics read (`carlos analytics`, apianalytics.go) — the third of |
| + // the same family, on the same footing: an app token that may read every |
| + // line of its own logs is not learning anything new from a count of its |
| + // own page views. The SETTING that decides whether those counts exist is |
| + // the telemetry PUT below, and that stays Operate-only. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/analytics", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAnalytics) |
| // The telemetry SETTINGS write, Operate only and deliberately narrower |
| // than the read beside it: this decides what counts as an error for the |
| // app's owner, and a credential the app itself holds must not be able to |
diff --git a/internal/console/server.go b/internal/console/server.go| index 0da617e31..f9a80409a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -123,6 +123,12 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| // (logsview.go). The padding is computed across the whole window, so it |
| // has to be one call over all the records, not one per row. |
| "logLines": logLines, |
| + // sortedKV: the Analytics tab's three top-key tables (analyticsview.go). |
| + // A Go template ranges a map in key order, which would sort a top-pages |
| + // table alphabetically — the one order that hides which page is the |
| + // popular one. This puts the biggest first and leaves the overflow |
| + // bucket last, wherever its count happens to land. |
| + "sortedKV": sortedKV, |
| // bytesOrDash renders a byte count in the smallest unit that keeps it |
| // readable; nil (no reading, from a unit that reported CPU but not |
| // memory/IO that tick) is "—", never a fabricated 0 B. |
| @@ -1055,9 +1061,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // (CSRF, then owner, then the byte-identical 404). |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/errors", s.handleAppErrors) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/errors/level", s.handleAppErrorLevel) |
| - // The analytics switch (analyticsview.go) rides the same gate at both |
| - // levels, and bumps the serial where the errors level does not: a box |
| - // acts on whether the edge counts. |
| + // The Analytics tab (analyticsview.go) and the switch beside it. The tab |
| + // is member-readable like every other app tab; the switch rides the same |
| + // gate the errors level does at both levels, and bumps the serial where |
| + // the errors level does not: a box acts on whether the edge counts. |
| + s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/analytics", s.handleAppAnalytics) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/analytics/setting", s.handleAppAnalyticsSetting) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings) |
| @@ -1417,6 +1425,15 @@ type page struct { |
| AnalyticsSetting telemetry.Resolved |
| AnalyticsUnread bool |
| + // App Analytics (analyticsview.go's tab, apianalytics.go's sibling): the |
| + // assembled window plus the range the form re-renders with. A zero |
| + // AnalyticsWin with AnalyticsErr "" is the normal nothing-counted-yet |
| + // state, never an error — and the window carries its own resolved |
| + // setting, because the tab has to say whether counting is on. |
| + AnalyticsWin analyticsWindow |
| + AnalyticsRange string // "today" | "7d" | "30d" |
| + AnalyticsErr string |
| + |
| // Action confirm interstitial (confirm.go): what is about to happen, |
| // carried fields and all. Nil everywhere except the confirm page. |
| Confirm *ConfirmView |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index b11f70e9a..921ac1b17 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -293,9 +293,20 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) { |
| // gains nothing by reading the error-level ones grouped. The telemetry PUT |
| // registered beside it is Operate-only and no instance credential reaches it |
| // — TestAPITelemetryPutSetsLogLevel pins that refusal. |
| +// GET .../analytics is the ninth (spec 2026-09-07 §3.6), and it stands on |
| +// the errors door's footing exactly. It did not exist before service |
| +// principals, so listing it in `want` would make the legacy map lie. The |
| +// instance role reaches it DELIBERATELY, for the reason the logs door in |
| +// `want` is reached: the counts are the app's own traffic, aggregated and |
| +// stripped of addresses, and a credential that may read every line of its |
| +// own app's logs is not learning anything new from a tally of its own page |
| +// views. The telemetry PUT that decides whether those counts are collected |
| +// at all is Operate-only and no instance credential reaches it — |
| +// TestAPITelemetryPutSetsAnalytics pins that half. |
| var exemptFromLegacyPin = map[string]bool{ |
| "GET /api/cli/apps": true, |
| "GET /api/cli/apps/{account}/{app}/errors": true, |
| + "GET /api/cli/apps/{account}/{app}/analytics": true, |
| "GET /api/cli/apps/{account}/{app}/domains/{host}": true, |
| "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true, |
| "GET /api/cli/apps/{account}/{app}/features": true, |
diff --git a/internal/console/templates/app_analytics.html b/internal/console/templates/app_analytics.html| new file mode 100644 |
| index 000000000..9ebee65d3 |
| --- /dev/null |
| +++ b/internal/console/templates/app_analytics.html |
| @@ -0,0 +1,45 @@ |
| +{{define "app_analytics"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{template "appnav" .}} |
| + |
| +<h2>Analytics</h2> |
| +{{if .AnalyticsErr}}<p class="banner error">{{.AnalyticsErr}}</p>{{end}} |
| +{{if not .AnalyticsWin.On}} |
| +<p><strong>Analytics is off for this app</strong> (from the {{.AnalyticsWin.Setting.CountFrom}}). |
| +When on, the edge counts HTML page views and daily visitors as it serves them: no JavaScript, no cookie, |
| +no stored IP. A visitor is a salted hash that changes every day. Turn it on for this app or for the whole |
| +account on the <a href="{{.Base}}/apps/{{.AppName}}/settings">Settings</a> tab.</p> |
| +{{end}} |
| +<form method="get" class="small"> |
| + <label>range |
| + <select name="range"> |
| + <option value="today"{{if eq .AnalyticsRange "today"}} selected{{end}}>today</option> |
| + <option value="7d"{{if eq .AnalyticsRange "7d"}} selected{{end}}>7 days</option> |
| + <option value="30d"{{if eq .AnalyticsRange "30d"}} selected{{end}}>30 days</option> |
| + </select> |
| + </label> |
| + <button type="submit">Update</button> |
| +</form> |
| +{{with .AnalyticsWin}} |
| +<div class="tablewrap"><table> |
| + <tr><th>Page views</th><th>Visitors{{if ne $.AnalyticsRange "today"}} (daily visitors, summed){{end}}</th><th>Bytes served</th><th>Bots excluded</th><th>Dropped</th></tr> |
| + <tr><td>{{.Sum.PV}}</td><td>{{.Sum.Visitors}}</td><td>{{.Sum.Bytes}}</td><td>{{.Sum.Bots}}</td><td>{{.Sum.Dropped.Flush}}</td></tr> |
| +</table></div> |
| +{{if .ChartBars}} |
| +<svg viewBox="0 0 480 140" width="100%" role="img" aria-label="Page views"> |
| + {{range .ChartBars}}<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}" fill="currentColor"><title>{{.Label}}: {{.Value}}</title></rect>{{end}} |
| + <text x="0" y="136" class="small">{{if eq $.AnalyticsRange "today"}}per hour, UTC{{else}}per day{{end}}; peak {{.ChartMax}}</text> |
| +</svg> |
| +{{end}} |
| +{{range .Sum.Days}}{{if .Restarted}}<p class="small">{{.Date}}: counts since the last flush may be missing (the edge restarted that day).</p>{{end}}{{end}} |
| +<h3>Top pages{{if .Sum.PagesApprox}} <span class="small">approximate: more than 500 distinct keys</span>{{end}}</h3> |
| +{{template "kvtable" .Sum.Pages}} |
| +<h3>Top referers{{if .Sum.ReferersApprox}} <span class="small">approximate</span>{{end}}</h3> |
| +{{template "kvtable" .Sum.Referers}} |
| +<h3>Named events{{if .Sum.EventsApprox}} <span class="small">approximate</span>{{end}}</h3> |
| +{{template "kvtable" .Sum.Events}} |
| +{{end}} |
| +<p class="small">Times are UTC. The same view from a terminal: <code>carlos analytics --app {{.AppName}} --range {{.AnalyticsRange}}</code>.</p> |
| +{{template "bottom" .}}{{end}} |
| +{{define "kvtable"}}{{if .}}<div class="tablewrap"><table>{{range sortedKV .}}<tr><td>{{.Key}}</td><td>{{.Value}}</td></tr>{{end}}</table></div>{{else}}<p class="small">Nothing yet.</p>{{end}}{{end}} |
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html| index c19afaaf7..6ab0ca34e 100644 |
| --- a/internal/console/templates/appnav.html |
| +++ b/internal/console/templates/appnav.html |
| @@ -9,6 +9,7 @@ |
| <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/logs"{{if eq .ActiveTab "logs"}} aria-current="page"{{end}}>Logs</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/errors"{{if eq .ActiveTab "errors"}} aria-current="page"{{end}}>Errors</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/analytics"{{if eq .ActiveTab "analytics"}} aria-current="page"{{end}}>Analytics</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a> |
| </nav> |