carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// `carlos analytics` — an app's page views and visitors from a terminal,
2// counted at the edge as it serves your app. API mode only.
3package main
4
5import (
6 "context"
7 "flag"
8 "fmt"
9 "io"
10 "log"
11 "net/url"
12 "sort"
13)
14
15const analyticsUsage = `usage: carlos analytics --app <a> [flags]
16
17Page views, daily visitors, bytes served, top pages, top referers and named
18events, counted at the edge. Off until you turn it on.
19
20--range is today, 7d or 30d; multi-day visitor totals are daily visitors
21summed. The same view is the Analytics tab on the app's dashboard.
22`
23
24func runAnalytics(args []string) {
25 fs := flag.NewFlagSet("analytics", flag.ExitOnError)
26 app := appFlag(fs, "app whose analytics to read")
27 rangeFlag := fs.String("range", "today", "window: today | 7d | 30d")
28 setCounting := fs.String("set-count", "", "set analytics counting: edge | off")
29 setDoor := fs.String("set-door", "", "set door: on | off")
30 consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)")
31 acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
32 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
33 fs.Usage = flagUsage(fs, analyticsUsage)
34 fs.Parse(args)
35
36 sess := requireAPISession("analytics", *consoleFlag)
37 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
38 app2 := resolvedApp(sess, acct, *app, analyticsUsage)
39 ctx := context.Background()
40
41 if *setCounting != "" || *setDoor != "" {
42 body, err := analyticsSetBody(*setCounting, *setDoor)
43 if err != nil {
44 log.Fatalf("analytics: %v", err)
45 }
46 if err := sess.telemetryPut(ctx, acct, app2, map[string]any{"analytics": body}); err != nil {
47 log.Fatalf("analytics: %v", err)
48 }
49 // Build summary of what was set
50 var parts []string
51 if *setCounting != "" {
52 parts = append(parts, fmt.Sprintf("counting %s", *setCounting))
53 }
54 if *setDoor != "" {
55 parts = append(parts, fmt.Sprintf("door %s", *setDoor))
56 }
57 fmt.Fprintf(stdout, "Analytics for %s: %s.\n", app2, joinOr(parts, ""))
58 return
59 }
60
61 q := url.Values{"range": {*rangeFlag}}
62 res, err := sess.analyticsGet(ctx, acct, app2, q)
63 if err != nil {
64 log.Fatalf("analytics: %v", err)
65 }
66 printAnalytics(stdout, res)
67 if !res.On {
68 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)
69 }
70}
71
72// analyticsSetBody builds the analytics settings map from the given flag values,
73// validating them first. Returns error for invalid values.
74func analyticsSetBody(count, door string) (map[string]string, error) {
75 if count != "" && count != "edge" && count != "off" {
76 return nil, fmt.Errorf("--set-count wants edge or off")
77 }
78 if door != "" && door != "on" && door != "off" {
79 return nil, fmt.Errorf("--set-door wants on or off")
80 }
81 body := make(map[string]string)
82 if count != "" {
83 body["count"] = count
84 }
85 if door != "" {
86 body["door"] = door
87 }
88 return body, nil
89}
90
91// printAnalytics renders the analytics summary: a headline line with totals,
92// a text sparkline of days, then three tables sorted by value descending.
93func printAnalytics(w io.Writer, res apiAnalyticsResponse) {
94 c := colorFor(w)
95
96 // Headline line
97 fmt.Fprintf(w, "%s · %s · %s · %s · %s\n",
98 c.head(fmt.Sprintf("page views %d", res.PV)),
99 c.head(fmt.Sprintf("visitors %d (daily visitors, summed)", res.Visitors)),
100 c.muted(fmt.Sprintf("bytes %d", res.Bytes)),
101 c.muted(fmt.Sprintf("bots %d", res.Bots)),
102 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)))
103
104 // Sparkline: for today (Days[0]), show hours; otherwise one glyph per day
105 sparkline := ""
106 if len(res.Days) > 0 {
107 if res.Range == "today" {
108 // Show hours of today
109 maxHour := int64(0)
110 for _, h := range res.Days[0].Hours {
111 if h > maxHour {
112 maxHour = h
113 }
114 }
115 glyphs := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
116 for _, h := range res.Days[0].Hours {
117 if maxHour == 0 {
118 sparkline += "▁"
119 } else {
120 level := int((h * int64(len(glyphs)-1)) / maxHour)
121 sparkline += string(glyphs[level])
122 }
123 }
124 } else {
125 // One glyph per day
126 maxPV := int64(0)
127 for _, day := range res.Days {
128 if day.PV > maxPV {
129 maxPV = day.PV
130 }
131 }
132 glyphs := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
133 for _, day := range res.Days {
134 if maxPV == 0 {
135 sparkline += "▁"
136 } else {
137 level := int((day.PV * int64(len(glyphs)-1)) / maxPV)
138 sparkline += string(glyphs[level])
139 }
140 }
141 }
142 fmt.Fprintf(w, "%s\n\n", sparkline)
143 }
144
145 // Pages table
146 printAnalyticsTable(w, "Pages", res.Pages)
147
148 // Referers table
149 printAnalyticsTable(w, "Referers", res.Referers)
150
151 // Events table
152 printAnalyticsTable(w, "Events", res.Events)
153
154 // Countries table (geoip spec §9)
155 printAnalyticsTable(w, "Countries", res.Countries)
156}
157
158// printAnalyticsTable renders one analytics table (pages, referers, or events)
159// sorted by value descending, with _other last.
160func printAnalyticsTable(w io.Writer, title string, data map[string]int64) {
161 if len(data) == 0 {
162 return
163 }
164
165 c := colorFor(w)
166
167 // Separate _other and regular entries
168 var other int64
169 entries := make([]struct {
170 key string
171 value int64
172 }, 0, len(data))
173
174 for k, v := range data {
175 if k == "_other" {
176 other = v
177 } else {
178 entries = append(entries, struct {
179 key string
180 value int64
181 }{k, v})
182 }
183 }
184
185 // Sort by value descending
186 sort.Slice(entries, func(i, j int) bool {
187 return entries[i].value > entries[j].value
188 })
189
190 fmt.Fprintf(w, "%s\n", c.head(title))
191 for _, e := range entries {
192 fmt.Fprintf(w, " %-30s %6d\n", e.key, e.value)
193 }
194 if other > 0 {
195 fmt.Fprintf(w, " %-30s %6d\n", c.muted("_other"), other)
196 }
197 fmt.Fprintf(w, "\n")
198}
199