Add city-level tracking to geo stats

- Update update-geo-stats to accept optional city parameter
- Update get-cached-geo to cache and return city along with country
- Update collect-geo-stats-for-mount and collect-geo-stats-from-web-listeners
  to track by country+city
- Revert migration to keep UNIQUE(date, country_code, city) constraint
This commit is contained in:
Glenn Thompson 2025-12-12 19:23:05 +03:00
parent 8a0b1b346c
commit 474e9c6176
2 changed files with 35 additions and 31 deletions

View File

@ -265,18 +265,19 @@
(error (e) (error (e)
(log:error "Session cleanup failed: ~a" e)))) (log:error "Session cleanup failed: ~a" e))))
(defun update-geo-stats (country-code listener-count) (defun update-geo-stats (country-code listener-count &optional city)
"Update geo stats for today" "Update geo stats for today, optionally including city"
(when country-code (when country-code
(handler-case (handler-case
(with-db (with-db
(postmodern:execute (let ((city-sql (if city (format nil "'~a'" city) "NULL")))
(format nil "INSERT INTO listener_geo_stats (date, country_code, listener_count, listen_minutes) (postmodern:execute
VALUES (CURRENT_DATE, '~a', ~a, 1) (format nil "INSERT INTO listener_geo_stats (date, country_code, city, listener_count, listen_minutes)
ON CONFLICT (date, country_code) VALUES (CURRENT_DATE, '~a', ~a, ~a, 1)
ON CONFLICT (date, country_code, city)
DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a, DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a,
listen_minutes = listener_geo_stats.listen_minutes + 1" listen_minutes = listener_geo_stats.listen_minutes + 1"
country-code listener-count listener-count))) country-code city-sql listener-count listener-count))))
(error (e) (error (e)
(log:error "Failed to update geo stats: ~a" e))))) (log:error "Failed to update geo stats: ~a" e)))))
@ -402,52 +403,55 @@
;;; Polling Service ;;; Polling Service
(defun get-cached-geo (ip) (defun get-cached-geo (ip)
"Get cached geo data for IP, or lookup and cache" "Get cached geo data for IP, or lookup and cache. Returns (country . city) or nil."
(let* ((ip-hash (hash-ip-address ip)) (let* ((ip-hash (hash-ip-address ip))
(cached (gethash ip-hash *geo-cache*))) (cached (gethash ip-hash *geo-cache*)))
(if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*)) (if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*))
(getf cached :country) (cons (getf cached :country) (getf cached :city))
;; Lookup and cache ;; Lookup and cache
(let ((geo (lookup-geoip ip))) (let ((geo (lookup-geoip ip)))
(when geo (when geo
(let ((country (getf geo :country-code))) (let ((country (getf geo :country-code))
(city (getf geo :city)))
(setf (gethash ip-hash *geo-cache*) (setf (gethash ip-hash *geo-cache*)
(list :country country :time (get-universal-time))) (list :country country :city city :time (get-universal-time)))
country)))))) (cons country city)))))))
(defun collect-geo-stats-for-mount (mount) (defun collect-geo-stats-for-mount (mount)
"Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)" "Collect geo stats for all listeners on a mount (from Icecast - may show proxy IPs)"
(let ((listclients-xml (fetch-icecast-listclients mount))) (let ((listclients-xml (fetch-icecast-listclients mount)))
(when listclients-xml (when listclients-xml
(let ((ips (extract-listener-ips listclients-xml)) (let ((ips (extract-listener-ips listclients-xml))
(country-counts (make-hash-table :test 'equal))) (location-counts (make-hash-table :test 'equal)))
;; Group by country ;; Group by country+city
(dolist (ip ips) (dolist (ip ips)
(let ((country (get-cached-geo ip))) (let ((geo (get-cached-geo ip))) ; Returns (country . city) or nil
(when country (when geo
(incf (gethash country country-counts 0))))) (incf (gethash geo location-counts 0)))))
;; Store each country's count ;; Store each country+city count
(maphash (lambda (country count) (maphash (lambda (key count)
(update-geo-stats country count)) (update-geo-stats (car key) count (cdr key)))
country-counts))))) location-counts)))))
(defun collect-geo-stats-from-web-listeners () (defun collect-geo-stats-from-web-listeners ()
"Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)" "Collect geo stats from web listeners (uses real IPs from X-Forwarded-For)"
(cleanup-stale-web-listeners) (cleanup-stale-web-listeners)
(let ((country-counts (make-hash-table :test 'equal))) (let ((location-counts (make-hash-table :test 'equal)))
;; Count listeners by country from cached geo data ;; Count listeners by country+city from cached geo data
(maphash (lambda (session-id data) (maphash (lambda (session-id data)
(declare (ignore session-id)) (declare (ignore session-id))
(let* ((ip-hash (getf data :ip-hash)) (let* ((ip-hash (getf data :ip-hash))
(cached-geo (gethash ip-hash *geo-cache*)) (cached-geo (gethash ip-hash *geo-cache*))
(country (when cached-geo (getf cached-geo :country)))) (country (when cached-geo (getf cached-geo :country)))
(when country (city (when cached-geo (getf cached-geo :city)))
(incf (gethash country country-counts 0))))) (key (when country (cons country city))))
(when key
(incf (gethash key location-counts 0)))))
*web-listeners*) *web-listeners*)
;; Store each country's count ;; Store each country+city count
(maphash (lambda (country count) (maphash (lambda (key count)
(update-geo-stats country count)) (update-geo-stats (car key) count (cdr key)))
country-counts))) location-counts)))
(defun poll-and-store-stats () (defun poll-and-store-stats ()
"Single poll iteration: fetch stats and store" "Single poll iteration: fetch stats and store"

View File

@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS listener_geo_stats (
city VARCHAR(100), city VARCHAR(100),
listener_count INTEGER DEFAULT 0, listener_count INTEGER DEFAULT 0,
listen_minutes INTEGER DEFAULT 0, listen_minutes INTEGER DEFAULT 0,
UNIQUE(date, country_code) UNIQUE(date, country_code, city)
); );
CREATE INDEX IF NOT EXISTS idx_geo_stats_date ON listener_geo_stats(date); CREATE INDEX IF NOT EXISTS idx_geo_stats_date ON listener_geo_stats(date);