internal: better device classification via UA parsing

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6c78f1eebe71ef4cf037ebbda2caaeb36a6a6964
This commit is contained in:
raf 2026-03-02 21:27:47 +03:00
commit 6977a501b1
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 128 additions and 43 deletions

View file

@ -137,7 +137,7 @@ func (h *IngestionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Device classification // Device classification
if h.cfg.Site.Collect.Device { if h.cfg.Site.Collect.Device {
device = h.classifyDevice(event.Width) device = h.classifyDevice(event.Width, userAgent)
} }
// Referrer classification // Referrer classification
@ -271,19 +271,43 @@ func (h *IngestionHandler) ipInCIDR(ip, cidr string) bool {
return network.Contains(testIP) return network.Contains(testIP)
} }
// Classifies screen width into device categories using configured breakpoints // Classifies device using both screen width and User-Agent parsing
// FIXME: we need a more robust mechanism for classifying devices. Breakpoints // Uses UA hints for better detection, falls back to width breakpoints
// are the only ones I can think of *right now* but I'm positive there are better func (h *IngestionHandler) classifyDevice(width int, userAgent string) string {
// mechanisns. We'll get to this later. // First try User-Agent based detection for better accuracy
func (h *IngestionHandler) classifyDevice(width int) string { ua := strings.ToLower(userAgent)
if width == 0 {
return "unknown" // Tablet detection via UA (must come before mobile: Android tablets lack "mobile" keyword)
} if strings.Contains(ua, "tablet") ||
if width < h.cfg.Limits.DeviceBreakpoints.Mobile { strings.Contains(ua, "ipad") ||
return "mobile" (strings.Contains(ua, "android") && !strings.Contains(ua, "mobile")) {
}
if width < h.cfg.Limits.DeviceBreakpoints.Tablet {
return "tablet" return "tablet"
} }
return "desktop"
// Mobile detection via UA
if strings.Contains(ua, "mobile") ||
strings.Contains(ua, "iphone") ||
strings.Contains(ua, "ipod") ||
strings.Contains(ua, "windows phone") ||
strings.Contains(ua, "blackberry") {
return "mobile"
}
// If UA doesn't provide clear signal, use width breakpoints
if width > 0 {
if width < h.cfg.Limits.DeviceBreakpoints.Mobile {
return "mobile"
}
if width < h.cfg.Limits.DeviceBreakpoints.Tablet {
return "tablet"
}
return "desktop"
}
// Default to desktop if UA suggests desktop browser
if userAgent != "" {
return "desktop"
}
return "unknown"
} }

View file

@ -2,7 +2,6 @@ package api
import ( import (
"bytes" "bytes"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -207,51 +206,113 @@ func TestIngestionHandler_InvalidJSON(t *testing.T) {
} }
} }
func TestIngestionHandler_DeviceClassification(t *testing.T) { func newTestHandler(cfg *config.Config) *IngestionHandler {
cfg := config.Config{
Site: config.SiteConfig{
Domains: []string{"example.com"},
Collect: config.CollectConfig{
Pageviews: true,
Device: true,
},
Path: config.PathConfig{},
},
Limits: config.LimitsConfig{
MaxPaths: 100,
MaxSources: 50,
},
}
pathNorm := normalize.NewPathNormalizer(cfg.Site.Path) pathNorm := normalize.NewPathNormalizer(cfg.Site.Path)
pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths) pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths)
refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources) refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources)
metricsAgg := aggregate.NewMetricsAggregator( metricsAgg := aggregate.NewMetricsAggregator(
pathRegistry, pathRegistry,
aggregate.NewCustomEventRegistry(100), aggregate.NewCustomEventRegistry(100),
&cfg, cfg,
) )
return NewIngestionHandler(cfg, pathNorm, pathRegistry, refRegistry, metricsAgg)
}
handler := NewIngestionHandler(&cfg, pathNorm, pathRegistry, refRegistry, metricsAgg) func TestClassifyDevice_UA(t *testing.T) {
cfg := &config.Config{
Limits: config.LimitsConfig{
DeviceBreakpoints: config.DeviceBreaks{
Mobile: 768,
Tablet: 1024,
},
},
}
h := newTestHandler(cfg)
tests := []struct { tests := []struct {
name string name string
width int width int
userAgent string
want string
}{ }{
{"mobile", 375}, // UA takes priority
{"tablet", 768}, {
{"desktop", 1920}, name: "iphone via UA",
width: 390,
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
want: "mobile",
},
{
name: "android phone via UA",
width: 0,
userAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) Mobile Safari/537.36",
want: "mobile",
},
{
name: "windows phone via UA",
width: 0,
userAgent: "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0)",
want: "mobile",
},
{
name: "ipad via UA",
width: 1024,
userAgent: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
want: "tablet",
},
{
name: "android tablet via UA (no mobile keyword)",
width: 0,
userAgent: "Mozilla/5.0 (Linux; Android 13; SM-T870) AppleWebKit/537.36",
want: "tablet",
},
// Falls back to width when UA is desktop
{
name: "desktop UA wide screen",
width: 1920,
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0",
want: "desktop",
},
{
name: "desktop UA narrow width",
width: 500,
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0",
want: "mobile",
},
// Width-only fallback
{
name: "no UA mobile width",
width: 375,
userAgent: "",
want: "mobile",
},
{
name: "no UA tablet width",
width: 800,
userAgent: "",
want: "tablet",
},
{
name: "no UA desktop width",
width: 1440,
userAgent: "",
want: "desktop",
},
// Unknown
{
name: "no UA no width",
width: 0,
userAgent: "",
want: "unknown",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
body := fmt.Sprintf(`{"d":"example.com","p":"/test","w":%d}`, tt.width) got := h.classifyDevice(tt.width, tt.userAgent)
req := httptest.NewRequest("POST", "/api/event", bytes.NewBufferString(body)) if got != tt.want {
w := httptest.NewRecorder() t.Errorf("classifyDevice(%d, %q) = %q, want %q",
handler.ServeHTTP(w, req) tt.width, tt.userAgent, got, tt.want)
if w.Code != http.StatusNoContent {
t.Errorf("expected status %d, got %d", http.StatusNoContent, w.Code)
} }
}) })
} }