carlos analytics: the app's page views and visitors from a terminal, and the switch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+311
−0
analytics_cmd.go+178 −0analytics_cmd_test.go+66 −0apimode.go+48 −0docs/site/cli.md+15 −0main.go+4 −0
diff --git a/analytics_cmd.go b/analytics_cmd.go| new file mode 100644 |
| index 000000000..f7c1f2d5d |
| --- /dev/null |
| +++ b/analytics_cmd.go |
| @@ -0,0 +1,178 @@ |
| +// `carlos analytics` — an app's page views and visitors from a terminal, |
| +// counted at the edge as it serves your app. API mode only. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/url" |
| + "sort" |
| +) |
| + |
| +const analyticsUsage = `usage: carlos analytics --app <a> [flags] |
| + |
| +Page views, daily visitors, bytes served, top pages, top referers and named |
| +events, counted at the edge. Off until you turn it on. |
| + |
| +--range is today, 7d or 30d; multi-day visitor totals are daily visitors |
| +summed. The same view is the Analytics tab on the app's dashboard. |
| +` |
| + |
| +func runAnalytics(args []string) { |
| + fs := flag.NewFlagSet("analytics", flag.ExitOnError) |
| + app := appFlag(fs, "app whose analytics to read") |
| + rangeFlag := fs.String("range", "today", "window: today | 7d | 30d") |
| + setCounting := fs.String("set-count", "", "set analytics counting: edge | off") |
| + setDoor := fs.String("set-door", "", "set door: on | off") |
| + consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + fs.Usage = flagUsage(fs, analyticsUsage) |
| + fs.Parse(args) |
| + |
| + sess := requireAPISession("analytics", *consoleFlag) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, analyticsUsage) |
| + ctx := context.Background() |
| + |
| + if *setCounting != "" { |
| + if *setCounting != "edge" && *setCounting != "off" { |
| + log.Fatalf("analytics: --set-count wants edge or off") |
| + } |
| + if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"analytics": map[string]string{"count": *setCounting}}); err != nil { |
| + log.Fatalf("analytics: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "Analytics counting for %s is now %s.\n", app2, *setCounting) |
| + return |
| + } |
| + |
| + if *setDoor != "" { |
| + if *setDoor != "on" && *setDoor != "off" { |
| + log.Fatalf("analytics: --set-door wants on or off") |
| + } |
| + if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"analytics": map[string]string{"door": *setDoor}}); err != nil { |
| + log.Fatalf("analytics: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "Analytics door for %s is now %s.\n", app2, *setDoor) |
| + return |
| + } |
| + |
| + q := url.Values{"range": {*rangeFlag}} |
| + res, err := sess.analyticsGet(ctx, acct, app2, q) |
| + if err != nil { |
| + log.Fatalf("analytics: %v", err) |
| + } |
| + printAnalytics(stdout, res) |
| + if !res.On { |
| + mutedf(stdout, "analytics is off for this app — turn it on with `carlos analytics --app %s --set-count edge`", app2) |
| + } |
| +} |
| + |
| +// printAnalytics renders the analytics summary: a headline line with totals, |
| +// a text sparkline of days, then three tables sorted by value descending. |
| +func printAnalytics(w io.Writer, res apiAnalyticsResponse) { |
| + c := colorFor(w) |
| + |
| + // Headline line |
| + fmt.Fprintf(w, "%s · %s · %s · %s · %s\n", |
| + c.head(fmt.Sprintf("page views %d", res.PV)), |
| + c.head(fmt.Sprintf("visitors %d (daily visitors, summed)", res.Visitors)), |
| + c.muted(fmt.Sprintf("bytes %d", res.Bytes)), |
| + c.muted(fmt.Sprintf("bots %d", res.Bots)), |
| + c.muted(fmt.Sprintf("dropped %d", res.Dropped.E.Rate+res.Dropped.E.Size+res.Dropped.E.Origin+res.Dropped.E.Malformed+res.Dropped.A.Rate+res.Dropped.A.Size+res.Dropped.A.Origin+res.Dropped.A.Malformed+res.Dropped.Flush))) |
| + |
| + // Sparkline: for today (Days[0]), show hours; otherwise one glyph per day |
| + sparkline := "" |
| + if len(res.Days) > 0 { |
| + if res.Range == "today" { |
| + // Show hours of today |
| + maxHour := int64(0) |
| + for _, h := range res.Days[0].Hours { |
| + if h > maxHour { |
| + maxHour = h |
| + } |
| + } |
| + glyphs := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} |
| + for _, h := range res.Days[0].Hours { |
| + if maxHour == 0 { |
| + sparkline += "▁" |
| + } else { |
| + level := int((h * int64(len(glyphs)-1)) / maxHour) |
| + sparkline += string(glyphs[level]) |
| + } |
| + } |
| + } else { |
| + // One glyph per day |
| + maxPV := int64(0) |
| + for _, day := range res.Days { |
| + if day.PV > maxPV { |
| + maxPV = day.PV |
| + } |
| + } |
| + glyphs := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} |
| + for _, day := range res.Days { |
| + if maxPV == 0 { |
| + sparkline += "▁" |
| + } else { |
| + level := int((day.PV * int64(len(glyphs)-1)) / maxPV) |
| + sparkline += string(glyphs[level]) |
| + } |
| + } |
| + } |
| + fmt.Fprintf(w, "%s\n\n", sparkline) |
| + } |
| + |
| + // Pages table |
| + printAnalyticsTable(w, "Pages", res.Pages) |
| + |
| + // Referers table |
| + printAnalyticsTable(w, "Referers", res.Referers) |
| + |
| + // Events table |
| + printAnalyticsTable(w, "Events", res.Events) |
| +} |
| + |
| +// printAnalyticsTable renders one analytics table (pages, referers, or events) |
| +// sorted by value descending, with _other last. |
| +func printAnalyticsTable(w io.Writer, title string, data map[string]int64) { |
| + if len(data) == 0 { |
| + return |
| + } |
| + |
| + c := colorFor(w) |
| + |
| + // Separate _other and regular entries |
| + var other int64 |
| + entries := make([]struct { |
| + key string |
| + value int64 |
| + }, 0, len(data)) |
| + |
| + for k, v := range data { |
| + if k == "_other" { |
| + other = v |
| + } else { |
| + entries = append(entries, struct { |
| + key string |
| + value int64 |
| + }{k, v}) |
| + } |
| + } |
| + |
| + // Sort by value descending |
| + sort.Slice(entries, func(i, j int) bool { |
| + return entries[i].value > entries[j].value |
| + }) |
| + |
| + fmt.Fprintf(w, "%s\n", c.head(title)) |
| + for _, e := range entries { |
| + fmt.Fprintf(w, " %-30s %6d\n", e.key, e.value) |
| + } |
| + if other > 0 { |
| + fmt.Fprintf(w, " %-30s %6d\n", c.muted("_other"), other) |
| + } |
| + fmt.Fprintf(w, "\n") |
| +} |
diff --git a/analytics_cmd_test.go b/analytics_cmd_test.go| new file mode 100644 |
| index 000000000..ac7b9fff7 |
| --- /dev/null |
| +++ b/analytics_cmd_test.go |
| @@ -0,0 +1,66 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/analytics" |
| +) |
| + |
| +func TestPrintAnalytics(t *testing.T) { |
| + res := apiAnalyticsResponse{ |
| + Range: "7d", |
| + PV: 5, |
| + Visitors: 4, |
| + Bytes: 100, |
| + Bots: 0, |
| + Pages: map[string]int64{ |
| + "/pricing": 3, |
| + "/": 2, |
| + }, |
| + Referers: map[string]int64{ |
| + "news.ycombinator.com": 5, |
| + }, |
| + Events: map[string]int64{ |
| + "signup": 1, |
| + }, |
| + Days: []analytics.DaySummary{ |
| + { |
| + Date: "2026-09-07", |
| + PV: 3, |
| + Bytes: 50, |
| + Bots: 0, |
| + Visitors: 2, |
| + Hours: [24]int64{1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, |
| + }, |
| + { |
| + Date: "2026-09-06", |
| + PV: 2, |
| + Bytes: 50, |
| + Bots: 0, |
| + Visitors: 2, |
| + Hours: [24]int64{0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, |
| + }, |
| + }, |
| + On: true, |
| + } |
| + |
| + var buf bytes.Buffer |
| + printAnalytics(&buf, res) |
| + output := buf.String() |
| + |
| + checks := []string{ |
| + "5", // PV count |
| + "4", // Visitors count |
| + "daily visitors, summed", // text from headline |
| + "/pricing", // page |
| + "news.ycombinator.com", // referer |
| + "signup", // event |
| + } |
| + |
| + for _, check := range checks { |
| + if !bytes.Contains(buf.Bytes(), []byte(check)) { |
| + t.Errorf("output missing %q:\n%s", check, output) |
| + } |
| + } |
| +} |
diff --git a/apimode.go b/apimode.go| index 226121240..2608f204f 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -32,6 +32,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/analytics" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/apperr" |
| "github.com/carlosframework/platform/internal/applog" |
| @@ -2881,6 +2882,53 @@ func (s *apiSession) errorsGet(ctx context.Context, a apiAcct, app string, query |
| return res, nil |
| } |
| +// apiAnalyticsResponse is the console's analytics answer — days, pages, |
| +// referers, and events are the wire shapes decoded as-is. |
| +type apiAnalyticsResponse struct { |
| + Range string `json:"range"` |
| + From string `json:"from"` |
| + To string `json:"to"` |
| + On bool `json:"on"` |
| + Count string `json:"count"` |
| + PV int64 `json:"pv"` |
| + Visitors int64 `json:"visitors"` |
| + Bytes int64 `json:"bytes"` |
| + Bots int64 `json:"bots"` |
| + 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"` |
| + Days []analytics.DaySummary `json:"days"` |
| + Dropped analytics.Dropped `json:"dropped"` |
| +} |
| + |
| +// analyticsGet reads an app's analytics for the given range. query carries |
| +// the filter params exactly as the endpoint speaks them. |
| +func (s *apiSession) analyticsGet(ctx context.Context, a apiAcct, app string, query url.Values) (apiAnalyticsResponse, error) { |
| + path := apiPath(a, app, "/analytics") |
| + if enc := query.Encode(); enc != "" { |
| + if strings.Contains(path, "?") { |
| + path += "&" + enc |
| + } else { |
| + path += "?" + enc |
| + } |
| + } |
| + var res apiAnalyticsResponse |
| + status, body, err := s.getJSON(ctx, path) |
| + if err != nil { |
| + return res, err |
| + } |
| + if status != http.StatusOK { |
| + return res, apiErrorFromBody(status, body) |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return res, fmt.Errorf("analytics: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| // telemetryPut sets an app's telemetry settings (currently just the errors |
| // log level) — PUT /api/cli/apps/{account}/{app}/telemetry. |
| func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, body any) error { |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 6b7b3c38e..ba2b1cb8c 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -579,6 +579,21 @@ carlos errors --app hello --set-level warn |
| read without changing the setting; `--set-level` changes it for the app. |
| The same view is the Errors tab on the app's dashboard. |
| +### carlos analytics |
| + |
| +Page views, daily visitors, bytes served, top pages, top referers and named |
| +events, counted at the edge as it serves your app: no JavaScript, no cookie, |
| +no stored IP. Off until you turn it on, for one app or for the whole account. |
| + |
| +```sh |
| +carlos analytics --app hello --set-count edge |
| +carlos analytics --app hello --range 7d |
| +``` |
| + |
| +`--range` is `today`, `7d` or `30d`; multi-day visitor totals are daily |
| +visitors summed. The same view is the Analytics tab on the app's dashboard, |
| +and the switch is on the Settings tab too. |
| + |
| ### carlos store |
| Object storage for an app. `create` declares that the app wants a bucket, |
diff --git a/main.go b/main.go| index 1a6ff88b0..4f8de37b9 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -81,6 +81,8 @@ func main() { |
| runLogs(args) |
| case "errors": |
| runErrors(args) |
| + case "analytics": |
| + runAnalytics(args) |
| case "channels": |
| runChannels(args) |
| case "pipeline": |
| @@ -200,6 +202,8 @@ Your apps — ship and run software on a CARLOS deployment |
| events (wakes, restarts, failures); -f follows live |
| errors error groups for an app: log lines at the set level plus |
| 5xx the edge saw |
| + analytics an app's page views and visitors from a terminal, counted at |
| + the edge as it serves your app |
| ledger open ledgers: append | head | list | publish | blob | verify |
| vet check a release against the platform contract |
| update update this carlos binary to the latest release (or name |