Google Analytics gives you a lot. It also takes a lot: every visitor event goes to Google, your data feeds their ad network, and the moment you load the GA script, you've invited a third party into every conversation your product has with a visitor. For an enterprise product built on the promise of data ownership, that was not acceptable.
So I built my own. The result is a visitor analytics system with a real-time geographic world map, device and browser breakdowns, bot filtering with 30+ signals, and full MongoDB aggregation pipelines for historical queries. Everything runs in Go, everything stays in our own database, and nothing leaves our network.
The real reason was not privacy alone. It was that I wanted data that GA does not provide: exact city-level coordinates for a world map, raw referrer domains, and direct MongoDB queries I can join with other collections. GA forces you through their query interface. Self-hosted means the data is mine to query however I need.
An enterprise product that sells data ownership cannot use a third-party analytics script. The constraint was simple. The implementation was not.
The geographic map is the most visible feature. It works by resolving each visitor's IP address to a city and latitude/longitude pair using the MaxMind GeoLite2-City database.
var geoDB *geoip2.Reader
func initGeoIP() {
path := os.Getenv("GEOIP_DB_PATH")
if path == "" {
path = "/app/geoip/GeoLite2-City.mmdb"
}
db, err := geoip2.Open(path)
if err != nil {
log.Printf("⚠️ GeoIP database not loaded: %v — location tracking disabled", err)
return
}
geoDB = db
log.Printf("✅ GeoIP database loaded: %s", path)
}
The database is a binary .mmdb file loaded once at startup into a global *geoip2.Reader. Each lookup is a single function call with no network round-trip. When the database is missing, the system degrades gracefully: events are still stored, just without geo fields.
The lookup function returns everything needed for the map:
func geoLookup(ip string) (country, region, city string, lat, lon float64) {
if geoDB == nil { return }
parsed := net.ParseIP(ip)
record, err := geoDB.City(parsed)
if err != nil { return }
country = record.Country.Names["en"]
region = record.Subdivisions[0].Names["en"]
city = record.City.Names["en"]
lat = record.Location.Latitude
lon = record.Location.Longitude
return
}
The lat/lon fields are stored directly on each event. The frontend reads them from GET /api/visit/geo-points and plots SVG dots on a world map projection. No tile server, no external map API.
Every page visit is a single MongoDB document in the page_visit_events collection:
type visitEvent struct {
Page string `bson:"page"`
Timestamp time.Time `bson:"timestamp"`
IPHash string `bson:"ip_hash"` // SHA-256, never raw IP
SessionID string `bson:"session_id"`
Country string `bson:"country"`
Region string `bson:"region"`
City string `bson:"city"`
Lat float64 `bson:"lat,omitempty"`
Lon float64 `bson:"lon,omitempty"`
Device string `bson:"device"`
Browser string `bson:"browser"`
OS string `bson:"os"`
Referrer string `bson:"referrer"`
}
The most important field is IPHash. The raw IP address is never stored. Instead it is hashed with SHA-256 before being written to the database. This enables unique visitor counting (count distinct hashes) while making it cryptographically impossible to recover the original IP. The hash is computed once per request and discarded.
SHA-256 of an IP is not reversible. You can count distinct visitors. You cannot identify who those visitors are. That is the design goal.
The visitor endpoint accepts a page query parameter. Without validation, anyone can inject arbitrary page names and pollute the analytics database. The fix is a strict whitelist:
var validPages = map[string]bool{
"karyospace-info": true,
"karyospace-story": true,
"karyospace-docs": true,
"karyospace-help": true,
}
func visitHandler(w http.ResponseWriter, r *http.Request) {
page := strings.TrimSpace(r.URL.Query().Get("page"))
if !validPages[page] {
http.Error(w, "invalid page", http.StatusBadRequest)
return
}
// ...
}
Any page key not in the map returns 400. Adding a new tracked page requires a code change and deploy, which is intentional. Accidental tracking is harder to introduce than deliberate tracking.
Without bot filtering, most "traffic" to a public website is automated. Search crawlers, uptime monitors, CI health checks, social preview fetchers: they all generate events that would inflate visitor counts and distort geography data.
The filter checks the User-Agent string against a list of known bot substrings:
| Category | Examples |
|---|---|
| HTTP libraries | curl/, wget/, python-requests, go-http-client/, okhttp/ |
| Search crawlers | googlebot, bingbot, yandexbot, ahrefsbot, semrushbot |
| Social preview bots | facebookexternalhit, twitterbot, linkedinbot, slackbot, discordbot |
| Monitoring services | pingdom, uptimerobot, newrelic, datadog, statuscake |
| Headless / automation | headlesschrome, phantomjs, selenium, puppeteer, playwright |
| Other bots | bot/, crawler, spider, scraper, applebot, telegrambot |
Bots still receive a valid JSON response: {"count": 0}. Returning an error would cause some monitoring services to retry, generating more noise. Returning a zero count satisfies them silently.
KaryoSpace runs behind nginx as a reverse proxy. The Go application sees nginx's loopback address, not the visitor's real IP. The extraction function checks headers in priority order:
func realIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can be a comma-separated list; take the first
if i := strings.Index(xff, ","); i != -1 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
Third-party UA parsing libraries are large and add maintenance burden. For our purposes, we need three fields: device type, browser, and OS. Plain string matching is sufficient:
func parseUA(ua string) (device, browser, osName string) {
lower := strings.ToLower(ua)
// Device
if strings.ContainsAny(lower, "mobile android iphone ipad") {
device = "Mobile"
} else {
device = "Desktop"
}
// Browser (order matters: Chrome check must come before Safari)
switch {
case strings.Contains(lower, "firefox"): browser = "Firefox"
case strings.Contains(lower, "edg/"): browser = "Edge"
case strings.Contains(lower, "chrome"): browser = "Chrome"
case strings.Contains(lower, "safari"): browser = "Safari"
case strings.Contains(lower, "opera"): browser = "Opera"
default: browser = "Other"
}
// OS
switch {
case strings.Contains(lower, "windows"): osName = "Windows"
case strings.Contains(lower, "mac os"): osName = "macOS"
case strings.Contains(lower, "android"): osName = "Android"
case strings.Contains(lower, "iphone"): osName = "iOS"
case strings.Contains(lower, "ipad"): osName = "iOS"
case strings.Contains(lower, "linux"): osName = "Linux"
default: osName = "Other"
}
return
}
The Chrome/Safari ordering matters. Safari's UA string contains "Safari", but Chrome's UA string contains both "Chrome" and "Safari". Checking Chrome first prevents all Chrome visits from being misclassified as Safari.
Session IDs enable page-view deduplication within a visit. They are 16 random bytes from crypto/rand, hex-encoded and stored in a cookie:
func getOrCreateSession(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie("_ws_sid"); err == nil {
return c.Value
}
b := make([]byte, 16)
_, _ = rand.Read(b)
sid := hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: "_ws_sid",
Value: sid,
MaxAge: 86400 * 30,
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
})
return sid
}
The admin page at /admin/analytics queries MongoDB aggregation pipelines. Each query is scoped by date range, page, country, and device filters. The key functions:
Unique visitors (count distinct ip_hash), total page views, bounce rate, and daily trend in a single pipeline pass.
Generic $group by any field name (page, country, device, browser, OS). Returns []nameCount{Name, Count} sorted descending.
Groups by $dateToString on the timestamp field, returns []trendPoint{Date, Count} for the line chart.
Returns []geoPoint{Lat, Lon, City, Country} for the SVG world map. Frontend plots one dot per point.
Filter dropdowns (country list, device list) come from distinctValues() which runs db.collection.distinct(field, filter). The filters are applied as a $match stage prepended to every pipeline.
The first version stored the raw IP address. I had planned to hash it "later." Later never came before I realized having raw IPs in the database was a GDPR liability and switched to hashing immediately on the request path.
The first bot filter was a simple strings.Contains(ua, "bot"). It caught search crawlers but missed everything else. The comprehensive list of 30+ signals came from observing what was actually hitting the endpoint in production over two weeks.
The first GeoIP integration used an external HTTP API per request. It was 40-80ms per visit event. Switching to a local .mmdb database reduced it to under 1ms with no network dependency.
The result is a system that gives us everything Google Analytics offers for this use case, stores nothing externally, and runs as a few hundred lines of Go. The world map alone makes the investment worth it: watching real city dots appear across the globe is a better signal of product traction than any pageview number.