| 1 | package console |
| 2 | |
| 3 | // The analytics switch, at both ends: the CLI's telemetry PUT (which now |
| 4 | // accepts the analytics half and bumps the serial, because a box acts on |
| 5 | // it) and the two settings pages' owner-only forms. |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "encoding/json" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/analytics" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | "github.com/carlosframework/platform/internal/svcrole" |
| 20 | "github.com/carlosframework/platform/internal/telemetry" |
| 21 | ) |
| 22 | |
| 23 | func TestAPITelemetryPutSetsAnalytics(t *testing.T) { |
| 24 | fx, _ := instanceFixture(t, "code-analytics-set") |
| 25 | op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) |
| 26 | put := func(body string) (int, string) { |
| 27 | st, _, b := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, body) |
| 28 | return st, b |
| 29 | } |
| 30 | if st, b := put(`{"analytics":{"count":"edge"}}`); st != 200 { |
| 31 | t.Fatalf("count=edge: %d %s", st, b) |
| 32 | } |
| 33 | r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo") |
| 34 | if r.Count != "edge" || r.CountFrom != "app" || r.Door != "on" { |
| 35 | t.Fatalf("resolved = %+v", r) |
| 36 | } |
| 37 | if st, _ := put(`{"analytics":{"count":"browser"}}`); st != 400 { |
| 38 | t.Fatal("reserved mode must be refused for now") |
| 39 | } |
| 40 | if st, _ := put(`{"analytics":{"door":"sideways"}}`); st != 400 { |
| 41 | t.Fatal("bad door") |
| 42 | } |
| 43 | if st, _ := put(`{"analytics":{"door":"off"},"errors":{"log_level":"warn"}}`); st != 200 { |
| 44 | t.Fatal("both halves in one PUT") |
| 45 | } |
| 46 | r, _ = telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo") |
| 47 | if r.Door != "off" || r.LogLevel != "warn" || r.Count != "edge" { |
| 48 | t.Fatalf("after both: %+v", r) |
| 49 | } |
| 50 | // The serial moved: boxes converge on it. |
| 51 | if doc, _, err := release.ReadSerial(context.Background(), fx.rel); err != nil || doc.Serial == 0 { |
| 52 | t.Fatalf("serial not bumped: %+v %v", doc, err) |
| 53 | } |
| 54 | |
| 55 | // An errors-only PUT touches no box-acted-on setting, so it must not |
| 56 | // bump the serial: boxes would converge for no reason. |
| 57 | before, _, err := release.ReadSerial(context.Background(), fx.rel) |
| 58 | if err != nil { |
| 59 | t.Fatalf("serial before: %v", err) |
| 60 | } |
| 61 | if st, b := put(`{"errors":{"log_level":"error"}}`); st != 200 { |
| 62 | t.Fatalf("errors-only: %d %s", st, b) |
| 63 | } |
| 64 | after, _, err := release.ReadSerial(context.Background(), fx.rel) |
| 65 | if err != nil { |
| 66 | t.Fatalf("serial after: %v", err) |
| 67 | } |
| 68 | if after.Serial != before.Serial { |
| 69 | t.Fatalf("errors-only PUT bumped the serial: before %+v, after %+v", before, after) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestSettingsPagesShowAnalyticsSwitch(t *testing.T) { |
| 74 | fx, _ := instanceFixture(t, "code-analytics-page") |
| 75 | fx.setUser("paul@keymail.dev") |
| 76 | c := newBrowser(t) |
| 77 | signUp(t, fx, c, "code-analytics-signin") |
| 78 | status, _, body := get(t, c, fx.u("/apps/titogo/settings")) |
| 79 | if status != 200 || !strings.Contains(body, "analytics/setting") || !strings.Contains(body, "currently off, from the default") { |
| 80 | t.Fatalf("app settings: %d", status) |
| 81 | } |
| 82 | // Post the account-level switch on; the app page shows it inherited. |
| 83 | status, _, _ = postForm(t, c, fx.u("/analytics/setting"), url.Values{"csrf": {sessionToken(t, fx, c)}, "count": {"edge"}, "door": {"on"}}) |
| 84 | if status != 303 { |
| 85 | t.Fatalf("account switch: %d", status) |
| 86 | } |
| 87 | _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| 88 | if !strings.Contains(body, "currently edge, from the account") { |
| 89 | t.Fatal("inheritance not shown") |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // seedAnalytics writes one box's day file for the fixture's app: five page |
| 94 | // views of /pricing from five addresses with a referer, one bot hit, and one |
| 95 | // named event. |
| 96 | // |
| 97 | // The observations are stamped at MIDDAY UTC of the current day rather than |
| 98 | // an hour before the run: an hour before a run that starts just after 00:00 |
| 99 | // UTC lands on YESTERDAY's day file, and range=today would then read an empty |
| 100 | // window one hour in twenty-four. ReadRange is day-granular — `to` bounds |
| 101 | // which DATES are read, never which hours inside them — so a stamp anywhere |
| 102 | // in the current UTC day is counted whatever the wall clock says. |
| 103 | func seedAnalytics(t *testing.T, fx *consoleFixture, when time.Time) { |
| 104 | t.Helper() |
| 105 | m := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return when }) |
| 106 | for i := 0; i < 5; i++ { |
| 107 | m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, |
| 108 | ContentType: "text/html", Path: "/pricing", RefererHost: "news.ycombinator.com", UA: "Mozilla/5.0 Safari", |
| 109 | ClientIP: "203.0.113." + strconv.Itoa(i), Bytes: 1000, When: when}) |
| 110 | } |
| 111 | m.Observe(analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, |
| 112 | ContentType: "text/html", Path: "/", UA: "Googlebot/2.1", ClientIP: "1.1.1.1", Bytes: 500, When: when}) |
| 113 | m.Track(release.OpsAccount, "titogo", "signup", when) |
| 114 | for k, ds := range m.Drain() { |
| 115 | for _, d := range ds { |
| 116 | if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // seedTime is the stamp seedAnalytics uses — midday of the current UTC day. |
| 124 | func seedTime() time.Time { return time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) } |
| 125 | |
| 126 | func TestAPIAnalyticsSummary(t *testing.T) { |
| 127 | fx, token := instanceFixture(t, "code-analytics-api") |
| 128 | seedAnalytics(t, fx, seedTime()) |
| 129 | status, _, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=today", token, "") |
| 130 | if status != 200 { |
| 131 | t.Fatalf("%d %s", status, body) |
| 132 | } |
| 133 | var resp analyticsResponse |
| 134 | if err := json.Unmarshal([]byte(body), &resp); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if resp.PV != 5 || resp.Bots != 1 || resp.Bytes != 5500 || resp.Visitors < 4 || resp.Visitors > 6 || |
| 138 | resp.Pages["/pricing"] != 5 || resp.Referers["news.ycombinator.com"] != 5 || resp.Events["signup"] != 1 || resp.On { |
| 139 | t.Fatalf("resp = %+v", resp) |
| 140 | } |
| 141 | if st, _, _ := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=fortnight", token, ""); st != 400 { |
| 142 | t.Fatal("bad range") |
| 143 | } |
| 144 | if st, _, b := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/analytics?range=7d", token, ""); st != 200 { |
| 145 | t.Fatalf("7d: %d %s", st, b) |
| 146 | } |
| 147 | // Verify snake_case JSON tags |
| 148 | if !strings.Contains(body, "\"days\":[{\"date\":") { |
| 149 | t.Fatal("missing snake_case days array with date field") |
| 150 | } |
| 151 | if !strings.Contains(body, "\"pv\":") { |
| 152 | t.Fatal("missing snake_case pv field") |
| 153 | } |
| 154 | if strings.Contains(body, "\"PV\":") { |
| 155 | t.Fatal("found PascalCase PV field (should be pv)") |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestAppAnalyticsPage(t *testing.T) { |
| 160 | fx, _ := instanceFixture(t, "code-analytics-tab") |
| 161 | seedAnalytics(t, fx, seedTime()) |
| 162 | fx.setUser("paul@keymail.dev") |
| 163 | c := newBrowser(t) |
| 164 | signUp(t, fx, c, "code-analytics-tab-signin") |
| 165 | status, _, body := get(t, c, fx.u("/apps/titogo/analytics")) |
| 166 | if status != 200 { |
| 167 | t.Fatalf("%d %s", status, body) |
| 168 | } |
| 169 | // Off: the switch and the reading, and the numbers that exist already. |
| 170 | for _, want := range []string{"Analytics is off for this app", "no JavaScript, no cookie", "/pricing", "news.ycombinator.com", "signup", "<svg"} { |
| 171 | if !strings.Contains(body, want) { |
| 172 | t.Errorf("page missing %q", want) |
| 173 | } |
| 174 | } |
| 175 | if err := telemetry.Update(context.Background(), fx.rel, telemetry.AppKey(fx.sqid, "titogo"), func(st *telemetry.Settings) { |
| 176 | st.Analytics = &telemetry.Analytics{Count: telemetry.CountEdge} |
| 177 | }); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | _, _, body = get(t, c, fx.u("/apps/titogo/analytics?range=7d")) |
| 181 | if strings.Contains(body, "Analytics is off") || !strings.Contains(body, "daily visitors, summed") || !strings.Contains(body, "carlos analytics --app titogo") { |
| 182 | t.Fatal("on + 7d page") |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestAppAnalyticsPageCountriesAndAttribution(t *testing.T) { |
| 187 | fx, _ := instanceFixture(t, "code-analytics-geo") |
| 188 | seedAnalytics(t, fx, seedTime()) // no geo state: an edge from before GeoIP |
| 189 | fx.setUser("paul@keymail.dev") |
| 190 | c := newBrowser(t) |
| 191 | signUp(t, fx, c, "code-analytics-geo-signin") |
| 192 | _, _, body := get(t, c, fx.u("/apps/titogo/analytics")) |
| 193 | if !strings.Contains(body, "No country data for this period.") { |
| 194 | t.Fatal("a range with no country data must say so") |
| 195 | } |
| 196 | when := seedTime() |
| 197 | m := analytics.NewMeter(t.TempDir(), "box2", func() time.Time { return when }) |
| 198 | obs := func(country, src, state string) analytics.Obs { |
| 199 | return analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, |
| 200 | ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.9", When: when, |
| 201 | Country: country, GeoSource: src, GeoState: state} |
| 202 | } |
| 203 | m.Observe(obs("GB", "DBIP-City-Lite", "hit")) |
| 204 | m.Observe(obs("GB", "DBIP-City-Lite", "hit")) |
| 205 | m.Observe(obs("", "GeoLite2-City", "miss")) |
| 206 | for k, ds := range m.Drain() { |
| 207 | for _, d := range ds { |
| 208 | if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | } |
| 212 | } |
| 213 | _, _, body = get(t, c, fx.u("/apps/titogo/analytics")) |
| 214 | for _, want := range []string{"<h3>Countries</h3>", "<td>GB</td><td>2</td>", "<td>unknown</td><td>1</td>", |
| 215 | `href="https://db-ip.com">IP Geolocation by DB-IP</a>`, "GeoLite2 data created by MaxMind"} { |
| 216 | if !strings.Contains(body, want) { |
| 217 | t.Errorf("page lacks %q", want) |
| 218 | } |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func TestGeoAttributionsDeduplicates(t *testing.T) { |
| 223 | got := geoAttributions(map[string]int64{"GeoLite2-City": 1, "GeoLite2-Country": 1, "Custom": 1, "DBIP-City-Lite": 1}) |
| 224 | if len(got) != 2 { |
| 225 | t.Fatalf("attributions = %+v, want one DB-IP and one MaxMind line", got) |
| 226 | } |
| 227 | } |
| 228 | |