console: Countries table with per-source attribution on the analytics page (geoip spec §9, §13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+91
−0
internal/console/analyticsview.go+41 −0internal/console/apianalytics_test.go+43 −0internal/console/server.go+4 −0internal/console/templates/app_analytics.html+3 −0
diff --git a/internal/console/analyticsview.go b/internal/console/analyticsview.go| index 8a596d72c..f4fcb6158 100644 |
| --- a/internal/console/analyticsview.go |
| +++ b/internal/console/analyticsview.go |
| @@ -19,6 +19,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/analytics" |
| + "github.com/carlosframework/platform/internal/geoip" |
| "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| @@ -407,3 +408,43 @@ func (s *Server) handleAppAnalytics(w http.ResponseWriter, r *http.Request) { |
| p.AnalyticsWin = win |
| s.render(w, http.StatusOK, "app_analytics", p) |
| } |
| + |
| +// geoCountries is the Countries table (geoip spec §9): the top 10 plus |
| +// other, with the two reserved buckets given words. nil when nothing was |
| +// counted, so the template can say so. |
| +func geoCountries(m map[string]int64) map[string]int64 { |
| + if len(m) == 0 { |
| + return nil |
| + } |
| + out := map[string]int64{} |
| + for k, v := range topWithOther(m, 10) { |
| + switch k { |
| + case "_unknown": |
| + k = "unknown" |
| + case "_other": |
| + k = "other" |
| + } |
| + out[k] += v |
| + } |
| + return out |
| +} |
| + |
| +// geoAttributions is one credit line per source in the range that requires |
| +// one (geoip spec §13), in a stable order, without repeats: two GeoLite2 |
| +// types share one line. |
| +func geoAttributions(sources map[string]int64) []geoip.Attribution { |
| + var keys []string |
| + for k := range sources { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + seen := map[string]bool{} |
| + var out []geoip.Attribution |
| + for _, k := range keys { |
| + if a, ok := geoip.AttributionFor(k); ok && !seen[a.Text] { |
| + seen[a.Text] = true |
| + out = append(out, a) |
| + } |
| + } |
| + return out |
| +} |
diff --git a/internal/console/apianalytics_test.go b/internal/console/apianalytics_test.go| index 4435324c7..4c4831835 100644 |
| --- a/internal/console/apianalytics_test.go |
| +++ b/internal/console/apianalytics_test.go |
| @@ -182,3 +182,46 @@ func TestAppAnalyticsPage(t *testing.T) { |
| t.Fatal("on + 7d page") |
| } |
| } |
| + |
| +func TestAppAnalyticsPageCountriesAndAttribution(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-analytics-geo") |
| + seedAnalytics(t, fx, seedTime()) // no geo state: an edge from before GeoIP |
| + fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-analytics-geo-signin") |
| + _, _, body := get(t, c, fx.u("/apps/titogo/analytics")) |
| + if !strings.Contains(body, "No country data for this period.") { |
| + t.Fatal("a range with no country data must say so") |
| + } |
| + when := seedTime() |
| + m := analytics.NewMeter(t.TempDir(), "box2", func() time.Time { return when }) |
| + obs := func(country, src, state string) analytics.Obs { |
| + return analytics.Obs{Acct: release.OpsAccount, App: "titogo", Host: "titogo.example.com", Method: "GET", Status: 200, |
| + ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "203.0.113.9", When: when, |
| + Country: country, GeoSource: src, GeoState: state} |
| + } |
| + m.Observe(obs("GB", "DBIP-City-Lite", "hit")) |
| + m.Observe(obs("GB", "DBIP-City-Lite", "hit")) |
| + m.Observe(obs("", "GeoLite2-City", "miss")) |
| + for k, ds := range m.Drain() { |
| + for _, d := range ds { |
| + if err := analytics.UpsertDay(context.Background(), fx.rel, k.Acct, k.App, d); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/titogo/analytics")) |
| + for _, want := range []string{"<h3>Countries</h3>", "<td>GB</td><td>2</td>", "<td>unknown</td><td>1</td>", |
| + `href="https://db-ip.com">IP Geolocation by DB-IP</a>`, "GeoLite2 data created by MaxMind"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("page lacks %q", want) |
| + } |
| + } |
| +} |
| + |
| +func TestGeoAttributionsDeduplicates(t *testing.T) { |
| + got := geoAttributions(map[string]int64{"GeoLite2-City": 1, "GeoLite2-Country": 1, "Custom": 1, "DBIP-City-Lite": 1}) |
| + if len(got) != 2 { |
| + t.Fatalf("attributions = %+v, want one DB-IP and one MaxMind line", got) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index f5251c092..9c2d267c0 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -140,6 +140,10 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| // popular one. This puts the biggest first and leaves the overflow |
| // bucket last, wherever its count happens to land. |
| "sortedKV": sortedKV, |
| + // geoCountries and geoAttributions: the Analytics tab's Countries table |
| + // and the credit its sources require (geoip spec §9, §13). |
| + "geoCountries": geoCountries, |
| + "geoAttributions": geoAttributions, |
| // bytesOrDash renders a byte count in the smallest unit that keeps it |
| // readable; nil (no reading, from a unit that reported CPU but not |
| // memory/IO that tick) is "—", never a fabricated 0 B. |
diff --git a/internal/console/templates/app_analytics.html b/internal/console/templates/app_analytics.html| index b5f15b06b..d1e9d3656 100644 |
| --- a/internal/console/templates/app_analytics.html |
| +++ b/internal/console/templates/app_analytics.html |
| @@ -40,6 +40,9 @@ account on the <a href="{{.Base}}/apps/{{.AppName}}/settings">Settings</a> tab.< |
| {{template "kvtable" .Sum.Referers}} |
| <h3>Named events{{if .Sum.EventsApprox}} <span class="small">approximate</span>{{end}}</h3> |
| {{template "kvtable" .Sum.Events}} |
| +<h3>Countries</h3> |
| +{{with geoCountries .Sum.Countries}}{{template "kvtable" .}}{{else}}<p class="small">No country data for this period.</p>{{end}} |
| +{{range geoAttributions .Sum.GeoSources}}<p class="small"><a href="{{.URL}}">{{.Text}}</a></p>{{end}} |
| {{end}} |
| <p class="small">Times are UTC. The same view from a terminal: <code>carlos analytics --app {{.AppName}} --range {{.AnalyticsRange}}</code>.</p> |
| {{template "bottom" .}}{{end}} |