carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 66a07598f84f2c647de9a2e393701fa6474e0238 parent d44e58a
5 files changed, +311 −0
  • analytics_cmd.go +178 −0
  • analytics_cmd_test.go +66 −0
  • apimode.go +48 −0
  • docs/site/cli.md +15 −0
  • main.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