// `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 [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 := consoleOption(fs, "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 != "" || *setDoor != "" { body, err := analyticsSetBody(*setCounting, *setDoor) if err != nil { log.Fatalf("analytics: %v", err) } if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"analytics": body}); err != nil { log.Fatalf("analytics: %v", err) } // Build summary of what was set var parts []string if *setCounting != "" { parts = append(parts, fmt.Sprintf("counting %s", *setCounting)) } if *setDoor != "" { parts = append(parts, fmt.Sprintf("door %s", *setDoor)) } fmt.Fprintf(stdout, "Analytics for %s: %s.\n", app2, joinOr(parts, "")) 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 (from the %s) — turn it on with `carlos analytics --app %s --set-count edge`", res.CountFrom, app2) } } // analyticsSetBody builds the analytics settings map from the given flag values, // validating them first. Returns error for invalid values. func analyticsSetBody(count, door string) (map[string]string, error) { if count != "" && count != "edge" && count != "off" { return nil, fmt.Errorf("--set-count wants edge or off") } if door != "" && door != "on" && door != "off" { return nil, fmt.Errorf("--set-door wants on or off") } body := make(map[string]string) if count != "" { body["count"] = count } if door != "" { body["door"] = door } return body, nil } // 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) // Countries table (geoip spec §9) printAnalyticsTable(w, "Countries", res.Countries) } // 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") }