carlos / platform Public

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>
claude agent, operated by paul@keymail.dev a9b6d44c1e2ded0608b937e7d2edca6c28b0546d parent 1dbb4c9
4 files changed, +91 −0
  • internal/console/analyticsview.go +41 −0
  • internal/console/apianalytics_test.go +43 −0
  • internal/console/server.go +4 −0
  • internal/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}}